mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
1397 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 | ||
|
|
84b0339505 | ||
|
|
12fa1db0db | ||
|
|
0919091f19 | ||
|
|
e1c04b4e7f | ||
|
|
b9642067a1 | ||
|
|
ceca37df08 | ||
|
|
dfdc5d0946 | ||
|
|
3619cd2585 | ||
|
|
f93e52611e | ||
|
|
ecb81cb151 | ||
|
|
adf73cb9d1 | ||
|
|
4202807777 | ||
|
|
1c21615103 | ||
|
|
732070e89b | ||
|
|
d9a00dd157 | ||
|
|
103be75f6e | ||
|
|
8dd4e358b4 | ||
|
|
2e8dfaee09 | ||
|
|
db3084b373 | ||
|
|
83e4a2ad4c | ||
|
|
c1d23fac7b | ||
|
|
de32310801 | ||
|
|
5c82dff8df | ||
|
|
abe2d6528a | ||
|
|
8b56fe3d6e | ||
|
|
bdce7c9358 | ||
|
|
81a4ae392f | ||
|
|
6e438b8ee2 | ||
|
|
31974e7271 | ||
|
|
676be1a932 | ||
|
|
9883f2eb1a | ||
|
|
87e46be86f | ||
|
|
b745a92a7e | ||
|
|
5a79da4024 | ||
|
|
a7d05a29f5 | ||
|
|
0740d53d37 | ||
|
|
914738abb4 | ||
|
|
4fac5f42c9 | ||
|
|
16b3668e73 | ||
|
|
f3a83256a8 | ||
|
|
0e98ddeef4 | ||
|
|
1ba375e93e | ||
|
|
5d14d25d0e | ||
|
|
616038a23a | ||
|
|
14fce2c3dc | ||
|
|
7c511de474 | ||
|
|
3a10ac8691 | ||
|
|
205879f948 | ||
|
|
bff43f4a28 | ||
|
|
2a7fffd594 | ||
|
|
50a4161e16 | ||
|
|
5fd8976097 | ||
|
|
80b26abff2 | ||
|
|
c0638851c6 | ||
|
|
9b6b14cfe8 | ||
|
|
b6ad62a3ab | ||
|
|
85d87bac29 | ||
|
|
3b31eceeab | ||
|
|
442c138d6a | ||
|
|
61e6027252 | ||
|
|
7d373015b4 | ||
|
|
32b1286079 | ||
|
|
17fdd92827 | ||
|
|
aa6aeb2723 | ||
|
|
4b0f275f57 | ||
|
|
73e2e060e3 | ||
|
|
3007ae83c2 | ||
|
|
a862eb880e | ||
|
|
016e369fb1 | ||
|
|
4f21982c48 | ||
|
|
f6d3fe9aba | ||
|
|
fc60e6b80a | ||
|
|
d9cdbb7279 | ||
|
|
401d333e0f | ||
|
|
d32a47e3c3 | ||
|
|
35efdb6d3f | ||
|
|
c7f7792d73 | ||
|
|
8aa26caae0 | ||
|
|
6c00904bd5 | ||
|
|
23526954ea | ||
|
|
9a437dd97b | ||
|
|
0baf75462c | ||
|
|
30b8f1af92 | ||
|
|
07aea9d35f | ||
|
|
41a4abff37 | ||
|
|
c9998984c3 | ||
|
|
a799709e62 | ||
|
|
18c6c3e68a | ||
|
|
5e7395652f | ||
|
|
83076e7b01 | ||
|
|
c79f4122da | ||
|
|
179fe0bbc2 | ||
|
|
20b4f2b1b2 | ||
|
|
936f9093cf | ||
|
|
3149e5b824 | ||
|
|
8619cecaf3 | ||
|
|
d40c49edb0 | ||
|
|
ce5d97fb1f | ||
|
|
ffb8031a05 | ||
|
|
d54e1072b8 | ||
|
|
55defb645c | ||
|
|
1ba9595542 | ||
|
|
340913f15f | ||
|
|
1d6595f754 | ||
|
|
6099e3c6a4 | ||
|
|
ed75bc6059 | ||
|
|
dcfc08287e | ||
|
|
35b2168f9a | ||
|
|
f8a9079e5f | ||
|
|
780996c7c5 | ||
|
|
809437faa6 | ||
|
|
36f14811ae | ||
|
|
8b95af9c49 | ||
|
|
236ade3d2f | ||
|
|
c2e882ec31 | ||
|
|
0a382e77b9 | ||
|
|
a1fd4bfc94 | ||
|
|
530cc20cba | ||
|
|
a275c0a8e3 | ||
|
|
cb43b748e4 | ||
|
|
ff9ce46448 | ||
|
|
1e6718fe1e | ||
|
|
d6a913f2a6 | ||
|
|
8030e2fa00 | ||
|
|
1ff2f28566 | ||
|
|
78457335c6 | ||
|
|
553feb10df | ||
|
|
ba5d7df3bd | ||
|
|
cf3ca2d527 | ||
|
|
06763d5307 | ||
|
|
a08e4fdc24 | ||
|
|
bc7b4ae42d | ||
|
|
4dc1894ef3 | ||
|
|
f00f26dfe0 | ||
|
|
2e59bc9375 | ||
|
|
0d50d05245 | ||
|
|
90c74a8e9d | ||
|
|
a4bad34a90 | ||
|
|
84ff24e06a | ||
|
|
aaf8a9d4fc | ||
|
|
efa6d13726 | ||
|
|
6116dd12bc | ||
|
|
210cdd41ec | ||
|
|
9378b3c9a9 | ||
|
|
973409e82a | ||
|
|
5d6f48b9a8 | ||
|
|
4921427ad4 | ||
|
|
ad8cad29d3 | ||
|
|
8d4a4a04a3 | ||
|
|
1dc44930b4 | ||
|
|
c77907f87a | ||
|
|
9345228e66 | ||
|
|
811362175c | ||
|
|
3d22e7a3cb | ||
|
|
0b0d3c2859 | ||
|
|
1f8d18071c | ||
|
|
a4afe59437 | ||
|
|
1fe3786a3d | ||
|
|
42d265731f | ||
|
|
e4b4b97874 | ||
|
|
1870c307da | ||
|
|
bcb6cfbe97 | ||
|
|
6ba1ce27b7 | ||
|
|
2f620265f4 | ||
|
|
61ae31c6a2 | ||
|
|
b0fcb0e897 | ||
|
|
3b08cd5d23 | ||
|
|
a3a00b8456 | ||
|
|
7fecc0c0c3 | ||
|
|
93d0284fd6 | ||
|
|
94d5089e33 | ||
|
|
5965bc1747 | ||
|
|
0fbf80b04f | ||
|
|
2004ce76c9 | ||
|
|
90c79e34eb | ||
|
|
6ea0fd292c | ||
|
|
193c1f45d4 | ||
|
|
4da3a0347f | ||
|
|
795ef5016e | ||
|
|
83693f7fb0 | ||
|
|
c55e20f341 | ||
|
|
1430d2fc47 | ||
|
|
3f24ccff74 | ||
|
|
51b7e53385 | ||
|
|
8dbb18b1c8 | ||
|
|
88bc7f690e | ||
|
|
29ef21a1fa | ||
|
|
7a75982715 | ||
|
|
f95f8f4bf1 | ||
|
|
9eef5855a9 | ||
|
|
2e70745bab | ||
|
|
8a971dfe52 | ||
|
|
a004e96eca | ||
|
|
ce2432632c | ||
|
|
56b3100c8e | ||
|
|
327d65a128 | ||
|
|
e5a7a07deb | ||
|
|
5bd57573be | ||
|
|
c2223e6b08 | ||
|
|
d1ffc8c3f9 | ||
|
|
5a5cd14df5 | ||
|
|
2fb25da9d6 | ||
|
|
21228cd212 | ||
|
|
e0b86a84ba | ||
|
|
c3a4e41968 | ||
|
|
f3205843ac | ||
|
|
9a03dd312f | ||
|
|
b711b21048 | ||
|
|
8eaba04d91 | ||
|
|
0785b034e4 | ||
|
|
47e698f197 | ||
|
|
3a752a761a | ||
|
|
f6cc49c07a | ||
|
|
5c4fca9cc9 | ||
|
|
536a7ce1fa | ||
|
|
61072aef40 | ||
|
|
b7ec1fcf06 | ||
|
|
d2fd8fb8fe | ||
|
|
68ee1b3122 | ||
|
|
a37735fc1c | ||
|
|
de0f587174 | ||
|
|
f977561779 | ||
|
|
043ea168fb | ||
|
|
5336bafed4 | ||
|
|
c51291bf81 | ||
|
|
489e48fe4d | ||
|
|
744a145e9f | ||
|
|
7ad925dbd3 | ||
|
|
a69298a3a9 | ||
|
|
2c3aff0407 | ||
|
|
aad35d41db | ||
|
|
cc6189a5d9 | ||
|
|
18bf8f9a2c | ||
|
|
37f3a32a1c | ||
|
|
c9678564a5 | ||
|
|
721c18c509 | ||
|
|
9e30fe683b | ||
|
|
7fff50c146 | ||
|
|
fc1c845b67 | ||
|
|
c2ec1f3677 | ||
|
|
0cbd357856 | ||
|
|
26ea9ed547 | ||
|
|
9cbbecb32c | ||
|
|
db12c89731 | ||
|
|
6f413deb90 | ||
|
|
0127e2dc86 | ||
|
|
7743928702 | ||
|
|
bf76150fc1 | ||
|
|
c62107172b | ||
|
|
a253587dfa | ||
|
|
1938533d53 | ||
|
|
28943c55bd | ||
|
|
791bbb68b6 | ||
|
|
ec8adcc794 | ||
|
|
68058e7661 | ||
|
|
416c62369c | ||
|
|
a19dd53423 | ||
|
|
79ec33b79a | ||
|
|
be881b957c | ||
|
|
244872e9f2 | ||
|
|
1397f7f0f4 | ||
|
|
96424dd65c | ||
|
|
9efc5459fb | ||
|
|
7e02168e54 | ||
|
|
f8e6b3e828 | ||
|
|
c06176bfc9 | ||
|
|
e2a1701000 | ||
|
|
d7703ceef4 | ||
|
|
93daabc673 | ||
|
|
9264245944 | ||
|
|
f56423040b | ||
|
|
4b91504a50 | ||
|
|
1f0f7fef5e | ||
|
|
6aced653fb | ||
|
|
0899482869 | ||
|
|
1bdfa1e6e1 | ||
|
|
f22a8f15c0 | ||
|
|
bf6394fc7d | ||
|
|
6f08586e8f | ||
|
|
d60a4a24ad | ||
|
|
51069f3623 | ||
|
|
1407af22e3 | ||
|
|
ea6220277d | ||
|
|
fbffa03dad | ||
|
|
a74760d804 | ||
|
|
c4b0a712d2 | ||
|
|
1fecf9c7f4 | ||
|
|
7be21203d9 | ||
|
|
f65f2c6597 | ||
|
|
227def4328 | ||
|
|
b506624f57 | ||
|
|
fbb6a0a153 | ||
|
|
528de32689 | ||
|
|
230e5380ca | ||
|
|
349237d097 | ||
|
|
d4df9f0424 | ||
|
|
2f68e84002 | ||
|
|
b18dcc29cd | ||
|
|
680169e312 | ||
|
|
11753c4515 | ||
|
|
bd29dfd65f | ||
|
|
4b1ae838e5 | ||
|
|
85599d3103 | ||
|
|
4603c5a258 | ||
|
|
ec45fbc5e8 | ||
|
|
53400334b2 | ||
|
|
af4ff7081a | ||
|
|
7f21b8ed76 | ||
|
|
55e44dcc9c | ||
|
|
59dac947ab | ||
|
|
7d33c3c024 | ||
|
|
38a014ef84 | ||
|
|
f451348430 | ||
|
|
685aaf43b0 | ||
|
|
d6a20b5272 | ||
|
|
d8d7a19fa1 | ||
|
|
63626fae3a | ||
|
|
de09ef2935 | ||
|
|
bcb28a63a7 | ||
|
|
a479903ce3 | ||
|
|
567d105261 | ||
|
|
83743c5a9f | ||
|
|
0b8f88ea1d | ||
|
|
fadc755930 | ||
|
|
f67f171e64 | ||
|
|
449c59015e | ||
|
|
4d697e6a79 | ||
|
|
04ae70873a | ||
|
|
2f8a64826a | ||
|
|
11cb3542ee | ||
|
|
905296621c | ||
|
|
769484bc0d | ||
|
|
27ff4cef22 | ||
|
|
a352e2616e | ||
|
|
77cbb9394f | ||
|
|
39c8b3dfe4 | ||
|
|
7bd11e695e | ||
|
|
a76b703d36 | ||
|
|
df51173405 | ||
|
|
a79d7f9eaf | ||
|
|
1032a46456 | ||
|
|
ae997758ab | ||
|
|
91a827324d | ||
|
|
bf849c9faa | ||
|
|
118ab46ac0 | ||
|
|
d2f2b689f9 | ||
|
|
5229e45566 | ||
|
|
b17043e85d | ||
|
|
19ca909ef5 | ||
|
|
f7ff309b6e | ||
|
|
ea5a8486b9 | ||
|
|
58897b3436 | ||
|
|
6a59ecfa47 | ||
|
|
272066c6e0 | ||
|
|
0426c9d3b0 | ||
|
|
c22419ba0e | ||
|
|
8278fed2fb | ||
|
|
b24a65b490 | ||
|
|
fb509fabd8 | ||
|
|
d21285123f | ||
|
|
1029b6be0c | ||
|
|
3fff9455a1 | ||
|
|
8c6232e029 | ||
|
|
f6c562e9be | ||
|
|
a92b14e877 | ||
|
|
b69a956247 | ||
|
|
82a8dcf6eb | ||
|
|
8e19e22289 | ||
|
|
e167b57810 | ||
|
|
ba3b82e6b5 | ||
|
|
b5edfbb2c9 | ||
|
|
48048f877a | ||
|
|
bd1afc54c3 | ||
|
|
a2c4bed0f5 | ||
|
|
9bad49fe5f | ||
|
|
2aa6536496 | ||
|
|
bd6d8a0342 | ||
|
|
dc8e86bc57 | ||
|
|
32b843908e | ||
|
|
5a71480459 | ||
|
|
17455aa47b | ||
|
|
4cc32c27de | ||
|
|
99bfe209a5 | ||
|
|
0a28bfbd50 | ||
|
|
ba9fb109f6 | ||
|
|
ec9d2fcb49 | ||
|
|
f841043e03 | ||
|
|
94dc95e1f0 | ||
|
|
32a5145d8f | ||
|
|
a856e8ca26 | ||
|
|
d54306cf92 | ||
|
|
9fdb96b64e | ||
|
|
c50aa3a243 | ||
|
|
adef1a922c | ||
|
|
99df4d6761 | ||
|
|
5f6a414953 | ||
|
|
ed17a68986 | ||
|
|
bedf3daed1 | ||
|
|
2c913cf7e8 | ||
|
|
aff5bff03b | ||
|
|
e90f902f0b | ||
|
|
d763aa5f15 | ||
|
|
9d6b1f6f84 | ||
|
|
9eb2f35dbf | ||
|
|
5f33ad3ba0 | ||
|
|
3db4855532 | ||
|
|
3305be1da5 | ||
|
|
fe55e87496 | ||
|
|
f78f1a3460 | ||
|
|
e73d89739b | ||
|
|
7e2b4b46c9 | ||
|
|
fddf79e0c6 | ||
|
|
cf2098a723 | ||
|
|
5568437663 | ||
|
|
7bfd7fdf6c | ||
|
|
e6876d141f | ||
|
|
5bb81b3c22 | ||
|
|
1e8e58fa05 | ||
|
|
f44e36e4bf | ||
|
|
11c7564f8c | ||
|
|
a064376bd8 | ||
|
|
292e8e9bda | ||
|
|
951a3699ca | ||
|
|
860ec70b1c | ||
|
|
2b69c72939 | ||
|
|
b98d774cbf | ||
|
|
8972571a18 | ||
|
|
ab5d5dca58 | ||
|
|
e383356af1 | ||
|
|
165d10c49b | ||
|
|
e0869c436b | ||
|
|
95432fc276 | ||
|
|
1982d25fa8 | ||
|
|
2fc64b6028 | ||
|
|
6e8686a49d | ||
|
|
fd5ce80a06 | ||
|
|
ac4185e2cc | ||
|
|
9217077283 | ||
|
|
b7c14b5c7c | ||
|
|
9b3cc41770 | ||
|
|
4c4bd2214c | ||
|
|
93c31650f4 | ||
|
|
7f0d99fc29 | ||
|
|
eb6dbe1644 | ||
|
|
474da25f77 | ||
|
|
02eaa1c8f8 | ||
|
|
8800791723 | ||
|
|
6758b9678b | ||
|
|
63f58e010f | ||
|
|
85649ae283 | ||
|
|
d0b814e39d | ||
|
|
f4a227e40a | ||
|
|
6ef0a6dd71 | ||
|
|
5502d71ac4 | ||
|
|
5e1146b015 | ||
|
|
8f89165711 | ||
|
|
674634326f | ||
|
|
30eaec5770 | ||
|
|
0ff3c864a9 | ||
|
|
ab2ca1f5e7 | ||
|
|
cf2d227f61 | ||
|
|
2c9e6cc54e | ||
|
|
8da0a06711 | ||
|
|
be8d857223 | ||
|
|
d50bcd700e | ||
|
|
820ab1d902 | ||
|
|
f5e9e5bf61 | ||
|
|
40b43532e8 | ||
|
|
51a3008730 | ||
|
|
e30cbc72c3 | ||
|
|
6f913262f4 | ||
|
|
0f0462e6ac | ||
|
|
e353f0e2d6 | ||
|
|
ee1365d3ca | ||
|
|
a215d0b026 | ||
|
|
b8d76c0bd8 | ||
|
|
233169b082 | ||
|
|
72b9a04cd2 | ||
|
|
432715efb6 | ||
|
|
8b2b954dde | ||
|
|
c2d2bd8106 | ||
|
|
a5c3085c59 | ||
|
|
c0332f08d6 | ||
|
|
38a1d6caec | ||
|
|
39dd607e7b | ||
|
|
9dc0db3e06 | ||
|
|
b1eb58a385 | ||
|
|
f3c6404f76 | ||
|
|
1a42a6422d | ||
|
|
2e2de4ccda | ||
|
|
4325d3a519 | ||
|
|
51115c5f68 | ||
|
|
2aa6fe860b | ||
|
|
86f39eacf8 | ||
|
|
d15daef3ea | ||
|
|
281c70cdea | ||
|
|
d6d6087543 | ||
|
|
d06e38bc19 | ||
|
|
cfc8eb0bbc | ||
|
|
b85f9b79c3 | ||
|
|
1b0045c737 | ||
|
|
3dc8d7d440 | ||
|
|
bf9ca48d64 | ||
|
|
70441f3d59 | ||
|
|
431f28e861 | ||
|
|
3b1fc095c4 | ||
|
|
9a6c7a29d0 | ||
|
|
c1d173f40e | ||
|
|
f03ec5df8c | ||
|
|
6c74a12636 | ||
|
|
39797803d3 | ||
|
|
c66c1e928d | ||
|
|
f934b641bb | ||
|
|
1128a11603 | ||
|
|
9f90718918 | ||
|
|
067a07fc00 | ||
|
|
1811cf045e | ||
|
|
270b4f429f | ||
|
|
380acbb55f | ||
|
|
c384f0b4fb | ||
|
|
27cf393a03 | ||
|
|
8831726913 | ||
|
|
2f4327874c | ||
|
|
483845962e | ||
|
|
c44b1d6349 | ||
|
|
79f28a142d | ||
|
|
02dd537cd9 | ||
|
|
5af1f14a0b | ||
|
|
664f59a9cc | ||
|
|
7d3641aab7 | ||
|
|
7924df4c67 | ||
|
|
68a8eed4af | ||
|
|
887db84ce7 | ||
|
|
05348fbfeb | ||
|
|
38eb6716f8 | ||
|
|
d7f9cd30eb | ||
|
|
922d041e0e | ||
|
|
76f4588c85 | ||
|
|
e163b92a7e | ||
|
|
11925a42b0 | ||
|
|
acf45530ca | ||
|
|
3792ad6abf | ||
|
|
bf98b307e8 | ||
|
|
d15392f41e | ||
|
|
f26a024255 | ||
|
|
bf9f894c0d | ||
|
|
53a7b7d1c5 | ||
|
|
a12a883cc6 | ||
|
|
0cf076b010 | ||
|
|
e2c712033f | ||
|
|
e38237ca8e | ||
|
|
1fff44fc6c | ||
|
|
4e50073e07 | ||
|
|
0ce64fe83f | ||
|
|
ef848aa93e | ||
|
|
67b287d75d | ||
|
|
b795dfd2c6 | ||
|
|
c68d855983 | ||
|
|
fb1c19e64b | ||
|
|
384c16e29d | ||
|
|
789982bd76 | ||
|
|
8bccc9de48 | ||
|
|
ec8584b4d2 | ||
|
|
54bd59fa2d | ||
|
|
b19f5f55f7 | ||
|
|
0964f25f97 | ||
|
|
5f3e6335c1 | ||
|
|
f30c894c87 | ||
|
|
bec769ac1b | ||
|
|
cb3748e06f | ||
|
|
d5a24f0a46 | ||
|
|
401a8241bd | ||
|
|
2193a7a863 | ||
|
|
e6bc4d7fda | ||
|
|
aee9f73316 | ||
|
|
aef7b4cea4 | ||
|
|
c9a8a3b91e | ||
|
|
0c7b11bdf8 | ||
|
|
8c151a5855 | ||
|
|
9b54fa9c14 | ||
|
|
99d7705404 | ||
|
|
eaa590b8e2 | ||
|
|
715fd8cf10 | ||
|
|
99a9709605 | ||
|
|
65d330d5ed | ||
|
|
1d1d389a03 | ||
|
|
0392389355 | ||
|
|
cf2a500a07 | ||
|
|
7d3748202e | ||
|
|
d7f90faea9 | ||
|
|
cb0066aac9 | ||
|
|
b48397b7a6 | ||
|
|
82ab8419e3 | ||
|
|
142a2414d3 | ||
|
|
081bd95f60 | ||
|
|
300aed0589 | ||
|
|
b2b23c66cf | ||
|
|
838bb6aa3d | ||
|
|
f14ecc5acb | ||
|
|
d533e23dc0 | ||
|
|
eefcf99364 | ||
|
|
1c0790bfb6 | ||
|
|
29e351ba78 | ||
|
|
7592c5c327 | ||
|
|
f5018204ab |
@@ -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
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -8,6 +8,11 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Applesauce Reference
|
# Reference Projects
|
||||||
applesauce
|
applesauce
|
||||||
|
primal-web-app
|
||||||
|
Amber
|
||||||
|
|
||||||
|
.env
|
||||||
|
scripts/.env
|
||||||
|
.vercel
|
||||||
|
|||||||
155
Amber.md
Normal file
155
Amber.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
## Boris ↔ Amber bunker: current findings
|
||||||
|
|
||||||
|
- **Environment**
|
||||||
|
- Client: Boris (web) using `applesauce` stack (`NostrConnectSigner`, `RelayPool`).
|
||||||
|
- Bunker: Amber (mobile).
|
||||||
|
- We restored a `nostr-connect` account from localStorage and re-wired the signer to the app `RelayPool` before use.
|
||||||
|
|
||||||
|
## What we changed client-side
|
||||||
|
|
||||||
|
- **Signer wiring**
|
||||||
|
- Bound `NostrConnectSigner.subscriptionMethod/publishMethod` to the app `RelayPool` at startup.
|
||||||
|
- After deserialization, recreated the signer with pool context and merged its relays with app `RELAYS` (includes local relays).
|
||||||
|
- Opened the signer subscription and performed a guarded `connect()` with default permissions including `nip04_encrypt/decrypt` and `nip44_encrypt/decrypt`.
|
||||||
|
|
||||||
|
- **Account queue disabling (CRITICAL)**
|
||||||
|
- `applesauce-accounts` `BaseAccount` queues requests by default - each request waits for the previous one to complete before being sent.
|
||||||
|
- This caused batch decrypt operations to hang: first request would timeout waiting for user interaction, blocking all subsequent requests in the queue.
|
||||||
|
- **Solution**: Set `accounts.disableQueue = true` globally on the `AccountManager` in `App.tsx` during initialization. This applies to all accounts.
|
||||||
|
- Without this, Amber never sees decrypt requests because they're stuck in the account's internal queue.
|
||||||
|
- Reference: https://hzrd149.github.io/applesauce/typedoc/classes/applesauce-accounts.BaseAccount.html#disablequeue
|
||||||
|
|
||||||
|
- **Probes and timeouts**
|
||||||
|
- Initial probe tried `decrypt('invalid-ciphertext')` → timed out.
|
||||||
|
- Switched to roundtrip probes: `encrypt(self, ... )` then `decrypt(self, cipher)` for both nip-44 and nip-04.
|
||||||
|
- Increased probe timeout from 3s → 10s; increased bookmark decrypt timeout from 15s → 30s.
|
||||||
|
|
||||||
|
- **Logging**
|
||||||
|
- Added logs for publish/subscribe and parsed the NIP-46 request content length.
|
||||||
|
- Confirmed NIP‑46 request events are kind `24133` with a single `p` tag (expected). The method is inside the encrypted content, so it prints as `method: undefined` (expected).
|
||||||
|
|
||||||
|
## Evidence from logs (client)
|
||||||
|
|
||||||
|
```
|
||||||
|
[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription
|
||||||
|
[bunker] 🔗 Signer relays merged with app RELAYS: (19) [...]
|
||||||
|
[bunker] subscribe via signer: { relays: [...], filters: [...] }
|
||||||
|
[bunker] ✅ Signer subscription opened
|
||||||
|
[bunker] publish via signer: { relays: [...], kind: 24133, tags: [['p', <remote>]], contentLength: 260|304|54704 }
|
||||||
|
[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
|
||||||
|
[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
|
||||||
|
bookmarkProcessing.ts: ❌ nip44.decrypt failed: Decrypt timeout after 30000ms
|
||||||
|
bookmarkProcessing.ts: ❌ nip04.decrypt failed: Decrypt timeout after 30000ms
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Final signer status shows `listening: true`, `isConnected: true`, and requests are published to 19 relays (includes Amber’s).
|
||||||
|
|
||||||
|
## Evidence from Amber (device)
|
||||||
|
|
||||||
|
- Activity screen shows multiple entries for: “Encrypt data using nip 4” and “Encrypt data using nip 44” with green checkmarks.
|
||||||
|
- No entries for “Decrypt data using nip 4” or “Decrypt data using nip 44”.
|
||||||
|
|
||||||
|
## Interpretation
|
||||||
|
|
||||||
|
- Transport and publish paths are working: Boris is publishing NIP‑46 requests (kind 24133) and Amber receives them (ENCRYPT activity visible).
|
||||||
|
- The persistent failure is specific to DECRYPT handling: Amber does not show any DECRYPT activity and Boris receives no decrypt responses within 10–30s windows.
|
||||||
|
- Client-side wiring is likely correct (subscription open, permissions requested, relays merged). The remaining issue appears provider-side in Amber’s NIP‑46 decrypt handling or permission gating.
|
||||||
|
|
||||||
|
## Repro steps (quick)
|
||||||
|
|
||||||
|
1) Revoke Boris in Amber.
|
||||||
|
2) Reconnect with a fresh bunker URI; approve signing and both encrypt/decrypt scopes for nip‑04 and nip‑44.
|
||||||
|
3) Keep Amber unlocked and foregrounded.
|
||||||
|
4) Reload Boris; observe:
|
||||||
|
- Logs showing `publish via signer` for kind 24133.
|
||||||
|
- In Amber, activity should include “Decrypt data using nip 4/44”.
|
||||||
|
|
||||||
|
If DECRYPT entries still don’t appear:
|
||||||
|
|
||||||
|
- This points to Amber’s NIP‑46 provider not executing/authorizing `nip04_decrypt`/`nip44_decrypt` methods, or not publishing responses.
|
||||||
|
|
||||||
|
## Suggestions for Amber-side debugging
|
||||||
|
|
||||||
|
- Verify permission gating allows `nip04_decrypt` and `nip44_decrypt` (not just encrypt).
|
||||||
|
- Confirm the provider recognizes NIP‑46 methods `nip04_decrypt` and `nip44_decrypt` in the decrypted payload and routes them to decrypt routines.
|
||||||
|
- Ensure the response event is published back to the same relays and correctly addressed to the client (`p` tag set and content encrypted back to client pubkey).
|
||||||
|
- Add activity logging for “Decrypt …” attempts and failures to surface denial/exception states.
|
||||||
|
|
||||||
|
## Performance improvements (post-debugging)
|
||||||
|
|
||||||
|
### Non-blocking publish wiring
|
||||||
|
- **Problem**: Awaiting `pool.publish()` completion blocks until all relay sends finish (can take 30s+ with timeouts).
|
||||||
|
- **Solution**: Wrapped `NostrConnectSigner.publishMethod` at app startup to fire-and-forget publish Observable/Promise; responses still arrive via signer subscription.
|
||||||
|
- **Result**: Encrypt/decrypt operations complete in <2s as seen in `/debug` page (NIP-44: ~900ms enc, ~700ms dec; NIP-04: ~1s enc, ~2s dec).
|
||||||
|
|
||||||
|
### Bookmark decryption optimization
|
||||||
|
- **Problem #1**: Sequential decrypt of encrypted bookmark events blocks UI and takes long with multiple events.
|
||||||
|
- **Problem #2**: 30-second timeouts on `nip44.decrypt` meant waiting 30s per event if bunker didn't support nip44.
|
||||||
|
- **Problem #3**: Account request queue blocked all decrypt requests until first one completed (waiting for user interaction).
|
||||||
|
- **Solution**:
|
||||||
|
- Removed all artificial timeouts - let decrypt fail naturally like debug page does.
|
||||||
|
- Added smart encryption detection (NIP-04 has `?iv=`, NIP-44 doesn't) to try the right method first.
|
||||||
|
- **Disabled account queue globally** (`accounts.disableQueue = true`) in `App.tsx` so all requests are sent immediately.
|
||||||
|
- Process sequentially (removed concurrent `mapWithConcurrency` hack).
|
||||||
|
- **Result**: Bookmark decryption is near-instant, limited only by bunker response time and user approval speed.
|
||||||
|
|
||||||
|
## Amethyst-style bookmarks (kind:30001)
|
||||||
|
|
||||||
|
**Important**: Amethyst bookmarks are stored in a **SINGLE** `kind:30001` event with d-tag `"bookmark"` that contains BOTH public AND private bookmarks in different parts of the event.
|
||||||
|
|
||||||
|
### Event structure:
|
||||||
|
- **Event kind**: `30001` (NIP-51 bookmark set)
|
||||||
|
- **d-tag**: `"bookmark"` (identifies this as the Amethyst bookmark list)
|
||||||
|
- **Public bookmarks**: Stored in event `tags` (e.g., `["e", "..."]`, `["a", "..."]`)
|
||||||
|
- **Private bookmarks**: Stored in encrypted `content` field (NIP-04 or NIP-44)
|
||||||
|
|
||||||
|
### Example event:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 30001,
|
||||||
|
"tags": [
|
||||||
|
["d", "bookmark"], // Identifies this as Amethyst bookmarks
|
||||||
|
["e", "102a2fe..."], // Public bookmark (76 total)
|
||||||
|
["a", "30023:..."] // Public bookmark
|
||||||
|
],
|
||||||
|
"content": "lvOfl7Qb...?iv=5KzDXv09..." // NIP-04 encrypted (416 private bookmarks)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Processing:
|
||||||
|
When this single event is processed:
|
||||||
|
1. **Public tags** → 76 bookmark items with `sourceKind: 30001, isPrivate: false, setName: "bookmark"`
|
||||||
|
2. **Encrypted content** → 416 bookmark items with `sourceKind: 30001, isPrivate: true, setName: "bookmark"`
|
||||||
|
3. Total: 492 bookmarks from one event
|
||||||
|
|
||||||
|
### Encryption detection:
|
||||||
|
- The encrypted `content` field contains a JSON array of private bookmark tags
|
||||||
|
- `Helpers.hasHiddenContent()` from `applesauce-core` only detects **NIP-44** encrypted content
|
||||||
|
- **NIP-04** encrypted content must be detected explicitly by checking for `?iv=` in the content string
|
||||||
|
- Both detection methods are needed in:
|
||||||
|
1. **Display logic** (`Debug.tsx` - `hasEncryptedContent()`) - to show padlock emoji and decrypt button
|
||||||
|
2. **Decryption logic** (`bookmarkProcessing.ts`) - to schedule decrypt jobs
|
||||||
|
|
||||||
|
### Grouping:
|
||||||
|
In the UI, these are separated into two groups:
|
||||||
|
- **Amethyst Lists**: `sourceKind === 30001 && !isPrivate && setName === 'bookmark'` (public items)
|
||||||
|
- **Amethyst Private**: `sourceKind === 30001 && isPrivate && setName === 'bookmark'` (private items)
|
||||||
|
|
||||||
|
Both groups come from the same event, separated by whether they were in public tags or encrypted content.
|
||||||
|
|
||||||
|
### Why this matters:
|
||||||
|
This dual-storage format (public + private in one event) is why we need explicit NIP-04 detection. Without it, `Helpers.hasHiddenContent()` returns `false` and the encrypted content is never decrypted, resulting in 0 private bookmarks despite having encrypted data.
|
||||||
|
|
||||||
|
## Current conclusion
|
||||||
|
|
||||||
|
- Client is configured and publishing requests correctly; encryption proves end‑to‑end path is alive.
|
||||||
|
- Non-blocking publish keeps operations fast (~1-2s for encrypt/decrypt).
|
||||||
|
- **Account queue is GLOBALLY DISABLED** - this was the primary cause of hangs/timeouts.
|
||||||
|
- Smart encryption detection (both NIP-04 and NIP-44) and no artificial timeouts make operations instant.
|
||||||
|
- Sequential processing is cleaner and more predictable than concurrent hacks.
|
||||||
|
- Relay queries now trust EOSE signals instead of arbitrary timeouts, completing in 1-2s instead of 6s.
|
||||||
|
- The missing DECRYPT activity in Amber was partially due to requests never being sent (stuck in queue). With queue disabled globally, Amber receives all decrypt requests immediately.
|
||||||
|
- **Amethyst-style bookmarks** require explicit NIP-04 detection (`?iv=` check) since `Helpers.hasHiddenContent()` only detects NIP-44.
|
||||||
|
|
||||||
|
|
||||||
1469
CHANGELOG.md
1469
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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
60
api/article-og.ts
Normal file
60
api/article-og.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { getArticleMeta, setArticleMeta } from './services/ogStore.js'
|
||||||
|
import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
|
||||||
|
import { generateHtml } from './services/ogHtml.js'
|
||||||
|
|
||||||
|
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
||||||
|
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
|
||||||
|
if (!naddr) {
|
||||||
|
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||||
|
if (debugEnabled) {
|
||||||
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Redis cache first
|
||||||
|
let meta = await getArticleMeta(naddr).catch((err) => {
|
||||||
|
console.error('Failed to get article meta from Redis:', err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
let cacheMaxAge = 86400
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
// Cache miss: fetch from relays (let it use its natural timeouts)
|
||||||
|
try {
|
||||||
|
meta = await fetchArticleMetadataViaRelays(naddr)
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
// Store in Redis and use it
|
||||||
|
await setArticleMeta(naddr, meta).catch((err) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate and send HTML
|
||||||
|
const html = generateHtml(naddr, meta)
|
||||||
|
setCacheHeaders(res, cacheMaxAge)
|
||||||
|
|
||||||
|
if (debugEnabled) {
|
||||||
|
// Debug mode enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]))
|
||||||
|
|||||||
13
index.html
13
index.html
@@ -9,23 +9,28 @@
|
|||||||
<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:site_name" content="Boris" />
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
|
||||||
<!-- 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" />
|
||||||
|
|
||||||
|
<!-- 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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
165
package-lock.json
generated
165
package-lock.json
generated
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.9",
|
"version": "0.10.33",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.9",
|
"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",
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
"applesauce-react": "^4.0.0",
|
"applesauce-react": "^4.0.0",
|
||||||
"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",
|
||||||
|
"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",
|
||||||
@@ -34,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",
|
||||||
@@ -53,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": {
|
||||||
@@ -99,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",
|
||||||
@@ -2259,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"
|
||||||
},
|
},
|
||||||
@@ -3550,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"
|
||||||
@@ -3592,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",
|
||||||
@@ -3634,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",
|
||||||
@@ -3796,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",
|
||||||
@@ -3924,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",
|
||||||
@@ -4009,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"
|
||||||
@@ -4085,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"
|
||||||
},
|
},
|
||||||
@@ -4500,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",
|
||||||
@@ -4628,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",
|
||||||
@@ -5864,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",
|
||||||
@@ -6086,6 +6132,15 @@
|
|||||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-average-color": {
|
||||||
|
"version": "9.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.5.0.tgz",
|
||||||
|
"integrity": "sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -6160,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",
|
||||||
@@ -6253,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",
|
||||||
@@ -6885,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",
|
||||||
@@ -9644,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",
|
||||||
@@ -9790,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"
|
||||||
},
|
},
|
||||||
@@ -9802,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"
|
||||||
@@ -10367,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"
|
||||||
}
|
}
|
||||||
@@ -11122,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",
|
||||||
@@ -11198,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"
|
||||||
},
|
},
|
||||||
@@ -11205,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",
|
||||||
@@ -11434,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"
|
||||||
@@ -11461,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",
|
||||||
@@ -11477,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",
|
||||||
@@ -11759,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",
|
||||||
@@ -12144,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",
|
||||||
@@ -12188,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"
|
||||||
},
|
},
|
||||||
@@ -12436,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",
|
||||||
|
|||||||
27
package.json
27
package.json
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.13",
|
"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",
|
||||||
@@ -25,6 +30,8 @@
|
|||||||
"applesauce-react": "^4.0.0",
|
"applesauce-react": "^4.0.0",
|
||||||
"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",
|
||||||
|
"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",
|
||||||
@@ -37,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",
|
||||||
@@ -94,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/boris-social-1200.png
Normal file
BIN
public/boris-social-1200.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 819 KiB |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Boris - Nostr Bookmarks",
|
"name": "Boris - Read, Highlight, Explore",
|
||||||
"short_name": "Boris",
|
"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",
|
||||||
|
|||||||
75
public/md/NIP-85.md
Normal file
75
public/md/NIP-85.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# NIP-85
|
||||||
|
|
||||||
|
## Reading Progress
|
||||||
|
|
||||||
|
`draft` `optional`
|
||||||
|
|
||||||
|
This NIP defines kind `39802`, a parameterized replaceable event for tracking reading progress across articles and web content.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
* [Format](#format)
|
||||||
|
* [Tags](#tags)
|
||||||
|
* [Content](#content)
|
||||||
|
* [Examples](#examples)
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Reading progress events use NIP-33 parameterized replaceable semantics. The `d` tag serves as the unique identifier per author and target content.
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
Events SHOULD tag the source of the reading progress, whether nostr-native or not. `a` tags should be used for nostr events and `r` tags for URLs.
|
||||||
|
|
||||||
|
When tagging a URL, clients generating these events SHOULD do a best effort of cleaning the URL from trackers or obvious non-useful information from the query string.
|
||||||
|
|
||||||
|
- `d` (required): Unique identifier for the target content
|
||||||
|
- For Nostr articles: `30023:<pubkey>:<identifier>` (matching the article's coordinate)
|
||||||
|
- For external URLs: `url:<base64url-encoded-url>`
|
||||||
|
- `a` (optional but recommended for Nostr articles): Article coordinate `30023:<pubkey>:<identifier>`
|
||||||
|
- `r` (optional but recommended for URLs): Raw URL of the external content
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
The content is a JSON object with the following fields:
|
||||||
|
|
||||||
|
- `progress` (required): Number between 0 and 1 representing reading progress (0 = not started, 1 = completed)
|
||||||
|
- `loc` (optional): Number representing a location marker (e.g., pixel scroll position, page number, etc.)
|
||||||
|
- `ts` (optional): Unix timestamp (seconds) when the progress was recorded
|
||||||
|
- `ver` (optional): Schema version string
|
||||||
|
|
||||||
|
The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics).
|
||||||
|
|
||||||
|
Clients SHOULD implement rate limiting to avoid excessive relay traffic (debounce writes, only save significant changes).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Nostr Article
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 39802,
|
||||||
|
"pubkey": "<user-pubkey>",
|
||||||
|
"created_at": 1734635012,
|
||||||
|
"content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}",
|
||||||
|
"tags": [
|
||||||
|
["d", "30023:<author-pubkey>:<article-identifier>"],
|
||||||
|
["a", "30023:<author-pubkey>:<article-identifier>"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### External URL
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 39802,
|
||||||
|
"pubkey": "<user-pubkey>",
|
||||||
|
"created_at": 1734635999,
|
||||||
|
"content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}",
|
||||||
|
"tags": [
|
||||||
|
["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"],
|
||||||
|
["r", "https://example.com/post"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
215
public/pwa.svg
Normal file
215
public/pwa.svg
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="649.67538"
|
||||||
|
height="568.22024"
|
||||||
|
viewBox="0 0 649.67538 568.22024"
|
||||||
|
role="img"
|
||||||
|
artist="Katerina Limpitsouni"
|
||||||
|
source="https://undraw.co/"
|
||||||
|
version="1.1"
|
||||||
|
id="svg31"
|
||||||
|
sodipodi:docname="pwa.svg"
|
||||||
|
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs31" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview31"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.6866359"
|
||||||
|
inkscape:cx="303.56285"
|
||||||
|
inkscape:cy="531.82789"
|
||||||
|
inkscape:window-width="3840"
|
||||||
|
inkscape:window-height="1027"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="svg31" />
|
||||||
|
<path
|
||||||
|
d="M397.23858,566.04035,390.539,618.81819l-9.85909-59.95407c-47.3817-18.18194-102.78179-21.713-102.78179-21.713s-12.22552,114.50728,28.139,162.38683,82.92182,40.60129,118.03379,11.00042c35.1114-29.60039,49.48123-70.31412,9.11675-118.19368C424.20327,581.68766,411.521,573.04476,397.23858,566.04035Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#f2f2f2"
|
||||||
|
id="path1" />
|
||||||
|
<path
|
||||||
|
d="M384.1004,626.79762l1.98958,2.36c22.98681,27.551,36.40476,52.8555,40.0327,75.5803.05864.33032.09573.65881.15431.98919l-1.53846.23773-1.48187.20991c-3.64942-24.76543-19.47993-50.77428-39.52347-74.8103-.63842-.781-1.28663-1.57364-1.95824-2.34655-8.57477-10.1-17.832-19.82437-27.217-28.9415-.72021-.712-1.46191-1.42587-2.20361-2.13968-12.44963-11.96994-25.01434-22.84351-36.237-32.03036-.7903-.653-1.59224-1.296-2.38439-1.92739-19.05943-15.4717-33.9044-25.802-37.21424-28.06849-.39875-.28343-.62465-.43273-.67573-.46958l.844-1.25121.00183-.02155.85568-1.26106c.05113.03692.81117.53546,2.18233,1.49814,5.15056,3.57268,18.987,13.39417,36.1433,27.27236.77059.62957,1.57259,1.27267,2.36284,1.92555,9.11521,7.44575,19.072,15.96086,29.1037,25.25221q3.78542,3.49455,7.37706,6.9724c.75332.704,1.495,1.41783,2.21523,2.12988Q372.14864,612.73905,384.1004,626.79762Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path2" />
|
||||||
|
<path
|
||||||
|
d="M315.8701,561.67759c-.6941.76509-1.39989,1.54-2.13716,2.30139a84.299,84.299,0,0,1-6.3038,5.89408,82.00518,82.00518,0,0,1-32.26683,16.72907c.03131,1.03285.06269,2.06578.09217,3.12018a85.04164,85.04164,0,0,0,34.14459-17.51256,87.22471,87.22471,0,0,0,6.71826-6.30338c.72551-.75156,1.43131-1.52651,2.11561-2.30323a84.3256,84.3256,0,0,0,13.87772-21.35332q-1.56615-.32858-3.06776-.65165A81.72351,81.72351,0,0,1,315.8701,561.67759Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path3" />
|
||||||
|
<path
|
||||||
|
d="M354.7137,595.82775q-1.15019,1.08949-2.35939,2.109c-.23552.21856-.49252.43522-.7379.64208a82.4401,82.4401,0,0,1-74.51659,16.59042c.1138,1.08323.22759,2.1666.36294,3.25167a85.5013,85.5013,0,0,0,76.12358-17.5054c.32717-.27581.65427-.55157.97158-.83909.80793-.70112,1.59437-1.40414,2.371-2.11878a85.04917,85.04917,0,0,0,24.39782-41.355c-.955-.37409-1.91-.74825-2.87668-1.11248A81.874,81.874,0,0,1,354.7137,595.82775Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path4" />
|
||||||
|
<path
|
||||||
|
d="M384.1004,626.79762c-.75869.75952-1.53717,1.49572-2.32545,2.22029-.84674.77374-1.70328,1.53585-2.57954,2.27457a82.66307,82.66307,0,0,1-98.92522,5.60818c.27211,1.38968.5343,2.76759.82973,4.13747a85.69022,85.69022,0,0,0,100.06542-7.409c.87626-.73872,1.74266-1.48914,2.56785-2.26471.80983-.72274,1.58831-1.45893,2.35679-2.20683a85.43958,85.43958,0,0,0,25.37276-57.38712c-.97424-.6577-1.97364-1.27419-2.98289-1.90237A82.39644,82.39644,0,0,1,384.1004,626.79762Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path5" />
|
||||||
|
<path
|
||||||
|
d="M648.03621,300.20693V215.13007a49.24034,49.24034,0,0,0-49.24-49.24019H418.54942a49.24029,49.24029,0,0,0-49.2406,49.24V271.632h-3.16709v19.90855h3.16709V312.7763h-3.16709v30.52644h3.16709V356.5751h-3.16709v30.52643h3.16709v294.7669a49.23993,49.23993,0,0,0,49.23995,49.24019H598.79561a49.24028,49.24028,0,0,0,49.2406-49.24V360.76613h3.10552v-60.5592Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#3f3d56"
|
||||||
|
id="path6" />
|
||||||
|
<path
|
||||||
|
d="M600.78268,178.70047H577.2545a17.47031,17.47031,0,0,1-16.17511,24.06836H457.81825a17.4703,17.4703,0,0,1-16.17512-24.06839H419.66775a36.772,36.772,0,0,0-36.772,36.772V681.526a36.772,36.772,0,0,0,36.772,36.77205h181.115a36.772,36.772,0,0,0,36.772-36.772h0V215.47244A36.772,36.772,0,0,0,600.78268,178.70047Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path7" />
|
||||||
|
<path
|
||||||
|
d="M605.33827,340.8917H415.11207a5.0058,5.0058,0,0,1-5-5V258.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V335.8917A5.00573,5.00573,0,0,1,605.33827,340.8917Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path8" />
|
||||||
|
<path
|
||||||
|
d="M587.22522,377.41807h-154a5.5,5.5,0,0,1,0-11h154a5.5,5.5,0,0,1,0,11Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path9" />
|
||||||
|
<path
|
||||||
|
d="M587.22523,405.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path10" />
|
||||||
|
<path
|
||||||
|
d="M587.22523,432.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path11" />
|
||||||
|
<path
|
||||||
|
d="M605.33827,571.8917H415.11207a5.0058,5.0058,0,0,1-5-5V489.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V566.8917A5.00573,5.00573,0,0,1,605.33827,571.8917Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path12" />
|
||||||
|
<path
|
||||||
|
d="M587.22523,608.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path13" />
|
||||||
|
<path
|
||||||
|
d="M587.22523,636.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path14" />
|
||||||
|
<path
|
||||||
|
d="M587.22523,663.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path15" />
|
||||||
|
<path
|
||||||
|
d="M760.06605,312.22721c-1.93457-14.18963-4.36084-29.42431-14.3689-39.66754a33.65518,33.65518,0,0,0-48.62622.5033c-7.28515,7.77185-10.50244,18.68475-10.79687,29.33325s2.07714,21.17865,4.708,31.50122a97.0913,97.0913,0,0,0,40.52124-7.97583,65.28916,65.28916,0,0,1,9.71558-3.81427c3.376-.85925,5.78247,1.303,8.92285,2.81073l1.72388-3.30078c1.41113,2.62616,5.78076,1.84772,7.36572-.67737C760.81605,318.41483,760.46888,315.18107,760.06605,312.22721Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="path16" />
|
||||||
|
<polygon
|
||||||
|
points="612.434 535.007 602.208 541.77 571.257 505.545 586.349 495.564 612.434 535.007"
|
||||||
|
fill="#9e616a"
|
||||||
|
id="polygon16" />
|
||||||
|
<path
|
||||||
|
d="M896.7595,709.08432,863.787,730.89015l-.27582-.417a15.38729,15.38729,0,0,1,4.34573-21.32122l.00081-.00054,20.13853-13.31819Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="path17" />
|
||||||
|
<polygon
|
||||||
|
points="480.429 553.116 468.169 553.116 462.337 505.828 480.431 505.829 480.429 553.116"
|
||||||
|
fill="#9e616a"
|
||||||
|
id="polygon17" />
|
||||||
|
<path
|
||||||
|
d="M758.71777,730.89015l-39.53076-.00146v-.5a15.3873,15.3873,0,0,1,15.38647-15.38623h.001l24.144.001Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="path18" />
|
||||||
|
<path
|
||||||
|
d="M668.3639,394.03709l-46.28906-33.06561a8.99743,8.99743,0,1,0-10.80762,7.74816c5.78613,4.85816,48.04785,46.88825,54.09888,44.67127,6.1416-2.25012,32.99341-6.32324,32.99341-6.32324l.74755-25.4953Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#9e616a"
|
||||||
|
id="path19" />
|
||||||
|
<path
|
||||||
|
d="M704.73272,454.19782l.437,58.1781s10.01741,86.201,13.712,100.76318,18.69148,81.94564,18.69148,81.94564l24.3788-3.93292-15.69975-88.09791,4.74535-73.017,27.36445,73.178L847.847,675.848l17.61024-14.2095-60.48051-88.88116-18.47283-72.811s2.29785-37.66031-18.40081-52.16322Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="path20" />
|
||||||
|
<circle
|
||||||
|
cx="443.5739"
|
||||||
|
cy="133.65539"
|
||||||
|
r="26.72083"
|
||||||
|
fill="#9e616a"
|
||||||
|
id="circle20" />
|
||||||
|
<rect
|
||||||
|
x="722.98731"
|
||||||
|
y="465.33587"
|
||||||
|
width="24.29166"
|
||||||
|
height="31.57916"
|
||||||
|
transform="translate(-279.66359 789.41207) rotate(-65.86746)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="rect20" />
|
||||||
|
<path
|
||||||
|
d="M593.23271,362.65743"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path21" />
|
||||||
|
<path
|
||||||
|
d="M761.53382,350.95884c-3.14892-6.267-4.67895-14.009-11.39209-16.04077-4.5332-1.372-22.86841.68408-27,3-6.87231,3.85236-.64453,11.07111-4.699,17.82642q-6.61121,11.01552-13.22241,22.031c-3.03,5.04852-6.0918,10.16889-7.73023,15.82434-1.63818,5.65546-1.717,12.00305,1.074,17.18756,2.4978,4.64045,7.02294,7.93158,9.53515,12.56433,2.61231,4.81806-2.07715,26.33136-4.50854,31.24341l1.167.539a263.08934,263.08934,0,0,0,48.448-1.63024c3.9873-.50489,8.12744-1.16449,11.41308-3.47895,4.83985-3.40918,6.75318-9.5954,7.949-15.39337A129.67713,129.67713,0,0,0,761.53382,350.95884Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path22" />
|
||||||
|
<path
|
||||||
|
d="M706.84845,411.65133c7.23924-7.1146,14.51542-14.27181,20.47486-22.48827s10.5936-17.62115,11.88744-27.68835a20.50914,20.50914,0,0,0-.64136-9.62007c-1.11054-3.049-3.56912-5.755-6.73861-6.45068-5.07194-1.11355-9.6829,2.93435-13.30226,6.6577q-16.00732,16.46812-32.01478,32.936,10.19649,13.42191,20.393,26.84353Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path23" />
|
||||||
|
<path
|
||||||
|
d="M785.75257,417.13127c-2.25-6.14148-6.32324-32.99323-6.32324-32.99323l-25.49512-.74756,12.4646,30.7431-34.01367,47.61615s.063.10462.17749.2912a8.99538,8.99538,0,1,0,7.54468,9.55927.62106.62106,0,0,0,.77978-.13385C744.67176,466.7169,788.00257,423.27281,785.75257,417.13127Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#9e616a"
|
||||||
|
id="path24" />
|
||||||
|
<path
|
||||||
|
d="M788.34461,400.17338c-2.34008-9.87665-4.69751-19.807-8.64282-29.15894s-9.59326-18.18512-17.53711-24.50317a20.50909,20.50909,0,0,0-8.563-4.43085c-3.18359-.62805-6.77148.07483-9.00732,2.42658-3.57813,3.76318-2.50147,9.80365-1.18921,14.8277q5.80444,22.2203,11.60864,44.44061,16.76184-1.77667,33.52344-3.55347Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path25" />
|
||||||
|
<path
|
||||||
|
d="M752.14124,301.6237c-.83545-6.464-1.708-12.98224-3.67065-19.06879-1.96265-6.08661-5.12622-11.78747-9.66431-15.23547-7.1853-5.459-16.488-4.40613-24.54394-1.266-6.23,2.42846-12.31153,6.1195-16.70484,12.05346-4.39355,5.934-6.86108,14.40119-5.2268,22.1601q12.88989-3.58722,25.77954-7.1745l-.94068.783c5.57642,3.14221,9.81153,9.64361,11.07691,17.00482a28.7171,28.7171,0,0,1-4.53662,21.03778q8.79089-3.67337,17.58178-7.34662c3.61744-1.51153,7.489-3.25317,9.634-7.13025C753.41273,312.94608,752.83485,306.98814,752.14124,301.6237Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="path26" />
|
||||||
|
<path
|
||||||
|
d="M625.98113,343.51431,608.792,369.31226a4.46863,4.46863,0,0,1-3.75549,2.00125,4.47943,4.47943,0,0,1-4.13509-2.75491,4.12763,4.12763,0,0,1-.2689-.85745,4.51165,4.51165,0,0,1,.66976-3.37929l17.18913-25.79794a4.5,4.5,0,1,1,7.48973,4.99039Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path27" />
|
||||||
|
<path
|
||||||
|
d="M610.17821,367.23178l-3.47923,5.19091-6.15652,5.42689a2.45095,2.45095,0,0,1-3.94221-2.627l2.69471-7.8881,3.39353-5.09311Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#3f3d56"
|
||||||
|
id="path28" />
|
||||||
|
<path
|
||||||
|
d="M626.74053,329.98545l-8.6142,7.59289a2.45233,2.45233,0,0,0,.26168,3.88081l1.62984,1.086-4.71315,7.07363a1,1,0,0,0,1.66439,1.109l4.71314-7.07362,1.62985,1.086a2.45552,2.45552,0,0,0,3.39872-.675,2.46816,2.46816,0,0,0,.28357-.57793l3.69013-10.8738a2.45251,2.45251,0,0,0-3.944-2.62786Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#3f3d56"
|
||||||
|
id="path29" />
|
||||||
|
<path
|
||||||
|
d="M516.97522,187.41807h-27a2,2,0,0,1,0-4h27a2,2,0,0,1,0,4Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path31" />
|
||||||
|
<circle
|
||||||
|
cx="255.31291"
|
||||||
|
cy="19.52819"
|
||||||
|
r="2"
|
||||||
|
fill="#fff"
|
||||||
|
id="circle31" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
235
public/zaps.svg
Normal file
235
public/zaps.svg
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="720.44"
|
||||||
|
height="718.635"
|
||||||
|
viewBox="0 0 720.44 718.635"
|
||||||
|
role="img"
|
||||||
|
artist="Katerina Limpitsouni"
|
||||||
|
source="https://undraw.co/"
|
||||||
|
version="1.1"
|
||||||
|
id="svg30"
|
||||||
|
sodipodi:docname="zaps.svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs30" /><sodipodi:namedview
|
||||||
|
id="namedview30"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="2.6746164"
|
||||||
|
inkscape:cx="38.510195"
|
||||||
|
inkscape:cy="485.67712"
|
||||||
|
inkscape:window-width="3840"
|
||||||
|
inkscape:window-height="1027"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="g27" /><g
|
||||||
|
transform="translate(-600 -181)"
|
||||||
|
id="g30"><g
|
||||||
|
transform="translate(783.85 181)"
|
||||||
|
id="g2"><path
|
||||||
|
d="M624.7,249.968h-3.952V141.8a62.6,62.6,0,0,0-62.6-62.6H328.97a62.6,62.6,0,0,0-62.6,62.6V735.225a62.6,62.6,0,0,0,62.6,62.6H558.143a62.6,62.6,0,0,0,62.6-62.6V326.965H624.7Z"
|
||||||
|
transform="translate(-266.365 -79.193)"
|
||||||
|
fill="#090814"
|
||||||
|
id="path1" /><path
|
||||||
|
d="M560.888,95.686H530.974a22.212,22.212,0,0,1-20.565,30.6h-131.3a22.212,22.212,0,0,1-20.566-30.6H330.607a46.752,46.752,0,0,0-46.752,46.752V735a46.752,46.752,0,0,0,46.752,46.752H560.879A46.752,46.752,0,0,0,607.63,735V142.439a46.752,46.752,0,0,0-46.744-46.752Z"
|
||||||
|
transform="translate(-266.577 -79.397)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path2" /></g><path
|
||||||
|
d="M8,0H256a8,8,0,0,1,8,8V72a8,8,0,0,1-8,8H8a8,8,0,0,1-8-8V8A8,8,0,0,1,8,0Z"
|
||||||
|
transform="translate(828 265)"
|
||||||
|
fill="#f2f2f2"
|
||||||
|
id="path3" /><path
|
||||||
|
d="M8,0H256a8.065,8.065,0,0,1,8,8.128V475.474a8.065,8.065,0,0,1-8,8.128H8a8.065,8.065,0,0,1-8-8.128V8.128A8.065,8.065,0,0,1,8,0Z"
|
||||||
|
transform="translate(828 358.398)"
|
||||||
|
fill="#f2f2f2"
|
||||||
|
id="path4" /><g
|
||||||
|
transform="translate(623.104 296.398)"
|
||||||
|
id="g9"><rect
|
||||||
|
width="278.304"
|
||||||
|
height="69.313"
|
||||||
|
rx="16"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#090814"
|
||||||
|
id="rect4" /><rect
|
||||||
|
width="272.003"
|
||||||
|
height="63.012"
|
||||||
|
rx="15"
|
||||||
|
transform="translate(3.151 3.151)"
|
||||||
|
fill="#fff"
|
||||||
|
id="rect5" /><path
|
||||||
|
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||||
|
transform="translate(-53.047 -325.676)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path5" /><g
|
||||||
|
transform="translate(17.038 13.546)"
|
||||||
|
id="g7"><path
|
||||||
|
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path6" /><path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.7844991 20.830193,9.2808662 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||||
|
id="path2-8-2"
|
||||||
|
style="stroke-width:0.575581" /></g><path
|
||||||
|
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(72.886 17.036)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path8" /><path
|
||||||
|
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(72.886 37.98)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path9" /></g><g
|
||||||
|
transform="translate(1003.278 402.469)"
|
||||||
|
id="g14"><rect
|
||||||
|
width="279.354"
|
||||||
|
height="69.313"
|
||||||
|
rx="16"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#090814"
|
||||||
|
id="rect9" /><rect
|
||||||
|
width="272.003"
|
||||||
|
height="63.012"
|
||||||
|
rx="15"
|
||||||
|
transform="translate(3.151 3.151)"
|
||||||
|
fill="#fff"
|
||||||
|
id="rect10" /><path
|
||||||
|
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||||
|
transform="translate(-52.751 -325.287)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path10" /><g
|
||||||
|
transform="translate(17.334 13.936)"
|
||||||
|
id="g12"><path
|
||||||
|
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path11" /><path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||||
|
id="path2-8-2-7"
|
||||||
|
style="stroke-width:0.575581" /></g><path
|
||||||
|
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.181 17.426)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path13" /><path
|
||||||
|
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.181 38.369)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path14" /></g><g
|
||||||
|
transform="translate(663.012 510.639)"
|
||||||
|
id="g19"><rect
|
||||||
|
width="279.354"
|
||||||
|
height="69.313"
|
||||||
|
rx="16"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#090814"
|
||||||
|
id="rect14" /><rect
|
||||||
|
width="272.003"
|
||||||
|
height="63.012"
|
||||||
|
rx="15"
|
||||||
|
transform="translate(3.151 3.151)"
|
||||||
|
fill="#fff"
|
||||||
|
id="rect15" /><path
|
||||||
|
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||||
|
transform="translate(-52.814 -325.25)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path15" /><g
|
||||||
|
transform="translate(17.272 13.972)"
|
||||||
|
id="g17"><path
|
||||||
|
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path16" /><path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||||
|
id="path2-8-2-0"
|
||||||
|
style="stroke-width:0.575581" /></g><path
|
||||||
|
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.119 17.463)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path18" /><path
|
||||||
|
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.119 38.406)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path19" /></g><g
|
||||||
|
transform="translate(1041.086 616.711)"
|
||||||
|
id="g24"><rect
|
||||||
|
width="279.354"
|
||||||
|
height="70.364"
|
||||||
|
rx="16"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#090814"
|
||||||
|
id="rect19" /><rect
|
||||||
|
width="272.003"
|
||||||
|
height="63.012"
|
||||||
|
rx="15"
|
||||||
|
transform="translate(4.201 4.201)"
|
||||||
|
fill="#fff"
|
||||||
|
id="rect20" /><path
|
||||||
|
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||||
|
transform="translate(-52.163 -324.86)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path20" /><g
|
||||||
|
transform="translate(17.922 14.362)"
|
||||||
|
id="g22"><path
|
||||||
|
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path21" /><path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||||
|
id="path2-8-2-9"
|
||||||
|
style="stroke-width:0.575581" /></g><path
|
||||||
|
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.77 17.853)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path23" /><path
|
||||||
|
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.77 38.796)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path24" /></g><g
|
||||||
|
transform="translate(600 723.832)"
|
||||||
|
id="g29"><rect
|
||||||
|
width="279.354"
|
||||||
|
height="69.313"
|
||||||
|
rx="16"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#090814"
|
||||||
|
id="rect24" /><rect
|
||||||
|
width="273.053"
|
||||||
|
height="63.012"
|
||||||
|
rx="15"
|
||||||
|
transform="translate(3.151 3.151)"
|
||||||
|
fill="#fff"
|
||||||
|
id="rect25" /><path
|
||||||
|
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||||
|
transform="translate(-52.631 -325.518)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path25" /><g
|
||||||
|
transform="translate(17.454 13.704)"
|
||||||
|
id="g27"><path
|
||||||
|
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path26" /><path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||||
|
id="path2-8-2-98"
|
||||||
|
style="stroke-width:0.575581" /></g><path
|
||||||
|
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.301 17.194)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path28" /><path
|
||||||
|
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.301 38.138)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path29" /></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 17 KiB |
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
|
||||||
|
|
||||||
526
src/App.tsx
526
src/App.tsx
@@ -1,19 +1,37 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||||
import { EventStore } from 'applesauce-core'
|
import { EventStore } from 'applesauce-core'
|
||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import type { NostrEvent } from 'nostr-tools'
|
||||||
|
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
|
import Debug from './components/Debug'
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
|
import RouteDebug from './components/RouteDebug'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
|
import ShareTargetHandler from './components/ShareTargetHandler'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
|
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
|
||||||
|
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS, HARDCODED_RELAYS } from './services/relayManager'
|
||||||
|
import { Bookmark } from './types/bookmarks'
|
||||||
|
import { bookmarkController } from './services/bookmarkController'
|
||||||
|
import { contactsController } from './services/contactsController'
|
||||||
|
import { highlightsController } from './services/highlightsController'
|
||||||
|
import { writingsController } from './services/writingsController'
|
||||||
|
import { readingProgressController } from './services/readingProgressController'
|
||||||
|
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
||||||
|
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
||||||
|
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
||||||
|
import { archiveController } from './services/archiveController'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||||
@@ -21,26 +39,140 @@ const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
|||||||
// AppRoutes component that has access to hooks
|
// AppRoutes component that has access to hooks
|
||||||
function AppRoutes({
|
function AppRoutes({
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
showToast
|
showToast
|
||||||
}: {
|
}: {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
|
eventStore: EventStore | null
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
}) {
|
}) {
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
|
||||||
|
// Centralized bookmark state (fed by controller)
|
||||||
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
|
const [bookmarksLoading, setBookmarksLoading] = useState(false)
|
||||||
|
|
||||||
|
// Centralized contacts state (fed by controller)
|
||||||
|
const [contacts, setContacts] = useState<Set<string>>(new Set())
|
||||||
|
const [contactsLoading, setContactsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Subscribe to bookmark controller
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
|
||||||
|
setBookmarks(bookmarks)
|
||||||
|
})
|
||||||
|
const unsubLoading = bookmarkController.onLoading((loading) => {
|
||||||
|
setBookmarksLoading(loading)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubBookmarks()
|
||||||
|
unsubLoading()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to contacts controller
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubContacts = contactsController.onContacts((contacts) => {
|
||||||
|
setContacts(contacts)
|
||||||
|
})
|
||||||
|
const unsubLoading = contactsController.onLoading((loading) => {
|
||||||
|
setContactsLoading(loading)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubContacts()
|
||||||
|
unsubLoading()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
// Auto-load bookmarks, contacts, and highlights when account is ready (on login or page mount)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeAccount && relayPool) {
|
||||||
|
const pubkey = (activeAccount as { pubkey?: string }).pubkey
|
||||||
|
|
||||||
|
// Load bookmarks
|
||||||
|
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||||
|
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load contacts
|
||||||
|
if (pubkey && contacts.size === 0 && !contactsLoading) {
|
||||||
|
contactsController.start({ relayPool, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load highlights (controller manages its own state)
|
||||||
|
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
|
||||||
|
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load writings (controller manages its own state)
|
||||||
|
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
|
||||||
|
writingsController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reading progress (controller manages its own state)
|
||||||
|
if (pubkey && eventStore && !readingProgressController.isLoadedFor(pubkey)) {
|
||||||
|
readingProgressController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load archive (marked-as-read) controller
|
||||||
|
if (pubkey && eventStore && !archiveController.isLoadedFor(pubkey)) {
|
||||||
|
archiveController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start centralized nostrverse highlights controller (non-blocking)
|
||||||
|
if (eventStore) {
|
||||||
|
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||||
|
nostrverseWritingsController.start({ relayPool, eventStore })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
|
||||||
|
|
||||||
|
// Ensure nostrverse controllers run even when logged out
|
||||||
|
useEffect(() => {
|
||||||
|
if (relayPool && eventStore) {
|
||||||
|
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||||
|
nostrverseWritingsController.start({ relayPool, eventStore })
|
||||||
|
}
|
||||||
|
}, [relayPool, eventStore])
|
||||||
|
|
||||||
|
// Manual refresh (for sidebar button)
|
||||||
|
const handleRefreshBookmarks = useCallback(async () => {
|
||||||
|
if (!relayPool || !activeAccount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bookmarkController.reset()
|
||||||
|
await bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||||
|
}, [relayPool, activeAccount, accountManager])
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
accountManager.clearActive()
|
accountManager.clearActive()
|
||||||
|
bookmarkController.reset() // Clear bookmarks via controller
|
||||||
|
contactsController.reset() // Clear contacts via controller
|
||||||
|
highlightsController.reset() // Clear highlights via controller
|
||||||
|
readingProgressController.reset() // Clear reading progress via controller
|
||||||
|
archiveController.reset() // Clear archive state
|
||||||
showToast('Logged out successfully')
|
showToast('Logged out successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/share-target"
|
||||||
|
element={<ShareTargetHandler relayPool={relayPool} />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/a/:naddr"
|
path="/a/:naddr"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -50,6 +182,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -59,6 +194,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -68,6 +206,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -77,6 +218,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -86,46 +230,97 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<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}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/reading-list"
|
path="/my/bookmarks"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/archive"
|
path="/my/reads"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/writings"
|
path="/my/reads/:filter"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/my/links"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/my/links/:filter"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/my/writings"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -135,6 +330,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -144,6 +342,34 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/e/:eventId"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/debug"
|
||||||
|
element={
|
||||||
|
<Debug
|
||||||
|
relayPool={relayPool}
|
||||||
|
eventStore={eventStore}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -165,20 +391,45 @@ function App() {
|
|||||||
const store = new EventStore()
|
const store = new EventStore()
|
||||||
const accounts = new AccountManager()
|
const accounts = new AccountManager()
|
||||||
|
|
||||||
|
// Disable request queueing globally - makes all operations instant
|
||||||
|
// Queue causes requests to wait for user interaction which blocks batch operations
|
||||||
|
accounts.disableQueue = true
|
||||||
|
|
||||||
// Register common account types (needed for deserialization)
|
// Register common account types (needed for deserialization)
|
||||||
registerCommonAccountTypes(accounts)
|
registerCommonAccountTypes(accounts)
|
||||||
|
|
||||||
|
// Create relay pool and set it up BEFORE loading accounts
|
||||||
|
// NostrConnectAccount.fromJSON needs this to restore the signer
|
||||||
|
const pool = new RelayPool()
|
||||||
|
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
||||||
|
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||||
|
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||||
|
NostrConnectSigner.publishMethod = (relays: string[], event: NostrEvent) => {
|
||||||
|
// Fire-and-forget publish; do not block callers
|
||||||
|
pool.publish(relays, event).catch(() => { /* ignore errors */ })
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a relay group for better event deduplication and management
|
||||||
|
pool.group(RELAYS)
|
||||||
|
|
||||||
// Load persisted accounts from localStorage
|
// Load persisted accounts from localStorage
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
const accountsJson = localStorage.getItem('accounts')
|
||||||
|
|
||||||
|
const json = JSON.parse(accountsJson || '[]')
|
||||||
|
|
||||||
await accounts.fromJSON(json)
|
await accounts.fromJSON(json)
|
||||||
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
|
|
||||||
|
|
||||||
// Load active account from storage
|
// Load active account from storage
|
||||||
const activeId = localStorage.getItem('active')
|
const activeId = localStorage.getItem('active')
|
||||||
if (activeId && accounts.getAccount(activeId)) {
|
|
||||||
accounts.setActive(activeId)
|
if (activeId) {
|
||||||
console.log('Restored active account:', activeId)
|
const account = accounts.getAccount(activeId)
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
accounts.setActive(activeId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load accounts from storage:', err)
|
console.error('Failed to load accounts from storage:', err)
|
||||||
@@ -198,21 +449,237 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const pool = new RelayPool()
|
// Reconnect bunker signers when active account changes
|
||||||
|
// Keep track of which accounts we've already reconnected to avoid double-connecting
|
||||||
|
const reconnectedAccounts = new Set<string>()
|
||||||
|
|
||||||
// Create a relay group for better event deduplication and management
|
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
|
||||||
pool.group(RELAYS)
|
|
||||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
if (account && account.type === 'nostr-connect') {
|
||||||
console.log('Relay URLs:', RELAYS)
|
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||||
|
// Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops
|
||||||
|
try {
|
||||||
|
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
|
||||||
|
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore queue disable errors
|
||||||
|
}
|
||||||
|
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
|
||||||
|
|
||||||
|
// Skip if we've already reconnected this account
|
||||||
|
if (reconnectedAccounts.has(account.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For restored signers, ensure they have the pool's subscription methods
|
||||||
|
// The signer was created in fromJSON without pool context, so we need to recreate it
|
||||||
|
const signerData = nostrConnectAccount.toJSON().signer
|
||||||
|
|
||||||
|
// Add bunker's relays to the pool BEFORE recreating the signer
|
||||||
|
// This ensures the pool has all relays when the signer sets up its methods
|
||||||
|
const bunkerRelays = signerData.relays || []
|
||||||
|
const existingRelayUrls = new Set(Array.from(pool.relays.keys()))
|
||||||
|
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
|
||||||
|
|
||||||
|
if (newBunkerRelays.length > 0) {
|
||||||
|
pool.group(newBunkerRelays)
|
||||||
|
} else {
|
||||||
|
// Bunker relays already in pool
|
||||||
|
}
|
||||||
|
|
||||||
|
const recreatedSigner = new NostrConnectSigner({
|
||||||
|
relays: signerData.relays,
|
||||||
|
pubkey: nostrConnectAccount.pubkey,
|
||||||
|
remote: signerData.remote,
|
||||||
|
signer: nostrConnectAccount.signer.signer, // Use the existing SimpleSigner
|
||||||
|
pool: pool
|
||||||
|
})
|
||||||
|
// Ensure local relays are included for NIP-46 request/response traffic (e.g., Amber bunker)
|
||||||
|
try {
|
||||||
|
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||||
|
recreatedSigner.relays = mergedRelays
|
||||||
|
} catch (err) { /* ignore */ }
|
||||||
|
|
||||||
|
// Replace the signer on the account
|
||||||
|
nostrConnectAccount.signer = recreatedSigner
|
||||||
|
|
||||||
|
// Fire-and-forget publish for bunker: trigger but don't wait for completion
|
||||||
|
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
||||||
|
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
||||||
|
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
|
||||||
|
const result = originalPublish(relays, event)
|
||||||
|
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
||||||
|
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return {} as unknown as never
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Just ensure the signer is listening for responses - don't call connect() again
|
||||||
|
// The fromBunkerURI already connected with permissions during login
|
||||||
|
if (!nostrConnectAccount.signer.listening) {
|
||||||
|
await nostrConnectAccount.signer.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
||||||
|
try {
|
||||||
|
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
await nostrConnectAccount.signer.connect(undefined, permissions)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore reconnect errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the subscription a moment to fully establish before allowing decrypt operations
|
||||||
|
// This ensures the signer is ready to handle and receive responses
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
// Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt
|
||||||
|
try {
|
||||||
|
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
|
||||||
|
return await Promise.race([
|
||||||
|
p,
|
||||||
|
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`probe timeout after ${ms}ms`)), ms)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
setTimeout(async () => {
|
||||||
|
const self = nostrConnectAccount.pubkey
|
||||||
|
// Try a roundtrip so the bunker can respond successfully
|
||||||
|
try {
|
||||||
|
await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
|
||||||
|
await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, ''))
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore probe errors
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
|
||||||
|
await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, ''))
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore probe errors
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore signer setup errors
|
||||||
|
}
|
||||||
|
// The bunker remembers the permissions from the initial connection
|
||||||
|
nostrConnectAccount.signer.isConnected = true
|
||||||
|
|
||||||
|
|
||||||
|
// Mark this account as reconnected
|
||||||
|
reconnectedAccounts.add(account.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open signer:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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: () => {}
|
||||||
})
|
})
|
||||||
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
|
||||||
|
|
||||||
// Store subscription for cleanup
|
// Store subscription for cleanup
|
||||||
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
||||||
@@ -233,6 +700,8 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
accountsSub.unsubscribe()
|
accountsSub.unsubscribe()
|
||||||
activeSub.unsubscribe()
|
activeSub.unsubscribe()
|
||||||
|
bunkerReconnectSub.unsubscribe()
|
||||||
|
userRelaysSub.unsubscribe()
|
||||||
// Clean up keep-alive subscription if it exists
|
// Clean up keep-alive subscription if it exists
|
||||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
if (poolWithSub._keepAliveSubscription) {
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
@@ -249,7 +718,7 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (cleanup) cleanup()
|
if (cleanup) cleanup()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isOnline, showToast])
|
||||||
|
|
||||||
// Monitor online/offline status
|
// Monitor online/offline status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -270,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">
|
||||||
@@ -284,7 +763,8 @@ function App() {
|
|||||||
<AccountsProvider manager={accountManager}>
|
<AccountsProvider manager={accountManager}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
<AppRoutes relayPool={relayPool} eventStore={eventStore} showToast={showToast} />
|
||||||
|
<RouteDebug />
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
47
src/components/ArchiveFilters.tsx
Normal file
47
src/components/ArchiveFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
|
||||||
|
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||||
|
|
||||||
|
interface ArchiveFiltersProps {
|
||||||
|
selectedFilter: ArchiveFilterType
|
||||||
|
onFilterChange: (filter: ArchiveFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||||
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
|
{ type: 'marked' as const, icon: faBooks, label: 'Archived' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => {
|
||||||
|
const isActive = selectedFilter === filter.type
|
||||||
|
// Only "completed" gets green color, everything else uses default blue
|
||||||
|
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArchiveFilters
|
||||||
|
|
||||||
@@ -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,26 +6,70 @@ 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)
|
||||||
|
hideBotByName?: boolean // default true
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
const 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(), {
|
||||||
addSuffix: true
|
addSuffix: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Calculate progress percentage and determine color (matching readingProgressUtils.ts logic)
|
||||||
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug log - reading progress shown as visual indicator
|
||||||
|
if (readingProgress !== undefined) {
|
||||||
|
// Reading progress display
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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' }}
|
||||||
>
|
>
|
||||||
@@ -47,7 +91,37 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
|||||||
{post.summary && (
|
{post.summary && (
|
||||||
<p className="blog-post-card-summary">{post.summary}</p>
|
<p className="blog-post-card-summary">{post.summary}</p>
|
||||||
)}
|
)}
|
||||||
<div className="blog-post-card-meta">
|
|
||||||
|
{/* Reading progress indicator - replaces the dividing line */}
|
||||||
|
{readingProgress !== undefined && readingProgress > 0 ? (
|
||||||
|
<div
|
||||||
|
className="blog-post-reading-progress"
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
height: '1px',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
marginTop: '1rem'
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="blog-post-card-meta" style={{ borderTop: 'none', paddingTop: '0.75rem' }}>
|
||||||
<span className="blog-post-card-author">
|
<span className="blog-post-card-author">
|
||||||
<FontAwesomeIcon icon={faUser} />
|
<FontAwesomeIcon icon={faUser} />
|
||||||
{displayName}
|
{displayName}
|
||||||
|
|||||||
44
src/components/BookmarkFilters.tsx
Normal file
44
src/components/BookmarkFilters.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faNewspaper, faStickyNote, faCirclePlay } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
import { faGlobe, faAsterisk, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
export type BookmarkFilterType = 'all' | 'article' | 'external' | 'video' | 'note' | 'web'
|
||||||
|
|
||||||
|
interface BookmarkFiltersProps {
|
||||||
|
selectedFilter: BookmarkFilterType
|
||||||
|
onFilterChange: (filter: BookmarkFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookmarkFilters: React.FC<BookmarkFiltersProps> = ({
|
||||||
|
selectedFilter,
|
||||||
|
onFilterChange
|
||||||
|
}) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'article' as const, icon: faNewspaper, label: 'Articles' },
|
||||||
|
{ type: 'external' as const, icon: faLink, label: 'External Articles' },
|
||||||
|
{ type: 'video' as const, icon: faCirclePlay, label: 'Videos' },
|
||||||
|
{ type: 'note' as const, icon: faStickyNote, label: 'Notes' },
|
||||||
|
{ type: 'web' as const, icon: faGlobe, label: 'Web' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BookmarkFilters
|
||||||
|
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { 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'
|
||||||
@@ -17,9 +21,11 @@ interface BookmarkItemProps {
|
|||||||
index: number
|
index: number
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
viewMode?: ViewMode
|
viewMode?: ViewMode
|
||||||
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
|
||||||
|
const 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)}`
|
||||||
@@ -37,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
|
||||||
|
|
||||||
@@ -55,39 +62,69 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// use helper from kindIcon.ts
|
// Get content type icon based on bookmark kind and URL classification
|
||||||
|
const getContentTypeIcon = (): IconDefinition => {
|
||||||
|
if (isArticle) return faNewspaper // Nostr-native article
|
||||||
|
|
||||||
|
// For web bookmarks, classify the URL to determine icon
|
||||||
|
if (isWebBookmark && firstUrlClassification) {
|
||||||
|
switch (firstUrlClassification.type) {
|
||||||
|
case 'youtube':
|
||||||
|
case 'video':
|
||||||
|
return faCirclePlay
|
||||||
|
case 'image':
|
||||||
|
return faCamera
|
||||||
|
case 'article':
|
||||||
|
return faLink // External article
|
||||||
|
default:
|
||||||
|
return faGlobe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUrls) return faStickyNote // Just a text note
|
||||||
|
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
|
||||||
|
if (firstUrlClassification?.type === 'article') return faLink // External article
|
||||||
|
return faFileLines
|
||||||
|
}
|
||||||
|
|
||||||
const getIconForUrlType = (url: string) => {
|
const getIconForUrlType = (url: string) => {
|
||||||
const classification = classifyUrl(url)
|
const classification = classifyUrl(url)
|
||||||
switch (classification.type) {
|
switch (classification.type) {
|
||||||
case 'youtube':
|
case 'youtube':
|
||||||
case 'video':
|
case 'video':
|
||||||
return faPlay
|
return faCirclePlay
|
||||||
case 'image':
|
case 'image':
|
||||||
return faEye
|
return faCamera
|
||||||
default:
|
default:
|
||||||
return faBookOpen
|
return faFileLines
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -109,15 +146,26 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary
|
articleSummary,
|
||||||
|
contentTypeIcon: getContentTypeIcon(),
|
||||||
|
readingProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'compact') {
|
if (viewMode === 'compact') {
|
||||||
return <CompactView {...sharedProps} />
|
const compactProps = {
|
||||||
|
bookmark,
|
||||||
|
index,
|
||||||
|
hasUrls,
|
||||||
|
extractedUrls,
|
||||||
|
onSelectUrl,
|
||||||
|
articleTitle,
|
||||||
|
contentTypeIcon: getContentTypeIcon(),
|
||||||
|
readingProgress
|
||||||
|
}
|
||||||
|
return <CompactView {...compactProps} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'large') {
|
if (viewMode === 'large') {
|
||||||
@@ -125,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} getIconForUrlType={getIconForUrlType} articleImage={articleImage} />
|
return <CardView {...sharedProps} articleImage={articleImage} articleTitle={articleTitle} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import React, { useRef } from 'react'
|
import React, { useRef, useState, useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } 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'
|
||||||
import SidebarHeader from './SidebarHeader'
|
import SidebarHeader from './SidebarHeader'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
|
import CompactButton from './CompactButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
|
||||||
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, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import AddBookmarkModal from './AddBookmarkModal'
|
||||||
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import LoginOptions from './LoginOptions'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -29,6 +43,7 @@ interface BookmarkListProps {
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
isMobile?: boolean
|
isMobile?: boolean
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||||
@@ -43,12 +58,97 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
lastFetchTime,
|
|
||||||
loading = false,
|
loading = false,
|
||||||
relayPool,
|
relayPool,
|
||||||
isMobile = false
|
isMobile = false,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||||
|
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||||
|
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||||
|
const saved = localStorage.getItem('bookmarkGroupingMode')
|
||||||
|
return saved === 'grouped' ? 'grouped' : 'flat'
|
||||||
|
})
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
|
// Subscribe to reading progress updates
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial progress map
|
||||||
|
setReadingProgressMap(readingProgressController.getProgressMap())
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubProgress()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Helper to get reading progress for a bookmark
|
||||||
|
const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => {
|
||||||
|
if (bookmark.kind === 30023) {
|
||||||
|
// For articles, use naddr as key
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return readingProgressMap.get(naddr)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
||||||
|
if (bookmark.kind === 39701) {
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
// Ensure URL has protocol
|
||||||
|
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
|
||||||
|
return readingProgressMap.get(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other bookmark types, try to extract URL from content
|
||||||
|
const urls = extractUrlsFromContent(bookmark.content)
|
||||||
|
if (urls.length > 0) {
|
||||||
|
return readingProgressMap.get(urls[0])
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleGroupingMode = () => {
|
||||||
|
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
||||||
|
setGroupingMode(newMode)
|
||||||
|
localStorage.setItem('bookmarkGroupingMode', newMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFilterTitle = (filter: BookmarkFilterType): string => {
|
||||||
|
const titles: Record<BookmarkFilterType, string> = {
|
||||||
|
'all': 'All Bookmarks',
|
||||||
|
'article': 'Bookmarked Reads',
|
||||||
|
'external': 'Bookmarked Links',
|
||||||
|
'video': 'Bookmarked Videos',
|
||||||
|
'note': 'Bookmarked Notes',
|
||||||
|
'web': 'Web Bookmarks'
|
||||||
|
}
|
||||||
|
return titles[filter]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
|
if (!activeAccount || !relayPool) {
|
||||||
|
throw new Error('Please login to create bookmarks')
|
||||||
|
}
|
||||||
|
|
||||||
|
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
|
||||||
|
}
|
||||||
|
|
||||||
// Pull-to-refresh for bookmarks
|
// Pull-to-refresh for bookmarks
|
||||||
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
|
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
|
||||||
@@ -62,36 +162,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
isDisabled: !onRefresh
|
isDisabled: !onRefresh
|
||||||
})
|
})
|
||||||
|
|
||||||
// Helper to check if a bookmark has either content or a URL
|
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
|
||||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
const sections = useMemo(() => {
|
||||||
// Check if has content (text)
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
const hasContent = ib.content && ib.content.trim().length > 0
|
.filter(hasContent)
|
||||||
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||||
|
|
||||||
// Check if has URL
|
// Apply filter
|
||||||
let hasUrl = false
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||||
|
|
||||||
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||||
if (ib.kind === 39701) {
|
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||||
hasUrl = !!dTag && dTag.trim().length > 0
|
|
||||||
} else {
|
// Group non-set bookmarks by source or flatten based on mode
|
||||||
// For other bookmarks, extract URLs from content
|
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||||
const urls = extractUrlsFromContent(ib.content || '')
|
const sectionsArray: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
hasUrl = urls.length > 0
|
groupingMode === 'flat'
|
||||||
|
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
|
||||||
|
: [
|
||||||
|
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb },
|
||||||
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||||
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||||
|
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
||||||
|
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add bookmark sets as additional sections (only in grouped mode)
|
||||||
|
if (groupingMode === 'grouped') {
|
||||||
|
bookmarkSets.forEach(set => {
|
||||||
|
sectionsArray.push({
|
||||||
|
key: `set-${set.name}`,
|
||||||
|
title: set.title || set.name,
|
||||||
|
items: set.bookmarks
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show articles (kind:30023) as they have special handling
|
return sectionsArray
|
||||||
if (ib.kind === 30023) return true
|
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
|
||||||
|
|
||||||
// Otherwise, must have either content or URL
|
|
||||||
return hasContent || hasUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge and flatten all individual bookmarks from all lists
|
// Get all filtered bookmarks for empty state checks
|
||||||
// Re-sort after flattening to ensure newest first across all lists
|
const allIndividualBookmarks = useMemo(() =>
|
||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(hasContentOrUrl)
|
.filter(hasContent)
|
||||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
|
||||||
|
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredBookmarks = useMemo(() =>
|
||||||
|
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
|
||||||
|
[allIndividualBookmarks, selectedFilter]
|
||||||
|
)
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
// Check if the selected URL is in bookmarks
|
// Check if the selected URL is in bookmarks
|
||||||
@@ -121,11 +243,32 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
relayPool={relayPool}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{allIndividualBookmarks.length === 0 ? (
|
{allIndividualBookmarks.length > 0 && (
|
||||||
|
<div className="bookmark-filters-wrapper">
|
||||||
|
<BookmarkFilters
|
||||||
|
selectedFilter={selectedFilter}
|
||||||
|
onFilterChange={setSelectedFilter}
|
||||||
|
/>
|
||||||
|
<CompactButton
|
||||||
|
icon={faPlus}
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
title="Add web bookmark"
|
||||||
|
ariaLabel="Add web bookmark"
|
||||||
|
className="bookmark-section-action"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!activeAccount ? (
|
||||||
|
<LoginOptions />
|
||||||
|
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>No bookmarks match this filter.</p>
|
||||||
|
</div>
|
||||||
|
) : allIndividualBookmarks.length === 0 ? (
|
||||||
loading ? (
|
loading ? (
|
||||||
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
||||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
@@ -138,7 +281,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No bookmarks found.</p>
|
<p>No bookmarks found.</p>
|
||||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||||
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -150,53 +292,79 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
isRefreshing={isPulling || isRefreshing || false}
|
isRefreshing={isPulling || isRefreshing || false}
|
||||||
pullPosition={pullPosition}
|
pullPosition={pullPosition}
|
||||||
/>
|
/>
|
||||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
{sections.filter(s => s.items.length > 0).map(section => (
|
||||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
<div key={section.key} className="bookmarks-section">
|
||||||
<BookmarkItem
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
key={`${individualBookmark.id}-${index}`}
|
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
||||||
bookmark={individualBookmark}
|
</div>
|
||||||
index={index}
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
onSelectUrl={onSelectUrl}
|
{section.items.map((individualBookmark, index) => (
|
||||||
viewMode={viewMode}
|
<BookmarkItem
|
||||||
/>
|
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||||
)}
|
bookmark={individualBookmark}
|
||||||
</div>
|
index={index}
|
||||||
|
onSelectUrl={onSelectUrl}
|
||||||
|
viewMode={viewMode}
|
||||||
|
readingProgress={getBookmarkReadingProgress(individualBookmark)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="view-mode-controls">
|
<div className="view-mode-controls">
|
||||||
{onRefresh && (
|
<div className="view-mode-left">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRotate}
|
icon={faHeart}
|
||||||
onClick={onRefresh}
|
onClick={() => navigate('/support')}
|
||||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
title="Support Boris"
|
||||||
ariaLabel="Refresh bookmarks"
|
ariaLabel="Support"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={isRefreshing}
|
style={{ color: friendsColor }}
|
||||||
spin={isRefreshing}
|
|
||||||
/>
|
/>
|
||||||
|
{activeAccount && (
|
||||||
|
<IconButton
|
||||||
|
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||||
|
onClick={toggleGroupingMode}
|
||||||
|
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||||
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeAccount && (
|
||||||
|
<div className="view-mode-right">
|
||||||
|
<IconButton
|
||||||
|
icon={faList}
|
||||||
|
onClick={() => onViewModeChange('compact')}
|
||||||
|
title="Compact list view"
|
||||||
|
ariaLabel="Compact list view"
|
||||||
|
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faThLarge}
|
||||||
|
onClick={() => onViewModeChange('cards')}
|
||||||
|
title="Cards view"
|
||||||
|
ariaLabel="Cards view"
|
||||||
|
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faImage}
|
||||||
|
onClick={() => onViewModeChange('large')}
|
||||||
|
title="Large preview view"
|
||||||
|
ariaLabel="Large preview view"
|
||||||
|
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
|
||||||
icon={faList}
|
|
||||||
onClick={() => onViewModeChange('compact')}
|
|
||||||
title="Compact list view"
|
|
||||||
ariaLabel="Compact list view"
|
|
||||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faThLarge}
|
|
||||||
onClick={() => onViewModeChange('cards')}
|
|
||||||
title="Cards view"
|
|
||||||
ariaLabel="Cards view"
|
|
||||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faImage}
|
|
||||||
onClick={() => onViewModeChange('large')}
|
|
||||||
title="Large preview view"
|
|
||||||
ariaLabel="Large preview view"
|
|
||||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{showAddModal && (
|
||||||
|
<AddBookmarkModal
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSave={handleSaveBookmark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +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 { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
import { faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { 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 IconButton from '../IconButton'
|
|
||||||
import { classifyUrl } from '../../utils/helpers'
|
import { classifyUrl } from '../../utils/helpers'
|
||||||
import { IconGetter } from './shared'
|
|
||||||
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,13 +19,13 @@ interface CardViewProps {
|
|||||||
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
|
||||||
getIconForUrlType: IconGetter
|
|
||||||
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
|
||||||
|
articleTitle?: string
|
||||||
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardView: React.FC<CardViewProps> = ({
|
export const CardView: React.FC<CardViewProps> = ({
|
||||||
@@ -32,27 +33,53 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
index,
|
index,
|
||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
|
||||||
getIconForUrlType,
|
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary
|
articleSummary,
|
||||||
|
articleTitle,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||||
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 isWebBookmark = bookmark.kind === 39701
|
||||||
|
const isNote = bookmark.kind === 1
|
||||||
|
|
||||||
|
// Extract title from tags for regular bookmarks (not just articles)
|
||||||
|
const bookmarkTitle = bookmark.tags.find(t => t[0] === 'title')?.[1]
|
||||||
|
|
||||||
|
// Get content type icon based on bookmark kind and URL classification
|
||||||
|
const getContentTypeIcon = () => {
|
||||||
|
if (isArticle) return faNewspaper // Nostr-native article
|
||||||
|
|
||||||
|
// For web bookmarks, classify the URL to determine icon
|
||||||
|
if (isWebBookmark && firstUrlClassificationType) {
|
||||||
|
switch (firstUrlClassificationType) {
|
||||||
|
case 'youtube':
|
||||||
|
case 'video':
|
||||||
|
return faCirclePlay
|
||||||
|
case 'image':
|
||||||
|
return faCamera
|
||||||
|
case 'article':
|
||||||
|
return faFileLines
|
||||||
|
default:
|
||||||
|
return faGlobe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For notes, use sticky note icon
|
||||||
|
if (isNote) return faStickyNote
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return faLink
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Determine which image to use (article image, instant preview, or OG image)
|
// Determine which image to use (article image, instant preview, or OG image)
|
||||||
const previewImage = articleImage || instantPreview || ogImage
|
const previewImage = articleImage || instantPreview || ogImage
|
||||||
@@ -65,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) => {
|
||||||
@@ -74,130 +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">
|
>
|
||||||
{isWebBookmark ? (
|
{!cachedImage && firstUrl && (
|
||||||
<span className="fa-layers fa-fw">
|
<div className="thumbnail-placeholder">
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
<FontAwesomeIcon icon={getContentTypeIcon()} />
|
||||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
|
||||||
</span>
|
|
||||||
) : bookmark.isPrivate ? (
|
|
||||||
<>
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
)}
|
|
||||||
</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 (
|
|
||||||
<div key={urlIndex} className="url-row">
|
|
||||||
<button
|
|
||||||
className="bookmark-url"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
|
||||||
title="Open in reader"
|
|
||||||
>
|
|
||||||
{url}
|
|
||||||
</button>
|
|
||||||
<IconButton
|
|
||||||
icon={getIconForUrlType(url)}
|
|
||||||
ariaLabel="Open"
|
|
||||||
title="Open"
|
|
||||||
variant="success"
|
|
||||||
size={32}
|
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
})}
|
</div>
|
||||||
{extractedUrls.length > 1 && (
|
)}
|
||||||
<button
|
<div className="card-text-content">
|
||||||
className="expand-toggle-urls"
|
<div className="bookmark-header">
|
||||||
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
|
</div>
|
||||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
|
||||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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,10 +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 { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
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 { useImageCache } from '../../hooks/useImageCache'
|
import { naddrEncode } from 'nostr-tools/nip19'
|
||||||
|
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||||
|
|
||||||
interface CompactViewProps {
|
interface CompactViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -12,8 +14,9 @@ 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
|
||||||
articleImage?: string
|
articleTitle?: string
|
||||||
articleSummary?: string
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CompactView: React.FC<CompactViewProps> = ({
|
export const CompactView: React.FC<CompactViewProps> = ({
|
||||||
@@ -22,31 +25,37 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
articleImage,
|
articleTitle,
|
||||||
articleSummary
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
const isNote = bookmark.kind === 1
|
||||||
|
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
|
||||||
// Get cached image for thumbnail
|
|
||||||
const cachedImage = useImageCache(articleImage || undefined)
|
|
||||||
|
|
||||||
|
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -55,36 +64,30 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
role={isClickable ? 'button' : undefined}
|
role={isClickable ? 'button' : undefined}
|
||||||
tabIndex={isClickable ? 0 : undefined}
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
>
|
>
|
||||||
{/* Thumbnail image */}
|
|
||||||
{cachedImage && (
|
|
||||||
<div className="compact-thumbnail">
|
|
||||||
<img src={cachedImage} alt="" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="bookmark-type-compact">
|
<span className="bookmark-type-compact">
|
||||||
{isWebBookmark ? (
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
<span className="fa-layers fa-fw">
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
|
||||||
</span>
|
|
||||||
) : bookmark.isPrivate ? (
|
|
||||||
<>
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
)}
|
|
||||||
</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 - only show when there's actual progress */}
|
||||||
|
{readingProgress !== undefined && readingProgress > 0 && (
|
||||||
|
<ReadingProgressBar
|
||||||
|
readingProgress={readingProgress}
|
||||||
|
height={1}
|
||||||
|
marginLeft="1.5rem"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React from 'react'
|
import React 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 { 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
|
||||||
@@ -17,10 +19,11 @@ 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
|
||||||
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LargeView: React.FC<LargeViewProps> = ({
|
export const LargeView: React.FC<LargeViewProps> = ({
|
||||||
@@ -32,14 +35,16 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
getIconForUrlType,
|
getIconForUrlType,
|
||||||
previewImage,
|
previewImage,
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary
|
articleSummary,
|
||||||
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
|
|
||||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -48,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}`}
|
||||||
@@ -80,16 +109,22 @@ 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 all bookmark types - always shown */}
|
||||||
|
<ReadingProgressBar
|
||||||
|
readingProgress={readingProgress}
|
||||||
|
height={3}
|
||||||
|
marginTop="0.75rem"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="large-footer">
|
<div className="large-footer">
|
||||||
|
<span className="bookmark-type-large">
|
||||||
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
|
</span>
|
||||||
<span className="large-author">
|
<span className="large-author">
|
||||||
<Link
|
<Link
|
||||||
to={`/p/${authorNpub}`}
|
to={`/p/${authorNpub}`}
|
||||||
@@ -100,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,9 +16,13 @@ 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 ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
import Me from './Me'
|
import Me from './Me'
|
||||||
|
import Profile from './Profile'
|
||||||
import Support from './Support'
|
import Support from './Support'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
|
|
||||||
@@ -24,10 +31,19 @@ export type ViewMode = 'compact' | 'cards' | 'large'
|
|||||||
interface BookmarksProps {
|
interface BookmarksProps {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
|
bookmarks: Bookmark[]
|
||||||
|
bookmarksLoading: boolean
|
||||||
|
onRefreshBookmarks: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
const Bookmarks: React.FC<BookmarksProps> = ({
|
||||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
relayPool,
|
||||||
|
onLogout,
|
||||||
|
bookmarks,
|
||||||
|
bookmarksLoading,
|
||||||
|
onRefreshBookmarks
|
||||||
|
}) => {
|
||||||
|
const { naddr, npub, eventId: eventIdParam } = useParams<{ naddr?: string; npub?: string; eventId?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const previousLocationRef = useRef<string>()
|
const previousLocationRef = useRef<string>()
|
||||||
@@ -41,44 +57,59 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
|
|
||||||
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 === '/me/archive' ? 'archive' :
|
location.pathname.startsWith('/my/reads') ? 'reads' :
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname.startsWith('/my/links') ? 'links' :
|
||||||
|
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()
|
||||||
@@ -151,8 +182,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bookmarks,
|
|
||||||
bookmarksLoading,
|
|
||||||
highlights,
|
highlights,
|
||||||
setHighlights,
|
setHighlights,
|
||||||
highlightsLoading,
|
highlightsLoading,
|
||||||
@@ -165,11 +194,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
} = useBookmarksData({
|
} = useBookmarksData({
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
|
||||||
naddr,
|
naddr,
|
||||||
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings,
|
||||||
|
eventStore,
|
||||||
|
onRefreshBookmarks
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -208,14 +239,28 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
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,
|
||||||
@@ -230,8 +275,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
|
|
||||||
// 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,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
@@ -242,6 +288,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
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)
|
||||||
@@ -315,10 +372,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
me={showMe ? (
|
me={showMe ? (
|
||||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} settings={settings} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
profile={showProfile && profilePubkey ? (
|
profile={showProfile && profilePubkey ? (
|
||||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
support={showSupport ? (
|
support={showSupport ? (
|
||||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||||
|
|||||||
@@ -4,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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
1960
src/components/Debug.tsx
Normal file
1960
src/components/Debug.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
src/components/EventViewer.tsx
Normal file
1
src/components/EventViewer.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react'
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } 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'
|
||||||
@@ -8,20 +8,33 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { fetchContacts } from '../services/contactService'
|
// Contacts are managed via controller subscription
|
||||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||||
|
import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController'
|
||||||
|
import { highlightsController } from '../services/highlightsController'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
import { getCachedPosts, setCachedPosts, getCachedHighlights, setCachedHighlights } from '../services/exploreCache'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
import { HighlightVisibility } from './HighlightsPanel'
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
|
// import { KINDS } from '../config/kinds'
|
||||||
|
// import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
// import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
|
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||||
|
import { writingsController } from '../services/writingsController'
|
||||||
|
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { contactsController } from '../services/contactsController'
|
||||||
|
|
||||||
|
// Accessors from Helpers (currently unused here)
|
||||||
|
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
interface ExploreProps {
|
interface ExploreProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -41,14 +54,242 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
||||||
|
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
||||||
|
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
||||||
|
const hasHydratedRef = useRef(false)
|
||||||
|
|
||||||
// Visibility filters (defaults from settings, or friends only)
|
// Get myHighlights directly from controller
|
||||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
|
||||||
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
|
// Remove unused loading state to avoid warnings
|
||||||
friends: settings?.defaultHighlightVisibilityFriends ?? true,
|
|
||||||
mine: settings?.defaultHighlightVisibilityMine ?? false
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
|
// Load cached content from event store (instant display)
|
||||||
|
// const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||||
|
|
||||||
|
// const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
||||||
|
// event,
|
||||||
|
// title: getArticleTitle(event) || 'Untitled',
|
||||||
|
// summary: getArticleSummary(event),
|
||||||
|
// image: getArticleImage(event),
|
||||||
|
// published: getArticlePublished(event),
|
||||||
|
// author: event.pubkey
|
||||||
|
// }), [])
|
||||||
|
|
||||||
|
// const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Visibility filters - load from localStorage first, fallback to settings
|
||||||
|
const [visibility, setVisibility] = useState<HighlightVisibility>(() => {
|
||||||
|
// Try to load from localStorage first
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('exploreScopeVisibility')
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved)
|
||||||
|
// Validate that at least one scope is enabled
|
||||||
|
if (parsed.nostrverse || parsed.friends || parsed.mine) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load explore scope from localStorage:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to settings or defaults
|
||||||
|
return {
|
||||||
|
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||||
|
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||||
|
mine: settings?.defaultExploreScopeMine ?? false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Ensure at least one scope remains active
|
||||||
|
const toggleScope = useCallback((key: 'nostrverse' | 'friends' | 'mine') => {
|
||||||
|
setVisibility(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] }
|
||||||
|
if (!next.nostrverse && !next.friends && !next.mine) {
|
||||||
|
return prev // ignore toggle that would disable all scopes
|
||||||
|
}
|
||||||
|
// Persist to localStorage
|
||||||
|
try {
|
||||||
|
localStorage.setItem('exploreScopeVisibility', JSON.stringify(next))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to save explore scope to localStorage:', err)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to highlights controller
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||||
|
return () => {
|
||||||
|
unsubHighlights()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to contacts stream and mirror into local state
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = contactsController.onContacts((contacts) => {
|
||||||
|
setFollowedPubkeys(new Set(contacts))
|
||||||
|
})
|
||||||
|
return () => unsubscribe()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Ensure contacts controller is started for the active account (non-blocking)
|
||||||
|
useEffect(() => {
|
||||||
|
if (relayPool && activeAccount?.pubkey) {
|
||||||
|
contactsController.start({ relayPool, pubkey: activeAccount.pubkey }).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [relayPool, activeAccount?.pubkey])
|
||||||
|
|
||||||
|
// Subscribe to nostrverse highlights controller for global stream
|
||||||
|
useEffect(() => {
|
||||||
|
const apply = (incoming: Highlight[]) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const byId = new Map(prev.map(h => [h.id, h]))
|
||||||
|
for (const h of incoming) byId.set(h.id, h)
|
||||||
|
return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// seed immediately
|
||||||
|
apply(nostrverseHighlightsController.getHighlights())
|
||||||
|
const unsub = nostrverseHighlightsController.onHighlights(apply)
|
||||||
|
return () => unsub()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to nostrverse writings controller for global stream
|
||||||
|
useEffect(() => {
|
||||||
|
const apply = (incoming: BlogPostPreview[]) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const byKey = new Map<string, BlogPostPreview>()
|
||||||
|
for (const p of prev) {
|
||||||
|
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${p.author}:${dTag}`
|
||||||
|
byKey.set(key, p)
|
||||||
|
}
|
||||||
|
for (const p of incoming) {
|
||||||
|
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${p.author}:${dTag}`
|
||||||
|
const existing = byKey.get(key)
|
||||||
|
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
|
||||||
|
}
|
||||||
|
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
apply(nostrverseWritingsController.getWritings())
|
||||||
|
const unsub = nostrverseWritingsController.onWritings(apply)
|
||||||
|
return () => unsub()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to writings controller for "mine" posts and seed immediately
|
||||||
|
useEffect(() => {
|
||||||
|
// Seed from controller's current state
|
||||||
|
const seed = writingsController.getWritings()
|
||||||
|
if (seed.length > 0) {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, ...seed])
|
||||||
|
return merged.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream updates
|
||||||
|
const unsub = writingsController.onWritings((posts) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, ...posts])
|
||||||
|
return merged.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => unsub()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to reading progress controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
const initialMap = readingProgressController.getProgressMap()
|
||||||
|
setReadingProgressMap(initialMap)
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubProgress = readingProgressController.onProgress((newMap) => {
|
||||||
|
setReadingProgressMap(newMap)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubProgress()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load reading progress data when logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount?.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgressController.start({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey: activeAccount.pubkey,
|
||||||
|
force: refreshTrigger > 0
|
||||||
|
})
|
||||||
|
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
|
// Update visibility when settings/login state changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user has a saved preference
|
||||||
|
const hasSavedPreference = (() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('exploreScopeVisibility') !== null
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Only reset to defaults if no saved preference exists
|
||||||
|
if (hasSavedPreference) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeAccount) {
|
||||||
|
// When logged out, show nostrverse by default
|
||||||
|
const defaultVisibility = { nostrverse: true, friends: false, mine: false }
|
||||||
|
setVisibility(defaultVisibility)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to save explore scope to localStorage:', err)
|
||||||
|
}
|
||||||
|
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
|
||||||
|
setHasLoadedNostrverseHighlights(true)
|
||||||
|
} else {
|
||||||
|
// When logged in, use settings defaults immediately
|
||||||
|
const defaultVisibility = {
|
||||||
|
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||||
|
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||||
|
mine: settings?.defaultExploreScopeMine ?? false
|
||||||
|
}
|
||||||
|
setVisibility(defaultVisibility)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to save explore scope to localStorage:', err)
|
||||||
|
}
|
||||||
|
setHasLoadedNostrverse(false)
|
||||||
|
setHasLoadedNostrverseHighlights(false)
|
||||||
|
}
|
||||||
|
}, [activeAccount, settings?.defaultExploreScopeNostrverse, settings?.defaultExploreScopeFriends, settings?.defaultExploreScopeMine])
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (propActiveTab) {
|
if (propActiveTab) {
|
||||||
@@ -56,162 +297,164 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
}, [propActiveTab])
|
}, [propActiveTab])
|
||||||
|
|
||||||
useEffect(() => {
|
// Load initial data and refresh on triggers
|
||||||
const loadData = async () => {
|
const loadData = useCallback(() => {
|
||||||
if (!activeAccount) {
|
if (!relayPool) return
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Seed from cache for instant UI
|
||||||
// show spinner but keep existing data
|
if (activeAccount) {
|
||||||
setLoading(true)
|
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||||
|
if (cachedPosts && cachedPosts.length > 0) setBlogPosts(cachedPosts)
|
||||||
|
const cached = getCachedHighlights(activeAccount.pubkey)
|
||||||
|
if (cached && cached.length > 0) setHighlights(cached)
|
||||||
|
}
|
||||||
|
|
||||||
// Seed from in-memory cache if available to avoid empty flash
|
setLoading(true)
|
||||||
// Use functional update to check current state without creating dependency
|
|
||||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
|
||||||
if (cachedPosts && cachedPosts.length > 0) {
|
|
||||||
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
|
|
||||||
}
|
|
||||||
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
|
||||||
if (cachedHighlights && cachedHighlights.length > 0) {
|
|
||||||
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the user's contacts (friends)
|
try {
|
||||||
const contacts = await fetchContacts(
|
// Prepare parallel fetches
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
|
||||||
|
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
|
||||||
|
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
|
||||||
|
fetchNostrverseBlogPosts(
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount.pubkey,
|
relayUrls,
|
||||||
(partial) => {
|
50,
|
||||||
// Store followed pubkeys for highlight classification
|
eventStore || undefined,
|
||||||
setFollowedPubkeys(partial)
|
(post) => {
|
||||||
// When local contacts are available, kick off early fetch
|
setBlogPosts(prev => {
|
||||||
if (partial.size > 0) {
|
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
const partialArray = Array.from(partial)
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
// Fetch blog posts
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
fetchBlogPostsFromAuthors(
|
|
||||||
relayPool,
|
|
||||||
partialArray,
|
|
||||||
relayUrls,
|
|
||||||
(post) => {
|
|
||||||
setBlogPosts((prev) => {
|
|
||||||
const exists = prev.some(p => p.event.id === post.event.id)
|
|
||||||
if (exists) return prev
|
|
||||||
const next = [...prev, post]
|
|
||||||
return next.sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
})
|
|
||||||
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
|
||||||
}
|
|
||||||
).then((all) => {
|
|
||||||
setBlogPosts((prev) => {
|
|
||||||
const byId = new Map(prev.map(p => [p.event.id, p]))
|
|
||||||
for (const post of all) byId.set(post.event.id, post)
|
|
||||||
const merged = Array.from(byId.values()).sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
setCachedPosts(activeAccount.pubkey, merged)
|
|
||||||
return merged
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch highlights
|
|
||||||
fetchHighlightsFromAuthors(
|
|
||||||
relayPool,
|
|
||||||
partialArray,
|
|
||||||
(highlight) => {
|
|
||||||
setHighlights((prev) => {
|
|
||||||
const exists = prev.some(h => h.id === highlight.id)
|
|
||||||
if (exists) return prev
|
|
||||||
const next = [...prev, highlight]
|
|
||||||
return next.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
})
|
|
||||||
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
|
||||||
}
|
|
||||||
).then((all) => {
|
|
||||||
setHighlights((prev) => {
|
|
||||||
const byId = new Map(prev.map(h => [h.id, h]))
|
|
||||||
for (const highlight of all) byId.set(highlight.id, highlight)
|
|
||||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
|
||||||
setCachedHighlights(activeAccount.pubkey, merged)
|
|
||||||
return merged
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
).then((nostrversePosts) => {
|
||||||
|
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||||
// Always proceed to load nostrverse content even if no contacts
|
}).catch(() => {})
|
||||||
// (removed blocking error for empty contacts)
|
}
|
||||||
|
|
||||||
// Store final followed pubkeys
|
|
||||||
setFollowedPubkeys(contacts)
|
|
||||||
|
|
||||||
// Fetch both friends content and nostrverse content in parallel
|
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
||||||
const contactsArray = Array.from(contacts)
|
|
||||||
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
|
||||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
|
||||||
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
|
||||||
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
|
|
||||||
fetchNostrverseHighlights(relayPool, 100)
|
|
||||||
])
|
|
||||||
|
|
||||||
// Merge and deduplicate all posts
|
|
||||||
const allPosts = [...friendsPosts, ...nostrversePosts]
|
|
||||||
const postsByKey = new Map<string, BlogPostPreview>()
|
|
||||||
for (const post of allPosts) {
|
|
||||||
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
|
|
||||||
const existing = postsByKey.get(key)
|
|
||||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
|
||||||
postsByKey.set(key, post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
|
|
||||||
// Merge and deduplicate all highlights
|
|
||||||
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
|
|
||||||
const highlightsByKey = new Map<string, Highlight>()
|
|
||||||
for (const highlight of allHighlights) {
|
|
||||||
highlightsByKey.set(highlight.id, highlight)
|
|
||||||
}
|
|
||||||
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
|
|
||||||
|
|
||||||
// Fetch profiles for all blog post authors to cache them
|
|
||||||
if (uniquePosts.length > 0) {
|
|
||||||
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
|
|
||||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
|
|
||||||
console.error('Failed to fetch author profiles:', err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// No blocking errors - let empty states handle messaging
|
|
||||||
setBlogPosts(uniquePosts)
|
|
||||||
setCachedPosts(activeAccount.pubkey, uniquePosts)
|
|
||||||
|
|
||||||
setHighlights(uniqueHighlights)
|
|
||||||
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
console.error('Failed to load data:', err)
|
||||||
// No blocking error - user can pull-to-refresh
|
// No blocking error - user can pull-to-refresh
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
// loading is already turned off after seeding
|
||||||
}
|
}
|
||||||
}
|
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
}, [loadData, refreshTrigger])
|
||||||
|
|
||||||
|
// Kick off friends fetches reactively when contacts arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (!relayPool) return
|
||||||
|
if (followedPubkeys.size === 0) return
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
const contactsArray = Array.from(followedPubkeys)
|
||||||
|
|
||||||
|
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls, (post) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||||
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
|
// Pre-cache profiles in background
|
||||||
|
const authorPubkeys = Array.from(new Set(merged.map(p => p.author)))
|
||||||
|
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
||||||
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
|
}, 100, eventStore).then((friendsPosts) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
||||||
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
fetchHighlightsFromAuthors(relayPool, contactsArray, (highlight) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const merged = dedupeHighlightsById([...prev, highlight])
|
||||||
|
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||||
|
return merged.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
|
}, eventStore || undefined).then((friendsHighlights) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
|
||||||
|
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||||
|
return merged.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [relayPool, followedPubkeys, eventStore, settings, activeAccount])
|
||||||
|
|
||||||
|
// Lazy-load nostrverse writings when user toggles it on (logged in)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
setHasLoadedNostrverse(true)
|
||||||
|
fetchNostrverseBlogPosts(
|
||||||
|
relayPool,
|
||||||
|
relayUrls,
|
||||||
|
50,
|
||||||
|
eventStore || undefined,
|
||||||
|
(post) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${post.author}:${dTag}`
|
||||||
|
const existingIndex = prev.findIndex(p => {
|
||||||
|
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
return `${p.author}:${pDTag}` === key
|
||||||
|
})
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = prev[existingIndex]
|
||||||
|
if (post.event.created_at <= existing.event.created_at) return prev
|
||||||
|
const next = [...prev]
|
||||||
|
next[existingIndex] = post
|
||||||
|
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
}
|
||||||
|
const next = [...prev, post]
|
||||||
|
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
).then((finalPosts) => {
|
||||||
|
// Ensure final deduped list
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const byKey = new Map<string, BlogPostPreview>()
|
||||||
|
for (const p of [...prev, ...finalPosts]) {
|
||||||
|
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${p.author}:${dTag}`
|
||||||
|
const existing = byKey.get(key)
|
||||||
|
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
|
||||||
|
}
|
||||||
|
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||||
|
.then((nostriverseHighlights) => {
|
||||||
|
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [activeAccount, relayPool, visibility.nostrverse, hasLoadedNostrverse, eventStore])
|
||||||
|
|
||||||
|
// Lazy-load nostrverse highlights when user toggles it on (logged in)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverseHighlights) return
|
||||||
|
setHasLoadedNostrverseHighlights(true)
|
||||||
|
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||||
|
.then((hl) => {
|
||||||
|
if (hl && hl.length > 0) {
|
||||||
|
setHighlights(prev => dedupeHighlightsById([...prev, ...hl]).sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverseHighlights])
|
||||||
|
|
||||||
|
// Lazy-load my writings when user toggles "mine" on (logged in)
|
||||||
|
// No direct fetch here; writingsController streams my posts centrally
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount || !visibility.mine || hasLoadedMine) return
|
||||||
|
setHasLoadedMine(true)
|
||||||
|
}, [visibility.mine, activeAccount, hasLoadedMine])
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
@@ -237,35 +480,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
return `/a/${naddr}`
|
return `/a/${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHighlightClick = (highlightId: string) => {
|
|
||||||
const highlight = highlights.find(h => h.id === highlightId)
|
|
||||||
if (!highlight) return
|
|
||||||
|
|
||||||
// For nostr-native articles
|
|
||||||
if (highlight.eventReference) {
|
|
||||||
// Convert eventReference to naddr
|
|
||||||
if (highlight.eventReference.includes(':')) {
|
|
||||||
const parts = highlight.eventReference.split(':')
|
|
||||||
const kind = parseInt(parts[0])
|
|
||||||
const pubkey = parts[1]
|
|
||||||
const identifier = parts[2] || ''
|
|
||||||
|
|
||||||
const naddr = nip19.naddrEncode({
|
|
||||||
kind,
|
|
||||||
pubkey,
|
|
||||||
identifier
|
|
||||||
})
|
|
||||||
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
|
|
||||||
} else {
|
|
||||||
// Already an naddr
|
|
||||||
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For web URLs
|
|
||||||
else if (highlight.urlReference) {
|
|
||||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classify highlights with levels based on user context and apply visibility filters
|
// Classify highlights with levels based on user context and apply visibility filters
|
||||||
const classifiedHighlights = useMemo(() => {
|
const classifiedHighlights = useMemo(() => {
|
||||||
@@ -278,15 +492,31 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
})
|
})
|
||||||
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
||||||
|
|
||||||
|
// Dedupe and sort posts once for rendering
|
||||||
|
const uniqueSortedPosts = useMemo(() => {
|
||||||
|
const unique = dedupeWritingsByReplaceable(blogPosts)
|
||||||
|
return unique.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}, [blogPosts])
|
||||||
|
|
||||||
// Filter blog posts by future dates and visibility, and add level classification
|
// Filter blog posts by future dates and visibility, and add level classification
|
||||||
const filteredBlogPosts = useMemo(() => {
|
const filteredBlogPosts = useMemo(() => {
|
||||||
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
||||||
return blogPosts
|
return uniqueSortedPosts
|
||||||
.filter(post => {
|
.filter(post => {
|
||||||
// Filter out future dates
|
// Filter out future dates
|
||||||
const publishedTime = post.published || post.event.created_at
|
const publishedTime = post.published || post.event.created_at
|
||||||
if (publishedTime > maxFutureTime) return false
|
if (publishedTime > maxFutureTime) return false
|
||||||
|
|
||||||
|
// Hide bot authors by profile display name if setting enabled
|
||||||
|
if (settings?.hideBotArticlesByName !== false) {
|
||||||
|
// Profile resolution and filtering is handled in BlogPostCard via ProfileModel
|
||||||
|
// Keep list intact here; individual cards will render null if author is a bot
|
||||||
|
}
|
||||||
|
|
||||||
// Apply visibility filters
|
// Apply visibility filters
|
||||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||||
const isFriend = followedPubkeys.has(post.author)
|
const isFriend = followedPubkeys.has(post.author)
|
||||||
@@ -305,7 +535,29 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||||
return { ...post, level }
|
return { ...post, level }
|
||||||
})
|
})
|
||||||
}, [blogPosts, activeAccount, followedPubkeys, visibility])
|
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility, settings?.hideBotArticlesByName])
|
||||||
|
|
||||||
|
// Helper to get reading progress for a post
|
||||||
|
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
const progress = readingProgressMap.get(naddr)
|
||||||
|
|
||||||
|
return progress
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[progress] ❌ Error encoding naddr:', err)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [readingProgressMap])
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -320,8 +572,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return filteredBlogPosts.length === 0 ? (
|
return filteredBlogPosts.length === 0 ? (
|
||||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
|
<div className="explore-grid">
|
||||||
<p>No blog posts yet. Pull to refresh!</p>
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
@@ -331,6 +585,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
post={post}
|
post={post}
|
||||||
href={getPostUrl(post)}
|
href={getPostUrl(post)}
|
||||||
level={post.level}
|
level={post.level}
|
||||||
|
readingProgress={getReadingProgress(post)}
|
||||||
|
hideBotByName={settings?.hideBotArticlesByName !== false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -339,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} />
|
||||||
))}
|
))}
|
||||||
@@ -347,17 +603,16 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return classifiedHighlights.length === 0 ? (
|
return classifiedHighlights.length === 0 ? (
|
||||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
|
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<p>No highlights yet. Pull to refresh!</p>
|
<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}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onHighlightClick={handleHighlightClick}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -368,7 +623,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show skeletons while first load in this session
|
||||||
const hasData = highlights.length > 0 || blogPosts.length > 0
|
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||||
const showSkeletons = loading && !hasData
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
@@ -380,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>
|
||||||
|
|
||||||
@@ -397,7 +652,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faNetworkWired}
|
icon={faNetworkWired}
|
||||||
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
|
onClick={() => toggleScope('nostrverse')}
|
||||||
title="Toggle nostrverse content"
|
title="Toggle nostrverse content"
|
||||||
ariaLabel="Toggle nostrverse content"
|
ariaLabel="Toggle nostrverse content"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -408,7 +663,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faUserGroup}
|
icon={faUserGroup}
|
||||||
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
|
onClick={() => toggleScope('friends')}
|
||||||
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
||||||
ariaLabel="Toggle friends content"
|
ariaLabel="Toggle friends content"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -420,7 +675,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faUser}
|
icon={faUser}
|
||||||
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
|
onClick={() => toggleScope('mine')}
|
||||||
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
||||||
ariaLabel="Toggle my content"
|
ariaLabel="Toggle my content"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -452,7 +707,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderTabContent()}
|
<div>
|
||||||
|
{renderTabContent()}
|
||||||
|
</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
|
||||||
@@ -27,7 +28,6 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
|||||||
// Fallback: extract directly from p tag
|
// Fallback: extract directly from p tag
|
||||||
const pTag = highlight.tags.find(t => t[0] === 'p')
|
const pTag = highlight.tags.find(t => t[0] === 'p')
|
||||||
if (pTag && pTag[1]) {
|
if (pTag && pTag[1]) {
|
||||||
console.log('📝 Found author from p tag:', pTag[1])
|
|
||||||
return pTag[1]
|
return pTag[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +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(':')) {
|
||||||
@@ -74,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,22 +1,25 @@
|
|||||||
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'
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
|
||||||
import { getNostrUrl } from '../config/nostrGateways'
|
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 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,30 +116,22 @@ 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)
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// Resolve the profile of the user who made the highlight
|
// Resolve the profile of the user who made the highlight
|
||||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||||
|
|
||||||
// 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(() => {
|
||||||
@@ -232,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
|
||||||
}
|
}
|
||||||
@@ -249,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) {
|
||||||
@@ -257,34 +163,114 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isSelected])
|
}, [isSelected])
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu and reset delete confirm when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
setShowMenu(false)
|
setShowMenu(false)
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showMenu) {
|
if (showMenu || showDeleteConfirm) {
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside)
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [showMenu])
|
}, [showMenu, showDeleteConfirm])
|
||||||
|
|
||||||
|
// Navigate to the article that this highlight references and scroll to the highlight
|
||||||
|
const navigateToArticle = () => {
|
||||||
|
// Always try to navigate if we have a reference - quote button should always work
|
||||||
|
if (highlight.eventReference) {
|
||||||
|
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
||||||
|
const parts = highlight.eventReference.split(':')
|
||||||
|
|
||||||
|
// If it's an article coordinate (3 parts) and kind is 30023, navigate to it
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [kind, pubkey, identifier] = parts
|
||||||
|
|
||||||
|
if (kind === '30023') {
|
||||||
|
// Encode as naddr and navigate
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
// Pass highlight ID in navigation state to trigger scroll
|
||||||
|
navigate(`/a/${naddr}`, {
|
||||||
|
state: {
|
||||||
|
highlightId: highlight.id,
|
||||||
|
openHighlights: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If eventReference is just an event ID (not a coordinate), we can't navigate to it
|
||||||
|
// as we don't have enough info to construct the article URL
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
const handleItemClick = () => {
|
||||||
|
// If onHighlightClick is provided, use it (legacy behavior)
|
||||||
if (onHighlightClick) {
|
if (onHighlightClick) {
|
||||||
onHighlightClick(highlight.id)
|
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,
|
||||||
@@ -318,13 +304,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Publish to all configured relays - let the relay pool handle connection state
|
// Publish to all configured relays - let the relay pool handle connection state
|
||||||
const targetRelays = RELAYS
|
const targetRelays = getActiveRelayUrls(relayPool)
|
||||||
|
|
||||||
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
|
|
||||||
|
|
||||||
await relayPool.publish(targetRelays, event)
|
await relayPool.publish(targetRelays, event)
|
||||||
|
|
||||||
console.log('✅ Rebroadcast successful!')
|
|
||||||
|
|
||||||
// Update the highlight with new relay info
|
// Update the highlight with new relay info
|
||||||
const isLocalOnly = areAllRelaysLocal(targetRelays)
|
const isLocalOnly = areAllRelaysLocal(targetRelays)
|
||||||
@@ -340,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 {
|
||||||
@@ -361,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) {
|
||||||
@@ -370,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
|
||||||
}
|
}
|
||||||
@@ -388,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 {
|
||||||
@@ -421,7 +432,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('✅ Highlight deletion request published')
|
|
||||||
|
|
||||||
// Notify parent to remove this highlight from the list
|
// Notify parent to remove this highlight from the list
|
||||||
if (onHighlightDelete) {
|
if (onHighlightDelete) {
|
||||||
@@ -434,12 +444,12 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancelDelete = () => {
|
|
||||||
setShowDeleteConfirm(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMenuToggle = (e: React.MouseEvent) => {
|
const handleMenuToggle = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
// Reset delete confirm state when opening/closing menu
|
||||||
|
if (!showMenu) {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
}
|
||||||
setShowMenu(!showMenu)
|
setShowMenu(!showMenu)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +471,76 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
setShowDeleteConfirm(true)
|
setShowDeleteConfirm(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleConfirmDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
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
|
||||||
@@ -468,7 +548,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||||
data-highlight-id={highlight.id}
|
data-highlight-id={highlight.id}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
<div className="highlight-header">
|
<div className="highlight-header">
|
||||||
<CompactButton
|
<CompactButton
|
||||||
@@ -476,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)}
|
||||||
@@ -486,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 && (
|
||||||
@@ -527,12 +653,43 @@ 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}>
|
||||||
|
{showDeleteConfirm && canDelete && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.5rem' }}>
|
||||||
|
<span style={{ fontSize: '0.875rem', color: 'rgb(220 38 38)', fontWeight: 500 }}>Confirm?</span>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmDeleteClick}
|
||||||
|
disabled={isDeleting}
|
||||||
|
title="Confirm deletion"
|
||||||
|
style={{
|
||||||
|
color: 'rgb(220 38 38)',
|
||||||
|
background: 'rgba(220, 38, 38, 0.1)',
|
||||||
|
border: '1px solid rgb(220 38 38)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '0.375rem',
|
||||||
|
cursor: isDeleting ? 'not-allowed' : 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: '33px',
|
||||||
|
minHeight: '33px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<CompactButton
|
<CompactButton
|
||||||
icon={faEllipsisH}
|
icon={faEllipsisH}
|
||||||
onClick={handleMenuToggle}
|
onClick={handleMenuToggle}
|
||||||
@@ -541,12 +698,26 @@ 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}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
<span>Open on Nostr</span>
|
<span>Open with njump</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="highlight-menu-item"
|
className="highlight-menu-item"
|
||||||
@@ -571,17 +742,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={showDeleteConfirm}
|
|
||||||
title="Delete Highlight?"
|
|
||||||
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
|
|
||||||
confirmText="Delete"
|
|
||||||
cancelText="Cancel"
|
|
||||||
variant="danger"
|
|
||||||
onConfirm={handleConfirmDelete}
|
|
||||||
onCancel={handleCancelDelete}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
|
|||||||
relayPool?: RelayPool | null
|
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
|
||||||
@@ -46,49 +55,42 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
{currentUserPubkey && (
|
||||||
icon={faUserGroup}
|
<>
|
||||||
onClick={() => onHighlightVisibilityChange({
|
<IconButton
|
||||||
...highlightVisibility,
|
icon={faUserGroup}
|
||||||
friends: !highlightVisibility.friends
|
onClick={() => onHighlightVisibilityChange({
|
||||||
})}
|
...highlightVisibility,
|
||||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
friends: !highlightVisibility.friends
|
||||||
ariaLabel="Toggle friends highlights"
|
})}
|
||||||
variant="ghost"
|
title="Toggle friends highlights"
|
||||||
disabled={!currentUserPubkey}
|
ariaLabel="Toggle friends highlights"
|
||||||
style={{
|
variant="ghost"
|
||||||
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
style={{
|
||||||
opacity: highlightVisibility.friends ? 1 : 0.4
|
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
}}
|
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||||
/>
|
}}
|
||||||
<IconButton
|
/>
|
||||||
icon={faUser}
|
<IconButton
|
||||||
onClick={() => onHighlightVisibilityChange({
|
icon={faUser}
|
||||||
...highlightVisibility,
|
onClick={() => onHighlightVisibilityChange({
|
||||||
mine: !highlightVisibility.mine
|
...highlightVisibility,
|
||||||
})}
|
mine: !highlightVisibility.mine
|
||||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
})}
|
||||||
ariaLabel="Toggle my highlights"
|
title="Toggle my highlights"
|
||||||
variant="ghost"
|
ariaLabel="Toggle my highlights"
|
||||||
disabled={!currentUserPubkey}
|
variant="ghost"
|
||||||
style={{
|
style={{
|
||||||
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
opacity: highlightVisibility.mine ? 1 : 0.4
|
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onRefresh && (
|
</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}
|
||||||
@@ -99,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>
|
||||||
)
|
)
|
||||||
|
|||||||
209
src/components/LoginOptions.tsx
Normal file
209
src/components/LoginOptions.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faPuzzlePiece, faShieldHalved, faCircleInfo } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { Accounts } from 'applesauce-accounts'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
|
||||||
|
|
||||||
|
const LoginOptions: React.FC = () => {
|
||||||
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const [showBunkerInput, setShowBunkerInput] = useState(false)
|
||||||
|
const [bunkerUri, setBunkerUri] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<React.ReactNode | null>(null)
|
||||||
|
|
||||||
|
const handleExtensionLogin = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const account = await Accounts.ExtensionAccount.fromExtension()
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Extension login failed:', err)
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||||
|
|
||||||
|
// Check if extension is not installed
|
||||||
|
if (errorMessage.includes('Signer extension missing') || errorMessage.includes('window.nostr') || errorMessage.includes('not found') || errorMessage.includes('undefined') || errorMessage.toLowerCase().includes('extension missing')) {
|
||||||
|
setError(
|
||||||
|
<>
|
||||||
|
No browser extension found. Please install{' '}
|
||||||
|
<a href="https://chromewebstore.google.com/detail/nos2x/kpgefcfmnafjgpblomihpgmejjdanjjp" target="_blank" rel="noopener noreferrer">
|
||||||
|
nos2x
|
||||||
|
</a>
|
||||||
|
{' '}or another nostr extension.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (errorMessage.includes('denied') || errorMessage.includes('rejected') || errorMessage.includes('cancel')) {
|
||||||
|
setError('Authentication was cancelled or denied.')
|
||||||
|
} else {
|
||||||
|
setError(`Authentication failed: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBunkerLogin = async () => {
|
||||||
|
if (!bunkerUri.trim()) {
|
||||||
|
setError('Please enter a bunker URI')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bunkerUri.startsWith('bunker://')) {
|
||||||
|
setError(
|
||||||
|
<>
|
||||||
|
Invalid bunker URI. Must start with bunker://. Don't have a signer? Give{' '}
|
||||||
|
<a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">
|
||||||
|
Amber
|
||||||
|
</a>
|
||||||
|
{' '}or{' '}
|
||||||
|
<a href="https://testflight.apple.com/join/DUzVMDMK" target="_blank" rel="noopener noreferrer">
|
||||||
|
Aegis
|
||||||
|
</a>
|
||||||
|
{' '}a try.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Create signer from bunker URI with default permissions
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions })
|
||||||
|
|
||||||
|
// Get pubkey from signer
|
||||||
|
const pubkey = await signer.getPublicKey()
|
||||||
|
|
||||||
|
// Create account from signer
|
||||||
|
const account = new Accounts.NostrConnectAccount(pubkey, signer)
|
||||||
|
|
||||||
|
// Add to account manager and set active
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
|
||||||
|
// Clear input on success
|
||||||
|
setBunkerUri('')
|
||||||
|
setShowBunkerInput(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bunker] Login failed:', err)
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker'
|
||||||
|
|
||||||
|
// Check for permission-related errors
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
|
||||||
|
} else {
|
||||||
|
// Show helpful message for bunker connection failures
|
||||||
|
setError(
|
||||||
|
<>
|
||||||
|
Failed: {errorMessage}
|
||||||
|
<br /><br />
|
||||||
|
Don't have a signer? Give{' '}
|
||||||
|
<a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">
|
||||||
|
Amber
|
||||||
|
</a>
|
||||||
|
{' '}or{' '}
|
||||||
|
<a href="https://testflight.apple.com/join/DUzVMDMK" target="_blank" rel="noopener noreferrer">
|
||||||
|
Aegis
|
||||||
|
</a>
|
||||||
|
{' '}a try.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="empty-state login-container">
|
||||||
|
<div className="login-content">
|
||||||
|
<h2 className="login-title">Hi! I'm Boris.</h2>
|
||||||
|
<p className="login-description">
|
||||||
|
<mark className="login-highlight">Connect your npub</mark> to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights.</mark>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="login-buttons">
|
||||||
|
{!showBunkerInput && (
|
||||||
|
<button
|
||||||
|
onClick={handleExtensionLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="login-button login-button-primary"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPuzzlePiece} />
|
||||||
|
<span>{isLoading ? 'Connecting...' : 'Extension'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showBunkerInput ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBunkerInput(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="login-button login-button-secondary"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faShieldHalved} />
|
||||||
|
<span>Signer</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="bunker-input-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="bunker://..."
|
||||||
|
value={bunkerUri}
|
||||||
|
onChange={(e) => setBunkerUri(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bunker-input"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleBunkerLogin()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="bunker-actions">
|
||||||
|
<button
|
||||||
|
onClick={handleBunkerLogin}
|
||||||
|
disabled={isLoading || !bunkerUri.trim()}
|
||||||
|
className="bunker-button bunker-connect"
|
||||||
|
>
|
||||||
|
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowBunkerInput(false)
|
||||||
|
setBunkerUri('')
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bunker-button bunker-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="login-error">
|
||||||
|
<FontAwesomeIcon icon={faCircleInfo} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="login-footer">
|
||||||
|
New to nostr? Start here:{' '}
|
||||||
|
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
|
||||||
|
nstart.me
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginOptions
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
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
|
||||||
|
|
||||||
379
src/components/Profile.tsx
Normal file
379
src/components/Profile.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faHighlighter, faPenToSquare, faEllipsisH, faCopy, faShare, faExternalLinkAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { HighlightItem } from './HighlightItem'
|
||||||
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import AuthorCard from './AuthorCard'
|
||||||
|
import CompactButton from './CompactButton'
|
||||||
|
import BlogPostCard from './BlogPostCard'
|
||||||
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
|
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { toBlogPostPreview } from '../utils/toBlogPostPreview'
|
||||||
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { writingsController } from '../services/writingsController'
|
||||||
|
import { highlightsController } from '../services/highlightsController'
|
||||||
|
import { getProfileUrl } from '../config/nostrGateways'
|
||||||
|
|
||||||
|
interface ProfileProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
activeTab?: 'highlights' | 'writings'
|
||||||
|
}
|
||||||
|
|
||||||
|
const Profile: React.FC<ProfileProps> = ({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey,
|
||||||
|
activeTab: propActiveTab
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
const [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||||
|
const profileMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
|
// Load cached data from event store instantly
|
||||||
|
const cachedHighlights = useStoreTimeline(
|
||||||
|
eventStore,
|
||||||
|
{ kinds: [KINDS.Highlights], authors: [pubkey] },
|
||||||
|
eventToHighlight,
|
||||||
|
[pubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
const cachedWritings = useStoreTimeline(
|
||||||
|
eventStore,
|
||||||
|
{ kinds: [30023], authors: [pubkey] },
|
||||||
|
toBlogPostPreview,
|
||||||
|
[pubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sort writings by publication date, newest first
|
||||||
|
const sortedWritings = useMemo(() => {
|
||||||
|
return cachedWritings.slice().sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}, [cachedWritings])
|
||||||
|
|
||||||
|
// Update local state when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (propActiveTab) {
|
||||||
|
setActiveTab(propActiveTab)
|
||||||
|
}
|
||||||
|
}, [propActiveTab])
|
||||||
|
|
||||||
|
// Subscribe to reading progress controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
const initialMap = readingProgressController.getProgressMap()
|
||||||
|
setReadingProgressMap(initialMap)
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubProgress = readingProgressController.onProgress((newMap) => {
|
||||||
|
setReadingProgressMap(newMap)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubProgress()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load reading progress data when logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount?.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgressController.start({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey: activeAccount.pubkey,
|
||||||
|
force: refreshTrigger > 0
|
||||||
|
})
|
||||||
|
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
|
// Background fetch via controllers to populate event store
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pubkey || !relayPool || !eventStore) return
|
||||||
|
|
||||||
|
// Start controllers to fetch and populate event store
|
||||||
|
// Controllers handle streaming, deduplication, and storage
|
||||||
|
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||||
|
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
|
||||||
|
|
||||||
|
writingsController.start({ relayPool, eventStore, pubkey, force: refreshTrigger > 0 })
|
||||||
|
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
|
||||||
|
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
|
// Pull-to-refresh
|
||||||
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
setRefreshTrigger(prev => prev + 1)
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !pubkey
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPostUrl = (post: BlogPostPreview) => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return `/a/${naddr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get reading progress for a post
|
||||||
|
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
const progress = readingProgressMap.get(naddr)
|
||||||
|
|
||||||
|
// Only log when found or map is empty
|
||||||
|
if (progress || readingProgressMap.size === 0) {
|
||||||
|
// Progress found or map is empty
|
||||||
|
}
|
||||||
|
|
||||||
|
return progress
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [readingProgressMap])
|
||||||
|
|
||||||
|
const handleHighlightDelete = () => {
|
||||||
|
// Not allowed to delete other users' highlights
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
|
||||||
|
|
||||||
|
// 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 = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'highlights':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<HighlightSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cachedHighlights.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No highlights yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="highlights-list me-highlights-list">
|
||||||
|
{cachedHighlights.map((highlight) => (
|
||||||
|
<HighlightItem
|
||||||
|
key={highlight.id}
|
||||||
|
highlight={{ ...highlight, level: 'mine' }}
|
||||||
|
relayPool={relayPool}
|
||||||
|
onHighlightDelete={handleHighlightDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'writings':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return sortedWritings.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No articles written yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{sortedWritings.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={post.event.id}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
readingProgress={getReadingProgress(post)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="explore-container">
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
|
<div className="explore-header">
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||||
|
data-tab="highlights"
|
||||||
|
onClick={() => navigate(`/p/${npub}`)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span className="tab-label">Highlights</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||||
|
data-tab="writings"
|
||||||
|
onClick={() => navigate(`/p/${npub}/writings`)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
|
<span className="tab-label">Writings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="me-tab-content">
|
||||||
|
{renderTabContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|||||||
import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { useImageCache } from '../hooks/useImageCache'
|
import { useImageCache } from '../hooks/useImageCache'
|
||||||
|
import { useAdaptiveTextColor } from '../hooks/useAdaptiveTextColor'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import { Highlight, HighlightLevel } from '../types/highlights'
|
import { Highlight, HighlightLevel } from '../types/highlights'
|
||||||
import { HighlightVisibility } from './HighlightsPanel'
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
@@ -19,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> = ({
|
||||||
@@ -31,9 +33,11 @@ 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 formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||||
const isLongSummary = summary && summary.length > 150
|
const isLongSummary = summary && summary.length > 150
|
||||||
|
|
||||||
@@ -76,14 +80,25 @@ 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} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{formattedDate && (
|
{formattedDate && (
|
||||||
<div className="publish-date-topright">
|
<div
|
||||||
|
className="publish-date-topright"
|
||||||
|
style={{
|
||||||
|
color: textColor
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -100,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>
|
||||||
@@ -125,7 +142,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
{title && (
|
{title && (
|
||||||
<div className="reader-header">
|
<div className="reader-header">
|
||||||
{formattedDate && (
|
{formattedDate && (
|
||||||
<div className="publish-date-topright">
|
<div
|
||||||
|
className="publish-date-topright"
|
||||||
|
style={{
|
||||||
|
color: textColor
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -140,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
src/components/ReadingProgressFilters.tsx
Normal file
60
src/components/ReadingProgressFilters.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
|
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'archive'
|
||||||
|
|
||||||
|
interface ReadingProgressFiltersProps {
|
||||||
|
selectedFilter: ReadingProgressFilterType
|
||||||
|
onFilterChange: (filter: ReadingProgressFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
|
||||||
|
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||||
|
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||||
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
|
// Archive-marked items (previously emoji-marked)
|
||||||
|
{ type: 'archive' as const, icon: faBooks, label: 'Archive' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => {
|
||||||
|
const isActive = selectedFilter === filter.type
|
||||||
|
// Only "completed" gets green color, "highlighted" gets yellow, everything else uses default blue
|
||||||
|
let activeStyle: Record<string, string> | undefined = undefined
|
||||||
|
if (isActive) {
|
||||||
|
if (filter.type === 'completed') {
|
||||||
|
activeStyle = { color: '#10b981' } // green
|
||||||
|
} else if (filter.type === 'highlighted') {
|
||||||
|
activeStyle = { color: '#fde047' } // yellow
|
||||||
|
} else if (filter.type === 'archive') {
|
||||||
|
activeStyle = { color: '#60a5fa' } // blue accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadingProgressFilters
|
||||||
|
|
||||||
@@ -19,6 +19,21 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
}) => {
|
}) => {
|
||||||
const clampedProgress = Math.min(100, Math.max(0, progress))
|
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||||
|
|
||||||
|
// Determine reading state based on progress (matching readingProgressUtils.ts logic)
|
||||||
|
const progressDecimal = clampedProgress / 100
|
||||||
|
const isStarted = progressDecimal > 0 && progressDecimal <= 0.10
|
||||||
|
|
||||||
|
// Determine bar color based on state
|
||||||
|
let barColorClass = ''
|
||||||
|
let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
barColorClass = 'bg-green-500'
|
||||||
|
barColorStyle = undefined
|
||||||
|
} else if (isStarted) {
|
||||||
|
barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles)
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate left and right offsets based on sidebar states (desktop only)
|
// Calculate left and right offsets based on sidebar states (desktop only)
|
||||||
const leftOffset = isSidebarCollapsed
|
const leftOffset = isSidebarCollapsed
|
||||||
? 'var(--sidebar-collapsed-width)'
|
? 'var(--sidebar-collapsed-width)'
|
||||||
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
style={{ backgroundColor: 'var(--color-border)' }}
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
|
||||||
isComplete
|
|
||||||
? 'bg-green-500'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
width: `${clampedProgress}%`,
|
width: `${clampedProgress}%`,
|
||||||
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
|
backgroundColor: barColorStyle
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||||
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||||
isComplete ? 'text-green-500' : ''
|
isComplete ? 'text-green-500' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
|
style={{
|
||||||
|
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isComplete ? '✓' : `${clampedProgress}%`}
|
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,16 +50,8 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
|||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('🔌 Relay Status Indicator:', {
|
// Mode and relay status determined
|
||||||
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
}, [isConnecting, offlineMode, localOnlyMode, relayStatuses, hasLocalRelay, hasRemoteRelay])
|
||||||
totalStatuses: relayStatuses.length,
|
|
||||||
connectedCount: connectedUrls.length,
|
|
||||||
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
|
||||||
hasLocalRelay,
|
|
||||||
hasRemoteRelay,
|
|
||||||
isConnecting
|
|
||||||
})
|
|
||||||
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
|
||||||
|
|
||||||
// Don't show indicator when fully connected (but show when connecting)
|
// Don't show indicator when fully connected (but show when connecting)
|
||||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||||
@@ -156,7 +148,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
|||||||
fontWeight: 400
|
fontWeight: 400
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
|
Local relays only
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
30
src/components/RouteDebug.tsx
Normal file
30
src/components/RouteDebug.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation, useMatch } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function RouteDebug() {
|
||||||
|
const location = useLocation()
|
||||||
|
const matchArticle = useMatch('/a/:naddr')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
if (params.get('debug') !== '1') return
|
||||||
|
|
||||||
|
const info: Record<string, unknown> = {
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: location.search || null,
|
||||||
|
matchedArticleRoute: Boolean(matchArticle),
|
||||||
|
referrer: document.referrer || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.pathname === '/') {
|
||||||
|
// Unexpected during deep-link refresh tests
|
||||||
|
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||||
|
} else {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}, [location, matchArticle])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,13 +6,15 @@ import IconButton from './IconButton'
|
|||||||
import { loadFont } from '../utils/fontLoader'
|
import { 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 LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
import MediaDisplaySettings from './Settings/MediaDisplaySettings'
|
||||||
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
import ExploreSettings from './Settings/ExploreSettings'
|
||||||
|
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||||
import ZapSettings from './Settings/ZapSettings'
|
import ZapSettings from './Settings/ZapSettings'
|
||||||
import OfflineModeSettings from './Settings/OfflineModeSettings'
|
|
||||||
import RelaySettings from './Settings/RelaySettings'
|
import RelaySettings from './Settings/RelaySettings'
|
||||||
import PWASettings from './Settings/PWASettings'
|
import PWASettings from './Settings/PWASettings'
|
||||||
|
import TTSSettings from './Settings/TTSSettings'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
|
import VersionFooter from './VersionFooter'
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: UserSettings = {
|
const DEFAULT_SETTINGS: UserSettings = {
|
||||||
collapseOnArticleOpen: true,
|
collapseOnArticleOpen: true,
|
||||||
@@ -30,11 +32,27 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
defaultHighlightVisibilityNostrverse: true,
|
defaultHighlightVisibilityNostrverse: true,
|
||||||
defaultHighlightVisibilityFriends: true,
|
defaultHighlightVisibilityFriends: true,
|
||||||
defaultHighlightVisibilityMine: true,
|
defaultHighlightVisibilityMine: true,
|
||||||
|
defaultExploreScopeNostrverse: false,
|
||||||
|
defaultExploreScopeFriends: true,
|
||||||
|
defaultExploreScopeMine: false,
|
||||||
zapSplitHighlighterWeight: 50,
|
zapSplitHighlighterWeight: 50,
|
||||||
zapSplitBorisWeight: 2.1,
|
zapSplitBorisWeight: 2.1,
|
||||||
zapSplitAuthorWeight: 50,
|
zapSplitAuthorWeight: 50,
|
||||||
useLocalRelayAsCache: true,
|
useLocalRelayAsCache: true,
|
||||||
rebroadcastToAllRelays: false,
|
rebroadcastToAllRelays: false,
|
||||||
|
paragraphAlignment: 'justify',
|
||||||
|
fullWidthImages: true,
|
||||||
|
renderVideoLinksAsEmbeds: true,
|
||||||
|
syncReadingPosition: true,
|
||||||
|
autoScrollToReadingPosition: true,
|
||||||
|
autoMarkAsReadOnCompletion: false,
|
||||||
|
hideBookmarksWithoutCreationDate: true,
|
||||||
|
ttsUseSystemLanguage: false,
|
||||||
|
ttsDetectContentLanguage: true,
|
||||||
|
ttsLanguageMode: 'content',
|
||||||
|
ttsDefaultSpeed: 2.1,
|
||||||
|
linkColorDark: '#38bdf8',
|
||||||
|
linkColorLight: '#3b82f6',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
@@ -162,13 +180,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
<TTSSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||||
<PWASettings />
|
|
||||||
</div>
|
</div>
|
||||||
|
<VersionFooter />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/components/Settings/ExploreSettings.tsx
Normal file
72
src/components/Settings/ExploreSettings.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
|
interface ExploreSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Explore</h3>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Default Explore Scope</label>
|
||||||
|
<div className="highlight-level-toggles">
|
||||||
|
<IconButton
|
||||||
|
icon={faNetworkWired}
|
||||||
|
onClick={() => onUpdate({ defaultExploreScopeNostrverse: !(settings.defaultExploreScopeNostrverse !== false) })}
|
||||||
|
title="Nostrverse content"
|
||||||
|
ariaLabel="Toggle nostrverse content by default in explore"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultExploreScopeNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||||
|
opacity: (settings.defaultExploreScopeNostrverse !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUserGroup}
|
||||||
|
onClick={() => onUpdate({ defaultExploreScopeFriends: !(settings.defaultExploreScopeFriends !== false) })}
|
||||||
|
title="Friends content"
|
||||||
|
ariaLabel="Toggle friends content by default in explore"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultExploreScopeFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
|
opacity: (settings.defaultExploreScopeFriends !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUser}
|
||||||
|
onClick={() => onUpdate({ defaultExploreScopeMine: !(settings.defaultExploreScopeMine !== false) })}
|
||||||
|
title="My content"
|
||||||
|
ariaLabel="Toggle my content by default in explore"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultExploreScopeMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
|
opacity: (settings.defaultExploreScopeMine !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="hideBotArticlesByName" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="hideBotArticlesByName"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.hideBotArticlesByName !== false}
|
||||||
|
onChange={(e) => onUpdate({ hideBotArticlesByName: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Hide content posted by bots</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExploreSettings
|
||||||
|
|
||||||
164
src/components/Settings/LayoutBehaviorSettings.tsx
Normal file
164
src/components/Settings/LayoutBehaviorSettings.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
|
interface LayoutBehaviorSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Layout & Behavior</h3>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Default Bookmark View</label>
|
||||||
|
<div className="setting-buttons">
|
||||||
|
<IconButton
|
||||||
|
icon={faList}
|
||||||
|
onClick={() => onUpdate({ defaultViewMode: 'compact' })}
|
||||||
|
title="Compact list view"
|
||||||
|
ariaLabel="Compact list view"
|
||||||
|
variant={(settings.defaultViewMode || 'compact') === 'compact' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faThLarge}
|
||||||
|
onClick={() => onUpdate({ defaultViewMode: 'cards' })}
|
||||||
|
title="Cards view"
|
||||||
|
ariaLabel="Cards view"
|
||||||
|
variant={settings.defaultViewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faImage}
|
||||||
|
onClick={() => onUpdate({ defaultViewMode: 'large' })}
|
||||||
|
title="Large preview view"
|
||||||
|
ariaLabel="Large preview view"
|
||||||
|
variant={settings.defaultViewMode === 'large' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="collapseOnArticleOpen" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="collapseOnArticleOpen"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.collapseOnArticleOpen !== false}
|
||||||
|
onChange={(e) => onUpdate({ collapseOnArticleOpen: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Collapse bookmark bar when opening an article</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="sidebarCollapsed"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.sidebarCollapsed !== false}
|
||||||
|
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Start with bookmarks sidebar collapsed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="highlightsCollapsed"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.highlightsCollapsed !== false}
|
||||||
|
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Start with highlights panel collapsed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="rebroadcastToAllRelays"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.rebroadcastToAllRelays ?? false}
|
||||||
|
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Rebroadcast events while browsing</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="autoCollapseSidebarOnMobile"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||||
|
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Auto-collapse sidebar on small screens</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="syncReadingPosition" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="syncReadingPosition"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.syncReadingPosition ?? false}
|
||||||
|
onChange={(e) => onUpdate({ syncReadingPosition: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Sync reading position across devices</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="autoScrollToReadingPosition" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="autoScrollToReadingPosition"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoScrollToReadingPosition !== false}
|
||||||
|
onChange={(e) => onUpdate({ autoScrollToReadingPosition: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Auto-scroll to saved reading position</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="autoMarkAsReadOnCompletion"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoMarkAsReadOnCompletion ?? false}
|
||||||
|
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Automatically move to archive at 100%</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="hideBookmarksWithoutCreationDate" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="hideBookmarksWithoutCreationDate"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.hideBookmarksWithoutCreationDate ?? false}
|
||||||
|
onChange={(e) => onUpdate({ hideBookmarksWithoutCreationDate: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Hide bookmarks missing a creation date</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LayoutBehaviorSettings
|
||||||
|
|
||||||
@@ -3,15 +3,15 @@ import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
|||||||
import { UserSettings } from '../../services/settingsService'
|
import { UserSettings } from '../../services/settingsService'
|
||||||
import IconButton from '../IconButton'
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
interface LayoutNavigationSettingsProps {
|
interface LayoutBehaviorSettingsProps {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
onUpdate: (updates: Partial<UserSettings>) => void
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ settings, onUpdate }) => {
|
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Layout & Navigation</h3>
|
<h3 className="section-title">Layout & Behavior</h3>
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label>Default Bookmark View</label>
|
<label>Default Bookmark View</label>
|
||||||
@@ -52,9 +52,61 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
|
|||||||
<span>Collapse bookmark bar when opening an article</span>
|
<span>Collapse bookmark bar when opening an article</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="sidebarCollapsed"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.sidebarCollapsed !== false}
|
||||||
|
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Start with bookmarks sidebar collapsed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="highlightsCollapsed"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.highlightsCollapsed !== false}
|
||||||
|
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Start with highlights panel collapsed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="rebroadcastToAllRelays"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.rebroadcastToAllRelays ?? false}
|
||||||
|
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Rebroadcast events while browsing</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="autoCollapseSidebarOnMobile"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||||
|
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Auto-collapse sidebar on small screens</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LayoutNavigationSettings
|
export default LayoutBehaviorSettings
|
||||||
|
|
||||||
|
|||||||
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
|
||||||
|
interface MediaDisplaySettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaDisplaySettings: React.FC<MediaDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Media Display</h3>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="fullWidthImages" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="fullWidthImages"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.fullWidthImages === true}
|
||||||
|
onChange={(e) => onUpdate({ fullWidthImages: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Full-width images in articles</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="renderVideoLinksAsEmbeds" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="renderVideoLinksAsEmbeds"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.renderVideoLinksAsEmbeds === true}
|
||||||
|
onChange={(e) => onUpdate({ renderVideoLinksAsEmbeds: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Render video links as embeds</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaDisplaySettings
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { faTrash } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { UserSettings } from '../../services/settingsService'
|
|
||||||
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
|
|
||||||
import IconButton from '../IconButton'
|
|
||||||
|
|
||||||
interface OfflineModeSettingsProps {
|
|
||||||
settings: UserSettings
|
|
||||||
onUpdate: (updates: Partial<UserSettings>) => void
|
|
||||||
onClose?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [cacheStats, setCacheStats] = useState<{
|
|
||||||
totalSizeMB: number
|
|
||||||
itemCount: number
|
|
||||||
items: Array<{ url: string, sizeMB: number }>
|
|
||||||
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
|
|
||||||
|
|
||||||
const handleLinkClick = (url: string) => {
|
|
||||||
if (onClose) onClose()
|
|
||||||
navigate(`/r/${encodeURIComponent(url)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearCache = async () => {
|
|
||||||
if (confirm('Are you sure you want to clear all cached images?')) {
|
|
||||||
await clearImageCache()
|
|
||||||
const stats = await getImageCacheStatsAsync()
|
|
||||||
setCacheStats(stats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cache stats periodically
|
|
||||||
useEffect(() => {
|
|
||||||
const updateStats = async () => {
|
|
||||||
const stats = await getImageCacheStatsAsync()
|
|
||||||
setCacheStats(stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStats() // Initial load
|
|
||||||
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="settings-section">
|
|
||||||
<h3 className="section-title">Flight Mode</h3>
|
|
||||||
|
|
||||||
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
|
||||||
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
|
||||||
<input
|
|
||||||
id="enableImageCache"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.enableImageCache ?? true}
|
|
||||||
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Use local image cache</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{(settings.enableImageCache ?? true) && (
|
|
||||||
<div style={{
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.5rem'
|
|
||||||
}}>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
|
||||||
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
|
||||||
<input
|
|
||||||
id="imageCacheSizeMB"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
max="500"
|
|
||||||
value={settings.imageCacheSizeMB ?? 210}
|
|
||||||
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
|
||||||
style={{
|
|
||||||
width: '50px',
|
|
||||||
padding: '0.15rem 0.35rem',
|
|
||||||
background: 'var(--surface-secondary)',
|
|
||||||
border: '1px solid var(--border-color, #333)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: 'inherit',
|
|
||||||
fontSize: 'inherit',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
MB used )
|
|
||||||
</span>
|
|
||||||
<IconButton
|
|
||||||
icon={faTrash}
|
|
||||||
onClick={handleClearCache}
|
|
||||||
title="Clear cache"
|
|
||||||
variant="ghost"
|
|
||||||
size={28}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
|
||||||
<input
|
|
||||||
id="useLocalRelayAsCache"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.useLocalRelayAsCache ?? true}
|
|
||||||
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Use local relays as cache</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
marginTop: '1.5rem',
|
|
||||||
padding: '1rem',
|
|
||||||
background: 'var(--surface-secondary)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
lineHeight: '1.6'
|
|
||||||
}}>
|
|
||||||
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
|
|
||||||
Boris works best with a local relay. Consider running{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
|
||||||
>
|
|
||||||
Citrine
|
|
||||||
</a>
|
|
||||||
{' or '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
|
||||||
>
|
|
||||||
nostr-relay-tray
|
|
||||||
</a>
|
|
||||||
. Don't know what relays are? Learn more{' '}
|
|
||||||
<a
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
handleLinkClick('https://nostr.how/en/relays')
|
|
||||||
}}
|
|
||||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</a>
|
|
||||||
{' and '}
|
|
||||||
<a
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
|
|
||||||
}}
|
|
||||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OfflineModeSettings
|
|
||||||
|
|
||||||
@@ -1,58 +1,222 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { faDownload, faCheckCircle, faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { usePWAInstall } from '../../hooks/usePWAInstall'
|
import { usePWAInstall } from '../../hooks/usePWAInstall'
|
||||||
|
import { useIsMobile } from '../../hooks/useMediaQuery'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
|
||||||
|
|
||||||
const PWASettings: React.FC = () => {
|
interface PWASettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const { isInstallable, isInstalled, installApp } = usePWAInstall()
|
const { isInstallable, isInstalled, installApp } = usePWAInstall()
|
||||||
|
const [cacheStats, setCacheStats] = useState<{
|
||||||
|
totalSizeMB: number
|
||||||
|
itemCount: number
|
||||||
|
items: Array<{ url: string, sizeMB: number }>
|
||||||
|
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
|
||||||
|
|
||||||
const handleInstall = async () => {
|
const handleInstall = async () => {
|
||||||
|
if (isInstalled) return
|
||||||
const success = await installApp()
|
const success = await installApp()
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log('App installed successfully')
|
// Installation successful
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInstalled) {
|
const handleLinkClick = (url: string) => {
|
||||||
return (
|
if (onClose) onClose()
|
||||||
<div className="settings-section">
|
// If it's an internal route (starts with /), navigate directly
|
||||||
<h3 className="section-title">Progressive Web App</h3>
|
if (url.startsWith('/')) {
|
||||||
<div className="setting-item">
|
navigate(url)
|
||||||
<div className="setting-info">
|
} else {
|
||||||
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
|
// External URL: wrap with /r/ path
|
||||||
<span>Boris is installed as an app</span>
|
navigate(`/r/${encodeURIComponent(url)}`)
|
||||||
</div>
|
}
|
||||||
<p className="setting-description">
|
|
||||||
You can launch Boris from your home screen or app drawer.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isInstallable) {
|
const handleClearCache = async () => {
|
||||||
return null
|
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||||
|
await clearImageCache()
|
||||||
|
const stats = await getImageCacheStatsAsync()
|
||||||
|
setCacheStats(stats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update cache stats periodically
|
||||||
|
useEffect(() => {
|
||||||
|
const updateStats = async () => {
|
||||||
|
const stats = await getImageCacheStatsAsync()
|
||||||
|
setCacheStats(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats() // Initial load
|
||||||
|
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Progressive Web App</h3>
|
<h3 className="section-title">App & Airplane Mode</h3>
|
||||||
<div className="setting-group">
|
|
||||||
<div className="setting-info">
|
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
|
||||||
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
<span>Install Boris as an app</span>
|
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
Boris is offline‑first by design. You can read, create highlights, and browse your library without being connected to the internet. Boris will store changes locally and sync later.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Flight Mode Section - Checkboxes First */}
|
||||||
|
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
||||||
|
<input
|
||||||
|
id="enableImageCache"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enableImageCache ?? true}
|
||||||
|
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Use local image cache</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{(settings.enableImageCache ?? true) && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
|
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
||||||
|
<input
|
||||||
|
id="imageCacheSizeMB"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="500"
|
||||||
|
value={settings.imageCacheSizeMB ?? 210}
|
||||||
|
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
||||||
|
style={{
|
||||||
|
width: '50px',
|
||||||
|
padding: '0.15rem 0.35rem',
|
||||||
|
background: 'var(--surface-secondary)',
|
||||||
|
border: '1px solid var(--border-color, #333)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: 'inherit',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
MB used )
|
||||||
|
</span>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faTrash}
|
||||||
|
onClick={handleClearCache}
|
||||||
|
title="Clear cache"
|
||||||
|
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PWA Install Section - Paragraphs */}
|
||||||
|
<div className="setting-group">
|
||||||
|
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '0.75rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
<strong>Note:</strong> Boris works best with a local relay. Consider running{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||||
|
>
|
||||||
|
Citrine
|
||||||
|
</a>
|
||||||
|
{' or '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||||
|
>
|
||||||
|
nostr-relay-tray
|
||||||
|
</a>
|
||||||
|
{' '}to bring full offline functionality to Boris. Don't know what relays are? Learn more{' '}
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleLinkClick('https://nostr.how/en/relays')
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
{', '}
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
{', and '}
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleLinkClick('/a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8')
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="useLocalRelayAsCache"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.useLocalRelayAsCache ?? true}
|
||||||
|
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Use local relays as cache</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
Install Boris on your device for a native app experience.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="zap-preset-btn"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||||
|
disabled={isInstalled || !isInstallable}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isInstalled ? faCheckCircle : faDownload} />
|
||||||
|
{isInstalled ? 'Installed' : 'Install App'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
|
||||||
Install Boris on your device for a native app experience with offline support.
|
{!isMobile && (
|
||||||
</p>
|
<img
|
||||||
<button
|
src="/pwa.svg"
|
||||||
onClick={handleInstall}
|
alt="Progressive Web App"
|
||||||
className="zap-preset-btn"
|
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
/>
|
||||||
>
|
)}
|
||||||
<FontAwesomeIcon icon={faDownload} />
|
|
||||||
Install App
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser, faAlignLeft, faAlignJustify } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { UserSettings } from '../../services/settingsService'
|
||||||
import IconButton from '../IconButton'
|
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,40 +14,28 @@ 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">
|
||||||
<h3 className="section-title">Reading & Display</h3>
|
<h3 className="section-title">Reading & Display</h3>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
|
||||||
<div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}>
|
|
||||||
<label htmlFor="readingFont">Reading Font</label>
|
|
||||||
<div className="setting-control">
|
|
||||||
<FontSelector
|
|
||||||
value={settings.readingFont || 'source-serif-4'}
|
|
||||||
onChange={(font) => onUpdate({ readingFont: font })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}>
|
|
||||||
<label>Font Size</label>
|
|
||||||
<div className="setting-buttons">
|
|
||||||
{[16, 18, 21, 24, 28, 32].map(size => (
|
|
||||||
<button
|
|
||||||
key={size}
|
|
||||||
onClick={() => onUpdate({ fontSize: size })}
|
|
||||||
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
|
|
||||||
title={`${size}px`}
|
|
||||||
style={{ fontSize: `${size - 2}px` }}
|
|
||||||
>
|
|
||||||
A
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label>Highlight Style</label>
|
<label>Highlight Style</label>
|
||||||
<div className="setting-buttons">
|
<div className="setting-buttons">
|
||||||
@@ -69,34 +57,25 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label className="setting-label">My Highlights</label>
|
<label>Paragraph Alignment</label>
|
||||||
<div className="setting-control">
|
<div className="setting-buttons">
|
||||||
<ColorPicker
|
<IconButton
|
||||||
selectedColor={settings.highlightColorMine || '#fde047'}
|
icon={faAlignLeft}
|
||||||
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
onClick={() => onUpdate({ paragraphAlignment: 'left' })}
|
||||||
|
title="Left aligned"
|
||||||
|
ariaLabel="Left aligned"
|
||||||
|
variant={settings.paragraphAlignment === 'left' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faAlignJustify}
|
||||||
|
onClick={() => onUpdate({ paragraphAlignment: 'justify' })}
|
||||||
|
title="Justified"
|
||||||
|
ariaLabel="Justified"
|
||||||
|
variant={(settings.paragraphAlignment || 'justify') === 'justify' ? 'primary' : 'ghost'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
|
||||||
<label className="setting-label">Friends Highlights</label>
|
|
||||||
<div className="setting-control">
|
|
||||||
<ColorPicker
|
|
||||||
selectedColor={settings.highlightColorFriends || '#f97316'}
|
|
||||||
onColorChange={(color) => onUpdate({ highlightColorFriends: color })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
|
||||||
<label className="setting-label">Nostrverse Highlights</label>
|
|
||||||
<div className="setting-control">
|
|
||||||
<ColorPicker
|
|
||||||
selectedColor={settings.highlightColorNostrverse || '#9333ea'}
|
|
||||||
onColorChange={(color) => onUpdate({ highlightColorNostrverse: color })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label>Default Highlight Visibility</label>
|
<label>Default Highlight Visibility</label>
|
||||||
@@ -137,6 +116,76 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label htmlFor="readingFont">Reading Font</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<FontSelector
|
||||||
|
value={settings.readingFont || 'source-serif-4'}
|
||||||
|
onChange={(font) => onUpdate({ readingFont: font })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">Link Color</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<ColorPicker
|
||||||
|
selectedColor={currentLinkColor}
|
||||||
|
onColorChange={handleLinkColorChange}
|
||||||
|
colors={linkColors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">Font Size</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<div className="setting-buttons">
|
||||||
|
{[16, 18, 21, 24, 28, 32].map(size => (
|
||||||
|
<button
|
||||||
|
key={size}
|
||||||
|
onClick={() => onUpdate({ fontSize: size })}
|
||||||
|
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
|
||||||
|
title={`${size}px`}
|
||||||
|
style={{ fontSize: `${size - 2}px` }}
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">My Highlights</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<ColorPicker
|
||||||
|
selectedColor={settings.highlightColorMine || '#fde047'}
|
||||||
|
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">Friends Highlights</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<ColorPicker
|
||||||
|
selectedColor={settings.highlightColorFriends || '#f97316'}
|
||||||
|
onColorChange={(color) => onUpdate({ highlightColorFriends: color })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">Nostrverse Highlights</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<ColorPicker
|
||||||
|
selectedColor={settings.highlightColorNostrverse || '#9333ea'}
|
||||||
|
onColorChange={(color) => onUpdate({ highlightColorNostrverse: color })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label htmlFor="showHighlights" className="checkbox-label">
|
<label htmlFor="showHighlights" className="checkbox-label">
|
||||||
<input
|
<input
|
||||||
@@ -157,14 +206,17 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
style={{
|
style={{
|
||||||
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',
|
||||||
|
'--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>
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { UserSettings } from '../../services/settingsService'
|
|
||||||
|
|
||||||
interface StartupPreferencesSettingsProps {
|
|
||||||
settings: UserSettings
|
|
||||||
onUpdate: (updates: Partial<UserSettings>) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
|
|
||||||
return (
|
|
||||||
<div className="settings-section">
|
|
||||||
<h3 className="section-title">Startup & Behavior</h3>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
|
||||||
<input
|
|
||||||
id="sidebarCollapsed"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.sidebarCollapsed !== false}
|
|
||||||
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Start with bookmarks sidebar collapsed</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
|
||||||
<input
|
|
||||||
id="highlightsCollapsed"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.highlightsCollapsed !== false}
|
|
||||||
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Start with highlights panel collapsed</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
|
||||||
<input
|
|
||||||
id="rebroadcastToAllRelays"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.rebroadcastToAllRelays ?? false}
|
|
||||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Rebroadcast events while browsing</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
|
||||||
<input
|
|
||||||
id="autoCollapseSidebarOnMobile"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.autoCollapseSidebarOnMobile !== false}
|
|
||||||
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Auto-collapse sidebar on small screens</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StartupPreferencesSettings
|
|
||||||
|
|
||||||
86
src/components/Settings/TTSSettings.tsx
Normal file
86
src/components/Settings/TTSSettings.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faGauge } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import TTSControls from '../TTSControls'
|
||||||
|
|
||||||
|
interface TTSSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
|
||||||
|
const EXAMPLE_TEXT = "Boris aims to be a calm reader app with clean typography, beautiful design, and a focus on readability. Boris does not and will never have ads, trackers, paywalls, subscriptions, or any other distractions."
|
||||||
|
|
||||||
|
const TTSSettings: React.FC<TTSSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
const currentSpeed = settings.ttsDefaultSpeed || 2.1
|
||||||
|
|
||||||
|
const handleCycleSpeed = () => {
|
||||||
|
const currentIndex = SPEED_OPTIONS.indexOf(currentSpeed)
|
||||||
|
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||||
|
onUpdate({ ttsDefaultSpeed: SPEED_OPTIONS[nextIndex] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Text-to-Speech</h3>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Default Playback Speed</label>
|
||||||
|
<div className="setting-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handleCycleSpeed}
|
||||||
|
title="Cycle speed"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faGauge} />
|
||||||
|
<span>{currentSpeed}x</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Speaker language</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<select
|
||||||
|
value={settings.ttsLanguageMode || 'content'}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value
|
||||||
|
onUpdate({
|
||||||
|
ttsLanguageMode: value,
|
||||||
|
ttsUseSystemLanguage: value === 'system',
|
||||||
|
ttsDetectContentLanguage: value === 'content'
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="setting-select"
|
||||||
|
>
|
||||||
|
<option value="system">System Language</option>
|
||||||
|
<option value="content">Content (auto-detect)</option>
|
||||||
|
<option disabled>────────────</option>
|
||||||
|
<option value="en-US">English (American)</option>
|
||||||
|
<option value="en-GB">English (British)</option>
|
||||||
|
<option value="zh">Mandarin Chinese</option>
|
||||||
|
<option value="es">Spanish</option>
|
||||||
|
<option value="hi">Hindi</option>
|
||||||
|
<option value="ar">Arabic</option>
|
||||||
|
<option value="fr">French</option>
|
||||||
|
<option value="pt">Portuguese</option>
|
||||||
|
<option value="de">German</option>
|
||||||
|
<option value="ja">Japanese</option>
|
||||||
|
<option value="ru">Russian</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<div style={{ padding: '0.75rem', backgroundColor: 'var(--color-bg)', borderRadius: '4px', marginBottom: '0.75rem', fontSize: '0.95rem', lineHeight: '1.5' }}>
|
||||||
|
{EXAMPLE_TEXT}
|
||||||
|
</div>
|
||||||
|
<TTSControls text={EXAMPLE_TEXT} settings={settings} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TTSSettings
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import { useIsMobile } from '../../hooks/useMediaQuery'
|
||||||
|
|
||||||
interface ZapSettingsProps {
|
interface ZapSettingsProps {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
@@ -7,6 +8,7 @@ interface ZapSettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
|
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
|
||||||
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
|
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
|
||||||
const authorWeight = settings.zapSplitAuthorWeight ?? 50
|
const authorWeight = settings.zapSplitAuthorWeight ?? 50
|
||||||
@@ -42,98 +44,119 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
|||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Zap Splits</h3>
|
<h3 className="section-title">Zap Splits</h3>
|
||||||
|
|
||||||
<div className="setting-group">
|
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
|
||||||
<label className="setting-label">Presets</label>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
<div className="zap-preset-buttons">
|
<div className="setting-group">
|
||||||
<button
|
<label className="setting-label">Presets</label>
|
||||||
onClick={() => applyPreset(presets.default)}
|
<div className="zap-preset-buttons">
|
||||||
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
|
<button
|
||||||
title="You: 49%, Author: 49%, Boris: 2%"
|
onClick={() => applyPreset(presets.default)}
|
||||||
>
|
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
|
||||||
Default
|
title="You: 49%, Author: 49%, Boris: 2%"
|
||||||
</button>
|
>
|
||||||
<button
|
Default
|
||||||
onClick={() => applyPreset(presets.generous)}
|
</button>
|
||||||
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
|
<button
|
||||||
title="You: 6%, Author: 83%, Boris: 11%"
|
onClick={() => applyPreset(presets.generous)}
|
||||||
>
|
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
|
||||||
Generous
|
title="You: 6%, Author: 83%, Boris: 11%"
|
||||||
</button>
|
>
|
||||||
<button
|
Generous
|
||||||
onClick={() => applyPreset(presets.selfless)}
|
</button>
|
||||||
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
|
<button
|
||||||
title="You: 1%, Author: 80%, Boris: 19%"
|
onClick={() => applyPreset(presets.selfless)}
|
||||||
>
|
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
|
||||||
Selfless
|
title="You: 1%, Author: 80%, Boris: 19%"
|
||||||
</button>
|
>
|
||||||
<button
|
Selfless
|
||||||
onClick={() => applyPreset(presets.boris)}
|
</button>
|
||||||
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
|
<button
|
||||||
title="You: 10%, Author: 10%, Boris: 80%"
|
onClick={() => applyPreset(presets.boris)}
|
||||||
>
|
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
|
||||||
Boris 🧡
|
title="You: 10%, Author: 10%, Boris: 80%"
|
||||||
</button>
|
>
|
||||||
</div>
|
Boris 🧡
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
<div className="setting-group">
|
|
||||||
<label className="setting-label">Your Share</label>
|
|
||||||
<div className="zap-split-container">
|
|
||||||
<div className="zap-split-labels">
|
|
||||||
<span className="zap-split-label">Weight: {highlighterWeight}</span>
|
|
||||||
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="range"
|
<div className="setting-group">
|
||||||
min="0"
|
<div className="zap-split-container">
|
||||||
max="100"
|
<div className="zap-split-labels">
|
||||||
value={highlighterWeight}
|
<span className="zap-split-label">Your Share: {highlighterWeight}</span>
|
||||||
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
||||||
className="zap-split-slider"
|
</div>
|
||||||
/>
|
<input
|
||||||
</div>
|
type="range"
|
||||||
</div>
|
min="0"
|
||||||
|
max="100"
|
||||||
<div className="setting-group">
|
value={highlighterWeight}
|
||||||
<label className="setting-label">Author(s) Share</label>
|
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
||||||
<div className="zap-split-container">
|
className="zap-split-slider"
|
||||||
<div className="zap-split-labels">
|
list="highlighter-ticks"
|
||||||
<span className="zap-split-label">Weight: {authorWeight}</span>
|
/>
|
||||||
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
<datalist id="highlighter-ticks">
|
||||||
|
<option value="50" label="50%"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={authorWeight}
|
|
||||||
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
|
||||||
className="zap-split-slider"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label className="setting-label">Support Boris</label>
|
<div className="zap-split-container">
|
||||||
<div className="zap-split-container">
|
<div className="zap-split-labels">
|
||||||
<div className="zap-split-labels">
|
<span className="zap-split-label">Author's Share: {authorWeight}</span>
|
||||||
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
|
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
||||||
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={authorWeight}
|
||||||
|
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
||||||
|
className="zap-split-slider"
|
||||||
|
list="author-ticks"
|
||||||
|
/>
|
||||||
|
<datalist id="author-ticks">
|
||||||
|
<option value="50" label="50%"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="10"
|
|
||||||
step="0.1"
|
|
||||||
value={borisWeight}
|
|
||||||
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
|
||||||
className="zap-split-slider"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="zap-split-description">
|
<div className="setting-group">
|
||||||
Weights determine zap splits when highlighting nostr-native content.
|
<div className="zap-split-container">
|
||||||
If the content has multiple authors, their share is divided proportionally.
|
<div className="zap-split-labels">
|
||||||
|
<span className="zap-split-label">Boris' Share: {borisWeight.toFixed(1)}</span>
|
||||||
|
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="0.1"
|
||||||
|
value={borisWeight}
|
||||||
|
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
||||||
|
className="zap-split-slider"
|
||||||
|
list="boris-ticks"
|
||||||
|
/>
|
||||||
|
<datalist id="boris-ticks">
|
||||||
|
<option value="5" label="5"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
Weights determine zap splits when highlighting nostr-native content.
|
||||||
|
If the content has multiple authors, their share is divided proportionally.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<img
|
||||||
|
src="/zaps.svg"
|
||||||
|
alt="Zap Splits"
|
||||||
|
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
99
src/components/ShareTargetHandler.tsx
Normal file
99
src/components/ShareTargetHandler.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import { useToast } from '../hooks/useToast'
|
||||||
|
|
||||||
|
interface ShareTargetHandlerProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming shared URLs from the Web Share Target API.
|
||||||
|
* Auto-saves the shared URL as a web bookmark (NIP-B0).
|
||||||
|
*/
|
||||||
|
export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
const [waitingForLogin, setWaitingForLogin] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSharedContent = async () => {
|
||||||
|
// Parse query parameters
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const link = params.get('link')
|
||||||
|
const title = params.get('title')
|
||||||
|
const text = params.get('text')
|
||||||
|
|
||||||
|
// Validate we have a URL
|
||||||
|
if (!link) {
|
||||||
|
showToast('No URL to save')
|
||||||
|
navigate('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no active account, wait for login
|
||||||
|
if (!activeAccount) {
|
||||||
|
setWaitingForLogin(true)
|
||||||
|
showToast('Please log in to save this bookmark')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have account and URL, proceed with saving
|
||||||
|
if (!processing) {
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
await createWebBookmark(
|
||||||
|
link,
|
||||||
|
title || undefined,
|
||||||
|
text || undefined,
|
||||||
|
undefined,
|
||||||
|
activeAccount,
|
||||||
|
relayPool,
|
||||||
|
getActiveRelayUrls(relayPool)
|
||||||
|
)
|
||||||
|
showToast('Bookmark saved!')
|
||||||
|
navigate('/my/links')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save shared bookmark:', err)
|
||||||
|
showToast('Failed to save bookmark')
|
||||||
|
navigate('/')
|
||||||
|
} finally {
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSharedContent()
|
||||||
|
}, [activeAccount, location.search, navigate, relayPool, showToast, processing])
|
||||||
|
|
||||||
|
// Show waiting for login state
|
||||||
|
if (waitingForLogin && !activeAccount) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
|
||||||
|
<p className="text-lg">Waiting for login...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show processing state
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
|
||||||
|
<p className="text-lg">Saving bookmark...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,46 +1,28 @@
|
|||||||
import React, { useState } 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, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes, faBolt } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking, faHighlighter, faBookmark, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { Accounts } from 'applesauce-accounts'
|
|
||||||
import { RelayPool } from 'applesauce-relay'
|
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import AddBookmarkModal from './AddBookmarkModal'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { createWebBookmark } from '../services/webBookmarkService'
|
import { preloadImage } from '../hooks/useImageCache'
|
||||||
import { RELAYS } from '../config/relays'
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
relayPool: RelayPool | null
|
|
||||||
isMobile?: boolean
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
|
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
|
||||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||||
|
const [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||||
const handleLogin = async () => {
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
try {
|
|
||||||
setIsConnecting(true)
|
|
||||||
const account = await Accounts.ExtensionAccount.fromExtension()
|
|
||||||
accountManager.addAccount(account)
|
|
||||||
accountManager.setActive(account)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error)
|
|
||||||
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
|
|
||||||
} finally {
|
|
||||||
setIsConnecting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getProfileImage = () => {
|
const getProfileImage = () => {
|
||||||
return profile?.picture || null
|
return profile?.picture || null
|
||||||
@@ -48,123 +30,161 @@ 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 handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
|
||||||
if (!activeAccount || !relayPool) {
|
|
||||||
throw new Error('Please login to create bookmarks')
|
|
||||||
}
|
|
||||||
|
|
||||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
||||||
<div
|
<IconButton
|
||||||
className="profile-avatar"
|
icon={faPersonHiking}
|
||||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
onClick={() => {
|
||||||
onClick={
|
if (isMobile) {
|
||||||
activeAccount
|
onToggleCollapse()
|
||||||
? () => navigate('/me')
|
}
|
||||||
: (isConnecting ? () => {} : handleLogin)
|
navigate('/explore')
|
||||||
}
|
}}
|
||||||
style={{ cursor: 'pointer' }}
|
title="Explore"
|
||||||
>
|
ariaLabel="Explore"
|
||||||
{profileImage ? (
|
variant="ghost"
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
/>
|
||||||
) : (
|
<IconButton
|
||||||
<FontAwesomeIcon icon={faUserCircle} />
|
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>
|
||||||
<IconButton
|
|
||||||
icon={faHome}
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
title="Home"
|
|
||||||
ariaLabel="Home"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faNewspaper}
|
|
||||||
onClick={() => navigate('/explore')}
|
|
||||||
title="Explore"
|
|
||||||
ariaLabel="Explore"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faBolt}
|
|
||||||
onClick={() => navigate('/support')}
|
|
||||||
title="Support"
|
|
||||||
ariaLabel="Support"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faGear}
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
title="Settings"
|
|
||||||
ariaLabel="Settings"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
{activeAccount && (
|
|
||||||
<IconButton
|
|
||||||
icon={faPlus}
|
|
||||||
onClick={() => setShowAddModal(true)}
|
|
||||||
title="Add bookmark"
|
|
||||||
ariaLabel="Add bookmark"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeAccount ? (
|
|
||||||
<IconButton
|
|
||||||
icon={faRightFromBracket}
|
|
||||||
onClick={onLogout}
|
|
||||||
title="Logout"
|
|
||||||
ariaLabel="Logout"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
icon={faRightToBracket}
|
|
||||||
onClick={isConnecting ? () => {} : handleLogin}
|
|
||||||
title={isConnecting ? "Connecting..." : "Login"}
|
|
||||||
ariaLabel="Login"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{showAddModal && (
|
|
||||||
<AddBookmarkModal
|
|
||||||
onClose={() => setShowAddModal(false)}
|
|
||||||
onSave={handleSaveBookmark}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { faBolt, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
import { faHeart, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
import { 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)
|
||||||
@@ -207,7 +226,7 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
|
|||||||
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
|
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
|
||||||
style={{ borderColor: 'var(--color-bg)' }}
|
style={{ borderColor: 'var(--color-bg)' }}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBolt} className="text-zinc-900 text-sm" />
|
<FontAwesomeIcon icon={faHeart} className="text-zinc-900 text-sm" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -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}
|
||||||
@@ -324,6 +341,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
loading={props.bookmarksLoading}
|
loading={props.bookmarksLoading}
|
||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -357,40 +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.classifiedHighlights}
|
image={props.readerContent?.image}
|
||||||
showHighlights={props.showHighlights}
|
summary={props.readerContent?.summary}
|
||||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
published={props.readerContent?.published}
|
||||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
settings={props.settings}
|
||||||
onHighlightClick={props.onHighlightClick}
|
relayPool={props.relayPool}
|
||||||
selectedHighlightId={props.selectedHighlightId}
|
activeAccount={props.activeAccount}
|
||||||
highlightVisibility={props.highlightVisibility}
|
onOpenHighlights={() => {
|
||||||
onTextSelection={props.onTextSelection}
|
if (props.isHighlightsCollapsed) {
|
||||||
onClearSelection={props.onClearSelection}
|
props.onToggleHighlightsPanel()
|
||||||
currentUserPubkey={props.currentUserPubkey}
|
}
|
||||||
followedPubkeys={props.followedPubkeys}
|
}}
|
||||||
settings={props.settings}
|
/>
|
||||||
relayPool={props.relayPool}
|
)
|
||||||
activeAccount={props.activeAccount}
|
}
|
||||||
currentArticle={props.currentArticle}
|
|
||||||
isSidebarCollapsed={props.isCollapsed}
|
return (
|
||||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
<ContentPanel
|
||||||
/>
|
loading={props.readerLoading}
|
||||||
)}
|
title={props.readerContent?.title}
|
||||||
|
html={props.readerContent?.html}
|
||||||
|
markdown={props.readerContent?.markdown}
|
||||||
|
image={props.readerContent?.image}
|
||||||
|
summary={props.readerContent?.summary}
|
||||||
|
published={props.readerContent?.published}
|
||||||
|
selectedUrl={props.selectedUrl}
|
||||||
|
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||||
|
? props.highlights // article-specific highlights only
|
||||||
|
: props.classifiedHighlights}
|
||||||
|
showHighlights={props.showHighlights}
|
||||||
|
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||||
|
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||||
|
onHighlightClick={props.onHighlightClick}
|
||||||
|
selectedHighlightId={props.selectedHighlightId}
|
||||||
|
highlightVisibility={props.highlightVisibility}
|
||||||
|
onTextSelection={props.onTextSelection}
|
||||||
|
onClearSelection={props.onClearSelection}
|
||||||
|
currentUserPubkey={props.currentUserPubkey}
|
||||||
|
followedPubkeys={props.followedPubkeys}
|
||||||
|
settings={props.settings}
|
||||||
|
relayPool={props.relayPool}
|
||||||
|
activeAccount={props.activeAccount}
|
||||||
|
currentArticle={props.currentArticle}
|
||||||
|
isSidebarCollapsed={props.isCollapsed}
|
||||||
|
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||||
|
onOpenHighlights={() => {
|
||||||
|
if (props.isHighlightsCollapsed) {
|
||||||
|
props.onToggleHighlightsPanel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<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}
|
||||||
@@ -410,10 +461,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
eventStore={props.eventStore}
|
eventStore={props.eventStore}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{props.hasActiveAccount && (
|
{props.hasActiveAccount && props.readerContent && (
|
||||||
<HighlightButton
|
<HighlightButton
|
||||||
ref={props.highlightButtonRef}
|
ref={props.highlightButtonRef}
|
||||||
onHighlight={props.onCreateHighlight}
|
onHighlight={props.onCreateHighlight}
|
||||||
|
|||||||
32
src/components/VersionFooter.tsx
Normal file
32
src/components/VersionFooter.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* global __APP_VERSION__, __GIT_COMMIT__, __GIT_COMMIT_URL__, __RELEASE_URL__ */
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const VersionFooter: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
|
||||||
|
<span>
|
||||||
|
{typeof __RELEASE_URL__ !== 'undefined' && __RELEASE_URL__ ? (
|
||||||
|
<a href={__RELEASE_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
`Version ${typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
|
||||||
|
<span>
|
||||||
|
{' '}·{' '}
|
||||||
|
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
|
||||||
|
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VersionFooter
|
||||||
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)
|
||||||
|
}
|
||||||
22
src/config/kinds.ts
Normal file
22
src/config/kinds.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Nostr event kinds used throughout the application
|
||||||
|
export const KINDS = {
|
||||||
|
Highlights: 9802, // NIP-84 user highlights
|
||||||
|
BlogPost: 30023, // NIP-23 long-form article
|
||||||
|
AppData: 30078, // NIP-78 application data
|
||||||
|
ReadingProgress: 39802, // NIP-85 reading progress
|
||||||
|
List: 30001, // NIP-51 list (addressable)
|
||||||
|
ListReplaceable: 30003, // NIP-51 replaceable list
|
||||||
|
ListSimple: 10003, // NIP-51 simple list
|
||||||
|
WebBookmark: 39701, // NIP-B0 web bookmark
|
||||||
|
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
|
||||||
|
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type KindValue = typeof KINDS[keyof typeof KINDS]
|
||||||
|
|
||||||
|
// Reading progress tracking configuration
|
||||||
|
export const READING_PROGRESS = {
|
||||||
|
// Minimum character count to track reading progress (roughly 150 words)
|
||||||
|
MIN_CONTENT_LENGTH: 1000
|
||||||
|
} as const
|
||||||
|
|
||||||
@@ -2,20 +2,21 @@
|
|||||||
* Nostr gateway URLs for viewing events and profiles on the web
|
* Nostr gateway URLs for viewing events and profiles on the web
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const NOSTR_GATEWAY = 'https://ants.sh' as const
|
export const NOSTR_GATEWAY = 'https://njump.to' as const
|
||||||
|
export const SEARCH_PORTAL = 'https://ants.sh' as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a profile URL on the gateway
|
* Get a profile URL on the gateway
|
||||||
*/
|
*/
|
||||||
export function getProfileUrl(npub: string): string {
|
export function getProfileUrl(npub: string): string {
|
||||||
return `${NOSTR_GATEWAY}/p/${npub}`
|
return `${NOSTR_GATEWAY}/${npub}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an event URL on the gateway
|
* Get an event URL on the gateway
|
||||||
*/
|
*/
|
||||||
export function getEventUrl(nevent: string): string {
|
export function getEventUrl(nevent: string): string {
|
||||||
return `${NOSTR_GATEWAY}/e/${nevent}`
|
return `${NOSTR_GATEWAY}/${nevent}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,12 +24,14 @@ export function getEventUrl(nevent: string): string {
|
|||||||
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
* 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 {
|
||||||
// Check the prefix to determine if it's a profile or event
|
// njump.to uses simple /{identifier} format for all types
|
||||||
if (identifier.startsWith('npub') || identifier.startsWith('nprofile')) {
|
return `${NOSTR_GATEWAY}/${identifier}`
|
||||||
return `${NOSTR_GATEWAY}/p/${identifier}`
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
// Everything else (note, nevent, naddr) goes to /e/
|
* Get a search portal URL with a query
|
||||||
return `${NOSTR_GATEWAY}/e/${identifier}`
|
*/
|
||||||
|
export function getSearchUrl(query: string): string {
|
||||||
|
return `${SEARCH_PORTAL}/?q=${encodeURIComponent(query)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +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.damus.io',
|
roles: RelayRole[]
|
||||||
'wss://nos.lol',
|
}
|
||||||
'wss://relay.nostr.band',
|
|
||||||
'wss://relay.dergigi.com',
|
/**
|
||||||
'wss://wot.dergigi.com',
|
* Central relay registry with role annotations
|
||||||
'wss://relay.snort.social',
|
*/
|
||||||
'wss://relay.current.fyi',
|
const RELAY_CONFIGS: RelayConfig[] = [
|
||||||
'wss://nostr-pub.wellorder.net',
|
{ url: 'ws://localhost:10547', roles: ['local-cache'] },
|
||||||
'wss://purplepag.es',
|
{ url: 'ws://localhost:4869', roles: ['local-cache'] },
|
||||||
'wss://relay.primal.net',
|
{ url: 'wss://relay.nsec.app', roles: ['default', 'non-content'] },
|
||||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
|
{ url: 'wss://relay.damus.io', roles: ['default', 'fallback'] },
|
||||||
|
{ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
83
src/hooks/useAdaptiveTextColor.ts
Normal file
83
src/hooks/useAdaptiveTextColor.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FastAverageColor } from 'fast-average-color'
|
||||||
|
|
||||||
|
interface AdaptiveTextColor {
|
||||||
|
textColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to determine optimal text color based on image background
|
||||||
|
* Samples the top-right corner of the image to ensure publication date is readable
|
||||||
|
*
|
||||||
|
* @param imageUrl - The URL of the image to analyze
|
||||||
|
* @returns Object containing textColor for optimal contrast
|
||||||
|
*/
|
||||||
|
export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveTextColor {
|
||||||
|
const [colors, setColors] = useState<AdaptiveTextColor>({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageUrl) {
|
||||||
|
// No image, use default white text
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fac = new FastAverageColor()
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const width = img.naturalWidth
|
||||||
|
const height = img.naturalHeight
|
||||||
|
|
||||||
|
// Sample top-right corner (last 25% width, first 25% height)
|
||||||
|
const color = fac.getColor(img, {
|
||||||
|
left: Math.floor(width * 0.75),
|
||||||
|
top: 0,
|
||||||
|
width: Math.floor(width * 0.25),
|
||||||
|
height: Math.floor(height * 0.25)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Color analysis complete
|
||||||
|
|
||||||
|
// Use library's built-in isLight check for optimal contrast
|
||||||
|
if (color.isLight) {
|
||||||
|
setColors({
|
||||||
|
textColor: '#000000'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to default on error
|
||||||
|
console.error('Error analyzing image color:', error)
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
// Fallback to default if image fails to load
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
img.src = imageUrl
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
fac.destroy()
|
||||||
|
}
|
||||||
|
}, [imageUrl])
|
||||||
|
|
||||||
|
return colors
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,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,79 +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)
|
||||||
|
|
||||||
console.log('📰 Article loaded:', article.title)
|
|
||||||
console.log('📍 Coordinate:', articleCoordinate)
|
|
||||||
|
|
||||||
// Set reader loading to false immediately after article content is ready
|
|
||||||
// Don't wait for highlights to finish loading
|
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
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)
|
||||||
console.log(`📌 Found ${highlightsMap.size} highlights`)
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +1,195 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IAccount, AccountManager } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
|
||||||
import { fetchContacts } from '../services/contactService'
|
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { highlightsController } from '../services/highlightsController'
|
||||||
|
import { contactsController } from '../services/contactsController'
|
||||||
|
import { useStoreTimeline } from './useStoreTimeline'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
interface UseBookmarksDataParams {
|
interface UseBookmarksDataParams {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
activeAccount: IAccount | undefined
|
activeAccount: IAccount | undefined
|
||||||
accountManager: AccountManager
|
|
||||||
naddr?: string
|
naddr?: string
|
||||||
|
externalUrl?: string
|
||||||
currentArticleCoordinate?: string
|
currentArticleCoordinate?: string
|
||||||
currentArticleEventId?: string
|
currentArticleEventId?: string
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
eventStore?: IEventStore | null
|
||||||
|
bookmarks: Bookmark[] // Passed from App.tsx (centralized loading)
|
||||||
|
bookmarksLoading: boolean // Passed from App.tsx (centralized loading)
|
||||||
|
onRefreshBookmarks: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBookmarksData = ({
|
export const useBookmarksData = ({
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
|
||||||
naddr,
|
naddr,
|
||||||
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings,
|
||||||
}: UseBookmarksDataParams) => {
|
eventStore,
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
onRefreshBookmarks
|
||||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||||
|
const [articleHighlights, setArticleHighlights] = useState<Highlight[]>([])
|
||||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||||
|
|
||||||
const handleFetchContacts = useCallback(async () => {
|
// Determine effective article coordinate as early as possible
|
||||||
if (!relayPool || !activeAccount) return
|
// Prefer state-derived coordinate, but fall back to route naddr before content loads
|
||||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
const effectiveArticleCoordinate = useMemo(() => {
|
||||||
setFollowedPubkeys(contacts)
|
if (currentArticleCoordinate) return currentArticleCoordinate
|
||||||
}, [relayPool, activeAccount])
|
if (!naddr) return undefined
|
||||||
|
|
||||||
const handleFetchBookmarks = useCallback(async () => {
|
|
||||||
if (!relayPool || !activeAccount) return
|
|
||||||
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
|
|
||||||
setBookmarksLoading(true)
|
|
||||||
try {
|
try {
|
||||||
const fullAccount = accountManager.getActive()
|
const decoded = nip19.decode(naddr)
|
||||||
// merge-friendly: updater form that preserves visible list until replacement
|
if (decoded.type === 'naddr') {
|
||||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
|
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||||
setBookmarks(() => next)
|
return `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||||
}, settings)
|
}
|
||||||
} finally {
|
} catch {
|
||||||
setBookmarksLoading(false)
|
// ignore decode failure; treat as no coordinate yet
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, accountManager, settings])
|
return undefined
|
||||||
|
}, [currentArticleCoordinate, naddr])
|
||||||
|
|
||||||
|
// Load cached article-specific highlights from event store
|
||||||
|
const articleFilter = useMemo(() => {
|
||||||
|
if (!effectiveArticleCoordinate) return null
|
||||||
|
return {
|
||||||
|
kinds: [KINDS.Highlights],
|
||||||
|
'#a': [effectiveArticleCoordinate],
|
||||||
|
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
|
||||||
|
}
|
||||||
|
}, [effectiveArticleCoordinate, currentArticleEventId])
|
||||||
|
|
||||||
|
const cachedArticleHighlights = useStoreTimeline(
|
||||||
|
eventStore || null,
|
||||||
|
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
|
||||||
|
eventToHighlight,
|
||||||
|
[effectiveArticleCoordinate, currentArticleEventId]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe to centralized controllers
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
setMyHighlights(highlightsController.getHighlights())
|
||||||
|
setFollowedPubkeys(new Set(contactsController.getContacts()))
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||||
|
const unsubContacts = contactsController.onContacts((contacts) => {
|
||||||
|
setFollowedPubkeys(new Set(contacts))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubHighlights()
|
||||||
|
unsubContacts()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleFetchHighlights = useCallback(async () => {
|
const handleFetchHighlights = useCallback(async () => {
|
||||||
if (!relayPool) return
|
if (!relayPool) return
|
||||||
|
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
try {
|
try {
|
||||||
if (currentArticleCoordinate) {
|
if (effectiveArticleCoordinate) {
|
||||||
|
// Seed with cached highlights first
|
||||||
|
if (cachedArticleHighlights.length > 0) {
|
||||||
|
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh article-specific highlights (from all users)
|
||||||
const highlightsMap = new Map<string, Highlight>()
|
const highlightsMap = new Map<string, Highlight>()
|
||||||
|
// Seed map with cached highlights
|
||||||
|
cachedArticleHighlights.forEach(h => highlightsMap.set(h.id, h))
|
||||||
|
|
||||||
await fetchHighlightsForArticle(
|
await fetchHighlightsForArticle(
|
||||||
relayPool,
|
relayPool,
|
||||||
currentArticleCoordinate,
|
effectiveArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
(highlight) => {
|
(highlight) => {
|
||||||
// Deduplicate highlights by ID as they arrive
|
// Deduplicate highlights by ID as they arrive
|
||||||
if (!highlightsMap.has(highlight.id)) {
|
if (!highlightsMap.has(highlight.id)) {
|
||||||
highlightsMap.set(highlight.id, highlight)
|
highlightsMap.set(highlight.id, highlight)
|
||||||
const highlightsList = Array.from(highlightsMap.values())
|
const highlightsList = Array.from(highlightsMap.values())
|
||||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
setArticleHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
settings
|
settings,
|
||||||
|
false, // force
|
||||||
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
|
} else {
|
||||||
} else if (activeAccount) {
|
// No article selected - clear article highlights
|
||||||
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
|
setArticleHighlights([])
|
||||||
setHighlights(fetchedHighlights)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setHighlightsLoading(false)
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
|
}, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
|
||||||
|
|
||||||
const handleRefreshAll = useCallback(async () => {
|
const handleRefreshAll = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount || isRefreshing) return
|
if (!relayPool || !activeAccount || isRefreshing) return
|
||||||
|
|
||||||
setIsRefreshing(true)
|
setIsRefreshing(true)
|
||||||
try {
|
try {
|
||||||
await handleFetchBookmarks()
|
await onRefreshBookmarks()
|
||||||
await handleFetchHighlights()
|
await handleFetchHighlights()
|
||||||
await handleFetchContacts()
|
// Contacts and own highlights are managed by controllers
|
||||||
setLastFetchTime(Date.now())
|
setLastFetchTime(Date.now())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to refresh data:', err)
|
console.error('Failed to refresh data:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights])
|
||||||
|
|
||||||
// Load initial data (avoid clearing on route-only changes)
|
// Fetch article-specific highlights when viewing an article
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) {
|
||||||
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
|
setHighlightsLoading(false)
|
||||||
handleFetchBookmarks()
|
return
|
||||||
}, [relayPool, activeAccount, handleFetchBookmarks])
|
|
||||||
|
|
||||||
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
|
||||||
useEffect(() => {
|
|
||||||
if (!relayPool || !activeAccount) return
|
|
||||||
if (!naddr) {
|
|
||||||
handleFetchHighlights()
|
|
||||||
}
|
}
|
||||||
handleFetchContacts()
|
// Fetch article-specific highlights when viewing an article
|
||||||
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
|
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||||
|
if (effectiveArticleCoordinate && !externalUrl) {
|
||||||
|
handleFetchHighlights()
|
||||||
|
} else if (!naddr && !externalUrl) {
|
||||||
|
// Clear article highlights when not viewing an article
|
||||||
|
setArticleHighlights([])
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
} else {
|
||||||
|
// For external URLs or other cases, loading is not needed
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||||
|
|
||||||
|
// When viewing an article, show only article-specific highlights
|
||||||
|
// Otherwise, show user's highlights from controller
|
||||||
|
const highlights = effectiveArticleCoordinate || externalUrl
|
||||||
|
? articleHighlights.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
: myHighlights
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookmarks,
|
|
||||||
bookmarksLoading,
|
|
||||||
highlights,
|
highlights,
|
||||||
setHighlights,
|
setHighlights: setArticleHighlights, // For external updates (like from useExternalUrlLoader)
|
||||||
highlightsLoading,
|
highlightsLoading,
|
||||||
setHighlightsLoading,
|
setHighlightsLoading,
|
||||||
followedPubkeys,
|
followedPubkeys,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
lastFetchTime,
|
lastFetchTime,
|
||||||
handleFetchBookmarks,
|
|
||||||
handleFetchHighlights,
|
handleFetchHighlights,
|
||||||
handleRefreshAll
|
handleRefreshAll
|
||||||
}
|
}
|
||||||
|
|||||||
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,8 +1,13 @@
|
|||||||
import { useEffect } 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 { fetchReadableContent, ReadableContent } from '../services/readerService'
|
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { useStoreTimeline } from './useStoreTimeline'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
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 {
|
||||||
@@ -20,6 +25,7 @@ function getFilenameFromUrl(url: string): string {
|
|||||||
interface UseExternalUrlLoaderProps {
|
interface UseExternalUrlLoaderProps {
|
||||||
url: string | undefined
|
url: string | undefined
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
|
eventStore?: IEventStore | null
|
||||||
setSelectedUrl: (url: string) => void
|
setSelectedUrl: (url: string) => void
|
||||||
setReaderContent: (content: ReadableContent | undefined) => void
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
setReaderLoading: (loading: boolean) => void
|
setReaderLoading: (loading: boolean) => void
|
||||||
@@ -33,6 +39,7 @@ interface UseExternalUrlLoaderProps {
|
|||||||
export function useExternalUrlLoader({
|
export function useExternalUrlLoader({
|
||||||
url,
|
url,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
@@ -42,73 +49,141 @@ 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
|
||||||
|
const urlFilter = useMemo(() => {
|
||||||
|
if (!url) return null
|
||||||
|
return { kinds: [KINDS.Highlights], '#r': [url] }
|
||||||
|
}, [url])
|
||||||
|
|
||||||
|
const cachedUrlHighlights = useStoreTimeline(
|
||||||
|
eventStore || null,
|
||||||
|
urlFilter || { kinds: [KINDS.Highlights], limit: 0 },
|
||||||
|
eventToHighlight,
|
||||||
|
[url]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load content and start streaming highlights when URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
|
||||||
if (!relayPool || !url) return
|
if (!relayPool || !url) return
|
||||||
|
|
||||||
const loadExternalUrl = async () => {
|
const loadExternalUrl = async () => {
|
||||||
|
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)
|
||||||
|
|
||||||
console.log('🌐 External URL loaded:', content.title)
|
|
||||||
|
|
||||||
// Set reader loading to false immediately after content is ready
|
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|
||||||
// Fetch highlights for this URL asynchronously
|
// Fetch highlights for this URL asynchronously
|
||||||
try {
|
try {
|
||||||
setHighlightsLoading(true)
|
if (!mountedRef.current) return
|
||||||
setHighlights([])
|
|
||||||
|
setHighlightsLoading(true)
|
||||||
|
|
||||||
|
// Seed with cached highlights first
|
||||||
|
if (cachedUrlHighlights.length > 0) {
|
||||||
|
setHighlights((prev) => {
|
||||||
|
const seen = new Set<string>(cachedUrlHighlights.map(h => h.id))
|
||||||
|
const localOnly = prev.filter(h => !seen.has(h.id))
|
||||||
|
const next = [...cachedUrlHighlights, ...localOnly]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setHighlights([])
|
||||||
|
}
|
||||||
|
|
||||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
|
||||||
if (typeof fetchHighlightsForUrl === 'function') {
|
if (typeof fetchHighlightsForUrl === 'function') {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const highlightsList = await fetchHighlightsForUrl(
|
cachedUrlHighlights.forEach(h => seen.add(h.id))
|
||||||
|
|
||||||
|
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,
|
||||||
|
false,
|
||||||
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
// Ensure final list is sorted and contains all items
|
|
||||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
|
||||||
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
|
||||||
} else {
|
|
||||||
console.log('📌 Highlight fetching for URLs not yet implemented')
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
|
|
||||||
|
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 => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ReadableContent } from '../services/readerService'
|
|||||||
import { createHighlight } from '../services/highlightCreationService'
|
import { createHighlight } from '../services/highlightCreationService'
|
||||||
import { HighlightButtonRef } from '../components/HighlightButton'
|
import { HighlightButtonRef } from '../components/HighlightButton'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
interface UseHighlightCreationParams {
|
interface UseHighlightCreationParams {
|
||||||
activeAccount: IAccount | undefined
|
activeAccount: IAccount | undefined
|
||||||
@@ -32,6 +33,7 @@ export const useHighlightCreation = ({
|
|||||||
settings
|
settings
|
||||||
}: UseHighlightCreationParams) => {
|
}: UseHighlightCreationParams) => {
|
||||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
const handleTextSelection = useCallback((text: string) => {
|
const handleTextSelection = useCallback((text: string) => {
|
||||||
highlightButtonRef.current?.updateSelection(text)
|
highlightButtonRef.current?.updateSelection(text)
|
||||||
@@ -42,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
|
||||||
@@ -58,8 +61,6 @@ export const useHighlightCreation = ({
|
|||||||
? currentArticle.content
|
? currentArticle.content
|
||||||
: readerContent?.markdown || readerContent?.html
|
: readerContent?.markdown || readerContent?.html
|
||||||
|
|
||||||
console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
|
|
||||||
|
|
||||||
const newHighlight = await createHighlight(
|
const newHighlight = await createHighlight(
|
||||||
text,
|
text,
|
||||||
source,
|
source,
|
||||||
@@ -71,13 +72,7 @@ export const useHighlightCreation = ({
|
|||||||
settings
|
settings
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('✅ Highlight created successfully!', {
|
// Highlight created successfully
|
||||||
id: newHighlight.id,
|
|
||||||
isLocalOnly: newHighlight.isLocalOnly,
|
|
||||||
isOfflineCreated: newHighlight.isOfflineCreated,
|
|
||||||
publishedRelays: newHighlight.publishedRelays
|
|
||||||
})
|
|
||||||
|
|
||||||
// Clear the browser's text selection immediately to allow DOM update
|
// Clear the browser's text selection immediately to allow DOM update
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection) {
|
if (selection) {
|
||||||
@@ -92,10 +87,19 @@ export const useHighlightCreation = ({
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to create highlight:', error)
|
console.error('❌ Failed to create highlight:', error)
|
||||||
|
|
||||||
|
// Show user-friendly error messages
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create highlight'
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
showToast('Reconnect bunker and approve signing permissions to create highlights')
|
||||||
|
} else {
|
||||||
|
showToast(`Failed to create highlight: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Re-throw to allow parent to handle
|
// Re-throw to allow parent to handle
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
|
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings, showToast])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
highlightButtonRef,
|
highlightButtonRef,
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ export const useHighlightedContent = ({
|
|||||||
}: UseHighlightedContentParams) => {
|
}: UseHighlightedContentParams) => {
|
||||||
// Filter highlights by URL and visibility settings
|
// Filter highlights by URL and visibility settings
|
||||||
const relevantHighlights = useMemo(() => {
|
const relevantHighlights = useMemo(() => {
|
||||||
console.log('🔍 ContentPanel: Processing highlights', {
|
|
||||||
totalHighlights: highlights.length,
|
|
||||||
selectedUrl,
|
|
||||||
showHighlights
|
|
||||||
})
|
|
||||||
|
|
||||||
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
||||||
console.log('📌 URL filtered highlights:', urlFiltered.length)
|
|
||||||
|
|
||||||
// Apply visibility filtering
|
// Apply visibility filtering
|
||||||
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
|
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
|
||||||
@@ -49,37 +42,25 @@ export const useHighlightedContent = ({
|
|||||||
return highlightVisibility.nostrverse
|
return highlightVisibility.nostrverse
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
|
|
||||||
return filtered
|
return filtered
|
||||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
|
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||||
|
|
||||||
// Prepare the final HTML with highlights applied
|
// Prepare the final HTML with highlights applied
|
||||||
const finalHtml = useMemo(() => {
|
const finalHtml = useMemo(() => {
|
||||||
const sourceHtml = markdown ? renderedMarkdownHtml : html
|
const sourceHtml = markdown ? renderedMarkdownHtml : html
|
||||||
|
|
||||||
console.log('🎨 Preparing final HTML:', {
|
// Prepare final HTML
|
||||||
hasMarkdown: !!markdown,
|
|
||||||
hasHtml: !!html,
|
|
||||||
renderedHtmlLength: renderedMarkdownHtml.length,
|
|
||||||
sourceHtmlLength: sourceHtml?.length || 0,
|
|
||||||
showHighlights,
|
|
||||||
relevantHighlightsCount: relevantHighlights.length
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!sourceHtml) {
|
if (!sourceHtml) {
|
||||||
console.warn('⚠️ No source HTML available')
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showHighlights && relevantHighlights.length > 0) {
|
if (showHighlights && relevantHighlights.length > 0) {
|
||||||
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
|
|
||||||
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||||
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
|
|
||||||
return highlightedHtml
|
return highlightedHtml
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📄 Returning source HTML without highlights')
|
|
||||||
return sourceHtml
|
return sourceHtml
|
||||||
|
|
||||||
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
||||||
|
|
||||||
return { finalHtml, relevantHighlights }
|
return { finalHtml, relevantHighlights }
|
||||||
|
|||||||
@@ -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,59 +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)
|
|
||||||
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
|
||||||
} 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)
|
|
||||||
|
|
||||||
console.log('📝 Converting markdown to HTML...')
|
try {
|
||||||
|
const titlesMap = await fetchArticleTitles(relayPool!, naddrs)
|
||||||
const rafId = requestAnimationFrame(() => {
|
if (!isCancelled) {
|
||||||
if (previewRef.current && !isCancelled) {
|
setArticleTitles(titlesMap)
|
||||||
const html = previewRef.current.innerHTML
|
|
||||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
|
||||||
setRenderedHtml(html)
|
|
||||||
} else if (!isCancelled) {
|
|
||||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
|
||||||
}
|
}
|
||||||
})
|
} 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()
|
||||||
@@ -78,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, [])
|
||||||
|
}
|
||||||
|
|
||||||
@@ -50,16 +50,10 @@ export function useOfflineSync({
|
|||||||
const isNowOnline = hasRemoteRelays
|
const isNowOnline = hasRemoteRelays
|
||||||
|
|
||||||
if (wasLocalOnly && isNowOnline) {
|
if (wasLocalOnly && isNowOnline) {
|
||||||
console.log('✈️ Detected transition: Flight Mode → Online')
|
// Coming back online, sync events
|
||||||
console.log('📊 Relay state:', {
|
|
||||||
connectedRelays: connectedRelays.length,
|
|
||||||
remoteRelays: connectedRelays.filter(r => !isLocalRelay(r.url)).length,
|
|
||||||
localRelays: connectedRelays.filter(r => isLocalRelay(r.url)).length
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait a moment for relays to fully establish connections
|
// Wait a moment for relays to fully establish connections
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('🚀 Starting sync after delay...')
|
|
||||||
syncLocalEventsToRemote(relayPool, eventStore)
|
syncLocalEventsToRemote(relayPool, eventStore)
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user