mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
792 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ec89458c | ||
|
|
d8849b2d81 | ||
|
|
a431bbea6c | ||
|
|
3cbad434d6 | ||
|
|
4d3047476d | ||
|
|
bf81cd51b7 | ||
|
|
d50276adca | ||
|
|
785be6aa9e | ||
|
|
934bee2d62 | ||
|
|
00eb9ae55b | ||
|
|
61968c8892 | ||
|
|
bd0dcbb7f2 | ||
|
|
645e1f2b18 | ||
|
|
02de0e7011 | ||
|
|
e491f7e385 | ||
|
|
62e5b2b0af | ||
|
|
be03b9c9cc | ||
|
|
3da6a70f77 | ||
|
|
a2dc928681 | ||
|
|
1f88201c18 | ||
|
|
85e93b69aa | ||
|
|
5cede24650 | ||
|
|
2348361d1d | ||
|
|
c134c3db57 | ||
|
|
18dbc521ee | ||
|
|
8600c09344 | ||
|
|
efb6b56c3b | ||
|
|
cc22524466 | ||
|
|
bca1ee2b2e | ||
|
|
4d18c84243 | ||
|
|
c1b171d188 | ||
|
|
fdb22491a2 | ||
|
|
ff2cb41a3c | ||
|
|
5a5cfb7edd | ||
|
|
63a820faf8 | ||
|
|
0bfa0a2e7b | ||
|
|
6445445e5d | ||
|
|
85d256b47b | ||
|
|
55d14d9e77 | ||
|
|
f41cb4b17e | ||
|
|
286d5df5b8 | ||
|
|
36659ad2cc | ||
|
|
ee7e88bc62 | ||
|
|
120409dc7b | ||
|
|
b2aa9c4179 | ||
|
|
0dc9e37ff4 | ||
|
|
5181176260 | ||
|
|
3b4f3e8161 | ||
|
|
2323427dbd | ||
|
|
43e6455668 | ||
|
|
7b3f36b0bb | ||
|
|
feafe4a07b | ||
|
|
ed1a4e489e | ||
|
|
4ab34456d1 | ||
|
|
54ed0c547f | ||
|
|
98291f0904 | ||
|
|
f0b3ad239c | ||
|
|
7d7e60c226 | ||
|
|
55ea43e103 | ||
|
|
631d65be21 | ||
|
|
76b9797c41 | ||
|
|
4fc4971345 | ||
|
|
f2bc0c1da1 | ||
|
|
f486de1597 | ||
|
|
b0e43ccee7 | ||
|
|
66db9cd23f | ||
|
|
c2552d2e34 | ||
|
|
56547b3526 | ||
|
|
70ac7dce95 | ||
|
|
f982781dd8 | ||
|
|
a73c7db9d3 | ||
|
|
c81b7b89d1 | ||
|
|
971b672591 | ||
|
|
8b30ffd5e7 | ||
|
|
3975ef15dd | ||
|
|
61e8517137 | ||
|
|
b0d30946eb | ||
|
|
c0cfd41e76 | ||
|
|
be7b6c2cfb | ||
|
|
afd27032e0 | ||
|
|
696fe42bee | ||
|
|
1a0370aef9 | ||
|
|
ed3e8e9799 | ||
|
|
f590ff56ec | ||
|
|
cc68980cdb | ||
|
|
d83708ceb3 | ||
|
|
507aa27d29 | ||
|
|
1d4c5a7393 | ||
|
|
64fd2cc0d3 | ||
|
|
b6182b3c11 | ||
|
|
e7e02dd129 | ||
|
|
d76bfb66bb | ||
|
|
024e62118b | ||
|
|
ed93675d8d | ||
|
|
2089208448 | ||
|
|
4fd8a0b18f | ||
|
|
48213fa584 | ||
|
|
eaabad98c2 | ||
|
|
31bcd61aae | ||
|
|
f6c00f4c20 | ||
|
|
0ce9f76f3b | ||
|
|
781cade78b | ||
|
|
15e91414da | ||
|
|
453a4f48ca | ||
|
|
a91aa87ef9 | ||
|
|
52be65e382 | ||
|
|
142995e83c | ||
|
|
03a7f91961 | ||
|
|
496b329e82 | ||
|
|
a4c8a7d68b | ||
|
|
8f90de01fd | ||
|
|
341fbd8c2a | ||
|
|
01722cff38 | ||
|
|
a7a7857219 | ||
|
|
104332fd94 | ||
|
|
e736c9f5b9 | ||
|
|
103e104cb2 | ||
|
|
5389397e9b | ||
|
|
54cba2beed | ||
|
|
da76cb247c | ||
|
|
9b4a7b6263 | ||
|
|
e6f98d69e7 | ||
|
|
3785d34e8f | ||
|
|
a30943686e | ||
|
|
d4b78d9484 | ||
|
|
66de230f66 | ||
|
|
8cb77864bc | ||
|
|
ea3c130cc3 | ||
|
|
f417ed8210 | ||
|
|
945b9502bc | ||
|
|
4a432bac8d | ||
|
|
541d30764e | ||
|
|
7c2b373254 | ||
|
|
0bf33f1a7d | ||
|
|
1eca19154d | ||
|
|
fd2d4d106f | ||
|
|
d41cbb5305 | ||
|
|
f57a4d4f1b | ||
|
|
4b03f32d21 | ||
|
|
8f1288b1a2 | ||
|
|
7ec87b66d8 | ||
|
|
27dde5afa2 | ||
|
|
3b2732681d | ||
|
|
51a4b545e9 | ||
|
|
7e5972a6e2 | ||
|
|
156cf31625 | ||
|
|
ee7df54d87 | ||
|
|
15c016ad5e | ||
|
|
b0574d3f8e | ||
|
|
4fd6605666 | ||
|
|
76a117cdda | ||
|
|
d4c6747d98 | ||
|
|
6b221e4d13 | ||
|
|
7ec2ddcceb | ||
|
|
5ce13c667d | ||
|
|
c1877a40e9 | ||
|
|
18a38d054f | ||
|
|
500cec88d0 | ||
|
|
affd80ca2e | ||
|
|
5e1ed6b8de | ||
|
|
5d36d6de4f | ||
|
|
93eb8a63de | ||
|
|
6074caaae3 | ||
|
|
d206ff228e | ||
|
|
074af764ed | ||
|
|
e814aadb5b | ||
|
|
aaddd0ef6b | ||
|
|
8a39258d8e | ||
|
|
3136b198d5 | ||
|
|
8a431d962e | ||
|
|
50ab59ebcd | ||
|
|
3ba5bce437 | ||
|
|
9ed56b213e | ||
|
|
34804540c5 | ||
|
|
30c2ca5b85 | ||
|
|
68e6fcd3ac | ||
|
|
da385cd037 | ||
|
|
3b30bc98c7 | ||
|
|
056da1ad23 | ||
|
|
b7cda7a351 | ||
|
|
5896a5d6db | ||
|
|
af91e52555 | ||
|
|
b4ebb6334f | ||
|
|
27178bc3d1 | ||
|
|
76fefc88ca | ||
|
|
98c006939b | ||
|
|
80ed646dd4 | ||
|
|
7ea868d0b2 | ||
|
|
88e1bc3419 | ||
|
|
4ec34a0379 | ||
|
|
aec2dcb75c | ||
|
|
5bdc435f5d | ||
|
|
db46edd39e | ||
|
|
c9739f804d | ||
|
|
eeb44e344f | ||
|
|
a6674610b8 | ||
|
|
6ae3decafb | ||
|
|
00da638e81 | ||
|
|
f04c0a401e | ||
|
|
f5e9f164f5 | ||
|
|
589ac17114 | ||
|
|
8d3510947c | ||
|
|
08a8f5623a | ||
|
|
e85ccdc7da | ||
|
|
d0e7f146fb | ||
|
|
efdb33eb31 | ||
|
|
0abbe62515 | ||
|
|
ab0972dd29 | ||
|
|
83fbb26e03 | ||
|
|
e8ce928ec6 | ||
|
|
1a01e14702 | ||
|
|
aab8176987 | ||
|
|
5a8b885d25 | ||
|
|
c129b24352 | ||
|
|
d98d750268 | ||
|
|
8262b2bf24 | ||
|
|
b99f36c0c5 | ||
|
|
dfe37a260e | ||
|
|
2a42f1de53 | ||
|
|
cea2d0eda2 | ||
|
|
ef05974a72 | ||
|
|
5a6ac628d2 | ||
|
|
826f07544e | ||
|
|
911215c0fb | ||
|
|
43ed41bfae | ||
|
|
81597fbb6d | ||
|
|
cc722c2599 | ||
|
|
c20682fbe8 | ||
|
|
cfa6dc4400 | ||
|
|
851cecf18c | ||
|
|
d4c67485a2 | ||
|
|
381fd05c90 | ||
|
|
60c4ef55c0 | ||
|
|
0b7891419b | ||
|
|
aeedc622b1 | ||
|
|
6f5b87136b | ||
|
|
1ac0c719a2 | ||
|
|
c9ce5442e0 | ||
|
|
c28052720e | ||
|
|
d0f942c495 | ||
|
|
907ef82efb | ||
|
|
415ff04345 | ||
|
|
683ea27526 | ||
|
|
fa052483b2 | ||
|
|
0ae9e6321e | ||
|
|
5623f2e595 | ||
|
|
2c94c1e3f0 | ||
|
|
19dc2f70f2 | ||
|
|
5013ccc552 | ||
|
|
29eed3395f | ||
|
|
d6da27c634 | ||
|
|
5551b52bce | ||
|
|
af7eb48893 | ||
|
|
51ce79f13a | ||
|
|
bcfc04c35c | ||
|
|
d6911b2acb | ||
|
|
2a869f11e0 | ||
|
|
deab9974fa | ||
|
|
49872337f3 | ||
|
|
389b4de0eb | ||
|
|
959ccac857 | ||
|
|
78c58693a5 | ||
|
|
ab81fe5030 | ||
|
|
6bae30070e | ||
|
|
1f6a904717 | ||
|
|
9379475d1c | ||
|
|
77a5f4bd2a | ||
|
|
4fa01231cd | ||
|
|
1cd85507a7 | ||
|
|
b6f151c711 | ||
|
|
e3d924f3fc | ||
|
|
5914df23d3 | ||
|
|
2083c2b8c6 | ||
|
|
35a8411d9b | ||
|
|
15b3b5b990 | ||
|
|
ad56acb712 | ||
|
|
2f5fe87fc8 | ||
|
|
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 |
@@ -2,4 +2,4 @@
|
|||||||
alwaysApply: true
|
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
|
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.)
|
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.
|
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.
|
||||||
|
|||||||
2
.env
2
.env
@@ -1,2 +0,0 @@
|
|||||||
# Default article to display on app load
|
|
||||||
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew
|
|
||||||
17
.env.example
17
.env.example
@@ -1,3 +1,14 @@
|
|||||||
# Default article to display on app load
|
# Nostr configuration for publish-markdown.sh script
|
||||||
# This should be a valid naddr1... string (NIP-19 encoded address pointer to a kind:30023 long-form article)
|
# Copy this file to .env and fill in your values
|
||||||
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew
|
|
||||||
|
# Your Nostr secret key (nsec, ncryptsec, or hex format)
|
||||||
|
# You can also set this via environment variable: export NOSTR_SECRET_KEY=your_key
|
||||||
|
NOSTR_SECRET_KEY=
|
||||||
|
|
||||||
|
# Space-separated list of relay URLs to publish to
|
||||||
|
# If not provided, events will be created but not published
|
||||||
|
RELAYS="ws://localhost:10547 ws://localhost:4869 wss://relay.primal.net wss://wot.dergigi.com wss://relay.dergigi.com wss://nostr.einundzwanzig.space wss://relay.damus.io wss://relay.nostr.bg wss://nos.lol wss://eden.nostr.land"
|
||||||
|
|
||||||
|
# Test account used for publishing markdown test documents:
|
||||||
|
# npub: npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2
|
||||||
|
# Profile: https://read.withboris.com/p/npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2/writings
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@ applesauce
|
|||||||
primal-web-app
|
primal-web-app
|
||||||
Amber
|
Amber
|
||||||
|
|
||||||
|
.env
|
||||||
|
scripts/.env
|
||||||
|
.vercel
|
||||||
|
|||||||
1487
CHANGELOG.md
1487
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.
|
- **Distraction‑free view**: Clean typography, optional hero image, summary, and published date.
|
||||||
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
||||||
- **Progress**: Reading progress indicator with completion state.
|
- **Progress**: Reading progress indicator with completion state.
|
||||||
|
- **Text‑to‑Speech**: Listen to articles with browser‑native TTS; play/pause/stop controls with adjustable speed (0.8–1.6x).
|
||||||
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
||||||
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
|
|
||||||
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
|
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
|
||||||
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
|
- **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
|
## 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
|
|
||||||
|
|
||||||
41
api/article-og-refresh.ts
Normal file
41
api/article-og-refresh.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { setArticleMeta } from './services/ogStore.js'
|
||||||
|
import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
// Validate refresh secret
|
||||||
|
const providedSecret = req.headers['x-refresh-key']
|
||||||
|
const expectedSecret = process.env.OG_REFRESH_SECRET || ''
|
||||||
|
|
||||||
|
if (providedSecret !== expectedSecret) {
|
||||||
|
console.error('Background refresh unauthorized: secret mismatch')
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
if (!naddr) {
|
||||||
|
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Background refresh started for ${naddr}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch metadata via relays (WebSockets) - no timeout, let it take as long as needed
|
||||||
|
const meta = await fetchArticleMetadataViaRelays(naddr)
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
console.log(`Background refresh found metadata for ${naddr}:`, { title: meta.title, summary: meta.summary?.substring(0, 50) })
|
||||||
|
// Store in Redis
|
||||||
|
await setArticleMeta(naddr, meta)
|
||||||
|
console.log(`Background refresh cached metadata for ${naddr}`)
|
||||||
|
return res.status(200).json({ ok: true, cached: true })
|
||||||
|
} else {
|
||||||
|
console.log(`Background refresh found no metadata for ${naddr}`)
|
||||||
|
return res.status(200).json({ ok: true, cached: false })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error refreshing article metadata for ${naddr}:`, err)
|
||||||
|
return res.status(500).json({ error: 'Internal server error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,208 +1,13 @@
|
|||||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { getArticleMeta, setArticleMeta } from './services/ogStore.js'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
|
||||||
import { AddressPointer } from 'nostr-tools/nip19'
|
import { generateHtml } from './services/ogHtml.js'
|
||||||
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://relay.current.fyi',
|
|
||||||
'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 {
|
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
||||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArticleMetadata {
|
|
||||||
title: string
|
|
||||||
summary: string
|
|
||||||
image: string
|
|
||||||
author: string
|
|
||||||
published?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchEventsFromRelays(
|
|
||||||
relayPool: RelayPool,
|
|
||||||
relayUrls: string[],
|
|
||||||
filter: Filter,
|
|
||||||
timeoutMs: number
|
|
||||||
): Promise<NostrEvent[]> {
|
|
||||||
const events: NostrEvent[] = []
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const timeout = setTimeout(() => resolve(), timeoutMs)
|
|
||||||
|
|
||||||
// `request` emits NostrEvent objects directly
|
|
||||||
relayPool.request(relayUrls, filter).subscribe({
|
|
||||||
next: (event) => {
|
|
||||||
events.push(event)
|
|
||||||
},
|
|
||||||
error: () => resolve(),
|
|
||||||
complete: () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort by created_at and return most recent first
|
|
||||||
return events.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
|
|
||||||
const relayPool = new RelayPool()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Decode naddr
|
|
||||||
const decoded = nip19.decode(naddr)
|
|
||||||
if (decoded.type !== 'naddr') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const pointer = decoded.data as AddressPointer
|
|
||||||
|
|
||||||
// Determine relay URLs
|
|
||||||
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
|
||||||
|
|
||||||
// Fetch article and profile in parallel
|
|
||||||
const [articleEvents, profileEvents] = await Promise.all([
|
|
||||||
fetchEventsFromRelays(relayPool, relayUrls, {
|
|
||||||
kinds: [pointer.kind],
|
|
||||||
authors: [pointer.pubkey],
|
|
||||||
'#d': [pointer.identifier || '']
|
|
||||||
}, 5000),
|
|
||||||
fetchEventsFromRelays(relayPool, relayUrls, {
|
|
||||||
kinds: [0],
|
|
||||||
authors: [pointer.pubkey]
|
|
||||||
}, 3000)
|
|
||||||
])
|
|
||||||
|
|
||||||
if (articleEvents.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const article = articleEvents[0]
|
|
||||||
|
|
||||||
// Extract article metadata
|
|
||||||
const title = getArticleTitle(article) || 'Untitled Article'
|
|
||||||
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
|
||||||
const image = getArticleImage(article) || '/boris-social-1200.png'
|
|
||||||
|
|
||||||
// Extract author name from profile
|
|
||||||
let authorName = pointer.pubkey.slice(0, 8) + '...'
|
|
||||||
if (profileEvents.length > 0) {
|
|
||||||
try {
|
|
||||||
const profileData = JSON.parse(profileEvents[0].content)
|
|
||||||
authorName = profileData.display_name || profileData.name || authorName
|
|
||||||
} catch {
|
|
||||||
// Use fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
summary,
|
|
||||||
image,
|
|
||||||
author: authorName,
|
|
||||||
published: article.created_at
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch article metadata:', err)
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
// No explicit close needed; pool manages connections internally
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
|
||||||
const baseUrl = 'https://read.withboris.com'
|
|
||||||
const articleUrl = `${baseUrl}/a/${naddr}`
|
|
||||||
|
|
||||||
const title = meta?.title || 'Boris – Nostr Bookmarks'
|
|
||||||
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
|
||||||
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
|
||||||
const author = meta?.author || 'Boris'
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
||||||
<meta name="theme-color" content="#0f172a" />
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
|
||||||
<title>${escapeHtml(title)}</title>
|
|
||||||
<meta name="description" content="${escapeHtml(description)}" />
|
|
||||||
<link rel="canonical" href="${articleUrl}" />
|
|
||||||
|
|
||||||
<!-- Open Graph / Social Media -->
|
|
||||||
<meta property="og:type" content="article" />
|
|
||||||
<meta property="og:url" content="${articleUrl}" />
|
|
||||||
<meta property="og:title" content="${escapeHtml(title)}" />
|
|
||||||
<meta property="og:description" content="${escapeHtml(description)}" />
|
|
||||||
<meta property="og:image" content="${escapeHtml(image)}" />
|
|
||||||
<meta property="og:site_name" content="Boris" />
|
|
||||||
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
|
||||||
<meta property="article:author" content="${escapeHtml(author)}" />
|
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:url" content="${articleUrl}" />
|
|
||||||
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
|
||||||
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
|
||||||
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>
|
|
||||||
<p>Redirecting to <a href="/">Boris</a>...</p>
|
|
||||||
</noscript>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCrawler(userAgent: string | undefined): boolean {
|
|
||||||
if (!userAgent) return false
|
|
||||||
const crawlers = [
|
|
||||||
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
|
|
||||||
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
|
|
||||||
]
|
|
||||||
const ua = userAgent.toLowerCase()
|
|
||||||
return crawlers.some(crawler => ua.includes(crawler))
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
const naddr = (req.query.naddr as string | undefined)?.trim()
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
|
||||||
@@ -210,89 +15,46 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
return res.status(400).json({ error: 'Missing naddr parameter' })
|
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'
|
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
res.setHeader('X-Boris-Debug', '1')
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a regular browser (not a bot), serve HTML that loads SPA
|
// Try Redis cache first
|
||||||
// Use history.replaceState to set the URL before the SPA boots
|
let meta = await getArticleMeta(naddr).catch((err) => {
|
||||||
if (!isCrawlerRequest) {
|
console.error('Failed to get article meta from Redis:', err)
|
||||||
const articlePath = `/a/${naddr}`
|
return null
|
||||||
// Serve a minimal HTML that sets up the URL and loads the SPA
|
})
|
||||||
const html = `<!DOCTYPE html>
|
let cacheMaxAge = 86400
|
||||||
<html lang="en">
|
|
||||||
<head>
|
if (!meta) {
|
||||||
<meta charset="UTF-8">
|
// Cache miss: fetch from relays (let it use its natural timeouts)
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
try {
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
meta = await fetchArticleMetadataViaRelays(naddr)
|
||||||
<title>Boris - Loading Article...</title>
|
|
||||||
<script>
|
if (meta) {
|
||||||
// Set the URL to the article path before SPA loads
|
// Store in Redis and use it
|
||||||
if (window.location.pathname !== '${articlePath}') {
|
await setArticleMeta(naddr, meta).catch((err) => {
|
||||||
history.replaceState(null, '', '${articlePath}');
|
console.error('Failed to cache relay metadata:', err)
|
||||||
|
})
|
||||||
|
cacheMaxAge = 86400
|
||||||
|
} else {
|
||||||
|
// Relay fetch failed: use default fallback
|
||||||
|
cacheMaxAge = 300
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error fetching from relays for ${naddr}:`, err)
|
||||||
|
cacheMaxAge = 300
|
||||||
}
|
}
|
||||||
</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
|
// Generate and send HTML
|
||||||
const now = Date.now()
|
const html = generateHtml(naddr, meta)
|
||||||
const cached = memoryCache.get(naddr)
|
setCacheHeaders(res, cacheMaxAge)
|
||||||
if (cached && cached.expires > now) {
|
|
||||||
setCacheHeaders(res)
|
if (debugEnabled) {
|
||||||
if (debugEnabled) {
|
// Debug mode enabled
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
224
api/services/articleMeta.ts
Normal file
224
api/services/articleMeta.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import WebSocket from 'ws'
|
||||||
|
;(globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket ??= WebSocket
|
||||||
|
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'
|
||||||
|
import { extractProfileDisplayName } from '../../lib/profile.js'
|
||||||
|
import { RELAYS } from '../../src/config/relays.js'
|
||||||
|
import type { ArticleMetadata } from './ogStore.js'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
relayPool.request(relayUrls, filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
events.push(event)
|
||||||
|
},
|
||||||
|
error: () => resolve(),
|
||||||
|
complete: () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFirstEvent(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
filter: Filter,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<NostrEvent | null> {
|
||||||
|
return new Promise<NostrEvent | null>((resolve) => {
|
||||||
|
let resolved = false
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
const subscription = relayPool.request(relayUrls, filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
subscription.unsubscribe()
|
||||||
|
resolve(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuthorProfile(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
pubkey: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<string | null> {
|
||||||
|
const profileEvents = await fetchEventsFromRelays(relayPool, relayUrls, {
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pubkey]
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
if (profileEvents.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = extractProfileDisplayName(profileEvents[0])
|
||||||
|
if (displayName && !displayName.startsWith('@')) {
|
||||||
|
return displayName
|
||||||
|
} else if (displayName) {
|
||||||
|
return displayName.substring(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchArticleMetadataViaRelays(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
const relayPool = new RelayPool()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type !== 'naddr') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
||||||
|
|
||||||
|
// Step A: Fetch article - return as soon as first event arrives
|
||||||
|
const article = await fetchFirstEvent(relayPool, relayUrls, {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier || '']
|
||||||
|
}, 7000)
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step B: Extract article metadata immediately
|
||||||
|
const title = getArticleTitle(article) || 'Untitled Article'
|
||||||
|
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
||||||
|
const image = getArticleImage(article) || '/boris-social-1200.png'
|
||||||
|
|
||||||
|
// Extract 't' tags (topic tags) from article event
|
||||||
|
const tags = article.tags
|
||||||
|
?.filter((tag) => tag[0] === 't' && tag[1])
|
||||||
|
.map((tag) => tag[1])
|
||||||
|
.filter((tag) => tag.length > 0) || []
|
||||||
|
|
||||||
|
// Generate image alt text (use title as fallback)
|
||||||
|
const imageAlt = title || 'Article cover image'
|
||||||
|
|
||||||
|
// Step C: Fetch author profile with micro-wait (connections already warm)
|
||||||
|
let authorName = await fetchAuthorProfile(relayPool, relayUrls, pointer.pubkey, 400)
|
||||||
|
|
||||||
|
// Step D: Optional hedge - try again with slightly longer timeout if first attempt failed
|
||||||
|
if (!authorName) {
|
||||||
|
authorName = await fetchAuthorProfile(relayPool, relayUrls, pointer.pubkey, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorName) {
|
||||||
|
authorName = pointer.pubkey.slice(0, 8) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
author: authorName,
|
||||||
|
published: article.created_at,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
imageAlt
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch article metadata via relays:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchArticleMetadataViaGateway(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 2000)
|
||||||
|
|
||||||
|
const url = `https://njump.to/${naddr}`
|
||||||
|
console.log(`Fetching from gateway: ${url}`)
|
||||||
|
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
clearTimeout(timeout)
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error(`Gateway fetch failed: ${resp.status} ${resp.statusText} for ${url}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await resp.text()
|
||||||
|
console.log(`Gateway response length: ${html.length} chars`)
|
||||||
|
|
||||||
|
const pick = (re: RegExp) => {
|
||||||
|
const match = html.match(re)
|
||||||
|
return match?.[1] ? match[1].trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = pick(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) ||
|
||||||
|
pick(/<title[^>]*>([^<]+)<\/title>/i)
|
||||||
|
const summary = pick(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
|
||||||
|
const image = pick(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
|
||||||
|
|
||||||
|
console.log(`Parsed from gateway - title: ${title ? 'found' : 'missing'}, summary: ${summary ? 'found' : 'missing'}, image: ${image ? 'found' : 'missing'}`)
|
||||||
|
|
||||||
|
if (!title && !summary && !image) {
|
||||||
|
console.log('No OG metadata found in gateway response')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title || 'Read on Boris',
|
||||||
|
summary: summary || 'Read this article on Boris',
|
||||||
|
image: image || '/boris-social-1200.png',
|
||||||
|
author: 'Boris'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch article metadata via gateway:', err)
|
||||||
|
if (err instanceof Error) {
|
||||||
|
console.error('Error details:', err.message, err.stack)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
80
api/services/ogHtml.ts
Normal file
80
api/services/ogHtml.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ArticleMetadata } from './ogStore.js'
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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'
|
||||||
|
const imageAlt = meta?.imageAlt || title
|
||||||
|
|
||||||
|
// Generate article:tag meta tags
|
||||||
|
const articleTags = meta?.tags && meta.tags.length > 0
|
||||||
|
? meta.tags.map((tag) => ` <meta property="article:tag" content="${escapeHtml(tag)}" />`).join('\n')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
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:image:alt" content="${escapeHtml(imageAlt)}" />
|
||||||
|
<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)}" />
|
||||||
|
${articleTags}
|
||||||
|
|
||||||
|
<!-- 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="/a/${naddr}">Boris</a>...</p>
|
||||||
|
</noscript>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
var p = '/a/${naddr}';
|
||||||
|
if (window.location.pathname !== p) {
|
||||||
|
history.replaceState(null, '', p);
|
||||||
|
}
|
||||||
|
var sep = window.location.search ? '&' : '?';
|
||||||
|
window.location.replace(p + sep + '_spa=1');
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
39
api/services/ogStore.ts
Normal file
39
api/services/ogStore.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Redis } from '@upstash/redis'
|
||||||
|
|
||||||
|
// Support both KV_* and UPSTASH_* env var names
|
||||||
|
const redisUrl = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL
|
||||||
|
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN
|
||||||
|
const readOnlyToken = process.env.KV_REST_API_READ_ONLY_TOKEN
|
||||||
|
|
||||||
|
if (!redisUrl || !redisToken) {
|
||||||
|
console.error('Missing Redis credentials: UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN or KV_REST_API_URL/KV_REST_API_TOKEN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const redisWrite = redisUrl && redisToken
|
||||||
|
? new Redis({ url: redisUrl, token: redisToken })
|
||||||
|
: Redis.fromEnv() // Fallback to fromEnv() if explicit vars not set
|
||||||
|
|
||||||
|
const redisRead = readOnlyToken && redisUrl
|
||||||
|
? new Redis({ url: redisUrl, token: readOnlyToken })
|
||||||
|
: redisWrite
|
||||||
|
|
||||||
|
const keyOf = (naddr: string) => `og:${naddr}`
|
||||||
|
|
||||||
|
export type ArticleMetadata = {
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
image: string
|
||||||
|
author: string
|
||||||
|
published?: number
|
||||||
|
tags?: string[]
|
||||||
|
imageAlt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArticleMeta(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
return (await redisRead.get<ArticleMetadata>(keyOf(naddr))) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setArticleMeta(naddr: string, meta: ArticleMetadata, ttlSec = 604800): Promise<void> {
|
||||||
|
await redisWrite.set(keyOf(naddr), meta, { ex: ttlSec })
|
||||||
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ async function pickCaptions(videoID: string, preferredLangs: string[], manualFir
|
|||||||
return null
|
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 vimeoUrl = `https://vimeo.com/${videoId}`
|
||||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
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 {
|
return {
|
||||||
title: data.title || '',
|
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 {
|
try {
|
||||||
if (videoInfo.source === 'youtube') {
|
if (videoInfo.source === 'youtube') {
|
||||||
// YouTube handling
|
// YouTube handling
|
||||||
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
|
// Fetch basic metadata from YouTube page
|
||||||
const title = ''
|
let title = ''
|
||||||
const description = ''
|
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
|
// 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[]))
|
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)
|
return ok(res, response)
|
||||||
} else if (videoInfo.source === 'vimeo') {
|
} else if (videoInfo.source === 'vimeo') {
|
||||||
// Vimeo handling
|
// Vimeo handling
|
||||||
const { title, description } = await getVimeoMetadata(videoInfo.id)
|
const { title, description, thumbnail_url } = await getVimeoMetadata(videoInfo.id)
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
thumbnail_url,
|
||||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||||
transcript: '', // No transcript available
|
transcript: '', // No transcript available
|
||||||
lang: 'en', // Default language
|
lang: 'en', // Default language
|
||||||
|
|||||||
@@ -63,10 +63,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Since getVideoDetails doesn't exist, we'll use a simple approach
|
// Fetch basic metadata from YouTube page
|
||||||
// In a real implementation, you might want to use YouTube's API or other methods
|
let title = ''
|
||||||
const title = '' // Will be populated from captions or other sources
|
let description = ''
|
||||||
const 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
|
// 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[]))
|
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||||
|
|||||||
11
index.html
11
index.html
@@ -9,14 +9,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<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." />
|
<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/" />
|
<link rel="canonical" href="https://read.withboris.com/" />
|
||||||
|
|
||||||
<!-- Open Graph / Social Media -->
|
<!-- Open Graph / Social Media -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://read.withboris.com/" />
|
<meta property="og:url" content="https://read.withboris.com/" />
|
||||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
<meta property="og:title" content="Boris - 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: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:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
<meta property="og:site_name" content="Boris" />
|
<meta property="og:site_name" content="Boris" />
|
||||||
@@ -24,10 +24,13 @@
|
|||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
<meta name="twitter:title" content="Boris - 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: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" />
|
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
|
|
||||||
|
<!-- Fathom -->
|
||||||
|
<script src="https://cdn.usefathom.com/script.js" data-site="LLSGRVAP" defer></script>
|
||||||
|
|
||||||
<!-- Default to system theme until settings load from Nostr -->
|
<!-- Default to system theme until settings load from Nostr -->
|
||||||
<script>
|
<script>
|
||||||
document.documentElement.className = 'theme-system';
|
document.documentElement.className = 'theme-system';
|
||||||
|
|||||||
39
lib/profile.ts
Normal file
39
lib/profile.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import type { NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
|
export function getNpubFallbackDisplay(pubkey: string): string {
|
||||||
|
try {
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
return `${npub.slice(5, 12)}...`
|
||||||
|
} catch {
|
||||||
|
return `${pubkey.slice(0, 8)}...`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractProfileDisplayName(profileEvent: NostrEvent | null | undefined): string {
|
||||||
|
if (!profileEvent || profileEvent.kind !== 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileData = JSON.parse(profileEvent.content || '{}') as {
|
||||||
|
name?: string
|
||||||
|
display_name?: string
|
||||||
|
nip05?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileData.name) return profileData.name
|
||||||
|
if (profileData.display_name) return profileData.display_name
|
||||||
|
if (profileData.nip05) return profileData.nip05
|
||||||
|
|
||||||
|
return getNpubFallbackDisplay(profileEvent.pubkey)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return getNpubFallbackDisplay(profileEvent.pubkey)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
155
package-lock.json
generated
155
package-lock.json
generated
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.13",
|
"version": "0.10.33",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.13",
|
"version": "0.10.33",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||||
|
"@upstash/redis": "^1.35.6",
|
||||||
"@vercel/node": "^5.3.26",
|
"@vercel/node": "^5.3.26",
|
||||||
"applesauce-accounts": "^4.0.0",
|
"applesauce-accounts": "^4.0.0",
|
||||||
"applesauce-content": "^4.0.0",
|
"applesauce-content": "^4.0.0",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"applesauce-relay": "^4.0.0",
|
"applesauce-relay": "^4.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"fast-average-color": "^9.5.0",
|
"fast-average-color": "^9.5.0",
|
||||||
|
"fetch-opengraph": "^1.0.36",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.4.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -35,12 +37,15 @@
|
|||||||
"rehype-prism-plus": "^2.0.1",
|
"rehype-prism-plus": "^2.0.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"use-pull-to-refresh": "^2.4.1"
|
"tinyld": "^1.3.4",
|
||||||
|
"use-pull-to-refresh": "^2.4.1",
|
||||||
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -54,6 +59,9 @@
|
|||||||
"vite": "^5.0.8",
|
"vite": "^5.0.8",
|
||||||
"vite-plugin-pwa": "^1.0.3",
|
"vite-plugin-pwa": "^1.0.3",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "22.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -100,6 +108,7 @@
|
|||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -2260,6 +2269,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
|
||||||
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
|
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||||
},
|
},
|
||||||
@@ -3551,6 +3561,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
|
||||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -3593,6 +3604,16 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||||
@@ -3635,6 +3656,7 @@
|
|||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
@@ -3797,6 +3819,15 @@
|
|||||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@upstash/redis": {
|
||||||
|
"version": "1.35.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.6.tgz",
|
||||||
|
"integrity": "sha512-aSEIGJgJ7XUfTYvhQcQbq835re7e/BXjs8Janq6Pvr6LlmTZnyqwT97RziZLO/8AVUL037RLXqqiQC6kCt+5pA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uncrypto": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vercel/build-utils": {
|
"node_modules/@vercel/build-utils": {
|
||||||
"version": "12.1.2",
|
"version": "12.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
|
||||||
@@ -3925,7 +3956,8 @@
|
|||||||
"version": "16.18.11",
|
"version": "16.18.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz",
|
||||||
"integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==",
|
"integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@vercel/node/node_modules/esbuild": {
|
"node_modules/@vercel/node/node_modules/esbuild": {
|
||||||
"version": "0.14.47",
|
"version": "0.14.47",
|
||||||
@@ -4010,6 +4042,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4086,6 +4119,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4501,6 +4535,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||||
"version": "0.4.14",
|
"version": "0.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
||||||
@@ -4629,6 +4672,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.9",
|
"baseline-browser-mapping": "^2.8.9",
|
||||||
"caniuse-lite": "^1.0.30001746",
|
"caniuse-lite": "^1.0.30001746",
|
||||||
@@ -5865,6 +5909,7 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -6170,6 +6215,16 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@@ -6263,6 +6318,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -6895,6 +6970,22 @@
|
|||||||
"he": "bin/he"
|
"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": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
@@ -9654,6 +9745,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -9800,6 +9892,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -9812,6 +9905,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -10377,6 +10471,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -11132,6 +11227,7 @@
|
|||||||
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
@@ -11208,6 +11304,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -11215,6 +11312,22 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyld": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyld/-/tinyld-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-u26CNoaInA4XpDU+8s/6Cq8xHc2T5M4fXB3ICfXPokUQoLzmPgSZU02TAkFwFMJCWTjk53gtkS8pETTreZwCqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tinyld": "bin/tinyld.js",
|
||||||
|
"tinyld-heavy": "bin/tinyld-heavy.js",
|
||||||
|
"tinyld-light": "bin/tinyld-light.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.10.0",
|
||||||
|
"npm": ">= 6.12.0",
|
||||||
|
"yarn": ">= 1.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -11444,6 +11557,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -11471,6 +11585,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uncrypto": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "5.28.4",
|
"version": "5.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
|
||||||
@@ -11487,8 +11607,7 @@
|
|||||||
"version": "7.14.0",
|
"version": "7.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
||||||
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
|
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
@@ -11769,6 +11888,7 @@
|
|||||||
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
|
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -12154,6 +12274,7 @@
|
|||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -12198,6 +12319,7 @@
|
|||||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
@@ -12446,6 +12568,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
26
package.json
26
package.json
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.8.2",
|
"version": "0.11.1",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "22.x"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"publish:test:markdown": "./scripts/publish-markdown.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
@@ -16,6 +20,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||||
|
"@upstash/redis": "^1.35.6",
|
||||||
"@vercel/node": "^5.3.26",
|
"@vercel/node": "^5.3.26",
|
||||||
"applesauce-accounts": "^4.0.0",
|
"applesauce-accounts": "^4.0.0",
|
||||||
"applesauce-content": "^4.0.0",
|
"applesauce-content": "^4.0.0",
|
||||||
@@ -26,6 +31,7 @@
|
|||||||
"applesauce-relay": "^4.0.0",
|
"applesauce-relay": "^4.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"fast-average-color": "^9.5.0",
|
"fast-average-color": "^9.5.0",
|
||||||
|
"fetch-opengraph": "^1.0.36",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.4.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -38,12 +44,15 @@
|
|||||||
"rehype-prism-plus": "^2.0.1",
|
"rehype-prism-plus": "^2.0.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"use-pull-to-refresh": "^2.4.1"
|
"tinyld": "^1.3.4",
|
||||||
|
"use-pull-to-refresh": "^2.4.1",
|
||||||
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -95,6 +104,15 @@
|
|||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
"prefer-const": "error",
|
"prefer-const": "error",
|
||||||
"no-var": "error"
|
"no-var": "error"
|
||||||
}
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["api/**/*.ts"],
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"browser": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Boris - Nostr Bookmarks",
|
"name": "Boris - Read, Highlight, Explore",
|
||||||
"short_name": "Boris",
|
"short_name": "Boris",
|
||||||
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
|
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
@@ -9,6 +9,16 @@
|
|||||||
"background_color": "#0b1220",
|
"background_color": "#0b1220",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"categories": ["productivity", "social", "utilities"],
|
"categories": ["productivity", "social", "utilities"],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/share-target",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "link"
|
||||||
|
}
|
||||||
|
},
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon-192.png",
|
"src": "/icon-192.png",
|
||||||
|
|||||||
47
public/sw-dev.js
Normal file
47
public/sw-dev.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Development Service Worker - simplified version for testing image caching
|
||||||
|
// This is served in dev mode when vite-plugin-pwa doesn't serve the injectManifest SW
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Image caching - simple version for dev testing
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url)
|
||||||
|
const isImage = event.request.destination === 'image' ||
|
||||||
|
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open('boris-images-dev').then((cache) => {
|
||||||
|
return cache.match(event.request).then((cachedResponse) => {
|
||||||
|
// Try to fetch from network
|
||||||
|
return fetch(event.request).then((response) => {
|
||||||
|
// If fetch succeeds, cache it and return
|
||||||
|
if (response.ok) {
|
||||||
|
cache.put(event.request, response.clone()).catch(() => {
|
||||||
|
// Ignore cache put errors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}).catch((error) => {
|
||||||
|
// If fetch fails (network error, CORS, etc.), return cached response if available
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse
|
||||||
|
}
|
||||||
|
// No cache available, reject the promise so browser handles it
|
||||||
|
return Promise.reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).catch(() => {
|
||||||
|
// If cache operations fail, try to fetch directly without caching
|
||||||
|
return fetch(event.request)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
202
scripts/publish-markdown.sh
Executable file
202
scripts/publish-markdown.sh
Executable file
@@ -0,0 +1,202 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to publish markdown files from test/markdown/ to Nostr using nak
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/publish-markdown.sh [filename] [relay1] [relay2] ...
|
||||||
|
# ./scripts/publish-markdown.sh # Interactive mode
|
||||||
|
# ./scripts/publish-markdown.sh tables.md # Publish specific file
|
||||||
|
# ./scripts/publish-markdown.sh tables.md wss://relay.example.com # With relay
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# The script reads .env from the project root directory ($PROJECT_ROOT/.env)
|
||||||
|
# Required: NOSTR_SECRET_KEY (your nsec, ncryptsec, or hex format key)
|
||||||
|
# Optional: RELAYS (space-separated list of relay URLs)
|
||||||
|
#
|
||||||
|
# Test account for markdown test documents:
|
||||||
|
# npub: npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2
|
||||||
|
# Profile: https://read.withboris.com/p/npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2/writings
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
MARKDOWN_DIR="$PROJECT_ROOT/test/markdown"
|
||||||
|
ENV_FILE="$PROJECT_ROOT/.env"
|
||||||
|
|
||||||
|
# Load .env file if it exists
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
# Source the .env file, handling quoted values properly
|
||||||
|
set -a # Automatically export all variables
|
||||||
|
# Use eval to properly handle quoted values (safe since we control the file)
|
||||||
|
# This handles both unquoted and quoted values correctly
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
# Skip comments and empty lines
|
||||||
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
# Remove leading/trailing whitespace
|
||||||
|
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||||
|
# Export the variable (handles quoted values)
|
||||||
|
eval "export $line"
|
||||||
|
done < "$ENV_FILE"
|
||||||
|
set +a # Stop automatically exporting
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if nak is installed
|
||||||
|
if ! command -v nak &> /dev/null; then
|
||||||
|
echo "Error: nak is not installed or not in PATH"
|
||||||
|
echo "Install from: https://github.com/fiatjaf/nak"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to publish a markdown file
|
||||||
|
publish_file() {
|
||||||
|
local file_path="$1"
|
||||||
|
shift # Remove first argument, rest are relay URLs
|
||||||
|
local relays=("$@")
|
||||||
|
local filename=$(basename "$file_path")
|
||||||
|
local identifier="${filename%.md}" # Remove .md extension
|
||||||
|
|
||||||
|
echo "📝 Publishing: $filename"
|
||||||
|
echo " Identifier: $identifier"
|
||||||
|
|
||||||
|
# Extract title from first H1 if available, otherwise use filename
|
||||||
|
local title=$(grep -m 1 "^# " "$file_path" | sed 's/^# //' || echo "$identifier")
|
||||||
|
|
||||||
|
# Add relays if provided
|
||||||
|
if [ ${#relays[@]} -gt 0 ]; then
|
||||||
|
echo " Relays: ${relays[*]}"
|
||||||
|
else
|
||||||
|
echo " Note: No relays specified. Event will be created but not published."
|
||||||
|
echo " Add relay URLs as arguments to publish, e.g.: wss://relay.example.com"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Publish as kind 30023 (NIP-23 blog post)
|
||||||
|
# The "d" tag is required for replaceable events (kind 30023)
|
||||||
|
# Using the filename (without extension) as the identifier
|
||||||
|
# Build command array to avoid eval issues
|
||||||
|
# Use @filename syntax to read content from file (nak supports this)
|
||||||
|
local cmd_args=(
|
||||||
|
"event"
|
||||||
|
"-k" "30023"
|
||||||
|
"-d" "$identifier"
|
||||||
|
"-t" "title=$title"
|
||||||
|
"--content" "@$file_path"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add relays if provided
|
||||||
|
if [ ${#relays[@]} -gt 0 ]; then
|
||||||
|
cmd_args+=("${relays[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
nak "${cmd_args[@]}"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Successfully published: $filename"
|
||||||
|
else
|
||||||
|
echo "❌ Failed to publish: $filename"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for NOSTR_SECRET_KEY
|
||||||
|
if [ -z "$NOSTR_SECRET_KEY" ]; then
|
||||||
|
echo "⚠️ Warning: NOSTR_SECRET_KEY environment variable not set"
|
||||||
|
echo " Set it in .env file or with: export NOSTR_SECRET_KEY=your_key_here"
|
||||||
|
echo " Or use --prompt-sec flag (nak will prompt for key)"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse RELAYS from environment if set
|
||||||
|
default_relays=()
|
||||||
|
if [ -n "$RELAYS" ]; then
|
||||||
|
# Split RELAYS string into array
|
||||||
|
read -ra default_relays <<< "$RELAYS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Main logic
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
# No arguments: list all markdown files and let user choose
|
||||||
|
echo "Available markdown files:"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
files=("$MARKDOWN_DIR"/*.md)
|
||||||
|
if [ ! -e "${files[0]}" ]; then
|
||||||
|
echo "No markdown files found in $MARKDOWN_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display files with numbers
|
||||||
|
declare -a file_array
|
||||||
|
i=1
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
filename=$(basename "$file")
|
||||||
|
echo " $i) $filename"
|
||||||
|
file_array[$i]="$file"
|
||||||
|
((i++))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Enter file number(s) to publish (space-separated), or 'all' for all files:"
|
||||||
|
read -r selection
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ ${#default_relays[@]} -gt 0 ]; then
|
||||||
|
echo "Enter relay URLs (space-separated, or press Enter to use defaults from .env):"
|
||||||
|
echo " Defaults: ${default_relays[*]}"
|
||||||
|
else
|
||||||
|
echo "Enter relay URLs (space-separated, or press Enter to skip):"
|
||||||
|
fi
|
||||||
|
read -r relay_input
|
||||||
|
|
||||||
|
# Parse relay URLs
|
||||||
|
relays=()
|
||||||
|
if [ -n "$relay_input" ]; then
|
||||||
|
read -ra relays <<< "$relay_input"
|
||||||
|
elif [ ${#default_relays[@]} -gt 0 ]; then
|
||||||
|
# Use defaults from .env
|
||||||
|
relays=("${default_relays[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$selection" = "all" ]; then
|
||||||
|
# Publish all files
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
publish_file "$file" "${relays[@]}"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# Publish selected files
|
||||||
|
for num in $selection; do
|
||||||
|
if [ -n "${file_array[$num]}" ]; then
|
||||||
|
publish_file "${file_array[$num]}" "${relays[@]}"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "⚠️ Invalid selection: $num"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Argument provided: publish specific file
|
||||||
|
filename="$1"
|
||||||
|
shift # Remove filename, rest are relay URLs
|
||||||
|
relays=("$@")
|
||||||
|
|
||||||
|
# If no relays provided as arguments, use defaults from .env
|
||||||
|
if [ ${#relays[@]} -eq 0 ] && [ ${#default_relays[@]} -gt 0 ]; then
|
||||||
|
relays=("${default_relays[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If filename doesn't end with .md, add it
|
||||||
|
if [[ ! "$filename" =~ \.md$ ]]; then
|
||||||
|
filename="${filename}.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_path="$MARKDOWN_DIR/$filename"
|
||||||
|
|
||||||
|
if [ ! -f "$file_path" ]; then
|
||||||
|
echo "Error: File not found: $file_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
publish_file "$file_path" "${relays[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
231
src/App.tsx
231
src/App.tsx
@@ -8,17 +8,20 @@ import { AccountManager, Accounts } from 'applesauce-accounts'
|
|||||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrConnectSigner } from 'applesauce-signers'
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import type { NostrEvent } from 'nostr-tools'
|
||||||
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
import Debug from './components/Debug'
|
import Debug from './components/Debug'
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
import RouteDebug from './components/RouteDebug'
|
import RouteDebug from './components/RouteDebug'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
|
import ShareTargetHandler from './components/ShareTargetHandler'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
import { DebugBus } from './utils/debugBus'
|
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
|
||||||
|
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS, HARDCODED_RELAYS } from './services/relayManager'
|
||||||
import { Bookmark } from './types/bookmarks'
|
import { Bookmark } from './types/bookmarks'
|
||||||
import { bookmarkController } from './services/bookmarkController'
|
import { bookmarkController } from './services/bookmarkController'
|
||||||
import { contactsController } from './services/contactsController'
|
import { contactsController } from './services/contactsController'
|
||||||
@@ -28,6 +31,7 @@ import { readingProgressController } from './services/readingProgressController'
|
|||||||
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
||||||
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
||||||
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
||||||
|
import { archiveController } from './services/archiveController'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||||
@@ -91,7 +95,7 @@ function AppRoutes({
|
|||||||
|
|
||||||
// Load bookmarks
|
// Load bookmarks
|
||||||
if (bookmarks.length === 0 && !bookmarksLoading) {
|
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||||
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load contacts
|
// Load contacts
|
||||||
@@ -114,6 +118,11 @@ function AppRoutes({
|
|||||||
readingProgressController.start({ relayPool, eventStore, 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)
|
// Start centralized nostrverse highlights controller (non-blocking)
|
||||||
if (eventStore) {
|
if (eventStore) {
|
||||||
nostrverseHighlightsController.start({ relayPool, eventStore })
|
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||||
@@ -145,11 +154,16 @@ function AppRoutes({
|
|||||||
contactsController.reset() // Clear contacts via controller
|
contactsController.reset() // Clear contacts via controller
|
||||||
highlightsController.reset() // Clear highlights via controller
|
highlightsController.reset() // Clear highlights via controller
|
||||||
readingProgressController.reset() // Clear reading progress via controller
|
readingProgressController.reset() // Clear reading progress via controller
|
||||||
|
archiveController.reset() // Clear archive state
|
||||||
showToast('Logged out successfully')
|
showToast('Logged out successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/share-target"
|
||||||
|
element={<ShareTargetHandler relayPool={relayPool} />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/a/:naddr"
|
path="/a/:naddr"
|
||||||
element={
|
element={
|
||||||
@@ -223,11 +237,11 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me"
|
path="/my"
|
||||||
element={<Navigate to="/me/highlights" replace />}
|
element={<Navigate to="/my/highlights" replace />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/highlights"
|
path="/my/highlights"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
@@ -239,7 +253,7 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/reading-list"
|
path="/my/bookmarks"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
@@ -251,7 +265,7 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/reads"
|
path="/my/reads"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
@@ -263,7 +277,7 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/reads/:filter"
|
path="/my/reads/:filter"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
@@ -275,7 +289,7 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/links"
|
path="/my/links"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
@@ -287,7 +301,19 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/writings"
|
path="/my/links/:filter"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/my/writings"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
@@ -322,6 +348,18 @@ function AppRoutes({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/e/:eventId"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/debug"
|
path="/debug"
|
||||||
element={
|
element={
|
||||||
@@ -366,16 +404,9 @@ function App() {
|
|||||||
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
// 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.
|
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||||
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||||
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
NostrConnectSigner.publishMethod = (relays: string[], event: NostrEvent) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// Fire-and-forget publish; do not block callers
|
||||||
const result: any = pool.publish(relays, event as any)
|
pool.publish(relays, event).catch(() => { /* ignore errors */ })
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
if (result && typeof (result as any).subscribe === 'function') {
|
|
||||||
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
try { (result as any).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
// Return an already-resolved promise so upstream await finishes immediately
|
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,14 +429,10 @@ function App() {
|
|||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
accounts.setActive(activeId)
|
accounts.setActive(activeId)
|
||||||
} else {
|
|
||||||
console.warn('[bunker] ⚠️ Active ID found but account not in list')
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// No active account ID in localStorage
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
|
console.error('Failed to load accounts from storage:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to accounts changes and persist to localStorage
|
// Subscribe to accounts changes and persist to localStorage
|
||||||
@@ -474,61 +501,27 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||||
recreatedSigner.relays = mergedRelays
|
recreatedSigner.relays = mergedRelays
|
||||||
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
|
} catch (err) { /* ignore */ }
|
||||||
|
|
||||||
// Replace the signer on the account
|
// Replace the signer on the account
|
||||||
nostrConnectAccount.signer = recreatedSigner
|
nostrConnectAccount.signer = recreatedSigner
|
||||||
|
|
||||||
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
|
// 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
|
// 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)
|
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) => {
|
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
|
||||||
try {
|
|
||||||
let method: string | undefined
|
|
||||||
const content = (event as { content?: unknown })?.content
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
|
|
||||||
method = parsed?.method
|
|
||||||
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
|
|
||||||
}
|
|
||||||
const summary = {
|
|
||||||
relays,
|
|
||||||
kind: (event as { kind?: number })?.kind,
|
|
||||||
method,
|
|
||||||
// include tags array for debugging (NIP-46 expects method tag)
|
|
||||||
tags: (event as { tags?: unknown })?.tags,
|
|
||||||
contentLength: typeof content === 'string' ? content.length : undefined
|
|
||||||
}
|
|
||||||
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
|
|
||||||
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
|
|
||||||
// Fire-and-forget publish: trigger the publish but do not return the
|
|
||||||
// Observable/Promise to upstream to avoid their awaiting of completion.
|
|
||||||
const result = originalPublish(relays, event)
|
const result = originalPublish(relays, event)
|
||||||
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
||||||
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
|
||||||
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
|
|
||||||
// Return a benign object so callers that probe for a "subscribe" property
|
|
||||||
// (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`.
|
|
||||||
return {} as unknown as never
|
return {} as unknown as never
|
||||||
}
|
}
|
||||||
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
|
|
||||||
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
|
|
||||||
try {
|
|
||||||
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
|
|
||||||
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
|
|
||||||
return originalSubscribe(relays, filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Just ensure the signer is listening for responses - don't call connect() again
|
// Just ensure the signer is listening for responses - don't call connect() again
|
||||||
// The fromBunkerURI already connected with permissions during login
|
// The fromBunkerURI already connected with permissions during login
|
||||||
if (!nostrConnectAccount.signer.listening) {
|
if (!nostrConnectAccount.signer.listening) {
|
||||||
await nostrConnectAccount.signer.open()
|
await nostrConnectAccount.signer.open()
|
||||||
} else {
|
|
||||||
// Signer already listening
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
||||||
@@ -538,7 +531,7 @@ function App() {
|
|||||||
await nostrConnectAccount.signer.connect(undefined, permissions)
|
await nostrConnectAccount.signer.connect(undefined, permissions)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
|
// Ignore reconnect errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give the subscription a moment to fully establish before allowing decrypt operations
|
// Give the subscription a moment to fully establish before allowing decrypt operations
|
||||||
@@ -578,17 +571,114 @@ function App() {
|
|||||||
// Mark this account as reconnected
|
// Mark this account as reconnected
|
||||||
reconnectedAccounts.add(account.id)
|
reconnectedAccounts.add(account.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[bunker] ❌ Failed to open signer:', error)
|
console.error('Failed to open signer:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper to update keep-alive subscription based on current active relays
|
||||||
|
const updateKeepAlive = (relayUrls?: string[]) => {
|
||||||
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
|
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||||
|
}
|
||||||
|
const targetRelays = relayUrls || getActiveRelayUrls(pool)
|
||||||
|
const newKeepAliveSub = pool.subscription(targetRelays, { kinds: [0], limit: 0 }).subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: () => {}
|
||||||
|
})
|
||||||
|
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to update address loader based on current active relays
|
||||||
|
const updateAddressLoader = (relayUrls?: string[]) => {
|
||||||
|
const targetRelays = relayUrls || getActiveRelayUrls(pool)
|
||||||
|
const addressLoader = createAddressLoader(pool, {
|
||||||
|
eventStore: store,
|
||||||
|
lookupRelays: targetRelays
|
||||||
|
})
|
||||||
|
store.addressableLoader = addressLoader
|
||||||
|
store.replaceableLoader = addressLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
updateAddressLoader()
|
||||||
|
}).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)
|
||||||
|
updateKeepAlive(RELAYS)
|
||||||
|
updateAddressLoader(RELAYS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
||||||
// This prevents disconnection when no other subscriptions are active
|
// This prevents disconnection when no other subscriptions are active
|
||||||
// Create a minimal subscription that never completes to keep connections alive
|
// Create a minimal subscription that never completes to keep connections alive
|
||||||
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
||||||
next: () => {}, // No-op, we don't care about events
|
next: () => {},
|
||||||
error: (err) => console.warn('Keep-alive subscription error:', err)
|
error: () => {}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store subscription for cleanup
|
// Store subscription for cleanup
|
||||||
@@ -611,6 +701,7 @@ function App() {
|
|||||||
accountsSub.unsubscribe()
|
accountsSub.unsubscribe()
|
||||||
activeSub.unsubscribe()
|
activeSub.unsubscribe()
|
||||||
bunkerReconnectSub.unsubscribe()
|
bunkerReconnectSub.unsubscribe()
|
||||||
|
userRelaysSub.unsubscribe()
|
||||||
// Clean up keep-alive subscription if it exists
|
// Clean up keep-alive subscription if it exists
|
||||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
if (poolWithSub._keepAliveSubscription) {
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
@@ -648,6 +739,16 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [showToast])
|
}, [showToast])
|
||||||
|
|
||||||
|
// Strip _spa query parameter from URL after SPA loads
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
if (url.searchParams.has('_spa')) {
|
||||||
|
url.searchParams.delete('_spa')
|
||||||
|
const path = url.pathname + (url.search ? url.search : '') + url.hash
|
||||||
|
window.history.replaceState(null, '', path)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (!eventStore || !accountManager || !relayPool) {
|
if (!eventStore || !accountManager || !relayPool) {
|
||||||
return (
|
return (
|
||||||
<div className="loading">
|
<div className="loading">
|
||||||
|
|||||||
@@ -4,41 +4,40 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|||||||
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { fetchReadableContent } from '../services/readerService'
|
import { fetchReadableContent } from '../services/readerService'
|
||||||
|
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
|
||||||
|
|
||||||
interface AddBookmarkModalProps {
|
interface AddBookmarkModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
|
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to extract metadata from HTML
|
// Helper to extract tags from OpenGraph data
|
||||||
function extractMetaTag(html: string, patterns: string[]): string | null {
|
function extractTagsFromOgData(ogData: Record<string, unknown>): string[] {
|
||||||
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[] {
|
|
||||||
const tags: string[] = []
|
const tags: string[] = []
|
||||||
|
|
||||||
// Extract keywords meta tag
|
// Extract keywords from OpenGraph data
|
||||||
const keywords = extractMetaTag(html, [
|
if (ogData.keywords && typeof ogData.keywords === 'string') {
|
||||||
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
ogData.keywords.split(/[,;]/)
|
||||||
])
|
.map((k: string) => k.trim().toLowerCase())
|
||||||
if (keywords) {
|
.filter((k: string) => k.length > 0 && k.length < 30)
|
||||||
keywords.split(/[,;]/)
|
.forEach((k: string) => tags.push(k))
|
||||||
.map(k => k.trim().toLowerCase())
|
|
||||||
.filter(k => k.length > 0 && k.length < 30)
|
|
||||||
.forEach(k => tags.push(k))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract article:tag (multiple possible)
|
// Extract article:tag from OpenGraph data
|
||||||
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi
|
if (ogData['article:tag']) {
|
||||||
let match
|
const articleTagValue = ogData['article:tag']
|
||||||
while ((match = articleTagRegex.exec(html)) !== null) {
|
const articleTags = Array.isArray(articleTagValue)
|
||||||
const tag = match[1].trim().toLowerCase()
|
? articleTagValue
|
||||||
if (tag && tag.length < 30) tags.push(tag)
|
: [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)
|
return Array.from(new Set(tags)).slice(0, 5)
|
||||||
@@ -83,17 +82,27 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
fetchTimeoutRef.current = window.setTimeout(async () => {
|
fetchTimeoutRef.current = window.setTimeout(async () => {
|
||||||
setIsFetchingMetadata(true)
|
setIsFetchingMetadata(true)
|
||||||
try {
|
try {
|
||||||
const content = await fetchReadableContent(normalizedUrl)
|
// Fetch both readable content and OpenGraph data in parallel
|
||||||
lastFetchedUrlRef.current = normalizedUrl
|
const [content, ogData] = await Promise.all([
|
||||||
|
fetchReadableContent(normalizedUrl),
|
||||||
|
fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails
|
||||||
|
])
|
||||||
|
|
||||||
|
lastFetchedUrlRef.current = normalizedUrl
|
||||||
let extractedAnything = false
|
let extractedAnything = false
|
||||||
|
|
||||||
// Extract title: prioritize og:title > twitter:title > <title>
|
// Extract title: prioritize og:title > twitter:title > content.title
|
||||||
if (!title && content.html) {
|
if (!title) {
|
||||||
const extractedTitle = extractMetaTag(content.html, [
|
let extractedTitle = null
|
||||||
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
|
||||||
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
if (ogData) {
|
||||||
]) || content.title
|
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) {
|
if (extractedTitle) {
|
||||||
setTitle(extractedTitle)
|
setTitle(extractedTitle)
|
||||||
@@ -102,12 +111,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract description: prioritize og:description > twitter:description > meta description
|
// Extract description: prioritize og:description > twitter:description > meta description
|
||||||
if (!description && content.html) {
|
if (!description && ogData) {
|
||||||
const extractedDesc = extractMetaTag(content.html, [
|
const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
|
||||||
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
|
||||||
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
|
||||||
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
|
||||||
])
|
|
||||||
|
|
||||||
if (extractedDesc) {
|
if (extractedDesc) {
|
||||||
setDescription(extractedDesc)
|
setDescription(extractedDesc)
|
||||||
@@ -116,8 +121,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
|
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
|
||||||
if (!tagsInput && content.html) {
|
if (!tagsInput && ogData) {
|
||||||
const extractedTags = extractTags(content.html)
|
const extractedTags = extractTagsFromOgData(ogData)
|
||||||
|
|
||||||
// Only add boris tag if we extracted something
|
// Only add boris tag if we extracted something
|
||||||
if (extractedAnything || extractedTags.length > 0) {
|
if (extractedAnything || extractedTags.length > 0) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
|
|||||||
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
|
{ type: 'marked' as const, icon: faBooks, label: 'Archived' }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
|||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface AuthorCardProps {
|
interface AuthorCardProps {
|
||||||
authorPubkey: string
|
authorPubkey: string
|
||||||
@@ -16,9 +17,7 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true
|
|||||||
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
|
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
|
||||||
|
|
||||||
const getAuthorName = () => {
|
const getAuthorName = () => {
|
||||||
if (profile?.name) return profile.name
|
return getProfileDisplayName(profile, authorPubkey)
|
||||||
if (profile?.display_name) return profile.display_name
|
|
||||||
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorImage = profile?.picture || profile?.image
|
const authorImage = profile?.picture || profile?.image
|
||||||
@@ -27,7 +26,7 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true
|
|||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (clickable) {
|
if (clickable) {
|
||||||
const npub = nip19.npubEncode(authorPubkey)
|
const npub = nip19.npubEncode(authorPubkey)
|
||||||
navigate(`/p/${npub}`)
|
navigate(`/p/${npub}/writings`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,32 @@ import { formatDistance } from 'date-fns'
|
|||||||
import { BlogPostPreview } from '../services/exploreService'
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
|
import { isKnownBot } from '../config/bots'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface BlogPostCardProps {
|
interface BlogPostCardProps {
|
||||||
post: BlogPostPreview
|
post: BlogPostPreview
|
||||||
href: string
|
href: string
|
||||||
level?: 'mine' | 'friends' | 'nostrverse'
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
readingProgress?: number // 0-1 reading progress (optional)
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
|
hideBotByName?: boolean // default true
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||||
const displayName = profile?.name || profile?.display_name ||
|
|
||||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
// Note: Images are lazy-loaded (loading="lazy" below), so they'll be fetched
|
||||||
|
// when they come into view. The Service Worker will cache them automatically.
|
||||||
|
// No need to preload all images at once - this causes ERR_INSUFFICIENT_RESOURCES
|
||||||
|
// when there are many blog posts.
|
||||||
|
|
||||||
|
const displayName = getProfileDisplayName(profile, post.author)
|
||||||
|
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
|
||||||
|
|
||||||
|
// Hide bot authors by name/display_name
|
||||||
|
if (hideBotByName && (rawName.includes('bot') || isKnownBot(post.author))) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const publishedDate = post.published || post.event.created_at
|
const publishedDate = post.published || post.event.created_at
|
||||||
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||||
@@ -33,15 +47,29 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
|||||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug log - reading progress shown as visual indicator
|
// Debug log - reading progress shown as visual indicator
|
||||||
if (readingProgress !== undefined) {
|
if (readingProgress !== undefined) {
|
||||||
// Reading progress display
|
// Reading progress display
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build article coordinate for navigation state (kind:pubkey:dTag)
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = dTag ? `${post.event.kind}:${post.author}:${dTag}` : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
|
state={{
|
||||||
|
previewData: {
|
||||||
|
title: post.title,
|
||||||
|
image: post.image,
|
||||||
|
summary: post.summary,
|
||||||
|
published: post.published
|
||||||
|
},
|
||||||
|
articleCoordinate,
|
||||||
|
eventId: post.event.id
|
||||||
|
}}
|
||||||
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
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 { IndividualBookmark } from '../types/bookmarks'
|
||||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||||
import { classifyUrl } from '../utils/helpers'
|
import { classifyUrl } from '../utils/helpers'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
import { CompactView } from './BookmarkViews/CompactView'
|
import { CompactView } from './BookmarkViews/CompactView'
|
||||||
import { LargeView } from './BookmarkViews/LargeView'
|
import { LargeView } from './BookmarkViews/LargeView'
|
||||||
import { CardView } from './BookmarkViews/CardView'
|
import { CardView } from './BookmarkViews/CardView'
|
||||||
@@ -23,6 +25,7 @@ interface BookmarkItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
|
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||||
|
|
||||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||||
@@ -40,10 +43,11 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||||
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : 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.
|
// 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)
|
// When we have full events, we use getArticleImage() helper (see articleService.ts)
|
||||||
const isArticle = bookmark.kind === 30023
|
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 articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
|
||||||
const articleSummary = isArticle ? bookmark.tags.find(t => t[0] === 'summary')?.[1] : undefined
|
const articleSummary = isArticle ? bookmark.tags.find(t => t[0] === 'summary')?.[1] : undefined
|
||||||
|
|
||||||
@@ -58,15 +62,16 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
// Resolve author profile using applesauce
|
// Resolve author profile using applesauce
|
||||||
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
|
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
|
||||||
const authorNpub = npubEncode(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
|
// Get display name for author using centralized utility
|
||||||
const getAuthorDisplayName = () => {
|
const getAuthorDisplayName = () => {
|
||||||
if (authorProfile?.name) return authorProfile.name
|
const displayName = getProfileDisplayName(authorProfile, bookmark.pubkey)
|
||||||
if (authorProfile?.display_name) return authorProfile.display_name
|
// getProfileDisplayName returns npub format for fallback, but we want short pubkey format
|
||||||
if (authorProfile?.nip05) return authorProfile.nip05
|
// So check if it's the fallback format and use short() instead
|
||||||
return short(bookmark.pubkey) // fallback to short pubkey
|
if (displayName.startsWith('@') && displayName.includes('...')) {
|
||||||
|
return short(bookmark.pubkey)
|
||||||
|
}
|
||||||
|
return displayName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get content type icon based on bookmark kind and URL classification
|
// Get content type icon based on bookmark kind and URL classification
|
||||||
@@ -110,10 +115,16 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
event.preventDefault()
|
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 (bookmark.kind === 30023) {
|
||||||
if (onSelectUrl) {
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
if (dTag) {
|
||||||
|
const naddr = naddrEncode({
|
||||||
|
kind: bookmark.kind,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
navigate(`/a/${naddr}`)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -135,7 +146,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
@@ -145,8 +155,16 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'compact') {
|
if (viewMode === 'compact') {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
const compactProps = {
|
||||||
const { articleImage, ...compactProps } = sharedProps
|
bookmark,
|
||||||
|
index,
|
||||||
|
hasUrls,
|
||||||
|
extractedUrls,
|
||||||
|
onSelectUrl,
|
||||||
|
articleTitle,
|
||||||
|
contentTypeIcon: getContentTypeIcon(),
|
||||||
|
readingProgress
|
||||||
|
}
|
||||||
return <CompactView {...compactProps} />
|
return <CompactView {...compactProps} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,5 +173,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
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 { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
@@ -13,12 +13,12 @@ import { ViewMode } from './Bookmarks'
|
|||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { BookmarkSkeleton } from './Skeletons'
|
import { BookmarkSkeleton } from './Skeletons'
|
||||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import AddBookmarkModal from './AddBookmarkModal'
|
import AddBookmarkModal from './AddBookmarkModal'
|
||||||
import { createWebBookmark } from '../services/webBookmarkService'
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import LoginOptions from './LoginOptions'
|
import LoginOptions from './LoginOptions'
|
||||||
@@ -58,7 +58,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
lastFetchTime,
|
|
||||||
loading = false,
|
loading = false,
|
||||||
relayPool,
|
relayPool,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
@@ -71,7 +70,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||||
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||||
const saved = localStorage.getItem('bookmarkGroupingMode')
|
const saved = localStorage.getItem('bookmarkGroupingMode')
|
||||||
return saved === 'flat' ? 'flat' : 'grouped'
|
return saved === 'grouped' ? 'grouped' : 'flat'
|
||||||
})
|
})
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
@@ -106,7 +105,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// For web bookmarks and other types, try to use URL if available
|
|
||||||
|
// 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)
|
const urls = extractUrlsFromContent(bookmark.content)
|
||||||
if (urls.length > 0) {
|
if (urls.length > 0) {
|
||||||
return readingProgressMap.get(urls[0])
|
return readingProgressMap.get(urls[0])
|
||||||
@@ -120,12 +130,24 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
localStorage.setItem('bookmarkGroupingMode', newMode)
|
localStorage.setItem('bookmarkGroupingMode', newMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFilterTitle = (filter: BookmarkFilterType): string => {
|
||||||
|
const titles: Record<BookmarkFilterType, string> = {
|
||||||
|
'all': 'All Bookmarks',
|
||||||
|
'article': 'Bookmarked Reads',
|
||||||
|
'external': 'Bookmarked Links',
|
||||||
|
'video': 'Bookmarked Videos',
|
||||||
|
'note': 'Bookmarked Notes',
|
||||||
|
'web': 'Web Bookmarks'
|
||||||
|
}
|
||||||
|
return titles[filter]
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
if (!activeAccount || !relayPool) {
|
if (!activeAccount || !relayPool) {
|
||||||
throw new Error('Please login to create bookmarks')
|
throw new Error('Please login to create bookmarks')
|
||||||
}
|
}
|
||||||
|
|
||||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull-to-refresh for bookmarks
|
// Pull-to-refresh for bookmarks
|
||||||
@@ -140,39 +162,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
isDisabled: !onRefresh
|
isDisabled: !onRefresh
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge and flatten all individual bookmarks from all lists
|
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
|
||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const sections = useMemo(() => {
|
||||||
.filter(hasContent)
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
.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])
|
||||||
|
|
||||||
// Apply filter
|
// Get all filtered bookmarks for empty state checks
|
||||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
const allIndividualBookmarks = useMemo(() =>
|
||||||
|
bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
.filter(hasContent)
|
||||||
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
|
||||||
|
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
|
||||||
|
)
|
||||||
|
|
||||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
const filteredBookmarks = useMemo(() =>
|
||||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
|
||||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
[allIndividualBookmarks, selectedFilter]
|
||||||
|
)
|
||||||
// Group non-set bookmarks by source or flatten based on mode
|
|
||||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
|
||||||
groupingMode === 'flat'
|
|
||||||
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
|
|
||||||
: [
|
|
||||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
|
||||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
|
||||||
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
|
||||||
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
|
|
||||||
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Add bookmark sets as additional sections
|
|
||||||
bookmarkSets.forEach(set => {
|
|
||||||
sections.push({
|
|
||||||
key: `set-${set.name}`,
|
|
||||||
title: set.title || set.name,
|
|
||||||
items: set.bookmarks
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
// Check if the selected URL is in bookmarks
|
// Check if the selected URL is in bookmarks
|
||||||
@@ -206,10 +247,19 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{allIndividualBookmarks.length > 0 && (
|
{allIndividualBookmarks.length > 0 && (
|
||||||
<BookmarkFilters
|
<div className="bookmark-filters-wrapper">
|
||||||
selectedFilter={selectedFilter}
|
<BookmarkFilters
|
||||||
onFilterChange={setSelectedFilter}
|
selectedFilter={selectedFilter}
|
||||||
/>
|
onFilterChange={setSelectedFilter}
|
||||||
|
/>
|
||||||
|
<CompactButton
|
||||||
|
icon={faPlus}
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
title="Add web bookmark"
|
||||||
|
ariaLabel="Add web bookmark"
|
||||||
|
className="bookmark-section-action"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!activeAccount ? (
|
{!activeAccount ? (
|
||||||
@@ -246,15 +296,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
<div key={section.key} className="bookmarks-section">
|
<div key={section.key} className="bookmarks-section">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
||||||
{section.key === 'web' && activeAccount && (
|
|
||||||
<CompactButton
|
|
||||||
icon={faPlus}
|
|
||||||
onClick={() => setShowAddModal(true)}
|
|
||||||
title="Add web bookmark"
|
|
||||||
ariaLabel="Add web bookmark"
|
|
||||||
className="bookmark-section-action"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
{section.items.map((individualBookmark, index) => (
|
{section.items.map((individualBookmark, index) => (
|
||||||
@@ -282,27 +323,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
style={{ color: friendsColor }}
|
style={{ color: friendsColor }}
|
||||||
/>
|
/>
|
||||||
</div>
|
{activeAccount && (
|
||||||
{activeAccount && (
|
|
||||||
<div className="view-mode-right">
|
|
||||||
{onRefresh && (
|
|
||||||
<IconButton
|
|
||||||
icon={faRotate}
|
|
||||||
onClick={onRefresh}
|
|
||||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
|
||||||
ariaLabel="Refresh bookmarks"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={isRefreshing}
|
|
||||||
spin={isRefreshing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||||
onClick={toggleGroupingMode}
|
onClick={toggleGroupingMode}
|
||||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeAccount && (
|
||||||
|
<div className="view-mode-right">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faList}
|
icon={faList}
|
||||||
onClick={() => onViewModeChange('compact')}
|
onClick={() => onViewModeChange('compact')}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
import { faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
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 { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import RichContent from '../RichContent'
|
||||||
import { classifyUrl } from '../../utils/helpers'
|
import { classifyUrl } from '../../utils/helpers'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||||
import { getEventUrl } from '../../config/nostrGateways'
|
import { naddrEncode } from 'nostr-tools/nip19'
|
||||||
|
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||||
|
|
||||||
interface CardViewProps {
|
interface CardViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -18,12 +20,11 @@ interface CardViewProps {
|
|||||||
extractedUrls: string[]
|
extractedUrls: string[]
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
authorNpub: string
|
authorNpub: string
|
||||||
eventNevent?: string
|
|
||||||
getAuthorDisplayName: () => string
|
getAuthorDisplayName: () => string
|
||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleImage?: string
|
articleImage?: string
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
articleTitle?: string
|
||||||
readingProgress?: number
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,14 +33,12 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
index,
|
index,
|
||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon,
|
articleTitle,
|
||||||
readingProgress
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||||
@@ -47,21 +46,41 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
||||||
|
|
||||||
const [ogImage, setOgImage] = useState<string | null>(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 isArticle = bookmark.kind === 30023
|
||||||
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
|
const isNote = bookmark.kind === 1
|
||||||
|
|
||||||
// Calculate progress color (matching BlogPostCard logic)
|
// Extract title from tags for regular bookmarks (not just articles)
|
||||||
let progressColor = '#6366f1' // Default blue (reading)
|
const bookmarkTitle = bookmark.tags.find(t => t[0] === 'title')?.[1]
|
||||||
if (readingProgress && readingProgress >= 0.95) {
|
|
||||||
progressColor = '#10b981' // Green (completed)
|
// Get content type icon based on bookmark kind and URL classification
|
||||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
const getContentTypeIcon = () => {
|
||||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
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)
|
// Determine which image to use (article image, instant preview, or OG image)
|
||||||
const previewImage = articleImage || instantPreview || ogImage
|
const previewImage = articleImage || instantPreview || ogImage
|
||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
@@ -73,6 +92,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
}
|
}
|
||||||
}, [firstUrl, articleImage, instantPreview, ogImage])
|
}, [firstUrl, articleImage, instantPreview, ogImage])
|
||||||
|
|
||||||
|
|
||||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
|
|
||||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
@@ -82,131 +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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${bookmark.id}-${index}`}
|
key={`${bookmark.id}-${index}`}
|
||||||
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
className={`individual-bookmark card-view ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||||
onClick={triggerOpen}
|
onClick={triggerOpen}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{cachedImage && (
|
<div className="card-layout">
|
||||||
<div
|
<div className="card-content">
|
||||||
className="article-hero-image"
|
<div className="card-content-header">
|
||||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
{(cachedImage || firstUrl) && (
|
||||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
<div
|
||||||
/>
|
className="card-thumbnail"
|
||||||
)}
|
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
|
||||||
<div className="bookmark-header">
|
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||||
<span className="bookmark-type">
|
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
{url}
|
{!cachedImage && firstUrl && (
|
||||||
</button>
|
<div className="thumbnail-placeholder">
|
||||||
)
|
<FontAwesomeIcon icon={getContentTypeIcon()} />
|
||||||
})}
|
</div>
|
||||||
{extractedUrls.length > 1 && (
|
)}
|
||||||
<button
|
</div>
|
||||||
className="expand-toggle-urls"
|
)}
|
||||||
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
|
<div className="card-text-content">
|
||||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
<div className="bookmark-header">
|
||||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
</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`}
|
{getAuthorDisplayName()}
|
||||||
</button>
|
</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>
|
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reading progress indicator for articles */}
|
|
||||||
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '3px',
|
|
||||||
width: '100%',
|
|
||||||
background: 'var(--color-border)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
marginTop: '0.75rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${Math.round(readingProgress * 100)}%`,
|
|
||||||
background: progressColor,
|
|
||||||
transition: 'width 0.3s ease, background 0.3s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bookmark-footer">
|
|
||||||
<div className="bookmark-meta-minimal">
|
|
||||||
<Link
|
|
||||||
to={`/p/${authorNpub}`}
|
|
||||||
className="author-link-minimal"
|
|
||||||
title="Open author profile"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{getAuthorDisplayName()}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{/* CTA removed */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import RichContent from '../RichContent'
|
||||||
|
import { naddrEncode } from 'nostr-tools/nip19'
|
||||||
|
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||||
|
|
||||||
interface CompactViewProps {
|
interface CompactViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -11,7 +14,7 @@ interface CompactViewProps {
|
|||||||
hasUrls: boolean
|
hasUrls: boolean
|
||||||
extractedUrls: string[]
|
extractedUrls: string[]
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
articleSummary?: string
|
articleTitle?: string
|
||||||
contentTypeIcon: IconDefinition
|
contentTypeIcon: IconDefinition
|
||||||
readingProgress?: number
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
@@ -22,37 +25,37 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
articleSummary,
|
articleTitle,
|
||||||
contentTypeIcon,
|
contentTypeIcon,
|
||||||
readingProgress
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
const isNote = bookmark.kind === 1
|
||||||
|
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
|
||||||
|
|
||||||
// Calculate progress color (matching BlogPostCard logic)
|
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
|
||||||
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 handleCompactClick = () => {
|
const handleCompactClick = () => {
|
||||||
if (!onSelectUrl) return
|
|
||||||
|
|
||||||
if (isArticle) {
|
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) {
|
} 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 (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||||
<div
|
<div
|
||||||
@@ -64,36 +67,26 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
<span className="bookmark-type-compact">
|
<span className="bookmark-type-compact">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
</span>
|
</span>
|
||||||
{displayText && (
|
{displayText ? (
|
||||||
<div className="compact-text">
|
<div className="compact-text">
|
||||||
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
|
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="compact-text" style={{ opacity: 0.5, fontSize: '0.85em' }}>
|
||||||
|
<code>{bookmark.id.slice(0, 12)}...</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||||
{/* CTA removed */}
|
{/* CTA removed */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reading progress indicator for all bookmark types with reading data */}
|
{/* Reading progress indicator - only show when there's actual progress */}
|
||||||
{readingProgress !== undefined && readingProgress > 0 && (
|
{readingProgress !== undefined && readingProgress > 0 && (
|
||||||
<div
|
<ReadingProgressBar
|
||||||
style={{
|
readingProgress={readingProgress}
|
||||||
height: '1px',
|
height={1}
|
||||||
width: '100%',
|
marginLeft="1.5rem"
|
||||||
background: 'var(--color-border)',
|
/>
|
||||||
overflow: 'hidden',
|
|
||||||
margin: '0',
|
|
||||||
marginLeft: '1.5rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${Math.round(readingProgress * 100)}%`,
|
|
||||||
background: progressColor,
|
|
||||||
transition: 'width 0.3s ease, background 0.3s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate } from '../../utils/bookmarkUtils'
|
import { formatDate } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import RichContent from '../RichContent'
|
||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { getEventUrl } from '../../config/nostrGateways'
|
import { naddrEncode } from 'nostr-tools/nip19'
|
||||||
|
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||||
|
|
||||||
interface LargeViewProps {
|
interface LargeViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -18,7 +19,6 @@ interface LargeViewProps {
|
|||||||
getIconForUrlType: IconGetter
|
getIconForUrlType: IconGetter
|
||||||
previewImage: string | null
|
previewImage: string | null
|
||||||
authorNpub: string
|
authorNpub: string
|
||||||
eventNevent?: string
|
|
||||||
getAuthorDisplayName: () => string
|
getAuthorDisplayName: () => string
|
||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
@@ -35,7 +35,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
getIconForUrlType,
|
getIconForUrlType,
|
||||||
previewImage,
|
previewImage,
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
@@ -45,15 +44,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
// Calculate progress display (matching readingProgressUtils.ts logic)
|
|
||||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
|
||||||
let progressColor = '#6366f1' // Default blue (reading)
|
|
||||||
|
|
||||||
if (readingProgress && readingProgress >= 0.95) {
|
|
||||||
progressColor = '#10b981' // Green (completed)
|
|
||||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
|
||||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
@@ -63,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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${bookmark.id}-${index}`}
|
key={`${bookmark.id}-${index}`}
|
||||||
@@ -95,36 +109,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
|
|
||||||
<div className="large-content">
|
<div className="large-content">
|
||||||
{isArticle && articleSummary ? (
|
{isArticle && articleSummary ? (
|
||||||
<div className="large-text article-summary">
|
<RichContent content={articleSummary} className="large-text article-summary" />
|
||||||
<ContentWithResolvedProfiles content={articleSummary} />
|
|
||||||
</div>
|
|
||||||
) : bookmark.content && (
|
) : bookmark.content && (
|
||||||
<div className="large-text">
|
<RichContent content={bookmark.content} className="large-text" />
|
||||||
<ContentWithResolvedProfiles content={bookmark.content} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reading progress indicator for articles - shown only if there's progress */}
|
{/* Reading progress indicator for all bookmark types - always shown */}
|
||||||
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
<ReadingProgressBar
|
||||||
<div
|
readingProgress={readingProgress}
|
||||||
style={{
|
height={3}
|
||||||
height: '3px',
|
marginTop="0.75rem"
|
||||||
width: '100%',
|
/>
|
||||||
background: 'var(--color-border)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
marginTop: '0.75rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${progressPercent}%`,
|
|
||||||
background: progressColor,
|
|
||||||
transition: 'width 0.3s ease, background 0.3s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="large-footer">
|
<div className="large-footer">
|
||||||
<span className="bookmark-type-large">
|
<span className="bookmark-type-large">
|
||||||
@@ -140,16 +135,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{eventNevent && (
|
{getInternalRoute() ? (
|
||||||
<a
|
<Link
|
||||||
href={getEventUrl(eventNevent)}
|
to={getInternalRoute()!}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="bookmark-date-link"
|
className="bookmark-date-link"
|
||||||
|
title="Open in app"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{formatDate(bookmark.created_at)}
|
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||||
</a>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CTA removed */}
|
{/* CTA removed */}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import React, { useMemo, useEffect, useRef } from 'react'
|
|||||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventStore } from 'applesauce-react/hooks'
|
import { useEventStore } from 'applesauce-react/hooks'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
const { getPubkeyFromDecodeResult } = Helpers
|
||||||
import { useSettings } from '../hooks/useSettings'
|
import { useSettings } from '../hooks/useSettings'
|
||||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||||
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
||||||
@@ -13,6 +16,8 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
|||||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
|
import { useEventLoader } from '../hooks/useEventLoader'
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
@@ -38,7 +43,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
bookmarksLoading,
|
bookmarksLoading,
|
||||||
onRefreshBookmarks
|
onRefreshBookmarks
|
||||||
}) => {
|
}) => {
|
||||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
const { naddr, npub, eventId: eventIdParam } = useParams<{ naddr?: string; npub?: string; eventId?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const previousLocationRef = useRef<string>()
|
const previousLocationRef = useRef<string>()
|
||||||
@@ -52,45 +57,59 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
|
|
||||||
const showSettings = location.pathname === '/settings'
|
const showSettings = location.pathname === '/settings'
|
||||||
const showExplore = location.pathname.startsWith('/explore')
|
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 showProfile = location.pathname.startsWith('/p/')
|
||||||
const showSupport = location.pathname === '/support'
|
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
|
// Extract tab from explore routes
|
||||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Extract tab from me routes
|
// Extract tab from me routes
|
||||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
const meTab = location.pathname === '/my' ? 'highlights' :
|
||||||
location.pathname === '/me/highlights' ? 'highlights' :
|
location.pathname === '/my/highlights' ? 'highlights' :
|
||||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
location.pathname === '/my/bookmarks' ? 'bookmarks' :
|
||||||
location.pathname.startsWith('/me/reads') ? 'reads' :
|
location.pathname.startsWith('/my/reads') ? 'reads' :
|
||||||
location.pathname === '/me/links' ? 'links' :
|
location.pathname.startsWith('/my/links') ? 'links' :
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname === '/my/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Extract tab from profile routes
|
// Extract tab from profile routes
|
||||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Decode npub or nprofile to pubkey for profile view
|
// Decode npub or nprofile to pubkey for profile view using applesauce helper
|
||||||
let profilePubkey: string | undefined
|
let profilePubkey: string | undefined
|
||||||
if (npub && showProfile) {
|
if (npub && showProfile) {
|
||||||
try {
|
try {
|
||||||
const decoded = nip19.decode(npub)
|
const decoded = nip19.decode(npub)
|
||||||
if (decoded.type === 'npub') {
|
profilePubkey = getPubkeyFromDecodeResult(decoded)
|
||||||
profilePubkey = decoded.data
|
|
||||||
} else if (decoded.type === 'nprofile') {
|
|
||||||
profilePubkey = decoded.data.pubkey
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to decode npub/nprofile:', err)
|
console.error('Failed to decode npub/nprofile:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track previous location for going back from settings/me/explore/profile
|
// Track previous location for going back from settings/my/explore/profile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSettings && !showMe && !showExplore && !showProfile) {
|
if (!showSettings && !showMe && !showExplore && !showProfile) {
|
||||||
previousLocationRef.current = location.pathname
|
previousLocationRef.current = location.pathname
|
||||||
}
|
}
|
||||||
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
||||||
|
|
||||||
|
// Reset scroll to top when navigating to profile routes
|
||||||
|
useEffect(() => {
|
||||||
|
if (showProfile) {
|
||||||
|
// Reset scroll position when navigating to profile pages
|
||||||
|
// Use requestAnimationFrame to ensure it happens after DOM updates
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [location.pathname, showProfile])
|
||||||
|
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
@@ -220,14 +239,28 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
currentArticle,
|
currentArticle,
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
readerContent,
|
readerContent,
|
||||||
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
|
onHighlightCreated: (highlight) => setHighlights(prev => {
|
||||||
|
// Deduplicate by checking if highlight with this ID already exists
|
||||||
|
const exists = prev.some(h => h.id === highlight.id)
|
||||||
|
if (exists) {
|
||||||
|
return prev // Don't add duplicate
|
||||||
|
}
|
||||||
|
return [highlight, ...prev]
|
||||||
|
}),
|
||||||
settings
|
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
|
// Load nostr-native article if naddr is in URL
|
||||||
useArticleLoader({
|
useArticleLoader({
|
||||||
naddr,
|
naddr: shouldLoadArticle ? naddr : undefined,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
@@ -242,7 +275,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
|
|
||||||
// Load external URL if /r/* route is used
|
// Load external URL if /r/* route is used
|
||||||
useExternalUrlLoader({
|
useExternalUrlLoader({
|
||||||
url: externalUrl,
|
url: shouldLoadExternal ? externalUrl : undefined,
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
@@ -255,6 +288,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
setCurrentArticleEventId
|
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
|
// Classify highlights with levels based on user context
|
||||||
const classifiedHighlights = useMemo(() => {
|
const classifiedHighlights = useMemo(() => {
|
||||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||||
@@ -328,7 +372,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
me={showMe ? (
|
me={showMe ? (
|
||||||
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} settings={settings} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
profile={showProfile && profilePubkey ? (
|
profile={showProfile && profilePubkey ? (
|
||||||
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
|
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ import { HIGHLIGHT_COLORS } from '../utils/colorHelpers'
|
|||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
selectedColor: string
|
selectedColor: string
|
||||||
onColorChange: (color: string) => void
|
onColorChange: (color: string) => void
|
||||||
|
colors?: typeof HIGHLIGHT_COLORS
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange }) => {
|
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange, colors = HIGHLIGHT_COLORS }) => {
|
||||||
return (
|
return (
|
||||||
<div className="color-picker">
|
<div className="color-picker">
|
||||||
{HIGHLIGHT_COLORS.map(color => (
|
{colors.map(color => (
|
||||||
<button
|
<button
|
||||||
key={color.value}
|
key={color.value}
|
||||||
onClick={() => onColorChange(color.value)}
|
onClick={() => onColorChange(color.value)}
|
||||||
className={`color-swatch ${selectedColor === color.value ? 'active' : ''}`}
|
className={`color-swatch ${selectedColor === color.value ? 'active' : ''}`}
|
||||||
style={{ backgroundColor: color.value }}
|
style={{ backgroundColor: color.value }}
|
||||||
title={color.name}
|
title={color.name}
|
||||||
aria-label={`${color.name} highlight color`}
|
aria-label={`${color.name} color`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import ReactPlayer from 'react-player'
|
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import rehypeRaw from 'rehype-raw'
|
import rehypeRaw from 'rehype-raw'
|
||||||
import rehypePrism from 'rehype-prism-plus'
|
import rehypePrism from 'rehype-prism-plus'
|
||||||
|
import VideoEmbedProcessor from './VideoEmbedProcessor'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import 'prismjs/themes/prism-tomorrow.css'
|
import 'prismjs/themes/prism-tomorrow.css'
|
||||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { ContentSkeleton } from './Skeletons'
|
import { ContentSkeleton } from './Skeletons'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import { isContentRelay } from '../config/relays'
|
||||||
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
import { IAccount } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
@@ -29,20 +31,21 @@ import {
|
|||||||
hasMarkedEventAsRead,
|
hasMarkedEventAsRead,
|
||||||
hasMarkedWebsiteAsRead
|
hasMarkedWebsiteAsRead
|
||||||
} from '../services/reactionService'
|
} from '../services/reactionService'
|
||||||
|
import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
|
||||||
|
import { archiveController } from '../services/archiveController'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
import { shouldTrackReadingProgress } from '../utils/helpers'
|
||||||
import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
|
|
||||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
|
||||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||||
import { EventFactory } from 'applesauce-factory'
|
import { EventFactory } from 'applesauce-factory'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import {
|
import {
|
||||||
generateArticleIdentifier,
|
generateArticleIdentifier,
|
||||||
loadReadingPosition,
|
saveReadingPosition
|
||||||
saveReadingPosition
|
|
||||||
} from '../services/readingPositionService'
|
} from '../services/readingPositionService'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import TTSControls from './TTSControls'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -72,6 +75,7 @@ interface ContentPanelProps {
|
|||||||
// For reading progress indicator positioning
|
// For reading progress indicator positioning
|
||||||
isSidebarCollapsed?: boolean
|
isSidebarCollapsed?: boolean
|
||||||
isHighlightsCollapsed?: boolean
|
isHighlightsCollapsed?: boolean
|
||||||
|
onOpenHighlights?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||||
@@ -99,21 +103,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
onTextSelection,
|
onTextSelection,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
isSidebarCollapsed = false,
|
isSidebarCollapsed = false,
|
||||||
isHighlightsCollapsed = false
|
isHighlightsCollapsed = false,
|
||||||
|
onOpenHighlights
|
||||||
}) => {
|
}) => {
|
||||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||||
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
|
||||||
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||||
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
|
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
|
||||||
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
|
||||||
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
|
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
|
||||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
|
||||||
const externalMenuRef = 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 { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||||
|
|
||||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||||
@@ -128,8 +129,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
currentUserPubkey,
|
currentUserPubkey,
|
||||||
followedPubkeys
|
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({
|
const { contentRef } = useHighlightInteractions({
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
onTextSelection,
|
onTextSelection,
|
||||||
@@ -139,8 +145,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
// Get event store for reading position service
|
// Get event store for reading position service
|
||||||
const eventStore = Hooks.useEventStore()
|
const eventStore = Hooks.useEventStore()
|
||||||
|
|
||||||
// Reading position tracking - only for text content, not videos
|
// Reading position tracking - only for text content that's loaded and long enough
|
||||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
// 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
|
// Generate article identifier for saving/loading position
|
||||||
const articleIdentifier = useMemo(() => {
|
const articleIdentifier = useMemo(() => {
|
||||||
@@ -148,6 +164,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
return generateArticleIdentifier(selectedUrl)
|
return generateArticleIdentifier(selectedUrl)
|
||||||
}, [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
|
// Callback to save reading position
|
||||||
const handleSavePosition = useCallback(async (position: number) => {
|
const handleSavePosition = useCallback(async (position: number) => {
|
||||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
@@ -158,7 +182,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if content is long enough to track reading progress
|
// Check if content is long enough to track reading progress
|
||||||
if (!shouldTrackReadingProgress(html, markdown)) {
|
if (!shouldTrackReadingProgress(htmlRef.current, markdownRef.current)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,18 +202,50 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
|
console.error('[reading-position] Failed to save reading position:', error)
|
||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition])
|
||||||
|
|
||||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
// Delay enabling position tracking to ensure content is stable
|
||||||
enabled: isTextContent,
|
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,
|
syncEnabled: settings?.syncReadingPosition !== false,
|
||||||
onSave: handleSavePosition,
|
onSave: handleSavePosition,
|
||||||
onReadingComplete: () => {
|
onReadingComplete: () => {
|
||||||
// Auto-mark as read when reading is complete (if enabled in settings)
|
// Auto-mark as read when reading is complete (if enabled in settings)
|
||||||
if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) {
|
if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return
|
||||||
|
if (!isMarkedAsRead) {
|
||||||
handleMarkAsRead()
|
handleMarkAsRead()
|
||||||
|
} else {
|
||||||
|
// Already archived: still show the success animation for feedback
|
||||||
|
setShowCheckAnimation(true)
|
||||||
|
setTimeout(() => setShowCheckAnimation(false), 600)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -198,59 +254,99 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
||||||
|
|
||||||
// Load saved reading position when article loads
|
// Load saved reading position when article loads (using pre-loaded data from controller)
|
||||||
|
const suppressSavesForRef = useRef(suppressSavesFor)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
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)
|
||||||
|
|
||||||
|
// Reset scroll position and restore ref when article changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!articleIdentifier) return
|
||||||
|
|
||||||
|
// Suppress saves during navigation to prevent saving 0% position
|
||||||
|
// The 500ms suppression covers the scroll reset and initial render
|
||||||
|
if (suppressSavesForRef.current) {
|
||||||
|
suppressSavesForRef.current(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset scroll to top when article identifier changes
|
||||||
|
// This prevents showing wrong scroll position from previous article
|
||||||
|
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||||
|
// Reset restore attempt tracking for new article
|
||||||
|
hasAttemptedRestoreRef.current = null
|
||||||
|
}, [articleIdentifier])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTextContent || !activeAccount || !articleIdentifier) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (settings?.syncReadingPosition === false) {
|
if (settings?.syncReadingPosition === false) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (settings?.autoScrollToReadingPosition === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isTrackingEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const loadPosition = async () => {
|
// Only attempt restore once per article (after tracking is enabled)
|
||||||
try {
|
if (hasAttemptedRestoreRef.current === restoreKey) {
|
||||||
const savedPosition = await loadReadingPosition(
|
return
|
||||||
relayPool,
|
}
|
||||||
eventStore,
|
|
||||||
activeAccount.pubkey,
|
|
||||||
articleIdentifier
|
|
||||||
)
|
|
||||||
|
|
||||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
// Mark as attempted using composite key
|
||||||
// Wait for content to be fully rendered before scrolling
|
hasAttemptedRestoreRef.current = restoreKey
|
||||||
setTimeout(() => {
|
|
||||||
const documentHeight = document.documentElement.scrollHeight
|
// Get the saved position from the controller (already loaded and displayed on card)
|
||||||
const windowHeight = window.innerHeight
|
const savedProgress = readingProgressController.getProgress(articleIdentifier)
|
||||||
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
|
||||||
|
if (!savedProgress || savedProgress <= 0.05 || savedProgress >= 1) {
|
||||||
window.scrollTo({
|
return
|
||||||
top: scrollTop,
|
}
|
||||||
behavior: 'smooth'
|
|
||||||
})
|
// Suppress saves during restore (500ms render + 1000ms smooth scroll = 1500ms)
|
||||||
}, 500) // Give content time to render
|
if (suppressSavesForRef.current) {
|
||||||
} else if (savedPosition) {
|
suppressSavesForRef.current(1500)
|
||||||
if (savedPosition.position === 1) {
|
}
|
||||||
// Article was completed, start from top
|
|
||||||
} else {
|
// Wait for content to be fully rendered
|
||||||
// Position was too early, skip restore
|
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)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return
|
||||||
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
loadPosition()
|
// Perform smooth animated restore
|
||||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
window.scrollTo({
|
||||||
|
top: targetTop,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}, 500) // Give content time to render
|
||||||
|
}, [isTextContent, activeAccount, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToReadingPosition, selectedUrl, isTrackingEnabled, restoreKey])
|
||||||
|
|
||||||
// Save position before unmounting or changing article
|
// Note: We intentionally do NOT save on unmount because:
|
||||||
useEffect(() => {
|
// 1. Browser may scroll to top during back navigation, causing 0% saves
|
||||||
return () => {
|
// 2. The auto-save with 1s throttle already captures position during reading
|
||||||
if (saveNow) {
|
// 3. Position state may not reflect actual reading position during navigation
|
||||||
saveNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [saveNow, selectedUrl])
|
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -259,21 +355,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
||||||
setShowArticleMenu(false)
|
setShowArticleMenu(false)
|
||||||
}
|
}
|
||||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
|
||||||
setShowVideoMenu(false)
|
|
||||||
}
|
|
||||||
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||||
setShowExternalMenu(false)
|
setShowExternalMenu(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
if (showArticleMenu || showExternalMenu) {
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside)
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
}, [showArticleMenu, showExternalMenu])
|
||||||
|
|
||||||
// Check available space and position menu upward if needed
|
// Check available space and position menu upward if needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -296,13 +389,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
if (showArticleMenu) {
|
if (showArticleMenu) {
|
||||||
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
|
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
|
||||||
}
|
}
|
||||||
if (showVideoMenu) {
|
|
||||||
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
|
||||||
}
|
|
||||||
if (showExternalMenu) {
|
if (showExternalMenu) {
|
||||||
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
|
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
|
||||||
}
|
}
|
||||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
}, [showArticleMenu, showExternalMenu])
|
||||||
|
|
||||||
const readingStats = useMemo(() => {
|
const readingStats = useMemo(() => {
|
||||||
const content = markdown || html || ''
|
const content = markdown || html || ''
|
||||||
@@ -313,36 +403,29 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const hasHighlights = relevantHighlights.length > 0
|
const hasHighlights = relevantHighlights.length > 0
|
||||||
|
|
||||||
|
// Extract plain text for TTS
|
||||||
|
const baseHtml = useMemo(() => {
|
||||||
|
if (markdown) return renderedMarkdownHtml && finalHtml ? finalHtml : ''
|
||||||
|
return finalHtml || html || ''
|
||||||
|
}, [markdown, renderedMarkdownHtml, finalHtml, html])
|
||||||
|
|
||||||
|
const articleText = useMemo(() => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (title) parts.push(title)
|
||||||
|
if (summary) parts.push(summary)
|
||||||
|
if (baseHtml) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = baseHtml
|
||||||
|
const txt = (div.textContent || '').replace(/\s+/g, ' ').trim()
|
||||||
|
if (txt) parts.push(txt)
|
||||||
|
}
|
||||||
|
return parts.join('. ')
|
||||||
|
}, [title, summary, baseHtml])
|
||||||
|
|
||||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||||
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
|
||||||
|
|
||||||
// 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
|
// Get article links for menu
|
||||||
@@ -350,9 +433,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
if (!currentArticle) return null
|
if (!currentArticle) return null
|
||||||
|
|
||||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const relayHints = RELAYS.filter(r =>
|
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
const relayHints = activeRelays
|
||||||
).slice(0, 3)
|
.filter(url => !isLocalRelay(url))
|
||||||
|
.filter(url => isContentRelay(url))
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
const naddr = nip19.naddrEncode({
|
const naddr = nip19.naddrEncode({
|
||||||
kind: 30023,
|
kind: 30023,
|
||||||
@@ -379,7 +464,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
setShowArticleMenu(!showArticleMenu)
|
setShowArticleMenu(!showArticleMenu)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
|
||||||
|
|
||||||
const handleOpenPortal = () => {
|
const handleOpenPortal = () => {
|
||||||
if (articleLinks) {
|
if (articleLinks) {
|
||||||
@@ -456,52 +540,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenSearch = () => {
|
const handleOpenSearch = () => {
|
||||||
if (articleLinks) {
|
// For regular notes (kind:1), open via /e/ path
|
||||||
|
if (currentArticle?.kind === 1) {
|
||||||
|
const borisUrl = `${window.location.origin}/e/${currentArticle.id}`
|
||||||
|
window.open(borisUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
} else if (articleLinks) {
|
||||||
|
// For articles, use search portal
|
||||||
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
setShowArticleMenu(false)
|
setShowArticleMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// External article actions
|
||||||
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||||
@@ -543,7 +592,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const handleSearchExternalUrl = () => {
|
const handleSearchExternalUrl = () => {
|
||||||
if (selectedUrl) {
|
if (selectedUrl) {
|
||||||
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
// If it's a nostr event sentinel, open the event directly on ants.sh
|
||||||
|
if (selectedUrl.startsWith('nostr-event:')) {
|
||||||
|
const eventId = selectedUrl.replace('nostr-event:', '')
|
||||||
|
window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer')
|
||||||
|
} else {
|
||||||
|
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setShowExternalMenu(false)
|
setShowExternalMenu(false)
|
||||||
}
|
}
|
||||||
@@ -566,12 +621,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
activeAccount.pubkey,
|
activeAccount.pubkey,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
// Also check archiveController
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||||
|
hasRead = hasRead || archiveController.isMarked(naddr)
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore encoding errors
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
hasRead = await hasMarkedWebsiteAsRead(
|
hasRead = await hasMarkedWebsiteAsRead(
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
activeAccount.pubkey,
|
activeAccount.pubkey,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
// Also check archiveController
|
||||||
|
const ctrl = archiveController.isMarked(selectedUrl)
|
||||||
|
hasRead = hasRead || ctrl
|
||||||
}
|
}
|
||||||
setIsMarkedAsRead(hasRead)
|
setIsMarkedAsRead(hasRead)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -585,7 +653,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
if (!activeAccount || !relayPool) return
|
||||||
|
|
||||||
|
// Toggle archive state: if already archived, request deletion; else archive
|
||||||
|
if (isMarkedAsRead) {
|
||||||
|
// Optimistically unarchive in UI; background deletion request (NIP-09)
|
||||||
|
setIsMarkedAsRead(false)
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
if (isNostrArticle && currentArticle) {
|
||||||
|
// Send deletion for all matching reactions
|
||||||
|
await unarchiveEvent(currentArticle.id, activeAccount, relayPool)
|
||||||
|
// Also clear controller mark so lists update
|
||||||
|
try {
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||||
|
archiveController.unmark(naddr)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[archive][content] encode naddr failed', e)
|
||||||
|
}
|
||||||
|
} else if (selectedUrl) {
|
||||||
|
await unarchiveWebsite(selectedUrl, activeAccount, relayPool)
|
||||||
|
archiveController.unmark(selectedUrl)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[archive][content] unarchive failed', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,14 +703,34 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
currentArticle.pubkey,
|
currentArticle.pubkey,
|
||||||
currentArticle.kind,
|
currentArticle.kind,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
relayPool
|
relayPool,
|
||||||
|
{
|
||||||
|
aCoord: (() => {
|
||||||
|
try {
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
return `${30023}:${currentArticle.pubkey}:${dTag}`
|
||||||
|
} catch { return undefined }
|
||||||
|
})()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
// Update archiveController immediately
|
||||||
|
try {
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||||
|
archiveController.mark(naddr)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[archive][content] optimistic article mark failed', err)
|
||||||
|
}
|
||||||
} else if (selectedUrl) {
|
} else if (selectedUrl) {
|
||||||
await createWebsiteReaction(
|
await createWebsiteReaction(
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
archiveController.mark(selectedUrl)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to mark as read:', error)
|
console.error('Failed to mark as read:', error)
|
||||||
@@ -632,13 +748,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="reader" aria-busy="true">
|
|
||||||
<ContentSkeleton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightRgb = hexToRgb(highlightColor)
|
const highlightRgb = hexToRgb(highlightColor)
|
||||||
|
|
||||||
@@ -648,7 +757,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
{isTextContent && (
|
{isTextContent && (
|
||||||
<ReadingProgressIndicator
|
<ReadingProgressIndicator
|
||||||
progress={progressPercentage}
|
progress={progressPercentage}
|
||||||
isComplete={isReadingComplete}
|
// Consider complete only at 95%+
|
||||||
|
isComplete={progressPercentage >= 95}
|
||||||
showPercentage={true}
|
showPercentage={true}
|
||||||
isSidebarCollapsed={isSidebarCollapsed}
|
isSidebarCollapsed={isSidebarCollapsed}
|
||||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||||
@@ -658,16 +768,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||||
{markdown && (
|
{markdown && (
|
||||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
<div ref={markdownPreviewRef} key={`preview:${contentKey}`} style={{ display: 'none' }}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||||
components={{
|
components={{
|
||||||
img: ({ src, alt, ...props }) => (
|
img: ({ src, alt }) => (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -678,126 +787,55 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ReaderHeader
|
<ReaderHeader
|
||||||
title={ytMeta?.title || title}
|
title={title}
|
||||||
image={image}
|
image={image}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
published={published}
|
published={published}
|
||||||
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
|
readingTimeText={readingStats ? readingStats.text : null}
|
||||||
hasHighlights={hasHighlights}
|
hasHighlights={hasHighlights}
|
||||||
highlightCount={relevantHighlights.length}
|
highlightCount={relevantHighlights.length}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
highlights={relevantHighlights}
|
highlights={relevantHighlights}
|
||||||
highlightVisibility={highlightVisibility}
|
highlightVisibility={highlightVisibility}
|
||||||
|
onHighlightCountClick={onOpenHighlights}
|
||||||
/>
|
/>
|
||||||
{isExternalVideo ? (
|
{isTextContent && articleText && (
|
||||||
<>
|
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
|
||||||
<div className="reader-video">
|
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
|
||||||
<ReactPlayer
|
</div>
|
||||||
url={selectedUrl as string}
|
)}
|
||||||
controls
|
{loading || !markdown && !html ? (
|
||||||
width="100%"
|
<div className="reader" aria-busy="true">
|
||||||
height="auto"
|
<ContentSkeleton />
|
||||||
style={{
|
</div>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : markdown || html ? (
|
) : markdown || html ? (
|
||||||
<>
|
<>
|
||||||
{markdown ? (
|
{markdown ? (
|
||||||
renderedMarkdownHtml && finalHtml ? (
|
renderedMarkdownHtml && finalHtml ? (
|
||||||
<div
|
<VideoEmbedProcessor
|
||||||
ref={contentRef}
|
key={`content:${contentKey}`}
|
||||||
className="reader-markdown"
|
ref={contentRef}
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
html={finalHtml}
|
||||||
onMouseUp={handleSelectionEnd}
|
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||||
onTouchEnd={handleSelectionEnd}
|
className="reader-markdown"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="reader-markdown">
|
<div className="reader-markdown">
|
||||||
<div className="loading-spinner">
|
<ContentSkeleton />
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<VideoEmbedProcessor
|
||||||
ref={contentRef}
|
key={`content:${contentKey}`}
|
||||||
className="reader-html"
|
ref={contentRef}
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
html={finalHtml || html || ''}
|
||||||
onMouseUp={handleSelectionEnd}
|
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||||
onTouchEnd={handleSelectionEnd}
|
className="reader-html"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Article menu for external URLs */}
|
{/* Article menu for external URLs */}
|
||||||
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
{!isNostrArticle && selectedUrl && (
|
||||||
<div className="article-menu-container">
|
<div className="article-menu-container">
|
||||||
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||||
<button
|
<button
|
||||||
@@ -824,13 +862,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<FontAwesomeIcon icon={faCopy} />
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
<span>Copy URL</span>
|
<span>Copy URL</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* Only show "Open Original" for actual external URLs, not nostr events */}
|
||||||
className="article-menu-item"
|
{!selectedUrl?.startsWith('nostr-event:') && (
|
||||||
onClick={handleOpenExternalUrl}
|
<button
|
||||||
>
|
className="article-menu-item"
|
||||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
onClick={handleOpenExternalUrl}
|
||||||
<span>Open Original</span>
|
>
|
||||||
</button>
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open Original</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="article-menu-item"
|
className="article-menu-item"
|
||||||
onClick={handleSearchExternalUrl}
|
onClick={handleSearchExternalUrl}
|
||||||
@@ -917,21 +958,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mark as Read button */}
|
{/* Archive button */}
|
||||||
{activeAccount && (
|
{activeAccount && (
|
||||||
<div className="mark-as-read-container">
|
<div className="mark-as-read-container">
|
||||||
<button
|
<button
|
||||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
disabled={isCheckingReadStatus}
|
||||||
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
title={isMarkedAsRead ? 'Already Archived' : 'Move to Archive'}
|
||||||
|
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||||
spin={isCheckingReadStatus}
|
spin={isCheckingReadStatus}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Archived' : 'Move to Archive'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -944,11 +986,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
<div className="reader empty">
|
|
||||||
<p>No readable content found for this URL.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
|
||||||
import { Models, Helpers } from 'applesauce-core'
|
|
||||||
import { decode } from 'nostr-tools/nip19'
|
|
||||||
import { extractNprofilePubkeys } from '../utils/helpers'
|
|
||||||
|
|
||||||
const { getPubkeyFromDecodeResult } = Helpers
|
|
||||||
|
|
||||||
interface Props { content: string }
|
|
||||||
|
|
||||||
const ContentWithResolvedProfiles: React.FC<Props> = ({ content }) => {
|
|
||||||
const matches = extractNprofilePubkeys(content)
|
|
||||||
const decoded = matches
|
|
||||||
.map((m) => {
|
|
||||||
try { return decode(m) } catch { return undefined as undefined }
|
|
||||||
})
|
|
||||||
.filter((v): v is ReturnType<typeof decode> => Boolean(v))
|
|
||||||
|
|
||||||
const lookups = decoded
|
|
||||||
.map((res) => getPubkeyFromDecodeResult(res))
|
|
||||||
.filter((v): v is string => typeof v === 'string')
|
|
||||||
|
|
||||||
const profiles = lookups.map((pubkey) => ({ pubkey, profile: useEventModel(Models.ProfileModel, [pubkey]) }))
|
|
||||||
|
|
||||||
let rendered = content
|
|
||||||
matches.forEach((m, i) => {
|
|
||||||
const pk = getPubkeyFromDecodeResult(decoded[i])
|
|
||||||
const found = profiles.find((p) => p.pubkey === pk)
|
|
||||||
const name = found?.profile?.name || found?.profile?.display_name || found?.profile?.nip05 || `${pk?.slice(0,8)}...`
|
|
||||||
if (name) rendered = rendered.replace(m, `@${name}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
return <div className="bookmark-content">{rendered}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContentWithResolvedProfiles
|
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ import { useSettings } from '../hooks/useSettings'
|
|||||||
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
|
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
import { contactsController } from '../services/contactsController'
|
import { contactsController } from '../services/contactsController'
|
||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
|
|
||||||
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||||
@@ -102,6 +103,27 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
|
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
|
||||||
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
|
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Reading Progress loading state
|
||||||
|
const [isLoadingReadingProgress, setIsLoadingReadingProgress] = useState(false)
|
||||||
|
const [readingProgressEvents, setReadingProgressEvents] = useState<NostrEvent[]>([])
|
||||||
|
const [tLoadReadingProgress, setTLoadReadingProgress] = useState<number | null>(null)
|
||||||
|
const [tFirstReadingProgress, setTFirstReadingProgress] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Mark-as-read reactions loading state
|
||||||
|
const [isLoadingMarkAsRead, setIsLoadingMarkAsRead] = useState(false)
|
||||||
|
const [markAsReadReactions, setMarkAsReadReactions] = useState<NostrEvent[]>([])
|
||||||
|
const [tLoadMarkAsRead, setTLoadMarkAsRead] = useState<number | null>(null)
|
||||||
|
const [tFirstMarkAsRead, setTFirstMarkAsRead] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Relay list loading state
|
||||||
|
const [isLoadingRelayList, setIsLoadingRelayList] = useState(false)
|
||||||
|
const [relayListEvents, setRelayListEvents] = useState<NostrEvent[]>([])
|
||||||
|
const [tLoadRelayList, setTLoadRelayList] = useState<number | null>(null)
|
||||||
|
const [tFirstRelayList, setTFirstRelayList] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Deduplicated reading progress from controller
|
||||||
|
const [deduplicatedProgressMap, setDeduplicatedProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Live timing state
|
// Live timing state
|
||||||
const [liveTiming, setLiveTiming] = useState<{
|
const [liveTiming, setLiveTiming] = useState<{
|
||||||
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
@@ -109,6 +131,9 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
loadBookmarks?: { startTime: number }
|
loadBookmarks?: { startTime: number }
|
||||||
decryptBookmarks?: { startTime: number }
|
decryptBookmarks?: { startTime: number }
|
||||||
loadHighlights?: { startTime: number }
|
loadHighlights?: { startTime: number }
|
||||||
|
loadReadingProgress?: { startTime: number }
|
||||||
|
loadMarkAsRead?: { startTime: number }
|
||||||
|
loadRelayList?: { startTime: number }
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
// Web of Trust state
|
// Web of Trust state
|
||||||
@@ -409,11 +434,7 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
|
|
||||||
const elapsed = Math.round(performance.now() - start)
|
const elapsed = Math.round(performance.now() - start)
|
||||||
setTLoadHighlights(elapsed)
|
setTLoadHighlights(elapsed)
|
||||||
setLiveTiming(prev => {
|
setLiveTiming(prev => ({ ...prev, loadHighlights: undefined }))
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
|
||||||
const { loadHighlights, ...rest } = prev
|
|
||||||
return rest
|
|
||||||
})
|
|
||||||
|
|
||||||
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
|
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -630,7 +651,9 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
return timeB - timeA
|
return timeB - timeA
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
100,
|
||||||
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
setWritingPosts(posts)
|
setWritingPosts(posts)
|
||||||
@@ -724,6 +747,209 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
setTFirstWriting(null)
|
setTFirstWriting(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLoadReadingProgress = async () => {
|
||||||
|
if (!relayPool || !eventStore || !activeAccount?.pubkey) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load reading progress')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingReadingProgress(true)
|
||||||
|
setReadingProgressEvents([])
|
||||||
|
setTLoadReadingProgress(null)
|
||||||
|
setTFirstReadingProgress(null)
|
||||||
|
setDeduplicatedProgressMap(new Map())
|
||||||
|
DebugBus.info('debug', 'Loading reading progress events...')
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadReadingProgress: { startTime: start } }))
|
||||||
|
|
||||||
|
const { queryEvents } = await import('../services/dataFetch')
|
||||||
|
const { KINDS } = await import('../config/kinds')
|
||||||
|
|
||||||
|
// Load raw events for display
|
||||||
|
const rawEvents: NostrEvent[] = []
|
||||||
|
const rawQueryPromise = queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstReadingProgress(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
rawEvents.push(evt)
|
||||||
|
setReadingProgressEvents([...rawEvents])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load deduplicated results via controller (includes articles and external URLs)
|
||||||
|
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
||||||
|
setDeduplicatedProgressMap(new Map(progressMap))
|
||||||
|
|
||||||
|
// Regression guard: ensure keys include both naddr and raw URL forms when present
|
||||||
|
try {
|
||||||
|
const keys = Array.from(progressMap.keys())
|
||||||
|
const sample = keys.slice(0, 5).join(', ')
|
||||||
|
DebugBus.info('debug', `Progress keys sample: ${sample}`)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Run both in parallel
|
||||||
|
await Promise.all([
|
||||||
|
rawQueryPromise,
|
||||||
|
readingProgressController.start({ relayPool, eventStore, pubkey: activeAccount.pubkey, force: true })
|
||||||
|
])
|
||||||
|
|
||||||
|
unsubProgress()
|
||||||
|
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadReadingProgress(elapsed)
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadReadingProgress: undefined }))
|
||||||
|
|
||||||
|
const finalMap = readingProgressController.getProgressMap()
|
||||||
|
DebugBus.info('debug', `Loaded ${rawEvents.length} raw events, deduplicated to ${finalMap.size} articles in ${elapsed}ms`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load reading progress:', err)
|
||||||
|
DebugBus.error('debug', `Failed to load reading progress: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingReadingProgress(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearReadingProgress = () => {
|
||||||
|
setReadingProgressEvents([])
|
||||||
|
setTLoadReadingProgress(null)
|
||||||
|
setTFirstReadingProgress(null)
|
||||||
|
setDeduplicatedProgressMap(new Map())
|
||||||
|
DebugBus.info('debug', 'Cleared reading progress data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadMarkAsReadReactions = async () => {
|
||||||
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load mark-as-read reactions')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingMarkAsRead(true)
|
||||||
|
setMarkAsReadReactions([])
|
||||||
|
setTLoadMarkAsRead(null)
|
||||||
|
setTFirstMarkAsRead(null)
|
||||||
|
DebugBus.info('debug', 'Loading mark-as-read reactions...')
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: { startTime: start } }))
|
||||||
|
|
||||||
|
const { queryEvents } = await import('../services/dataFetch')
|
||||||
|
const { ARCHIVE_EMOJI } = await import('../services/reactionService')
|
||||||
|
|
||||||
|
// Load both kind:7 (reactions to events) and kind:17 (reactions to URLs)
|
||||||
|
const [kind7Events, kind17Events] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [7], authors: [activeAccount.pubkey] }, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.content === ARCHIVE_EMOJI) {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstMarkAsRead(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setMarkAsReadReactions(prev => [...prev, evt])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
queryEvents(relayPool, { kinds: [17], authors: [activeAccount.pubkey] }, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.content === ARCHIVE_EMOJI) {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstMarkAsRead(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setMarkAsReadReactions(prev => [...prev, evt])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
const totalEvents = kind7Events.length + kind17Events.length
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadMarkAsRead(elapsed)
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: undefined }))
|
||||||
|
|
||||||
|
DebugBus.info('debug', `Loaded ${totalEvents} mark-as-read reactions in ${elapsed}ms`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load mark-as-read reactions:', err)
|
||||||
|
DebugBus.error('debug', `Failed to load mark-as-read reactions: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMarkAsRead(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearMarkAsRead = () => {
|
||||||
|
setMarkAsReadReactions([])
|
||||||
|
setTLoadMarkAsRead(null)
|
||||||
|
setTFirstMarkAsRead(null)
|
||||||
|
DebugBus.info('debug', 'Cleared mark-as-read reactions data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadRelayList = async () => {
|
||||||
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load relay list')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingRelayList(true)
|
||||||
|
setRelayListEvents([])
|
||||||
|
setTLoadRelayList(null)
|
||||||
|
setTFirstRelayList(null)
|
||||||
|
DebugBus.info('debug', 'Loading relay list (kind 10002)...')
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadRelayList: { startTime: start } }))
|
||||||
|
|
||||||
|
const { queryEvents } = await import('../services/dataFetch')
|
||||||
|
|
||||||
|
// Query for kind:10002 (relay list)
|
||||||
|
const events = await queryEvents(relayPool, {
|
||||||
|
kinds: [10002],
|
||||||
|
authors: [activeAccount.pubkey],
|
||||||
|
limit: 10
|
||||||
|
}, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstRelayList(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setRelayListEvents(prev => [...prev, evt])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadRelayList(elapsed)
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadRelayList: undefined }))
|
||||||
|
|
||||||
|
DebugBus.info('debug', `Loaded ${events.length} relay list events in ${elapsed}ms`)
|
||||||
|
|
||||||
|
// Log details about the events
|
||||||
|
events.forEach((event, index) => {
|
||||||
|
const relayCount = event.tags.filter(tag => tag[0] === 'r').length
|
||||||
|
DebugBus.info('debug', `Event ${index + 1}: ${relayCount} relays, created ${new Date(event.created_at * 1000).toISOString()}`)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load relay list:', err)
|
||||||
|
DebugBus.error('debug', `Failed to load relay list: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingRelayList(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearRelayList = () => {
|
||||||
|
setRelayListEvents([])
|
||||||
|
setTLoadRelayList(null)
|
||||||
|
setTFirstRelayList(null)
|
||||||
|
DebugBus.info('debug', 'Cleared relay list data')
|
||||||
|
}
|
||||||
|
|
||||||
const handleLoadFriendsList = async () => {
|
const handleLoadFriendsList = async () => {
|
||||||
if (!relayPool || !activeAccount?.pubkey) {
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
DebugBus.warn('debug', 'Please log in to load friends list')
|
DebugBus.warn('debug', 'Please log in to load friends list')
|
||||||
@@ -1348,6 +1574,260 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reading Progress Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Reading Progress Loading</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Test reading progress loading (kind: 39802) for the logged-in user</div>
|
||||||
|
<div className="flex gap-2 mb-3 items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadReadingProgress}
|
||||||
|
disabled={isLoadingReadingProgress || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
{isLoadingReadingProgress ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Reading Progress'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ml-auto"
|
||||||
|
onClick={handleClearReadingProgress}
|
||||||
|
disabled={readingProgressEvents.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex gap-2 flex-wrap">
|
||||||
|
<Stat label="total" value={tLoadReadingProgress} />
|
||||||
|
<Stat label="first event" value={tFirstReadingProgress} />
|
||||||
|
</div>
|
||||||
|
{readingProgressEvents.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Reading Progress ({readingProgressEvents.length}):</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{readingProgressEvents.map((evt, idx) => {
|
||||||
|
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||||
|
const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1]
|
||||||
|
const content = evt.content || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">Reading Progress #{idx + 1}</div>
|
||||||
|
<div className="opacity-70 mb-1">
|
||||||
|
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||||
|
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
{dTag && <div>d-tag: {dTag}</div>}
|
||||||
|
{aTag && <div className="text-[11px] opacity-70">#a: {aTag}</div>}
|
||||||
|
{content && <div>Progress: {content}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deduplicatedProgressMap.size > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Deduplicated Reading Progress ({deduplicatedProgressMap.size} articles):</div>
|
||||||
|
|
||||||
|
{/* Category breakdown */}
|
||||||
|
<div className="mb-3 p-2 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-700">
|
||||||
|
<div className="text-sm font-semibold mb-2">Breakdown by Category:</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(() => {
|
||||||
|
let unopened = 0, started = 0, reading = 0, completed = 0
|
||||||
|
for (const progress of deduplicatedProgressMap.values()) {
|
||||||
|
if (progress === 0) unopened++
|
||||||
|
else if (progress > 0 && progress <= 0.10) started++
|
||||||
|
else if (progress > 0.10 && progress <= 0.94) reading++
|
||||||
|
else if (progress >= 0.95) completed++
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span>Unopened (0%):</span>
|
||||||
|
<span className="font-semibold">{unopened}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span>Started (0% < progress ≤ 10%):</span>
|
||||||
|
<span className="font-semibold">{started}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs bg-green-100 dark:bg-green-900/30 px-1 py-0.5 rounded">
|
||||||
|
<span>Reading (10% < progress ≤ 94%) ✓:</span>
|
||||||
|
<span className="font-semibold text-green-700 dark:text-green-400">{reading}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span>Completed (≥ 95%):</span>
|
||||||
|
<span className="font-semibold">{completed}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{Array.from(deduplicatedProgressMap.entries()).map(([articleId, progress], idx) => {
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-700">
|
||||||
|
<div className="font-semibold mb-1">Article #{idx + 1}</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="break-all">ID: {articleId}</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="text-[11px] opacity-70">Progress: {(progress * 100).toFixed(1)}%</div>
|
||||||
|
<div className="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5 mt-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-full"
|
||||||
|
style={{ width: `${progress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mark-as-read Reactions Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Mark-as-read Reactions Loading</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Test loading mark-as-read reactions (kind: 7 and 17) with the ARCHIVE_EMOJI for the logged-in user</div>
|
||||||
|
<div className="flex gap-2 mb-3 items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadMarkAsReadReactions}
|
||||||
|
disabled={isLoadingMarkAsRead || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
{isLoadingMarkAsRead ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Mark-as-read Reactions'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ml-auto"
|
||||||
|
onClick={handleClearMarkAsRead}
|
||||||
|
disabled={markAsReadReactions.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex gap-2 flex-wrap">
|
||||||
|
<Stat label="total" value={tLoadMarkAsRead} />
|
||||||
|
<Stat label="first event" value={tFirstMarkAsRead} />
|
||||||
|
</div>
|
||||||
|
{markAsReadReactions.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Mark-as-read Reactions ({markAsReadReactions.length}):</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{markAsReadReactions.map((evt, idx) => {
|
||||||
|
const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1]
|
||||||
|
const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1]
|
||||||
|
const pTag = evt.tags?.find((t: string[]) => t[0] === 'p')?.[1]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">Mark-as-read Reaction #{idx + 1}</div>
|
||||||
|
<div className="opacity-70 mb-1">
|
||||||
|
<div>Kind: {evt.kind}</div>
|
||||||
|
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||||
|
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div>Emoji: {evt.content}</div>
|
||||||
|
{eTag && <div className="text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
|
||||||
|
{rTag && <div className="text-[11px] opacity-70">#r: {rTag.length > 60 ? rTag.substring(0, 60) + '...' : rTag}</div>}
|
||||||
|
{pTag && <div className="text-[11px] opacity-70">#p: {pTag.slice(0, 16)}...</div>}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Relay List Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Relay List Loading (kind 10002)</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Load your relay list to debug dynamic relay integration:</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-3 items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadRelayList}
|
||||||
|
disabled={isLoadingRelayList || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
{isLoadingRelayList ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Relay List'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ml-auto"
|
||||||
|
onClick={handleClearRelayList}
|
||||||
|
disabled={relayListEvents.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mb-3 text-sm">
|
||||||
|
<Stat label="total" value={tLoadRelayList} />
|
||||||
|
<Stat label="first event" value={tFirstRelayList} />
|
||||||
|
</div>
|
||||||
|
{relayListEvents.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Relay List Events ({relayListEvents.length}):</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{relayListEvents.map((evt, idx) => {
|
||||||
|
const relayTags = evt.tags?.filter((t: string[]) => t[0] === 'r') || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">Relay List Event #{idx + 1}</div>
|
||||||
|
<div className="opacity-70 mb-1">
|
||||||
|
<div>Kind: {evt.kind}</div>
|
||||||
|
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||||
|
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||||
|
<div>Relays: {relayTags.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="text-[11px] opacity-70 mb-1">Relay URLs:</div>
|
||||||
|
{relayTags.map((tag, tagIdx) => (
|
||||||
|
<div key={tagIdx} className="text-[10px] opacity-60 break-all">
|
||||||
|
{tag[1]} {tag[2] ? `(${tag[2]})` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Web of Trust Section */}
|
{/* Web of Trust Section */}
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Web of Trust</h3>
|
<h3 className="section-title">Web of Trust</h3>
|
||||||
|
|||||||
1
src/components/EventViewer.tsx
Normal file
1
src/components/EventViewer.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faPersonHiking, faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore, Helpers } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { nip19, NostrEvent } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { fetchContacts } from '../services/contactService'
|
// Contacts are managed via controller subscription
|
||||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
@@ -19,20 +19,22 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
import { getCachedPosts, setCachedPosts, getCachedHighlights, setCachedHighlights } from '../services/exploreCache'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
import { HighlightVisibility } from './HighlightsPanel'
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
import { KINDS } from '../config/kinds'
|
// import { KINDS } from '../config/kinds'
|
||||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
// import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
// import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||||
import { readingProgressController } from '../services/readingProgressController'
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { contactsController } from '../services/contactsController'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
// Accessors from Helpers (currently unused here)
|
||||||
|
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
interface ExploreProps {
|
interface ExploreProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -55,35 +57,53 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
||||||
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
||||||
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
||||||
|
const hasHydratedRef = useRef(false)
|
||||||
|
|
||||||
// Get myHighlights directly from controller
|
// Get myHighlights directly from controller
|
||||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
|
||||||
// Remove unused loading state to avoid warnings
|
// Remove unused loading state to avoid warnings
|
||||||
|
|
||||||
// Reading progress state (naddr -> progress 0-1)
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Load cached content from event store (instant display)
|
// Load cached content from event store (instant display)
|
||||||
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
// const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||||
|
|
||||||
const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
// const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
||||||
event,
|
// event,
|
||||||
title: getArticleTitle(event) || 'Untitled',
|
// title: getArticleTitle(event) || 'Untitled',
|
||||||
summary: getArticleSummary(event),
|
// summary: getArticleSummary(event),
|
||||||
image: getArticleImage(event),
|
// image: getArticleImage(event),
|
||||||
published: getArticlePublished(event),
|
// published: getArticlePublished(event),
|
||||||
author: event.pubkey
|
// author: event.pubkey
|
||||||
}), [])
|
// }), [])
|
||||||
|
|
||||||
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
// const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Visibility filters (defaults from settings or nostrverse when logged out)
|
// Visibility filters - load from localStorage first, fallback to settings
|
||||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
const [visibility, setVisibility] = useState<HighlightVisibility>(() => {
|
||||||
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
// Try to load from localStorage first
|
||||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
try {
|
||||||
mine: settings?.defaultExploreScopeMine ?? false
|
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
|
// Ensure at least one scope remains active
|
||||||
@@ -93,6 +113,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
if (!next.nostrverse && !next.friends && !next.mine) {
|
if (!next.nostrverse && !next.friends && !next.mine) {
|
||||||
return prev // ignore toggle that would disable all scopes
|
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
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -105,6 +131,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 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
|
// Subscribe to nostrverse highlights controller for global stream
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const apply = (incoming: Highlight[]) => {
|
const apply = (incoming: Highlight[]) => {
|
||||||
@@ -206,18 +247,44 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
|
|
||||||
// Update visibility when settings/login state changes
|
// Update visibility when settings/login state changes
|
||||||
useEffect(() => {
|
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) {
|
if (!activeAccount) {
|
||||||
// When logged out, show nostrverse by default
|
// When logged out, show nostrverse by default
|
||||||
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
|
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
|
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
|
||||||
setHasLoadedNostrverseHighlights(true)
|
setHasLoadedNostrverseHighlights(true)
|
||||||
} else {
|
} else {
|
||||||
// When logged in, use settings defaults immediately
|
// When logged in, use settings defaults immediately
|
||||||
setVisibility({
|
const defaultVisibility = {
|
||||||
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||||
mine: settings?.defaultExploreScopeMine ?? false
|
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)
|
setHasLoadedNostrverse(false)
|
||||||
setHasLoadedNostrverseHighlights(false)
|
setHasLoadedNostrverseHighlights(false)
|
||||||
}
|
}
|
||||||
@@ -230,242 +297,95 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
}, [propActiveTab])
|
}, [propActiveTab])
|
||||||
|
|
||||||
useEffect(() => {
|
// Load initial data and refresh on triggers
|
||||||
const loadData = async () => {
|
const loadData = useCallback(() => {
|
||||||
try {
|
if (!relayPool) return
|
||||||
// begin load, but do not block rendering
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
// If not logged in, only fetch nostrverse content with streaming posts
|
// Seed from cache for instant UI
|
||||||
if (!activeAccount) {
|
if (activeAccount) {
|
||||||
// Logged out: rely entirely on centralized controllers; do not fetch here
|
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||||
setLoading(false)
|
if (cachedPosts && cachedPosts.length > 0) setBlogPosts(cachedPosts)
|
||||||
}
|
const cached = getCachedHighlights(activeAccount.pubkey)
|
||||||
|
if (cached && cached.length > 0) setHighlights(cached)
|
||||||
|
}
|
||||||
|
|
||||||
// Seed from in-memory cache if available to avoid empty flash
|
setLoading(true)
|
||||||
const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
|
|
||||||
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
|
|
||||||
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
|
|
||||||
}
|
|
||||||
const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
|
|
||||||
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
|
|
||||||
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed with cached content from event store (instant display)
|
|
||||||
if (cachedHighlights.length > 0 || myHighlights.length > 0) {
|
|
||||||
const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights])
|
|
||||||
setHighlights(prev => {
|
|
||||||
const all = dedupeHighlightsById([...prev, ...merged])
|
|
||||||
return all.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed with cached writings from event store
|
|
||||||
if (cachedWritings.length > 0) {
|
|
||||||
setBlogPosts(prev => {
|
|
||||||
const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings])
|
|
||||||
return all.sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// At this point, we have seeded any available data; lift the loading state
|
try {
|
||||||
setLoading(false)
|
// Prepare parallel fetches
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
|
||||||
// Fetch the user's contacts (friends)
|
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
|
||||||
const contacts = await fetchContacts(
|
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
|
||||||
|
fetchNostrverseBlogPosts(
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount?.pubkey || '',
|
relayUrls,
|
||||||
(partial) => {
|
50,
|
||||||
// Store followed pubkeys for highlight classification
|
eventStore || undefined,
|
||||||
setFollowedPubkeys(partial)
|
(post) => {
|
||||||
// When local contacts are available, kick off early fetch
|
|
||||||
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) => {
|
|
||||||
// Deduplicate by author:d-tag (replaceable event key)
|
|
||||||
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 exists, only replace if this one is newer
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
const existing = prev[existingIndex]
|
|
||||||
if (post.event.created_at <= existing.event.created_at) {
|
|
||||||
return prev // Keep existing (newer or same)
|
|
||||||
}
|
|
||||||
// Replace with newer version
|
|
||||||
const next = [...prev]
|
|
||||||
next[existingIndex] = 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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// New post, add it
|
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
|
||||||
}
|
|
||||||
).then((all) => {
|
|
||||||
setBlogPosts((prev) => {
|
|
||||||
// Deduplicate by author:d-tag (replaceable event key)
|
|
||||||
const byKey = new Map<string, BlogPostPreview>()
|
|
||||||
|
|
||||||
// Add existing posts
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge in new posts (keeping newer versions)
|
|
||||||
for (const post of all) {
|
|
||||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
const key = `${post.author}:${dTag}`
|
|
||||||
const existing = byKey.get(key)
|
|
||||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
|
||||||
byKey.set(key, post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = Array.from(byKey.values()).sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
if (activeAccount) 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)
|
|
||||||
})
|
|
||||||
if (activeAccount) 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)
|
|
||||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
|
||||||
return merged
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Always proceed to load nostrverse content even if no contacts
|
|
||||||
// (removed blocking error for empty contacts)
|
|
||||||
|
|
||||||
// Store final followed pubkeys
|
|
||||||
setFollowedPubkeys(contacts)
|
|
||||||
|
|
||||||
// Fetch friends content and (optionally) nostrverse + mine content in parallel
|
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
||||||
const contactsArray = Array.from(contacts)
|
|
||||||
// Use centralized writingsController for my posts (non-blocking)
|
|
||||||
// pull from writingsController; no need to store promise
|
|
||||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
|
||||||
setHasLoadedMine(true)
|
|
||||||
const nostrversePostsPromise = visibility.nostrverse
|
|
||||||
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => {
|
|
||||||
// Stream nostrverse posts too when logged in
|
|
||||||
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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
: Promise.resolve([] as BlogPostPreview[])
|
|
||||||
|
|
||||||
// Fire non-blocking fetches and merge as they resolve
|
|
||||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
|
|
||||||
.then((friendsPosts) => {
|
|
||||||
setBlogPosts(prev => {
|
setBlogPosts(prev => {
|
||||||
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||||
const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted)
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
// Pre-cache profiles in background
|
|
||||||
const authorPubkeys = Array.from(new Set(sorted.map(p => p.author)))
|
|
||||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
|
||||||
return sorted
|
|
||||||
})
|
})
|
||||||
}).catch(() => {})
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
|
}
|
||||||
fetchHighlightsFromAuthors(relayPool, contactsArray)
|
).then((nostrversePosts) => {
|
||||||
.then((friendsHighlights) => {
|
|
||||||
setHighlights(prev => {
|
|
||||||
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
|
|
||||||
const sorted = merged.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, sorted)
|
|
||||||
return sorted
|
|
||||||
})
|
|
||||||
}).catch(() => {})
|
|
||||||
|
|
||||||
nostrversePostsPromise.then((nostrversePosts) => {
|
|
||||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
}
|
||||||
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
|
||||||
.then((nostriverseHighlights) => {
|
|
||||||
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
|
|
||||||
}).catch(() => {})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
console.error('Failed to load data:', err)
|
||||||
// No blocking error - user can pull-to-refresh
|
// No blocking error - user can pull-to-refresh
|
||||||
} finally {
|
} finally {
|
||||||
// loading is already turned off after seeding
|
// loading is already turned off after seeding
|
||||||
}
|
}
|
||||||
}
|
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [loadData, refreshTrigger])
|
||||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
|
||||||
|
// 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)
|
// Lazy-load nostrverse writings when user toggles it on (logged in)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -509,7 +429,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
})
|
})
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse])
|
|
||||||
|
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)
|
// Lazy-load nostrverse highlights when user toggles it on (logged in)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -586,6 +511,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const publishedTime = post.published || post.event.created_at
|
const publishedTime = post.published || post.event.created_at
|
||||||
if (publishedTime > maxFutureTime) return false
|
if (publishedTime > maxFutureTime) return false
|
||||||
|
|
||||||
|
// Hide bot authors by profile display name if setting enabled
|
||||||
|
if (settings?.hideBotArticlesByName !== false) {
|
||||||
|
// Profile resolution and filtering is handled in BlogPostCard via ProfileModel
|
||||||
|
// Keep list intact here; individual cards will render null if author is a bot
|
||||||
|
}
|
||||||
|
|
||||||
// Apply visibility filters
|
// Apply visibility filters
|
||||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||||
const isFriend = followedPubkeys.has(post.author)
|
const isFriend = followedPubkeys.has(post.author)
|
||||||
@@ -604,7 +535,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||||
return { ...post, level }
|
return { ...post, level }
|
||||||
})
|
})
|
||||||
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
|
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility, settings?.hideBotArticlesByName])
|
||||||
|
|
||||||
// Helper to get reading progress for a post
|
// Helper to get reading progress for a post
|
||||||
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||||
@@ -641,8 +572,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return filteredBlogPosts.length === 0 ? (
|
return filteredBlogPosts.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-grid">
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
@@ -653,6 +586,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
href={getPostUrl(post)}
|
href={getPostUrl(post)}
|
||||||
level={post.level}
|
level={post.level}
|
||||||
readingProgress={getReadingProgress(post)}
|
readingProgress={getReadingProgress(post)}
|
||||||
|
hideBotByName={settings?.hideBotArticlesByName !== false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -661,7 +595,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
case 'highlights':
|
case 'highlights':
|
||||||
if (showSkeletons) {
|
if (showSkeletons) {
|
||||||
return (
|
return (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid single-column">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
<HighlightSkeleton key={i} />
|
<HighlightSkeleton key={i} />
|
||||||
))}
|
))}
|
||||||
@@ -673,7 +607,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
<span>No highlights to show for the selected scope.</span>
|
<span>No highlights to show for the selected scope.</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid single-column">
|
||||||
{classifiedHighlights.map((highlight) => (
|
{classifiedHighlights.map((highlight) => (
|
||||||
<HighlightItem
|
<HighlightItem
|
||||||
key={highlight.id}
|
key={highlight.id}
|
||||||
@@ -701,7 +635,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<div className="explore-header">
|
<div className="explore-header">
|
||||||
<h1>
|
<h1>
|
||||||
<FontAwesomeIcon icon={faNewspaper} />
|
<FontAwesomeIcon icon={faPersonHiking} />
|
||||||
Explore
|
Explore
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -773,7 +707,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div key={activeTab}>
|
<div>
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightBut
|
|||||||
className="highlight-fab"
|
className="highlight-fab"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: '32px',
|
bottom: '80px',
|
||||||
right: '32px',
|
right: '32px',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
width: '56px',
|
width: '56px',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Models } from 'applesauce-core'
|
|||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { fetchArticleTitle } from '../services/articleTitleResolver'
|
import { fetchArticleTitle } from '../services/articleTitleResolver'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface HighlightCitationProps {
|
interface HighlightCitationProps {
|
||||||
highlight: Highlight
|
highlight: Highlight
|
||||||
@@ -44,6 +45,12 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
|||||||
try {
|
try {
|
||||||
if (!highlight.eventReference) return
|
if (!highlight.eventReference) return
|
||||||
|
|
||||||
|
// Skip if it's a raw event ID (hex string without colons)
|
||||||
|
// Raw event IDs cannot be decoded to nadrs without additional context
|
||||||
|
if (!highlight.eventReference.includes(':') && !highlight.eventReference.startsWith('naddr')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Convert eventReference to naddr if needed
|
// Convert eventReference to naddr if needed
|
||||||
let naddr: string
|
let naddr: string
|
||||||
if (highlight.eventReference.includes(':')) {
|
if (highlight.eventReference.includes(':')) {
|
||||||
@@ -73,7 +80,8 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
|||||||
loadTitle()
|
loadTitle()
|
||||||
}, [highlight.eventReference, relayPool])
|
}, [highlight.eventReference, relayPool])
|
||||||
|
|
||||||
const authorName = authorProfile?.name || authorProfile?.display_name
|
// Use centralized profile display name utility
|
||||||
|
const authorName = authorPubkey ? getProfileDisplayName(authorProfile, authorPubkey) : undefined
|
||||||
|
|
||||||
// For nostr-native content with article reference
|
// For nostr-native content with article reference
|
||||||
if (highlight.eventReference && (authorName || articleTitle)) {
|
if (highlight.eventReference && (authorName || articleTitle)) {
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models, IEventStore } from 'applesauce-core'
|
import { Models, IEventStore } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
|
||||||
import { areAllRelaysLocal } from '../utils/helpers'
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import { isContentRelay, getContentRelays, getFallbackContentRelays } from '../config/relays'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||||
import { createDeletionRequest } from '../services/deletionService'
|
import { createDeletionRequest } from '../services/deletionService'
|
||||||
@@ -17,6 +18,8 @@ import { getNostrUrl } from '../config/nostrGateways'
|
|||||||
import CompactButton from './CompactButton'
|
import CompactButton from './CompactButton'
|
||||||
import { HighlightCitation } from './HighlightCitation'
|
import { HighlightCitation } from './HighlightCitation'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import NostrMentionLink from './NostrMentionLink'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
// Helper to detect if a URL is an image
|
// Helper to detect if a URL is an image
|
||||||
const isImageUrl = (url: string): boolean => {
|
const isImageUrl = (url: string): boolean => {
|
||||||
@@ -29,99 +32,6 @@ const isImageUrl = (url: string): boolean => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to render a nostr identifier
|
|
||||||
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
|
|
||||||
try {
|
|
||||||
// Remove nostr: prefix
|
|
||||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
|
||||||
const decoded = nip19.decode(identifier)
|
|
||||||
|
|
||||||
switch (decoded.type) {
|
|
||||||
case 'npub': {
|
|
||||||
const pubkey = decoded.data
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={index}
|
|
||||||
href={`/p/${nip19.npubEncode(pubkey)}`}
|
|
||||||
className="highlight-comment-link"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
@{pubkey.slice(0, 8)}...
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'nprofile': {
|
|
||||||
const { pubkey } = decoded.data
|
|
||||||
const npub = nip19.npubEncode(pubkey)
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={index}
|
|
||||||
href={`/p/${npub}`}
|
|
||||||
className="highlight-comment-link"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
@{pubkey.slice(0, 8)}...
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'naddr': {
|
|
||||||
const { kind, pubkey, identifier } = decoded.data
|
|
||||||
// Check if it's a blog post (kind:30023)
|
|
||||||
if (kind === 30023) {
|
|
||||||
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={index}
|
|
||||||
href={`/a/${naddr}`}
|
|
||||||
className="highlight-comment-link"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{identifier || 'Article'}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// For other kinds, show shortened identifier
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
nostr:{identifier.slice(0, 12)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'note': {
|
|
||||||
const eventId = decoded.data
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
note:{eventId.slice(0, 12)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'nevent': {
|
|
||||||
const { id } = decoded.data
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
event:{id.slice(0, 12)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Fallback for unrecognized types
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
{identifier.slice(0, 20)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If decoding fails, show shortened identifier
|
|
||||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
{identifier.slice(0, 20)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component to render comment with links, inline images, and nostr identifiers
|
// Component to render comment with links, inline images, and nostr identifiers
|
||||||
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
||||||
// Pattern to match both http(s) URLs and nostr: URIs
|
// Pattern to match both http(s) URLs and nostr: URIs
|
||||||
@@ -131,9 +41,15 @@ const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{parts.map((part, index) => {
|
{parts.map((part, index) => {
|
||||||
// Handle nostr: URIs
|
// Handle nostr: URIs - now with profile resolution
|
||||||
if (part.startsWith('nostr:')) {
|
if (part.startsWith('nostr:')) {
|
||||||
return renderNostrId(part, index)
|
return (
|
||||||
|
<NostrMentionLink
|
||||||
|
key={index}
|
||||||
|
nostrUri={part}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle http(s) URLs
|
// Handle http(s) URLs
|
||||||
@@ -200,7 +116,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
const itemRef = useRef<HTMLDivElement>(null)
|
const itemRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
||||||
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
|
||||||
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
@@ -214,17 +129,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
|
|
||||||
// Get display name for the user
|
// Get display name for the user
|
||||||
const getUserDisplayName = () => {
|
const getUserDisplayName = () => {
|
||||||
if (profile?.name) return profile.name
|
return getProfileDisplayName(profile, highlight.pubkey)
|
||||||
if (profile?.display_name) return profile.display_name
|
|
||||||
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update offline indicator when highlight prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (highlight.isOfflineCreated && !isSyncing) {
|
|
||||||
setShowOfflineIndicator(true)
|
|
||||||
}
|
|
||||||
}, [highlight.isOfflineCreated, isSyncing])
|
|
||||||
|
|
||||||
// Listen to sync state changes
|
// Listen to sync state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -233,13 +140,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
setIsSyncing(syncingState)
|
setIsSyncing(syncingState)
|
||||||
// When sync completes successfully, update highlight to show all relays
|
// When sync completes successfully, update highlight to show all relays
|
||||||
if (!syncingState) {
|
if (!syncingState) {
|
||||||
setShowOfflineIndicator(false)
|
|
||||||
|
|
||||||
// Update the highlight with all relays after successful sync
|
// Update the highlight with all relays after successful sync
|
||||||
if (onHighlightUpdate && highlight.isLocalOnly) {
|
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
|
||||||
const updatedHighlight = {
|
const updatedHighlight = {
|
||||||
...highlight,
|
...highlight,
|
||||||
publishedRelays: RELAYS,
|
publishedRelays: getActiveRelayUrls(relayPool),
|
||||||
isLocalOnly: false,
|
isLocalOnly: false,
|
||||||
isOfflineCreated: false
|
isOfflineCreated: false
|
||||||
}
|
}
|
||||||
@@ -250,7 +155,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [highlight, onHighlightUpdate])
|
}, [highlight, onHighlightUpdate, relayPool])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSelected && itemRef.current) {
|
if (isSelected && itemRef.current) {
|
||||||
@@ -275,14 +180,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
}, [showMenu, showDeleteConfirm])
|
}, [showMenu, showDeleteConfirm])
|
||||||
|
|
||||||
const handleItemClick = () => {
|
// Navigate to the article that this highlight references and scroll to the highlight
|
||||||
// If onHighlightClick is provided, use it (legacy behavior)
|
const navigateToArticle = () => {
|
||||||
if (onHighlightClick) {
|
// Always try to navigate if we have a reference - quote button should always work
|
||||||
onHighlightClick(highlight.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, navigate to the article that this highlight references
|
|
||||||
if (highlight.eventReference) {
|
if (highlight.eventReference) {
|
||||||
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
||||||
const parts = highlight.eventReference.split(':')
|
const parts = highlight.eventReference.split(':')
|
||||||
@@ -298,21 +198,79 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
pubkey,
|
pubkey,
|
||||||
identifier
|
identifier
|
||||||
})
|
})
|
||||||
navigate(`/a/${naddr}`)
|
// Pass highlight ID in navigation state to trigger scroll
|
||||||
|
navigate(`/a/${naddr}`, {
|
||||||
|
state: {
|
||||||
|
highlightId: highlight.id,
|
||||||
|
openHighlights: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (highlight.urlReference) {
|
// If eventReference is just an event ID (not a coordinate), we can't navigate to it
|
||||||
// Navigate to external URL
|
// as we don't have enough info to construct the article URL
|
||||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (highlight.urlReference) {
|
||||||
|
// Navigate to external URL with highlight ID to trigger scroll
|
||||||
|
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
||||||
|
state: {
|
||||||
|
highlightId: highlight.id,
|
||||||
|
openHighlights: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, there's no valid reference to navigate to
|
||||||
|
// This shouldn't happen for valid highlights, but we'll log it for debugging
|
||||||
|
console.warn('Cannot navigate to article: highlight has no valid eventReference or urlReference', highlight.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
navigateToArticle()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHighlightLinks = () => {
|
const getHighlightLinks = () => {
|
||||||
// Encode the highlight event itself (kind 9802) as a nevent
|
// Encode the highlight event itself (kind 9802) as a nevent
|
||||||
// Get non-local relays for the hint
|
// Relay hint selection priority:
|
||||||
const relayHints = RELAYS.filter(r =>
|
// 1. Published relays (where we successfully published the event)
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
// 2. Seen relays (where we observed the event)
|
||||||
).slice(0, 3) // Include up to 3 relay hints
|
// 3. Configured content relays (deterministic fallback)
|
||||||
|
// All candidates are deduplicated, filtered to content-capable remote relays, and limited to 3
|
||||||
|
|
||||||
|
const publishedRelays = highlight.publishedRelays || []
|
||||||
|
const seenOnRelays = highlight.seenOnRelays || []
|
||||||
|
|
||||||
|
// Determine base candidates: prefer published, then seen, then configured relays
|
||||||
|
let candidates: string[]
|
||||||
|
if (publishedRelays.length > 0) {
|
||||||
|
// Prefer published relays, but include seen relays as backup
|
||||||
|
candidates = Array.from(new Set([...publishedRelays, ...seenOnRelays]))
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
} else if (seenOnRelays.length > 0) {
|
||||||
|
candidates = seenOnRelays
|
||||||
|
} else {
|
||||||
|
// Fallback to deterministic configured content relays
|
||||||
|
const contentRelays = getContentRelays()
|
||||||
|
const fallbackRelays = getFallbackContentRelays()
|
||||||
|
candidates = Array.from(new Set([...contentRelays, ...fallbackRelays]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to content-capable remote relays (exclude local and non-content relays)
|
||||||
|
// Then take up to 3 for relay hints
|
||||||
|
const relayHints = candidates
|
||||||
|
.filter(url => !isLocalRelay(url))
|
||||||
|
.filter(url => isContentRelay(url))
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
const nevent = nip19.neventEncode({
|
const nevent = nip19.neventEncode({
|
||||||
id: highlight.id,
|
id: highlight.id,
|
||||||
@@ -346,7 +304,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Publish to all configured relays - let the relay pool handle connection state
|
// Publish to all configured relays - let the relay pool handle connection state
|
||||||
const targetRelays = RELAYS
|
const targetRelays = getActiveRelayUrls(relayPool)
|
||||||
|
|
||||||
|
|
||||||
await relayPool.publish(targetRelays, event)
|
await relayPool.publish(targetRelays, event)
|
||||||
@@ -366,9 +324,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
onHighlightUpdate(updatedHighlight)
|
onHighlightUpdate(updatedHighlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setShowOfflineIndicator(false)
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to rebroadcast:', error)
|
console.error('❌ Failed to rebroadcast:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -387,8 +342,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show relay list, use plane icon for local-only
|
// Check if this highlight was only published to local relays
|
||||||
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
let isLocalOnly = highlight.isLocalOnly
|
||||||
|
const publishedRelays = highlight.publishedRelays || []
|
||||||
|
|
||||||
|
// Fallback 1: Check if this highlight was marked for offline sync (flight mode)
|
||||||
|
if (isLocalOnly === undefined) {
|
||||||
|
if (isEventOfflineCreated(highlight.id)) {
|
||||||
|
isLocalOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback 2: If publishedRelays only contains local relays, it's local-only
|
||||||
|
if (isLocalOnly === undefined && publishedRelays.length > 0) {
|
||||||
|
const hasOnlyLocalRelays = publishedRelays.every(url => isLocalRelay(url))
|
||||||
|
const hasRemoteRelays = publishedRelays.some(url => !isLocalRelay(url))
|
||||||
|
if (hasOnlyLocalRelays && !hasRemoteRelays) {
|
||||||
|
isLocalOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// If isLocalOnly is true (from any fallback), show airplane icon
|
||||||
|
if (isLocalOnly === true) {
|
||||||
|
return {
|
||||||
|
icon: faPlane,
|
||||||
|
tooltip: publishedRelays.length > 0
|
||||||
|
? 'Local relays only - will sync when remote relays available'
|
||||||
|
: 'Created in flight mode - will sync when remote relays available',
|
||||||
|
spin: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show highlighter icon with relay info if available
|
// Show highlighter icon with relay info if available
|
||||||
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
||||||
@@ -396,7 +380,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
icon: isLocalOrOffline ? faPlane : faHighlighter,
|
icon: faHighlighter,
|
||||||
tooltip: relayNames.join('\n'),
|
tooltip: relayNames.join('\n'),
|
||||||
spin: false
|
spin: false
|
||||||
}
|
}
|
||||||
@@ -414,7 +398,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: show all relays we queried (where this was likely fetched from)
|
// Fallback: show all relays we queried (where this was likely fetched from)
|
||||||
const relayNames = RELAYS.map(url =>
|
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||||
|
const relayNames = activeRelays.map(url =>
|
||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -491,6 +476,71 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
handleConfirmDelete()
|
handleConfirmDelete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navigate to author's profile
|
||||||
|
const navigateToProfile = (tab?: 'highlights' | 'writings') => {
|
||||||
|
try {
|
||||||
|
const npub = nip19.npubEncode(highlight.pubkey)
|
||||||
|
const path = tab === 'writings' ? `/p/${npub}/writings` : `/p/${npub}`
|
||||||
|
navigate(path)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to encode npub for profile navigation:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAuthorClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigateToProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuViewProfile = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowMenu(false)
|
||||||
|
navigateToProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuGoToQuote = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowMenu(false)
|
||||||
|
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
} else {
|
||||||
|
navigateToArticle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderHighlightText = () => {
|
||||||
|
const { content, context } = highlight
|
||||||
|
|
||||||
|
if (context && context.length > 0) {
|
||||||
|
const index = context.indexOf(content)
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
const before = context.slice(0, index)
|
||||||
|
const after = context.slice(index + content.length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<span className="highlight-core">{content}</span>
|
||||||
|
{after}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: show context and the core highlight separately
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="highlight-context-prefix">{context}</span>
|
||||||
|
<br />
|
||||||
|
<span className="highlight-core">{content}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="highlight-core">{content}</span>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -506,7 +556,31 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
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)}
|
{formatDateCompact(highlight.created_at)}
|
||||||
@@ -516,15 +590,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
<CompactButton
|
<CompactButton
|
||||||
className="highlight-quote-button"
|
className="highlight-quote-button"
|
||||||
icon={faQuoteLeft}
|
icon={faQuoteLeft}
|
||||||
title="Quote"
|
title="Go to quote in article"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
} else {
|
||||||
|
navigateToArticle()
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* relay indicator lives in footer for consistent padding/alignment */}
|
{/* relay indicator lives in footer for consistent padding/alignment */}
|
||||||
|
|
||||||
<div className="highlight-content">
|
<div className="highlight-content">
|
||||||
<blockquote className="highlight-text">
|
<blockquote
|
||||||
{highlight.content}
|
className="highlight-text"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
} else {
|
||||||
|
navigateToArticle()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
title="Go to quote in article"
|
||||||
|
>
|
||||||
|
{renderHighlightText()}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
|
||||||
{showCitation && (
|
{showCitation && (
|
||||||
@@ -557,9 +653,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="highlight-author">
|
<CompactButton
|
||||||
|
className="highlight-author"
|
||||||
|
onClick={handleAuthorClick}
|
||||||
|
title="View profile"
|
||||||
|
>
|
||||||
{getUserDisplayName()}
|
{getUserDisplayName()}
|
||||||
</span>
|
</CompactButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="highlight-menu-wrapper" ref={menuRef}>
|
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||||
@@ -598,6 +698,20 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
|
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
<div className="highlight-menu">
|
<div className="highlight-menu">
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item"
|
||||||
|
onClick={handleMenuGoToQuote}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faQuoteLeft} />
|
||||||
|
<span>Go to quote</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item"
|
||||||
|
onClick={handleMenuViewProfile}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUser} />
|
||||||
|
<span>View profile</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="highlight-menu-item"
|
className="highlight-menu-item"
|
||||||
onClick={handleOpenPortal}
|
onClick={handleOpenPortal}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
|
|||||||
relayPool?: RelayPool | null
|
relayPool?: RelayPool | null
|
||||||
eventStore?: IEventStore | null
|
eventStore?: IEventStore | null
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||||
@@ -56,7 +57,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
followedPubkeys = new Set(),
|
followedPubkeys = new Set(),
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
settings
|
settings,
|
||||||
|
isMobile = false
|
||||||
}) => {
|
}) => {
|
||||||
const [showHighlights, setShowHighlights] = useState(true)
|
const [showHighlights, setShowHighlights] = useState(true)
|
||||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||||
@@ -116,15 +118,14 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="highlights-container">
|
<div className="highlights-container">
|
||||||
<HighlightsPanelHeader
|
<HighlightsPanelHeader
|
||||||
loading={loading}
|
|
||||||
hasHighlights={filteredHighlights.length > 0}
|
hasHighlights={filteredHighlights.length > 0}
|
||||||
showHighlights={showHighlights}
|
showHighlights={showHighlights}
|
||||||
highlightVisibility={highlightVisibility}
|
highlightVisibility={highlightVisibility}
|
||||||
currentUserPubkey={currentUserPubkey}
|
currentUserPubkey={currentUserPubkey}
|
||||||
onToggleHighlights={handleToggleHighlights}
|
onToggleHighlights={handleToggleHighlights}
|
||||||
onRefresh={onRefresh}
|
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading && filteredHighlights.length === 0 ? (
|
{loading && filteredHighlights.length === 0 ? (
|
||||||
|
|||||||
@@ -1,35 +1,44 @@
|
|||||||
import React from 'react'
|
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 { HighlightVisibility } from '../HighlightsPanel'
|
||||||
import IconButton from '../IconButton'
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
interface HighlightsPanelHeaderProps {
|
interface HighlightsPanelHeaderProps {
|
||||||
loading: boolean
|
|
||||||
hasHighlights: boolean
|
hasHighlights: boolean
|
||||||
showHighlights: boolean
|
showHighlights: boolean
|
||||||
highlightVisibility: HighlightVisibility
|
highlightVisibility: HighlightVisibility
|
||||||
currentUserPubkey?: string
|
currentUserPubkey?: string
|
||||||
onToggleHighlights: () => void
|
onToggleHighlights: () => void
|
||||||
onRefresh?: () => void
|
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||||
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||||
loading,
|
|
||||||
hasHighlights,
|
hasHighlights,
|
||||||
showHighlights,
|
showHighlights,
|
||||||
highlightVisibility,
|
highlightVisibility,
|
||||||
currentUserPubkey,
|
currentUserPubkey,
|
||||||
onToggleHighlights,
|
onToggleHighlights,
|
||||||
onRefresh,
|
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onHighlightVisibilityChange
|
onHighlightVisibilityChange,
|
||||||
|
isMobile = false
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="highlights-header">
|
<div className="highlights-header">
|
||||||
<div className="highlights-actions">
|
<div className="highlights-actions">
|
||||||
<div className="highlights-actions-left">
|
<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 && (
|
{onHighlightVisibilityChange && (
|
||||||
<div className="highlight-level-toggles">
|
<div className="highlight-level-toggles">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -80,17 +89,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onRefresh && (
|
</div>
|
||||||
<IconButton
|
<div className="highlights-actions-right">
|
||||||
icon={faRotate}
|
|
||||||
onClick={onRefresh}
|
|
||||||
title="Refresh highlights"
|
|
||||||
ariaLabel="Refresh highlights"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={loading}
|
|
||||||
spin={loading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={showHighlights ? faEye : faEyeSlash}
|
icon={showHighlights ? faEye : faEyeSlash}
|
||||||
@@ -101,14 +101,6 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
|
||||||
icon={faChevronRight}
|
|
||||||
onClick={onToggleCollapse}
|
|
||||||
title="Collapse highlights panel"
|
|
||||||
ariaLabel="Collapse highlights panel"
|
|
||||||
variant="ghost"
|
|
||||||
style={{ transform: 'rotate(180deg)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const LoginOptions: React.FC = () => {
|
|||||||
<div className="login-content">
|
<div className="login-content">
|
||||||
<h2 className="login-title">Hi! I'm Boris.</h2>
|
<h2 className="login-title">Hi! I'm Boris.</h2>
|
||||||
<p className="login-description">
|
<p className="login-description">
|
||||||
Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="login-buttons">
|
<div className="login-buttons">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faHeart } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
@@ -11,8 +12,8 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { highlightsController } from '../services/highlightsController'
|
import { highlightsController } from '../services/highlightsController'
|
||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
|
||||||
import { fetchLinks } from '../services/linksService'
|
import { fetchLinks } from '../services/linksService'
|
||||||
|
import { ReadItem, readsController } from '../services/readsController'
|
||||||
import { BlogPostPreview } from '../services/exploreService'
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
@@ -23,15 +24,16 @@ import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
|||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||||
|
import { dedupeBookmarksById } from '../services/bookmarkHelpers'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||||
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||||
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
|
||||||
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||||
import { mergeReadItem } from '../utils/readItemMerge'
|
|
||||||
import { readingProgressController } from '../services/readingProgressController'
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { archiveController } from '../services/archiveController'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -39,29 +41,30 @@ interface MeProps {
|
|||||||
activeTab?: TabType
|
activeTab?: TabType
|
||||||
bookmarks: Bookmark[] // From centralized App.tsx state
|
bookmarks: Bookmark[] // From centralized App.tsx state
|
||||||
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
|
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
|
||||||
|
settings: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
type TabType = 'highlights' | 'bookmarks' | 'reads' | 'links' | 'writings'
|
||||||
|
|
||||||
// Valid reading progress filters
|
// Valid reading progress filters
|
||||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted']
|
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
|
||||||
|
|
||||||
const Me: React.FC<MeProps> = ({
|
const Me: React.FC<MeProps> = ({
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
activeTab: propActiveTab,
|
activeTab: propActiveTab,
|
||||||
bookmarks
|
bookmarks,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const activeTab = propActiveTab || 'highlights'
|
||||||
|
|
||||||
// Only for own profile
|
// Only for own profile
|
||||||
const viewingPubkey = activeAccount?.pubkey
|
const viewingPubkey = activeAccount?.pubkey
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [reads, setReads] = useState<ReadItem[]>([])
|
const [reads, setReads] = useState<ReadItem[]>([])
|
||||||
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
|
||||||
const [links, setLinks] = useState<ReadItem[]>([])
|
const [links, setLinks] = useState<ReadItem[]>([])
|
||||||
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
|
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
@@ -90,8 +93,10 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize reading progress filter from URL param
|
// Initialize reading progress filter from URL param
|
||||||
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
// Backward compat: map legacy 'emoji' route to 'archive'
|
||||||
? (urlFilter as ReadingProgressFilterType)
|
const normalizedUrlFilter = urlFilter === 'emoji' ? 'archive' : urlFilter
|
||||||
|
const initialFilter = normalizedUrlFilter && VALID_FILTERS.includes(normalizedUrlFilter as ReadingProgressFilterType)
|
||||||
|
? (normalizedUrlFilter as ReadingProgressFilterType)
|
||||||
: 'all'
|
: 'all'
|
||||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
||||||
|
|
||||||
@@ -126,17 +131,11 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Update local state when prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (propActiveTab) {
|
|
||||||
setActiveTab(propActiveTab)
|
|
||||||
}
|
|
||||||
}, [propActiveTab])
|
|
||||||
|
|
||||||
// Sync filter state with URL changes
|
// Sync filter state with URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
|
||||||
? (urlFilter as ReadingProgressFilterType)
|
const filterFromUrl = normalized && VALID_FILTERS.includes(normalized as ReadingProgressFilterType)
|
||||||
|
? (normalized as ReadingProgressFilterType)
|
||||||
: 'all'
|
: 'all'
|
||||||
setReadingProgressFilter(filterFromUrl)
|
setReadingProgressFilter(filterFromUrl)
|
||||||
}, [urlFilter])
|
}, [urlFilter])
|
||||||
@@ -146,14 +145,33 @@ const Me: React.FC<MeProps> = ({
|
|||||||
setReadingProgressFilter(filter)
|
setReadingProgressFilter(filter)
|
||||||
if (activeTab === 'reads') {
|
if (activeTab === 'reads') {
|
||||||
if (filter === 'all') {
|
if (filter === 'all') {
|
||||||
navigate('/me/reads', { replace: true })
|
navigate('/my/reads', { replace: true })
|
||||||
} else {
|
} else {
|
||||||
navigate(`/me/reads/${filter}`, { replace: true })
|
navigate(`/my/reads/${filter}`, { replace: true })
|
||||||
|
}
|
||||||
|
} else if (activeTab === 'links') {
|
||||||
|
if (filter === 'all') {
|
||||||
|
navigate('/my/links', { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate(`/my/links/${filter}`, { replace: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to reading progress controller
|
// Subscribe to reads controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
setReads(readsController.getReads())
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubReads = readsController.onReads(setReads)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubReads()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to reading progress map for writings and links enrichment
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get initial state immediately
|
// Get initial state immediately
|
||||||
setReadingProgressMap(readingProgressController.getProgressMap())
|
setReadingProgressMap(readingProgressController.getProgressMap())
|
||||||
@@ -165,6 +183,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
unsubProgress()
|
unsubProgress()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
// Load reading progress data for writings tab
|
// Load reading progress data for writings tab
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -181,15 +200,15 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
// Tab-specific loading functions
|
// Tab-specific loading functions
|
||||||
const loadHighlightsTab = async () => {
|
const loadHighlightsTab = useCallback(async () => {
|
||||||
if (!viewingPubkey) return
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
// Highlights come from controller subscription (sync effect handles it)
|
// Highlights come from controller subscription (sync effect handles it)
|
||||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}, [viewingPubkey])
|
||||||
|
|
||||||
const loadWritingsTab = async () => {
|
const loadWritingsTab = useCallback(async () => {
|
||||||
if (!viewingPubkey) return
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -206,76 +225,63 @@ const Me: React.FC<MeProps> = ({
|
|||||||
console.error('Failed to load writings:', err)
|
console.error('Failed to load writings:', err)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
const loadReadingListTab = async () => {
|
const loadReadingListTab = useCallback(async () => {
|
||||||
if (!viewingPubkey || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('reading-list')
|
setLoadedTabs(prev => {
|
||||||
|
const hasBeenLoaded = prev.has('bookmarks')
|
||||||
try {
|
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
// Bookmarks come from centralized loading in App.tsx
|
return new Set(prev).add('bookmarks')
|
||||||
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
})
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load reading list:', err)
|
// Always turn off loading after a tick
|
||||||
} finally {
|
setTimeout(() => setLoading(false), 0)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
}, [viewingPubkey, activeAccount])
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadReadsTab = async () => {
|
const loadReadsTab = useCallback(async () => {
|
||||||
if (!viewingPubkey || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('reads')
|
let hasBeenLoaded = false
|
||||||
|
setLoadedTabs(prev => {
|
||||||
|
hasBeenLoaded = prev.has('reads')
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
// Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
// Use readsController to get reads with progressive hydration
|
||||||
const initialReads = deriveReadsFromBookmarks(bookmarks)
|
await readsController.start({
|
||||||
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
relayPool,
|
||||||
setReadsMap(initialMap)
|
eventStore,
|
||||||
setReads(initialReads)
|
pubkey: viewingPubkey
|
||||||
|
})
|
||||||
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('reads'))
|
setLoadedTabs(prev => new Set(prev).add('reads'))
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
|
||||||
// Background enrichment: merge reading progress and mark-as-read
|
|
||||||
// Only update items that are already in our map
|
|
||||||
fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => {
|
|
||||||
setReadsMap(prevMap => {
|
|
||||||
// Only update if item exists in our current map
|
|
||||||
if (!prevMap.has(item.id)) {
|
|
||||||
return prevMap
|
|
||||||
}
|
|
||||||
|
|
||||||
const newMap = new Map(prevMap)
|
|
||||||
const merged = mergeReadItem(newMap, item)
|
|
||||||
if (merged) {
|
|
||||||
// Update reads array after map is updated
|
|
||||||
setReads(Array.from(newMap.values()))
|
|
||||||
return newMap
|
|
||||||
}
|
|
||||||
return prevMap
|
|
||||||
})
|
|
||||||
}).catch(err => console.warn('Failed to enrich reads:', err))
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load reads:', err)
|
console.error('Failed to load reads:', err)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [viewingPubkey, activeAccount, relayPool, eventStore])
|
||||||
|
|
||||||
const loadLinksTab = async () => {
|
const loadLinksTab = useCallback(async () => {
|
||||||
if (!viewingPubkey || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('links')
|
let hasBeenLoaded = false
|
||||||
|
setLoadedTabs(prev => {
|
||||||
|
hasBeenLoaded = prev.has('links')
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
// Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
// Derive links from bookmarks with OpenGraph enhancement
|
||||||
const initialLinks = deriveLinksFromBookmarks(bookmarks)
|
const initialLinks = await deriveLinksFromBookmarks(bookmarks)
|
||||||
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||||
setLinksMap(initialMap)
|
setLinksMap(initialMap)
|
||||||
setLinks(initialLinks)
|
setLinks(initialLinks)
|
||||||
@@ -290,12 +296,13 @@ const Me: React.FC<MeProps> = ({
|
|||||||
if (!prevMap.has(item.id)) return prevMap
|
if (!prevMap.has(item.id)) return prevMap
|
||||||
|
|
||||||
const newMap = new Map(prevMap)
|
const newMap = new Map(prevMap)
|
||||||
if (mergeReadItem(newMap, item)) {
|
if (item.type === 'article' && item.author) {
|
||||||
// Update links array after map is updated
|
const progress = readingProgressMap.get(item.id)
|
||||||
setLinks(Array.from(newMap.values()))
|
if (progress !== undefined) {
|
||||||
return newMap
|
newMap.set(item.id, { ...item, readingProgress: progress })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return prevMap
|
return newMap
|
||||||
})
|
})
|
||||||
}).catch(err => console.warn('Failed to enrich links:', err))
|
}).catch(err => console.warn('Failed to enrich links:', err))
|
||||||
|
|
||||||
@@ -303,10 +310,10 @@ const Me: React.FC<MeProps> = ({
|
|||||||
console.error('Failed to load links:', err)
|
console.error('Failed to load links:', err)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [viewingPubkey, activeAccount, bookmarks, relayPool, readingProgressMap])
|
||||||
|
|
||||||
// Load active tab data
|
// Load active tab data
|
||||||
useEffect(() => {
|
const loadActiveTab = useCallback(() => {
|
||||||
if (!viewingPubkey || !activeTab) {
|
if (!viewingPubkey || !activeTab) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
@@ -329,7 +336,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
case 'writings':
|
case 'writings':
|
||||||
loadWritingsTab()
|
loadWritingsTab()
|
||||||
break
|
break
|
||||||
case 'reading-list':
|
case 'bookmarks':
|
||||||
loadReadingListTab()
|
loadReadingListTab()
|
||||||
break
|
break
|
||||||
case 'reads':
|
case 'reads':
|
||||||
@@ -339,8 +346,11 @@ const Me: React.FC<MeProps> = ({
|
|||||||
loadLinksTab()
|
loadLinksTab()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [viewingPubkey, activeTab, loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab, loadLinksTab])
|
||||||
}, [activeTab, viewingPubkey, refreshTrigger, bookmarks])
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadActiveTab()
|
||||||
|
}, [loadActiveTab])
|
||||||
|
|
||||||
// Sync myHighlights from controller
|
// Sync myHighlights from controller
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -385,8 +395,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getReadItemUrl = (item: ReadItem) => {
|
const getReadItemUrl = (item: ReadItem) => {
|
||||||
if (item.type === 'article') {
|
if (item.type === 'article' && item.id.startsWith('naddr1')) {
|
||||||
// ID is already in naddr format
|
|
||||||
return `/a/${item.id}`
|
return `/a/${item.id}`
|
||||||
} else if (item.url) {
|
} else if (item.url) {
|
||||||
return `/r/${encodeURIComponent(item.url)}`
|
return `/r/${encodeURIComponent(item.url)}`
|
||||||
@@ -410,7 +419,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
pubkey: item.author || '',
|
pubkey: item.author || '',
|
||||||
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
|
created_at: item.readingTimestamp || 0,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [] as string[][],
|
tags: [] as string[][],
|
||||||
content: item.title || item.url || 'Untitled',
|
content: item.title || item.url || 'Untitled',
|
||||||
@@ -429,19 +438,16 @@ const Me: React.FC<MeProps> = ({
|
|||||||
|
|
||||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||||
if (bookmark && bookmark.kind === 30023) {
|
if (bookmark && bookmark.kind === 30023) {
|
||||||
// For kind:30023 articles, navigate to the article route
|
|
||||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
if (dTag && bookmark.pubkey) {
|
if (dTag && bookmark.pubkey) {
|
||||||
const pointer = {
|
const naddr = nip19.naddrEncode({
|
||||||
identifier: dTag,
|
|
||||||
kind: 30023,
|
kind: 30023,
|
||||||
pubkey: bookmark.pubkey,
|
pubkey: bookmark.pubkey,
|
||||||
}
|
identifier: dTag
|
||||||
const naddr = nip19.naddrEncode(pointer)
|
})
|
||||||
navigate(`/a/${naddr}`)
|
navigate(`/a/${naddr}`)
|
||||||
}
|
}
|
||||||
} else if (url) {
|
} else if (url) {
|
||||||
// For regular URLs, navigate to the reader route
|
|
||||||
navigate(`/r/${encodeURIComponent(url)}`)
|
navigate(`/r/${encodeURIComponent(url)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,26 +488,19 @@ const Me: React.FC<MeProps> = ({
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge and flatten all individual bookmarks
|
// Merge and flatten all individual bookmarks with deduplication
|
||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allIndividualBookmarks = dedupeBookmarksById(
|
||||||
|
bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
)
|
||||||
.filter(hasContent)
|
.filter(hasContent)
|
||||||
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||||
|
|
||||||
// Apply bookmark filter
|
// Apply bookmark filter
|
||||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
||||||
|
|
||||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
// Enrich reads and links with reading progress from controller
|
// Enrich links with reading progress (reads already have progress from controller)
|
||||||
const readsWithProgress = reads.map(item => {
|
|
||||||
if (item.type === 'article' && item.author) {
|
|
||||||
const progress = readingProgressMap.get(item.id)
|
|
||||||
if (progress !== undefined) {
|
|
||||||
return { ...item, readingProgress: progress }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
|
|
||||||
const linksWithProgress = links.map(item => {
|
const linksWithProgress = links.map(item => {
|
||||||
if (item.url) {
|
if (item.url) {
|
||||||
const progress = readingProgressMap.get(item.url)
|
const progress = readingProgressMap.get(item.url)
|
||||||
@@ -512,12 +511,75 @@ const Me: React.FC<MeProps> = ({
|
|||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
|
|
||||||
// Apply reading progress filter
|
// Apply reading progress filter with simple type separation to keep Views distinct and DRY
|
||||||
const filteredReads = filterByReadingProgress(readsWithProgress, readingProgressFilter, highlights)
|
const filteredReads = filterByReadingProgress(
|
||||||
const filteredLinks = filterByReadingProgress(linksWithProgress, readingProgressFilter, highlights)
|
reads.filter(item => item.type === 'article'),
|
||||||
|
readingProgressFilter,
|
||||||
|
highlights
|
||||||
|
)
|
||||||
|
const filteredLinks = filterByReadingProgress(
|
||||||
|
linksWithProgress.filter(item => item.type === 'external'),
|
||||||
|
readingProgressFilter,
|
||||||
|
highlights
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper: build archive-only list from marked IDs and a base list
|
||||||
|
const buildArchiveOnly = (
|
||||||
|
baseItems: ReadItem[],
|
||||||
|
options: { kind: 'article' | 'external' }
|
||||||
|
): ReadItem[] => {
|
||||||
|
const allMarked = archiveController.getMarkedIds()
|
||||||
|
const relevantMarked = options.kind === 'article'
|
||||||
|
? allMarked.filter(id => id.startsWith('naddr1'))
|
||||||
|
: allMarked.filter(id => !id.startsWith('naddr1'))
|
||||||
|
const markedSet = new Set(relevantMarked)
|
||||||
|
|
||||||
|
const items: ReadItem[] = []
|
||||||
|
for (const item of baseItems) {
|
||||||
|
const key = options.kind === 'article' ? item.id : (item.url || item.id)
|
||||||
|
if (key && markedSet.has(key)) {
|
||||||
|
items.push({ ...item, markedAsRead: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of markedSet) {
|
||||||
|
const exists = items.find(i => (options.kind === 'article' ? i.id : (i.url || i.id)) === id)
|
||||||
|
if (!exists) {
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
source: 'marked-as-read',
|
||||||
|
type: options.kind,
|
||||||
|
url: options.kind === 'article' ? undefined : id,
|
||||||
|
markedAsRead: true,
|
||||||
|
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive-only lists: independent of reading progress
|
||||||
|
const archiveOnlyReads: ReadItem[] = readingProgressFilter === 'archive'
|
||||||
|
? buildArchiveOnly(reads, { kind: 'article' })
|
||||||
|
: []
|
||||||
|
const archiveOnlyLinks: ReadItem[] = readingProgressFilter === 'archive'
|
||||||
|
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
|
||||||
|
: []
|
||||||
|
|
||||||
|
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 sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
groupingMode === 'flat'
|
groupingMode === 'flat'
|
||||||
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
? [{ key: 'all', title: getFilterTitle(bookmarkFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
|
||||||
: [
|
: [
|
||||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||||
@@ -559,7 +621,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'reading-list':
|
case 'bookmarks':
|
||||||
if (showSkeletons) {
|
if (showSkeletons) {
|
||||||
return (
|
return (
|
||||||
<div className="bookmarks-list">
|
<div className="bookmarks-list">
|
||||||
@@ -605,21 +667,26 @@ const Me: React.FC<MeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)))}
|
)))}
|
||||||
<div className="view-mode-controls" style={{
|
<div className="view-mode-controls">
|
||||||
display: 'flex',
|
<div className="view-mode-left">
|
||||||
justifyContent: 'center',
|
<IconButton
|
||||||
gap: '0.5rem',
|
icon={faHeart}
|
||||||
padding: '1rem',
|
onClick={() => navigate('/support')}
|
||||||
marginTop: '1rem',
|
title="Support Boris"
|
||||||
borderTop: '1px solid var(--border-color)'
|
ariaLabel="Support"
|
||||||
}}>
|
variant="ghost"
|
||||||
<IconButton
|
style={{ color: 'rgb(251 146 60)' }}
|
||||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
/>
|
||||||
onClick={toggleGroupingMode}
|
<IconButton
|
||||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
onClick={toggleGroupingMode}
|
||||||
variant="ghost"
|
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||||
/>
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="view-mode-right">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -652,21 +719,42 @@ const Me: React.FC<MeProps> = ({
|
|||||||
selectedFilter={readingProgressFilter}
|
selectedFilter={readingProgressFilter}
|
||||||
onFilterChange={handleReadingProgressFilterChange}
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
/>
|
/>
|
||||||
{filteredReads.length === 0 ? (
|
{readingProgressFilter === 'archive' ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
archiveOnlyReads.length === 0 ? (
|
||||||
No articles match this filter.
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
</div>
|
No articles in archive.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{archiveOnlyReads
|
||||||
|
.filter(item => item.type === 'article')
|
||||||
|
.map((item) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={item.id}
|
||||||
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
filteredReads.length === 0 ? (
|
||||||
{filteredReads.map((item) => (
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<BlogPostCard
|
No articles match this filter.
|
||||||
key={item.id}
|
</div>
|
||||||
post={convertReadItemToBlogPostPreview(item)}
|
) : (
|
||||||
href={getReadItemUrl(item)}
|
<div className="explore-grid">
|
||||||
readingProgress={item.readingProgress}
|
{filteredReads.map((item) => (
|
||||||
/>
|
<BlogPostCard
|
||||||
))}
|
key={item.id}
|
||||||
</div>
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -699,21 +787,40 @@ const Me: React.FC<MeProps> = ({
|
|||||||
selectedFilter={readingProgressFilter}
|
selectedFilter={readingProgressFilter}
|
||||||
onFilterChange={handleReadingProgressFilterChange}
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
/>
|
/>
|
||||||
{filteredLinks.length === 0 ? (
|
{readingProgressFilter === 'archive' ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
archiveOnlyLinks.length === 0 ? (
|
||||||
No links match this filter.
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
</div>
|
No links in archive.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{archiveOnlyLinks.map((item) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={item.id}
|
||||||
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
filteredLinks.length === 0 ? (
|
||||||
{filteredLinks.map((item) => (
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<BlogPostCard
|
No links match this filter.
|
||||||
key={item.id}
|
</div>
|
||||||
post={convertReadItemToBlogPostPreview(item)}
|
) : (
|
||||||
href={getReadItemUrl(item)}
|
<div className="explore-grid">
|
||||||
readingProgress={item.readingProgress}
|
{filteredLinks.map((item) => (
|
||||||
/>
|
<BlogPostCard
|
||||||
))}
|
key={item.id}
|
||||||
</div>
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -740,6 +847,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
post={post}
|
post={post}
|
||||||
href={getPostUrl(post)}
|
href={getPostUrl(post)}
|
||||||
readingProgress={getWritingReadingProgress(post)}
|
readingProgress={getWritingReadingProgress(post)}
|
||||||
|
hideBotByName={settings.hideBotArticlesByName !== false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -763,15 +871,15 @@ const Me: React.FC<MeProps> = ({
|
|||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||||
data-tab="highlights"
|
data-tab="highlights"
|
||||||
onClick={() => navigate('/me/highlights')}
|
onClick={() => navigate('/my/highlights')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
<span className="tab-label">Highlights</span>
|
<span className="tab-label">Highlights</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'bookmarks' ? 'active' : ''}`}
|
||||||
data-tab="reading-list"
|
data-tab="bookmarks"
|
||||||
onClick={() => navigate('/me/reading-list')}
|
onClick={() => navigate('/my/bookmarks')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBookmark} />
|
<FontAwesomeIcon icon={faBookmark} />
|
||||||
<span className="tab-label">Bookmarks</span>
|
<span className="tab-label">Bookmarks</span>
|
||||||
@@ -779,7 +887,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||||
data-tab="reads"
|
data-tab="reads"
|
||||||
onClick={() => navigate('/me/reads')}
|
onClick={() => navigate('/my/reads')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBooks} />
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
<span className="tab-label">Reads</span>
|
<span className="tab-label">Reads</span>
|
||||||
@@ -787,7 +895,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
||||||
data-tab="links"
|
data-tab="links"
|
||||||
onClick={() => navigate('/me/links')}
|
onClick={() => navigate('/my/links')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faLink} />
|
<FontAwesomeIcon icon={faLink} />
|
||||||
<span className="tab-label">Links</span>
|
<span className="tab-label">Links</span>
|
||||||
@@ -795,7 +903,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||||
data-tab="writings"
|
data-tab="writings"
|
||||||
onClick={() => navigate('/me/writings')}
|
onClick={() => navigate('/my/writings')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPenToSquare} />
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
<span className="tab-label">Writings</span>
|
<span className="tab-label">Writings</span>
|
||||||
|
|||||||
138
src/components/NostrMentionLink.tsx
Normal file
138
src/components/NostrMentionLink.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { Models, Helpers } from 'applesauce-core'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils'
|
||||||
|
|
||||||
|
const { getPubkeyFromDecodeResult } = Helpers
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
try {
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
decoded = nip19.decode(identifier)
|
||||||
|
} catch (error) {
|
||||||
|
// Decoding failed, will fallback to shortened identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract pubkey for profile fetching using applesauce helper (works for npub and nprofile)
|
||||||
|
const pubkey = decoded ? getPubkeyFromDecodeResult(decoded) : undefined
|
||||||
|
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
|
// Fetch profile at top level (Rules of Hooks)
|
||||||
|
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
|
||||||
|
|
||||||
|
// Check if profile is in cache or eventStore for loading detection
|
||||||
|
const isInCacheOrStore = useMemo(() => {
|
||||||
|
if (!pubkey) return false
|
||||||
|
return isProfileInCacheOrStore(pubkey, eventStore)
|
||||||
|
}, [pubkey, eventStore])
|
||||||
|
|
||||||
|
// Show loading if profile doesn't exist and not in cache/store (for npub/nprofile)
|
||||||
|
// pubkey will be undefined for non-profile types, so no need for explicit type check
|
||||||
|
const isLoading = !profile && pubkey && !isInCacheOrStore
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to render profile links (used for both npub and nprofile)
|
||||||
|
const renderProfileLink = (pubkey: string) => {
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
const displayName = getProfileDisplayName(profile, pubkey)
|
||||||
|
const linkClassName = isLoading ? `${className} profile-loading` : className
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/p/${npub}`}
|
||||||
|
className={linkClassName}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
@{displayName}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render based on decoded type
|
||||||
|
// If we have a pubkey (from npub/nprofile), render profile link directly
|
||||||
|
if (pubkey) {
|
||||||
|
return renderProfileLink(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (decoded.type) {
|
||||||
|
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
|
||||||
|
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faPenToSquare, faEllipsisH, faCopy, faShare, faExternalLinkAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
import { fetchHighlights } from '../services/highlightService'
|
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
|
import CompactButton from './CompactButton'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
@@ -20,6 +19,9 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
|
|||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { readingProgressController } from '../services/readingProgressController'
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { writingsController } from '../services/writingsController'
|
||||||
|
import { highlightsController } from '../services/highlightsController'
|
||||||
|
import { getProfileUrl } from '../config/nostrGateways'
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -38,6 +40,8 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
const [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||||
|
const profileMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Reading progress state (naddr -> progress 0-1)
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
@@ -57,6 +61,15 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
[pubkey]
|
[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
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (propActiveTab) {
|
if (propActiveTab) {
|
||||||
@@ -94,28 +107,17 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
})
|
})
|
||||||
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
// Background fetch to populate event store (non-blocking)
|
// Background fetch via controllers to populate event store
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pubkey || !relayPool || !eventStore) return
|
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))
|
||||||
|
|
||||||
// Fetch highlights in background
|
writingsController.start({ relayPool, eventStore, pubkey, force: refreshTrigger > 0 })
|
||||||
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
|
||||||
.then(() => {
|
|
||||||
// Highlights fetched
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch writings in background (no limit for single user profile)
|
|
||||||
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
|
|
||||||
.then(writings => {
|
|
||||||
writings.forEach(w => eventStore.add(w.event))
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
|
|
||||||
})
|
|
||||||
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
@@ -168,7 +170,69 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const npub = nip19.npubEncode(pubkey)
|
const npub = nip19.npubEncode(pubkey)
|
||||||
const showSkeletons = cachedHighlights.length === 0 && cachedWritings.length === 0
|
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showProfileMenu) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [showProfileMenu])
|
||||||
|
|
||||||
|
// Profile menu handlers
|
||||||
|
const handleMenuToggle = () => {
|
||||||
|
setShowProfileMenu(!showProfileMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyProfileLink = async () => {
|
||||||
|
try {
|
||||||
|
const borisUrl = `${window.location.origin}/p/${npub}`
|
||||||
|
await navigator.clipboard.writeText(borisUrl)
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Copy failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareProfile = async () => {
|
||||||
|
try {
|
||||||
|
const borisUrl = `${window.location.origin}/p/${npub}`
|
||||||
|
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: 'Profile',
|
||||||
|
url: borisUrl
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(borisUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Share failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenPortal = () => {
|
||||||
|
const portalUrl = getProfileUrl(npub)
|
||||||
|
window.open(portalUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenNative = () => {
|
||||||
|
const nativeUrl = `nostr:${npub}`
|
||||||
|
window.location.href = nativeUrl
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -209,13 +273,13 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return cachedWritings.length === 0 ? (
|
return sortedWritings.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles written yet.
|
No articles written yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{cachedWritings.map((post) => (
|
{sortedWritings.map((post) => (
|
||||||
<BlogPostCard
|
<BlogPostCard
|
||||||
key={post.event.id}
|
key={post.event.id}
|
||||||
post={post}
|
post={post}
|
||||||
@@ -238,7 +302,51 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
pullPosition={pullPosition}
|
pullPosition={pullPosition}
|
||||||
/>
|
/>
|
||||||
<div className="explore-header">
|
<div className="explore-header">
|
||||||
<AuthorCard authorPubkey={pubkey} clickable={false} />
|
<div className="profile-header-wrapper">
|
||||||
|
<div className="profile-card-with-menu">
|
||||||
|
<AuthorCard authorPubkey={pubkey} clickable={false} />
|
||||||
|
<div className="profile-card-menu-wrapper" ref={profileMenuRef}>
|
||||||
|
<CompactButton
|
||||||
|
icon={faEllipsisH}
|
||||||
|
onClick={handleMenuToggle}
|
||||||
|
title="More options"
|
||||||
|
ariaLabel="Profile menu"
|
||||||
|
/>
|
||||||
|
{showProfileMenu && (
|
||||||
|
<div className="profile-card-menu">
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleCopyProfileLink}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
<span>Copy Link</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleShareProfile}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faShare} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open with njump</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleOpenNative}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faMobileAlt} />
|
||||||
|
<span>Open with Native App</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="me-tabs">
|
<div className="me-tabs">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface ReaderHeaderProps {
|
|||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
highlights?: Highlight[]
|
highlights?: Highlight[]
|
||||||
highlightVisibility?: HighlightVisibility
|
highlightVisibility?: HighlightVisibility
|
||||||
|
onHighlightCountClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||||
@@ -32,7 +33,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
highlightCount,
|
highlightCount,
|
||||||
settings,
|
settings,
|
||||||
highlights = [],
|
highlights = [],
|
||||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||||
|
onHighlightCountClick
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(image)
|
const cachedImage = useImageCache(image)
|
||||||
const { textColor } = useAdaptiveTextColor(cachedImage)
|
const { textColor } = useAdaptiveTextColor(cachedImage)
|
||||||
@@ -78,7 +80,13 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div className="reader-hero-image">
|
<div className="reader-hero-image">
|
||||||
{cachedImage ? (
|
{cachedImage ? (
|
||||||
<img src={cachedImage} alt={title || 'Article image'} />
|
<img
|
||||||
|
src={cachedImage}
|
||||||
|
alt={title || 'Article image'}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('[reader-header] Image failed to load:', cachedImage, e)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="reader-hero-placeholder">
|
<div className="reader-hero-placeholder">
|
||||||
<FontAwesomeIcon icon={faNewspaper} />
|
<FontAwesomeIcon icon={faNewspaper} />
|
||||||
@@ -107,8 +115,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<div
|
<div
|
||||||
className="highlight-indicator"
|
className="highlight-indicator clickable"
|
||||||
style={getHighlightIndicatorStyles(true)}
|
style={getHighlightIndicatorStyles(true)}
|
||||||
|
onClick={onHighlightCountClick}
|
||||||
|
title="Open highlights sidebar"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
@@ -152,8 +162,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<div
|
<div
|
||||||
className="highlight-indicator"
|
className="highlight-indicator clickable"
|
||||||
style={getHighlightIndicatorStyles(false)}
|
style={getHighlightIndicatorStyles(false)}
|
||||||
|
onClick={onHighlightCountClick}
|
||||||
|
title="Open highlights sidebar"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
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'
|
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted'
|
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'archive'
|
||||||
|
|
||||||
interface ReadingProgressFiltersProps {
|
interface ReadingProgressFiltersProps {
|
||||||
selectedFilter: ReadingProgressFilterType
|
selectedFilter: ReadingProgressFilterType
|
||||||
@@ -13,11 +14,13 @@ interface ReadingProgressFiltersProps {
|
|||||||
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
const filters = [
|
const filters = [
|
||||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
{ 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: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||||
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
// Archive-marked items (previously emoji-marked)
|
||||||
|
{ type: 'archive' as const, icon: faBooks, label: 'Archive' }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -31,6 +34,8 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
|
|||||||
activeStyle = { color: '#10b981' } // green
|
activeStyle = { color: '#10b981' } // green
|
||||||
} else if (filter.type === 'highlighted') {
|
} else if (filter.type === 'highlighted') {
|
||||||
activeStyle = { color: '#fde047' } // yellow
|
activeStyle = { color: '#fde047' } // yellow
|
||||||
|
} else if (filter.type === 'archive') {
|
||||||
|
activeStyle = { color: '#60a5fa' } // blue accent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
import { Models, Helpers } from 'applesauce-core'
|
import { Models, Helpers } from 'applesauce-core'
|
||||||
import { decode, npubEncode } from 'nostr-tools/nip19'
|
import { decode, npubEncode } from 'nostr-tools/nip19'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils'
|
||||||
|
|
||||||
const { getPubkeyFromDecodeResult } = Helpers
|
const { getPubkeyFromDecodeResult } = Helpers
|
||||||
|
|
||||||
@@ -19,15 +22,27 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
|
|||||||
// ignore; will fallback to showing the encoded value
|
// ignore; will fallback to showing the encoded value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
const profile = pubkey ? useEventModel(Models.ProfileModel, [pubkey]) : undefined
|
const profile = pubkey ? useEventModel(Models.ProfileModel, [pubkey]) : undefined
|
||||||
const display = profile?.name || profile?.display_name || profile?.nip05 || (pubkey ? `${pubkey.slice(0, 8)}...` : encoded)
|
|
||||||
|
// Check if profile is in cache or eventStore
|
||||||
|
const isInCacheOrStore = useMemo(() => {
|
||||||
|
if (!pubkey) return false
|
||||||
|
return isProfileInCacheOrStore(pubkey, eventStore)
|
||||||
|
}, [pubkey, eventStore])
|
||||||
|
|
||||||
|
// Show loading if profile doesn't exist and not in cache/store
|
||||||
|
const isLoading = !profile && pubkey && !isInCacheOrStore
|
||||||
|
|
||||||
|
const display = pubkey ? getProfileDisplayName(profile, pubkey) : encoded
|
||||||
const npub = pubkey ? npubEncode(pubkey) : undefined
|
const npub = pubkey ? npubEncode(pubkey) : undefined
|
||||||
|
|
||||||
if (npub) {
|
if (npub) {
|
||||||
|
const className = isLoading ? 'nostr-mention profile-loading' : 'nostr-mention'
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/p/${npub}`}
|
to={`/p/${npub}`}
|
||||||
className="nostr-mention"
|
className={className}
|
||||||
>
|
>
|
||||||
@{display}
|
@{display}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
100
src/components/RichContent.tsx
Normal file
100
src/components/RichContent.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import NostrMentionLink from './NostrMentionLink'
|
||||||
|
import { Tokens } from 'applesauce-content/helpers'
|
||||||
|
|
||||||
|
// Helper to add timestamps to error logs
|
||||||
|
const ts = () => {
|
||||||
|
const now = new Date()
|
||||||
|
const ms = now.getMilliseconds().toString().padStart(3, '0')
|
||||||
|
return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
// Pattern to match:
|
||||||
|
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) using applesauce Tokens.nostrLink
|
||||||
|
// 2. http(s) URLs
|
||||||
|
const nostrPattern = Tokens.nostrLink
|
||||||
|
const urlPattern = /https?:\/\/[^\s]+/gi
|
||||||
|
const combinedPattern = new RegExp(`(${nostrPattern.source}|${urlPattern.source})`, 'gi')
|
||||||
|
|
||||||
|
const parts = content.split(combinedPattern)
|
||||||
|
|
||||||
|
// Helper to check if a string is a nostr identifier (without mutating regex state)
|
||||||
|
const isNostrIdentifier = (str: string): boolean => {
|
||||||
|
const testPattern = new RegExp(nostrPattern.source, nostrPattern.flags)
|
||||||
|
return testPattern.test(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
// Skip empty or undefined parts
|
||||||
|
if (!part) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle nostr: URIs - Tokens.nostrLink matches both formats
|
||||||
|
if (part.startsWith('nostr:')) {
|
||||||
|
return (
|
||||||
|
<NostrMentionLink
|
||||||
|
key={index}
|
||||||
|
nostrUri={part}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle plain nostr identifiers (Tokens.nostrLink matches these too)
|
||||||
|
if (isNostrIdentifier(part)) {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${ts()}] [npub-resolve] RichContent: Error rendering:`, err)
|
||||||
|
return <div className={className}>Error rendering content</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RichContent
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export default function RouteDebug() {
|
|||||||
// Unexpected during deep-link refresh tests
|
// Unexpected during deep-link refresh tests
|
||||||
console.warn('[RouteDebug] unexpected root redirect', info)
|
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||||
} else {
|
} else {
|
||||||
console.debug('[RouteDebug]', info)
|
// silent
|
||||||
}
|
}
|
||||||
}, [location, matchArticle])
|
}, [location, matchArticle])
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import IconButton from './IconButton'
|
|||||||
import { loadFont } from '../utils/fontLoader'
|
import { loadFont } from '../utils/fontLoader'
|
||||||
import ThemeSettings from './Settings/ThemeSettings'
|
import ThemeSettings from './Settings/ThemeSettings'
|
||||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||||
|
import MediaDisplaySettings from './Settings/MediaDisplaySettings'
|
||||||
import ExploreSettings from './Settings/ExploreSettings'
|
import ExploreSettings from './Settings/ExploreSettings'
|
||||||
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||||
import ZapSettings from './Settings/ZapSettings'
|
import ZapSettings from './Settings/ZapSettings'
|
||||||
import RelaySettings from './Settings/RelaySettings'
|
import RelaySettings from './Settings/RelaySettings'
|
||||||
import PWASettings from './Settings/PWASettings'
|
import PWASettings from './Settings/PWASettings'
|
||||||
|
import TTSSettings from './Settings/TTSSettings'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import VersionFooter from './VersionFooter'
|
import VersionFooter from './VersionFooter'
|
||||||
|
|
||||||
@@ -39,9 +41,18 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
useLocalRelayAsCache: true,
|
useLocalRelayAsCache: true,
|
||||||
rebroadcastToAllRelays: false,
|
rebroadcastToAllRelays: false,
|
||||||
paragraphAlignment: 'justify',
|
paragraphAlignment: 'justify',
|
||||||
|
fullWidthImages: true,
|
||||||
|
renderVideoLinksAsEmbeds: true,
|
||||||
syncReadingPosition: true,
|
syncReadingPosition: true,
|
||||||
|
autoScrollToReadingPosition: true,
|
||||||
autoMarkAsReadOnCompletion: false,
|
autoMarkAsReadOnCompletion: false,
|
||||||
hideBookmarksWithoutCreationDate: false,
|
hideBookmarksWithoutCreationDate: true,
|
||||||
|
ttsUseSystemLanguage: false,
|
||||||
|
ttsDetectContentLanguage: true,
|
||||||
|
ttsLanguageMode: 'content',
|
||||||
|
ttsDefaultSpeed: 2.1,
|
||||||
|
linkColorDark: '#38bdf8',
|
||||||
|
linkColorLight: '#3b82f6',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
@@ -169,8 +180,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<TTSSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||||
|
|||||||
@@ -51,6 +51,19 @@ const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate })
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="setting-group">
|
||||||
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
|
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
|
||||||
<input
|
<input
|
||||||
@@ -127,7 +140,7 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
|||||||
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
|
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
|
||||||
className="setting-checkbox"
|
className="setting-checkbox"
|
||||||
/>
|
/>
|
||||||
<span>Automatically mark as read at 100%</span>
|
<span>Automatically move to archive at 100%</span>
|
||||||
</label>
|
</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
|
||||||
@@ -33,7 +33,13 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
|||||||
|
|
||||||
const handleLinkClick = (url: string) => {
|
const handleLinkClick = (url: string) => {
|
||||||
if (onClose) onClose()
|
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 () => {
|
const handleClearCache = async () => {
|
||||||
@@ -151,7 +157,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
|||||||
>
|
>
|
||||||
here
|
here
|
||||||
</a>
|
</a>
|
||||||
{' and '}
|
{', '}
|
||||||
<a
|
<a
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -161,6 +167,16 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
|||||||
>
|
>
|
||||||
here
|
here
|
||||||
</a>
|
</a>
|
||||||
|
{', and '}
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleLinkClick('/a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8')
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import IconButton from '../IconButton'
|
|||||||
import ColorPicker from '../ColorPicker'
|
import ColorPicker from '../ColorPicker'
|
||||||
import FontSelector from '../FontSelector'
|
import FontSelector from '../FontSelector'
|
||||||
import { getFontFamily } from '../../utils/fontLoader'
|
import { getFontFamily } from '../../utils/fontLoader'
|
||||||
import { hexToRgb } from '../../utils/colorHelpers'
|
import { hexToRgb, LINK_COLORS_DARK, LINK_COLORS_LIGHT } from '../../utils/colorHelpers'
|
||||||
|
|
||||||
interface ReadingDisplaySettingsProps {
|
interface ReadingDisplaySettingsProps {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
@@ -14,6 +14,23 @@ interface ReadingDisplaySettingsProps {
|
|||||||
|
|
||||||
const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
||||||
const previewFontFamily = getFontFamily(settings.readingFont || 'source-serif-4')
|
const previewFontFamily = getFontFamily(settings.readingFont || 'source-serif-4')
|
||||||
|
|
||||||
|
// Determine current effective theme for color palette selection
|
||||||
|
const currentTheme = settings.theme ?? 'system'
|
||||||
|
const isDark = currentTheme === 'dark' ||
|
||||||
|
(currentTheme === 'system' && (typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)').matches : true))
|
||||||
|
const linkColors = isDark ? LINK_COLORS_DARK : LINK_COLORS_LIGHT
|
||||||
|
const currentLinkColor = isDark
|
||||||
|
? (settings.linkColorDark || '#38bdf8')
|
||||||
|
: (settings.linkColorLight || '#3b82f6')
|
||||||
|
|
||||||
|
const handleLinkColorChange = (color: string) => {
|
||||||
|
if (isDark) {
|
||||||
|
onUpdate({ linkColorDark: color })
|
||||||
|
} else {
|
||||||
|
onUpdate({ linkColorLight: color })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
@@ -59,6 +76,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label>Default Highlight Visibility</label>
|
<label>Default Highlight Visibility</label>
|
||||||
<div className="highlight-level-toggles">
|
<div className="highlight-level-toggles">
|
||||||
@@ -108,6 +126,17 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">Link Color</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<ColorPicker
|
||||||
|
selectedColor={currentLinkColor}
|
||||||
|
onColorChange={handleLinkColorChange}
|
||||||
|
colors={linkColors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label className="setting-label">Font Size</label>
|
<label className="setting-label">Font Size</label>
|
||||||
<div className="setting-control">
|
<div className="setting-control">
|
||||||
@@ -178,14 +207,16 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
fontFamily: previewFontFamily,
|
fontFamily: previewFontFamily,
|
||||||
fontSize: `${settings.fontSize || 21}px`,
|
fontSize: `${settings.fontSize || 21}px`,
|
||||||
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
|
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
|
||||||
'--paragraph-alignment': settings.paragraphAlignment || 'justify'
|
'--paragraph-alignment': settings.paragraphAlignment || 'justify',
|
||||||
|
'--color-link': isDark
|
||||||
|
? (settings.linkColorDark || '#38bdf8')
|
||||||
|
: (settings.linkColorLight || '#3b82f6')
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<h3>The Quick Brown Fox</h3>
|
<h3>The Quick Brown Fox</h3>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
||||||
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
<p>Totam rem aperiam, eaque ipsa quae ab illo <a href="/a/naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqq8ky6t5vdhkjm3dd9ej6arfd4jszh5rdq">inventore veritatis</a> et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
||||||
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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,11 +1,14 @@
|
|||||||
import React from 'react'
|
import React, { useState, useRef, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronRight, faRightFromBracket, 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 { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
import { preloadImage } from '../hooks/useImageCache'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
@@ -18,6 +21,8 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||||
|
const [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const getProfileImage = () => {
|
const getProfileImage = () => {
|
||||||
return profile?.picture || null
|
return profile?.picture || null
|
||||||
@@ -25,81 +30,159 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
|
|
||||||
const getUserDisplayName = () => {
|
const getUserDisplayName = () => {
|
||||||
if (!activeAccount) return 'Unknown User'
|
if (!activeAccount) return 'Unknown User'
|
||||||
if (profile?.name) return profile.name
|
return getProfileDisplayName(profile, activeAccount.pubkey)
|
||||||
if (profile?.display_name) return profile.display_name
|
|
||||||
if (profile?.nip05) return profile.nip05
|
|
||||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileImage = getProfileImage()
|
const profileImage = getProfileImage()
|
||||||
|
|
||||||
|
// Preload profile image for offline access
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileImage) {
|
||||||
|
preloadImage(profileImage)
|
||||||
|
}
|
||||||
|
}, [profileImage])
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-header-bar">
|
<div className="sidebar-header-bar">
|
||||||
{isMobile ? (
|
<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
|
<IconButton
|
||||||
icon={faTimes}
|
icon={faHome}
|
||||||
onClick={onToggleCollapse}
|
onClick={() => {
|
||||||
title="Close sidebar"
|
if (isMobile) {
|
||||||
ariaLabel="Close sidebar"
|
onToggleCollapse()
|
||||||
|
}
|
||||||
|
navigate('/')
|
||||||
|
}}
|
||||||
|
title="Home"
|
||||||
|
ariaLabel="Home"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mobile-close-btn"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<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="sidebar-header-right">
|
||||||
{activeAccount && (
|
|
||||||
<div
|
|
||||||
className="profile-avatar"
|
|
||||||
title={getUserDisplayName()}
|
|
||||||
onClick={() => navigate('/me')}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
{profileImage ? (
|
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon icon={faUserCircle} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
icon={faHome}
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
title="Home"
|
|
||||||
ariaLabel="Home"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faNewspaper}
|
|
||||||
onClick={() => navigate('/explore')}
|
|
||||||
title="Explore"
|
|
||||||
ariaLabel="Explore"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faGear}
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
title="Settings"
|
|
||||||
ariaLabel="Settings"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
{activeAccount && (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRightFromBracket}
|
icon={faPersonHiking}
|
||||||
onClick={onLogout}
|
onClick={() => {
|
||||||
title="Logout"
|
if (isMobile) {
|
||||||
ariaLabel="Logout"
|
onToggleCollapse()
|
||||||
|
}
|
||||||
|
navigate('/explore')
|
||||||
|
}}
|
||||||
|
title="Explore"
|
||||||
|
ariaLabel="Explore"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
)}
|
<IconButton
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
import { faHeart, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
@@ -10,6 +10,7 @@ import { Models } from 'applesauce-core'
|
|||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface SupportProps {
|
interface SupportProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -21,7 +22,7 @@ type SupporterProfile = ZapSender
|
|||||||
|
|
||||||
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
||||||
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSupporters = async () => {
|
const loadSupporters = async () => {
|
||||||
@@ -31,7 +32,8 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
|||||||
|
|
||||||
if (zappers.length > 0) {
|
if (zappers.length > 0) {
|
||||||
const pubkeys = zappers.map(z => z.pubkey)
|
const pubkeys = zappers.map(z => z.pubkey)
|
||||||
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
|
// Fetch profiles in background without blocking
|
||||||
|
fetchProfiles(relayPool, eventStore, pubkeys, settings).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
setSupporters(zappers)
|
setSupporters(zappers)
|
||||||
@@ -45,14 +47,6 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
|||||||
loadSupporters()
|
loadSupporters()
|
||||||
}, [relayPool, eventStore, settings])
|
}, [relayPool, eventStore, settings])
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen p-4">
|
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
||||||
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
||||||
@@ -82,7 +76,32 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{supporters.length === 0 ? (
|
{loading ? (
|
||||||
|
<>
|
||||||
|
{/* Loading Skeletons */}
|
||||||
|
<div className="mb-16 md:mb-20">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Legends
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<SupporterSkeleton key={`whale-${i}`} isWhale={true} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Supporters
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<SupporterSkeleton key={`supporter-${i}`} isWhale={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : supporters.length === 0 ? (
|
||||||
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
<p>No supporters yet. Be the first to zap Boris!</p>
|
<p>No supporters yet. Be the first to zap Boris!</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +183,7 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
|
|||||||
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
|
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
|
||||||
|
|
||||||
const picture = profile?.picture
|
const picture = profile?.picture
|
||||||
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
|
const name = getProfileDisplayName(profile, supporter.pubkey)
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
const npub = nip19.npubEncode(supporter.pubkey)
|
const npub = nip19.npubEncode(supporter.pubkey)
|
||||||
@@ -231,5 +250,55 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SupporterSkeletonProps {
|
||||||
|
isWhale: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SupporterSkeleton: React.FC<SupporterSkeletonProps> = ({ isWhale }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Avatar Skeleton */}
|
||||||
|
<div
|
||||||
|
className={`rounded-full overflow-hidden flex items-center justify-center animate-pulse
|
||||||
|
${isWhale ? 'w-24 h-24 md:w-28 md:h-28' : 'w-10 h-10 md:w-12 md:h-12'}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rounded-full ${isWhale ? 'w-20 h-20 md:w-24 md:h-24' : 'w-8 h-8 md:w-10 md:h-10'}`}
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Whale Badge Skeleton */}
|
||||||
|
{isWhale && (
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-1 -right-1 w-8 h-8 rounded-full animate-pulse border-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-border)',
|
||||||
|
borderColor: 'var(--color-bg)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name and Total Skeleton */}
|
||||||
|
<div className="mt-2 text-center space-y-1">
|
||||||
|
<div
|
||||||
|
className={`rounded animate-pulse ${isWhale ? 'h-4 w-16' : 'h-3 w-12'}`}
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`rounded animate-pulse ${isWhale ? 'h-3 w-12' : 'h-2 w-10'}`}
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default Support
|
export default Support
|
||||||
|
|
||||||
|
|||||||
113
src/components/TTSControls.tsx
Normal file
113
src/components/TTSControls.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { useTextToSpeech } from '../hooks/useTextToSpeech'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faPlay, faPause, faGauge } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { detect } from 'tinyld'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
defaultLang?: string
|
||||||
|
className?: string
|
||||||
|
settings?: UserSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
|
||||||
|
|
||||||
|
const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }) => {
|
||||||
|
const {
|
||||||
|
supported, speaking, paused,
|
||||||
|
speak, pause, resume,
|
||||||
|
rate, setRate
|
||||||
|
} = useTextToSpeech({ defaultLang, defaultRate: settings?.ttsDefaultSpeed })
|
||||||
|
|
||||||
|
const canPlay = supported && text?.trim().length > 0
|
||||||
|
|
||||||
|
const resolvedSystemLang = useMemo(() => {
|
||||||
|
const mode = settings?.ttsLanguageMode
|
||||||
|
if ((mode ? mode === 'system' : settings?.ttsUseSystemLanguage) === true) {
|
||||||
|
return navigator?.language?.split('-')[0]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [settings?.ttsLanguageMode, settings?.ttsUseSystemLanguage])
|
||||||
|
|
||||||
|
const detectContentLang = useMemo(() => {
|
||||||
|
const mode = settings?.ttsLanguageMode
|
||||||
|
if (mode) return mode === 'content'
|
||||||
|
return settings?.ttsDetectContentLanguage !== false
|
||||||
|
}, [settings?.ttsLanguageMode, settings?.ttsDetectContentLanguage])
|
||||||
|
|
||||||
|
const specificLang = useMemo(() => {
|
||||||
|
const mode = settings?.ttsLanguageMode
|
||||||
|
// If mode is not 'system' or 'content', it's a specific language code
|
||||||
|
if (mode && mode !== 'system' && mode !== 'content') {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [settings?.ttsLanguageMode])
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (!canPlay) return
|
||||||
|
|
||||||
|
if (!speaking) {
|
||||||
|
let langOverride: string | undefined
|
||||||
|
|
||||||
|
// Priority: specific language > content detection > system language
|
||||||
|
if (specificLang) {
|
||||||
|
langOverride = specificLang
|
||||||
|
} else if (detectContentLang && text) {
|
||||||
|
try {
|
||||||
|
const lang = detect(text)
|
||||||
|
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
|
||||||
|
} catch (err) {
|
||||||
|
// ignore detection errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!langOverride && resolvedSystemLang) {
|
||||||
|
langOverride = resolvedSystemLang
|
||||||
|
}
|
||||||
|
speak(text, langOverride)
|
||||||
|
} else if (paused) {
|
||||||
|
resume()
|
||||||
|
} else {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCycleSpeed = () => {
|
||||||
|
const currentIndex = SPEED_OPTIONS.indexOf(rate)
|
||||||
|
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||||
|
const next = SPEED_OPTIONS[nextIndex]
|
||||||
|
setRate(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const playLabel = !speaking ? 'Listen' : (paused ? 'Resume' : 'Pause')
|
||||||
|
|
||||||
|
if (!supported) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className || 'tts-controls'} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
title={playLabel}
|
||||||
|
disabled={!canPlay}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={!speaking ? faPlay : (paused ? faPlay : faPause)} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handleCycleSpeed}
|
||||||
|
title="Cycle speed"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faGauge} />
|
||||||
|
<span>{rate}x</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TTSControls
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { BookmarkList } from './BookmarkList'
|
import { BookmarkList } from './BookmarkList'
|
||||||
import ContentPanel from './ContentPanel'
|
import ContentPanel from './ContentPanel'
|
||||||
|
import VideoView from './VideoView'
|
||||||
import { HighlightsPanel } from './HighlightsPanel'
|
import { HighlightsPanel } from './HighlightsPanel'
|
||||||
import Settings from './Settings'
|
import Settings from './Settings'
|
||||||
import Toast from './Toast'
|
import Toast from './Toast'
|
||||||
@@ -19,6 +20,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
|||||||
import { HighlightButtonRef } from './HighlightButton'
|
import { HighlightButtonRef } from './HighlightButton'
|
||||||
import { BookmarkReference } from '../utils/contentLoader'
|
import { BookmarkReference } from '../utils/contentLoader'
|
||||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
|
import { classifyUrl } from '../utils/helpers'
|
||||||
import { useScrollDirection } from '../hooks/useScrollDirection'
|
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||||
import { IAccount } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
@@ -134,15 +136,30 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
|
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
|
||||||
|
|
||||||
// Lock body scroll when mobile sidebar or highlights is open
|
// Lock body scroll when mobile sidebar or highlights is open
|
||||||
|
const savedScrollPosition = useRef<number>(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
|
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')
|
document.body.classList.add('mobile-sidebar-open')
|
||||||
} else {
|
} else {
|
||||||
|
// Restore scroll position
|
||||||
document.body.classList.remove('mobile-sidebar-open')
|
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 () => {
|
return () => {
|
||||||
document.body.classList.remove('mobile-sidebar-open')
|
document.body.classList.remove('mobile-sidebar-open')
|
||||||
|
document.body.style.top = ''
|
||||||
}
|
}
|
||||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
|
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
|
||||||
|
|
||||||
@@ -306,7 +323,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
|
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
|
||||||
aria-hidden={isMobile && !props.isSidebarOpen}
|
{...(isMobile && !props.isSidebarOpen ? { inert: '' } : {})}
|
||||||
>
|
>
|
||||||
<BookmarkList
|
<BookmarkList
|
||||||
bookmarks={props.bookmarks}
|
bookmarks={props.bookmarks}
|
||||||
@@ -358,42 +375,73 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
{props.support}
|
{props.support}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (() => {
|
||||||
<ContentPanel
|
// Determine if this is a video URL
|
||||||
loading={props.readerLoading}
|
const isNostrArticle = props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||||
title={props.readerContent?.title}
|
const isExternalVideo = !isNostrArticle && !!props.selectedUrl && ['youtube', 'video'].includes(classifyUrl(props.selectedUrl).type)
|
||||||
html={props.readerContent?.html}
|
|
||||||
markdown={props.readerContent?.markdown}
|
if (isExternalVideo) {
|
||||||
image={props.readerContent?.image}
|
return (
|
||||||
summary={props.readerContent?.summary}
|
<VideoView
|
||||||
published={props.readerContent?.published}
|
videoUrl={props.selectedUrl!}
|
||||||
selectedUrl={props.selectedUrl}
|
title={props.readerContent?.title}
|
||||||
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
image={props.readerContent?.image}
|
||||||
? props.highlights // article-specific highlights only
|
summary={props.readerContent?.summary}
|
||||||
: props.classifiedHighlights}
|
published={props.readerContent?.published}
|
||||||
showHighlights={props.showHighlights}
|
settings={props.settings}
|
||||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
relayPool={props.relayPool}
|
||||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
activeAccount={props.activeAccount}
|
||||||
onHighlightClick={props.onHighlightClick}
|
onOpenHighlights={() => {
|
||||||
selectedHighlightId={props.selectedHighlightId}
|
if (props.isHighlightsCollapsed) {
|
||||||
highlightVisibility={props.highlightVisibility}
|
props.onToggleHighlightsPanel()
|
||||||
onTextSelection={props.onTextSelection}
|
}
|
||||||
onClearSelection={props.onClearSelection}
|
}}
|
||||||
currentUserPubkey={props.currentUserPubkey}
|
/>
|
||||||
followedPubkeys={props.followedPubkeys}
|
)
|
||||||
settings={props.settings}
|
}
|
||||||
relayPool={props.relayPool}
|
|
||||||
activeAccount={props.activeAccount}
|
return (
|
||||||
currentArticle={props.currentArticle}
|
<ContentPanel
|
||||||
isSidebarCollapsed={props.isCollapsed}
|
loading={props.readerLoading}
|
||||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
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>
|
||||||
<div
|
<div
|
||||||
ref={highlightsRef}
|
ref={highlightsRef}
|
||||||
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
|
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
|
||||||
aria-hidden={isMobile && props.isHighlightsCollapsed}
|
{...(isMobile && props.isHighlightsCollapsed ? { inert: '' } : {})}
|
||||||
>
|
>
|
||||||
<HighlightsPanel
|
<HighlightsPanel
|
||||||
highlights={props.highlights}
|
highlights={props.highlights}
|
||||||
@@ -413,6 +461,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
eventStore={props.eventStore}
|
eventStore={props.eventStore}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
158
src/components/VideoEmbedProcessor.tsx
Normal file
158
src/components/VideoEmbedProcessor.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useMemo, forwardRef } from 'react'
|
||||||
|
import ReactPlayer from 'react-player'
|
||||||
|
import { classifyUrl } from '../utils/helpers'
|
||||||
|
|
||||||
|
interface VideoEmbedProcessorProps {
|
||||||
|
html: string
|
||||||
|
renderVideoLinksAsEmbeds: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}, 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 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the HTML by video placeholders and render with embedded players
|
||||||
|
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={className}>
|
||||||
|
{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)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Nostr gateway URLs for viewing events and profiles on the web
|
* Nostr gateway URLs for viewing events and profiles on the web
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const NOSTR_GATEWAY = 'https://nostr.at' as const
|
export const NOSTR_GATEWAY = 'https://njump.to' as const
|
||||||
export const SEARCH_PORTAL = 'https://ants.sh' as const
|
export const SEARCH_PORTAL = 'https://ants.sh' as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +24,7 @@ export function getEventUrl(nevent: string): string {
|
|||||||
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
||||||
*/
|
*/
|
||||||
export function getNostrUrl(identifier: string): string {
|
export function getNostrUrl(identifier: string): string {
|
||||||
// nostr.at uses simple /{identifier} format for all types
|
// njump.to uses simple /{identifier} format for all types
|
||||||
return `${NOSTR_GATEWAY}/${identifier}`
|
return `${NOSTR_GATEWAY}/${identifier}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,101 @@
|
|||||||
|
import { normalizeRelayUrl } from '../utils/helpers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized relay configuration
|
* Centralized relay configuration
|
||||||
* Single set of relays used throughout the application
|
* Single set of relays used throughout the application
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// All relays including local relays
|
export type RelayRole = 'local-cache' | 'default' | 'fallback' | 'non-content' | 'bunker'
|
||||||
export const RELAYS = [
|
|
||||||
'ws://localhost:10547',
|
export interface RelayConfig {
|
||||||
'ws://localhost:4869',
|
url: string
|
||||||
'wss://relay.nsec.app',
|
roles: RelayRole[]
|
||||||
'wss://relay.damus.io',
|
}
|
||||||
'wss://nos.lol',
|
|
||||||
'wss://relay.nostr.band',
|
/**
|
||||||
'wss://relay.dergigi.com',
|
* Central relay registry with role annotations
|
||||||
'wss://wot.dergigi.com',
|
*/
|
||||||
'wss://relay.snort.social',
|
const RELAY_CONFIGS: RelayConfig[] = [
|
||||||
'wss://relay.current.fyi',
|
{ url: 'ws://localhost:10547', roles: ['local-cache'] },
|
||||||
'wss://nostr-pub.wellorder.net',
|
{ url: 'ws://localhost:4869', roles: ['local-cache'] },
|
||||||
'wss://purplepag.es',
|
{ url: 'wss://relay.nsec.app', roles: ['default', 'non-content'] },
|
||||||
'wss://relay.primal.net',
|
{ url: 'wss://relay.damus.io', roles: ['default', 'fallback'] },
|
||||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
|
{ url: 'wss://nos.lol', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://relay.nostr.band', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://wot.dergigi.com', roles: ['default'] },
|
||||||
|
{ url: 'wss://relay.snort.social', roles: ['default'] },
|
||||||
|
{ url: 'wss://nostr-pub.wellorder.net', roles: ['default'] },
|
||||||
|
{ url: 'wss://purplepag.es', roles: ['default'] },
|
||||||
|
{ url: 'wss://relay.primal.net', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', roles: ['default'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all local cache relays (localhost relays)
|
||||||
|
*/
|
||||||
|
export function getLocalRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('local-cache'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all default relays (main public relays)
|
||||||
|
*/
|
||||||
|
export function getDefaultRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('default'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fallback content relays (last resort public relays for content fetching)
|
||||||
|
* These are reliable public relays that should be tried when other methods fail
|
||||||
|
*/
|
||||||
|
export function getFallbackContentRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('fallback'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relays suitable for content fetching (excludes non-content relays like auth/signer relays)
|
||||||
|
*/
|
||||||
|
export function getContentRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => !config.roles.includes('non-content'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relays that should NOT be used as content hints
|
||||||
|
*/
|
||||||
|
export function getNonContentRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('non-content'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All relays including local relays (backwards compatibility)
|
||||||
|
*/
|
||||||
|
export const RELAYS = [
|
||||||
|
...getLocalRelays(),
|
||||||
|
...getDefaultRelays(),
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relays that should NOT be used as content hints (backwards compatibility)
|
||||||
|
*/
|
||||||
|
export const NON_CONTENT_RELAYS = getNonContentRelays()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a relay URL is suitable for use as a content hint
|
||||||
|
* Returns true for relays that are reasonable for posts/highlights
|
||||||
|
*/
|
||||||
|
export function isContentRelay(url: string): boolean {
|
||||||
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
const nonContentRelays = getNonContentRelays().map(normalizeRelayUrl)
|
||||||
|
return !nonContentRelays.includes(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useRef, useState, Dispatch, SetStateAction } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { fetchArticleByNaddr } from '../services/articleService'
|
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, getFromCache, saveToCache } from '../services/articleService'
|
||||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
|
import { preloadImage } from './useImageCache'
|
||||||
import { ReadableContent } from '../services/readerService'
|
import { ReadableContent } from '../services/readerService'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { useDocumentTitle } from './useDocumentTitle'
|
||||||
|
|
||||||
|
interface PreviewData {
|
||||||
|
title: string
|
||||||
|
image?: string
|
||||||
|
summary?: string
|
||||||
|
published?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationState {
|
||||||
|
previewData?: PreviewData
|
||||||
|
articleCoordinate?: string
|
||||||
|
eventId?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface UseArticleLoaderProps {
|
interface UseArticleLoaderProps {
|
||||||
naddr: string | undefined
|
naddr: string | undefined
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
|
eventStore?: IEventStore | null
|
||||||
setSelectedUrl: (url: string) => void
|
setSelectedUrl: (url: string) => void
|
||||||
setReaderContent: (content: ReadableContent | undefined) => void
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
setReaderLoading: (loading: boolean) => void
|
setReaderLoading: (loading: boolean) => void
|
||||||
setIsCollapsed: (collapsed: boolean) => void
|
setIsCollapsed: (collapsed: boolean) => void
|
||||||
setHighlights: (highlights: Highlight[]) => void
|
setHighlights: Dispatch<SetStateAction<Highlight[]>>
|
||||||
setHighlightsLoading: (loading: boolean) => void
|
setHighlightsLoading: (loading: boolean) => void
|
||||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||||
setCurrentArticleEventId: (id: string | undefined) => void
|
setCurrentArticleEventId: (id: string | undefined) => void
|
||||||
@@ -25,6 +47,7 @@ interface UseArticleLoaderProps {
|
|||||||
export function useArticleLoader({
|
export function useArticleLoader({
|
||||||
naddr,
|
naddr,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
@@ -36,76 +59,651 @@ export function useArticleLoader({
|
|||||||
setCurrentArticle,
|
setCurrentArticle,
|
||||||
settings
|
settings
|
||||||
}: UseArticleLoaderProps) {
|
}: UseArticleLoaderProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
// Hold latest settings without retriggering effect
|
||||||
|
const settingsRef = useRef<UserSettings | undefined>(settings)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !naddr) return
|
settingsRef.current = settings
|
||||||
|
}, [settings])
|
||||||
|
// Track in-flight request to prevent stale updates from previous naddr
|
||||||
|
const currentRequestIdRef = useRef(0)
|
||||||
|
|
||||||
|
// Extract navigation state (from blog post cards)
|
||||||
|
const navState = (location.state as NavigationState | null) || {}
|
||||||
|
const previewData = navState.previewData
|
||||||
|
const navArticleCoordinate = navState.articleCoordinate
|
||||||
|
const navEventId = navState.eventId
|
||||||
|
|
||||||
|
// Track the current article title for document title
|
||||||
|
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||||
|
useDocumentTitle({ title: currentTitle })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
|
||||||
const loadArticle = async () => {
|
// First check: naddr is required
|
||||||
setReaderLoading(true)
|
if (!naddr) {
|
||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
setSelectedUrl(`nostr:${naddr}`)
|
return
|
||||||
setIsCollapsed(true)
|
}
|
||||||
// Keep highlights panel collapsed by default - only open on user interaction
|
|
||||||
|
// Clear readerContent immediately to prevent showing stale content from previous article
|
||||||
|
// This ensures images from previous articles don't flash briefly
|
||||||
|
setReaderContent(undefined)
|
||||||
|
|
||||||
|
// FIRST: Check navigation state for article coordinate/eventId (from Explore)
|
||||||
|
// This allows immediate hydration when coming from Explore without refetching
|
||||||
|
let foundInNavState = false
|
||||||
|
if (eventStore && (navArticleCoordinate || navEventId)) {
|
||||||
try {
|
try {
|
||||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
let storedEvent: NostrEvent | undefined
|
||||||
|
|
||||||
|
// Try coordinate first (most reliable for replaceable events)
|
||||||
|
if (navArticleCoordinate) {
|
||||||
|
storedEvent = eventStore.getEvent?.(navArticleCoordinate) as NostrEvent | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to eventId if coordinate lookup failed
|
||||||
|
if (!storedEvent && navEventId) {
|
||||||
|
// Note: eventStore.getEvent might not support eventId lookup directly
|
||||||
|
// We'll decode naddr to get coordinate as fallback
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||||
|
storedEvent = eventStore.getEvent?.(coordinate) as NostrEvent | undefined
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore decode errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedEvent) {
|
||||||
|
foundInNavState = true
|
||||||
|
const title = Helpers.getArticleTitle(storedEvent) || previewData?.title || 'Untitled Article'
|
||||||
|
setCurrentTitle(title)
|
||||||
|
const image = Helpers.getArticleImage(storedEvent) || previewData?.image
|
||||||
|
const summary = Helpers.getArticleSummary(storedEvent) || previewData?.summary
|
||||||
|
const published = Helpers.getArticlePublished(storedEvent) || previewData?.published
|
||||||
|
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)
|
||||||
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
|
setIsCollapsed(true)
|
||||||
|
|
||||||
|
// Preload image if available
|
||||||
|
if (image) {
|
||||||
|
preloadImage(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch highlights in background if relayPool is available
|
||||||
|
if (relayPool) {
|
||||||
|
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
|
||||||
|
const eventId = storedEvent.id
|
||||||
|
|
||||||
|
if (coord && eventId) {
|
||||||
|
setHighlightsLoading(true)
|
||||||
|
fetchHighlightsForArticle(
|
||||||
|
relayPool,
|
||||||
|
coord,
|
||||||
|
eventId,
|
||||||
|
(highlight) => {
|
||||||
|
if (!mountedRef.current) 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)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
false,
|
||||||
|
eventStore || undefined
|
||||||
|
).then(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background query to check for newer replaceable version
|
||||||
|
// but don't block UI - we already have content
|
||||||
|
if (relayPool) {
|
||||||
|
const backgroundRequestId = ++currentRequestIdRef.current
|
||||||
|
const originalCreatedAt = storedEvent.created_at
|
||||||
|
|
||||||
|
// Fire and forget background fetch
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type !== 'naddr') return
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const filter = {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryEvents(relayPool, filter, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (!mountedRef.current || currentRequestIdRef.current !== backgroundRequestId) return
|
||||||
|
|
||||||
|
// Store in event store
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
eventStore?.add?.(evt as unknown as any)
|
||||||
|
} catch {
|
||||||
|
// Ignore store errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if this is a newer version than what we loaded
|
||||||
|
if (evt.created_at > originalCreatedAt) {
|
||||||
|
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
|
||||||
|
const image = Helpers.getArticleImage(evt)
|
||||||
|
const summary = Helpers.getArticleSummary(evt)
|
||||||
|
const published = Helpers.getArticlePublished(evt)
|
||||||
|
|
||||||
|
setCurrentTitle(title)
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
const articleContent = {
|
||||||
|
title,
|
||||||
|
markdown: evt.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
author: evt.pubkey,
|
||||||
|
event: evt
|
||||||
|
}
|
||||||
|
saveToCache(naddr, articleContent, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// Silently ignore background fetch errors - we already have content
|
||||||
|
console.warn('[article-loader] Background fetch failed:', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return early - we have content from navigation state
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If navigation state lookup fails, fall through to cache/EventStore
|
||||||
|
console.warn('[article-loader] Navigation state lookup failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronously check cache sources BEFORE checking relayPool
|
||||||
|
// This prevents showing loading skeletons when content is immediately available
|
||||||
|
// and fixes the race condition where relayPool isn't ready yet
|
||||||
|
let foundInCache = false
|
||||||
|
try {
|
||||||
|
// Check localStorage cache first (synchronous, doesn't need relayPool)
|
||||||
|
const cachedArticle = getFromCache(naddr)
|
||||||
|
if (cachedArticle) {
|
||||||
|
foundInCache = true
|
||||||
|
const title = cachedArticle.title || 'Untitled Article'
|
||||||
|
setCurrentTitle(title)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: article.title,
|
title,
|
||||||
markdown: article.markdown,
|
markdown: cachedArticle.markdown,
|
||||||
image: article.image,
|
image: cachedArticle.image,
|
||||||
summary: article.summary,
|
summary: cachedArticle.summary,
|
||||||
published: article.published,
|
published: cachedArticle.published,
|
||||||
url: `nostr:${naddr}`
|
url: `nostr:${naddr}`
|
||||||
})
|
})
|
||||||
|
const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}`
|
||||||
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
|
|
||||||
|
|
||||||
setCurrentArticleCoordinate(articleCoordinate)
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
setCurrentArticleEventId(article.event.id)
|
setCurrentArticleEventId(cachedArticle.event.id)
|
||||||
setCurrentArticle?.(article.event)
|
setCurrentArticle?.(cachedArticle.event)
|
||||||
|
|
||||||
|
|
||||||
// Set reader loading to false immediately after article content is ready
|
|
||||||
// Don't wait for highlights to finish loading
|
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
|
setIsCollapsed(true)
|
||||||
|
|
||||||
// Fetch highlights asynchronously without blocking article display
|
// Preload image if available to ensure it's cached by Service Worker
|
||||||
// Stream them as they arrive for instant rendering
|
// This ensures images are available when offline
|
||||||
try {
|
if (cachedArticle.image) {
|
||||||
setHighlightsLoading(true)
|
preloadImage(cachedArticle.image)
|
||||||
setHighlights([]) // Clear old highlights
|
}
|
||||||
const highlightsMap = new Map<string, Highlight>()
|
|
||||||
|
// Store in EventStore for future lookups
|
||||||
|
if (eventStore) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
eventStore.add?.(cachedArticle.event as unknown as any)
|
||||||
|
} catch {
|
||||||
|
// Silently ignore store errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch highlights in background (don't block UI)
|
||||||
|
// Only fetch highlights if relayPool is available
|
||||||
|
if (mountedRef.current && relayPool) {
|
||||||
|
const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined
|
||||||
|
const eventId = cachedArticle.event.id
|
||||||
|
|
||||||
await fetchHighlightsForArticle(
|
if (coord && eventId) {
|
||||||
relayPool,
|
setHighlightsLoading(true)
|
||||||
articleCoordinate,
|
fetchHighlightsForArticle(
|
||||||
article.event.id,
|
relayPool,
|
||||||
(highlight) => {
|
coord,
|
||||||
// Deduplicate highlights by ID as they arrive
|
eventId,
|
||||||
if (!highlightsMap.has(highlight.id)) {
|
(highlight) => {
|
||||||
highlightsMap.set(highlight.id, highlight)
|
if (!mountedRef.current) return
|
||||||
const highlightsList = Array.from(highlightsMap.values())
|
setHighlights((prev: Highlight[]) => {
|
||||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
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)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
false,
|
||||||
|
eventStore || undefined
|
||||||
|
).then(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
},
|
}).catch(() => {
|
||||||
settings
|
if (mountedRef.current) {
|
||||||
)
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return early - we have cached content, no need to query relays
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If cache check fails, fall through to async loading
|
||||||
|
console.warn('[article-loader] Cache check failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check EventStore synchronously (also doesn't need relayPool)
|
||||||
|
let foundInEventStore = false
|
||||||
|
if (eventStore && !foundInCache && !foundInNavState) {
|
||||||
|
try {
|
||||||
|
// Decode naddr to get the coordinate
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||||
|
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||||
|
if (storedEvent) {
|
||||||
|
foundInEventStore = 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)
|
||||||
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
|
setIsCollapsed(true)
|
||||||
|
|
||||||
|
// Fetch highlights in background if relayPool is available
|
||||||
|
if (relayPool) {
|
||||||
|
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
|
||||||
|
const eventId = storedEvent.id
|
||||||
|
|
||||||
|
if (coord && eventId) {
|
||||||
|
setHighlightsLoading(true)
|
||||||
|
fetchHighlightsForArticle(
|
||||||
|
relayPool,
|
||||||
|
coord,
|
||||||
|
eventId,
|
||||||
|
(highlight) => {
|
||||||
|
if (!mountedRef.current) 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)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
false,
|
||||||
|
eventStore || undefined
|
||||||
|
).then(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return early - we have EventStore content, no need to query relays yet
|
||||||
|
// But we might want to fetch from relays in background if relayPool becomes available
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore store errors, fall through to relay query
|
||||||
|
console.warn('[article-loader] EventStore check failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return early if we have no content AND no relayPool to fetch from
|
||||||
|
if (!relayPool && !foundInCache && !foundInEventStore && !foundInNavState) {
|
||||||
|
setReaderLoading(true)
|
||||||
|
setReaderContent(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have relayPool, proceed with async loading
|
||||||
|
if (!relayPool) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadArticle = async () => {
|
||||||
|
const requestId = ++currentRequestIdRef.current
|
||||||
|
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
|
setIsCollapsed(true)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Note: Cache and EventStore were already checked synchronously above
|
||||||
|
// This async function only runs if we need to fetch from relays
|
||||||
|
|
||||||
|
// At this point, we've checked EventStore and cache - neither had content
|
||||||
|
// Only show loading skeleton if we also don't have preview data
|
||||||
|
if (previewData) {
|
||||||
|
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||||
|
setCurrentTitle(previewData.title)
|
||||||
|
setReaderContent({
|
||||||
|
title: previewData.title,
|
||||||
|
markdown: '', // Will be loaded from relay
|
||||||
|
image: previewData.image,
|
||||||
|
summary: previewData.summary,
|
||||||
|
published: previewData.published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
||||||
|
|
||||||
|
// Don't preload image here - it should already be cached from BlogPostCard
|
||||||
|
// Preloading again would be redundant and could cause unnecessary network requests
|
||||||
|
} else {
|
||||||
|
// No cache, no EventStore, no preview data - need to load from relays
|
||||||
|
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
|
||||||
|
|
||||||
|
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
|
||||||
|
const events = await queryEvents(relayPool, filter, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentRequestIdRef.current !== requestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in event store for future local reads
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
eventStore?.add?.(evt as unknown as any)
|
||||||
|
} catch {
|
||||||
|
// Silently ignore store errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep latest by created_at
|
||||||
|
if (!latestEvent || evt.created_at > latestEvent.created_at) {
|
||||||
|
latestEvent = evt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit immediately on first event
|
||||||
|
if (!firstEmitted) {
|
||||||
|
firstEmitted = true
|
||||||
|
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
|
||||||
|
const image = Helpers.getArticleImage(evt)
|
||||||
|
const summary = Helpers.getArticleSummary(evt)
|
||||||
|
const published = Helpers.getArticlePublished(evt)
|
||||||
|
|
||||||
|
setCurrentTitle(title)
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Save to cache immediately when we get the first event
|
||||||
|
// Don't wait for queryEvents to complete in case it hangs
|
||||||
|
const articleContent = {
|
||||||
|
title,
|
||||||
|
markdown: evt.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
author: evt.pubkey,
|
||||||
|
event: evt
|
||||||
|
}
|
||||||
|
saveToCache(naddr, articleContent, settings)
|
||||||
|
|
||||||
|
// Preload image to ensure it's cached by Service Worker
|
||||||
|
if (image) {
|
||||||
|
preloadImage(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!mountedRef.current || currentRequestIdRef.current !== requestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize with newest version if it's newer than what we first rendered
|
||||||
|
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
|
||||||
|
if (finalEvent) {
|
||||||
|
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
|
||||||
|
const image = Helpers.getArticleImage(finalEvent)
|
||||||
|
const summary = Helpers.getArticleSummary(finalEvent)
|
||||||
|
const published = Helpers.getArticlePublished(finalEvent)
|
||||||
|
|
||||||
|
setCurrentTitle(title)
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Save to cache for future loads (if we haven't already saved from first event)
|
||||||
|
// Only save if this is a different/newer event than what we first rendered
|
||||||
|
// Note: We already saved from first event, so only save if this is different
|
||||||
|
if (!firstEmitted) {
|
||||||
|
// First event wasn't emitted, so save now
|
||||||
|
const articleContent = {
|
||||||
|
title,
|
||||||
|
markdown: finalEvent.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
author: finalEvent.pubkey,
|
||||||
|
event: finalEvent
|
||||||
|
}
|
||||||
|
saveToCache(naddr, articleContent)
|
||||||
|
}
|
||||||
|
} 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 {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setHighlightsLoading(false)
|
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load article:', err)
|
console.error('Failed to load article:', err)
|
||||||
setReaderContent({
|
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||||
title: 'Error Loading Article',
|
setReaderContent({
|
||||||
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
title: 'Error Loading Article',
|
||||||
url: `nostr:${naddr}`
|
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||||
})
|
url: `nostr:${naddr}`
|
||||||
setReaderLoading(false)
|
})
|
||||||
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadArticle()
|
loadArticle()
|
||||||
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
// Include relayPool in dependencies so effect re-runs when it becomes available
|
||||||
|
// This fixes the race condition where articles don't load on direct navigation
|
||||||
|
// We guard against unnecessary re-renders by checking cache/EventStore first
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
naddr,
|
||||||
|
previewData,
|
||||||
|
relayPool
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,10 @@ export const useBookmarksData = ({
|
|||||||
|
|
||||||
// Fetch article-specific highlights when viewing an article
|
// Fetch article-specific highlights when viewing an article
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Fetch article-specific highlights when viewing an article
|
// Fetch article-specific highlights when viewing an article
|
||||||
// External URLs have their highlights fetched by useExternalUrlLoader
|
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||||
if (effectiveArticleCoordinate && !externalUrl) {
|
if (effectiveArticleCoordinate && !externalUrl) {
|
||||||
@@ -167,6 +170,9 @@ export const useBookmarksData = ({
|
|||||||
// Clear article highlights when not viewing an article
|
// Clear article highlights when not viewing an article
|
||||||
setArticleHighlights([])
|
setArticleHighlights([])
|
||||||
setHighlightsLoading(false)
|
setHighlightsLoading(false)
|
||||||
|
} else {
|
||||||
|
// For external URLs or other cases, loading is not needed
|
||||||
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||||
|
|
||||||
|
|||||||
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 }
|
||||||
|
}
|
||||||
143
src/hooks/useEventLoader.ts
Normal file
143
src/hooks/useEventLoader.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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'
|
||||||
|
import { getNpubFallbackDisplay } from '../utils/nostrUriResolver'
|
||||||
|
import { extractProfileDisplayName } from '../utils/profileUtils'
|
||||||
|
|
||||||
|
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 ${getNpubFallbackDisplay(event.pubkey)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const displayName = extractProfileDisplayName(storedProfile as NostrEvent)
|
||||||
|
if (displayName && !displayName.startsWith('@')) {
|
||||||
|
// Remove @ prefix if present (we'll add it when displaying)
|
||||||
|
resolved = displayName
|
||||||
|
} else if (displayName) {
|
||||||
|
resolved = displayName.substring(1) // Remove @ prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
const displayName = extractProfileDisplayName(latest)
|
||||||
|
if (displayName && !displayName.startsWith('@')) {
|
||||||
|
resolved = displayName
|
||||||
|
} else if (displayName) {
|
||||||
|
resolved = displayName.substring(1) // Remove @ prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,4 +1,4 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useRef, useMemo, useState } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||||
@@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { useStoreTimeline } from './useStoreTimeline'
|
import { useStoreTimeline } from './useStoreTimeline'
|
||||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { useDocumentTitle } from './useDocumentTitle'
|
||||||
|
|
||||||
// Helper to extract filename from URL
|
// Helper to extract filename from URL
|
||||||
function getFilenameFromUrl(url: string): string {
|
function getFilenameFromUrl(url: string): string {
|
||||||
@@ -48,6 +49,14 @@ export function useExternalUrlLoader({
|
|||||||
setCurrentArticleCoordinate,
|
setCurrentArticleCoordinate,
|
||||||
setCurrentArticleEventId
|
setCurrentArticleEventId
|
||||||
}: UseExternalUrlLoaderProps) {
|
}: 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
|
// Load cached URL-specific highlights from event store
|
||||||
const urlFilter = useMemo(() => {
|
const urlFilter = useMemo(() => {
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
@@ -61,79 +70,120 @@ export function useExternalUrlLoader({
|
|||||||
[url]
|
[url]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Load content and start streaming highlights when URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
|
||||||
if (!relayPool || !url) return
|
if (!relayPool || !url) return
|
||||||
|
|
||||||
const loadExternalUrl = async () => {
|
const loadExternalUrl = async () => {
|
||||||
|
const requestId = ++currentRequestIdRef.current
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderLoading(true)
|
setReaderLoading(true)
|
||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
setSelectedUrl(url)
|
setSelectedUrl(url)
|
||||||
setIsCollapsed(true)
|
setIsCollapsed(true)
|
||||||
// Clear article-specific state
|
|
||||||
setCurrentArticleCoordinate(undefined)
|
setCurrentArticleCoordinate(undefined)
|
||||||
setCurrentArticleEventId(undefined)
|
setCurrentArticleEventId(undefined)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await fetchReadableContent(url)
|
const content = await fetchReadableContent(url)
|
||||||
|
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
if (currentRequestIdRef.current !== requestId) return
|
||||||
|
|
||||||
|
setCurrentTitle(content.title)
|
||||||
setReaderContent(content)
|
setReaderContent(content)
|
||||||
|
|
||||||
|
|
||||||
// Set reader loading to false immediately after content is ready
|
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|
||||||
// Fetch highlights for this URL asynchronously
|
// Fetch highlights for this URL asynchronously
|
||||||
try {
|
try {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
|
|
||||||
// Seed with cached highlights first
|
// Seed with cached highlights first
|
||||||
if (cachedUrlHighlights.length > 0) {
|
if (cachedUrlHighlights.length > 0) {
|
||||||
setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at))
|
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 {
|
} else {
|
||||||
setHighlights([])
|
setHighlights([])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
|
||||||
if (typeof fetchHighlightsForUrl === 'function') {
|
if (typeof fetchHighlightsForUrl === 'function') {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
// Seed with cached IDs
|
|
||||||
cachedUrlHighlights.forEach(h => seen.add(h.id))
|
cachedUrlHighlights.forEach(h => seen.add(h.id))
|
||||||
|
|
||||||
await fetchHighlightsForUrl(
|
await fetchHighlightsForUrl(
|
||||||
relayPool,
|
relayPool,
|
||||||
url,
|
url,
|
||||||
(highlight) => {
|
(highlight) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
if (currentRequestIdRef.current !== requestId) return
|
||||||
|
|
||||||
if (seen.has(highlight.id)) return
|
if (seen.has(highlight.id)) return
|
||||||
seen.add(highlight.id)
|
seen.add(highlight.id)
|
||||||
setHighlights((prev) => {
|
setHighlights((prev) => {
|
||||||
if (prev.some(h => h.id === highlight.id)) return prev
|
if (prev.some(h => h.id === highlight.id)) return prev
|
||||||
const next = [...prev, highlight]
|
const next = [highlight, ...prev]
|
||||||
return next.sort((a, b) => b.created_at - a.created_at)
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
undefined, // settings
|
undefined,
|
||||||
false, // force
|
false,
|
||||||
eventStore || undefined
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setHighlightsLoading(false)
|
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load external URL:', err)
|
console.error('Failed to load external URL:', err)
|
||||||
// For videos and other media files, use the filename as the title
|
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||||
const filename = getFilenameFromUrl(url)
|
const filename = getFilenameFromUrl(url)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: filename,
|
title: filename,
|
||||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||||
url
|
url
|
||||||
})
|
})
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadExternalUrl()
|
loadExternalUrl()
|
||||||
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights])
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
|
||||||
|
// This fixes the loading skeleton appearing when going offline (flight mode)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
url,
|
||||||
|
cachedUrlHighlights
|
||||||
|
])
|
||||||
|
|
||||||
|
// 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 { HighlightVisibility } from '../components/HighlightsPanel'
|
||||||
import { normalizeUrl } from '../utils/urlHelpers'
|
import { normalizeUrl } from '../utils/urlHelpers'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
interface UseFilteredHighlightsParams {
|
interface UseFilteredHighlightsParams {
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
@@ -24,8 +25,29 @@ export const useFilteredHighlights = ({
|
|||||||
|
|
||||||
let urlFiltered = highlights
|
let urlFiltered = highlights
|
||||||
|
|
||||||
// For Nostr articles, we already fetched highlights specifically for this article
|
// Filter highlights based on URL type
|
||||||
if (!selectedUrl.startsWith('nostr:')) {
|
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)
|
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||||
|
|
||||||
urlFiltered = highlights.filter(h => {
|
urlFiltered = highlights.filter(h => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const useHighlightCreation = ({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleCreateHighlight = useCallback(async (text: string) => {
|
const handleCreateHighlight = useCallback(async (text: string) => {
|
||||||
|
|
||||||
if (!activeAccount || !relayPool || !eventStore) {
|
if (!activeAccount || !relayPool || !eventStore) {
|
||||||
console.error('Missing requirements for highlight creation')
|
console.error('Missing requirements for highlight creation')
|
||||||
return
|
return
|
||||||
@@ -60,7 +61,6 @@ export const useHighlightCreation = ({
|
|||||||
? currentArticle.content
|
? currentArticle.content
|
||||||
: readerContent?.markdown || readerContent?.html
|
: readerContent?.markdown || readerContent?.html
|
||||||
|
|
||||||
|
|
||||||
const newHighlight = await createHighlight(
|
const newHighlight = await createHighlight(
|
||||||
text,
|
text,
|
||||||
source,
|
source,
|
||||||
@@ -73,7 +73,6 @@ export const useHighlightCreation = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Highlight created successfully
|
// Highlight created successfully
|
||||||
|
|
||||||
// Clear the browser's text selection immediately to allow DOM update
|
// Clear the browser's text selection immediately to allow DOM update
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection) {
|
if (selection) {
|
||||||
|
|||||||
@@ -93,26 +93,37 @@ export const useHighlightInteractions = ({
|
|||||||
return () => clearTimeout(timeoutId)
|
return () => clearTimeout(timeoutId)
|
||||||
}, [selectedHighlightId, contentVersion])
|
}, [selectedHighlightId, contentVersion])
|
||||||
|
|
||||||
// Handle text selection (works for both mouse and touch)
|
// Shared function to check and handle text selection
|
||||||
const handleSelectionEnd = useCallback(() => {
|
const checkSelection = useCallback(() => {
|
||||||
setTimeout(() => {
|
const selection = window.getSelection()
|
||||||
const selection = window.getSelection()
|
if (!selection || selection.rangeCount === 0) {
|
||||||
if (!selection || selection.rangeCount === 0) {
|
onClearSelection?.()
|
||||||
onClearSelection?.()
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
const text = selection.toString().trim()
|
const text = selection.toString().trim()
|
||||||
|
|
||||||
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
||||||
onTextSelection?.(text)
|
onTextSelection?.(text)
|
||||||
} else {
|
} else {
|
||||||
onClearSelection?.()
|
onClearSelection?.()
|
||||||
}
|
}
|
||||||
}, 10)
|
|
||||||
}, [onTextSelection, onClearSelection])
|
}, [onTextSelection, onClearSelection])
|
||||||
|
|
||||||
return { contentRef, handleSelectionEnd }
|
// Listen to selectionchange events for immediate detection (works reliably on mobile)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
// Use requestAnimationFrame to ensure selection is checked after browser updates
|
||||||
|
requestAnimationFrame(checkSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('selectionchange', handleSelectionChange)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('selectionchange', handleSelectionChange)
|
||||||
|
}
|
||||||
|
}, [checkSelection])
|
||||||
|
|
||||||
|
return { contentRef }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export function useImageCache(
|
|||||||
imageUrl: string | undefined
|
imageUrl: string | undefined
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
// Service Worker handles everything - just return the URL as-is
|
// Service Worker handles everything - just return the URL as-is
|
||||||
|
// The Service Worker will intercept fetch requests and cache them
|
||||||
|
// Make sure images use standard <img src> tags for SW interception
|
||||||
return imageUrl
|
return imageUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,3 +28,26 @@ export function useCacheImageOnLoad(
|
|||||||
void imageUrl
|
void imageUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload an image URL to ensure it's cached by the Service Worker
|
||||||
|
* This is useful when loading content from cache - we want to ensure
|
||||||
|
* images are cached before going offline
|
||||||
|
*/
|
||||||
|
export function preloadImage(imageUrl: string | undefined): void {
|
||||||
|
if (!imageUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a link element with rel=prefetch or use Image object to trigger fetch
|
||||||
|
// Service Worker will intercept and cache the request
|
||||||
|
const img = new Image()
|
||||||
|
img.src = imageUrl
|
||||||
|
|
||||||
|
// Also try using fetch to explicitly trigger Service Worker
|
||||||
|
// This ensures the image is cached even if <img> tag hasn't rendered yet
|
||||||
|
fetch(imageUrl, { mode: 'no-cors' }).catch(() => {
|
||||||
|
// Ignore errors - image might not be CORS-enabled, but SW will still cache it
|
||||||
|
// The Image() approach above will work for most cases
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
|
import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels, addLoadingClassToProfileLinks } from '../utils/nostrUriResolver'
|
||||||
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
||||||
|
import { useProfileLabels } from './useProfileLabels'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
||||||
@@ -18,56 +19,129 @@ export const useMarkdownToHTML = (
|
|||||||
const previewRef = useRef<HTMLDivElement>(null)
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||||
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||||
|
const [articleTitles, setArticleTitles] = useState<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
// Resolve profile labels progressively as profiles load
|
||||||
|
const { labels: profileLabels, loading: profileLoading } = useProfileLabels(markdown || '', relayPool)
|
||||||
|
|
||||||
|
// Create stable dependencies based on Map contents, not Map objects
|
||||||
|
// This prevents unnecessary reprocessing when Maps are recreated with same content
|
||||||
|
const profileLabelsKey = useMemo(() => {
|
||||||
|
const key = Array.from(profileLabels.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|')
|
||||||
|
return key
|
||||||
|
}, [profileLabels])
|
||||||
|
|
||||||
|
const profileLoadingKey = useMemo(() => {
|
||||||
|
return Array.from(profileLoading.entries())
|
||||||
|
.filter(([, loading]) => loading)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([k]) => k)
|
||||||
|
.join('|')
|
||||||
|
}, [profileLoading])
|
||||||
|
|
||||||
|
const articleTitlesKey = useMemo(() => {
|
||||||
|
return Array.from(articleTitles.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|')
|
||||||
|
}, [articleTitles])
|
||||||
|
|
||||||
|
// Keep refs to latest Maps for processing without causing re-renders
|
||||||
|
const profileLabelsRef = useRef(profileLabels)
|
||||||
|
const profileLoadingRef = useRef(profileLoading)
|
||||||
|
const articleTitlesRef = useRef(articleTitles)
|
||||||
|
|
||||||
|
// Ref to track second RAF ID for HTML extraction cleanup
|
||||||
|
const htmlExtractionRafIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!markdown) {
|
profileLabelsRef.current = profileLabels
|
||||||
setRenderedHtml('')
|
profileLoadingRef.current = profileLoading
|
||||||
setProcessedMarkdown('')
|
articleTitlesRef.current = articleTitles
|
||||||
|
}, [profileLabels, profileLoading, articleTitles])
|
||||||
|
|
||||||
|
// Fetch article titles
|
||||||
|
useEffect(() => {
|
||||||
|
if (!markdown || !relayPool) {
|
||||||
|
setArticleTitles(new Map())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let isCancelled = false
|
let isCancelled = false
|
||||||
|
|
||||||
const processMarkdown = async () => {
|
const fetchTitles = async () => {
|
||||||
// Extract all naddr references
|
|
||||||
const naddrs = extractNaddrUris(markdown)
|
const naddrs = extractNaddrUris(markdown)
|
||||||
|
if (naddrs.length === 0) {
|
||||||
let processed: string
|
setArticleTitles(new Map())
|
||||||
|
return
|
||||||
if (naddrs.length > 0 && relayPool) {
|
|
||||||
// Fetch article titles for all naddrs
|
|
||||||
try {
|
|
||||||
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
|
|
||||||
|
|
||||||
if (isCancelled) return
|
|
||||||
|
|
||||||
// Replace nostr URIs with resolved titles
|
|
||||||
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch article titles:', error)
|
|
||||||
// Fall back to basic replacement
|
|
||||||
processed = replaceNostrUrisInMarkdown(markdown)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No articles to resolve, use basic replacement
|
|
||||||
processed = replaceNostrUrisInMarkdown(markdown)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCancelled) return
|
|
||||||
|
|
||||||
setProcessedMarkdown(processed)
|
|
||||||
|
|
||||||
|
try {
|
||||||
const rafId = requestAnimationFrame(() => {
|
const titlesMap = await fetchArticleTitles(relayPool!, naddrs)
|
||||||
if (previewRef.current && !isCancelled) {
|
if (!isCancelled) {
|
||||||
const html = previewRef.current.innerHTML
|
setArticleTitles(titlesMap)
|
||||||
setRenderedHtml(html)
|
|
||||||
} else if (!isCancelled) {
|
|
||||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
|
||||||
}
|
}
|
||||||
})
|
} catch {
|
||||||
|
if (!isCancelled) setArticleTitles(new Map())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => cancelAnimationFrame(rafId)
|
fetchTitles()
|
||||||
|
return () => { isCancelled = true }
|
||||||
|
}, [markdown, relayPool])
|
||||||
|
|
||||||
|
// Track previous markdown and processed state to detect actual content changes
|
||||||
|
const previousMarkdownRef = useRef<string | undefined>(markdown)
|
||||||
|
const processedMarkdownRef = useRef<string>(processedMarkdown)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
processedMarkdownRef.current = processedMarkdown
|
||||||
|
}, [processedMarkdown])
|
||||||
|
|
||||||
|
// Process markdown with progressive profile labels and article titles
|
||||||
|
// Use stable string keys instead of Map objects to prevent excessive reprocessing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!markdown) {
|
||||||
|
setRenderedHtml('')
|
||||||
|
setProcessedMarkdown('')
|
||||||
|
previousMarkdownRef.current = markdown
|
||||||
|
processedMarkdownRef.current = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
|
const processMarkdown = () => {
|
||||||
|
try {
|
||||||
|
// Replace nostr URIs with profile labels (progressive) and article titles
|
||||||
|
// Use refs to get latest values without causing dependency changes
|
||||||
|
const processed = replaceNostrUrisInMarkdownWithProfileLabels(
|
||||||
|
markdown,
|
||||||
|
profileLabelsRef.current,
|
||||||
|
articleTitlesRef.current,
|
||||||
|
profileLoadingRef.current
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isCancelled) return
|
||||||
|
|
||||||
|
setProcessedMarkdown(processed)
|
||||||
|
processedMarkdownRef.current = processed
|
||||||
|
// HTML extraction will happen in separate useEffect that watches processedMarkdown
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[markdown-to-html] Error processing markdown:`, error)
|
||||||
|
if (!isCancelled) {
|
||||||
|
setProcessedMarkdown(markdown) // Fallback to original
|
||||||
|
processedMarkdownRef.current = markdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clear previous content if this is the first processing or markdown changed
|
||||||
|
// For profile updates, just reprocess without clearing to avoid flicker
|
||||||
|
const isMarkdownChange = previousMarkdownRef.current !== markdown
|
||||||
|
previousMarkdownRef.current = markdown
|
||||||
|
|
||||||
|
if (isMarkdownChange || !processedMarkdownRef.current) {
|
||||||
|
setRenderedHtml('')
|
||||||
|
setProcessedMarkdown('')
|
||||||
|
processedMarkdownRef.current = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
processMarkdown()
|
processMarkdown()
|
||||||
@@ -75,7 +149,44 @@ export const useMarkdownToHTML = (
|
|||||||
return () => {
|
return () => {
|
||||||
isCancelled = true
|
isCancelled = true
|
||||||
}
|
}
|
||||||
}, [markdown, relayPool])
|
}, [markdown, profileLabelsKey, profileLoadingKey, articleTitlesKey])
|
||||||
|
|
||||||
|
// Extract HTML after processedMarkdown renders
|
||||||
|
// This useEffect watches processedMarkdown and extracts HTML once ReactMarkdown has rendered it
|
||||||
|
useEffect(() => {
|
||||||
|
if (!processedMarkdown || !markdown) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
|
// Use double RAF to ensure ReactMarkdown has finished rendering:
|
||||||
|
// First RAF: let React complete its render cycle
|
||||||
|
// Second RAF: extract HTML after DOM has updated
|
||||||
|
const rafId1 = requestAnimationFrame(() => {
|
||||||
|
htmlExtractionRafIdRef.current = requestAnimationFrame(() => {
|
||||||
|
if (previewRef.current && !isCancelled) {
|
||||||
|
let html = previewRef.current.innerHTML
|
||||||
|
|
||||||
|
// Post-process HTML to add loading class to profile links
|
||||||
|
html = addLoadingClassToProfileLinks(html, profileLoadingRef.current)
|
||||||
|
|
||||||
|
setRenderedHtml(html)
|
||||||
|
} else if (!isCancelled && processedMarkdown) {
|
||||||
|
console.warn('⚠️ markdownPreviewRef.current is null but processedMarkdown exists')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
cancelAnimationFrame(rafId1)
|
||||||
|
if (htmlExtractionRafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(htmlExtractionRafIdRef.current)
|
||||||
|
htmlExtractionRafIdRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [processedMarkdown, markdown])
|
||||||
|
|
||||||
return { renderedHtml, previewRef, processedMarkdown }
|
return { renderedHtml, previewRef, processedMarkdown }
|
||||||
}
|
}
|
||||||
|
|||||||
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, [])
|
||||||
|
}
|
||||||
|
|
||||||
324
src/hooks/useProfileLabels.ts
Normal file
324
src/hooks/useProfileLabels.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
|
import { getContentPointers } from 'applesauce-factory/helpers'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { fetchProfiles, loadCachedProfiles } from '../services/profileService'
|
||||||
|
import { getNpubFallbackDisplay } from '../utils/nostrUriResolver'
|
||||||
|
import { extractProfileDisplayName } from '../utils/profileUtils'
|
||||||
|
|
||||||
|
const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to resolve profile labels from content containing npub/nprofile identifiers
|
||||||
|
* Returns an object with labels Map and loading Map that updates progressively as profiles load
|
||||||
|
*/
|
||||||
|
export function useProfileLabels(
|
||||||
|
content: string,
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
): { labels: Map<string, string>; loading: Map<string, boolean> } {
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
|
|
||||||
|
// Extract profile pointers (npub and nprofile) using applesauce helpers
|
||||||
|
const profileData = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const pointers = getContentPointers(content)
|
||||||
|
const filtered = pointers.filter(p => p.type === 'npub' || p.type === 'nprofile')
|
||||||
|
const result: Array<{ pubkey: string; encoded: string }> = []
|
||||||
|
filtered.forEach(pointer => {
|
||||||
|
try {
|
||||||
|
const pubkey = getPubkeyFromDecodeResult(pointer)
|
||||||
|
const encoded = encodeDecodeResult(pointer)
|
||||||
|
if (pubkey && encoded) {
|
||||||
|
result.push({ pubkey, encoded: encoded as string })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors, continue processing other pointers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[profile-labels] Error extracting profile pointers:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
// Initialize labels synchronously from cache on first render to avoid delay
|
||||||
|
// Use pubkey (hex) as the key instead of encoded string for canonical identification
|
||||||
|
const initialLabels = useMemo(() => {
|
||||||
|
if (profileData.length === 0) {
|
||||||
|
return new Map<string, string>()
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPubkeys = profileData.map(({ pubkey }) => pubkey)
|
||||||
|
const cachedProfiles = loadCachedProfiles(allPubkeys)
|
||||||
|
const labels = new Map<string, string>()
|
||||||
|
|
||||||
|
profileData.forEach(({ pubkey }) => {
|
||||||
|
const cachedProfile = cachedProfiles.get(pubkey)
|
||||||
|
if (cachedProfile) {
|
||||||
|
const displayName = extractProfileDisplayName(cachedProfile)
|
||||||
|
if (displayName) {
|
||||||
|
// Add @ prefix (extractProfileDisplayName returns name without @)
|
||||||
|
const label = `@${displayName}`
|
||||||
|
labels.set(pubkey, label)
|
||||||
|
} else {
|
||||||
|
// Use fallback npub display if profile has no name (add @ prefix)
|
||||||
|
const fallback = getNpubFallbackDisplay(pubkey)
|
||||||
|
labels.set(pubkey, `@${fallback}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return labels
|
||||||
|
}, [profileData])
|
||||||
|
|
||||||
|
const [profileLabels, setProfileLabels] = useState<Map<string, string>>(initialLabels)
|
||||||
|
const [profileLoading, setProfileLoading] = useState<Map<string, boolean>>(new Map())
|
||||||
|
|
||||||
|
// Batching strategy: Collect profile updates and apply them in batches via RAF to prevent UI flicker
|
||||||
|
// when many profiles resolve simultaneously. We use refs to avoid stale closures in async callbacks.
|
||||||
|
// Use pubkey (hex) as the key for canonical identification
|
||||||
|
const pendingUpdatesRef = useRef<Map<string, string>>(new Map())
|
||||||
|
const rafScheduledRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to apply pending batched updates to state
|
||||||
|
* Cancels any scheduled RAF and applies updates synchronously
|
||||||
|
*/
|
||||||
|
const applyPendingUpdates = () => {
|
||||||
|
const pendingUpdates = pendingUpdatesRef.current
|
||||||
|
if (pendingUpdates.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel scheduled RAF since we're applying synchronously
|
||||||
|
if (rafScheduledRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafScheduledRef.current)
|
||||||
|
rafScheduledRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply all pending updates in one batch
|
||||||
|
setProfileLabels(prevLabels => {
|
||||||
|
const updatedLabels = new Map(prevLabels)
|
||||||
|
for (const [pubkey, label] of pendingUpdates.entries()) {
|
||||||
|
updatedLabels.set(pubkey, label)
|
||||||
|
}
|
||||||
|
pendingUpdates.clear()
|
||||||
|
return updatedLabels
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to schedule a batched update via RAF (if not already scheduled)
|
||||||
|
* This prevents multiple RAF calls when many profiles resolve at once
|
||||||
|
* Wrapped in useCallback for stable reference in dependency arrays
|
||||||
|
*/
|
||||||
|
const scheduleBatchedUpdate = useCallback(() => {
|
||||||
|
if (rafScheduledRef.current === null) {
|
||||||
|
rafScheduledRef.current = requestAnimationFrame(() => {
|
||||||
|
applyPendingUpdates()
|
||||||
|
rafScheduledRef.current = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, []) // Empty deps: only uses refs which are stable
|
||||||
|
|
||||||
|
// Sync state when initialLabels changes (e.g., when content changes)
|
||||||
|
// This ensures we start with the correct cached labels even if profiles haven't loaded yet
|
||||||
|
useEffect(() => {
|
||||||
|
// Use a functional update to access current state without including it in dependencies
|
||||||
|
setProfileLabels(prevLabels => {
|
||||||
|
const currentPubkeys = new Set(Array.from(prevLabels.keys()))
|
||||||
|
const newPubkeys = new Set(profileData.map(p => p.pubkey))
|
||||||
|
|
||||||
|
// If the content changed significantly (different set of profiles), reset state
|
||||||
|
const hasDifferentProfiles =
|
||||||
|
currentPubkeys.size !== newPubkeys.size ||
|
||||||
|
!Array.from(newPubkeys).every(pk => currentPubkeys.has(pk))
|
||||||
|
|
||||||
|
if (hasDifferentProfiles) {
|
||||||
|
// Clear pending updates and cancel RAF for old profiles
|
||||||
|
pendingUpdatesRef.current.clear()
|
||||||
|
if (rafScheduledRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafScheduledRef.current)
|
||||||
|
rafScheduledRef.current = null
|
||||||
|
}
|
||||||
|
// Reset to initial labels
|
||||||
|
return new Map(initialLabels)
|
||||||
|
} else {
|
||||||
|
// Same profiles, merge initial labels with existing state
|
||||||
|
// IMPORTANT: Preserve existing labels (from pending updates) and only add initial labels if missing
|
||||||
|
const merged = new Map(prevLabels)
|
||||||
|
for (const [pubkey, label] of initialLabels.entries()) {
|
||||||
|
// Only add initial label if we don't already have a label for this pubkey
|
||||||
|
// This preserves labels that were added via applyPendingUpdates
|
||||||
|
if (!merged.has(pubkey)) {
|
||||||
|
merged.set(pubkey, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset loading state when content changes significantly
|
||||||
|
setProfileLoading(prevLoading => {
|
||||||
|
const currentPubkeys = new Set(Array.from(prevLoading.keys()))
|
||||||
|
const newPubkeys = new Set(profileData.map(p => p.pubkey))
|
||||||
|
|
||||||
|
const hasDifferentProfiles =
|
||||||
|
currentPubkeys.size !== newPubkeys.size ||
|
||||||
|
!Array.from(newPubkeys).every(pk => currentPubkeys.has(pk))
|
||||||
|
|
||||||
|
if (hasDifferentProfiles) {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
return prevLoading
|
||||||
|
})
|
||||||
|
}, [initialLabels, profileData])
|
||||||
|
|
||||||
|
// Build initial labels: localStorage cache -> eventStore -> fetch from relays
|
||||||
|
useEffect(() => {
|
||||||
|
// Extract all pubkeys
|
||||||
|
const allPubkeys = profileData.map(({ pubkey }) => pubkey)
|
||||||
|
|
||||||
|
if (allPubkeys.length === 0) {
|
||||||
|
setProfileLabels(new Map())
|
||||||
|
setProfileLoading(new Map())
|
||||||
|
// Clear pending updates and cancel RAF when clearing labels
|
||||||
|
pendingUpdatesRef.current.clear()
|
||||||
|
if (rafScheduledRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafScheduledRef.current)
|
||||||
|
rafScheduledRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cached profiles to EventStore for consistency
|
||||||
|
const cachedProfiles = loadCachedProfiles(allPubkeys)
|
||||||
|
if (eventStore) {
|
||||||
|
for (const profile of cachedProfiles.values()) {
|
||||||
|
eventStore.add(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build labels from localStorage cache and eventStore
|
||||||
|
// initialLabels already has all cached profiles, so we only need to check eventStore
|
||||||
|
// Use pubkey (hex) as the key for canonical identification
|
||||||
|
const labels = new Map<string, string>(initialLabels)
|
||||||
|
const loading = new Map<string, boolean>()
|
||||||
|
|
||||||
|
const pubkeysToFetch: string[] = []
|
||||||
|
|
||||||
|
profileData.forEach(({ pubkey }) => {
|
||||||
|
// Skip if already resolved from initial cache
|
||||||
|
if (labels.has(pubkey)) {
|
||||||
|
loading.set(pubkey, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check EventStore for profiles that weren't in cache
|
||||||
|
const eventStoreProfile = eventStore?.getEvent(pubkey + ':0')
|
||||||
|
|
||||||
|
if (eventStoreProfile && eventStore) {
|
||||||
|
// Extract display name using centralized utility
|
||||||
|
const displayName = extractProfileDisplayName(eventStoreProfile as NostrEvent)
|
||||||
|
if (displayName) {
|
||||||
|
// Add @ prefix (extractProfileDisplayName returns name without @)
|
||||||
|
const label = `@${displayName}`
|
||||||
|
labels.set(pubkey, label)
|
||||||
|
} else {
|
||||||
|
// Use fallback npub display if profile has no name (add @ prefix)
|
||||||
|
const fallback = getNpubFallbackDisplay(pubkey)
|
||||||
|
labels.set(pubkey, `@${fallback}`)
|
||||||
|
}
|
||||||
|
loading.set(pubkey, false)
|
||||||
|
} else {
|
||||||
|
// No profile found yet, will use fallback after fetch or keep empty
|
||||||
|
// We'll set fallback labels for missing profiles at the end
|
||||||
|
// Mark as loading since we'll fetch it
|
||||||
|
pubkeysToFetch.push(pubkey)
|
||||||
|
loading.set(pubkey, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Don't set fallback labels in the Map - we'll use fallback directly when rendering
|
||||||
|
// This allows us to distinguish between "no label yet" (use fallback) vs "resolved label" (use Map value)
|
||||||
|
|
||||||
|
setProfileLabels(new Map(labels))
|
||||||
|
setProfileLoading(new Map(loading))
|
||||||
|
|
||||||
|
// Fetch missing profiles asynchronously with reactive updates
|
||||||
|
if (pubkeysToFetch.length > 0 && relayPool && eventStore) {
|
||||||
|
|
||||||
|
// Reactive callback: collects profile updates and batches them via RAF to prevent flicker
|
||||||
|
// Strategy: Apply label immediately when profile resolves, but still batch for multiple profiles
|
||||||
|
const handleProfileEvent = (event: NostrEvent) => {
|
||||||
|
// Use pubkey directly as the key
|
||||||
|
const pubkey = event.pubkey
|
||||||
|
|
||||||
|
// Determine the label for this profile using centralized utility
|
||||||
|
// Add @ prefix (both extractProfileDisplayName and getNpubFallbackDisplay return names without @)
|
||||||
|
const displayName = extractProfileDisplayName(event)
|
||||||
|
const label = displayName ? `@${displayName}` : `@${getNpubFallbackDisplay(pubkey)}`
|
||||||
|
|
||||||
|
// Apply label immediately to prevent race condition with loading state
|
||||||
|
// This ensures labels are available when isLoading becomes false
|
||||||
|
setProfileLabels(prevLabels => {
|
||||||
|
const updated = new Map(prevLabels)
|
||||||
|
updated.set(pubkey, label)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear loading state for this profile when it resolves
|
||||||
|
setProfileLoading(prevLoading => {
|
||||||
|
const updated = new Map(prevLoading)
|
||||||
|
updated.set(pubkey, false)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent)
|
||||||
|
.then(() => {
|
||||||
|
// After EOSE: apply any remaining pending updates immediately
|
||||||
|
// This ensures all profile updates are applied even if RAF hasn't fired yet
|
||||||
|
applyPendingUpdates()
|
||||||
|
|
||||||
|
// Clear loading state for all fetched profiles
|
||||||
|
setProfileLoading(prevLoading => {
|
||||||
|
const updated = new Map(prevLoading)
|
||||||
|
pubkeysToFetch.forEach(pubkey => {
|
||||||
|
updated.set(pubkey, false)
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(`[profile-labels] Error fetching profiles:`, error)
|
||||||
|
// Silently handle fetch errors, but still clear any pending updates
|
||||||
|
pendingUpdatesRef.current.clear()
|
||||||
|
if (rafScheduledRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafScheduledRef.current)
|
||||||
|
rafScheduledRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear loading state on error (show fallback)
|
||||||
|
setProfileLoading(prevLoading => {
|
||||||
|
const updated = new Map(prevLoading)
|
||||||
|
pubkeysToFetch.forEach(pubkey => {
|
||||||
|
updated.set(pubkey, false)
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup: apply any pending updates before unmount to avoid losing them
|
||||||
|
return () => {
|
||||||
|
applyPendingUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [profileData, eventStore, relayPool, initialLabels, scheduleBatchedUpdate])
|
||||||
|
|
||||||
|
return { labels: profileLabels, loading: profileLoading }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@ interface UseReadingPositionOptions {
|
|||||||
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
|
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
|
||||||
syncEnabled?: boolean // Whether to sync positions to Nostr
|
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||||
onSave?: (position: number) => void // Callback for saving position
|
onSave?: (position: number) => void // Callback for saving position
|
||||||
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
|
||||||
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
|
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,62 +17,69 @@ export const useReadingPosition = ({
|
|||||||
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
|
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
|
||||||
syncEnabled = false,
|
syncEnabled = false,
|
||||||
onSave,
|
onSave,
|
||||||
autoSaveInterval = 5000,
|
|
||||||
completionHoldMs = 2000
|
completionHoldMs = 2000
|
||||||
}: UseReadingPositionOptions = {}) => {
|
}: UseReadingPositionOptions = {}) => {
|
||||||
const [position, setPosition] = useState(0)
|
const [position, setPosition] = useState(0)
|
||||||
|
const positionRef = useRef(0)
|
||||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||||
const hasTriggeredComplete = useRef(false)
|
const hasTriggeredComplete = useRef(false)
|
||||||
const lastSavedPosition = useRef(0)
|
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const hasSavedOnce = useRef(false)
|
|
||||||
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const 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])
|
||||||
|
|
||||||
// Debounced save function
|
// 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) => {
|
const scheduleSave = useCallback((currentPosition: number) => {
|
||||||
if (!syncEnabled || !onSave) {
|
if (!syncEnabled || !onSaveRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't save if position hasn't changed significantly (less than 1%)
|
// Always save instantly when we reach completion (1.0)
|
||||||
// But always save if we've reached 100% (completion)
|
if (currentPosition === 1 && !lastSaved100Ref.current) {
|
||||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
if (saveTimerRef.current) {
|
||||||
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
clearTimeout(saveTimerRef.current)
|
||||||
const isInitialSave = !hasSavedOnce.current
|
saveTimerRef.current = null
|
||||||
|
}
|
||||||
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
|
lastSaved100Ref.current = true
|
||||||
// Not significant enough to save
|
onSaveRef.current(1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing timer
|
// 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) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current)
|
return // Already have a save scheduled, don't reset the timer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule new save
|
const THROTTLE_MS = 1000
|
||||||
saveTimerRef.current = setTimeout(() => {
|
saveTimerRef.current = setTimeout(() => {
|
||||||
lastSavedPosition.current = currentPosition
|
// Save the latest position, not the one from when timer was scheduled
|
||||||
hasSavedOnce.current = true
|
const positionToSave = pendingPositionRef.current
|
||||||
onSave(currentPosition)
|
onSaveRef.current?.(positionToSave)
|
||||||
}, autoSaveInterval)
|
|
||||||
}, [syncEnabled, onSave, autoSaveInterval])
|
|
||||||
|
|
||||||
// Immediate save function
|
|
||||||
const saveNow = useCallback(() => {
|
|
||||||
if (!syncEnabled || !onSave) return
|
|
||||||
|
|
||||||
// Cancel any pending saves
|
|
||||||
if (saveTimerRef.current) {
|
|
||||||
clearTimeout(saveTimerRef.current)
|
|
||||||
saveTimerRef.current = null
|
saveTimerRef.current = null
|
||||||
}
|
}, THROTTLE_MS)
|
||||||
|
}, [syncEnabled])
|
||||||
// Always allow immediate save (including 0%)
|
|
||||||
lastSavedPosition.current = position
|
|
||||||
hasSavedOnce.current = true
|
|
||||||
onSave(position)
|
|
||||||
}, [syncEnabled, onSave, position])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
@@ -87,27 +93,27 @@ export const useReadingPosition = ({
|
|||||||
const windowHeight = window.innerHeight
|
const windowHeight = window.innerHeight
|
||||||
const documentHeight = document.documentElement.scrollHeight
|
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
|
// Calculate position based on how much of the content has been scrolled through
|
||||||
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
|
|
||||||
const maxScroll = documentHeight - windowHeight
|
const maxScroll = documentHeight - windowHeight
|
||||||
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||||
|
|
||||||
// If we're within 5px of the bottom, consider it 100%
|
// Only consider it 100% if we're truly at the bottom AND have scrolled significantly
|
||||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
// 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))
|
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||||
|
|
||||||
// Only log on significant changes (every 5%) to avoid flooding console
|
|
||||||
const prevPercent = Math.floor(position * 20) // Groups by 5%
|
|
||||||
const newPercent = Math.floor(clampedProgress * 20)
|
|
||||||
if (prevPercent !== newPercent) {
|
|
||||||
// Position threshold crossed
|
|
||||||
}
|
|
||||||
|
|
||||||
setPosition(clampedProgress)
|
setPosition(clampedProgress)
|
||||||
onPositionChange?.(clampedProgress)
|
positionRef.current = clampedProgress
|
||||||
|
onPositionChangeRef.current?.(clampedProgress)
|
||||||
|
|
||||||
// Schedule auto-save if sync is enabled
|
// Schedule auto-save if sync is enabled (unless suppressed)
|
||||||
scheduleSave(clampedProgress)
|
if (Date.now() >= suppressUntilRef.current) {
|
||||||
|
scheduleSave(clampedProgress)
|
||||||
|
}
|
||||||
|
// Note: Suppression is silent to avoid log spam during scrolling
|
||||||
|
|
||||||
// Completion detection with 2s hold at 100%
|
// Completion detection with 2s hold at 100%
|
||||||
if (!hasTriggeredComplete.current) {
|
if (!hasTriggeredComplete.current) {
|
||||||
@@ -115,10 +121,10 @@ export const useReadingPosition = ({
|
|||||||
if (clampedProgress === 1) {
|
if (clampedProgress === 1) {
|
||||||
if (!completionTimerRef.current) {
|
if (!completionTimerRef.current) {
|
||||||
completionTimerRef.current = setTimeout(() => {
|
completionTimerRef.current = setTimeout(() => {
|
||||||
if (!hasTriggeredComplete.current && position === 1) {
|
if (!hasTriggeredComplete.current && positionRef.current === 1) {
|
||||||
setIsReadingComplete(true)
|
setIsReadingComplete(true)
|
||||||
hasTriggeredComplete.current = true
|
hasTriggeredComplete.current = true
|
||||||
onReadingComplete?.()
|
onReadingCompleteRef.current?.()
|
||||||
}
|
}
|
||||||
completionTimerRef.current = null
|
completionTimerRef.current = null
|
||||||
}, completionHoldMs)
|
}, completionHoldMs)
|
||||||
@@ -132,7 +138,7 @@ export const useReadingPosition = ({
|
|||||||
if (clampedProgress >= readingCompleteThreshold) {
|
if (clampedProgress >= readingCompleteThreshold) {
|
||||||
setIsReadingComplete(true)
|
setIsReadingComplete(true)
|
||||||
hasTriggeredComplete.current = true
|
hasTriggeredComplete.current = true
|
||||||
onReadingComplete?.()
|
onReadingCompleteRef.current?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,25 +156,20 @@ export const useReadingPosition = ({
|
|||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
window.removeEventListener('resize', handleScroll)
|
window.removeEventListener('resize', handleScroll)
|
||||||
|
|
||||||
// Clear save timer on unmount
|
// DON'T clear save timer - let it complete even if tracking is temporarily disabled
|
||||||
if (saveTimerRef.current) {
|
// Only clear completion timer since that's tied to the current scroll session
|
||||||
clearTimeout(saveTimerRef.current)
|
|
||||||
}
|
|
||||||
if (completionTimerRef.current) {
|
if (completionTimerRef.current) {
|
||||||
clearTimeout(completionTimerRef.current)
|
clearTimeout(completionTimerRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders
|
}, [enabled, readingCompleteThreshold, scheduleSave, completionHoldMs])
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
|
||||||
|
|
||||||
// Reset reading complete state when enabled changes
|
// Reset reading complete state when enabled changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setIsReadingComplete(false)
|
setIsReadingComplete(false)
|
||||||
hasTriggeredComplete.current = false
|
hasTriggeredComplete.current = false
|
||||||
hasSavedOnce.current = false
|
lastSaved100Ref.current = false
|
||||||
lastSavedPosition.current = 0
|
|
||||||
if (completionTimerRef.current) {
|
if (completionTimerRef.current) {
|
||||||
clearTimeout(completionTimerRef.current)
|
clearTimeout(completionTimerRef.current)
|
||||||
completionTimerRef.current = null
|
completionTimerRef.current = null
|
||||||
@@ -180,6 +181,6 @@ export const useReadingPosition = ({
|
|||||||
position,
|
position,
|
||||||
isReadingComplete,
|
isReadingComplete,
|
||||||
progressPercentage: Math.round(position * 100),
|
progressPercentage: Math.round(position * 100),
|
||||||
saveNow
|
suppressSavesFor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { EventFactory } from 'applesauce-factory'
|
import { EventFactory } from 'applesauce-factory'
|
||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager } from 'applesauce-accounts'
|
||||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
import { UserSettings, saveSettings, watchSettings, startSettingsStream } from '../services/settingsService'
|
||||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||||
import { applyTheme } from '../utils/theme'
|
import { applyTheme } from '../utils/theme'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
@@ -16,30 +16,28 @@ interface UseSettingsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
|
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
|
||||||
const [settings, setSettings] = useState<UserSettings>({})
|
const [settings, setSettings] = useState<UserSettings>({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true })
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||||
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||||||
|
|
||||||
// Load settings and set up subscription
|
// Load settings and set up streaming subscription (non-blocking, EOSE-driven)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !pubkey || !eventStore) return
|
if (!relayPool || !pubkey || !eventStore) return
|
||||||
|
|
||||||
const loadAndWatch = async () => {
|
// Start settings stream: seed from store, stream updates to store in background
|
||||||
try {
|
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
|
||||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||||
if (loadedSettings) setSettings(loadedSettings)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load settings:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAndWatch()
|
|
||||||
|
|
||||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
|
||||||
if (loadedSettings) setSettings(loadedSettings)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => subscription.unsubscribe()
|
// Also watch store reactively for any further updates
|
||||||
|
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||||
|
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
stopNetwork()
|
||||||
|
}
|
||||||
}, [relayPool, pubkey, eventStore])
|
}, [relayPool, pubkey, eventStore])
|
||||||
|
|
||||||
// Apply settings to document
|
// Apply settings to document
|
||||||
@@ -70,9 +68,19 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||||
|
|
||||||
|
// Set link colors for dark and light themes separately
|
||||||
|
const darkLinkColor = settings.linkColorDark || '#38bdf8'
|
||||||
|
const lightLinkColor = settings.linkColorLight || '#3b82f6'
|
||||||
|
root.setProperty('--color-link-dark', darkLinkColor)
|
||||||
|
root.setProperty('--color-link-light', lightLinkColor)
|
||||||
|
|
||||||
// Set paragraph alignment
|
// Set paragraph alignment
|
||||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||||
|
|
||||||
|
// Set image width and max-height based on full-width setting
|
||||||
|
root.setProperty('--image-width', settings.fullWidthImages ? '100%' : 'auto')
|
||||||
|
root.setProperty('--image-max-height', settings.fullWidthImages ? 'none' : '70vh')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyStyles()
|
applyStyles()
|
||||||
|
|||||||
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])
|
||||||
|
}
|
||||||
|
|
||||||
72
src/main.tsx
72
src/main.tsx
@@ -6,16 +6,59 @@ import './index.css'
|
|||||||
import 'react-loading-skeleton/dist/skeleton.css'
|
import 'react-loading-skeleton/dist/skeleton.css'
|
||||||
|
|
||||||
// Register Service Worker for PWA functionality
|
// Register Service Worker for PWA functionality
|
||||||
|
// With injectRegister: null, we need to register manually
|
||||||
|
// With devOptions.enabled: true, vite-plugin-pwa serves SW in dev mode too
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker
|
const swPath = '/sw.js'
|
||||||
.register('/sw.js', { type: 'module' })
|
|
||||||
|
// Check if already registered/active first
|
||||||
|
navigator.serviceWorker.getRegistrations().then(async (registrations) => {
|
||||||
|
if (registrations.length > 0) {
|
||||||
|
return registrations[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not registered yet, try to register
|
||||||
|
// In dev mode, use the dev Service Worker for testing
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const devSwPath = '/sw-dev.js'
|
||||||
|
try {
|
||||||
|
// Check if dev SW exists
|
||||||
|
const response = await fetch(devSwPath)
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
const isJavaScript = contentType.includes('javascript') || contentType.includes('application/javascript')
|
||||||
|
|
||||||
|
if (response.ok && isJavaScript) {
|
||||||
|
return await navigator.serviceWorker.register(devSwPath, { scope: '/' })
|
||||||
|
} else {
|
||||||
|
console.warn('[sw-registration] Development Service Worker not available')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[sw-registration] Could not load development Service Worker:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// In production, just register directly
|
||||||
|
return await navigator.serviceWorker.register(swPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
.then(registration => {
|
.then(registration => {
|
||||||
|
if (!registration) return
|
||||||
|
|
||||||
// Check for updates periodically
|
// Wait for Service Worker to activate
|
||||||
setInterval(() => {
|
if (registration.installing) {
|
||||||
registration.update()
|
registration.installing.addEventListener('statechange', () => {
|
||||||
}, 60 * 60 * 1000) // Check every hour
|
// Service Worker state changed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for updates periodically (production only)
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
setInterval(() => {
|
||||||
|
registration.update()
|
||||||
|
}, 60 * 60 * 1000) // Check every hour
|
||||||
|
}
|
||||||
|
|
||||||
// Handle service worker updates
|
// Handle service worker updates
|
||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
@@ -24,8 +67,6 @@ if ('serviceWorker' in navigator) {
|
|||||||
newWorker.addEventListener('statechange', () => {
|
newWorker.addEventListener('statechange', () => {
|
||||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
// New service worker available
|
// New service worker available
|
||||||
|
|
||||||
// Optionally show a toast notification
|
|
||||||
const updateAvailable = new CustomEvent('sw-update-available')
|
const updateAvailable = new CustomEvent('sw-update-available')
|
||||||
window.dispatchEvent(updateAvailable)
|
window.dispatchEvent(updateAvailable)
|
||||||
}
|
}
|
||||||
@@ -34,9 +75,22 @@ if ('serviceWorker' in navigator) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('❌ Service Worker registration failed:', error)
|
console.error('[sw-registration] ❌ Service Worker registration failed:', error)
|
||||||
|
console.error('[sw-registration] Error details:', {
|
||||||
|
message: error.message,
|
||||||
|
name: error.name,
|
||||||
|
stack: error.stack
|
||||||
|
})
|
||||||
|
|
||||||
|
// In dev mode, this is expected if vite-plugin-pwa isn't serving the SW
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[sw-registration] ⚠️ This is expected in dev mode if vite-plugin-pwa is not serving the SW file')
|
||||||
|
console.warn('[sw-registration] Image caching will not work in dev mode - test in production build')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser')
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
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()
|
||||||
|
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ import { nip19 } from 'nostr-tools'
|
|||||||
import { AddressPointer } from 'nostr-tools/nip19'
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
import { getContentRelays, getFallbackContentRelays, isContentRelay } from '../config/relays'
|
||||||
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
|
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
|
||||||
import { merge, toArray as rxToArray } from 'rxjs'
|
import { merge, toArray as rxToArray } from 'rxjs'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
@@ -34,11 +34,13 @@ function getCacheKey(naddr: string): string {
|
|||||||
return `${CACHE_PREFIX}${naddr}`
|
return `${CACHE_PREFIX}${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFromCache(naddr: string): ArticleContent | null {
|
export function getFromCache(naddr: string): ArticleContent | null {
|
||||||
try {
|
try {
|
||||||
const cacheKey = getCacheKey(naddr)
|
const cacheKey = getCacheKey(naddr)
|
||||||
const cached = localStorage.getItem(cacheKey)
|
const cached = localStorage.getItem(cacheKey)
|
||||||
if (!cached) return null
|
if (!cached) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const { content, timestamp }: CachedArticle = JSON.parse(cached)
|
const { content, timestamp }: CachedArticle = JSON.parse(cached)
|
||||||
const age = Date.now() - timestamp
|
const age = Date.now() - timestamp
|
||||||
@@ -49,12 +51,51 @@ function getFromCache(naddr: string): ArticleContent | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return content
|
return content
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// Silently handle cache read errors
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveToCache(naddr: string, content: ArticleContent): void {
|
/**
|
||||||
|
* Caches an article event to localStorage for offline access
|
||||||
|
* @param event - The Nostr event to cache
|
||||||
|
* @param settings - Optional user settings
|
||||||
|
*/
|
||||||
|
export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): void {
|
||||||
|
try {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
if (!dTag || event.kind !== 30023) return
|
||||||
|
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
|
||||||
|
const articleContent: ArticleContent = {
|
||||||
|
title: getArticleTitle(event) || 'Untitled Article',
|
||||||
|
markdown: event.content,
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
author: event.pubkey,
|
||||||
|
event
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToCache(naddr, articleContent, settings)
|
||||||
|
} catch (err) {
|
||||||
|
// Silently fail cache saves - quota exceeded, invalid data, etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void {
|
||||||
|
// Respect user settings: if image caching is disabled, we could skip article caching too
|
||||||
|
// However, for offline-first design, we default to caching unless explicitly disabled
|
||||||
|
// Future: could add explicit enableArticleCache setting
|
||||||
|
// For now, we cache aggressively but handle errors gracefully
|
||||||
|
// Note: settings parameter reserved for future use
|
||||||
|
void settings // Mark as intentionally unused for now
|
||||||
try {
|
try {
|
||||||
const cacheKey = getCacheKey(naddr)
|
const cacheKey = getCacheKey(naddr)
|
||||||
const cached: CachedArticle = {
|
const cached: CachedArticle = {
|
||||||
@@ -63,8 +104,8 @@ function saveToCache(naddr: string, content: ArticleContent): void {
|
|||||||
}
|
}
|
||||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to cache article:', err)
|
// Silently fail - don't block the UI if caching fails
|
||||||
// Silently fail if storage is full or unavailable
|
// Handles quota exceeded, invalid data, and other errors gracefully
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,13 +138,6 @@ export async function fetchArticleByNaddr(
|
|||||||
|
|
||||||
const pointer = decoded.data as AddressPointer
|
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
|
|
||||||
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
|
||||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
|
||||||
|
|
||||||
// Fetch the article event
|
// Fetch the article event
|
||||||
const filter = {
|
const filter = {
|
||||||
kinds: [pointer.kind],
|
kinds: [pointer.kind],
|
||||||
@@ -111,10 +145,52 @@ export async function fetchArticleByNaddr(
|
|||||||
'#d': [pointer.identifier]
|
'#d': [pointer.identifier]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parallel local+remote, stream immediate, collect up to first from each
|
let events: NostrEvent[] = []
|
||||||
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()))
|
// Build unified relay set: hints + configured content relays
|
||||||
const events = collected as NostrEvent[]
|
// Filter hinted relays to only content-capable relays
|
||||||
|
const hintedRelays = (pointer.relays && pointer.relays.length > 0)
|
||||||
|
? pointer.relays.filter(isContentRelay)
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Get configured content relays
|
||||||
|
const contentRelays = getContentRelays()
|
||||||
|
|
||||||
|
// Union of hinted and configured relays (deduplicated)
|
||||||
|
const unifiedRelays = Array.from(new Set([...hintedRelays, ...contentRelays]))
|
||||||
|
|
||||||
|
if (unifiedRelays.length > 0) {
|
||||||
|
const orderedUnified = prioritizeLocalRelays(unifiedRelays)
|
||||||
|
const { local: localUnified, remote: remoteUnified } = partitionRelays(orderedUnified)
|
||||||
|
|
||||||
|
const { local$, remote$ } = createParallelReqStreams(
|
||||||
|
relayPool,
|
||||||
|
localUnified,
|
||||||
|
remoteUnified,
|
||||||
|
filter,
|
||||||
|
1200,
|
||||||
|
6000
|
||||||
|
)
|
||||||
|
const collected = await lastValueFrom(
|
||||||
|
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
|
||||||
|
)
|
||||||
|
events = collected as NostrEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: try fallback content relays (most reliable public relays)
|
||||||
|
if (events.length === 0) {
|
||||||
|
const fallbackRelays = getFallbackContentRelays()
|
||||||
|
const { remote$: fallback$ } = createParallelReqStreams(
|
||||||
|
relayPool,
|
||||||
|
[], // no local for fallback
|
||||||
|
fallbackRelays,
|
||||||
|
filter,
|
||||||
|
1500,
|
||||||
|
12000
|
||||||
|
)
|
||||||
|
const fallbackCollected = await lastValueFrom(fallback$.pipe(take(1), rxToArray()))
|
||||||
|
events = fallbackCollected as NostrEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
throw new Error('Article not found')
|
throw new Error('Article not found')
|
||||||
@@ -143,7 +219,7 @@ export async function fetchArticleByNaddr(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to cache before returning
|
// Save to cache before returning
|
||||||
saveToCache(naddr, content)
|
saveToCache(naddr, content, settings)
|
||||||
|
|
||||||
// Image caching is handled automatically by Service Worker
|
// Image caching is handled automatically by Service Worker
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Helpers, EventStore } from 'applesauce-core'
|
import { Helpers, EventStore } from 'applesauce-core'
|
||||||
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { EventPointer } from 'nostr-tools/nip19'
|
|
||||||
import { merge } from 'rxjs'
|
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import {
|
import {
|
||||||
@@ -64,11 +60,8 @@ class BookmarkController {
|
|||||||
}> = new Map()
|
}> = new Map()
|
||||||
private isLoading = false
|
private isLoading = false
|
||||||
private hydrationGeneration = 0
|
private hydrationGeneration = 0
|
||||||
|
private externalEventStore: EventStore | null = null
|
||||||
// Event loaders for efficient batching
|
private relayPool: RelayPool | null = null
|
||||||
private eventStore = new EventStore()
|
|
||||||
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
|
||||||
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
|
||||||
|
|
||||||
onRawEvent(cb: RawEventCallback): () => void {
|
onRawEvent(cb: RawEventCallback): () => void {
|
||||||
this.rawEventListeners.push(cb)
|
this.rawEventListeners.push(cb)
|
||||||
@@ -117,15 +110,15 @@ class BookmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate events by IDs using EventLoader (auto-batching, streaming)
|
* Hydrate events by IDs using queryEvents (local-first, streaming)
|
||||||
*/
|
*/
|
||||||
private hydrateByIds(
|
private async hydrateByIds(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
idToEvent: Map<string, NostrEvent>,
|
idToEvent: Map<string, NostrEvent>,
|
||||||
onProgress: () => void,
|
onProgress: () => void,
|
||||||
generation: number
|
generation: number
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (!this.eventLoader) {
|
if (!this.relayPool) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,71 +128,146 @@ class BookmarkController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert IDs to EventPointers
|
// Fetch events using local-first queryEvents
|
||||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
await queryEvents(
|
||||||
|
this.relayPool,
|
||||||
// Use EventLoader - it auto-batches and streams results
|
{ ids: unique },
|
||||||
merge(...pointers.map(this.eventLoader)).subscribe({
|
{
|
||||||
next: (event) => {
|
onEvent: (event) => {
|
||||||
// Check if hydration was cancelled
|
// Check if hydration was cancelled
|
||||||
if (this.hydrationGeneration !== generation) return
|
if (this.hydrationGeneration !== generation) return
|
||||||
|
|
||||||
idToEvent.set(event.id, event)
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
// Also index by coordinate for addressable events
|
// Also index by coordinate for addressable events
|
||||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||||
idToEvent.set(coordinate, event)
|
idToEvent.set(coordinate, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress()
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
// Silent error - EventLoader handles retries
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming)
|
* Hydrate addressable events by coordinates using queryEvents (local-first, streaming)
|
||||||
*/
|
*/
|
||||||
private hydrateByCoordinates(
|
private async hydrateByCoordinates(
|
||||||
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
|
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||||
idToEvent: Map<string, NostrEvent>,
|
idToEvent: Map<string, NostrEvent>,
|
||||||
onProgress: () => void,
|
onProgress: () => void,
|
||||||
generation: number
|
generation: number
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (!this.addressLoader) {
|
if (!this.relayPool) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coords.length === 0) return
|
if (coords.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Convert coordinates to AddressPointers
|
// Group by kind and pubkey for efficient batching
|
||||||
const pointers = coords.map(c => ({
|
const filtersByKind = new Map<number, Map<string, string[]>>()
|
||||||
kind: c.kind,
|
|
||||||
pubkey: c.pubkey,
|
for (const coord of coords) {
|
||||||
identifier: c.identifier
|
if (!filtersByKind.has(coord.kind)) {
|
||||||
}))
|
filtersByKind.set(coord.kind, new Map())
|
||||||
|
|
||||||
// Use AddressLoader - it auto-batches and streams results
|
|
||||||
merge(...pointers.map(this.addressLoader)).subscribe({
|
|
||||||
next: (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)
|
|
||||||
|
|
||||||
onProgress()
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
// Silent error - AddressLoader handles retries
|
|
||||||
}
|
}
|
||||||
})
|
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(
|
private async buildAndEmitBookmarks(
|
||||||
@@ -244,42 +312,58 @@ class BookmarkController {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||||
|
const deduped = dedupeBookmarksById(allItems)
|
||||||
|
|
||||||
// Separate hex IDs from coordinates
|
// Separate hex IDs from coordinates for fetching
|
||||||
const noteIds: string[] = []
|
const noteIds: string[] = []
|
||||||
const coordinates: string[] = []
|
const coordinates: string[] = []
|
||||||
|
|
||||||
allItems.forEach(i => {
|
// Request hydration for all items that don't have content yet
|
||||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
deduped.forEach(i => {
|
||||||
noteIds.push(i.id)
|
// If item has no content, we need to fetch it
|
||||||
} else if (i.id.includes(':')) {
|
if (!i.content || i.content.length === 0) {
|
||||||
coordinates.push(i.id)
|
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
|
// Helper to build and emit bookmarks
|
||||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||||
const allBookmarks = dedupeBookmarksById([
|
// 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(publicItemsAll, idToEvent),
|
||||||
...hydrateItems(privateItemsAll, idToEvent)
|
...hydrateItems(privateItemsAll, idToEvent)
|
||||||
])
|
]
|
||||||
|
|
||||||
const enriched = allBookmarks.map(b => ({
|
const enriched = allBookmarks.map(b => ({
|
||||||
...b,
|
...b,
|
||||||
tags: b.tags || [],
|
tags: b.tags || [],
|
||||||
content: b.content || ''
|
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
|
const sortedBookmarks = enriched
|
||||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
.map(b => ({
|
||||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
...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 = {
|
const bookmark: Bookmark = {
|
||||||
id: `${activeAccount.pubkey}-bookmarks`,
|
id: `${activeAccount.pubkey}-bookmarks`,
|
||||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||||
url: '',
|
url: '',
|
||||||
content: latestContent,
|
content: latestContent,
|
||||||
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
|
created_at: newestCreatedAt || 0,
|
||||||
tags: allTags,
|
tags: allTags,
|
||||||
bookmarkCount: sortedBookmarks.length,
|
bookmarkCount: sortedBookmarks.length,
|
||||||
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
||||||
@@ -295,7 +379,7 @@ class BookmarkController {
|
|||||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||||
emitBookmarks(idToEvent)
|
emitBookmarks(idToEvent)
|
||||||
|
|
||||||
// Now fetch events progressively in background using batched hydrators
|
// Now fetch events progressively in background using local-first queries
|
||||||
|
|
||||||
const generation = this.hydrationGeneration
|
const generation = this.hydrationGeneration
|
||||||
const onProgress = () => emitBookmarks(idToEvent)
|
const onProgress = () => emitBookmarks(idToEvent)
|
||||||
@@ -310,10 +394,14 @@ class BookmarkController {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Kick off batched hydration (streaming, non-blocking)
|
// Kick off hydration (streaming, non-blocking, local-first)
|
||||||
// EventLoader and AddressLoader handle batching and streaming automatically
|
// Fire-and-forget - don't await, let it run in background
|
||||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
|
||||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Failed to build bookmarks:', error)
|
console.error('Failed to build bookmarks:', error)
|
||||||
this.bookmarksListeners.forEach(cb => cb([]))
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
@@ -324,8 +412,13 @@ class BookmarkController {
|
|||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
activeAccount: unknown
|
activeAccount: unknown
|
||||||
accountManager: { getActive: () => unknown }
|
accountManager: { getActive: () => unknown }
|
||||||
|
eventStore?: EventStore
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { relayPool, activeAccount, accountManager } = options
|
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') {
|
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||||
return
|
return
|
||||||
@@ -336,16 +429,6 @@ class BookmarkController {
|
|||||||
// Increment generation to cancel any in-flight hydration
|
// Increment generation to cancel any in-flight hydration
|
||||||
this.hydrationGeneration++
|
this.hydrationGeneration++
|
||||||
|
|
||||||
// Initialize loaders for this session
|
|
||||||
this.eventLoader = createEventLoader(relayPool, {
|
|
||||||
eventStore: this.eventStore,
|
|
||||||
extraRelays: RELAYS
|
|
||||||
})
|
|
||||||
this.addressLoader = createAddressLoader(relayPool, {
|
|
||||||
eventStore: this.eventStore,
|
|
||||||
extraRelays: RELAYS
|
|
||||||
})
|
|
||||||
|
|
||||||
this.setLoading(true)
|
this.setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,28 +15,30 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
|||||||
}
|
}
|
||||||
const unique = Array.from(byId.values())
|
const unique = Array.from(byId.values())
|
||||||
|
|
||||||
// Separate web bookmarks (kind:39701) from list-based bookmarks
|
|
||||||
const webBookmarks = unique.filter(e => e.kind === 39701)
|
|
||||||
|
|
||||||
const bookmarkLists = unique
|
const bookmarkLists = unique
|
||||||
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
||||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||||
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
||||||
|
|
||||||
|
// Deduplicate replaceable events (kind:30003, 30001, 39701) by d-tag
|
||||||
const byD = new Map<string, NostrEvent>()
|
const byD = new Map<string, NostrEvent>()
|
||||||
for (const e of unique) {
|
for (const e of unique) {
|
||||||
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) {
|
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001 || e.kind === 39701) {
|
||||||
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
|
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
const prev = byD.get(d)
|
const prev = byD.get(d)
|
||||||
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
|
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setsAndNamedLists = Array.from(byD.values())
|
// Separate web bookmarks from bookmark sets/lists
|
||||||
|
const allReplaceable = Array.from(byD.values())
|
||||||
|
const webBookmarks = allReplaceable.filter(e => e.kind === 39701)
|
||||||
|
const setsAndNamedLists = allReplaceable.filter(e => e.kind !== 39701)
|
||||||
|
|
||||||
const out: NostrEvent[] = []
|
const out: NostrEvent[] = []
|
||||||
if (latestBookmarkList) out.push(latestBookmarkList)
|
if (latestBookmarkList) out.push(latestBookmarkList)
|
||||||
out.push(...setsAndNamedLists)
|
out.push(...setsAndNamedLists)
|
||||||
// Add web bookmarks as individual events
|
// Add deduplicated web bookmarks as individual events
|
||||||
out.push(...webBookmarks)
|
out.push(...webBookmarks)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ export interface AddressPointer {
|
|||||||
pubkey: string
|
pubkey: string
|
||||||
identifier: string
|
identifier: string
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
|
added_at?: number
|
||||||
|
created_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventPointer {
|
export interface EventPointer {
|
||||||
id: string
|
id: string
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
author?: string
|
author?: string
|
||||||
|
added_at?: number
|
||||||
|
created_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplesauceBookmarks {
|
export interface ApplesauceBookmarks {
|
||||||
@@ -62,7 +66,8 @@ export { dedupeNip51Events } from './bookmarkEvents'
|
|||||||
export const processApplesauceBookmarks = (
|
export const processApplesauceBookmarks = (
|
||||||
bookmarks: unknown,
|
bookmarks: unknown,
|
||||||
activeAccount: ActiveAccount,
|
activeAccount: ActiveAccount,
|
||||||
isPrivate: boolean
|
isPrivate: boolean,
|
||||||
|
parentCreatedAt?: number
|
||||||
): IndividualBookmark[] => {
|
): IndividualBookmark[] => {
|
||||||
if (!bookmarks) return []
|
if (!bookmarks) return []
|
||||||
|
|
||||||
@@ -76,14 +81,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: note.created_at ?? null,
|
||||||
pubkey: note.author || activeAccount.pubkey,
|
pubkey: note.author || activeAccount.pubkey,
|
||||||
kind: 1, // Short note kind
|
kind: 1, // Short note kind
|
||||||
tags: [],
|
tags: [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
listUpdatedAt: parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -96,14 +101,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: coordinate,
|
id: coordinate,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: article.created_at ?? null,
|
||||||
pubkey: article.pubkey,
|
pubkey: article.pubkey,
|
||||||
kind: article.kind, // Usually 30023 for long-form articles
|
kind: article.kind, // Usually 30023 for long-form articles
|
||||||
tags: [],
|
tags: [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
listUpdatedAt: parentCreatedAt ?? null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -114,14 +119,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: `hashtag-${hashtag}`,
|
id: `hashtag-${hashtag}`,
|
||||||
content: `#${hashtag}`,
|
content: `#${hashtag}`,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: 0, // Hashtags don't have their own creation time
|
||||||
pubkey: activeAccount.pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [['t', hashtag]],
|
tags: [['t', hashtag]],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
listUpdatedAt: parentCreatedAt ?? null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -132,14 +137,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: `url-${url}`,
|
id: `url-${url}`,
|
||||||
content: url,
|
content: url,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: 0, // URLs don't have their own creation time
|
||||||
pubkey: activeAccount.pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [['r', url]],
|
tags: [['r', url]],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
listUpdatedAt: parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -148,20 +153,24 @@ export const processApplesauceBookmarks = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||||
return bookmarkArray
|
const processed = bookmarkArray
|
||||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||||
.map((bookmark: BookmarkData) => ({
|
.map((bookmark: BookmarkData) => {
|
||||||
id: bookmark.id!,
|
return {
|
||||||
content: bookmark.content || '',
|
id: bookmark.id!,
|
||||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
content: bookmark.content || '',
|
||||||
pubkey: activeAccount.pubkey,
|
created_at: bookmark.created_at ?? null,
|
||||||
kind: bookmark.kind || 30001,
|
pubkey: activeAccount.pubkey,
|
||||||
tags: bookmark.tags || [],
|
kind: bookmark.kind || 30001,
|
||||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
tags: bookmark.tags || [],
|
||||||
type: 'event' as const,
|
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||||
isPrivate,
|
type: 'event' as const,
|
||||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
isPrivate,
|
||||||
}))
|
listUpdatedAt: parentCreatedAt ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return processed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Types and guards around signer/decryption APIs
|
// Types and guards around signer/decryption APIs
|
||||||
@@ -169,29 +178,38 @@ export function hydrateItems(
|
|||||||
items: IndividualBookmark[],
|
items: IndividualBookmark[],
|
||||||
idToEvent: Map<string, NostrEvent>
|
idToEvent: Map<string, NostrEvent>
|
||||||
): IndividualBookmark[] {
|
): IndividualBookmark[] {
|
||||||
return items.map(item => {
|
return items
|
||||||
const ev = idToEvent.get(item.id)
|
.map(item => {
|
||||||
if (!ev) return item
|
const ev = idToEvent.get(item.id)
|
||||||
|
if (!ev) return item
|
||||||
// For long-form articles (kind:30023), use the article title as content
|
|
||||||
let content = ev.content || item.content || ''
|
// For long-form articles (kind:30023), use the article title as content
|
||||||
if (ev.kind === 30023) {
|
let content = ev.content || item.content || ''
|
||||||
const articleTitle = getArticleTitle(ev)
|
if (ev.kind === 30023) {
|
||||||
if (articleTitle) {
|
const articleTitle = getArticleTitle(ev)
|
||||||
content = articleTitle
|
if (articleTitle) {
|
||||||
|
content = articleTitle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Ensure all events with content get parsed content for proper rendering
|
||||||
return {
|
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
|
||||||
...item,
|
|
||||||
pubkey: ev.pubkey || item.pubkey,
|
return {
|
||||||
content,
|
...item,
|
||||||
created_at: ev.created_at || item.created_at,
|
pubkey: ev.pubkey || item.pubkey,
|
||||||
kind: ev.kind || item.kind,
|
content,
|
||||||
tags: ev.tags || item.tags,
|
created_at: ev.created_at || item.created_at,
|
||||||
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
|
kind: ev.kind || item.kind,
|
||||||
}
|
tags: ev.tags || item.tags,
|
||||||
})
|
parsedContent: parsedContent || item.parsedContent
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => {
|
||||||
|
// Filter out bookmark list events (they're containers, not content)
|
||||||
|
const isBookmarkListEvent = item.kind === 10003 || item.kind === 30003 || item.kind === 30001
|
||||||
|
return !isBookmarkListEvent
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: event decryption/collection lives in `bookmarkProcessing.ts`
|
// Note: event decryption/collection lives in `bookmarkProcessing.ts`
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ async function decryptEvent(
|
|||||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
||||||
privateItems.push(
|
privateItems.push(
|
||||||
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
|
...processApplesauceBookmarks(manualPrivate, activeAccount, true, evt.created_at).map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
@@ -84,7 +84,7 @@ async function decryptEvent(
|
|||||||
const priv = Helpers.getHiddenBookmarks(evt)
|
const priv = Helpers.getHiddenBookmarks(evt)
|
||||||
if (priv) {
|
if (priv) {
|
||||||
privateItems.push(
|
privateItems.push(
|
||||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
@@ -133,29 +133,36 @@ export async function collectBookmarksFromEvents(
|
|||||||
|
|
||||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||||
if (evt.kind === 39701) {
|
if (evt.kind === 39701) {
|
||||||
|
// Use coordinate format for web bookmarks to enable proper deduplication
|
||||||
|
// Web bookmarks are replaceable events (kind:39701:pubkey:d-tag)
|
||||||
|
const webBookmarkId = dTag ? `${evt.kind}:${evt.pubkey}:${dTag}` : evt.id
|
||||||
|
|
||||||
publicItemsAll.push({
|
publicItemsAll.push({
|
||||||
id: evt.id,
|
id: webBookmarkId,
|
||||||
content: evt.content || '',
|
content: evt.content || '',
|
||||||
created_at: evt.created_at || Math.floor(Date.now() / 1000),
|
created_at: evt.created_at ?? null,
|
||||||
pubkey: evt.pubkey,
|
pubkey: evt.pubkey,
|
||||||
kind: evt.kind,
|
kind: evt.kind,
|
||||||
tags: evt.tags || [],
|
tags: evt.tags || [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'web' as const,
|
type: 'web' as const,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
added_at: evt.created_at || Math.floor(Date.now() / 1000),
|
|
||||||
sourceKind: 39701,
|
sourceKind: 39701,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
setTitle,
|
setTitle,
|
||||||
setDescription,
|
setDescription,
|
||||||
setImage
|
setImage,
|
||||||
|
listUpdatedAt: evt.created_at ?? null
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const pub = Helpers.getPublicBookmarks(evt)
|
const pub = Helpers.getPublicBookmarks(evt)
|
||||||
|
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
|
||||||
|
|
||||||
|
|
||||||
publicItemsAll.push(
|
publicItemsAll.push(
|
||||||
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
|
...processedPub.map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
@@ -181,7 +188,7 @@ export async function collectBookmarksFromEvents(
|
|||||||
const priv = Helpers.getHiddenBookmarks(evt)
|
const priv = Helpers.getHiddenBookmarks(evt)
|
||||||
if (priv) {
|
if (priv) {
|
||||||
publicItemsAll.push(
|
publicItemsAll.push(
|
||||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
|
|||||||
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,8 +1,9 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { cacheArticleEvent } from './articleService'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export interface BlogPostPreview {
|
|||||||
* @param relayUrls - Array of relay URLs to query
|
* @param relayUrls - Array of relay URLs to query
|
||||||
* @param onPost - Optional callback for streaming posts
|
* @param onPost - Optional callback for streaming posts
|
||||||
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
|
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
|
||||||
|
* @param eventStore - Optional event store to persist fetched events
|
||||||
* @returns Array of blog post previews
|
* @returns Array of blog post previews
|
||||||
*/
|
*/
|
||||||
export const fetchBlogPostsFromAuthors = async (
|
export const fetchBlogPostsFromAuthors = async (
|
||||||
@@ -29,7 +31,8 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
pubkeys: string[],
|
pubkeys: string[],
|
||||||
relayUrls: string[],
|
relayUrls: string[],
|
||||||
onPost?: (post: BlogPostPreview) => void,
|
onPost?: (post: BlogPostPreview) => void,
|
||||||
limit: number | null = 100
|
limit: number | null = 100,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<BlogPostPreview[]> => {
|
): Promise<BlogPostPreview[]> => {
|
||||||
try {
|
try {
|
||||||
if (pubkeys.length === 0) {
|
if (pubkeys.length === 0) {
|
||||||
@@ -45,12 +48,17 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
|
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
|
||||||
: { kinds: [KINDS.BlogPost], authors: pubkeys }
|
: { kinds: [KINDS.BlogPost], authors: pubkeys }
|
||||||
|
|
||||||
await queryEvents(
|
const events = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
filter,
|
filter,
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
|
// Store in event store immediately if provided
|
||||||
|
if (eventStore) {
|
||||||
|
eventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const key = `${event.pubkey}:${dTag}`
|
const key = `${event.pubkey}:${dTag}`
|
||||||
const existing = uniqueEvents.get(key)
|
const existing = uniqueEvents.get(key)
|
||||||
@@ -68,11 +76,18 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
}
|
}
|
||||||
onPost(post)
|
onPost(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache article content in localStorage for offline access
|
||||||
|
cacheArticleEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Store all events in event store if provided (safety net for any missed during streaming)
|
||||||
|
if (eventStore) {
|
||||||
|
events.forEach(evt => eventStore.add(evt))
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to blog post previews and sort by published date (most recent first)
|
// Convert to blog post previews and sort by published date (most recent first)
|
||||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||||
@@ -94,7 +109,6 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
return timeB - timeA // Most recent first
|
return timeB - timeA // Most recent first
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
return blogPosts
|
return blogPosts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch blog posts:', error)
|
console.error('Failed to fetch blog posts:', error)
|
||||||
|
|||||||
@@ -7,13 +7,22 @@ import { Helpers, IEventStore } from 'applesauce-core'
|
|||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
import { publishEvent } from './writeService'
|
import { setHighlightMetadata } from './highlightEventProcessor'
|
||||||
|
|
||||||
// Boris pubkey for zap splits
|
// Boris pubkey for zap splits
|
||||||
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||||
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||||
|
|
||||||
|
// Extended event type with highlight metadata
|
||||||
|
interface HighlightEvent extends NostrEvent {
|
||||||
|
__highlightProps?: {
|
||||||
|
publishedRelays?: string[]
|
||||||
|
isLocalOnly?: boolean
|
||||||
|
isSyncing?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getHighlightText,
|
getHighlightText,
|
||||||
getHighlightContext,
|
getHighlightContext,
|
||||||
@@ -118,25 +127,111 @@ export async function createHighlight(
|
|||||||
// Sign the event
|
// Sign the event
|
||||||
const signedEvent = await factory.sign(highlightEvent)
|
const signedEvent = await factory.sign(highlightEvent)
|
||||||
|
|
||||||
// Use unified write service to store and publish
|
// Initialize custom properties on the event (will be updated after publishing)
|
||||||
await publishEvent(relayPool, eventStore, signedEvent)
|
;(signedEvent as HighlightEvent).__highlightProps = {
|
||||||
|
publishedRelays: [],
|
||||||
|
isLocalOnly: false,
|
||||||
|
isSyncing: false
|
||||||
|
}
|
||||||
|
|
||||||
// Check current connection status for UI feedback
|
// Get only connected relays to avoid long timeouts
|
||||||
const connectedRelays = Array.from(relayPool.relays.values())
|
const connectedRelays = Array.from(relayPool.relays.values())
|
||||||
.filter(relay => relay.connected)
|
.filter(relay => relay.connected)
|
||||||
.map(relay => relay.url)
|
.map(relay => relay.url)
|
||||||
|
|
||||||
|
let publishResponses: { ok: boolean; message?: string; from: string }[] = []
|
||||||
|
let isLocalOnly = false
|
||||||
|
|
||||||
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
|
||||||
const expectedSuccessRelays = hasRemoteConnection
|
|
||||||
? RELAYS
|
|
||||||
: RELAYS.filter(isLocalRelay)
|
|
||||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
|
||||||
|
|
||||||
// Convert to Highlight with relay tracking info and return IMMEDIATELY
|
try {
|
||||||
|
// Publish only to connected relays to avoid long timeouts
|
||||||
|
if (connectedRelays.length === 0) {
|
||||||
|
isLocalOnly = true
|
||||||
|
} else {
|
||||||
|
publishResponses = await relayPool.publish(connectedRelays, signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which relays successfully accepted the event
|
||||||
|
const successfulRelays = publishResponses
|
||||||
|
.filter(response => response.ok)
|
||||||
|
.map(response => response.from)
|
||||||
|
|
||||||
|
const successfulLocalRelays = successfulRelays.filter(url => isLocalRelay(url))
|
||||||
|
const successfulRemoteRelays = successfulRelays.filter(url => !isLocalRelay(url))
|
||||||
|
|
||||||
|
// isLocalOnly is true if only local relays accepted the event
|
||||||
|
isLocalOnly = successfulLocalRelays.length > 0 && successfulRemoteRelays.length === 0
|
||||||
|
|
||||||
|
|
||||||
|
// Handle case when no relays were connected
|
||||||
|
const successfulRelaysList = publishResponses.length > 0
|
||||||
|
? publishResponses
|
||||||
|
.filter(response => response.ok)
|
||||||
|
.map(response => response.from)
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Store metadata in cache (persists across EventStore serialization)
|
||||||
|
setHighlightMetadata(signedEvent.id, {
|
||||||
|
publishedRelays: successfulRelaysList,
|
||||||
|
isLocalOnly,
|
||||||
|
isSyncing: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also update the event with the actual properties (for backwards compatibility)
|
||||||
|
;(signedEvent as HighlightEvent).__highlightProps = {
|
||||||
|
publishedRelays: successfulRelaysList,
|
||||||
|
isLocalOnly,
|
||||||
|
isSyncing: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the event in EventStore AFTER updating with final properties
|
||||||
|
eventStore.add(signedEvent)
|
||||||
|
|
||||||
|
// Mark for offline sync if we're in local-only mode
|
||||||
|
if (isLocalOnly) {
|
||||||
|
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
|
||||||
|
markEventAsOfflineCreated(signedEvent.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [HIGHLIGHT-PUBLISH] Failed to publish highlight to relays:', error)
|
||||||
|
// If publishing fails completely, assume local-only mode
|
||||||
|
isLocalOnly = true
|
||||||
|
|
||||||
|
// Store metadata in cache (persists across EventStore serialization)
|
||||||
|
setHighlightMetadata(signedEvent.id, {
|
||||||
|
publishedRelays: [],
|
||||||
|
isLocalOnly: true,
|
||||||
|
isSyncing: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also update the event with the error state (for backwards compatibility)
|
||||||
|
;(signedEvent as HighlightEvent).__highlightProps = {
|
||||||
|
publishedRelays: [],
|
||||||
|
isLocalOnly: true,
|
||||||
|
isSyncing: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the event in EventStore AFTER updating with final properties
|
||||||
|
eventStore.add(signedEvent)
|
||||||
|
|
||||||
|
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
|
||||||
|
markEventAsOfflineCreated(signedEvent.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Highlight with relay tracking info
|
||||||
const highlight = eventToHighlight(signedEvent)
|
const highlight = eventToHighlight(signedEvent)
|
||||||
highlight.publishedRelays = expectedSuccessRelays
|
|
||||||
|
// Manually set the properties since __highlightProps might not be working
|
||||||
|
const finalPublishedRelays = publishResponses.length > 0
|
||||||
|
? publishResponses
|
||||||
|
.filter(response => response.ok)
|
||||||
|
.map(response => response.from)
|
||||||
|
: []
|
||||||
|
|
||||||
|
highlight.publishedRelays = finalPublishedRelays
|
||||||
highlight.isLocalOnly = isLocalOnly
|
highlight.isLocalOnly = isLocalOnly
|
||||||
highlight.isOfflineCreated = isLocalOnly
|
highlight.isSyncing = false
|
||||||
|
|
||||||
return highlight
|
return highlight
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { NostrEvent } from 'nostr-tools'
|
|||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
|
||||||
|
// Extended event type with highlight metadata
|
||||||
|
interface HighlightEvent extends NostrEvent {
|
||||||
|
__highlightProps?: {
|
||||||
|
publishedRelays?: string[]
|
||||||
|
isLocalOnly?: boolean
|
||||||
|
isSyncing?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getHighlightText,
|
getHighlightText,
|
||||||
getHighlightContext,
|
getHighlightContext,
|
||||||
@@ -12,6 +21,66 @@ const {
|
|||||||
getHighlightAttributions
|
getHighlightAttributions
|
||||||
} = Helpers
|
} = Helpers
|
||||||
|
|
||||||
|
const METADATA_CACHE_KEY = 'highlightMetadataCache'
|
||||||
|
|
||||||
|
type HighlightMetadata = {
|
||||||
|
publishedRelays?: string[]
|
||||||
|
isLocalOnly?: boolean
|
||||||
|
isSyncing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load highlight metadata from localStorage
|
||||||
|
*/
|
||||||
|
function loadHighlightMetadataFromStorage(): Map<string, HighlightMetadata> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(METADATA_CACHE_KEY)
|
||||||
|
if (!raw) return new Map()
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, HighlightMetadata>
|
||||||
|
return new Map(Object.entries(parsed))
|
||||||
|
} catch {
|
||||||
|
// Silently fail on parse errors or if storage is unavailable
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save highlight metadata to localStorage
|
||||||
|
*/
|
||||||
|
function saveHighlightMetadataToStorage(cache: Map<string, HighlightMetadata>): void {
|
||||||
|
try {
|
||||||
|
const record = Object.fromEntries(cache.entries())
|
||||||
|
localStorage.setItem(METADATA_CACHE_KEY, JSON.stringify(record))
|
||||||
|
} catch {
|
||||||
|
// Silently fail if storage is full or unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache for highlight metadata that persists across EventStore serialization
|
||||||
|
* Key: event ID, Value: { publishedRelays, isLocalOnly, isSyncing }
|
||||||
|
*/
|
||||||
|
const highlightMetadataCache = loadHighlightMetadataFromStorage()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store highlight metadata for an event ID
|
||||||
|
*/
|
||||||
|
export function setHighlightMetadata(
|
||||||
|
eventId: string,
|
||||||
|
metadata: HighlightMetadata
|
||||||
|
): void {
|
||||||
|
highlightMetadataCache.set(eventId, metadata)
|
||||||
|
saveHighlightMetadataToStorage(highlightMetadataCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get highlight metadata for an event ID
|
||||||
|
*/
|
||||||
|
export function getHighlightMetadata(eventId: string): HighlightMetadata | undefined {
|
||||||
|
return highlightMetadataCache.get(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a NostrEvent to a Highlight object
|
* Convert a NostrEvent to a Highlight object
|
||||||
*/
|
*/
|
||||||
@@ -28,6 +97,12 @@ export function eventToHighlight(event: NostrEvent): Highlight {
|
|||||||
const eventReference = sourceEventPointer?.id ||
|
const eventReference = sourceEventPointer?.id ||
|
||||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||||
|
|
||||||
|
// Check cache first (persists across EventStore serialization)
|
||||||
|
const cachedMetadata = getHighlightMetadata(event.id)
|
||||||
|
|
||||||
|
// Fall back to __highlightProps if cache doesn't have it (for backwards compatibility)
|
||||||
|
const customProps = cachedMetadata || (event as HighlightEvent).__highlightProps || {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
@@ -38,7 +113,11 @@ export function eventToHighlight(event: NostrEvent): Highlight {
|
|||||||
urlReference: sourceUrl,
|
urlReference: sourceUrl,
|
||||||
author,
|
author,
|
||||||
context,
|
context,
|
||||||
comment
|
comment,
|
||||||
|
// Preserve custom properties if they exist
|
||||||
|
publishedRelays: customProps.publishedRelays,
|
||||||
|
isLocalOnly: customProps.isLocalOnly,
|
||||||
|
isSyncing: customProps.isSyncing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const fetchHighlightsFromAuthors = async (
|
|||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const rawEvents = await queryEvents(
|
const rawEvents = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [9802], authors: pubkeys, limit: 200 },
|
{ kinds: [9802], authors: pubkeys, limit: 1000 },
|
||||||
{
|
{
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
if (!seenIds.has(event.id)) {
|
if (!seenIds.has(event.id)) {
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
|
|||||||
type HighlightsCallback = (highlights: Highlight[]) => void
|
type HighlightsCallback = (highlights: Highlight[]) => void
|
||||||
type LoadingCallback = (loading: boolean) => void
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
|
||||||
const LAST_SYNCED_KEY = 'highlights_last_synced'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared highlights controller
|
* Shared highlights controller
|
||||||
* Manages the user's highlights centrally, similar to bookmarkController
|
* Manages the user's highlights centrally, similar to bookmarkController
|
||||||
@@ -68,37 +66,10 @@ class HighlightsController {
|
|||||||
this.emitHighlights(this.currentHighlights)
|
this.emitHighlights(this.currentHighlights)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get last synced timestamp for incremental loading
|
|
||||||
*/
|
|
||||||
private getLastSyncedAt(pubkey: string): number | null {
|
|
||||||
try {
|
|
||||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
|
||||||
if (!data) return null
|
|
||||||
const parsed = JSON.parse(data)
|
|
||||||
return parsed[pubkey] || null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update last synced timestamp
|
|
||||||
*/
|
|
||||||
private setLastSyncedAt(pubkey: string, timestamp: number): void {
|
|
||||||
try {
|
|
||||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
|
||||||
const parsed = data ? JSON.parse(data) : {}
|
|
||||||
parsed[pubkey] = timestamp
|
|
||||||
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[highlights] Failed to save last synced timestamp:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load highlights for a user
|
* Load highlights for a user
|
||||||
* Streams results and stores in event store
|
* Streams results and stores in event store
|
||||||
|
* Always fetches ALL highlights to ensure completeness
|
||||||
*/
|
*/
|
||||||
async start(options: {
|
async start(options: {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -124,15 +95,12 @@ class HighlightsController {
|
|||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const highlightsMap = new Map<string, Highlight>()
|
const highlightsMap = new Map<string, Highlight>()
|
||||||
|
|
||||||
// Get last synced timestamp for incremental loading
|
// Fetch ALL highlights without limits (no since filter)
|
||||||
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
|
// This ensures we get complete results for profile/my pages
|
||||||
const filter: { kinds: number[]; authors: string[]; since?: number } = {
|
const filter = {
|
||||||
kinds: [KINDS.Highlights],
|
kinds: [KINDS.Highlights],
|
||||||
authors: [pubkey]
|
authors: [pubkey]
|
||||||
}
|
}
|
||||||
if (lastSyncedAt) {
|
|
||||||
filter.since = lastSyncedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = await queryEvents(
|
const events = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
@@ -179,12 +147,6 @@ class HighlightsController {
|
|||||||
this.lastLoadedPubkey = pubkey
|
this.lastLoadedPubkey = pubkey
|
||||||
this.emitHighlights(sorted)
|
this.emitHighlights(sorted)
|
||||||
|
|
||||||
// Update last synced timestamp
|
|
||||||
if (sorted.length > 0) {
|
|
||||||
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
|
|
||||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[highlights] ❌ Failed to load highlights:', error)
|
console.error('[highlights] ❌ Failed to load highlights:', error)
|
||||||
this.currentHighlights = []
|
this.currentHighlights = []
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user