mirror of
https://github.com/Athou/commafeed.git
synced 2026-11-06 04:16:54 +00:00
Compare commits
1957 Commits
5.7.0
...
renovate/l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c52bc3a65 | ||
|
|
e68268aa3f | ||
|
|
5dd870c9a9 | ||
|
|
c9ed8f9bfd | ||
|
|
e38c45e4d6 | ||
|
|
6c54ae1e1e | ||
|
|
ef4fde24e3 | ||
|
|
f0fd987f4b | ||
|
|
d691095746 | ||
|
|
ae707a2602 | ||
|
|
69cf674a02 | ||
|
|
29987a460d | ||
|
|
fc8cd9c0a2 | ||
|
|
44c2893fff | ||
|
|
2e66fd4702 | ||
|
|
01ac8d6ebd | ||
|
|
ddff408afe | ||
|
|
0c280c4dc4 | ||
|
|
ca4642f7c9 | ||
|
|
93edb47dca | ||
|
|
801680e373 | ||
|
|
5704e1e1fa | ||
|
|
a2a6720cd1 | ||
|
|
4f8cddeb54 | ||
|
|
90d0846bad | ||
|
|
698b2f4dfd | ||
|
|
0053c54ae4 | ||
|
|
16b6077496 | ||
|
|
edb4d5e178 | ||
|
|
4b091fb90b | ||
|
|
30c049d10a | ||
|
|
fa8a447787 | ||
|
|
20ec5d3eb9 | ||
|
|
796dc88c33 | ||
|
|
f979f5426f | ||
|
|
8f9be28002 | ||
|
|
89845681be | ||
|
|
8fdf63dd73 | ||
|
|
8f07454270 | ||
|
|
946d82d8ee | ||
|
|
f8d625b651 | ||
|
|
eefdd40a02 | ||
|
|
08f2889f1d | ||
|
|
45453fcf4f | ||
|
|
ef7bab4841 | ||
|
|
ac277f9ca5 | ||
|
|
44dab0287b | ||
|
|
88b75ec0cb | ||
|
|
cbb45ce68c | ||
|
|
b0c4dbf533 | ||
|
|
1891b63a0e | ||
|
|
c420fec8ee | ||
|
|
2ee2a5dfbe | ||
|
|
a5b98f9ea2 | ||
|
|
baedab2960 | ||
|
|
004e18bbb8 | ||
|
|
72e364afe9 | ||
|
|
86ff2dfcb2 | ||
|
|
69ce73012c | ||
|
|
f3bd1a2e11 | ||
|
|
a819283661 | ||
|
|
8255c1e929 | ||
|
|
eb7990f8dc | ||
|
|
982476b2d0 | ||
|
|
3ec653c3b7 | ||
|
|
5dc5b3795e | ||
|
|
f5f77a80a3 | ||
|
|
1d1099492e | ||
|
|
ccc2564929 | ||
|
|
084574ffd4 | ||
|
|
6a6978c491 | ||
|
|
0399375288 | ||
|
|
394346fbc1 | ||
|
|
0feec109bd | ||
|
|
fdb5c9b536 | ||
|
|
f7ad8aed60 | ||
|
|
4621d7e79f | ||
|
|
b64a49cfb0 | ||
|
|
aaddfbaf40 | ||
|
|
994da5e1ee | ||
|
|
811e282e28 | ||
|
|
892fcf8168 | ||
|
|
032ee9243f | ||
|
|
30cdf34d56 | ||
|
|
fd52caa3fd | ||
|
|
9788712808 | ||
|
|
af9d086f05 | ||
|
|
fc7190868e | ||
|
|
d3c1892c0b | ||
|
|
1fb4cdf3ca | ||
|
|
7928183036 | ||
|
|
ffa276df9e | ||
|
|
9f05e66e9d | ||
|
|
8ba90ded85 | ||
|
|
4e5e2f06d3 | ||
|
|
72396254d2 | ||
|
|
f9313006ba | ||
|
|
c894d701ff | ||
|
|
e9d0466018 | ||
|
|
28b4f61f20 | ||
|
|
ab6b3b225d | ||
|
|
ffb4a43e38 | ||
|
|
0653e2dc97 | ||
|
|
912e03d2ce | ||
|
|
a8df765cda | ||
|
|
b865b3ff84 | ||
|
|
69c1e8a5b8 | ||
|
|
c1522f1005 | ||
|
|
8144d24b06 | ||
|
|
4c2e2efbb5 | ||
|
|
ad452c9a7a | ||
|
|
a43da1304d | ||
|
|
c2da59a92f | ||
|
|
a89166d1c4 | ||
|
|
45039593bf | ||
|
|
d56c35cdfb | ||
|
|
73836c6800 | ||
|
|
f9a0440db8 | ||
|
|
6e7a8c1ef6 | ||
|
|
44147cc062 | ||
|
|
352b756e22 | ||
|
|
a5d9eb757f | ||
|
|
a19a73d675 | ||
|
|
4a57f87284 | ||
|
|
b523fe4c58 | ||
|
|
c208450abc | ||
|
|
b73ab2a781 | ||
|
|
ec679cc23c | ||
|
|
1dbbcc4d21 | ||
|
|
1573b61584 | ||
|
|
1ac8cc7674 | ||
|
|
793db4e9b6 | ||
|
|
12863e557a | ||
|
|
d999fc1e06 | ||
|
|
6c288d160c | ||
|
|
57d5dd8b20 | ||
|
|
0a8c0e0c7f | ||
|
|
f72bd4cc5c | ||
|
|
26cce1faac | ||
|
|
3101b4f423 | ||
|
|
e8e30faf3e | ||
|
|
3f6158fc47 | ||
|
|
3465645b70 | ||
|
|
2dd397757d | ||
|
|
5496419bb4 | ||
|
|
f1b950c38e | ||
|
|
4a3e97ad52 | ||
|
|
9fe17996b4 | ||
|
|
c7a2f87148 | ||
|
|
1125dd3fe7 | ||
|
|
adf48aee44 | ||
|
|
534d8a333e | ||
|
|
03fc91c19c | ||
|
|
d2429dada2 | ||
|
|
0fae323647 | ||
|
|
fae58d56be | ||
|
|
fc56d5e7cb | ||
|
|
04c85acae0 | ||
|
|
af2fd9bc09 | ||
|
|
d4ce2a213f | ||
|
|
7d9ba805e4 | ||
|
|
4c315ca3b8 | ||
|
|
bcf812ef7a | ||
|
|
fd603aa76f | ||
|
|
e34c08c803 | ||
|
|
9252ef1531 | ||
|
|
63153ca195 | ||
|
|
a8c2f69251 | ||
|
|
91eaef05e1 | ||
|
|
4822642fd7 | ||
|
|
a712d8f900 | ||
|
|
86e507f93e | ||
|
|
86eba276c5 | ||
|
|
4f18a98a4a | ||
|
|
547eff9f06 | ||
|
|
5bd1225d3f | ||
|
|
309a0aa94c | ||
|
|
f8286a6334 | ||
|
|
916086d60e | ||
|
|
4fa0eb0a87 | ||
|
|
295eb57922 | ||
|
|
2179cf57b6 | ||
|
|
cca0f2d5db | ||
|
|
d3c3ffb88d | ||
|
|
10c7196f9f | ||
|
|
254def6152 | ||
|
|
09bae2565c | ||
|
|
5572918750 | ||
|
|
4e91db09a6 | ||
|
|
bbaee20097 | ||
|
|
7554e2b03e | ||
|
|
3baac1138f | ||
|
|
8815cef66c | ||
|
|
afa836c6df | ||
|
|
bf513508e4 | ||
|
|
a0423f301d | ||
|
|
302fa8f4dd | ||
|
|
4a75a22a6c | ||
|
|
c7d5cf1cc2 | ||
|
|
e031ca3ef4 | ||
|
|
8e0e926a68 | ||
|
|
d4ed634c4e | ||
|
|
18582f1a2d | ||
|
|
3688b128e4 | ||
|
|
4718b75f89 | ||
|
|
aa7f8f79a5 | ||
|
|
c794a6aaf8 | ||
|
|
0786a45995 | ||
|
|
66fde65550 | ||
|
|
caf56aca94 | ||
|
|
06581692e6 | ||
|
|
ca2fbd9fdd | ||
|
|
81ce6c9950 | ||
|
|
31b67f2217 | ||
|
|
0ee76d39b2 | ||
|
|
6a3f4816d5 | ||
|
|
1365754d62 | ||
|
|
ac4c98bead | ||
|
|
74306719ca | ||
|
|
cc028dd37f | ||
|
|
43184fff6d | ||
|
|
d41f94f254 | ||
|
|
fb741a4e54 | ||
|
|
4319dfd0fe | ||
|
|
a06a0e893c | ||
|
|
8517092a88 | ||
|
|
52e79dfaa7 | ||
|
|
7fb19fd032 | ||
|
|
6344657940 | ||
|
|
3f01a99b66 | ||
|
|
69d7350e89 | ||
|
|
9ee502f437 | ||
|
|
b8b876535b | ||
|
|
816edefe25 | ||
|
|
c523ff608e | ||
|
|
663f14c439 | ||
|
|
20c6ef6702 | ||
|
|
91241f045f | ||
|
|
82e5c54cdc | ||
|
|
4b17102e5a | ||
|
|
bc58324a56 | ||
|
|
b43bcf244a | ||
|
|
befe1a0bdc | ||
|
|
1aae5433f3 | ||
|
|
afeb12686b | ||
|
|
75f4b94b26 | ||
|
|
4a867904ad | ||
|
|
249b161243 | ||
|
|
a709a3268a | ||
|
|
50b8a06f63 | ||
|
|
29e6338b41 | ||
|
|
82e9fd7f2e | ||
|
|
8eb41eec53 | ||
|
|
07f96019b1 | ||
|
|
1a0d4cb8cd | ||
|
|
5ae34047ee | ||
|
|
260181ec7c | ||
|
|
21bc826eda | ||
|
|
e1d9125a5c | ||
|
|
8d1b836f59 | ||
|
|
bd50399483 | ||
|
|
d641d25392 | ||
|
|
a8c9f41fce | ||
|
|
2ff9d84584 | ||
|
|
11cd61cd44 | ||
|
|
cf43988fae | ||
|
|
4ef27e3027 | ||
|
|
5ffabb387b | ||
|
|
d78a45b7e5 | ||
|
|
bd5a9c382d | ||
|
|
e238e829ad | ||
|
|
689c20d367 | ||
|
|
43588df2b4 | ||
|
|
d2aaddea33 | ||
|
|
de233f8ae5 | ||
|
|
3179e74dc7 | ||
|
|
6f5e9f5d49 | ||
|
|
8a43fdf200 | ||
|
|
69051e5bbb | ||
|
|
c648e7e0ec | ||
|
|
c91cf376ad | ||
|
|
f4e07597d0 | ||
|
|
64b64d3340 | ||
|
|
57ea87871e | ||
|
|
6154d80bb9 | ||
|
|
ad85745f85 | ||
|
|
41fc77322c | ||
|
|
1b83c26417 | ||
|
|
10ea5f0b93 | ||
|
|
032684de68 | ||
|
|
3efec5ce58 | ||
|
|
4ed32964d9 | ||
|
|
05c1d0cc6e | ||
|
|
3c54924971 | ||
|
|
e57dfc0e3f | ||
|
|
eff8bb16a0 | ||
|
|
87e681b71e | ||
|
|
ac73ef4ea7 | ||
|
|
20945e7443 | ||
|
|
b7b1278111 | ||
|
|
6f9378404f | ||
|
|
79787aca6e | ||
|
|
2d90ccf856 | ||
|
|
7f77cf0268 | ||
|
|
f3c390e0e0 | ||
|
|
1fdc471197 | ||
|
|
ecf5d9e50e | ||
|
|
97a49e8faa | ||
|
|
f5a020cdc2 | ||
|
|
1f0507ee65 | ||
|
|
393c90f334 | ||
|
|
03ecc2f49b | ||
|
|
17743adce6 | ||
|
|
126dde103a | ||
|
|
14a25581b4 | ||
|
|
c7c95efb3a | ||
|
|
4c1293d357 | ||
|
|
76a92ea44c | ||
|
|
ba454322db | ||
|
|
a3b9f471e1 | ||
|
|
d58ddc888e | ||
|
|
fe9837c45c | ||
|
|
332d126bd7 | ||
|
|
0c66bf2695 | ||
|
|
4d6d07c98b | ||
|
|
8f7d4bfa10 | ||
|
|
7b3f39001f | ||
|
|
b9630809a0 | ||
|
|
ce3244da5e | ||
|
|
4067297d2a | ||
|
|
03c11fa181 | ||
|
|
3676e78eb9 | ||
|
|
1e08c4486f | ||
|
|
fd8bb60daa | ||
|
|
557d56ffc5 | ||
|
|
30f76a2349 | ||
|
|
cf0fa5e22e | ||
|
|
f16becbd82 | ||
|
|
4855450ddc | ||
|
|
0733aedce5 | ||
|
|
399ad14d9b | ||
|
|
7881ea3c5c | ||
|
|
7e9de622da | ||
|
|
c6655078f2 | ||
|
|
b86cef9499 | ||
|
|
5375ab6e97 | ||
|
|
75920a6473 | ||
|
|
f474e76866 | ||
|
|
d0ae27c4ce | ||
|
|
ab191dded6 | ||
|
|
950b571a9f | ||
|
|
7a153ca69a | ||
|
|
effd8946de | ||
|
|
ae3ad3745c | ||
|
|
2e1e5257b4 | ||
|
|
bc6e767953 | ||
|
|
b9eceaae73 | ||
|
|
8ad9ba7d74 | ||
|
|
10bb4b7936 | ||
|
|
ab3127cef9 | ||
|
|
5d64e2f21e | ||
|
|
e404c9ce3b | ||
|
|
0c5a71650c | ||
|
|
8ce9eb8537 | ||
|
|
1ce0e4af97 | ||
|
|
44ad42a8a5 | ||
|
|
f213726c40 | ||
|
|
972d424846 | ||
|
|
6049263137 | ||
|
|
ae2dc7f066 | ||
|
|
503d129f22 | ||
|
|
543ec3bbb1 | ||
|
|
bed45127c4 | ||
|
|
71f10b0a62 | ||
|
|
cb235289c4 | ||
|
|
0b07c65320 | ||
|
|
868f65d7c9 | ||
|
|
07715383f4 | ||
|
|
29d10debdb | ||
|
|
cd640da960 | ||
|
|
56ecb4a2f4 | ||
|
|
00161fd549 | ||
|
|
a602ce17cd | ||
|
|
672588d08b | ||
|
|
5fa2579275 | ||
|
|
09bc317797 | ||
|
|
440c1ada50 | ||
|
|
911a5ca099 | ||
|
|
65017f6b76 | ||
|
|
64fb5ef3a2 | ||
|
|
dda389a50f | ||
|
|
419ad4fb6a | ||
|
|
ebae46f06b | ||
|
|
ac25accb31 | ||
|
|
3c7eb90e50 | ||
|
|
4b3973f3fa | ||
|
|
6ddbe40c3c | ||
|
|
d5164750e4 | ||
|
|
822fbd8add | ||
|
|
3a341d0707 | ||
|
|
23884fcbda | ||
|
|
3b16674f98 | ||
|
|
d5eb3e1bb1 | ||
|
|
8d47b38fd4 | ||
|
|
04fb3282fa | ||
|
|
bcce130b82 | ||
|
|
e5d336f6ba | ||
|
|
2d741f6e1e | ||
|
|
2968dff118 | ||
|
|
0c244ec154 | ||
|
|
34cd337992 | ||
|
|
1703cb7b93 | ||
|
|
dc89faa4a1 | ||
|
|
3eccfe8ccc | ||
|
|
6e34f6831c | ||
|
|
b7ebcd6a23 | ||
|
|
8b7efe056d | ||
|
|
87cb392516 | ||
|
|
aa11cb48f2 | ||
|
|
36a29a393c | ||
|
|
99f1b45866 | ||
|
|
9e8d529af3 | ||
|
|
bb80641b40 | ||
|
|
a2ed16c6fd | ||
|
|
4f10f57837 | ||
|
|
28f40cd6fe | ||
|
|
89a84a0c83 | ||
|
|
ebcda50a34 | ||
|
|
8640e62b77 | ||
|
|
d0418eea89 | ||
|
|
41ba067daf | ||
|
|
53346997b6 | ||
|
|
87a632761e | ||
|
|
998ebc43a3 | ||
|
|
945e312c41 | ||
|
|
963a562100 | ||
|
|
4be961b68b | ||
|
|
02a9140c13 | ||
|
|
5ea60ce6d6 | ||
|
|
e9e7be0fc6 | ||
|
|
60c30f2683 | ||
|
|
a17e69d37d | ||
|
|
157f75d33e | ||
|
|
30a14c73ff | ||
|
|
27cece3afb | ||
|
|
8124ecb51f | ||
|
|
e67c4c8670 | ||
|
|
fcc6798d63 | ||
|
|
d313ebfc69 | ||
|
|
6edb8f00f6 | ||
|
|
98a261d457 | ||
|
|
e97a69f475 | ||
|
|
e9dc284b26 | ||
|
|
856c52c64b | ||
|
|
386a4a32ef | ||
|
|
dbdb40ff01 | ||
|
|
1bd9b71215 | ||
|
|
f4dc279ec9 | ||
|
|
ca8438623d | ||
|
|
b81aac71f5 | ||
|
|
a4e0d28fa2 | ||
|
|
56d4e4bbb1 | ||
|
|
af1d10529b | ||
|
|
a5f84a3f6d | ||
|
|
69dfbc81cc | ||
|
|
34408ccdb0 | ||
|
|
b0522a6e5d | ||
|
|
2a559a07c0 | ||
|
|
bdf2a6a915 | ||
|
|
e814f75872 | ||
|
|
c0e0791854 | ||
|
|
17dbdb66bf | ||
|
|
6e3e9add36 | ||
|
|
5b25011853 | ||
|
|
804b24b2d6 | ||
|
|
de98b0f7c2 | ||
|
|
709de70fa9 | ||
|
|
1b7bc11f9d | ||
|
|
bbcaacf3dd | ||
|
|
e816b1262c | ||
|
|
fc019333dc | ||
|
|
56b0ddbc34 | ||
|
|
bb74d27d52 | ||
|
|
7b1d19ca71 | ||
|
|
af961678bd | ||
|
|
1579242644 | ||
|
|
2a45f5c596 | ||
|
|
c4560c49b0 | ||
|
|
2b7f75d9ef | ||
|
|
5f68bcbd46 | ||
|
|
c93c072259 | ||
|
|
516344a993 | ||
|
|
4d42fe5a0b | ||
|
|
47bf132fff | ||
|
|
04a9d48505 | ||
|
|
3716ddcf15 | ||
|
|
6c3ab40cb6 | ||
|
|
b7270974e5 | ||
|
|
9bfc53e77a | ||
|
|
f34fac8d17 | ||
|
|
555c11649e | ||
|
|
c308c5d606 | ||
|
|
54e9615994 | ||
|
|
b0b3b323fd | ||
|
|
f740a0b8ba | ||
|
|
e154675e72 | ||
|
|
2b3d3261de | ||
|
|
fab525ca56 | ||
|
|
1c273d1c1a | ||
|
|
90d684367c | ||
|
|
1ffc544ab0 | ||
|
|
cde0cceee1 | ||
|
|
1604fbc55c | ||
|
|
6f586e8866 | ||
|
|
c53455ad61 | ||
|
|
77b3c609f3 | ||
|
|
3b84598d99 | ||
|
|
6f536c0981 | ||
|
|
1f881ea252 | ||
|
|
b14df1b3cb | ||
|
|
e13763baa3 | ||
|
|
502ef67a71 | ||
|
|
05cab626df | ||
|
|
c7d54af7ad | ||
|
|
ad195fcd80 | ||
|
|
b87c68d598 | ||
|
|
33af37ed2e | ||
|
|
c4113fc92b | ||
|
|
ed40025d26 | ||
|
|
752008a551 | ||
|
|
a8bf970dca | ||
|
|
5b94bcdfdd | ||
|
|
a66a0e9485 | ||
|
|
80755e15b8 | ||
|
|
8cede0c6e0 | ||
|
|
9556f0f7d2 | ||
|
|
1b80f3060c | ||
|
|
00811f0be3 | ||
|
|
0fdfc2c59c | ||
|
|
8d25562a76 | ||
|
|
1d0d9d4be6 | ||
|
|
9ed4559625 | ||
|
|
2a53e8f15d | ||
|
|
15fbd18f31 | ||
|
|
2c248a7ca1 | ||
|
|
94d8fdb4ab | ||
|
|
3596439ed4 | ||
|
|
bc732a52ce | ||
|
|
ef096899a2 | ||
|
|
bf4bcf740e | ||
|
|
679281ac26 | ||
|
|
bde3d1408b | ||
|
|
3621a96d3f | ||
|
|
6583ad114f | ||
|
|
f4e42165bf | ||
|
|
00d7be735a | ||
|
|
1388fba8e4 | ||
|
|
c43644861f | ||
|
|
831ca599b8 | ||
|
|
43fbd03292 | ||
|
|
1efd04f6c5 | ||
|
|
e89ccfd74b | ||
|
|
622486160c | ||
|
|
6d172cae9e | ||
|
|
91b60fb147 | ||
|
|
3f9b7f48d5 | ||
|
|
3599fdec65 | ||
|
|
00e86974b3 | ||
|
|
2aa897eb80 | ||
|
|
ce9706b860 | ||
|
|
e140dfe556 | ||
|
|
4a1b845ac2 | ||
|
|
573f8af57e | ||
|
|
33445c93e8 | ||
|
|
f7fcb659f7 | ||
|
|
b0aa723c2f | ||
|
|
48dca41738 | ||
|
|
873d6f487f | ||
|
|
c8d73b129e | ||
|
|
c63e9e1cd2 | ||
|
|
def2ad84bb | ||
|
|
52510e9817 | ||
|
|
69a95488ce | ||
|
|
636841f647 | ||
|
|
1ea521236c | ||
|
|
6966efeda5 | ||
|
|
66ad0c3548 | ||
|
|
e061ccbf79 | ||
|
|
30cc6a111d | ||
|
|
8416ce5124 | ||
|
|
0cacfe391b | ||
|
|
c0eaab48cd | ||
|
|
c696bc3545 | ||
|
|
72505954a9 | ||
|
|
49159dba5c | ||
|
|
41e2d89abc | ||
|
|
6e7de97d93 | ||
|
|
2f20e6b498 | ||
|
|
ecf9158fd7 | ||
|
|
dc7e7321f3 | ||
|
|
0f087ad2a0 | ||
|
|
ff19a6e9eb | ||
|
|
8cec0c0779 | ||
|
|
314384f8e7 | ||
|
|
6c75ad95ad | ||
|
|
76e3b3caff | ||
|
|
c8f71480c9 | ||
|
|
9172a0cf9c | ||
|
|
eb476c6023 | ||
|
|
da4f3bd38e | ||
|
|
c65d628a36 | ||
|
|
589b8c3afc | ||
|
|
2684051da0 | ||
|
|
200db68d05 | ||
|
|
f6d8d32a01 | ||
|
|
72d5af5bb0 | ||
|
|
aabbc54cbb | ||
|
|
09b523bf56 | ||
|
|
5dc7f522bf | ||
|
|
fd58a42824 | ||
|
|
0a6d1c85ed | ||
|
|
13f84a260c | ||
|
|
4132ad9a97 | ||
|
|
1faba92ecc | ||
|
|
b93fc86663 | ||
|
|
7123036e8d | ||
|
|
cce4ea4dc6 | ||
|
|
b8f7df1555 | ||
|
|
ba8cc0477c | ||
|
|
010026bc88 | ||
|
|
a1ba34caf2 | ||
|
|
1214380bee | ||
|
|
03eb09c68c | ||
|
|
055d503862 | ||
|
|
5755ff762b | ||
|
|
88e24787e8 | ||
|
|
ed32d07ac4 | ||
|
|
3a917fcebe | ||
|
|
3535068ca8 | ||
|
|
4c39d4b96b | ||
|
|
8e864c6793 | ||
|
|
aeffecced3 | ||
|
|
4be42dc1e2 | ||
|
|
bdf6c62c29 | ||
|
|
a4592e5199 | ||
|
|
a5268f082d | ||
|
|
b556e72922 | ||
|
|
3602d2533a | ||
|
|
681f2c766a | ||
|
|
6e0a136cd1 | ||
|
|
9e9945a1cc | ||
|
|
3368cead8b | ||
|
|
a7b5126b4a | ||
|
|
a9ef1a03dc | ||
|
|
bc0ec8dbfa | ||
|
|
7bef009922 | ||
|
|
9a467bc27b | ||
|
|
e3934c9403 | ||
|
|
af337aa4d0 | ||
|
|
313d3a01ec | ||
|
|
feb9876cf5 | ||
|
|
9c3a5cb820 | ||
|
|
e219f781bf | ||
|
|
21ecd6ae05 | ||
|
|
2449902c3d | ||
|
|
c1a55037ba | ||
|
|
646a8122c0 | ||
|
|
edc4e6c508 | ||
|
|
5ddeb7b156 | ||
|
|
306624e55f | ||
|
|
6a9ca65cbc | ||
|
|
7db0026a0c | ||
|
|
7358705248 | ||
|
|
0b9ee2eec9 | ||
|
|
42eaf7fc39 | ||
|
|
6783cac9b4 | ||
|
|
628106ce88 | ||
|
|
1ce69b31a7 | ||
|
|
912ac11842 | ||
|
|
5f2fad560c | ||
|
|
b300f364a7 | ||
|
|
049ec41d13 | ||
|
|
f0d17bc84e | ||
|
|
cc442d7409 | ||
|
|
80bac26f21 | ||
|
|
015c39e6f4 | ||
|
|
e0ec3a4ebd | ||
|
|
a022de5cc3 | ||
|
|
a7b5e1e18c | ||
|
|
b8695df1c8 | ||
|
|
5e4382d1d2 | ||
|
|
ce983f4906 | ||
|
|
4a8be29616 | ||
|
|
1622eb642a | ||
|
|
422d992484 | ||
|
|
1065af1233 | ||
|
|
dde39d83be | ||
|
|
94fe7617bf | ||
|
|
e781d85b7c | ||
|
|
c30c9ca380 | ||
|
|
c395916af9 | ||
|
|
1a4037e360 | ||
|
|
f207bfaddd | ||
|
|
3dd298c0c8 | ||
|
|
8e2e4da7c3 | ||
|
|
418e3c109c | ||
|
|
cfda63a609 | ||
|
|
ba68276bfb | ||
|
|
97c700024a | ||
|
|
d4f67d726c | ||
|
|
ea87fa3e5a | ||
|
|
d12d247b3e | ||
|
|
c4a23915bd | ||
|
|
8645c57d13 | ||
|
|
b170e0bc17 | ||
|
|
4199f514f9 | ||
|
|
c96efb3bb4 | ||
|
|
d5571e0196 | ||
|
|
9f7f87b4ae | ||
|
|
4b03fce85d | ||
|
|
4b28d1c635 | ||
|
|
a5e803177f | ||
|
|
9462f284a4 | ||
|
|
4b0f6bd61b | ||
|
|
d6c85214ef | ||
|
|
de770cf801 | ||
|
|
dae0f740c2 | ||
|
|
9452c3e74c | ||
|
|
f826f1d543 | ||
|
|
fd13c80e5a | ||
|
|
75871348d5 | ||
|
|
0140e88595 | ||
|
|
e620b1254b | ||
|
|
77cfcf6c58 | ||
|
|
04e9df3d0a | ||
|
|
a953134089 | ||
|
|
88b9c0d783 | ||
|
|
ff7e0ba95b | ||
|
|
53dfbcaf00 | ||
|
|
d8fca22c00 | ||
|
|
4fb5963bd9 | ||
|
|
862d81f446 | ||
|
|
8ae7943b94 | ||
|
|
46e947d779 | ||
|
|
7780d6020f | ||
|
|
e5803b46f2 | ||
|
|
d776090298 | ||
|
|
f2f3111d49 | ||
|
|
dff1b957cd | ||
|
|
b1ec50c3cb | ||
|
|
85aa188287 | ||
|
|
e7d6526da9 | ||
|
|
d67c6fcc00 | ||
|
|
d9bf084300 | ||
|
|
da3977b0e2 | ||
|
|
7df930bccc | ||
|
|
11f73a1a53 | ||
|
|
2bbcd55e48 | ||
|
|
43890b8d5c | ||
|
|
b6b8e3741b | ||
|
|
c39f00c336 | ||
|
|
e48eaacfef | ||
|
|
6d97691388 | ||
|
|
d64cc996bc | ||
|
|
617d66c9b9 | ||
|
|
0fc50d2105 | ||
|
|
2bb9f28569 | ||
|
|
c1580b8359 | ||
|
|
dde235825d | ||
|
|
eac13b4025 | ||
|
|
59cc9c2706 | ||
|
|
40e20a0d33 | ||
|
|
a42679acaa | ||
|
|
fd2e8fcdb8 | ||
|
|
ebd5a15152 | ||
|
|
6cdd110304 | ||
|
|
3181cda2e0 | ||
|
|
2659c7fc02 | ||
|
|
5747ae3429 | ||
|
|
8d60be0e3f | ||
|
|
7e3f801a5a | ||
|
|
687ef53c65 | ||
|
|
a1fe9e727e | ||
|
|
6d625c88d3 | ||
|
|
c314e2de6b | ||
|
|
bd08b95390 | ||
|
|
aed0cca7e1 | ||
|
|
7d7761fb8d | ||
|
|
6d2fb5acc2 | ||
|
|
ec35d846cf | ||
|
|
1d23907652 | ||
|
|
efc6c10ace | ||
|
|
47a8ad5244 | ||
|
|
5d879c4619 | ||
|
|
6e0dd3d064 | ||
|
|
4657a403d0 | ||
|
|
bf09a07fe2 | ||
|
|
927802ca88 | ||
|
|
3d5bc9ef0e | ||
|
|
706ad7e44d | ||
|
|
88206f521b | ||
|
|
af786b4c11 | ||
|
|
d4c749c997 | ||
|
|
b5aa25cd0b | ||
|
|
ae49fb6dad | ||
|
|
3e24d17951 | ||
|
|
22030e6907 | ||
|
|
39fdcb9af3 | ||
|
|
866998f98b | ||
|
|
0fa71f76e8 | ||
|
|
317f655128 | ||
|
|
a871426f31 | ||
|
|
2caf9ff38a | ||
|
|
1de3a57d50 | ||
|
|
bed1355d63 | ||
|
|
189f9b4f26 | ||
|
|
931b5a22e6 | ||
|
|
4c9e0a8b74 | ||
|
|
589472314b | ||
|
|
f11657fdee | ||
|
|
e4ec081357 | ||
|
|
9e55527a2b | ||
|
|
cca70ffcec | ||
|
|
2a6a717ef2 | ||
|
|
11043554c2 | ||
|
|
cdbef468d7 | ||
|
|
4705b75580 | ||
|
|
245ccf889e | ||
|
|
2ad0a3d622 | ||
|
|
e775f774b5 | ||
|
|
ad2aec44e5 | ||
|
|
11d3effc28 | ||
|
|
2ef12e2868 | ||
|
|
ed8c5ce1ea | ||
|
|
33e507efe5 | ||
|
|
c425f5cf6a | ||
|
|
fc76d7e609 | ||
|
|
1b24bf33ed | ||
|
|
16036897cb | ||
|
|
58ff378735 | ||
|
|
1cee04a233 | ||
|
|
ac11a0efb8 | ||
|
|
f2ea1e3f7a | ||
|
|
76dbbdfbf9 | ||
|
|
153970c146 | ||
|
|
c21287e642 | ||
|
|
284942a82e | ||
|
|
e796916e73 | ||
|
|
004db1762c | ||
|
|
8e05ba8820 | ||
|
|
f4f386b5e5 | ||
|
|
ecbf8bec23 | ||
|
|
5d8c09ccda | ||
|
|
47c5c3d8a0 | ||
|
|
3ddce16d5b | ||
|
|
acefdc44d9 | ||
|
|
d05c5b9d7f | ||
|
|
5d1237d1f4 | ||
|
|
6bac8631ac | ||
|
|
2c803327ad | ||
|
|
451ae5bc51 | ||
|
|
f41ce8c878 | ||
|
|
177c54c813 | ||
|
|
3765e32fd4 | ||
|
|
aab7a16d18 | ||
|
|
f2463af63c | ||
|
|
212c2c3b56 | ||
|
|
3ab00b0cdd | ||
|
|
fad85e9299 | ||
|
|
3cd5e203e2 | ||
|
|
7b081fa870 | ||
|
|
5ce22051d4 | ||
|
|
1e59489fa5 | ||
|
|
1e691b1255 | ||
|
|
61e1bef63f | ||
|
|
46dbb78fbf | ||
|
|
99890707b3 | ||
|
|
f54c841fdc | ||
|
|
e2a6009ee9 | ||
|
|
a34dd15040 | ||
|
|
24c934003d | ||
|
|
5300e1a245 | ||
|
|
90381c670b | ||
|
|
23edea93db | ||
|
|
a51712e363 | ||
|
|
4310e979e1 | ||
|
|
f690b76d87 | ||
|
|
ac29594b67 | ||
|
|
93f535bb87 | ||
|
|
076eb3cf42 | ||
|
|
7b2e0fffbd | ||
|
|
8eaab0dbc3 | ||
|
|
eaa5bc896e | ||
|
|
42d1db5fc3 | ||
|
|
78c017ddaf | ||
|
|
231551d743 | ||
|
|
d0984eaba7 | ||
|
|
15854a72d1 | ||
|
|
7fd2bf0eda | ||
|
|
6a10a2167e | ||
|
|
1ba99d255c | ||
|
|
ff1c2947b6 | ||
|
|
a690d2e0db | ||
|
|
a3df327396 | ||
|
|
ede0016d8e | ||
|
|
c19b091795 | ||
|
|
9e62c8b9f3 | ||
|
|
9f30dc181c | ||
|
|
37fe44f860 | ||
|
|
68aaab8467 | ||
|
|
b951ed1fcd | ||
|
|
9bd9dc568a | ||
|
|
29e4356fee | ||
|
|
0ed31eaa99 | ||
|
|
1e9869b217 | ||
|
|
78cfc2c827 | ||
|
|
a6e5a0d125 | ||
|
|
ea13aecd27 | ||
|
|
d838e8f28f | ||
|
|
c9a7b9e17c | ||
|
|
8fe2d0bc0e | ||
|
|
71e2f1e1e6 | ||
|
|
1ce9d1b9b2 | ||
|
|
b3d6ae467f | ||
|
|
da8d720dc4 | ||
|
|
824c38f8ce | ||
|
|
b0579a70d8 | ||
|
|
f195eb6d0d | ||
|
|
f9fe2d0976 | ||
|
|
2aeadc8f67 | ||
|
|
ee3eaa1166 | ||
|
|
5dce143756 | ||
|
|
33a0568895 | ||
|
|
721d728906 | ||
|
|
c55cbaf373 | ||
|
|
3fd5cfdecd | ||
|
|
f87d3359c2 | ||
|
|
37f27849bd | ||
|
|
6fe326f052 | ||
|
|
de7e4e9c69 | ||
|
|
6861fe303b | ||
|
|
ba3214bf10 | ||
|
|
bcce77495a | ||
|
|
a165d2b27e | ||
|
|
e63714cab0 | ||
|
|
390c21dd91 | ||
|
|
9defea9c8e | ||
|
|
5906173de7 | ||
|
|
a81de48cfb | ||
|
|
2be61e8b1c | ||
|
|
4d7fc9f354 | ||
|
|
e11fd7b3a1 | ||
|
|
cc0348b113 | ||
|
|
77bb948bf2 | ||
|
|
8e2adcbce4 | ||
|
|
bb141203a8 | ||
|
|
579c22df65 | ||
|
|
694e8291de | ||
|
|
367259fd31 | ||
|
|
e54151d2eb | ||
|
|
ca2c687f26 | ||
|
|
d444a7080d | ||
|
|
08bfcded7f | ||
|
|
1c9e4f978b | ||
|
|
a5f1fba6ee | ||
|
|
88f1cf1913 | ||
|
|
1fb69d2861 | ||
|
|
3aeff66522 | ||
|
|
114cbe0ec5 | ||
|
|
bfe94222b7 | ||
|
|
30061abc54 | ||
|
|
126e07489b | ||
|
|
bd2577c089 | ||
|
|
5039c61e98 | ||
|
|
5f34ab3d7e | ||
|
|
fee47427cf | ||
|
|
15c8289a01 | ||
|
|
141a863079 | ||
|
|
6fa8d4be34 | ||
|
|
984e8a44d5 | ||
|
|
bdb296bce2 | ||
|
|
955a9084c3 | ||
|
|
70f486b0eb | ||
|
|
0bc383c6a8 | ||
|
|
0bb2b36585 | ||
|
|
9e3a24753a | ||
|
|
f2c400799e | ||
|
|
25a8c8a7e3 | ||
|
|
8f95d89fc6 | ||
|
|
39b0cdb9d5 | ||
|
|
42e06b848e | ||
|
|
7c3a13b1c4 | ||
|
|
151248fce2 | ||
|
|
6e8d6fe063 | ||
|
|
ca2da5e631 | ||
|
|
6cd3b70201 | ||
|
|
2dcfba75b5 | ||
|
|
44a51b03d3 | ||
|
|
6ee9e9831e | ||
|
|
68c717cee8 | ||
|
|
b15fc02c34 | ||
|
|
033ebfb497 | ||
|
|
4cceaa7650 | ||
|
|
5df47f1396 | ||
|
|
903f35c01b | ||
|
|
6a34f94277 | ||
|
|
dcc143eb7d | ||
|
|
fb47bf27e8 | ||
|
|
dcf969ff2e | ||
|
|
32c1318355 | ||
|
|
8ca6b89da4 | ||
|
|
b46c3a15f3 | ||
|
|
cbc5e014f7 | ||
|
|
8925b248e4 | ||
|
|
cc6aa2bbc5 | ||
|
|
1989aaf8b4 | ||
|
|
c90c91b748 | ||
|
|
bca23db213 | ||
|
|
c9a92d2043 | ||
|
|
c48e06fa46 | ||
|
|
5529eced91 | ||
|
|
2a0d935471 | ||
|
|
6c68fda572 | ||
|
|
861c1fc3dc | ||
|
|
5971bb4255 | ||
|
|
76ba360631 | ||
|
|
89d3ff3c90 | ||
|
|
34024a913d | ||
|
|
a858380121 | ||
|
|
e1dc870005 | ||
|
|
2fc1cac869 | ||
|
|
f627ff4958 | ||
|
|
5ff8e51948 | ||
|
|
538f25c6bb | ||
|
|
65100ba279 | ||
|
|
79fd470bbf | ||
|
|
866d74665b | ||
|
|
29da74f038 | ||
|
|
3c8ac35a46 | ||
|
|
afe957ba59 | ||
|
|
7e50e99351 | ||
|
|
62ce462cc8 | ||
|
|
108cb06f43 | ||
|
|
95a38675bc | ||
|
|
714681bc50 | ||
|
|
0f8d91d997 | ||
|
|
562297a82f | ||
|
|
b108bf06e5 | ||
|
|
3c819066fd | ||
|
|
5f30cb7e2e | ||
|
|
5a95b95801 | ||
|
|
eb573fdc8b | ||
|
|
238ea54e98 | ||
|
|
e4dfc47fb8 | ||
|
|
a1491c779a | ||
|
|
dabd7552be | ||
|
|
0a4c56af1f | ||
|
|
c3dae5b92c | ||
|
|
2c3105b526 | ||
|
|
20f5081ac8 | ||
|
|
3091eb9d14 | ||
|
|
5bdda42239 | ||
|
|
7eda8b7662 | ||
|
|
fc94ce5d2b | ||
|
|
e5d7161ab7 | ||
|
|
f725cb7fa4 | ||
|
|
830e689fe8 | ||
|
|
2832e8c638 | ||
|
|
d711cbab49 | ||
|
|
2e8fd737af | ||
|
|
a080ede15b | ||
|
|
ab3d41508f | ||
|
|
1ab4a5e925 | ||
|
|
543ce08be6 | ||
|
|
21829056ba | ||
|
|
1af59c87d0 | ||
|
|
799e6c082c | ||
|
|
09635cf0fd | ||
|
|
1dfbd30471 | ||
|
|
48e0a77d1f | ||
|
|
7ae8594c00 | ||
|
|
19663b0f38 | ||
|
|
4bcb9adb83 | ||
|
|
f7505298d7 | ||
|
|
df722ffa8b | ||
|
|
2a852fe08d | ||
|
|
540f796200 | ||
|
|
b726ac84fe | ||
|
|
61ac2bb6a3 | ||
|
|
5d702b3992 | ||
|
|
3bf4a004d4 | ||
|
|
7ac5876d2d | ||
|
|
0f18c612af | ||
|
|
03f4a3c478 | ||
|
|
7069343cf4 | ||
|
|
7fae79f2c5 | ||
|
|
66b714ed39 | ||
|
|
d371ebe354 | ||
|
|
9093d0d5e5 | ||
|
|
1139df0637 | ||
|
|
c1810de316 | ||
|
|
863ced57f8 | ||
|
|
2147aeb4ae | ||
|
|
a810b4fc9a | ||
|
|
abcbb61b4c | ||
|
|
83332223ef | ||
|
|
fd8d981ea0 | ||
|
|
03e3ade09d | ||
|
|
68305f2e00 | ||
|
|
b7d6b06242 | ||
|
|
9098050c5a | ||
|
|
0147ec0a6a | ||
|
|
c6b71605d0 | ||
|
|
64009c82e9 | ||
|
|
5b24cb370f | ||
|
|
2d261cd97b | ||
|
|
9455d91b3d | ||
|
|
cb645c56b4 | ||
|
|
1a6b91dee5 | ||
|
|
8d2edad488 | ||
|
|
522e26b1fa | ||
|
|
259b22c255 | ||
|
|
b61cf82b46 | ||
|
|
4f06f7424c | ||
|
|
d2d65437f8 | ||
|
|
3ae0f7558e | ||
|
|
604801686d | ||
|
|
554d4190ff | ||
|
|
1d71390349 | ||
|
|
fe24c6d682 | ||
|
|
4359d91a23 | ||
|
|
ae42eac7fd | ||
|
|
37a8888a32 | ||
|
|
2d7e065d39 | ||
|
|
35cf640691 | ||
|
|
b308fbe0ad | ||
|
|
d5e2b51b6d | ||
|
|
9b7844542d | ||
|
|
9f6fac0d58 | ||
|
|
f6011dc3f2 | ||
|
|
fdb7fa21f6 | ||
|
|
29bbe41e51 | ||
|
|
004ada8204 | ||
|
|
9a2894944c | ||
|
|
dfcff5029b | ||
|
|
853fc600dd | ||
|
|
a546b21755 | ||
|
|
e40c4e3779 | ||
|
|
60cbf6cff3 | ||
|
|
6d3f4b98d7 | ||
|
|
4812a2b401 | ||
|
|
5f99376d58 | ||
|
|
3e76c142c3 | ||
|
|
28f23a73af | ||
|
|
68b94fed8e | ||
|
|
657b02727c | ||
|
|
7d7a10073c | ||
|
|
9d5f0c791c | ||
|
|
212493e4ff | ||
|
|
9fc8e9c6d7 | ||
|
|
f69ddb71a0 | ||
|
|
290beec0c5 | ||
|
|
dcb2f6f8cd | ||
|
|
d200845906 | ||
|
|
a52e02695d | ||
|
|
febd7c3063 | ||
|
|
d5ae0b99f0 | ||
|
|
8b10c608fc | ||
|
|
0d49b91cc6 | ||
|
|
2af4b83e09 | ||
|
|
cde3ca3d9e | ||
|
|
429798190a | ||
|
|
583db4c70f | ||
|
|
3ec35eec91 | ||
|
|
65bfbfc7fd | ||
|
|
1b93701df2 | ||
|
|
d6debc55f5 | ||
|
|
87fd9ae686 | ||
|
|
3225a3b337 | ||
|
|
4eb98a6c31 | ||
|
|
38a6e2fc98 | ||
|
|
c171cf1487 | ||
|
|
b64a0f1d55 | ||
|
|
3ab124b2db | ||
|
|
d710f3995f | ||
|
|
f53c209082 | ||
|
|
9997be3462 | ||
|
|
c3b06e375c | ||
|
|
1476c5a932 | ||
|
|
8e1c9b9703 | ||
|
|
27c89f7cc7 | ||
|
|
9210198766 | ||
|
|
ce6fa0bf8f | ||
|
|
cd6629b424 | ||
|
|
f25a62ad71 | ||
|
|
cec3c872b6 | ||
|
|
e666e71281 | ||
|
|
3d0c303d41 | ||
|
|
d70a97cf77 | ||
|
|
c67c433258 | ||
|
|
0da6bd5ab6 | ||
|
|
e5cdb1580e | ||
|
|
2c10292073 | ||
|
|
30036a456e | ||
|
|
6349ae9e2b | ||
|
|
8d746669c3 | ||
|
|
0081abc9a7 | ||
|
|
a2f9ac05fe | ||
|
|
6c1f24bad7 | ||
|
|
77cd01e91f | ||
|
|
5487aac81d | ||
|
|
8a6257dc63 | ||
|
|
8146c69ebf | ||
|
|
78ece1abf2 | ||
|
|
baab35c4c5 | ||
|
|
357f9d46f9 | ||
|
|
4eb26302a7 | ||
|
|
a2071d9527 | ||
|
|
65c32c52ff | ||
|
|
fa4353f47d | ||
|
|
46fea1a3e5 | ||
|
|
497cf111d1 | ||
|
|
b1f2fd26e3 | ||
|
|
ae60d4a60f | ||
|
|
ae78e4691d | ||
|
|
9c058cf6d6 | ||
|
|
1ac9af23c5 | ||
|
|
f783bb660e | ||
|
|
e5c271ca1c | ||
|
|
f927247955 | ||
|
|
087e38bec8 | ||
|
|
bab3c8e6b0 | ||
|
|
54ac5d9e27 | ||
|
|
36519d9053 | ||
|
|
ccce4c622d | ||
|
|
4cbf677e55 | ||
|
|
1dbac44a93 | ||
|
|
7e1cfb5cd2 | ||
|
|
df9fb956fa | ||
|
|
16dc383f2b | ||
|
|
0dd7c4851b | ||
|
|
fce4e75eef | ||
|
|
16b578a76d | ||
|
|
483db9881e | ||
|
|
a4053c6084 | ||
|
|
e4f4b46047 | ||
|
|
36f77d5408 | ||
|
|
b3533771dc | ||
|
|
45372cba92 | ||
|
|
dd7fb5bb0d | ||
|
|
41bdc19a22 | ||
|
|
8b7f22021a | ||
|
|
f0160e4d2b | ||
|
|
39d727f98f | ||
|
|
13cc8ac70d | ||
|
|
eb2a219ec8 | ||
|
|
4a59565b20 | ||
|
|
4b7fa96308 | ||
|
|
1ebc8a1e7b | ||
|
|
df2a9aae20 | ||
|
|
dd8287c9d7 | ||
|
|
22fcb08dad | ||
|
|
8c2cf181bd | ||
|
|
69adae36b6 | ||
|
|
8ab700dfa9 | ||
|
|
0177529b45 | ||
|
|
4c6ae3364e | ||
|
|
6df8511a6d | ||
|
|
6fa39517f8 | ||
|
|
c69ce39424 | ||
|
|
a47f6736ac | ||
|
|
79bd7cfff3 | ||
|
|
bc02f23f0f | ||
|
|
715dffb6c8 | ||
|
|
702b3eb971 | ||
|
|
17f62bf491 | ||
|
|
28471302ee | ||
|
|
d8bfdd5d3b | ||
|
|
a36e68e9c3 | ||
|
|
343aed16fb | ||
|
|
142d873c8b | ||
|
|
a94b3e05d3 | ||
|
|
26a79d58f0 | ||
|
|
7c5e68e47d | ||
|
|
ba68627060 | ||
|
|
5bb6a7d4d4 | ||
|
|
76f7999046 | ||
|
|
547693df4f | ||
|
|
0206f8211a | ||
|
|
e061f2e259 | ||
|
|
560ccff04a | ||
|
|
2f0a84557b | ||
|
|
3ae7318ded | ||
|
|
6b7d66e833 | ||
|
|
ec8e594a5c | ||
|
|
858041772e | ||
|
|
b355c04d87 | ||
|
|
4918eaf752 | ||
|
|
80706f006d | ||
|
|
8a7fec1207 | ||
|
|
22a5b6e85e | ||
|
|
a51c533712 | ||
|
|
1f74674a11 | ||
|
|
2eada58ce5 | ||
|
|
31e74bd4a8 | ||
|
|
903f73ee78 | ||
|
|
b21198b239 | ||
|
|
e20ff09457 | ||
|
|
674393eabc | ||
|
|
d78a131713 | ||
|
|
e3816bf05b | ||
|
|
37fe1c60cc | ||
|
|
e705a0d32b | ||
|
|
eb658a644b | ||
|
|
cb905bfc8c | ||
|
|
d0accf6a84 | ||
|
|
55e6f89fc1 | ||
|
|
60695a0ffc | ||
|
|
8a8e4655cd | ||
|
|
2f4b390be1 | ||
|
|
31146cc713 | ||
|
|
9e020ff268 | ||
|
|
7e825192d0 | ||
|
|
8871ae894f | ||
|
|
2808f4b1a2 | ||
|
|
0324c22061 | ||
|
|
57227f9544 | ||
|
|
59c5131f1a | ||
|
|
ccbc07d7d8 | ||
|
|
a0247f0036 | ||
|
|
0979c2767b | ||
|
|
9a9613bba3 | ||
|
|
6451f5f3b7 | ||
|
|
4a4430ce9b | ||
|
|
a38d3dcf72 | ||
|
|
60e1e0d037 | ||
|
|
8071b85b3d | ||
|
|
c867bfb846 | ||
|
|
24b32ab69b | ||
|
|
b1fc65262f | ||
|
|
5af3fea74c | ||
|
|
dde38985e4 | ||
|
|
3f0084fa1c | ||
|
|
8936d4fdce | ||
|
|
4c47b7d838 | ||
|
|
093a9cb8e4 | ||
|
|
f27b3f8933 | ||
|
|
74a9e48e55 | ||
|
|
bafef26ffc | ||
|
|
f8e66170bf | ||
|
|
00bf99fe5a | ||
|
|
05dd66177f | ||
|
|
d5a9e6401e | ||
|
|
660ba67433 | ||
|
|
7ad948065b | ||
|
|
40fcb85c93 | ||
|
|
dcddb80f7b | ||
|
|
8e349aea19 | ||
|
|
3d72725ae0 | ||
|
|
270cb340f5 | ||
|
|
42b5462889 | ||
|
|
b98ab8d011 | ||
|
|
b4264a8ba3 | ||
|
|
a395246d1e | ||
|
|
4b7a2afd07 | ||
|
|
7f49ff20cf | ||
|
|
4e9995e610 | ||
|
|
9f61442cec | ||
|
|
9339847d09 | ||
|
|
39e57cb3ef | ||
|
|
f3a574d05c | ||
|
|
297c76006a | ||
|
|
62d025d827 | ||
|
|
999799ea68 | ||
|
|
331f68253e | ||
|
|
70d3c7a4be | ||
|
|
b3c75a0286 | ||
|
|
9946120304 | ||
|
|
7030a67389 | ||
|
|
eda5ef6965 | ||
|
|
0324479fda | ||
|
|
aeafecb88d | ||
|
|
fde7fbe21a | ||
|
|
7116efc490 | ||
|
|
1ac6058200 | ||
|
|
32b80b64f4 | ||
|
|
9e348767dc | ||
|
|
bce72e1152 | ||
|
|
64aba75be2 | ||
|
|
ca65e13f9a | ||
|
|
54797607c6 | ||
|
|
e174254a95 | ||
|
|
4378e24b49 | ||
|
|
35d276ea98 | ||
|
|
678c89d9c0 | ||
|
|
0a42223de0 | ||
|
|
54d3f3b007 | ||
|
|
3ee58ee464 | ||
|
|
3b5ff016fe | ||
|
|
8a8e786f5e | ||
|
|
2a15f68ffb | ||
|
|
9387e014c1 | ||
|
|
1ef37fcaff | ||
|
|
c5906a481f | ||
|
|
ac0bc916a1 | ||
|
|
5bbe76d56e | ||
|
|
1e6195d74c | ||
|
|
85acea7e64 | ||
|
|
0e4ff99602 | ||
|
|
575d2a0940 | ||
|
|
c548462eef | ||
|
|
3b4cc66b24 | ||
|
|
6d7273f822 | ||
|
|
65014d330a | ||
|
|
d9e3cf0190 | ||
|
|
2d8ee54d28 | ||
|
|
98c3bb780d | ||
|
|
7247c10615 | ||
|
|
0787284d80 | ||
|
|
1c73bffc95 | ||
|
|
6f79815933 | ||
|
|
bb108d594a | ||
|
|
f7716c8834 | ||
|
|
5ba076b1dd | ||
|
|
7861b5a414 | ||
|
|
f36a5988d8 | ||
|
|
8b57240db3 | ||
|
|
7b52efd2d1 | ||
|
|
4901b838e2 | ||
|
|
2313a60f32 | ||
|
|
c38e958588 | ||
|
|
43b1e14f41 | ||
|
|
1e23b3c355 | ||
|
|
85e1556148 | ||
|
|
b65f333a89 | ||
|
|
3dbcbb8280 | ||
|
|
06e464854a | ||
|
|
f7a944a78a | ||
|
|
7f53531489 | ||
|
|
8386c2889f | ||
|
|
13d2332984 | ||
|
|
ce496c205a | ||
|
|
66547661b5 | ||
|
|
8568a29461 | ||
|
|
5d42229aec | ||
|
|
ad8c928cf1 | ||
|
|
cc90883342 | ||
|
|
a4071da5de | ||
|
|
c65dbf978b | ||
|
|
c4ea804fee | ||
|
|
f71720c809 | ||
|
|
03ba601491 | ||
|
|
bdee3fc1b5 | ||
|
|
2e472fa90d | ||
|
|
aad7e896f2 | ||
|
|
2478fc2967 | ||
|
|
2db96c968d | ||
|
|
9bc1a69ace | ||
|
|
cca74e9e54 | ||
|
|
8185411071 | ||
|
|
c89addab2e | ||
|
|
6c617bf9e7 | ||
|
|
5847e340bf | ||
|
|
5a5fd8f425 | ||
|
|
d6283e326d | ||
|
|
c63deb70dd | ||
|
|
c071781099 | ||
|
|
0820b4b70a | ||
|
|
ac42d11251 | ||
|
|
324248ff1e | ||
|
|
f32e83d43b | ||
|
|
3820aaed21 | ||
|
|
a45ef79c6f | ||
|
|
9b9266a6c9 | ||
|
|
06e22030c3 | ||
|
|
ca146c977b | ||
|
|
6a96a3617f | ||
|
|
6dd6e05e0c | ||
|
|
1fb33d51d3 | ||
|
|
4841f2d7f6 | ||
|
|
ad388ae056 | ||
|
|
a80769fae3 | ||
|
|
b34c6f4c34 | ||
|
|
d6d084fbd1 | ||
|
|
1fca44c0da | ||
|
|
8bf1d0b776 | ||
|
|
484412514f | ||
|
|
6987449a7e | ||
|
|
18dac92fc1 | ||
|
|
54774fcfe5 | ||
|
|
b431229273 | ||
|
|
658dde158e | ||
|
|
ced3ada6fc | ||
|
|
0db236639b | ||
|
|
036ce7f94f | ||
|
|
68c887ffe0 | ||
|
|
e96da49d0a | ||
|
|
794684bc4e | ||
|
|
dd944c5293 | ||
|
|
be878454a9 | ||
|
|
e567f81046 | ||
|
|
6164ca5f91 | ||
|
|
655332e3fd | ||
|
|
7e300fea87 | ||
|
|
cea3e0aba8 | ||
|
|
459e270561 | ||
|
|
cba660e785 | ||
|
|
758301a39d | ||
|
|
a8d0bae16e | ||
|
|
583cc39849 | ||
|
|
3585bd3d2d | ||
|
|
3a895b6418 | ||
|
|
bb67733723 | ||
|
|
f380fd553f | ||
|
|
d22ef12adf | ||
|
|
eaec088348 | ||
|
|
fa1d0b9151 | ||
|
|
c0a418b8b1 | ||
|
|
1a4f633a28 | ||
|
|
c92ae40db6 | ||
|
|
0b42bea600 | ||
|
|
d8565cb3d3 | ||
|
|
f68798c10e | ||
|
|
a2ab927433 | ||
|
|
c7eae71c56 | ||
|
|
c3784c2606 | ||
|
|
60fe263b53 | ||
|
|
aaa0cfd0c8 | ||
|
|
a209b2774a | ||
|
|
84d67b6166 | ||
|
|
a7a215e6c7 | ||
|
|
8686fe4e97 | ||
|
|
afe2e8f95b | ||
|
|
f580226c27 | ||
|
|
e93db46e0a | ||
|
|
daea4b7f84 | ||
|
|
eb942b07b1 | ||
|
|
804ca38db7 | ||
|
|
7278c0beae | ||
|
|
096e3a0f5f | ||
|
|
5090c15f20 | ||
|
|
cb7e74fc21 | ||
|
|
ff90041ed4 | ||
|
|
f8fbe1844a | ||
|
|
1902172a04 | ||
|
|
2df384b847 | ||
|
|
65bb35b4de | ||
|
|
97516100f5 | ||
|
|
009ec7a59b | ||
|
|
02890c2b69 | ||
|
|
0f690bf00e | ||
|
|
cfe427b34c | ||
|
|
a44c76cdc3 | ||
|
|
730bde3d0d | ||
|
|
aa006fe22a | ||
|
|
ca77090ecd | ||
|
|
5619d1a4c5 | ||
|
|
b7c80c397d | ||
|
|
d1e7cd2f85 | ||
|
|
7da7aeb796 | ||
|
|
26b46166aa | ||
|
|
6d5eb51a5d | ||
|
|
917b6b318f | ||
|
|
bfd95687b8 | ||
|
|
4198ee1af1 | ||
|
|
e9b1280ae6 | ||
|
|
3c42831db0 | ||
|
|
b8482006b9 | ||
|
|
53f0c33c1d | ||
|
|
563516901e | ||
|
|
73b40fd8b7 | ||
|
|
08224a8486 | ||
|
|
993f3d3aa8 | ||
|
|
3a975de136 | ||
|
|
48b5195798 | ||
|
|
8eb34c7539 | ||
|
|
d75d7a9209 | ||
|
|
ddf851f1eb | ||
|
|
889dd00c23 | ||
|
|
c5ea2a1aa1 | ||
|
|
1489aff78e | ||
|
|
640296d42f | ||
|
|
3b12b2a5f6 | ||
|
|
d5c41a5167 | ||
|
|
58bf86d25d | ||
|
|
d73034d6d9 | ||
|
|
151a613dcc | ||
|
|
4bb741a42f | ||
|
|
cc9c8d3db3 | ||
|
|
c3d4831550 | ||
|
|
31e385fbfb | ||
|
|
a8c47d717c | ||
|
|
9a25157d3f | ||
|
|
9176e0f7b7 | ||
|
|
ff7aa890a6 | ||
|
|
03312c1592 | ||
|
|
9d1ec2c636 | ||
|
|
c49c31a44e | ||
|
|
947c1f562f | ||
|
|
2d1dbb6988 | ||
|
|
622e46ff67 | ||
|
|
4ff45a65c3 | ||
|
|
a62676061b | ||
|
|
11d77d2265 | ||
|
|
1e7d44b250 | ||
|
|
ffd86c6d8c | ||
|
|
a566c9460d | ||
|
|
24edae3d58 | ||
|
|
97876344c4 | ||
|
|
95dbeb9a47 | ||
|
|
3fc64859b1 | ||
|
|
896fe3b5b2 | ||
|
|
85404781a3 | ||
|
|
efe2abc86e | ||
|
|
b70b7a0b40 | ||
|
|
865c80f87b | ||
|
|
23a91aab12 | ||
|
|
085a3cbb50 | ||
|
|
fb9d875c31 | ||
|
|
5ee15c6f68 | ||
|
|
9853205849 | ||
|
|
2c9ce7e8fc | ||
|
|
9753ae60e2 | ||
|
|
bd66f1e682 | ||
|
|
ed6a45c119 | ||
|
|
8f53ce27fc | ||
|
|
f7ae2e6689 | ||
|
|
c6cc47192c | ||
|
|
1c447fe369 | ||
|
|
6b5c92db48 | ||
|
|
427e020d27 | ||
|
|
18084995b2 | ||
|
|
f894fdf564 | ||
|
|
0b0a964a90 | ||
|
|
d6df979d0d | ||
|
|
c366c37afe | ||
|
|
20cbd239b2 | ||
|
|
a9c7595ee7 | ||
|
|
3f09e3ca64 | ||
|
|
ed42db7a0d | ||
|
|
c85daeb46e | ||
|
|
3f2b93f1f8 | ||
|
|
78d2e66c56 | ||
|
|
0f2de651ff | ||
|
|
2eb7c7237e | ||
|
|
3b8f62ff11 | ||
|
|
f8bf9370de | ||
|
|
30cd0ec089 | ||
|
|
e984be9289 | ||
|
|
8069787754 | ||
|
|
343e442dff | ||
|
|
313ccdeae9 | ||
|
|
fdec8ebfd3 | ||
|
|
efddd86263 | ||
|
|
7d18bde40b | ||
|
|
7fee410be4 | ||
|
|
ebc2516a53 | ||
|
|
ade4d1d782 | ||
|
|
07f7a288d2 | ||
|
|
380ed16caf | ||
|
|
db654a10d1 | ||
|
|
2cf84d35cd | ||
|
|
a4eac86913 | ||
|
|
5168be45a8 | ||
|
|
163ab43da3 | ||
|
|
e5fa517270 | ||
|
|
b8b8ea5ce2 | ||
|
|
991b147af5 | ||
|
|
ecff62d0fa | ||
|
|
cdec4c0879 | ||
|
|
e8085ac4cf | ||
|
|
327062112b | ||
|
|
6dfc23c33a | ||
|
|
a601b0ab35 | ||
|
|
a48c8ca87a | ||
|
|
b59e64a3d1 | ||
|
|
5fc62dd06d | ||
|
|
d81a0cae91 | ||
|
|
50e31c6b69 | ||
|
|
92d3d88127 | ||
|
|
517fdb2095 | ||
|
|
d16ebb02b4 | ||
|
|
a5c64c8b7b | ||
|
|
5287a93484 | ||
|
|
06e84d9032 | ||
|
|
3a63dd032a | ||
|
|
889e227523 | ||
|
|
57d895daf5 | ||
|
|
a744394faa | ||
|
|
64e3c25bad | ||
|
|
75f85e1fb2 | ||
|
|
00bd4cab37 | ||
|
|
a9527f59a9 | ||
|
|
77661930f0 | ||
|
|
80a09bd9a0 | ||
|
|
6eb7cfbdc2 | ||
|
|
fb186530aa | ||
|
|
6c121ccb90 | ||
|
|
c08ad3b365 | ||
|
|
1668bc88ad | ||
|
|
3a43f62460 | ||
|
|
bfba5179d1 | ||
|
|
78bf7856dc | ||
|
|
e0c708f677 | ||
|
|
794d6824e8 | ||
|
|
15573a7bee | ||
|
|
31c61a79c6 | ||
|
|
87ca427094 | ||
|
|
99bdc904e0 | ||
|
|
2fdee68feb | ||
|
|
7be014f83e | ||
|
|
5668fe0a33 | ||
|
|
32c07efe19 | ||
|
|
21b23d0f79 | ||
|
|
793d0dd13f | ||
|
|
14e8ff4c1b | ||
|
|
416ab06997 | ||
|
|
493cd60dae | ||
|
|
e0948e1e9e | ||
|
|
5776b8c044 | ||
|
|
38ab4105d8 | ||
|
|
5ed9dadcc2 | ||
|
|
357d7e2381 | ||
|
|
8cfaab3e9f | ||
|
|
fef2404357 | ||
|
|
1aa1bce8c8 | ||
|
|
124b2761f6 | ||
|
|
066ca1af7c | ||
|
|
c20520879b | ||
|
|
4fa5b2b856 | ||
|
|
5c1b1fad76 | ||
|
|
c18d248c06 | ||
|
|
d46ee7f673 | ||
|
|
f2c0d99bd9 | ||
|
|
60ee0b9185 | ||
|
|
4b3e660ae7 | ||
|
|
0b42392bfc | ||
|
|
a94d7ce235 | ||
|
|
72aec432ed | ||
|
|
0e5db8d604 | ||
|
|
dc45fb4b84 | ||
|
|
6503d38fe3 | ||
|
|
32c89d9a11 | ||
|
|
f279465750 | ||
|
|
58ec1b022a | ||
|
|
612199429e | ||
|
|
e5482f9051 | ||
|
|
05df14fda2 | ||
|
|
29898ba1ba | ||
|
|
93d1cec503 | ||
|
|
9884f44122 | ||
|
|
d400456685 | ||
|
|
c39069cafd | ||
|
|
5fb0edc318 | ||
|
|
21a6b2d780 | ||
|
|
40c9063a54 | ||
|
|
59b0103ed5 | ||
|
|
f4730e9338 | ||
|
|
b7b520ca3c | ||
|
|
21d44e6a55 | ||
|
|
607886f0f0 | ||
|
|
7cd3c68256 | ||
|
|
6e37c1bd86 | ||
|
|
5db1a0748f | ||
|
|
a7584df4f4 | ||
|
|
4421197403 | ||
|
|
15b59467fb | ||
|
|
c95ff0a2ce | ||
|
|
7eff9df025 | ||
|
|
2f05e53e14 | ||
|
|
6089fe4036 | ||
|
|
10d9af0d86 | ||
|
|
c119d5062a | ||
|
|
324609ee60 | ||
|
|
a0a65f2b45 | ||
|
|
45e5ca704c | ||
|
|
f361be0c72 | ||
|
|
1611dc5703 | ||
|
|
04faad84a4 | ||
|
|
19c42e5838 | ||
|
|
4918b69d0a | ||
|
|
c7cec464aa | ||
|
|
91857c4d73 | ||
|
|
fc6f9f4258 | ||
|
|
34f9f9374a | ||
|
|
0ae4c1621f | ||
|
|
c393f5c045 | ||
|
|
1624290dc1 | ||
|
|
c6491990ac | ||
|
|
15dea17923 | ||
|
|
689d5ac7b2 | ||
|
|
2142e20e7d | ||
|
|
dc23126570 | ||
|
|
55856f9060 | ||
|
|
c756ce5fc8 | ||
|
|
0546f25d55 | ||
|
|
7b33717333 | ||
|
|
6ea6d16e58 | ||
|
|
a9b65c83aa | ||
|
|
a497802b50 | ||
|
|
42b0428b9a | ||
|
|
931c553e1d | ||
|
|
f3c0b92a3c | ||
|
|
970cabf241 | ||
|
|
e321ecde5d | ||
|
|
32ac326a77 | ||
|
|
134dcd4466 | ||
|
|
26a44353d4 | ||
|
|
55acb3ef28 | ||
|
|
0e96307726 | ||
|
|
0199a36238 | ||
|
|
3f2f6e83fa | ||
|
|
4fa780cac2 | ||
|
|
edb0f655b0 | ||
|
|
651ada7073 | ||
|
|
efb5d49d04 | ||
|
|
f78cc18b06 | ||
|
|
8acffa11e5 | ||
|
|
f4246807ff | ||
|
|
abf6e7131b | ||
|
|
b2688520cc | ||
|
|
fad0aea108 | ||
|
|
0b63773c83 | ||
|
|
3ef28009ac | ||
|
|
8979e2b191 | ||
|
|
d6910aa1e8 | ||
|
|
afc56c6053 | ||
|
|
1bd504cbfb | ||
|
|
2c089ddb5e | ||
|
|
0b5245643a | ||
|
|
ae35d43f7f | ||
|
|
fe55682c9f | ||
|
|
0d3e6f17e2 | ||
|
|
d5659c4278 | ||
|
|
69b87b9026 | ||
|
|
168bcd3a37 | ||
|
|
e3b6be0cd0 | ||
|
|
eeceda0ca8 | ||
|
|
aa903039c8 | ||
|
|
73d81d0cdb | ||
|
|
01fe539af6 | ||
|
|
c08063ca57 | ||
|
|
60d4af2890 | ||
|
|
6378f074a8 | ||
|
|
5082ec86fd | ||
|
|
6cff5bb099 | ||
|
|
d54562d56f | ||
|
|
2b45a8fae5 | ||
|
|
8654df8994 | ||
|
|
4d5145c17e | ||
|
|
850921bca9 | ||
|
|
1dc6470419 | ||
|
|
b5c197f499 | ||
|
|
d417655a86 | ||
|
|
ebca9f8290 | ||
|
|
53b1f89b30 | ||
|
|
6885191877 | ||
|
|
e69d9fe8b8 | ||
|
|
d6a1f1ae15 | ||
|
|
a7813f4442 | ||
|
|
1e4664987a | ||
|
|
7a819f5d58 | ||
|
|
45ef56e9da | ||
|
|
f43c7aa5d0 | ||
|
|
8d88711e59 | ||
|
|
280c0b60e9 | ||
|
|
605f8f6615 | ||
|
|
0e88b4de1b | ||
|
|
b02aa923d7 | ||
|
|
680c927e1d | ||
|
|
4b2e65abdc | ||
|
|
e501bf6b05 | ||
|
|
80bf2582bd | ||
|
|
586da4424d | ||
|
|
8e0e8c2407 | ||
|
|
40461ac883 | ||
|
|
9288a7e66e | ||
|
|
275db4ec72 | ||
|
|
67b2f8968d | ||
|
|
45ce35dfdb | ||
|
|
52aa9ab2fe | ||
|
|
d24725bd55 | ||
|
|
f368a67dec | ||
|
|
564d1744e1 | ||
|
|
d091ecfa5f | ||
|
|
7d23165e14 | ||
|
|
a9ca3278c6 | ||
|
|
e8734710ca | ||
|
|
f8a0e20df9 | ||
|
|
de90e4de54 | ||
|
|
03cb27f69a | ||
|
|
f86f1dd770 | ||
|
|
ec21ffc571 | ||
|
|
c73c9c74ba | ||
|
|
fd3c264d0c | ||
|
|
3983ba6cd0 | ||
|
|
dc0b5bdd11 | ||
|
|
5b3728186e | ||
|
|
8b0936b678 | ||
|
|
b99e81a389 | ||
|
|
0b8fb0f9a7 | ||
|
|
7d5bbe0130 | ||
|
|
c6637c6814 | ||
|
|
e302a011bb | ||
|
|
25f8bdaa28 | ||
|
|
e7d1018cbc | ||
|
|
e81fa69a03 | ||
|
|
176e76ad2d | ||
|
|
0925a91089 | ||
|
|
c04d0b147c | ||
|
|
a349eff1a3 | ||
|
|
2ddeda9a27 | ||
|
|
00b0fe921f | ||
|
|
bcc6cdf4b1 | ||
|
|
ba46cc3cd6 | ||
|
|
ed1bf609b8 | ||
|
|
13262b678d | ||
|
|
cf9321de23 | ||
|
|
ac7a78bdc0 | ||
|
|
640d3f1f6e | ||
|
|
d716a8081c | ||
|
|
1cec4e68b1 | ||
|
|
9d16299c5b | ||
|
|
cb92b1969c | ||
|
|
55a62d393d | ||
|
|
027b2252db | ||
|
|
ccfb88ddcc | ||
|
|
dde0736fcf | ||
|
|
6498bb5ee6 | ||
|
|
d7ca2db330 | ||
|
|
99b32795a5 | ||
|
|
840f670d5d | ||
|
|
e3c0a4c665 | ||
|
|
915506527a | ||
|
|
7541251344 | ||
|
|
40c9b42b24 | ||
|
|
dfab678070 | ||
|
|
3d371d5942 | ||
|
|
9bfbaa8ded | ||
|
|
be0fc95c45 | ||
|
|
dcb6113eb7 | ||
|
|
55491651f6 | ||
|
|
c1d471ebdc | ||
|
|
069e675f19 | ||
|
|
d34f719a4f | ||
|
|
4eb932a3f0 | ||
|
|
1ab27f2626 | ||
|
|
7d3ce7e602 | ||
|
|
cf530b2c60 | ||
|
|
d9bcd7f592 | ||
|
|
8b854b5cda | ||
|
|
f7b6677bb1 | ||
|
|
0cc5e8f5b8 | ||
|
|
8d153e3b2b | ||
|
|
d15428971c | ||
|
|
f34c2aa437 | ||
|
|
9b3ff5f81f |
1
.github/stale.yml
vendored
1
.github/stale.yml
vendored
@@ -7,6 +7,7 @@ exemptLabels:
|
|||||||
- pinned
|
- pinned
|
||||||
- security
|
- security
|
||||||
- enhancement
|
- enhancement
|
||||||
|
- feature-request
|
||||||
- bug
|
- bug
|
||||||
# Label to use when marking an issue as stale
|
# Label to use when marking an issue as stale
|
||||||
staleLabel: wontfix
|
staleLabel: wontfix
|
||||||
|
|||||||
93
.github/workflows/ci.yml
vendored
93
.github/workflows/ci.yml
vendored
@@ -7,7 +7,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
JAVA_VERSION: 21
|
JAVA_VERSION: 25
|
||||||
DOCKER_BUILD_SUMMARY: false
|
DOCKER_BUILD_SUMMARY: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -16,63 +16,78 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
|
os: [ "ubuntu-24.04", "ubuntu-22.04-arm", "windows-latest" ]
|
||||||
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
# Checkout
|
# Checkout
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
- name: Set up GraalVM
|
- name: Set up GraalVM
|
||||||
uses: graalvm/setup-graalvm@01ed653ac833fe80569f1ef9f25585ba2811baab # v1
|
uses: graalvm/setup-graalvm@f70bc8032f90a104502824a54bcb94b6f01715bc # v1.6.8
|
||||||
with:
|
with:
|
||||||
java-version: ${{ env.JAVA_VERSION }}
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
distribution: "graalvm"
|
distribution: "mandrel"
|
||||||
cache: "maven"
|
cache: "maven"
|
||||||
|
|
||||||
|
- name: Set up NodeJS cache
|
||||||
|
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
|
with:
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: 'commafeed-client/package-lock.json'
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright dependencies
|
||||||
run: sudo apt-get install -y libgbm1
|
run: sudo apt-get install -y libgbm1
|
||||||
if: matrix.os != 'windows-latest'
|
if: matrix.os != 'windows-latest'
|
||||||
|
|
||||||
# Build & Test
|
# Build & Test
|
||||||
- name: Build with Maven
|
- name: Build with Maven
|
||||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipITs=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
||||||
|
|
||||||
# Build pages
|
# Build pages
|
||||||
- name: Copy generated markdown documentation to /documentation
|
- name: Create pages directory structure
|
||||||
run: mkdir documentation && cp ./commafeed-server/target/quarkus-generated-doc/config/commafeed-server.md ./documentation/README.md
|
run: mkdir -p target/pages/documentation/custom-css
|
||||||
|
|
||||||
- name: Generate pages
|
- name: Convert readme file to html
|
||||||
uses: wranders/markdown-to-pages-action@8d8a750832932ac785f5424c8c5543aa0b26bb9a # v1
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
source: README.md
|
||||||
out_path: target/pages
|
output: target/pages/index.html
|
||||||
files: |-
|
|
||||||
README.md
|
- name: Convert config documentation to html
|
||||||
documentation/README.md
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
|
with:
|
||||||
|
source: commafeed-server/target/quarkus-generated-doc/config/commafeed-server.md
|
||||||
|
output: target/pages/documentation/index.html
|
||||||
|
|
||||||
|
- name: Convert custom css documentation to html
|
||||||
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
|
with:
|
||||||
|
source: documentation/CUSTOMCSS.md
|
||||||
|
output: target/pages/documentation/custom-css/index.html
|
||||||
|
|
||||||
# Upload artifacts
|
# Upload artifacts
|
||||||
- name: Upload cross-platform app
|
- name: Upload cross-platform app
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database
|
if: matrix.os == 'ubuntu-24.04' # we only need to upload the cross-platform artifact once per database
|
||||||
with:
|
with:
|
||||||
name: commafeed-${{ matrix.database }}-jvm
|
name: commafeed-${{ matrix.database }}-jvm
|
||||||
path: commafeed-server/target/commafeed-*.zip
|
path: commafeed-server/target/commafeed-*.zip
|
||||||
|
|
||||||
- name: Upload native executable
|
- name: Upload native executable
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
with:
|
with:
|
||||||
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
||||||
path: commafeed-server/target/commafeed-*-runner*
|
path: commafeed-server/target/commafeed-*-runner*
|
||||||
|
|
||||||
- name: Upload pages
|
- name: Upload pages
|
||||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||||
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once
|
if: matrix.os == 'ubuntu-24.04' && matrix.database == 'h2' # we only need to upload the pages once
|
||||||
with:
|
with:
|
||||||
path: target/pages
|
path: target/pages
|
||||||
|
|
||||||
@@ -89,23 +104,23 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Checkout
|
# Checkout
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- name: Install required packages
|
- name: Install required packages
|
||||||
run: sudo apt-get install -y rename unzip
|
run: sudo apt-get install -y rename unzip
|
||||||
|
|
||||||
# Prepare artifacts
|
# Prepare artifacts
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
with:
|
with:
|
||||||
pattern: commafeed-${{ matrix.database }}-*
|
pattern: commafeed-${{ matrix.database }}-*
|
||||||
path: ./artifacts
|
path: ./artifacts
|
||||||
@@ -126,7 +141,7 @@ jobs:
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
@@ -134,7 +149,7 @@ jobs:
|
|||||||
|
|
||||||
## build but don't push for PRs and renovate
|
## build but don't push for PRs and renovate
|
||||||
- name: Docker build - native
|
- name: Docker build - native
|
||||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
@@ -142,7 +157,7 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64/v8
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
|
||||||
- name: Docker build - jvm
|
- name: Docker build - jvm
|
||||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
@@ -151,7 +166,7 @@ jobs:
|
|||||||
|
|
||||||
## build and push tag
|
## build and push tag
|
||||||
- name: Docker build and push tag - native
|
- name: Docker build and push tag - native
|
||||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -163,7 +178,7 @@ jobs:
|
|||||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
||||||
|
|
||||||
- name: Docker build and push tag - jvm
|
- name: Docker build and push tag - jvm
|
||||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -176,7 +191,7 @@ jobs:
|
|||||||
|
|
||||||
## build and push master
|
## build and push master
|
||||||
- name: Docker build and push master - native
|
- name: Docker build and push master - native
|
||||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
if: ${{ github.ref_name == 'master' }}
|
if: ${{ github.ref_name == 'master' }}
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -186,7 +201,7 @@ jobs:
|
|||||||
tags: athou/commafeed:master-${{ matrix.database }}
|
tags: athou/commafeed:master-${{ matrix.database }}
|
||||||
|
|
||||||
- name: Docker build and push master - jvm
|
- name: Docker build and push master - jvm
|
||||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
if: ${{ github.ref_name == 'master' }}
|
if: ${{ github.ref_name == 'master' }}
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -206,12 +221,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
with:
|
with:
|
||||||
pattern: commafeed-*
|
pattern: commafeed-*
|
||||||
path: ./artifacts
|
path: ./artifacts
|
||||||
@@ -221,13 +236,13 @@ jobs:
|
|||||||
run: chmod +x artifacts/*-runner
|
run: chmod +x artifacts/*-runner
|
||||||
|
|
||||||
- name: Extract Changelog Entry
|
- name: Extract Changelog Entry
|
||||||
uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2
|
uses: mindsers/changelog-reader-action@1faaf50aa09d5793d9a100819973df801febfb31 # v2.4.0
|
||||||
id: changelog_reader
|
id: changelog_reader
|
||||||
with:
|
with:
|
||||||
version: ${{ github.ref_name }}
|
version: ${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1
|
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
|
||||||
with:
|
with:
|
||||||
name: CommaFeed ${{ github.ref_name }}
|
name: CommaFeed ${{ github.ref_name }}
|
||||||
body: ${{ steps.changelog_reader.outputs.changes }}
|
body: ${{ steps.changelog_reader.outputs.changes }}
|
||||||
@@ -240,12 +255,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Update Docker Hub Description
|
- name: Update Docker Hub Description
|
||||||
uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4
|
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -265,5 +280,5 @@ jobs:
|
|||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
- uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||||
id: deployment
|
id: deployment
|
||||||
|
|||||||
19
.mvn/wrapper/maven-wrapper.properties
vendored
19
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -1,18 +1,3 @@
|
|||||||
# Licensed to the Apache Software Foundation (ASF) under one
|
wrapperVersion=3.3.4
|
||||||
# or more contributor license agreements. See the NOTICE file
|
|
||||||
# distributed with this work for additional information
|
|
||||||
# regarding copyright ownership. The ASF licenses this file
|
|
||||||
# to you under the Apache License, Version 2.0 (the
|
|
||||||
# "License"); you may not use this file except in compliance
|
|
||||||
# with the License. You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing,
|
|
||||||
# software distributed under the License is distributed on an
|
|
||||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
# KIND, either express or implied. See the License for the
|
|
||||||
# specific language governing permissions and limitations
|
|
||||||
# under the License.
|
|
||||||
distributionType=only-script
|
distributionType=only-script
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zip
|
||||||
|
|||||||
209
CHANGELOG.md
209
CHANGELOG.md
@@ -1,11 +1,163 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [7.4.1]
|
||||||
|
|
||||||
|
- Fix entries being marked as read when scrolling back to an entry that was explicitly marked as unread (#2337)
|
||||||
|
- Add a global failsafe timeout to the new HTTP/2 client to prevent it from hanging indefinitely
|
||||||
|
- Add OCI source label to container images (#2335)
|
||||||
|
|
||||||
|
## [7.4.0]
|
||||||
|
|
||||||
|
- Add support for HTTP/2 when fetching feeds (#2317)
|
||||||
|
- Send more default headers when fetching feeds, to avoid being rejected by some servers (#2315)
|
||||||
|
- Harden the content cleaning of feed entries by only allowing `http`/`https` protocols in `iframe` `src` attributes
|
||||||
|
- Fix the header in mobile mode being wrapped/cut off when too wide, even above the mobile breakpoint (#2080)
|
||||||
|
- Document how to configure Quarkus host validation when running CommaFeed behind a reverse proxy
|
||||||
|
|
||||||
|
## [7.3.2]
|
||||||
|
|
||||||
|
- Prevent users from starring/tagging entries that are not theirs and subsequently reading them (GHSA-prfv-88mm-5gpg)
|
||||||
|
|
||||||
|
## [7.3.1]
|
||||||
|
|
||||||
|
- Prevent Host header injection attacks on the password recovery endpoint. A new setting
|
||||||
|
`commafeed.password-recovery-public-base-url` has been added to specify the base URL to use in password recovery
|
||||||
|
(GHSA-hp8h-jqfm-v7x5)
|
||||||
|
emails. This setting is only required when the password recovery feature is enabled, which is not the default.
|
||||||
|
|
||||||
|
## [7.3.0]
|
||||||
|
|
||||||
|
- Add support for the Google Reader API for native mobile apps that don't support the Fever API
|
||||||
|
- The profile menu now works again on mobile when action buttons are at the bottom of the screen
|
||||||
|
- More SSRF protection regarding IPv6 (GHSA-q5qw-345w-55xg)
|
||||||
|
- Changed the default value of `blockLocalAddresses` to make CommaFeed more secure out-of-the-box. See the "Access to
|
||||||
|
local address blocked" section of the README if you subscribe to feeds that are only available on your local network.
|
||||||
|
|
||||||
|
## [7.2.1]
|
||||||
|
|
||||||
|
- correctly handle favicons hosted at the root of the domain for feeds with non-default ports
|
||||||
|
- prevent XXS injections in third party apps. The CommaFeed React client was already stripping malicious "javascript:"
|
||||||
|
URLs, they are now filtered during feed parsing and no longer stored in the database/served through the REST API
|
||||||
|
(GHSA-44pq-9929-f8mq)
|
||||||
|
|
||||||
|
## [7.2.0]
|
||||||
|
|
||||||
|
- Add support for icons declared in feeds. This is useful for feeds exposed by RSS bridges (#2048)
|
||||||
|
- Search now also works for starred entries (#2217)
|
||||||
|
- The error message when subscribing to a feed is now properly displayed again
|
||||||
|
- Small performance improvements to the feed refresh engine
|
||||||
|
- Removed the Pocket sharing feature because the Pocket service has been discontinued
|
||||||
|
- Prevent the image proxy feature to access any URL to avoid SSRF attacks
|
||||||
|
- Strengthened the SSRF protection by also blocking ULA and CGNAT ranges (GHSA-hgrr-mjfp-gmr6)
|
||||||
|
- Prevent DDOS attacks by filtering OPML files during imports (GHSA-83jp-rww3-57rr)
|
||||||
|
|
||||||
|
## [7.1.0]
|
||||||
|
|
||||||
|
- Display the unread count in the header on mobile, since the tree is hidden by default and the unread count is not
|
||||||
|
visible otherwise (#2055)
|
||||||
|
- The feed refresh engine is now a little bit more performant (#2089)
|
||||||
|
- On shutdown, give the feed refresh engine some time to finish refreshing feeds before killing it. This is controlled
|
||||||
|
by the new `commafeed.shutdown-timeout` setting, which defaults to 2 seconds
|
||||||
|
|
||||||
|
## [7.0.0]
|
||||||
|
|
||||||
|
- Replaced the JEXL filter expression for marking feed entries as read automatically with a user-friendly visual query
|
||||||
|
builder. Expressions are now evaluated with Common Expression Language, which is safer than JEXL and sanboxed by
|
||||||
|
default.
|
||||||
|
- Added a per-feed setting for sending push notifications to ntfy, Gotify or Pushover when new feed entries are
|
||||||
|
discovered (#1610)
|
||||||
|
- Added a per-feed setting for marking entries as read after a number of days (#2041)
|
||||||
|
- The default value of `commafeed.http-client.block-local-addresses` is now false, allowing users to subscribe to feeds
|
||||||
|
only available on their local network. This may be a security risk (SSRF) if your instance is accessible by untrusted
|
||||||
|
users, so you may want to set it to true if you host a public instance of CommaFeed with user registeration enabled.
|
||||||
|
- When `commafeed.http-client.block-local-addresses` is enabled, SSRF is now also mitigated by blocking public websites
|
||||||
|
redirecting to local ones.
|
||||||
|
|
||||||
|
## [6.2.0]
|
||||||
|
|
||||||
|
- Starred entries are no longer deleted after a certain amount of time, they are now kept indefinitely. The new
|
||||||
|
`commafeed.database.cleanup.keep-starred-entries` setting can be disabled to restore the previous behavior if you want
|
||||||
|
to keep deleting starred entries during normal entries cleanup (#1581)
|
||||||
|
|
||||||
|
## [6.1.1]
|
||||||
|
|
||||||
|
- Fix old starred entries not loading if they were marked as read (#2031)
|
||||||
|
|
||||||
|
## [6.1.0]
|
||||||
|
|
||||||
|
- When clicking on the password reset link, a random password is no longer generated automatically. The user is now
|
||||||
|
redirected to a page where they can set their own password (#2023)
|
||||||
|
- Use browser preferred language instead of English when using CommaFeed for the first time (#2018)
|
||||||
|
- The profile menu is now closed when scrolling the page (#2019)
|
||||||
|
- The "disable pull to refresh" feature is now disabled by default (#2030)
|
||||||
|
|
||||||
|
## [6.0.0]
|
||||||
|
|
||||||
|
- When booting CommaFeed for the first time, the default "admin" account is no longer created automatically. A setup
|
||||||
|
wizard will guide you through the creation of an admin account
|
||||||
|
- Default password complexity requirements have been lowered for local network deployments, where strict password rules
|
||||||
|
are often unnecessary. The `commafeed.users.strict-password-policy` setting has been replaced by
|
||||||
|
`commafeed.users.minimum-password-length` with a default value of `4` (#1916)
|
||||||
|
- Email addresses are no longer required when creating users and when they update their profile. The
|
||||||
|
`commafeed.users.email-address-required` setting has been added to restore the previous behavior (#1914)
|
||||||
|
- Java 25+ is now required to build and run CommaFeed
|
||||||
|
|
||||||
|
## [5.12.1]
|
||||||
|
|
||||||
|
- The favicon is now crispier (#1978)
|
||||||
|
- The ReadKit iOS app now works via the Fever API (#1602)
|
||||||
|
|
||||||
|
## [5.12.0]
|
||||||
|
|
||||||
|
- Added a setting to disable the "disable pull to refresh" feature because it messes with some browsers (#1168)
|
||||||
|
- Emojis in feeds are now correctly displayed (#1955)
|
||||||
|
- Don't show "Star/Unstar" in the context menu if the entry is too old to be starred (#1935)
|
||||||
|
- Invalid relative urls in feeds no longer prevent those feeds from being parsed (#1939)
|
||||||
|
- Fix an issue that could prevent large feeds from being parsed when using Java 24+ (#1961)
|
||||||
|
- Enforce user password validation when created in the admin view (#1937)
|
||||||
|
- The process in the docker native image is now called "commafeed" instead of "application"
|
||||||
|
|
||||||
|
## [5.11.1]
|
||||||
|
|
||||||
|
- The search limit of 3 characters has been removed (#1887)
|
||||||
|
- Fix an issue that caused feed filtering expressions to be incorrectly converted to lowercase when saving them (#1899)
|
||||||
|
|
||||||
|
## [5.11.0]
|
||||||
|
|
||||||
|
- Add an option to navigate to the next unread category/feed when marking all entries as read (#1807)
|
||||||
|
- Google Analytics support has been removed
|
||||||
|
|
||||||
|
## [5.10.0]
|
||||||
|
|
||||||
|
- Add an indicator next to each feed's unread count in the tree to show when new entries are discovered while the app is
|
||||||
|
open (#1762)
|
||||||
|
- Feeds with uppercase HTTP:// or HTTPS:// URLs are now correctly handled again
|
||||||
|
- The aarch64 native executable now also works on the Raspberry Pi 5 (#1795)
|
||||||
|
- Improve general performance of the UI by reducing the number of re-renders, especially when a lot of entries are
|
||||||
|
displayed (#1087)
|
||||||
|
|
||||||
|
## [5.9.0]
|
||||||
|
|
||||||
|
- A lot of CSS classes have been added to the elements of the application to ease custom CSS rules (#1757)
|
||||||
|
- Added a link in the README to the [documentation](https://athou.github.io/commafeed/documentation/custom-css/) of the
|
||||||
|
new CSS classes
|
||||||
|
- Static resources are now cached for much longer (#1782)
|
||||||
|
|
||||||
|
## [5.8.0]
|
||||||
|
|
||||||
|
- A color picker is now available on the settings page to change the orange accent of the application (#1598)
|
||||||
|
- A font size slider is now available to change the size of the text of feed entries (#1462)
|
||||||
|
- The "mark all as read" confirmation setting now also applies to the "shift+a" keyboard shortcut (#1744)
|
||||||
|
- CommaFeed wil try to match the language of the browser before defaulting to english (#1767)
|
||||||
|
- The default value for the number of entries to keep above the selected entry when scrolling is now 1 instead of 0 to
|
||||||
|
match what other feed readers do
|
||||||
|
|
||||||
## [5.7.0]
|
## [5.7.0]
|
||||||
|
|
||||||
- Add Shift+J/Shift+K keyboard shortcuts to navigate to the next/previous feed or category with unread entries (#1746)
|
- Add Shift+J/Shift+K keyboard shortcuts to navigate to the next/previous feed or category with unread entries (#1558)
|
||||||
- Add the referrer "no-referrer" meta to index.html (#1724)
|
- Add the referrer "no-referrer" meta to index.html (#1724)
|
||||||
- Load custom JS code when the app is done loading (#1724)
|
- Load custom JS code when the app is done loading (#1724)
|
||||||
- Correctly handle feeds that return an unmodified Last-Modified header but a different ETag header (#1730)
|
- Correctly handle feeds that return an unmodified Last-Modified header but a different ETag header (#1746)
|
||||||
- Restore gzip compression of responses that was accidentaly disabled since 5.0.0
|
- Restore gzip compression of responses that was accidentaly disabled since 5.0.0
|
||||||
- Fix tooltips not showing up in mobile view
|
- Fix tooltips not showing up in mobile view
|
||||||
- Fix the bookmarklet generator on the About page
|
- Fix the bookmarklet generator on the About page
|
||||||
@@ -17,14 +169,22 @@
|
|||||||
|
|
||||||
## [5.6.0]
|
## [5.6.0]
|
||||||
|
|
||||||
- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is now true. This means feeds no longer refresh exactly every 5 minutes (the default value of `commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new `commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active feeds refresh more often (#1677)
|
- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is
|
||||||
|
now true. This means feeds no longer refresh exactly every 5 minutes (the default value of
|
||||||
|
`commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new
|
||||||
|
`commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active
|
||||||
|
feeds refresh more often (#1677)
|
||||||
- Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677)
|
- Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677)
|
||||||
- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses` setting if you subscribe to feeds only available on your local network and you trust all your users
|
- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could
|
||||||
- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does not contain a "Retry-After" header
|
potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses`
|
||||||
|
setting if you subscribe to feeds only available on your local network and you trust all your users
|
||||||
|
- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does
|
||||||
|
not contain a "Retry-After" header
|
||||||
|
|
||||||
## [5.5.0]
|
## [5.5.0]
|
||||||
|
|
||||||
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header
|
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this
|
||||||
|
header
|
||||||
- Audio enclosures (e.g. podcasts) now fill available entry width
|
- Audio enclosures (e.g. podcasts) now fill available entry width
|
||||||
- Fix an issue with some labels not correctly internationalized
|
- Fix an issue with some labels not correctly internationalized
|
||||||
|
|
||||||
@@ -41,8 +201,10 @@
|
|||||||
## [5.3.5]
|
## [5.3.5]
|
||||||
|
|
||||||
- Fixed an issue with the aspect ratio of images of some feeds (#1595)
|
- Fixed an issue with the aspect ratio of images of some feeds (#1595)
|
||||||
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615)
|
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age
|
||||||
- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may need to update it to the most recent patch version to support `-proc:full` (#1618)
|
property (#1615)
|
||||||
|
- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may
|
||||||
|
need to update it to the most recent patch version to support `-proc:full` (#1618)
|
||||||
|
|
||||||
## [5.3.4]
|
## [5.3.4]
|
||||||
|
|
||||||
@@ -69,19 +231,22 @@
|
|||||||
|
|
||||||
- Added an option to keep a number of entries above the selected entry when scrolling
|
- Added an option to keep a number of entries above the selected entry when scrolling
|
||||||
- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431)
|
- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431)
|
||||||
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour)
|
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up
|
||||||
|
(every hour)
|
||||||
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
|
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
|
||||||
|
|
||||||
## [5.1.1]
|
## [5.1.1]
|
||||||
|
|
||||||
- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544)
|
- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544)
|
||||||
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543)
|
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is
|
||||||
|
selected (#1543)
|
||||||
|
|
||||||
## [5.1.0]
|
## [5.1.0]
|
||||||
|
|
||||||
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)
|
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)
|
||||||
- Fixed an issue that could prevent the app from starting on some systems (#1532)
|
- Fixed an issue that could prevent the app from starting on some systems (#1532)
|
||||||
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date
|
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to
|
||||||
|
date
|
||||||
- Reduced database cleanup log verbosity
|
- Reduced database cleanup log verbosity
|
||||||
|
|
||||||
## [5.0.2]
|
## [5.0.2]
|
||||||
@@ -96,9 +261,9 @@
|
|||||||
## [5.0.0]
|
## [5.0.0]
|
||||||
|
|
||||||
CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in
|
CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in
|
||||||
the [announcement](https://github.com/Athou/commafeed/discussions/1517).
|
the [announcement](https://github.com/Athou/commafeed/discussions/1517). The gist of it is that CommaFeed can now be
|
||||||
The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around
|
compiled to a native binary, resulting in blazing fast startup times (around 0.3s) and very low memory footprint (<
|
||||||
0.3s) and very low memory footprint (< 50M).
|
50M).
|
||||||
|
|
||||||
- CommaFeed now has a different package for each supported database.
|
- CommaFeed now has a different package for each supported database.
|
||||||
- If you are deploying CommaFeed with a precompiled package, please
|
- If you are deploying CommaFeed with a precompiled package, please
|
||||||
@@ -154,8 +319,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
|||||||
- add a button in the entry headers to open links in a new tab (#1333)
|
- add a button in the entry headers to open links in a new tab (#1333)
|
||||||
- add two options in the settings to toggle those buttons
|
- add two options in the settings to toggle those buttons
|
||||||
- accept .opml file extension when importing and export with the .opml extension
|
- accept .opml file extension when importing and export with the .opml extension
|
||||||
- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read (
|
- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read
|
||||||
older than `keepStatusDays`) (#1303)
|
(older than `keepStatusDays`) (#1303)
|
||||||
|
|
||||||
## [4.3.3]
|
## [4.3.3]
|
||||||
|
|
||||||
@@ -199,9 +364,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
|||||||
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
||||||
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
|
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
|
||||||
- the full hierarchy of categories are now displayed in the category dropdown (#1045)
|
- the full hierarchy of categories are now displayed in the category dropdown (#1045)
|
||||||
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup.
|
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup. The setting is
|
||||||
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
|
disabled by default for existing installations, except for the docker image where it is enabled and set to 365 days
|
||||||
to 365 days
|
|
||||||
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
||||||
page instead of the welcome page when not logged in (#1185)
|
page instead of the welcome page when not logged in (#1185)
|
||||||
- the sidebar resizer is no longer shown in the middle of the screen on mobile
|
- the sidebar resizer is no longer shown in the middle of the screen on mobile
|
||||||
@@ -227,8 +391,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
|||||||
request, reducing CPU usage
|
request, reducing CPU usage
|
||||||
- updated UI library Mantine to 7.0, improving performance
|
- updated UI library Mantine to 7.0, improving performance
|
||||||
- the h2 embedded database is now compacted on shutdown to reclaim unused space
|
- the h2 embedded database is now compacted on shutdown to reclaim unused space
|
||||||
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is
|
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is recommended
|
||||||
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
(see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
||||||
- migrated documentation from swagger 2 to openapi 3
|
- migrated documentation from swagger 2 to openapi 3
|
||||||
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
||||||
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
||||||
@@ -470,7 +634,6 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
|||||||
## [2.0.0]
|
## [2.0.0]
|
||||||
|
|
||||||
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
||||||
consumption and better overall performances.
|
consumption and better overall performances. See the README on how to build CommaFeed from now on.
|
||||||
See the README on how to build CommaFeed from now on.
|
|
||||||
- CommaFeed should no longer fetch the same feed multiple times in a row
|
- CommaFeed should no longer fetch the same feed multiple times in a row
|
||||||
- Users can use their username or email to log in
|
- Users can use their username or email to log in
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -15,8 +15,10 @@ Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeSc
|
|||||||
- Supports thousands of users and millions of feeds
|
- Supports thousands of users and millions of feeds
|
||||||
- OPML import/export
|
- OPML import/export
|
||||||
- REST API
|
- REST API
|
||||||
- Fever-compatible API for native mobile apps
|
- Fever and Google Reader API for native mobile apps
|
||||||
- Can automatically mark articles as read based on user-defined rules
|
- Can automatically mark articles as read based on user-defined rules
|
||||||
|
- Push notifications when new articles are published
|
||||||
|
- Highly customizable with [custom CSS](https://athou.github.io/commafeed/documentation/custom-css) and JavaScript
|
||||||
- [Browser extension](https://github.com/Athou/commafeed-browser-extension)
|
- [Browser extension](https://github.com/Athou/commafeed-browser-extension)
|
||||||
- Compiles to native code for blazing fast startup and low memory usage
|
- Compiles to native code for blazing fast startup and low memory usage
|
||||||
- Supports 4 databases
|
- Supports 4 databases
|
||||||
@@ -25,11 +27,18 @@ Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeSc
|
|||||||
- MySQL
|
- MySQL
|
||||||
- MariaDB
|
- MariaDB
|
||||||
|
|
||||||
## Deployment
|
## Usage
|
||||||
|
|
||||||
|
### Public instance
|
||||||
|
|
||||||
|
A free public instance is available at https://www.commafeed.com.
|
||||||
|
|
||||||
|
It has no ads, no tracking, and your data is never exploited or sold to third parties. The service is funded entirely through donations.
|
||||||
|
However, this public instance does have a few limitations compared to self-hosted setups, outlined [here](https://github.com/Athou/commafeed/discussions/1567).
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
Docker is the easiest way to get started with CommaFeed.
|
Docker is the easiest way to get started with self-hosted CommaFeed.
|
||||||
|
|
||||||
Docker images are built automatically and are available at https://hub.docker.com/r/athou/commafeed
|
Docker images are built automatically and are available at https://hub.docker.com/r/athou/commafeed
|
||||||
|
|
||||||
@@ -61,8 +70,8 @@ memory usage.
|
|||||||
./mvnw clean package [-P<database> [-Pnative]] [-DskipTests]
|
./mvnw clean package [-P<database> [-Pnative]] [-DskipTests]
|
||||||
|
|
||||||
- `<database>` can be one of `h2`, `postgresql`, `mysql` or `mariadb`. The default is `h2`.
|
- `<database>` can be one of `h2`, `postgresql`, `mysql` or `mariadb`. The default is `h2`.
|
||||||
- `-Pnative` compiles the application to native code. This requires GraalVM to be installed (`GRAALVM_HOME` environment
|
- `-Pnative` compiles the application to native code. This requires either GraalVM to be installed (`GRAALVM_HOME` environment
|
||||||
variable pointing to a GraalVM installation).
|
variable pointing to a GraalVM installation) or a container environment to be available (docker/podman/...).
|
||||||
- `-DskipTests` to speed up the build process by skipping tests.
|
- `-DskipTests` to speed up the build process by skipping tests.
|
||||||
|
|
||||||
When the build is complete:
|
When the build is complete:
|
||||||
@@ -102,7 +111,7 @@ There are multiple ways to configure CommaFeed:
|
|||||||
- Environment variables (keys in UPPER_CASE)
|
- Environment variables (keys in UPPER_CASE)
|
||||||
- a `.env` file in the working directory (keys in UPPER_CASE)
|
- a `.env` file in the working directory (keys in UPPER_CASE)
|
||||||
|
|
||||||
The properties file is recommended because CommaFeed will be able to warn about invalid properties and typos.
|
When in doubt, the properties file is recommended because CommaFeed will be able to warn about invalid properties and typos.
|
||||||
|
|
||||||
All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are optional and have sensible default values.
|
All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are optional and have sensible default values.
|
||||||
|
|
||||||
@@ -112,7 +121,6 @@ meaning that you will have to log back in after each restart of the application.
|
|||||||
All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config).
|
All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config).
|
||||||
|
|
||||||
When started, the server will listen on http://localhost:8082.
|
When started, the server will listen on http://localhost:8082.
|
||||||
The default user is `admin` and the default password is `admin`.
|
|
||||||
|
|
||||||
### Updates
|
### Updates
|
||||||
|
|
||||||
@@ -151,6 +159,28 @@ named [Semeru](https://developer.ibm.com/languages/java/semeru-runtimes/download
|
|||||||
This is the JVM used in
|
This is the JVM used in
|
||||||
the [Docker image](https://github.com/Athou/commafeed/blob/master/commafeed-server/src/main/docker/Dockerfile.jvm).
|
the [Docker image](https://github.com/Athou/commafeed/blob/master/commafeed-server/src/main/docker/Dockerfile.jvm).
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Getting "Access to local address blocked" when adding a feed
|
||||||
|
|
||||||
|
CommaFeed blocks access to local resources by default to prevent [SSRF](https://en.wikipedia.org/wiki/Server-side_request_forgery) attacks.
|
||||||
|
If you want to subscribe to feeds that are only available on your local network, you can disable this security measure by setting the `commafeed.http-client.block-local-addresses` variable to `false`.
|
||||||
|
Do this only if you trust all users of your CommaFeed instance not to access private resources.
|
||||||
|
|
||||||
|
### Listen on a single network interface
|
||||||
|
|
||||||
|
By default, CommaFeed listens on all interfaces. You can restrict it by setting `quarkus.http.host`.
|
||||||
|
|
||||||
|
Note that if you set it to a local name like `127.0.0.1` host validation is enabled automatically. This prevents
|
||||||
|
access if you're using a reverse proxy like Nginx. To fix, add your actual hostname to `allowed-hosts`:
|
||||||
|
|
||||||
|
```
|
||||||
|
quarkus.http.host=127.0.0.1
|
||||||
|
quarkus.http.proxy.proxy-address-forwarding=true
|
||||||
|
quarkus.http.proxy.allow-forwarded=true
|
||||||
|
quarkus.http.host-validation.allowed-hosts=commafeed.example.com
|
||||||
|
```
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
Files for internationalization are
|
Files for internationalization are
|
||||||
|
|||||||
@@ -2,5 +2,4 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you found a vulnerability that you deem too sensitive to disclose publicly in a Github issue, please send an email at jeremiepanzer at gmail dot com.
|
If you found a vulnerability that you deem too sensitive to disclose publicly in a Github issue, please create a private security advisory here: https://github.com/Athou/commafeed/security/advisories
|
||||||
Thanks !
|
|
||||||
|
|||||||
3
commafeed-client/.gitignore
vendored
3
commafeed-client/.gitignore
vendored
@@ -23,9 +23,6 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# rollup-plugin-visualizer
|
|
||||||
/stats.html
|
|
||||||
|
|
||||||
# vite
|
# vite
|
||||||
vite.config.ts.timestamp-*.mjs
|
vite.config.ts.timestamp-*.mjs
|
||||||
|
|
||||||
|
|||||||
1
commafeed-client/.npmrc
Normal file
1
commafeed-client/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
loglevel=warn
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"lineEnding": "lf",
|
"lineEnding": "lf",
|
||||||
"lineWidth": 140
|
"lineWidth": 120
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": ["dist", "node_modules", "target", "target-ide"]
|
"includes": ["**", "!**/dist", "!**/node_modules", "!**/target", "!**/target-ide"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LinguiConfig } from "@lingui/conf"
|
import type { LinguiConfig } from "@lingui/conf"
|
||||||
|
import { formatter } from "@lingui/format-po"
|
||||||
|
|
||||||
const config: LinguiConfig = {
|
const config: LinguiConfig = {
|
||||||
locales: [
|
locales: [
|
||||||
@@ -38,11 +39,9 @@ const config: LinguiConfig = {
|
|||||||
exclude: ["src/locales/**"],
|
exclude: ["src/locales/**"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
format: "po",
|
format: formatter({
|
||||||
formatOptions: {
|
|
||||||
origins: true,
|
|
||||||
lineNumbers: false,
|
lineNumbers: false,
|
||||||
},
|
}),
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
fallbackLocales: {
|
fallbackLocales: {
|
||||||
default: "en",
|
default: "en",
|
||||||
|
|||||||
7571
commafeed-client/package-lock.json
generated
7571
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,70 +15,69 @@
|
|||||||
"i18n:extract": "lingui extract --clean"
|
"i18n:extract": "lingui extract --clean"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "11.14.0",
|
||||||
"@fontsource/open-sans": "^5.2.5",
|
"@fontsource/open-sans": "5.3.0",
|
||||||
"@lingui/core": "^5.3.0",
|
"@lingui/core": "6.12.0",
|
||||||
"@lingui/react": "^5.3.0",
|
"@lingui/react": "6.12.0",
|
||||||
"@mantine/core": "^7.17.3",
|
"@mantine/core": "9.7.4",
|
||||||
"@mantine/form": "^7.17.3",
|
"@mantine/dates": "9.7.4",
|
||||||
"@mantine/hooks": "^7.17.3",
|
"@mantine/form": "9.7.4",
|
||||||
"@mantine/modals": "^7.17.3",
|
"@mantine/hooks": "9.7.4",
|
||||||
"@mantine/notifications": "^7.17.3",
|
"@mantine/modals": "9.7.4",
|
||||||
"@mantine/spotlight": "^7.17.3",
|
"@mantine/notifications": "9.7.4",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@mantine/spotlight": "9.7.4",
|
||||||
"@reduxjs/toolkit": "^2.6.1",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"axios": "^1.8.4",
|
"@react-querybuilder/mantine": "8.26.0",
|
||||||
"dayjs": "^1.11.13",
|
"@reduxjs/toolkit": "2.13.0",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"@rolldown/plugin-babel": "0.2.4",
|
||||||
"interweave": "^13.1.1",
|
"axios": "1.21.0",
|
||||||
"monaco-editor": "^0.52.2",
|
"dayjs": "1.11.23",
|
||||||
"mousetrap": "^1.6.5",
|
"escape-string-regexp": "5.0.0",
|
||||||
"react": "^19.1.0",
|
"interweave": "13.1.1",
|
||||||
"react-async-hook": "^4.0.0",
|
"monaco-editor": "0.57.0",
|
||||||
"react-contexify": "^6.0.0",
|
"mousetrap": "1.6.5",
|
||||||
"react-device-detect": "^2.2.3",
|
"react": "19.3.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-async-hook": "4.0.0",
|
||||||
"react-draggable": "^4.4.6",
|
"react-contexify": "6.0.0",
|
||||||
"react-ga4": "^2.1.0",
|
"react-dom": "19.3.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-draggable": "4.7.2",
|
||||||
"react-infinite-scroller": "^1.2.6",
|
"react-icons": "5.7.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-infinite-scroller": "1.2.6",
|
||||||
"react-router-dom": "^7.4.1",
|
"react-querybuilder": "8.26.0",
|
||||||
"react-swipeable": "^7.0.2",
|
"react-redux": "9.3.1",
|
||||||
"redoc": "^2.4.0",
|
"react-router-dom": "7.18.5",
|
||||||
"style-to-object": "^1.0.8",
|
"react-swipeable": "7.0.2",
|
||||||
"throttle-debounce": "^5.0.2",
|
"style-to-object": "2.0.2",
|
||||||
"tinycon": "^0.6.8",
|
"throttle-debounce": "5.0.2",
|
||||||
"tss-react": "^4.9.16",
|
"tinycon": "0.6.8",
|
||||||
"websocket-heartbeat-js": "^1.1.3"
|
"tss-react": "4.9.21",
|
||||||
|
"websocket-heartbeat-js": "1.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "2.6.4",
|
||||||
"@lingui/babel-plugin-lingui-macro": "^5.3.0",
|
"@lingui/cli": "6.12.0",
|
||||||
"@lingui/cli": "^5.3.0",
|
"@lingui/vite-plugin": "6.12.0",
|
||||||
"@lingui/vite-plugin": "^5.3.0",
|
"@testing-library/jest-dom": "7.0.1",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/react": "16.3.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/user-event": "14.6.8",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@types/mousetrap": "1.6.15",
|
||||||
"@types/mousetrap": "^1.6.15",
|
"@types/react": "19.3.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@types/react-dom": "^19.1.1",
|
"@types/react-infinite-scroller": "1.2.5",
|
||||||
"@types/react-infinite-scroller": "^1.2.5",
|
"@types/throttle-debounce": "5.0.2",
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/tinycon": "0.6.7",
|
||||||
"@types/tinycon": "^0.6.7",
|
"@vitejs/plugin-react": "6.2.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"jsdom": "30.1.3",
|
||||||
"jsdom": "^26.0.0",
|
"typescript": "7.0.2",
|
||||||
"rollup-plugin-visualizer": "^5.14.0",
|
"vite": "8.3.5",
|
||||||
"typescript": "^5.8.2",
|
"vite-plugin-checker": "0.14.5",
|
||||||
"vite": "^6.2.5",
|
"vitest": "5.0.5",
|
||||||
"vite-plugin-checker": "^0.9.1",
|
"yaml": "2.9.1"
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
|
||||||
"vitest": "^3.1.1"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"react-infinite-scroller": {
|
"react-infinite-scroller": {
|
||||||
"react": "^19.1.0"
|
"react": "19.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>com.commafeed</groupId>
|
<groupId>com.commafeed</groupId>
|
||||||
<artifactId>commafeed</artifactId>
|
<artifactId>commafeed</artifactId>
|
||||||
<version>5.7.0</version>
|
<version>7.4.1</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>commafeed-client</artifactId>
|
<artifactId>commafeed-client</artifactId>
|
||||||
<name>CommaFeed Client</name>
|
<name>CommaFeed Client</name>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<!-- renovate: datasource=node-version depName=node -->
|
<!-- renovate: datasource=node-version depName=node -->
|
||||||
<node.version>v22.14.0</node.version>
|
<node.version>v26.13.0</node.version>
|
||||||
<!-- renovate: datasource=npm depName=npm -->
|
<!-- renovate: datasource=npm depName=npm -->
|
||||||
<npm.version>11.2.0</npm.version>
|
<npm.version>12.2.1</npm.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>com.github.eirslett</groupId>
|
<groupId>com.github.eirslett</groupId>
|
||||||
<artifactId>frontend-maven-plugin</artifactId>
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
<version>1.15.1</version>
|
<version>2.0.2</version>
|
||||||
<?m2e ignore?>
|
<?m2e ignore?>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<artifactId>maven-resources-plugin</artifactId>
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
<version>3.3.1</version>
|
<version>3.5.0</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<id>copy web interface to resources</id>
|
<id>copy web interface to resources</id>
|
||||||
@@ -94,4 +94,49 @@
|
|||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<!-- This profile is used to kill the Biome process on Windows -->
|
||||||
|
<!-- npm ci can fail if Biome is running (e.g., in the IDE) because it locks some files -->
|
||||||
|
<profile>
|
||||||
|
<id>kill-biome</id>
|
||||||
|
<activation>
|
||||||
|
<os>
|
||||||
|
<family>Windows</family>
|
||||||
|
</os>
|
||||||
|
</activation>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
<version>3.6.4</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>kill-biome</id>
|
||||||
|
<phase>initialize</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>exec</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<executable>taskkill</executable>
|
||||||
|
<arguments>
|
||||||
|
<argument>/F</argument>
|
||||||
|
<argument>/IM</argument>
|
||||||
|
<argument>biome.exe</argument>
|
||||||
|
</arguments>
|
||||||
|
<successCodes>
|
||||||
|
<successCode>0</successCode>
|
||||||
|
<!-- taskkill returns 128 if the process is not found, which is fine in this case -->
|
||||||
|
<successCode>128</successCode>
|
||||||
|
</successCodes>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
40
commafeed-client/public/favicon.svg
Normal file
40
commafeed-client/public/favicon.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
height="393.84613"
|
||||||
|
width="393.84613"
|
||||||
|
viewBox="0 0 5.0480766 5.0480766"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>CommaFeed favicon</title>
|
||||||
|
<defs
|
||||||
|
id="defs3" />
|
||||||
|
<rect
|
||||||
|
fill="#f88a14"
|
||||||
|
rx="0.53846151"
|
||||||
|
ry="0.53846151"
|
||||||
|
height="5.0480766"
|
||||||
|
width="5.0480766"
|
||||||
|
id="rect1"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
style="stroke-width:0.769231" />
|
||||||
|
<path
|
||||||
|
d="m 1.3450904,0.64548657 c 2.9002,0 2.9002,2.91010003 2.9002,2.91010003"
|
||||||
|
fill="none"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="0.78125"
|
||||||
|
id="path1" />
|
||||||
|
<path
|
||||||
|
d="m 1.3377904,1.9915866 c 1.5705,-0.00908 1.5705,1.5639 1.5705,1.5639"
|
||||||
|
fill="none"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="0.78125"
|
||||||
|
id="path2" />
|
||||||
|
<path
|
||||||
|
d="m 2.0192904,3.5227866 c 0,0.23366 -0.10712,0.47418 -0.24663,0.6537 -0.1814,0.2333 -0.5705,0.5618 -0.6913,0.5653 0.0402,-0.0662 0.263,-0.5654 0.2563,-0.5654 -0.36423004,0 -0.65950004,-0.29265 -0.65950004,-0.65365 0,-0.361 0.29527,-0.65365 0.65950004,-0.65365 0.36423,0 0.68159,0.29265 0.68159,0.65365 z"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="path3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,48 +1,53 @@
|
|||||||
import { i18n } from "@lingui/core"
|
import { i18n } from "@lingui/core"
|
||||||
import { I18nProvider } from "@lingui/react"
|
import { I18nProvider } from "@lingui/react"
|
||||||
import { MantineProvider } from "@mantine/core"
|
import { MantineProvider, v8CssVariablesResolver } from "@mantine/core"
|
||||||
import { ModalsProvider } from "@mantine/modals"
|
import { ModalsProvider } from "@mantine/modals"
|
||||||
import { Notifications } from "@mantine/notifications"
|
import { Notifications } from "@mantine/notifications"
|
||||||
import { Constants } from "app/constants"
|
import type React from "react"
|
||||||
import { redirectTo } from "app/redirect/slice"
|
import { useEffect } from "react"
|
||||||
import { reloadServerInfos } from "app/server/thunks"
|
import { HashRouter, Navigate, Route, Routes, useNavigate } from "react-router-dom"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { Constants } from "@/app/constants"
|
||||||
import { categoryUnreadCount } from "app/utils"
|
import { redirectTo } from "@/app/redirect/slice"
|
||||||
import { DisablePullToRefresh } from "components/DisablePullToRefresh"
|
import { redirectToInitialSetup } from "@/app/redirect/thunks"
|
||||||
import { ErrorBoundary } from "components/ErrorBoundary"
|
import { reloadServerInfos } from "@/app/server/thunks"
|
||||||
import { Header } from "components/header/Header"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { Tree } from "components/sidebar/Tree"
|
import { ErrorBoundary } from "@/components/ErrorBoundary"
|
||||||
import { useAppLoading } from "hooks/useAppLoading"
|
import { Header } from "@/components/header/Header"
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
import { Tree } from "@/components/sidebar/Tree"
|
||||||
import { useI18n } from "i18n"
|
import { useI18n } from "@/i18n"
|
||||||
import { WelcomePage } from "pages/WelcomePage"
|
import { AdminUsersPage } from "@/pages/admin/AdminUsersPage"
|
||||||
import { AdminUsersPage } from "pages/admin/AdminUsersPage"
|
import { MetricsPage } from "@/pages/admin/MetricsPage"
|
||||||
import { MetricsPage } from "pages/admin/MetricsPage"
|
import { AboutPage } from "@/pages/app/AboutPage"
|
||||||
import { AboutPage } from "pages/app/AboutPage"
|
import { AddPage } from "@/pages/app/AddPage"
|
||||||
import { AddPage } from "pages/app/AddPage"
|
import { CategoryDetailsPage } from "@/pages/app/CategoryDetailsPage"
|
||||||
import { CategoryDetailsPage } from "pages/app/CategoryDetailsPage"
|
import { DonatePage } from "@/pages/app/DonatePage"
|
||||||
import { DonatePage } from "pages/app/DonatePage"
|
import { FeedDetailsPage } from "@/pages/app/FeedDetailsPage"
|
||||||
import { FeedDetailsPage } from "pages/app/FeedDetailsPage"
|
import { FeedEntriesPage } from "@/pages/app/FeedEntriesPage"
|
||||||
import { FeedEntriesPage } from "pages/app/FeedEntriesPage"
|
import Layout from "@/pages/app/Layout"
|
||||||
import Layout from "pages/app/Layout"
|
import { SettingsPage } from "@/pages/app/SettingsPage"
|
||||||
import { SettingsPage } from "pages/app/SettingsPage"
|
import { TagDetailsPage } from "@/pages/app/TagDetailsPage"
|
||||||
import { TagDetailsPage } from "pages/app/TagDetailsPage"
|
import { InitialSetupPage } from "@/pages/auth/InitialSetupPage"
|
||||||
import { LoginPage } from "pages/auth/LoginPage"
|
import { LoginPage } from "@/pages/auth/LoginPage"
|
||||||
import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage"
|
import { PasswordRecoveryPage } from "@/pages/auth/PasswordRecoveryPage"
|
||||||
import { RegistrationPage } from "pages/auth/RegistrationPage"
|
import { PasswordResetPage } from "@/pages/auth/PasswordResetPage"
|
||||||
import React, { useEffect, useState } from "react"
|
import { RegistrationPage } from "@/pages/auth/RegistrationPage"
|
||||||
import { isSafari } from "react-device-detect"
|
import { WelcomePage } from "@/pages/WelcomePage"
|
||||||
import ReactGA from "react-ga4"
|
|
||||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
|
|
||||||
import Tinycon from "tinycon"
|
|
||||||
|
|
||||||
function Providers(props: { children: React.ReactNode }) {
|
function Providers(
|
||||||
|
props: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const primaryColor =
|
||||||
|
useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
||||||
return (
|
return (
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
defaultColorScheme="auto"
|
defaultColorScheme="auto"
|
||||||
|
// keep using css variables from mantine v8
|
||||||
|
cssVariablesResolver={v8CssVariablesResolver}
|
||||||
theme={{
|
theme={{
|
||||||
primaryColor: "orange",
|
primaryColor: primaryColor,
|
||||||
fontFamily: "Open Sans",
|
fontFamily: "Open Sans",
|
||||||
colors: {
|
colors: {
|
||||||
// keep using dark colors from mantine v6
|
// keep using dark colors from mantine v6
|
||||||
@@ -71,9 +76,6 @@ function Providers(props: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// api documentation page is very large, load only on-demand
|
|
||||||
const ApiDocumentationPage = React.lazy(async () => await import("pages/app/ApiDocumentationPage"))
|
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
||||||
|
|
||||||
@@ -81,11 +83,15 @@ function AppRoutes() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to={`/app/category/${Constants.categories.all.id}`} replace />} />
|
<Route path="/" element={<Navigate to={`/app/category/${Constants.categories.all.id}`} replace />} />
|
||||||
<Route path="welcome" element={<WelcomePage />} />
|
<Route path="welcome" element={<WelcomePage />} />
|
||||||
|
<Route path="setup" element={<InitialSetupPage />} />
|
||||||
<Route path="login" element={<LoginPage />} />
|
<Route path="login" element={<LoginPage />} />
|
||||||
<Route path="register" element={<RegistrationPage />} />
|
<Route path="register" element={<RegistrationPage />} />
|
||||||
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
||||||
<Route path="api" element={<ApiDocumentationPage />} />
|
<Route path="passwordReset" element={<PasswordResetPage />} />
|
||||||
<Route path="app" element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}>
|
<Route
|
||||||
|
path="app"
|
||||||
|
element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}
|
||||||
|
>
|
||||||
<Route path="category">
|
<Route path="category">
|
||||||
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
||||||
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
||||||
@@ -112,6 +118,18 @@ function AppRoutes() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InitialSetupHandler() {
|
||||||
|
const serverInfos = useAppSelector(state => state.server.serverInfos)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverInfos?.initialSetupRequired) {
|
||||||
|
dispatch(redirectToInitialSetup())
|
||||||
|
}
|
||||||
|
}, [serverInfos, dispatch])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function RedirectHandler() {
|
function RedirectHandler() {
|
||||||
const target = useAppSelector(state => state.redirect.to)
|
const target = useAppSelector(state => state.redirect.to)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@@ -127,115 +145,21 @@ function RedirectHandler() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function GoogleAnalyticsHandler() {
|
|
||||||
const location = useLocation()
|
|
||||||
const googleAnalyticsCode = useAppSelector(state => state.server.serverInfos?.googleAnalyticsCode)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (googleAnalyticsCode) ReactGA.initialize(googleAnalyticsCode)
|
|
||||||
}, [googleAnalyticsCode])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (ReactGA.isInitialized) ReactGA.send({ hitType: "pageview", page: location.pathname })
|
|
||||||
}, [location])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnreadCountTitleHandler({ unreadCount, enabled }: { unreadCount: number; enabled?: boolean }) {
|
|
||||||
return <title>{enabled && unreadCount > 0 ? `(${unreadCount}) CommaFeed` : "CommaFeed"}</title>
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnreadCountFaviconHandler({ unreadCount, enabled }: { unreadCount: number; enabled?: boolean }) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (enabled && unreadCount > 0) {
|
|
||||||
Tinycon.setBubble(unreadCount)
|
|
||||||
} else {
|
|
||||||
Tinycon.reset()
|
|
||||||
}
|
|
||||||
}, [unreadCount, enabled])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function BrowserExtensionBadgeUnreadCountHandler() {
|
|
||||||
const root = useAppSelector(state => state.tree.rootCategory)
|
|
||||||
const { setBadgeUnreadCount } = useBrowserExtension()
|
|
||||||
useEffect(() => {
|
|
||||||
if (!root) return
|
|
||||||
const unreadCount = categoryUnreadCount(root)
|
|
||||||
setBadgeUnreadCount(unreadCount)
|
|
||||||
}, [root, setBadgeUnreadCount])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomJsHandler() {
|
|
||||||
const [scriptLoaded, setScriptLoaded] = useState(false)
|
|
||||||
const { loading } = useAppLoading()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scriptLoaded || loading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement("script")
|
|
||||||
script.src = "custom_js.js"
|
|
||||||
script.async = true
|
|
||||||
document.body.appendChild(script)
|
|
||||||
|
|
||||||
setScriptLoaded(true)
|
|
||||||
}, [scriptLoaded, loading])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomCssHandler() {
|
|
||||||
useEffect(() => {
|
|
||||||
const link = document.createElement("link")
|
|
||||||
link.rel = "stylesheet"
|
|
||||||
link.type = "text/css"
|
|
||||||
link.href = "custom_css.css"
|
|
||||||
document.head.appendChild(link)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
useI18n()
|
useI18n()
|
||||||
const root = useAppSelector(state => state.tree.rootCategory)
|
|
||||||
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
|
|
||||||
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
|
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const unreadCount = categoryUnreadCount(root)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(reloadServerInfos())
|
dispatch(reloadServerInfos())
|
||||||
}, [dispatch])
|
}, [dispatch])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<>
|
<HashRouter>
|
||||||
<UnreadCountTitleHandler unreadCount={unreadCount} enabled={unreadCountTitle} />
|
<InitialSetupHandler />
|
||||||
<UnreadCountFaviconHandler unreadCount={unreadCount} enabled={unreadCountFavicon} />
|
<RedirectHandler />
|
||||||
<BrowserExtensionBadgeUnreadCountHandler />
|
<AppRoutes />
|
||||||
<CustomJsHandler />
|
</HashRouter>
|
||||||
<CustomCssHandler />
|
|
||||||
|
|
||||||
{/* disable pull-to-refresh as it messes with vertical scrolling
|
|
||||||
safari behaves weirdly when overscroll-behavior is set to none so we disable it only for other browsers
|
|
||||||
https://github.com/Athou/commafeed/issues/1168
|
|
||||||
*/}
|
|
||||||
{!isSafari && <DisablePullToRefresh />}
|
|
||||||
|
|
||||||
<HashRouter>
|
|
||||||
<GoogleAnalyticsHandler />
|
|
||||||
<RedirectHandler />
|
|
||||||
<AppRoutes />
|
|
||||||
</HashRouter>
|
|
||||||
</>
|
|
||||||
</Providers>
|
</Providers>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createAsyncThunk } from "@reduxjs/toolkit"
|
import { createAsyncThunk } from "@reduxjs/toolkit"
|
||||||
import type { AppDispatch, RootState } from "app/store"
|
import type { AppDispatch, RootState } from "@/app/store"
|
||||||
|
|
||||||
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||||
state: RootState
|
state: RootState
|
||||||
|
|||||||
36
commafeed-client/src/app/client.test.ts
Normal file
36
commafeed-client/src/app/client.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { i18n } from "@lingui/core"
|
||||||
|
import type { AxiosError } from "axios"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { errorToStrings } from "./client"
|
||||||
|
|
||||||
|
const axiosError = (status: number, data: unknown) =>
|
||||||
|
({
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status, data },
|
||||||
|
}) as AxiosError
|
||||||
|
|
||||||
|
describe("errorToStrings", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
it("translates known application error types", () => {
|
||||||
|
vi.spyOn(i18n, "_").mockReturnValue("Translated authentication error")
|
||||||
|
const error = axiosError(401, {
|
||||||
|
type: "WRONG_USERNAME_OR_PASSWORD",
|
||||||
|
message: "wrong username or password",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(errorToStrings(error)).toEqual(["Translated authentication error"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves backend messages for unexpected errors", () => {
|
||||||
|
const error = axiosError(500, { message: "unexpected error" })
|
||||||
|
|
||||||
|
expect(errorToStrings(error)).toEqual(["unexpected error"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves backend messages for unknown application error types", () => {
|
||||||
|
const error = axiosError(400, { type: "UNKNOWN_ERROR", message: "unknown error" })
|
||||||
|
|
||||||
|
expect(errorToStrings(error)).toEqual(["unknown error"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { i18n, type MessageDescriptor } from "@lingui/core"
|
||||||
|
import { msg } from "@lingui/core/macro"
|
||||||
import axios, { type AxiosError } from "axios"
|
import axios, { type AxiosError } from "axios"
|
||||||
import type {
|
import type {
|
||||||
AddCategoryRequest,
|
AddCategoryRequest,
|
||||||
@@ -6,18 +8,23 @@ import type {
|
|||||||
Category,
|
Category,
|
||||||
CategoryModificationRequest,
|
CategoryModificationRequest,
|
||||||
CollapseRequest,
|
CollapseRequest,
|
||||||
|
CommaFeedApplicationError,
|
||||||
|
CommaFeedExceptionType,
|
||||||
Entries,
|
Entries,
|
||||||
FeedInfo,
|
FeedInfo,
|
||||||
FeedInfoRequest,
|
FeedInfoRequest,
|
||||||
FeedModificationRequest,
|
FeedModificationRequest,
|
||||||
GetEntriesPaginatedRequest,
|
GetEntriesPaginatedRequest,
|
||||||
IDRequest,
|
IDRequest,
|
||||||
|
InitialSetupRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MarkRequest,
|
MarkRequest,
|
||||||
Metrics,
|
Metrics,
|
||||||
MultipleMarkRequest,
|
MultipleMarkRequest,
|
||||||
|
PasswordResetConfirmationRequest,
|
||||||
PasswordResetRequest,
|
PasswordResetRequest,
|
||||||
ProfileModificationRequest,
|
ProfileModificationRequest,
|
||||||
|
PushNotificationSettings,
|
||||||
RegistrationRequest,
|
RegistrationRequest,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -28,20 +35,25 @@ import type {
|
|||||||
UserModel,
|
UserModel,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
|
const applicationErrorMessages = {
|
||||||
|
WRONG_USERNAME_OR_PASSWORD: msg`Wrong username or password`,
|
||||||
|
} satisfies Record<CommaFeedExceptionType, MessageDescriptor>
|
||||||
|
|
||||||
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
||||||
axiosInstance.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
response => response,
|
response => response,
|
||||||
error => {
|
error => {
|
||||||
if (isAuthenticationError(error)) {
|
if (isAuthenticationError(error) && window.location.hash !== "#/login") {
|
||||||
const data = error.response?.data
|
const data = error.response?.data
|
||||||
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
|
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
|
||||||
|
window.location.reload()
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> {
|
function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> {
|
||||||
return axios.isAxiosError(error) && !!error.response && [401, 403].includes(error.response.status)
|
return axios.isAxiosError(error) && error.response?.status === 401
|
||||||
}
|
}
|
||||||
|
|
||||||
export const client = {
|
export const client = {
|
||||||
@@ -49,7 +61,8 @@ export const client = {
|
|||||||
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
||||||
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
||||||
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
||||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
|
getEntries: async (req: GetEntriesPaginatedRequest) =>
|
||||||
|
await axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
||||||
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
||||||
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
||||||
@@ -64,7 +77,8 @@ export const client = {
|
|||||||
feed: {
|
feed: {
|
||||||
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||||
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
||||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
getEntries: async (req: GetEntriesPaginatedRequest) =>
|
||||||
|
await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
||||||
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
||||||
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
||||||
@@ -93,9 +107,14 @@ export const client = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
||||||
|
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
|
||||||
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
||||||
|
passwordResetCallback: async (req: PasswordResetConfirmationRequest) =>
|
||||||
|
await axiosInstance.post("user/passwordResetCallback", req),
|
||||||
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
||||||
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
||||||
|
sendTestPushNotification: async (settings: PushNotificationSettings) =>
|
||||||
|
await axiosInstance.post("user/pushNotificationTest", settings),
|
||||||
getProfile: async () => await axiosInstance.get<UserModel>("user/profile"),
|
getProfile: async () => await axiosInstance.get<UserModel>("user/profile"),
|
||||||
saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req),
|
saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req),
|
||||||
deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"),
|
deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"),
|
||||||
@@ -105,7 +124,7 @@ export const client = {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
||||||
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post("admin/user/save", req),
|
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post<number>("admin/user/save", req),
|
||||||
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
|
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
|
||||||
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
|
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
|
||||||
},
|
},
|
||||||
@@ -120,18 +139,33 @@ export const errorToStrings = (err: unknown) => {
|
|||||||
let strings: string[] = []
|
let strings: string[] = []
|
||||||
|
|
||||||
if (axios.isAxiosError(err) && err.response) {
|
if (axios.isAxiosError(err) && err.response) {
|
||||||
if (typeof err.response.data === "string") strings.push(err.response.data)
|
if (isCommaFeedApplicationError(err)) {
|
||||||
if (isMessageError(err)) strings.push(err.response.data.message)
|
strings.push(i18n._(applicationErrorMessages[err.response.data.type]))
|
||||||
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
|
} else {
|
||||||
|
if (typeof err.response.data === "string") strings.push(err.response.data)
|
||||||
|
if (isMessageError(err)) strings.push(err.response.data.message)
|
||||||
|
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings
|
return strings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCommaFeedApplicationError(err: AxiosError): err is AxiosError<CommaFeedApplicationError> {
|
||||||
|
const data = err.response?.data
|
||||||
|
if (!data || typeof data !== "object" || !("type" in data)) return false
|
||||||
|
const type = data.type
|
||||||
|
return typeof type === "string" && Object.hasOwn(applicationErrorMessages, type)
|
||||||
|
}
|
||||||
|
|
||||||
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
||||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
return (
|
||||||
|
!!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
||||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
return (
|
||||||
|
!!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IconType } from "react-icons"
|
import type { IconType } from "react-icons"
|
||||||
import { FaAt } from "react-icons/fa"
|
import { FaAt } from "react-icons/fa"
|
||||||
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiX } from "react-icons/si"
|
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiTumblr, SiX } from "react-icons/si"
|
||||||
import type { Category, Entry, SharingSettings } from "./types"
|
import type { Category, Entry, SharingSettings } from "./types"
|
||||||
|
|
||||||
const categories: Record<string, Omit<Category, "name">> = {
|
const categories: Record<string, Omit<Category, "name">> = {
|
||||||
@@ -58,12 +58,6 @@ const sharing: {
|
|||||||
color: "#375672",
|
color: "#375672",
|
||||||
url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`,
|
url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`,
|
||||||
},
|
},
|
||||||
pocket: {
|
|
||||||
label: "Pocket",
|
|
||||||
icon: SiPocket,
|
|
||||||
color: "#EF4154",
|
|
||||||
url: (url, desc) => `https://getpocket.com/save?url=${url}&title=${desc}`,
|
|
||||||
},
|
|
||||||
instapaper: {
|
instapaper: {
|
||||||
label: "Instapaper",
|
label: "Instapaper",
|
||||||
icon: SiInstapaper,
|
icon: SiInstapaper,
|
||||||
@@ -87,23 +81,25 @@ export const Constants = {
|
|||||||
headerHeight: 60,
|
headerHeight: 60,
|
||||||
entryMaxWidth: 650,
|
entryMaxWidth: 650,
|
||||||
isTopVisible: (div: HTMLElement) => {
|
isTopVisible: (div: HTMLElement) => {
|
||||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||||
return div.getBoundingClientRect().top >= (header?.bottom ?? 0)
|
return div.getBoundingClientRect().top >= (header?.bottom ?? 0)
|
||||||
},
|
},
|
||||||
isBottomVisible: (div: HTMLElement) => {
|
isBottomVisible: (div: HTMLElement) => {
|
||||||
const footer = document.getElementById(Constants.dom.footerId)?.getBoundingClientRect()
|
const footer = document.getElementsByTagName("footer").item(0)?.getBoundingClientRect()
|
||||||
return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight)
|
return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dom: {
|
dom: {
|
||||||
headerId: "header",
|
|
||||||
footerId: "footer",
|
|
||||||
entryId: (entry: Entry) => `entry-id-${entry.id}`,
|
entryId: (entry: Entry) => `entry-id-${entry.id}`,
|
||||||
entryContextMenuId: (entry: Entry) => entry.id,
|
entryContextMenuId: (entry: Entry) => entry.id,
|
||||||
},
|
},
|
||||||
|
theme: {
|
||||||
|
defaultPrimaryColor: "orange",
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
delay: 500,
|
delay: 500,
|
||||||
},
|
},
|
||||||
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
|
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
|
||||||
|
customCssDocumentationUrl: "https://athou.github.io/commafeed/documentation/custom-css",
|
||||||
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",
|
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit"
|
import { configureStore } from "@reduxjs/toolkit"
|
||||||
import { client } from "app/client"
|
|
||||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "app/entries/thunks"
|
|
||||||
import { type RootState, reducers } from "app/store"
|
|
||||||
import type { Entries, Entry } from "app/types"
|
|
||||||
import type { AxiosResponse } from "axios"
|
import type { AxiosResponse } from "axios"
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { client } from "@/app/client"
|
||||||
|
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "@/app/entries/thunks"
|
||||||
|
import { type RootState, reducers } from "@/app/store"
|
||||||
|
import type { Entries, Entry } from "@/app/types"
|
||||||
|
|
||||||
vi.mock(import("app/client"))
|
vi.mock(import("@/app/client"))
|
||||||
|
|
||||||
describe("entries", () => {
|
describe("entries", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -27,7 +27,12 @@ describe("entries", () => {
|
|||||||
} as AxiosResponse<Entries>)
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
const store = configureStore({ reducer: reducers })
|
const store = configureStore({ reducer: reducers })
|
||||||
const promise = store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
|
const promise = store.dispatch(
|
||||||
|
loadEntries({
|
||||||
|
source: { type: "feed", id: "feed-id" },
|
||||||
|
clearSearch: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
expect(store.getState().entries.source.type).toBe("feed")
|
expect(store.getState().entries.source.type).toBe("feed")
|
||||||
expect(store.getState().entries.source.id).toBe("feed-id")
|
expect(store.getState().entries.source.id).toBe("feed-id")
|
||||||
@@ -84,6 +89,188 @@ describe("entries", () => {
|
|||||||
expect(store.getState().entries.hasMore).toBe(false)
|
expect(store.getState().entries.hasMore).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("stops loading after an initial-load failure and allows a retry", async () => {
|
||||||
|
const response = {
|
||||||
|
data: {
|
||||||
|
entries: [{ id: "3" } as Entry],
|
||||||
|
hasMore: true,
|
||||||
|
name: "my-feed",
|
||||||
|
feedLink: "https://mysite.com/feed",
|
||||||
|
timestamp: 123,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>
|
||||||
|
vi.mocked(client.feed.getEntries)
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce(response)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
const arg = { source: { type: "feed" as const, id: "feed-id" }, clearSearch: true }
|
||||||
|
const request = store.dispatch(loadEntries(arg))
|
||||||
|
|
||||||
|
expect(store.getState().entries.loading).toBe(true)
|
||||||
|
expect(loadEntries.rejected.match(await request)).toBe(true)
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
expect(store.getState().entries.entries).toStrictEqual([])
|
||||||
|
expect(store.getState().entries.hasMore).toBe(true)
|
||||||
|
|
||||||
|
expect(loadEntries.fulfilled.match(await store.dispatch(loadEntries(arg)))).toBe(true)
|
||||||
|
expect(store.getState().entries.entries).toStrictEqual(response.data.entries)
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves entries after a pagination failure and allows a retry", async () => {
|
||||||
|
const response = {
|
||||||
|
data: {
|
||||||
|
entries: [{ id: "3", read: false } as Entry],
|
||||||
|
hasMore: true,
|
||||||
|
name: "my-feed",
|
||||||
|
feedLink: "https://mysite.com/feed",
|
||||||
|
timestamp: 123,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>
|
||||||
|
vi.mocked(client.feed.getEntries)
|
||||||
|
.mockResolvedValueOnce(response)
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { ...response.data, entries: [{ id: "4" } as Entry], hasMore: false },
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
|
||||||
|
const previousState = store.getState().entries
|
||||||
|
const request = store.dispatch(loadMoreEntries())
|
||||||
|
|
||||||
|
expect(store.getState().entries.loading).toBe(true)
|
||||||
|
expect(loadMoreEntries.rejected.match(await request)).toBe(true)
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
expect(store.getState().entries.entries).toStrictEqual(previousState.entries)
|
||||||
|
expect(store.getState().entries.hasMore).toBe(true)
|
||||||
|
expect(store.getState().entries.timestamp).toBe(123)
|
||||||
|
expect(store.getState().entries.sourceLabel).toBe("my-feed")
|
||||||
|
expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed")
|
||||||
|
|
||||||
|
expect(loadMoreEntries.fulfilled.match(await store.dispatch(loadMoreEntries()))).toBe(true)
|
||||||
|
expect(store.getState().entries.entries).toStrictEqual([{ id: "3", read: false }, { id: "4" }])
|
||||||
|
expect(store.getState().entries.hasMore).toBe(false)
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
expect(vi.mocked(client.feed.getEntries).mock.calls[1][0]).toEqual(
|
||||||
|
vi.mocked(client.feed.getEntries).mock.calls[2][0]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.each(["initial load", "pagination"] as const)("overlapping %s requests", olderRequestType => {
|
||||||
|
it.each(["initial load", "pagination"] as const)(
|
||||||
|
"ignores an older failure while a newer %s is pending",
|
||||||
|
async newerRequestType => {
|
||||||
|
let rejectOlder!: (reason: Error) => void
|
||||||
|
vi.mocked(client.category.getEntries).mockReturnValueOnce(
|
||||||
|
new Promise<AxiosResponse<Entries>>((_, reject) => {
|
||||||
|
rejectOlder = reject
|
||||||
|
})
|
||||||
|
)
|
||||||
|
vi.mocked(client.category.getEntries).mockReturnValueOnce(new Promise<AxiosResponse<Entries>>(() => {}))
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
const arg = { source: { type: "category" as const, id: "all" }, clearSearch: true }
|
||||||
|
const older =
|
||||||
|
olderRequestType === "initial load"
|
||||||
|
? store.dispatch(loadEntries(arg))
|
||||||
|
: store.dispatch(loadMoreEntries())
|
||||||
|
const newer =
|
||||||
|
newerRequestType === "initial load"
|
||||||
|
? store.dispatch(loadEntries(arg))
|
||||||
|
: store.dispatch(loadMoreEntries())
|
||||||
|
|
||||||
|
rejectOlder(new Error("Older request failed"))
|
||||||
|
await older
|
||||||
|
expect(store.getState().entries.loading).toBe(true)
|
||||||
|
|
||||||
|
newer.abort()
|
||||||
|
await newer
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it("does not clear a newer request's loading state when an older request succeeds", async () => {
|
||||||
|
let resolveOlder!: (response: AxiosResponse<Entries>) => void
|
||||||
|
vi.mocked(client.category.getEntries).mockReturnValueOnce(
|
||||||
|
new Promise<AxiosResponse<Entries>>(resolve => {
|
||||||
|
resolveOlder = resolve
|
||||||
|
})
|
||||||
|
)
|
||||||
|
vi.mocked(client.category.getEntries).mockReturnValueOnce(new Promise<AxiosResponse<Entries>>(() => {}))
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
const arg = { source: { type: "category" as const, id: "all" }, clearSearch: true }
|
||||||
|
const older =
|
||||||
|
olderRequestType === "initial load"
|
||||||
|
? store.dispatch(loadEntries(arg))
|
||||||
|
: store.dispatch(loadMoreEntries())
|
||||||
|
const newer = store.dispatch(loadEntries(arg))
|
||||||
|
|
||||||
|
resolveOlder({
|
||||||
|
data: {
|
||||||
|
entries: [] as Entry[],
|
||||||
|
hasMore: true,
|
||||||
|
name: "old-category",
|
||||||
|
feedLink: "",
|
||||||
|
timestamp: 123,
|
||||||
|
errorCount: 0,
|
||||||
|
ignoredReadStatus: false,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
await older
|
||||||
|
expect(store.getState().entries.loading).toBe(true)
|
||||||
|
|
||||||
|
newer.abort()
|
||||||
|
await newer
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores an older failure after the newer request succeeds", async () => {
|
||||||
|
let rejectOlder!: (reason: Error) => void
|
||||||
|
vi.mocked(client.category.getEntries).mockReturnValueOnce(
|
||||||
|
new Promise<AxiosResponse<Entries>>((_, reject) => {
|
||||||
|
rejectOlder = reject
|
||||||
|
})
|
||||||
|
)
|
||||||
|
vi.mocked(client.category.getEntries).mockResolvedValueOnce({
|
||||||
|
data: { entries: [{ id: "4" } as Entry], hasMore: false, name: "new-category", timestamp: 456 },
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
const arg = { source: { type: "category" as const, id: "all" }, clearSearch: true }
|
||||||
|
const older =
|
||||||
|
olderRequestType === "initial load"
|
||||||
|
? store.dispatch(loadEntries(arg))
|
||||||
|
: store.dispatch(loadMoreEntries())
|
||||||
|
await store.dispatch(loadEntries(arg))
|
||||||
|
const completedState = store.getState().entries
|
||||||
|
|
||||||
|
rejectOlder(new Error("Older request failed"))
|
||||||
|
await older
|
||||||
|
expect(store.getState().entries).toStrictEqual(completedState)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not append entries already present when loading more entries", async () => {
|
||||||
|
vi.mocked(client.category.getEntries)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { entries: [{ id: "3" } as Entry], hasMore: true },
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { entries: [{ id: "3" } as Entry, { id: "4" } as Entry], hasMore: false },
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "category", id: "all" }, clearSearch: true }))
|
||||||
|
await store.dispatch(loadMoreEntries())
|
||||||
|
|
||||||
|
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }])
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
expect(store.getState().entries.hasMore).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it("marks an entry as read", () => {
|
it("marks an entry as read", () => {
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: reducers,
|
reducer: reducers,
|
||||||
@@ -111,6 +298,30 @@ describe("entries", () => {
|
|||||||
expect(client.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
expect(client.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("flags an entry marked as unread by the user until entries are reloaded", async () => {
|
||||||
|
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
entries: [{ id: "3", read: true, markable: true } as Entry],
|
||||||
|
hasMore: false,
|
||||||
|
name: "my-feed",
|
||||||
|
errorCount: 0,
|
||||||
|
feedLink: "https://mysite.com/feed",
|
||||||
|
timestamp: 123,
|
||||||
|
ignoredReadStatus: false,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
|
||||||
|
|
||||||
|
store.dispatch(markEntry({ entry: store.getState().entries.entries[0], read: false }))
|
||||||
|
expect(store.getState().entries.entries[0].keepUnread).toBe(true)
|
||||||
|
|
||||||
|
// reloading the entry list resets the flag
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: false }))
|
||||||
|
expect(store.getState().entries.entries[0].keepUnread).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it("marks all entries as read", () => {
|
it("marks all entries as read", () => {
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: reducers,
|
reducer: reducers,
|
||||||
@@ -130,11 +341,19 @@ describe("entries", () => {
|
|||||||
} as RootState,
|
} as RootState,
|
||||||
})
|
})
|
||||||
|
|
||||||
store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } }))
|
store.dispatch(
|
||||||
|
markAllEntries({
|
||||||
|
sourceType: "category",
|
||||||
|
req: { id: "all", read: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
expect(store.getState().entries.entries).toStrictEqual([
|
expect(store.getState().entries.entries).toStrictEqual([
|
||||||
{ id: "3", read: true },
|
{ id: "3", read: true },
|
||||||
{ id: "4", read: true },
|
{ id: "4", read: true },
|
||||||
])
|
])
|
||||||
expect(client.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true })
|
expect(client.category.markEntries).toHaveBeenCalledWith({
|
||||||
|
id: "all",
|
||||||
|
read: true,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
import { createSlice, isAnyOf, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "app/entries/thunks"
|
import {
|
||||||
import type { Entry } from "app/types"
|
loadEntries,
|
||||||
|
loadMoreEntries,
|
||||||
|
markAllEntries,
|
||||||
|
markEntry,
|
||||||
|
markMultipleEntries,
|
||||||
|
starEntry,
|
||||||
|
tagEntry,
|
||||||
|
} from "@/app/entries/thunks"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
|
||||||
export type EntrySourceType = "category" | "feed" | "tag"
|
export type EntrySourceType = "category" | "feed" | "tag"
|
||||||
|
|
||||||
@@ -10,7 +18,11 @@ export interface EntrySource {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
export type ExpendableEntry = Entry & {
|
||||||
|
expanded?: boolean
|
||||||
|
/** set when the user explicitly marks the entry as unread, prevents it from being marked as read again by scrolling in expanded mode until the entry list is reloaded */
|
||||||
|
keepUnread?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface EntriesState {
|
interface EntriesState {
|
||||||
/** selected source */
|
/** selected source */
|
||||||
@@ -26,8 +38,11 @@ interface EntriesState {
|
|||||||
selectedEntryId?: string
|
selectedEntryId?: string
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
/** identifies the request that owns the loading state */
|
||||||
|
currentRequestId?: string
|
||||||
search?: string
|
search?: string
|
||||||
scrollingToEntry: boolean
|
scrollingToEntry: boolean
|
||||||
|
markAllAsReadConfirmationDialogOpen: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: EntriesState = {
|
const initialState: EntriesState = {
|
||||||
@@ -41,6 +56,7 @@ const initialState: EntriesState = {
|
|||||||
hasMore: true,
|
hasMore: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
scrollingToEntry: false,
|
scrollingToEntry: false,
|
||||||
|
markAllAsReadConfirmationDialogOpen: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const entriesSlice = createSlice({
|
export const entriesSlice = createSlice({
|
||||||
@@ -61,25 +77,34 @@ export const entriesSlice = createSlice({
|
|||||||
setSearch: (state, action: PayloadAction<string>) => {
|
setSearch: (state, action: PayloadAction<string>) => {
|
||||||
state.search = action.payload
|
state.search = action.payload
|
||||||
},
|
},
|
||||||
|
setMarkAllAsReadConfirmationDialogOpen: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.markAllAsReadConfirmationDialogOpen = action.payload
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(markEntry.pending, (state, action) => {
|
builder.addCase(markEntry.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
|
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
|
||||||
e.read = action.meta.arg.read
|
e.read = action.meta.arg.read
|
||||||
|
if (!action.meta.arg.read) e.keepUnread = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
|
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
|
||||||
e.read = action.meta.arg.read
|
e.read = action.meta.arg.read
|
||||||
|
if (!action.meta.arg.read) e.keepUnread = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))) {
|
for (const e of state.entries.filter(e =>
|
||||||
|
action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true
|
||||||
|
)) {
|
||||||
e.read = true
|
e.read = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(starEntry.pending, (state, action) => {
|
builder.addCase(starEntry.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)) {
|
for (const e of state.entries.filter(
|
||||||
|
e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId
|
||||||
|
)) {
|
||||||
e.starred = action.meta.arg.starred
|
e.starred = action.meta.arg.starred
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -92,9 +117,11 @@ export const entriesSlice = createSlice({
|
|||||||
state.hasMore = true
|
state.hasMore = true
|
||||||
state.selectedEntryId = undefined
|
state.selectedEntryId = undefined
|
||||||
state.loading = true
|
state.loading = true
|
||||||
|
state.currentRequestId = action.meta.requestId
|
||||||
})
|
})
|
||||||
builder.addCase(loadMoreEntries.pending, state => {
|
builder.addCase(loadMoreEntries.pending, (state, action) => {
|
||||||
state.loading = true
|
state.loading = true
|
||||||
|
state.currentRequestId = action.meta.requestId
|
||||||
})
|
})
|
||||||
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
||||||
state.entries = action.payload.entries
|
state.entries = action.payload.entries
|
||||||
@@ -102,21 +129,25 @@ export const entriesSlice = createSlice({
|
|||||||
state.sourceLabel = action.payload.name
|
state.sourceLabel = action.payload.name
|
||||||
state.sourceWebsiteUrl = action.payload.feedLink
|
state.sourceWebsiteUrl = action.payload.feedLink
|
||||||
state.hasMore = action.payload.hasMore
|
state.hasMore = action.payload.hasMore
|
||||||
state.loading = false
|
|
||||||
})
|
})
|
||||||
builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
|
builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
|
||||||
// remove already existing entries
|
// remove already existing entries
|
||||||
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
|
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
|
||||||
state.entries = [...state.entries, ...entriesToAdd]
|
state.entries = [...state.entries, ...entriesToAdd]
|
||||||
state.hasMore = action.payload.hasMore
|
state.hasMore = action.payload.hasMore
|
||||||
state.loading = false
|
|
||||||
})
|
})
|
||||||
builder.addCase(tagEntry.pending, (state, action) => {
|
builder.addCase(tagEntry.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => +e.id === action.meta.arg.entryId)) {
|
for (const e of state.entries.filter(e => +e.id === action.meta.arg.entryId)) {
|
||||||
e.tags = action.meta.arg.tags
|
e.tags = action.meta.arg.tags
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
builder.addMatcher(isAnyOf(loadEntries.settled, loadMoreEntries.settled), (state, action) => {
|
||||||
|
if (state.currentRequestId === action.meta.requestId) {
|
||||||
|
state.loading = false
|
||||||
|
state.currentRequestId = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setSearch } = entriesSlice.actions
|
export const { setSearch, setMarkAllAsReadConfirmationDialogOpen } = entriesSlice.actions
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
|
||||||
import { client } from "app/client"
|
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { type EntrySource, type EntrySourceType, entriesSlice, setSearch } from "app/entries/slice"
|
|
||||||
import type { RootState } from "app/store"
|
|
||||||
import { reloadTree } from "app/tree/thunks"
|
|
||||||
import type { Entry, MarkRequest, TagRequest } from "app/types"
|
|
||||||
import { reloadTags } from "app/user/thunks"
|
|
||||||
import { scrollToWithCallback } from "app/utils"
|
|
||||||
import { flushSync } from "react-dom"
|
import { flushSync } from "react-dom"
|
||||||
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
|
import { client } from "@/app/client"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import {
|
||||||
|
type EntrySource,
|
||||||
|
type EntrySourceType,
|
||||||
|
entriesSlice,
|
||||||
|
setMarkAllAsReadConfirmationDialogOpen,
|
||||||
|
setSearch,
|
||||||
|
} from "@/app/entries/slice"
|
||||||
|
import type { RootState } from "@/app/store"
|
||||||
|
import { reloadTree, selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
|
import type { Entry, MarkRequest, TagRequest } from "@/app/types"
|
||||||
|
import { reloadTags } from "@/app/user/thunks"
|
||||||
|
import { scrollToWithCallback } from "@/app/utils"
|
||||||
|
|
||||||
const getEndpoint = (sourceType: EntrySourceType) =>
|
const getEndpoint = (sourceType: EntrySourceType) =>
|
||||||
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
||||||
@@ -34,7 +40,11 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
|
|||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const { source } = state.entries
|
const { source } = state.entries
|
||||||
const offset =
|
const offset =
|
||||||
state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length
|
state.user.settings?.readingMode === "all" ||
|
||||||
|
state.entries.search ||
|
||||||
|
(source.type === "category" && source.id === "starred")
|
||||||
|
? state.entries.entries.length
|
||||||
|
: state.entries.entries.filter(e => !e.read).length
|
||||||
const endpoint = getEndpoint(state.entries.source.type)
|
const endpoint = getEndpoint(state.entries.source.type)
|
||||||
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
||||||
return result.data
|
return result.data
|
||||||
@@ -50,7 +60,7 @@ const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource,
|
|||||||
keywords: state.entries.search,
|
keywords: state.entries.search,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const reloadEntries = createAppAsyncThunk("entries/reload", (arg, thunkApi) => {
|
export const reloadEntries = createAppAsyncThunk("entries/reload", (_, thunkApi) => {
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||||
})
|
})
|
||||||
@@ -123,12 +133,43 @@ export const markAllEntries = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
|
||||||
|
"entries/entry/markAllAsReadWithConfirmationIfRequired",
|
||||||
|
async (_, thunkApi) => {
|
||||||
|
const state = thunkApi.getState()
|
||||||
|
const source = state.entries.source
|
||||||
|
const entriesTimestamp = state.entries.timestamp ?? Date.now()
|
||||||
|
const search = state.entries.search
|
||||||
|
const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation
|
||||||
|
const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||||
|
|
||||||
|
if (markAllAsReadConfirmation) {
|
||||||
|
thunkApi.dispatch(setMarkAllAsReadConfirmationDialogOpen(true))
|
||||||
|
} else {
|
||||||
|
await thunkApi.dispatch(
|
||||||
|
markAllEntries({
|
||||||
|
sourceType: source.type,
|
||||||
|
req: {
|
||||||
|
id: source.id,
|
||||||
|
read: true,
|
||||||
|
olderThan: Date.now(),
|
||||||
|
insertedBefore: entriesTimestamp,
|
||||||
|
keywords: search,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
||||||
|
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
|
||||||
|
await thunkApi.dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export const starEntry = createAppAsyncThunk(
|
export const starEntry = createAppAsyncThunk(
|
||||||
"entries/entry/star",
|
"entries/entry/star",
|
||||||
(arg: { entry: Entry; starred: boolean }) => {
|
(arg: { entry: Entry; starred: boolean }) => {
|
||||||
client.entry.star({
|
client.entry.star({
|
||||||
id: arg.entry.id,
|
id: arg.entry.id,
|
||||||
feedId: +arg.entry.feedId,
|
|
||||||
starred: arg.starred,
|
starred: arg.starred,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -181,7 +222,9 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
|
|
||||||
const entryIndex = state.entries.entries.indexOf(entry)
|
const entryIndex = state.entries.entries.indexOf(entry)
|
||||||
const entriesToKeepOnTopWhenScrolling =
|
const entriesToKeepOnTopWhenScrolling =
|
||||||
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
viewMode === "expanded"
|
||||||
|
? 0
|
||||||
|
: Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
||||||
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
||||||
|
|
||||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||||
@@ -189,7 +232,8 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
if (entryElement && entryElementToScrollTo) {
|
if (entryElement && entryElementToScrollTo) {
|
||||||
const scrollMode = state.user.settings?.scrollMode
|
const scrollMode = state.user.settings?.scrollMode
|
||||||
const entryEntirelyVisible =
|
const entryEntirelyVisible =
|
||||||
Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement)
|
Constants.layout.isTopVisible(entryElementToScrollTo) &&
|
||||||
|
Constants.layout.isBottomVisible(entryElement)
|
||||||
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
||||||
const scrollSpeed = state.user.settings?.scrollSpeed
|
const scrollSpeed = state.user.settings?.scrollSpeed
|
||||||
const margin = viewMode === "detailed" ? 8 : 3
|
const margin = viewMode === "detailed" ? 8 : 3
|
||||||
@@ -203,8 +247,13 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
const scrollToEntry = (
|
||||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
entryElement: HTMLElement,
|
||||||
|
margin: number,
|
||||||
|
scrollSpeed: number | undefined,
|
||||||
|
onScrollEnded: () => void
|
||||||
|
) => {
|
||||||
|
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||||
const offset = (header?.bottom ?? 0) + margin
|
const offset = (header?.bottom ?? 0) + margin
|
||||||
scrollToWithCallback({
|
scrollToWithCallback({
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirectToCategory } from "app/redirect/thunks"
|
|
||||||
import { store } from "app/store"
|
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { redirectToCategory } from "@/app/redirect/thunks"
|
||||||
|
import { store } from "@/app/store"
|
||||||
|
|
||||||
describe("redirects", () => {
|
describe("redirects", () => {
|
||||||
it("redirects to category", async () => {
|
it("redirects to category", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
interface RedirectState {
|
interface RedirectState {
|
||||||
to?: string
|
to?: string
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { redirectTo } from "app/redirect/slice"
|
import { redirectTo } from "@/app/redirect/slice"
|
||||||
|
|
||||||
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
|
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/login"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/register"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
|
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/setup"))
|
||||||
|
)
|
||||||
|
|
||||||
|
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => {
|
||||||
|
window.location.href = "api-documentation/"
|
||||||
|
})
|
||||||
|
|
||||||
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
|
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
|
||||||
const { source } = thunkApi.getState().entries
|
const { source } = thunkApi.getState().entries
|
||||||
@@ -34,15 +44,21 @@ export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details"
|
|||||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo(`/app/tag/${id}`))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
|
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/add"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
|
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/settings"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||||
@@ -52,6 +68,10 @@ export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (
|
|||||||
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/donate"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/about"))
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import { reloadServerInfos } from "app/server/thunks"
|
import { reloadServerInfos } from "@/app/server/thunks"
|
||||||
import type { ServerInfo } from "app/types"
|
import type { ServerInfo } from "@/app/types"
|
||||||
|
|
||||||
interface ServerState {
|
interface ServerState {
|
||||||
serverInfos?: ServerInfo
|
serverInfos?: ServerInfo
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "app/client"
|
import { client } from "@/app/client"
|
||||||
|
|
||||||
export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data))
|
export const reloadServerInfos = createAppAsyncThunk(
|
||||||
|
"server/infos",
|
||||||
|
async () => await client.server.getServerInfos().then(r => r.data)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit"
|
import { configureStore } from "@reduxjs/toolkit"
|
||||||
import { entriesSlice } from "app/entries/slice"
|
import { shallowEqual, type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
||||||
import { redirectSlice } from "app/redirect/slice"
|
import { entriesSlice } from "@/app/entries/slice"
|
||||||
import { serverSlice } from "app/server/slice"
|
import { redirectSlice } from "@/app/redirect/slice"
|
||||||
import { treeSlice } from "app/tree/slice"
|
import { serverSlice } from "@/app/server/slice"
|
||||||
import type { LocalSettings } from "app/types"
|
import { treeSlice } from "@/app/tree/slice"
|
||||||
import { initialLocalSettings, userSlice } from "app/user/slice"
|
import type { LocalSettings } from "@/app/types"
|
||||||
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
import { initialLocalSettings, userSlice } from "@/app/user/slice"
|
||||||
|
|
||||||
export const reducers = {
|
export const reducers = {
|
||||||
entries: entriesSlice.reducer,
|
entries: entriesSlice.reducer,
|
||||||
@@ -17,19 +17,9 @@ export const reducers = {
|
|||||||
|
|
||||||
const loadLocalSettings = (): LocalSettings => {
|
const loadLocalSettings = (): LocalSettings => {
|
||||||
const json = localStorage.getItem("commafeed-local-settings")
|
const json = localStorage.getItem("commafeed-local-settings")
|
||||||
if (json) {
|
|
||||||
return JSON.parse(json)
|
|
||||||
}
|
|
||||||
|
|
||||||
// load old settings
|
|
||||||
const viewMode = localStorage.getItem("view-mode")
|
|
||||||
const sidebarWidth = localStorage.getItem("sidebar-width")
|
|
||||||
const announcementHash = localStorage.getItem("announcement-hash")
|
|
||||||
return {
|
return {
|
||||||
...initialLocalSettings,
|
...initialLocalSettings,
|
||||||
viewMode: viewMode ? JSON.parse(viewMode) : initialLocalSettings.viewMode,
|
...(json ? JSON.parse(json) : {}),
|
||||||
sidebarWidth: sidebarWidth ? JSON.parse(sidebarWidth) : initialLocalSettings.sidebarWidth,
|
|
||||||
announcementHash: announcementHash ? JSON.parse(announcementHash) : initialLocalSettings.announcementHash,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,3 +41,5 @@ export type AppDispatch = typeof store.dispatch
|
|||||||
|
|
||||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||||
|
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector =>
|
||||||
|
useSelector(selector, shallowEqual)
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import { markEntry } from "app/entries/thunks"
|
import { loadEntries, markEntry } from "@/app/entries/thunks"
|
||||||
import { redirectTo } from "app/redirect/slice"
|
import { redirectTo } from "@/app/redirect/slice"
|
||||||
import { collapseTreeCategory, reloadTree } from "app/tree/thunks"
|
import { collapseTreeCategory, reloadTree } from "@/app/tree/thunks"
|
||||||
import type { Category } from "app/types"
|
import type { Category, Subscription } from "@/app/types"
|
||||||
import { visitCategoryTree } from "app/utils"
|
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
||||||
|
|
||||||
|
export interface TreeSubscription extends Subscription {
|
||||||
|
// client-side only flag
|
||||||
|
hasNewEntries?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeCategory extends Category {
|
||||||
|
feeds: TreeSubscription[]
|
||||||
|
children: TreeCategory[]
|
||||||
|
}
|
||||||
|
|
||||||
interface TreeState {
|
interface TreeState {
|
||||||
rootCategory?: Category
|
rootCategory?: TreeCategory
|
||||||
mobileMenuOpen: boolean
|
mobileMenuOpen: boolean
|
||||||
sidebarVisible: boolean
|
sidebarVisible: boolean
|
||||||
}
|
}
|
||||||
@@ -37,12 +47,27 @@ export const treeSlice = createSlice({
|
|||||||
visitCategoryTree(state.rootCategory, c => {
|
visitCategoryTree(state.rootCategory, c => {
|
||||||
for (const f of c.feeds.filter(f => f.id === action.payload.feedId)) {
|
for (const f of c.feeds.filter(f => f.id === action.payload.feedId)) {
|
||||||
f.unread += action.payload.amount
|
f.unread += action.payload.amount
|
||||||
|
f.hasNewEntries = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(reloadTree.fulfilled, (state, action) => {
|
builder.addCase(reloadTree.fulfilled, (state, action) => {
|
||||||
|
// set hasNewEntries to true if new unread > previous unread
|
||||||
|
if (state.rootCategory) {
|
||||||
|
const oldFeeds = flattenCategoryTree(state.rootCategory).flatMap(c => c.feeds)
|
||||||
|
const oldFeedsById = new Map(oldFeeds.map(f => [f.id, f]))
|
||||||
|
|
||||||
|
const newFeeds = flattenCategoryTree(action.payload).flatMap(c => c.feeds)
|
||||||
|
for (const newFeed of newFeeds) {
|
||||||
|
const oldFeed = oldFeedsById.get(newFeed.id)
|
||||||
|
if (oldFeed && newFeed.unread > oldFeed.unread) {
|
||||||
|
newFeed.hasNewEntries = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.rootCategory = action.payload
|
state.rootCategory = action.payload
|
||||||
})
|
})
|
||||||
builder.addCase(collapseTreeCategory.pending, (state, action) => {
|
builder.addCase(collapseTreeCategory.pending, (state, action) => {
|
||||||
@@ -59,6 +84,25 @@ export const treeSlice = createSlice({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
||||||
|
if (!state.rootCategory) return
|
||||||
|
|
||||||
|
const { source } = action.meta.arg
|
||||||
|
if (source.type === "category") {
|
||||||
|
visitCategoryTree(state.rootCategory, c => {
|
||||||
|
if (c.id === source.id) {
|
||||||
|
for (const f of flattenCategoryTree(c).flatMap(c => c.feeds)) {
|
||||||
|
f.hasNewEntries = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (source.type === "feed") {
|
||||||
|
const feeds = flattenCategoryTree(state.rootCategory).flatMap(c => c.feeds)
|
||||||
|
for (const f of feeds.filter(f => f.id === +source.id)) {
|
||||||
|
f.hasNewEntries = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
builder.addCase(redirectTo, state => {
|
builder.addCase(redirectTo, state => {
|
||||||
state.mobileMenuOpen = false
|
state.mobileMenuOpen = false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "app/client"
|
import { client } from "@/app/client"
|
||||||
import { redirectToCategory, redirectToFeed } from "app/redirect/thunks"
|
import { Constants } from "@/app/constants"
|
||||||
import { incrementUnreadCount } from "app/tree/slice"
|
import { redirectToCategory, redirectToFeed } from "@/app/redirect/thunks"
|
||||||
import type { CollapseRequest, Subscription } from "app/types"
|
import { incrementUnreadCount } from "@/app/tree/slice"
|
||||||
import { flattenCategoryTree, visitCategoryTree } from "app/utils"
|
import type { CollapseRequest, Subscription } from "@/app/types"
|
||||||
|
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
||||||
|
|
||||||
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
|
export const reloadTree = createAppAsyncThunk(
|
||||||
|
"tree/reload",
|
||||||
|
async () => await client.category.getRoot().then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
export const collapseTreeCategory = createAppAsyncThunk(
|
export const collapseTreeCategory = createAppAsyncThunk(
|
||||||
"tree/category/collapse",
|
"tree/category/collapse",
|
||||||
async (req: CollapseRequest) => await client.category.collapse(req)
|
async (req: CollapseRequest) => await client.category.collapse(req).then(r => r.data)
|
||||||
)
|
)
|
||||||
|
|
||||||
export const selectNextUnreadTreeItem = createAppAsyncThunk(
|
export const selectNextUnreadTreeItem = createAppAsyncThunk(
|
||||||
@@ -53,6 +57,9 @@ export const selectNextUnreadTreeItem = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redirect to 'all' if no unread categories or feeds found or if we reached the end of the list
|
||||||
|
thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit"
|
import { configureStore } from "@reduxjs/toolkit"
|
||||||
import { type RootState, reducers } from "app/store"
|
import type { AxiosResponse } from "axios"
|
||||||
import { selectNextUnreadTreeItem } from "app/tree/thunks"
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
import type { Category, Subscription } from "app/types"
|
import { client } from "@/app/client"
|
||||||
import { describe, expect, it } from "vitest"
|
import { loadEntries } from "@/app/entries/thunks"
|
||||||
|
import { type RootState, reducers } from "@/app/store"
|
||||||
|
import { newFeedEntriesDiscovered, selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
|
import type { Category, Entries, Entry, Subscription } from "@/app/types"
|
||||||
|
|
||||||
|
vi.mock(import("@/app/client"))
|
||||||
|
|
||||||
const createCategory = (id: string): Category => ({
|
const createCategory = (id: string): Category => ({
|
||||||
id,
|
id,
|
||||||
@@ -22,6 +27,7 @@ const createFeed = (id: number, unread: number): Subscription => ({
|
|||||||
feedUrl: "",
|
feedUrl: "",
|
||||||
feedLink: "",
|
feedLink: "",
|
||||||
iconUrl: "",
|
iconUrl: "",
|
||||||
|
pushNotificationsEnabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const root = createCategory("root")
|
const root = createCategory("root")
|
||||||
@@ -117,3 +123,51 @@ describe("selectNextUnreadTreeItem", () => {
|
|||||||
expect(store.getState().redirect.to).toBe("/app/feed/3")
|
expect(store.getState().redirect.to).toBe("/app/feed/3")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("hasNewEntries", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets and clear flag for a feed", async () => {
|
||||||
|
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
entries: [{ id: "3" } as Entry],
|
||||||
|
hasMore: false,
|
||||||
|
name: "my-feed",
|
||||||
|
errorCount: 3,
|
||||||
|
feedLink: "https://mysite.com/feed",
|
||||||
|
timestamp: 123,
|
||||||
|
ignoredReadStatus: false,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
tree: {
|
||||||
|
rootCategory: root,
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
source: {
|
||||||
|
type: "feed",
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as RootState,
|
||||||
|
})
|
||||||
|
|
||||||
|
// initial state
|
||||||
|
expect(store.getState().tree.rootCategory?.children[0].feeds[0].unread).toBe(0)
|
||||||
|
expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBeFalsy()
|
||||||
|
|
||||||
|
// increments unread count and sets hasNewEntries to true
|
||||||
|
await store.dispatch(newFeedEntriesDiscovered({ feedId: 1, amount: 3 }))
|
||||||
|
expect(store.getState().tree.rootCategory?.children[0].feeds[0].unread).toBe(3)
|
||||||
|
expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBe(true)
|
||||||
|
|
||||||
|
// reload entries and sets hasNewEntries to false
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "feed", id: "1" }, clearSearch: true }))
|
||||||
|
expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export interface Subscription {
|
|||||||
position: number
|
position: number
|
||||||
newestItemTime?: number
|
newestItemTime?: number
|
||||||
filter?: string
|
filter?: string
|
||||||
|
filterLegacy?: string
|
||||||
|
pushNotificationsEnabled: boolean
|
||||||
|
autoMarkAsReadAfterDays?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@@ -109,6 +112,8 @@ export interface FeedModificationRequest {
|
|||||||
categoryId?: string
|
categoryId?: string
|
||||||
position?: number
|
position?: number
|
||||||
filter?: string
|
filter?: string
|
||||||
|
pushNotificationsEnabled: boolean
|
||||||
|
autoMarkAsReadAfterDays?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetEntriesRequest {
|
export interface GetEntriesRequest {
|
||||||
@@ -196,6 +201,12 @@ export interface PasswordResetRequest {
|
|||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetConfirmationRequest {
|
||||||
|
email: string
|
||||||
|
token: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProfileModificationRequest {
|
export interface ProfileModificationRequest {
|
||||||
currentPassword: string
|
currentPassword: string
|
||||||
email: string
|
email: string
|
||||||
@@ -209,18 +220,27 @@ export interface RegistrationRequest {
|
|||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InitialSetupRequest {
|
||||||
|
name: string
|
||||||
|
password: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
announcement?: string
|
announcement?: string
|
||||||
version: string
|
version: string
|
||||||
gitCommit: string
|
gitCommit: string
|
||||||
allowRegistrations: boolean
|
allowRegistrations: boolean
|
||||||
googleAnalyticsCode?: string
|
emailAddressRequired: boolean
|
||||||
smtpEnabled: boolean
|
smtpEnabled: boolean
|
||||||
demoAccountEnabled: boolean
|
demoAccountEnabled: boolean
|
||||||
websocketEnabled: boolean
|
websocketEnabled: boolean
|
||||||
websocketPingInterval: number
|
websocketPingInterval: number
|
||||||
treeReloadInterval: number
|
treeReloadInterval: number
|
||||||
forceRefreshCooldownDuration: number
|
forceRefreshCooldownDuration: number
|
||||||
|
initialSetupRequired: boolean
|
||||||
|
minimumPasswordLength: number
|
||||||
|
pushNotificationsEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SharingSettings {
|
export interface SharingSettings {
|
||||||
@@ -229,13 +249,22 @@ export interface SharingSettings {
|
|||||||
facebook: boolean
|
facebook: boolean
|
||||||
twitter: boolean
|
twitter: boolean
|
||||||
tumblr: boolean
|
tumblr: boolean
|
||||||
pocket: boolean
|
|
||||||
instapaper: boolean
|
instapaper: boolean
|
||||||
buffer: boolean
|
buffer: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PushNotificationType = "ntfy" | "gotify" | "pushover"
|
||||||
|
|
||||||
|
export interface PushNotificationSettings {
|
||||||
|
type?: PushNotificationType
|
||||||
|
serverUrl?: string
|
||||||
|
userId?: string
|
||||||
|
userSecret?: string
|
||||||
|
topic?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
language: string
|
language?: string
|
||||||
readingMode: ReadingMode
|
readingMode: ReadingMode
|
||||||
readingOrder: ReadingOrder
|
readingOrder: ReadingOrder
|
||||||
showRead: boolean
|
showRead: boolean
|
||||||
@@ -248,22 +277,26 @@ export interface Settings {
|
|||||||
starIconDisplayMode: IconDisplayMode
|
starIconDisplayMode: IconDisplayMode
|
||||||
externalLinkIconDisplayMode: IconDisplayMode
|
externalLinkIconDisplayMode: IconDisplayMode
|
||||||
markAllAsReadConfirmation: boolean
|
markAllAsReadConfirmation: boolean
|
||||||
|
markAllAsReadNavigateToNextUnread: boolean
|
||||||
customContextMenu: boolean
|
customContextMenu: boolean
|
||||||
mobileFooter: boolean
|
mobileFooter: boolean
|
||||||
unreadCountTitle: boolean
|
unreadCountTitle: boolean
|
||||||
unreadCountFavicon: boolean
|
unreadCountFavicon: boolean
|
||||||
|
disablePullToRefresh: boolean
|
||||||
|
primaryColor?: string
|
||||||
sharingSettings: SharingSettings
|
sharingSettings: SharingSettings
|
||||||
|
pushNotificationSettings: PushNotificationSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalSettings {
|
export interface LocalSettings {
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
sidebarWidth: number
|
sidebarWidth: number
|
||||||
announcementHash: string
|
announcementHash: string
|
||||||
|
fontSizePercentage: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StarRequest {
|
export interface StarRequest {
|
||||||
id: string
|
id: string
|
||||||
feedId: number
|
|
||||||
starred: boolean
|
starred: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,3 +337,10 @@ export interface AuthenticationError {
|
|||||||
message: string
|
message: string
|
||||||
allowRegistrations: boolean
|
allowRegistrations: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CommaFeedExceptionType = "WRONG_USERNAME_OR_PASSWORD"
|
||||||
|
|
||||||
|
export interface CommaFeedApplicationError {
|
||||||
|
type: CommaFeedExceptionType
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,8 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { showNotification } from "@mantine/notifications"
|
import { showNotification } from "@mantine/notifications"
|
||||||
import { type PayloadAction, createSlice, isAnyOf } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import type { LocalSettings, Settings, UserModel, ViewMode } from "app/types"
|
import type { LocalSettings, Settings, UserModel, ViewMode } from "@/app/types"
|
||||||
import {
|
import { changeSettings, reloadProfile, reloadSettings, reloadTags } from "./thunks"
|
||||||
changeCustomContextMenu,
|
|
||||||
changeEntriesToKeepOnTopWhenScrolling,
|
|
||||||
changeExternalLinkIconDisplayMode,
|
|
||||||
changeLanguage,
|
|
||||||
changeMarkAllAsReadConfirmation,
|
|
||||||
changeMobileFooter,
|
|
||||||
changeReadingMode,
|
|
||||||
changeReadingOrder,
|
|
||||||
changeScrollMarks,
|
|
||||||
changeScrollMode,
|
|
||||||
changeScrollSpeed,
|
|
||||||
changeSharingSetting,
|
|
||||||
changeShowRead,
|
|
||||||
changeStarIconDisplayMode,
|
|
||||||
changeUnreadCountFavicon,
|
|
||||||
changeUnreadCountTitle,
|
|
||||||
reloadProfile,
|
|
||||||
reloadSettings,
|
|
||||||
reloadTags,
|
|
||||||
} from "./thunks"
|
|
||||||
|
|
||||||
interface UserState {
|
interface UserState {
|
||||||
settings?: Settings
|
settings?: Settings
|
||||||
@@ -35,6 +15,7 @@ export const initialLocalSettings: LocalSettings = {
|
|||||||
viewMode: "detailed",
|
viewMode: "detailed",
|
||||||
sidebarWidth: 360,
|
sidebarWidth: 360,
|
||||||
announcementHash: "no-hash",
|
announcementHash: "no-hash",
|
||||||
|
fontSizePercentage: 100,
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UserState = {
|
const initialState: UserState = {
|
||||||
@@ -48,6 +29,9 @@ export const userSlice = createSlice({
|
|||||||
setViewMode: (state, action: PayloadAction<ViewMode>) => {
|
setViewMode: (state, action: PayloadAction<ViewMode>) => {
|
||||||
state.localSettings.viewMode = action.payload
|
state.localSettings.viewMode = action.payload
|
||||||
},
|
},
|
||||||
|
setFontSizePercentage: (state, action: PayloadAction<number>) => {
|
||||||
|
state.localSettings.fontSizePercentage = action.payload
|
||||||
|
},
|
||||||
setSidebarWidth: (state, action: PayloadAction<number>) => {
|
setSidebarWidth: (state, action: PayloadAction<number>) => {
|
||||||
state.localSettings.sidebarWidth = action.payload
|
state.localSettings.sidebarWidth = action.payload
|
||||||
},
|
},
|
||||||
@@ -65,95 +49,17 @@ export const userSlice = createSlice({
|
|||||||
builder.addCase(reloadTags.fulfilled, (state, action) => {
|
builder.addCase(reloadTags.fulfilled, (state, action) => {
|
||||||
state.tags = action.payload
|
state.tags = action.payload
|
||||||
})
|
})
|
||||||
builder.addCase(changeReadingMode.pending, (state, action) => {
|
builder.addCase(changeSettings.pending, (state, action) => {
|
||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.readingMode = action.meta.arg
|
state.settings = { ...state.settings, ...action.meta.arg }
|
||||||
})
|
})
|
||||||
builder.addCase(changeReadingOrder.pending, (state, action) => {
|
builder.addCase(changeSettings.fulfilled, () => {
|
||||||
if (!state.settings) return
|
showNotification({
|
||||||
state.settings.readingOrder = action.meta.arg
|
message: t`Settings saved.`,
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
builder.addCase(changeLanguage.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.language = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeScrollSpeed.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.scrollSpeed = action.meta.arg ? 400 : 0
|
|
||||||
})
|
|
||||||
builder.addCase(changeShowRead.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.showRead = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeScrollMarks.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.scrollMarks = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeScrollMode.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.scrollMode = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeEntriesToKeepOnTopWhenScrolling.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.entriesToKeepOnTopWhenScrolling = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeStarIconDisplayMode.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.starIconDisplayMode = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeExternalLinkIconDisplayMode.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.externalLinkIconDisplayMode = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.markAllAsReadConfirmation = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.customContextMenu = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeMobileFooter.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.mobileFooter = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeUnreadCountTitle.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.unreadCountTitle = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeUnreadCountFavicon.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.unreadCountFavicon = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeSharingSetting.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
|
||||||
})
|
|
||||||
builder.addMatcher(
|
|
||||||
isAnyOf(
|
|
||||||
changeLanguage.fulfilled,
|
|
||||||
changeScrollSpeed.fulfilled,
|
|
||||||
changeShowRead.fulfilled,
|
|
||||||
changeScrollMarks.fulfilled,
|
|
||||||
changeScrollMode.fulfilled,
|
|
||||||
changeEntriesToKeepOnTopWhenScrolling.fulfilled,
|
|
||||||
changeStarIconDisplayMode.fulfilled,
|
|
||||||
changeExternalLinkIconDisplayMode.fulfilled,
|
|
||||||
changeMarkAllAsReadConfirmation.fulfilled,
|
|
||||||
changeCustomContextMenu.fulfilled,
|
|
||||||
changeMobileFooter.fulfilled,
|
|
||||||
changeUnreadCountTitle.fulfilled,
|
|
||||||
changeUnreadCountFavicon.fulfilled,
|
|
||||||
changeSharingSetting.fulfilled
|
|
||||||
),
|
|
||||||
() => {
|
|
||||||
showNotification({
|
|
||||||
message: t`Settings saved.`,
|
|
||||||
color: "green",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setViewMode, setSidebarWidth, setAnnouncementHash } = userSlice.actions
|
export const { setViewMode, setSidebarWidth, setAnnouncementHash, setFontSizePercentage } = userSlice.actions
|
||||||
|
|||||||
@@ -1,135 +1,27 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "app/client"
|
import { client } from "@/app/client"
|
||||||
import { reloadEntries } from "app/entries/thunks"
|
import type { Settings } from "@/app/types"
|
||||||
import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "app/types"
|
|
||||||
|
|
||||||
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
|
export const reloadSettings = createAppAsyncThunk(
|
||||||
|
"settings/reload",
|
||||||
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
async () => await client.user.getSettings().then(r => r.data)
|
||||||
|
|
||||||
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
|
|
||||||
|
|
||||||
export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, readingMode })
|
|
||||||
thunkApi.dispatch(reloadEntries())
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, readingOrder })
|
|
||||||
thunkApi.dispatch(reloadEntries())
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, language })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, showRead })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, scrollMarks })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, scrollMode })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeEntriesToKeepOnTopWhenScrolling = createAppAsyncThunk(
|
|
||||||
"settings/entriesToKeepOnTopWhenScrolling",
|
|
||||||
(entriesToKeepOnTopWhenScrolling: number, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, entriesToKeepOnTopWhenScrolling })
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const changeStarIconDisplayMode = createAppAsyncThunk(
|
export const reloadProfile = createAppAsyncThunk(
|
||||||
"settings/starIconDisplayMode",
|
"profile/reload",
|
||||||
(starIconDisplayMode: IconDisplayMode, thunkApi) => {
|
async () => await client.user.getProfile().then(r => r.data)
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, starIconDisplayMode })
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
export const reloadTags = createAppAsyncThunk(
|
||||||
"settings/externalLinkIconDisplayMode",
|
"entries/tags",
|
||||||
(externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => {
|
async () => await client.entry.getTags().then(r => r.data)
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, externalLinkIconDisplayMode })
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
export const changeSettings = createAppAsyncThunk(
|
||||||
"settings/markAllAsReadConfirmation",
|
"settings/change",
|
||||||
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
async (newSettings: Partial<Settings>, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
await client.user.saveSettings({ ...settings, ...newSettings })
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, customContextMenu })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, mobileFooter })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeUnreadCountTitle = createAppAsyncThunk("settings/unreadCountTitle", (unreadCountTitle: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, unreadCountTitle })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeUnreadCountFavicon = createAppAsyncThunk("settings/unreadCountFavicon", (unreadCountFavicon: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, unreadCountFavicon })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeSharingSetting = createAppAsyncThunk(
|
|
||||||
"settings/sharingSetting",
|
|
||||||
(
|
|
||||||
sharingSetting: {
|
|
||||||
site: keyof SharingSettings
|
|
||||||
value: boolean
|
|
||||||
},
|
|
||||||
thunkApi
|
|
||||||
) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({
|
|
||||||
...settings,
|
|
||||||
sharingSettings: {
|
|
||||||
...settings.sharingSettings,
|
|
||||||
[sharingSetting.site]: sharingSetting.value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { throttle } from "throttle-debounce"
|
import { throttle } from "throttle-debounce"
|
||||||
|
import type { TreeCategory } from "@/app/tree/slice"
|
||||||
import type { Category } from "./types"
|
import type { Category } from "./types"
|
||||||
|
|
||||||
export function visitCategoryTree(
|
export function visitCategoryTree(
|
||||||
category: Category,
|
category: TreeCategory,
|
||||||
visitor: (category: Category) => void,
|
visitor: (category: TreeCategory) => void,
|
||||||
options?: {
|
options?: {
|
||||||
childrenFirst?: boolean
|
childrenFirst?: boolean
|
||||||
}
|
}
|
||||||
@@ -19,13 +20,13 @@ export function visitCategoryTree(
|
|||||||
if (childrenFirst) visitor(category)
|
if (childrenFirst) visitor(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flattenCategoryTree(category: Category): Category[] {
|
export function flattenCategoryTree(category: TreeCategory): TreeCategory[] {
|
||||||
const categories: Category[] = []
|
const categories: Category[] = []
|
||||||
visitCategoryTree(category, c => categories.push(c))
|
visitCategoryTree(category, c => categories.push(c))
|
||||||
return categories
|
return categories
|
||||||
}
|
}
|
||||||
|
|
||||||
export function categoryUnreadCount(category?: Category): number {
|
export function categoryUnreadCount(category?: TreeCategory): number {
|
||||||
if (!category) return 0
|
if (!category) return 0
|
||||||
|
|
||||||
return flattenCategoryTree(category)
|
return flattenCategoryTree(category)
|
||||||
@@ -34,13 +35,35 @@ export function categoryUnreadCount(category?: Category): number {
|
|||||||
.reduce((total, current) => total + current, 0)
|
.reduce((total, current) => total + current, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
export function categoryHasNewEntries(category?: TreeCategory): boolean {
|
||||||
|
if (!category) return false
|
||||||
|
|
||||||
|
return flattenCategoryTree(category)
|
||||||
|
.flatMap(c => c.feeds)
|
||||||
|
.some(f => f.hasNewEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculatePlaceholderSize = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maxWidth,
|
||||||
|
}: {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
maxWidth: number
|
||||||
|
}) => {
|
||||||
const placeholderWidth = width && Math.min(width, maxWidth)
|
const placeholderWidth = width && Math.min(width, maxWidth)
|
||||||
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
||||||
return { width: placeholderWidth, height: placeholderHeight }
|
return { width: placeholderWidth, height: placeholderHeight }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
|
export const scrollToWithCallback = ({
|
||||||
|
options,
|
||||||
|
onScrollEnded,
|
||||||
|
}: {
|
||||||
|
options: ScrollToOptions
|
||||||
|
onScrollEnded: () => void
|
||||||
|
}) => {
|
||||||
const offset = (options.top ?? 0).toFixed()
|
const offset = (options.top ?? 0).toFixed()
|
||||||
|
|
||||||
const onScroll = throttle(100, () => {
|
const onScroll = throttle(100, () => {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<svg height="512" width="512" viewBox="0 0 6.5625 6.5625" xmlns="http://www.w3.org/2000/svg">
|
<svg height="512" width="512" viewBox="0 0 6.5625 6.5625" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>CommaFeed logo</title>
|
||||||
<rect fill="#f88a14" rx="0.7" ry="0.7" height="6.5625" width="6.5625" />
|
<rect fill="#f88a14" rx="0.7" ry="0.7" height="6.5625" width="6.5625" />
|
||||||
<path d="m1.9761,1.5289c2.9002,0,2.9002,2.9101,2.9002,2.9101" fill="none" stroke="#FFF" stroke-linecap="round"
|
<path d="m1.9761,1.5289c2.9002,0,2.9002,2.9101,2.9002,2.9101" fill="none" stroke="#FFF" stroke-linecap="round"
|
||||||
stroke-width="0.78125" />
|
stroke-width="0.78125" />
|
||||||
<path d="m1.9688,2.875c1.5705-0.00908,1.5705,1.5639,1.5705,1.5639" fill="none" stroke="#FFF" stroke-linecap="round"
|
<path d="m1.9688,2.875c1.5705-0.00908,1.5705,1.5639,1.5705,1.5639" fill="none" stroke="#FFF" stroke-linecap="round"
|
||||||
stroke-width="0.78125" />
|
stroke-width="0.78125" />
|
||||||
<path d="m2.6503,4.4062c0,0.23366-0.10712,0.47418-0.24663,0.6537-0.1814,0.2333-0.5705,0.5618-0.6913,0.5653,0.0402-0.0662,0.263-0.5654,0.2563-0.5654-0.36423,0-0.6595-0.29265-0.6595-0.65365s0.29527-0.65365,0.6595-0.65365,0.68159,0.29265,0.68159,0.65365z"
|
<path d="m2.6503,4.4062c0,0.23366-0.10712,0.47418-0.24663,0.6537-0.1814,0.2333-0.5705,0.5618-0.6913,0.5653,0.0402-0.0662,0.263-0.5654,0.2563-0.5654-0.36423,0-0.6595-0.29265-0.6595-0.65365s0.29527-0.65365,0.6595-0.65365,0.68159,0.29265,0.68159,0.65365z"
|
||||||
fill="#FFF" />
|
fill="#FFF" />
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 791 B After Width: | Height: | Size: 831 B |
@@ -1,8 +1,8 @@
|
|||||||
import type { I18nContext } from "@lingui/react"
|
import type { I18nContext } from "@lingui/react"
|
||||||
import { MantineProvider } from "@mantine/core"
|
import { MantineProvider } from "@mantine/core"
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
|
||||||
import { describe, expect, it, vi } from "vitest"
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
import { ActionButton } from "./ActionButton"
|
import { ActionButton } from "./ActionButton"
|
||||||
|
|
||||||
vi.mock(import("@lingui/react"), () => ({
|
vi.mock(import("@lingui/react"), () => ({
|
||||||
@@ -10,7 +10,7 @@ vi.mock(import("@lingui/react"), () => ({
|
|||||||
_: msg => msg,
|
_: msg => msg,
|
||||||
} as I18nContext),
|
} as I18nContext),
|
||||||
}))
|
}))
|
||||||
vi.mock(import("hooks/useActionButton"))
|
vi.mock(import("@/hooks/useActionButton"))
|
||||||
|
|
||||||
const label = "Test Label"
|
const label = "Test Label"
|
||||||
const icon = "Test Icon"
|
const icon = "Test Icon"
|
||||||
@@ -18,7 +18,9 @@ describe("ActionButton", () => {
|
|||||||
it("renders Button with label on desktop", () => {
|
it("renders Button with label on desktop", () => {
|
||||||
vi.mocked(useActionButton).mockReturnValue({ mobile: false, spacing: 0 })
|
vi.mocked(useActionButton).mockReturnValue({ mobile: false, spacing: 0 })
|
||||||
|
|
||||||
render(<ActionButton label={label} icon={icon} />, { wrapper: MantineProvider })
|
render(<ActionButton label={label} icon={icon} />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
expect(screen.getByText(label)).toBeInTheDocument()
|
expect(screen.getByText(label)).toBeInTheDocument()
|
||||||
expect(screen.getByText(icon)).toBeInTheDocument()
|
expect(screen.getByText(icon)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -26,7 +28,9 @@ describe("ActionButton", () => {
|
|||||||
it("renders ActionIcon with tooltip on mobile", async () => {
|
it("renders ActionIcon with tooltip on mobile", async () => {
|
||||||
vi.mocked(useActionButton).mockReturnValue({ mobile: true, spacing: 0 })
|
vi.mocked(useActionButton).mockReturnValue({ mobile: true, spacing: 0 })
|
||||||
|
|
||||||
render(<ActionButton label={label} icon={icon} />, { wrapper: MantineProvider })
|
render(<ActionButton label={label} icon={icon} />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
expect(screen.queryByText(label)).not.toBeInTheDocument()
|
expect(screen.queryByText(label)).not.toBeInTheDocument()
|
||||||
expect(screen.getByText(icon)).toBeInTheDocument()
|
expect(screen.getByText(icon)).toBeInTheDocument()
|
||||||
|
|
||||||
@@ -39,7 +43,9 @@ describe("ActionButton", () => {
|
|||||||
vi.mocked(useActionButton).mockReturnValue({ mobile: false, spacing: 0 })
|
vi.mocked(useActionButton).mockReturnValue({ mobile: false, spacing: 0 })
|
||||||
const clickListener = vi.fn()
|
const clickListener = vi.fn()
|
||||||
|
|
||||||
render(<ActionButton label={label} icon={icon} onClick={clickListener} />, { wrapper: MantineProvider })
|
render(<ActionButton label={label} icon={icon} onClick={clickListener} />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
fireEvent.click(screen.getByRole("button"))
|
fireEvent.click(screen.getByRole("button"))
|
||||||
|
|
||||||
expect(clickListener).toHaveBeenCalled()
|
expect(clickListener).toHaveBeenCalled()
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import type { MessageDescriptor } from "@lingui/core"
|
import type { MessageDescriptor } from "@lingui/core"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { ActionIcon, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
import {
|
||||||
import type { ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
ActionIcon,
|
||||||
import { Constants } from "app/constants"
|
type ActionIconVariant,
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
Box,
|
||||||
import { type MouseEventHandler, type ReactNode, forwardRef } from "react"
|
Button,
|
||||||
|
type ButtonVariant,
|
||||||
|
Tooltip,
|
||||||
|
useMantineTheme,
|
||||||
|
} from "@mantine/core"
|
||||||
|
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
|
||||||
|
export type Mode = "auto" | "mobile" | "desktop"
|
||||||
|
|
||||||
interface ActionButtonProps {
|
interface ActionButtonProps {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
@@ -12,8 +21,7 @@ interface ActionButtonProps {
|
|||||||
label?: string | MessageDescriptor
|
label?: string | MessageDescriptor
|
||||||
onClick?: MouseEventHandler
|
onClick?: MouseEventHandler
|
||||||
variant?: ActionIconVariant & ButtonVariant
|
variant?: ActionIconVariant & ButtonVariant
|
||||||
hideLabelOnDesktop?: boolean
|
mode?: Mode
|
||||||
showLabelOnMobile?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,10 +34,12 @@ export const ActionButton = forwardRef<HTMLDivElement, ActionButtonProps>((props
|
|||||||
|
|
||||||
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
|
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
|
||||||
const variant = props.variant ?? "subtle"
|
const variant = props.variant ?? "subtle"
|
||||||
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
|
||||||
|
const mode: Mode = props.mode ?? "auto"
|
||||||
|
const iconOnly = mode === "mobile" || (mode === "auto" && mobile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={ref}>
|
<Box ref={ref} className="cf-action-button">
|
||||||
{iconOnly && (
|
{iconOnly && (
|
||||||
<Tooltip label={label} openDelay={Constants.tooltip.delay}>
|
<Tooltip label={label} openDelay={Constants.tooltip.delay}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface ErrorsAlertProps {
|
|||||||
messages: string[]
|
messages: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Alert(props: ErrorsAlertProps) {
|
export function Alert(props: Readonly<ErrorsAlertProps>) {
|
||||||
let title: React.ReactNode
|
let title: React.ReactNode
|
||||||
let color: string
|
let color: string
|
||||||
let icon: React.ReactNode
|
let icon: React.ReactNode
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Dialog, Text } from "@mantine/core"
|
import { Box, Dialog, Text } from "@mantine/core"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { setAnnouncementHash } from "app/user/slice"
|
|
||||||
import { Content } from "components/content/Content"
|
|
||||||
import { useAsync } from "react-async-hook"
|
import { useAsync } from "react-async-hook"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { setAnnouncementHash } from "@/app/user/slice"
|
||||||
|
import { Content } from "@/components/content/Content"
|
||||||
|
|
||||||
const sha256Hex = async (input: string | undefined) => {
|
const sha256Hex = async (input: string | undefined) => {
|
||||||
const data = new TextEncoder().encode(input)
|
const data = new TextEncoder().encode(input)
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
html,
|
|
||||||
body {
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export const DisablePullToRefresh = () => {
|
export const DisablePullToRefresh = ({ enabled }: { enabled: boolean | undefined }) => {
|
||||||
import("./DisablePullToRefresh.css")
|
return enabled ? <style>{`html, body { overscroll-behavior: none; }`}</style> : null
|
||||||
return <></>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ErrorPage } from "pages/ErrorPage"
|
|
||||||
import React, { type ReactNode } from "react"
|
import React, { type ReactNode } from "react"
|
||||||
|
import { ErrorPage } from "@/pages/ErrorPage"
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Box, Center } from "@mantine/core"
|
import { Box, Center } from "@mantine/core"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { TbPhoto } from "react-icons/tb"
|
import { TbPhoto } from "react-icons/tb"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
interface ImageWithPlaceholderWhileLoadingProps {
|
interface ImageWithPlaceholderWhileLoadingProps {
|
||||||
src: string
|
src: string
|
||||||
@@ -14,6 +14,7 @@ interface ImageWithPlaceholderWhileLoadingProps {
|
|||||||
placeholderHeight?: number
|
placeholderHeight?: number
|
||||||
placeholderBackgroundColor?: string
|
placeholderBackgroundColor?: string
|
||||||
placeholderIconSize?: number
|
placeholderIconSize?: number
|
||||||
|
onError?: (e: React.SyntheticEvent<HTMLImageElement, Event>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = tss
|
const useStyles = tss
|
||||||
@@ -44,7 +45,8 @@ export function ImageWithPlaceholderWhileLoading({
|
|||||||
title,
|
title,
|
||||||
width,
|
width,
|
||||||
style,
|
style,
|
||||||
}: ImageWithPlaceholderWhileLoadingProps) {
|
onError,
|
||||||
|
}: Readonly<ImageWithPlaceholderWhileLoadingProps>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
placeholderWidth,
|
placeholderWidth,
|
||||||
placeholderHeight,
|
placeholderHeight,
|
||||||
@@ -70,6 +72,7 @@ export function ImageWithPlaceholderWhileLoading({
|
|||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
onLoad={() => setLoading(false)}
|
onLoad={() => setLoading(false)}
|
||||||
|
onError={onError}
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
display: loading ? "none" : (style?.display ?? "initial"),
|
display: loading ? "none" : (style?.display ?? "initial"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Kbd, Stack, Table } from "@mantine/core"
|
import { Anchor, Box, Kbd, Stack, Table } from "@mantine/core"
|
||||||
import { useOs } from "@mantine/hooks"
|
import { useOs } from "@mantine/hooks"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
|
|
||||||
export function KeyboardShortcutsHelp() {
|
export function KeyboardShortcutsHelp() {
|
||||||
const isMacOS = useOs() === "macos"
|
const isMacOS = useOs() === "macos"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Image } from "@mantine/core"
|
import { Image } from "@mantine/core"
|
||||||
import logo from "assets/logo.svg"
|
import logo from "@/assets/logo.svg"
|
||||||
|
|
||||||
export interface LogoProps {
|
export interface LogoProps {
|
||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Logo(props: LogoProps) {
|
export function Logo(props: Readonly<LogoProps>) {
|
||||||
return <Image src={logo} w={props.size} />
|
return <Image src={logo} w={props.size} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { Button, Code, Group, Modal, Slider, Stack, Text } from "@mantine/core"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { setMarkAllAsReadConfirmationDialogOpen } from "@/app/entries/slice"
|
||||||
|
import { markAllEntries } from "@/app/entries/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
|
|
||||||
|
export function MarkAllAsReadConfirmationDialog() {
|
||||||
|
const [threshold, setThreshold] = useState(0)
|
||||||
|
const open = useAppSelector(state => state.entries.markAllAsReadConfirmationDialogOpen)
|
||||||
|
const source = useAppSelector(state => state.entries.source)
|
||||||
|
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
||||||
|
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
||||||
|
const search = useAppSelector(state => state.entries.search)
|
||||||
|
const markAllAsReadNavigateToNextUnread = useAppSelector(
|
||||||
|
state => state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||||
|
)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const onConfirm = async () => {
|
||||||
|
dispatch(setMarkAllAsReadConfirmationDialogOpen(false))
|
||||||
|
await dispatch(
|
||||||
|
markAllEntries({
|
||||||
|
sourceType: source.type,
|
||||||
|
req: {
|
||||||
|
id: source.id,
|
||||||
|
read: true,
|
||||||
|
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
|
||||||
|
insertedBefore: entriesTimestamp,
|
||||||
|
keywords: search,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
||||||
|
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
|
||||||
|
await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={open}
|
||||||
|
onClose={() => dispatch(setMarkAllAsReadConfirmationDialogOpen(false))}
|
||||||
|
title={<Trans>Mark all entries as read</Trans>}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">
|
||||||
|
{threshold === 0 && (
|
||||||
|
<Trans>
|
||||||
|
Are you sure you want to mark all entries of <Code>{sourceLabel}</Code> as read?
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
{threshold > 0 && (
|
||||||
|
<Trans>
|
||||||
|
Are you sure you want to mark entries older than {threshold} days of{" "}
|
||||||
|
<Code>{sourceLabel}</Code> as read?
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Slider
|
||||||
|
py="xl"
|
||||||
|
min={0}
|
||||||
|
max={28}
|
||||||
|
marks={[
|
||||||
|
{ value: 0, label: "0" },
|
||||||
|
{ value: 7, label: "7" },
|
||||||
|
{ value: 14, label: "14" },
|
||||||
|
{ value: 21, label: "21" },
|
||||||
|
{ value: 28, label: "28" },
|
||||||
|
]}
|
||||||
|
value={threshold}
|
||||||
|
onChange={setThreshold}
|
||||||
|
data-autofocus
|
||||||
|
onKeyDown={e => e.key === "Enter" && onConfirm()}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => dispatch(setMarkAllAsReadConfirmationDialogOpen(false))}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onClick={onConfirm}>
|
||||||
|
<Trans>Confirm</Trans>
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { Checkbox, type CheckboxProps } from "@mantine/core"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { useAppSelector } from "@/app/store"
|
||||||
|
|
||||||
|
export const ReceivePushNotificationsChechbox = (props: CheckboxProps) => {
|
||||||
|
const pushNotificationsEnabled = useAppSelector(state => state.server.serverInfos?.pushNotificationsEnabled)
|
||||||
|
const pushNotificationsConfigured = useAppSelector(state => !!state.user.settings?.pushNotificationSettings.type)
|
||||||
|
|
||||||
|
const disabled = !pushNotificationsEnabled || !pushNotificationsConfigured
|
||||||
|
let description: ReactNode = ""
|
||||||
|
if (!pushNotificationsEnabled) {
|
||||||
|
description = <Trans>Push notifications are not enabled on this CommaFeed instance.</Trans>
|
||||||
|
} else if (!pushNotificationsConfigured) {
|
||||||
|
description = <Trans>Push notifications are not configured in your user settings.</Trans>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
label={<Trans>Receive push notifications</Trans>}
|
||||||
|
disabled={disabled}
|
||||||
|
description={description}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Tooltip } from "@mantine/core"
|
import { Tooltip } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { useNow } from "hooks/useNow"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useNow } from "@/hooks/useNow"
|
||||||
|
|
||||||
export function RelativeDate(props: { date: Date | number | undefined }) {
|
export function RelativeDate(
|
||||||
|
props: Readonly<{
|
||||||
|
date: Date | number | undefined
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const now = useNow(60 * 1000)
|
const now = useNow(60 * 1000)
|
||||||
|
|
||||||
if (!props.date) return <Trans>N/A</Trans>
|
if (!props.date) return <Trans>N/A</Trans>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, Checkbox, Group, PasswordInput, Stack, TextInput } from "@mantine/core"
|
import { Box, Button, Checkbox, Group, PasswordInput, Stack, TextInput } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import type { AdminSaveUserRequest, UserModel } from "app/types"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbDeviceFloppy } from "react-icons/tb"
|
import { TbDeviceFloppy } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import type { AdminSaveUserRequest, UserModel } from "@/app/types"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
|
|
||||||
interface UserEditProps {
|
interface UserEditProps {
|
||||||
user?: UserModel
|
user?: UserModel
|
||||||
@@ -13,7 +13,7 @@ interface UserEditProps {
|
|||||||
onSave: () => void
|
onSave: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserEdit(props: UserEditProps) {
|
export function UserEdit(props: Readonly<UserEditProps>) {
|
||||||
const form = useForm<AdminSaveUserRequest>({
|
const form = useForm<AdminSaveUserRequest>({
|
||||||
initialValues: props.user ?? {
|
initialValues: props.user ?? {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -34,7 +34,11 @@ export function UserEdit(props: UserEditProps) {
|
|||||||
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
||||||
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} />
|
<PasswordInput
|
||||||
|
label={<Trans>Password</Trans>}
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
required={!props.user}
|
||||||
|
/>
|
||||||
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
||||||
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
||||||
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Input, Textarea } from "@mantine/core"
|
import { Input, Textarea } from "@mantine/core"
|
||||||
import RichCodeEditor from "components/code/RichCodeEditor"
|
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
import RichCodeEditor from "@/components/code/RichCodeEditor"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
interface CodeEditorProps {
|
interface CodeEditorProps {
|
||||||
|
label?: ReactNode
|
||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
language: "css" | "javascript"
|
language: "css" | "javascript"
|
||||||
value?: string
|
value?: string
|
||||||
onChange: (value: string | undefined) => void
|
onChange: (value: string | undefined) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeEditor(props: CodeEditorProps) {
|
export function CodeEditor(props: Readonly<CodeEditorProps>) {
|
||||||
const mobile = useMobile()
|
const mobile = useMobile()
|
||||||
|
|
||||||
return mobile ? (
|
return mobile ? (
|
||||||
@@ -19,6 +20,7 @@ export function CodeEditor(props: CodeEditorProps) {
|
|||||||
autosize
|
autosize
|
||||||
minRows={4}
|
minRows={4}
|
||||||
maxRows={15}
|
maxRows={15}
|
||||||
|
label={props.label}
|
||||||
description={props.description}
|
description={props.description}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
@@ -29,7 +31,7 @@ export function CodeEditor(props: CodeEditorProps) {
|
|||||||
onChange={e => props.onChange(e.currentTarget.value)}
|
onChange={e => props.onChange(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input.Wrapper description={props.description}>
|
<Input.Wrapper label={props.label} description={props.description}>
|
||||||
<RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} />
|
<RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} />
|
||||||
</Input.Wrapper>
|
</Input.Wrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Loader } from "components/Loader"
|
|
||||||
import { useColorScheme } from "hooks/useColorScheme"
|
|
||||||
import { useAsync } from "react-async-hook"
|
import { useAsync } from "react-async-hook"
|
||||||
|
import { Loader } from "@/components/Loader"
|
||||||
|
import { useColorScheme } from "@/hooks/useColorScheme"
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
window.MonacoEnvironment = {
|
window.MonacoEnvironment = {
|
||||||
async getWorker(_, label) {
|
async getWorker(_, label) {
|
||||||
let worker: typeof import("*?worker")
|
let worker: typeof import("*?worker")
|
||||||
if (label === "css") {
|
if (label === "css") {
|
||||||
worker = await import("monaco-editor/esm/vs/language/css/css.worker?worker")
|
worker = await import("monaco-editor/language/css/css.worker?worker")
|
||||||
} else if (label === "javascript") {
|
} else if (label === "javascript") {
|
||||||
worker = await import("monaco-editor/esm/vs/language/typescript/ts.worker?worker")
|
worker = await import("monaco-editor/language/typescript/ts.worker?worker")
|
||||||
} else {
|
} else {
|
||||||
worker = await import("monaco-editor/esm/vs/editor/editor.worker?worker")
|
worker = await import("monaco-editor/editor/editor.worker?worker")
|
||||||
}
|
}
|
||||||
return new worker.default()
|
return new worker.default()
|
||||||
},
|
},
|
||||||
@@ -30,7 +30,7 @@ interface RichCodeEditorProps {
|
|||||||
onChange: (value: string | undefined) => void
|
onChange: (value: string | undefined) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function RichCodeEditor(props: RichCodeEditorProps) {
|
function RichCodeEditor(props: Readonly<RichCodeEditorProps>) {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
|
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TypographyStylesProvider } from "@mantine/core"
|
import { Typography } from "@mantine/core"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component is used to provide basic styles to html typography elements.
|
* This component is used to provide basic styles to html typography elements.
|
||||||
@@ -20,5 +20,5 @@ const useStyles = tss.create(() => ({
|
|||||||
|
|
||||||
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
||||||
const { classes } = useStyles()
|
const { classes } = useStyles()
|
||||||
return <TypographyStylesProvider className={classes.content}>{props.children}</TypographyStylesProvider>
|
return <Typography className={classes.content}>{props.children}</Typography>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { MantineProvider } from "@mantine/core"
|
import { MantineProvider } from "@mantine/core"
|
||||||
import { render } from "@testing-library/react"
|
import { render } from "@testing-library/react"
|
||||||
import { Content } from "components/content/Content"
|
|
||||||
import React from "react"
|
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { Content } from "@/components/content/Content"
|
||||||
|
|
||||||
describe("Content component", () => {
|
describe("Content component", () => {
|
||||||
it("renders basic content", () => {
|
it("renders basic content", () => {
|
||||||
@@ -16,7 +15,9 @@ describe("Content component", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("renders iframe tag when included in content", () => {
|
it("renders iframe tag when included in content", () => {
|
||||||
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, { wrapper: MantineProvider })
|
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
|
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { Box, Mark } from "@mantine/core"
|
import { Box, Mark } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { calculatePlaceholderSize } from "app/utils"
|
|
||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
|
||||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
|
||||||
import escapeStringRegexp from "escape-string-regexp"
|
import escapeStringRegexp from "escape-string-regexp"
|
||||||
import { ALLOWED_TAG_LIST, type ChildrenNode, Interweave, type MatchResponse, Matcher, type Node, type TransformCallback } from "interweave"
|
import {
|
||||||
|
ALLOWED_TAG_LIST,
|
||||||
|
type ChildrenNode,
|
||||||
|
Interweave,
|
||||||
|
Matcher,
|
||||||
|
type MatchResponse,
|
||||||
|
type Node,
|
||||||
|
type TransformCallback,
|
||||||
|
} from "interweave"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import styleToObject from "style-to-object"
|
import styleToObject from "style-to-object"
|
||||||
import { tss } from "tss"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { calculatePlaceholderSize } from "@/app/utils"
|
||||||
|
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||||
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
export interface ContentProps {
|
export interface ContentProps {
|
||||||
content: string
|
content: string
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
|
|
||||||
export function Enclosure(props: {
|
export function Enclosure(
|
||||||
enclosureType: string
|
props: Readonly<{
|
||||||
enclosureUrl: string
|
enclosureType: string
|
||||||
}) {
|
enclosureUrl: string
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const hasVideo = props.enclosureType.startsWith("video")
|
const hasVideo = props.enclosureType.startsWith("video")
|
||||||
const hasAudio = props.enclosureType.startsWith("audio")
|
const hasAudio = props.enclosureType.startsWith("audio")
|
||||||
const hasImage = props.enclosureType.startsWith("image")
|
const hasImage = props.enclosureType.startsWith("image")
|
||||||
|
|||||||
@@ -1,36 +1,34 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { openModal } from "@mantine/modals"
|
import { openModal } from "@mantine/modals"
|
||||||
import { Constants } from "app/constants"
|
import { useEffect } from "react"
|
||||||
import type { ExpendableEntry } from "app/entries/slice"
|
import { useContextMenu } from "react-contexify"
|
||||||
|
import InfiniteScroll from "react-infinite-scroller"
|
||||||
|
import { throttle } from "throttle-debounce"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import type { ExpendableEntry } from "@/app/entries/slice"
|
||||||
import {
|
import {
|
||||||
loadMoreEntries,
|
loadMoreEntries,
|
||||||
markAllEntries,
|
markAllAsReadWithConfirmationIfRequired,
|
||||||
markEntry,
|
markEntry,
|
||||||
reloadEntries,
|
reloadEntries,
|
||||||
selectEntry,
|
selectEntry,
|
||||||
selectNextEntry,
|
selectNextEntry,
|
||||||
selectPreviousEntry,
|
selectPreviousEntry,
|
||||||
starEntry,
|
starEntry,
|
||||||
} from "app/entries/thunks"
|
} from "@/app/entries/thunks"
|
||||||
import { redirectToRootCategory } from "app/redirect/thunks"
|
import { redirectToRootCategory } from "@/app/redirect/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { toggleSidebar } from "app/tree/slice"
|
import { toggleSidebar } from "@/app/tree/slice"
|
||||||
import { selectNextUnreadTreeItem } from "app/tree/thunks"
|
import { selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
|
import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"
|
||||||
import { Loader } from "components/Loader"
|
import { Loader } from "@/components/Loader"
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
import { useMousetrap } from "hooks/useMousetrap"
|
import { useMousetrap } from "@/hooks/useMousetrap"
|
||||||
import { useEffect } from "react"
|
|
||||||
import { useContextMenu } from "react-contexify"
|
|
||||||
import InfiniteScroll from "react-infinite-scroller"
|
|
||||||
import { throttle } from "throttle-debounce"
|
|
||||||
import { FeedEntry } from "./FeedEntry"
|
import { FeedEntry } from "./FeedEntry"
|
||||||
|
|
||||||
export function FeedEntries() {
|
export function FeedEntries() {
|
||||||
const source = useAppSelector(state => state.entries.source)
|
|
||||||
const entries = useAppSelector(state => state.entries.entries)
|
const entries = useAppSelector(state => state.entries.entries)
|
||||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp)
|
|
||||||
const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId)
|
const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId)
|
||||||
const hasMore = useAppSelector(state => state.entries.hasMore)
|
const hasMore = useAppSelector(state => state.entries.hasMore)
|
||||||
const loading = useAppSelector(state => state.entries.loading)
|
const loading = useAppSelector(state => state.entries.loading)
|
||||||
@@ -113,12 +111,13 @@ export function FeedEntries() {
|
|||||||
const el = document.getElementById(Constants.dom.entryId(e))
|
const el = document.getElementById(Constants.dom.entryId(e))
|
||||||
return el && !Constants.layout.isTopVisible(el)
|
return el && !Constants.layout.isTopVisible(el)
|
||||||
})
|
})
|
||||||
if (currentEntry) {
|
if (currentEntry && currentEntry.id !== selectedEntryId) {
|
||||||
dispatch(
|
dispatch(
|
||||||
selectEntry({
|
selectEntry({
|
||||||
entry: currentEntry,
|
entry: currentEntry,
|
||||||
expand: false,
|
expand: false,
|
||||||
markAsRead: !!scrollMarks,
|
// don't mark as read entries that the user explicitly marked as unread
|
||||||
|
markAsRead: !!scrollMarks && !currentEntry.keepUnread,
|
||||||
scrollToEntry: false,
|
scrollToEntry: false,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -126,7 +125,7 @@ export function FeedEntries() {
|
|||||||
})
|
})
|
||||||
window.addEventListener("scroll", listener)
|
window.addEventListener("scroll", listener)
|
||||||
return () => window.removeEventListener("scroll", listener)
|
return () => window.removeEventListener("scroll", listener)
|
||||||
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
|
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry, selectedEntryId])
|
||||||
|
|
||||||
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
||||||
useMousetrap(
|
useMousetrap(
|
||||||
@@ -275,17 +274,7 @@ export function FeedEntries() {
|
|||||||
})
|
})
|
||||||
useMousetrap("shift+a", () => {
|
useMousetrap("shift+a", () => {
|
||||||
// mark all entries as read
|
// mark all entries as read
|
||||||
dispatch(
|
dispatch(markAllAsReadWithConfirmationIfRequired())
|
||||||
markAllEntries({
|
|
||||||
sourceType: source.type,
|
|
||||||
req: {
|
|
||||||
id: source.id,
|
|
||||||
read: true,
|
|
||||||
olderThan: Date.now(),
|
|
||||||
insertedBefore: entriesTimestamp,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
|
useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
|
||||||
useMousetrap("f", () => dispatch(toggleSidebar()))
|
useMousetrap("f", () => dispatch(toggleSidebar()))
|
||||||
@@ -299,33 +288,27 @@ export function FeedEntries() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
id="entries"
|
className={`cf-entries cf-view-mode-${viewMode}`}
|
||||||
className={`view-mode-${viewMode}`}
|
|
||||||
initialLoad={false}
|
initialLoad={false}
|
||||||
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
|
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loader={<Box key={0}>{loading && <Loader />}</Box>}
|
loader={<Box key={0}>{loading && <Loader />}</Box>}
|
||||||
>
|
>
|
||||||
{entries.map(entry => (
|
{entries.map(entry => (
|
||||||
<article
|
<FeedEntry
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
ref={el => {
|
entry={entry}
|
||||||
if (el) el.id = Constants.dom.entryId(entry)
|
expanded={!!entry.expanded || viewMode === "expanded"}
|
||||||
}}
|
selected={entry.id === selectedEntryId}
|
||||||
data-id={entry.id}
|
showSelectionIndicator={
|
||||||
>
|
entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")
|
||||||
<FeedEntry
|
}
|
||||||
entry={entry}
|
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
||||||
expanded={!!entry.expanded || viewMode === "expanded"}
|
onHeaderClick={event => headerClicked(entry, event)}
|
||||||
selected={entry.id === selectedEntryId}
|
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
||||||
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
onBodyClick={() => bodyClicked(entry)}
|
||||||
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
onSwipedLeft={async () => await swipedLeft(entry)}
|
||||||
onHeaderClick={event => headerClicked(entry, event)}
|
/>
|
||||||
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
|
||||||
onBodyClick={() => bodyClicked(entry)}
|
|
||||||
onSwipedLeft={async () => await swipedLeft(entry)}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Box, Divider, type MantineRadius, type MantineSpacing, Paper } from "@mantine/core"
|
import { Box, Divider, type MantineRadius, type MantineSpacing, Paper } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { useAppSelector } from "app/store"
|
|
||||||
import type { Entry, ViewMode } from "app/types"
|
|
||||||
import { FeedEntryCompactHeader } from "components/content/header/FeedEntryCompactHeader"
|
|
||||||
import { FeedEntryHeader } from "components/content/header/FeedEntryHeader"
|
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useSwipeable } from "react-swipeable"
|
import { useSwipeable } from "react-swipeable"
|
||||||
import { tss } from "tss"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useAppSelector } from "@/app/store"
|
||||||
|
import type { Entry, ViewMode } from "@/app/types"
|
||||||
|
import { FeedEntryCompactHeader } from "@/components/content/header/FeedEntryCompactHeader"
|
||||||
|
import { FeedEntryHeader } from "@/components/content/header/FeedEntryHeader"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
import { tss } from "@/tss"
|
||||||
import { FeedEntryBody } from "./FeedEntryBody"
|
import { FeedEntryBody } from "./FeedEntryBody"
|
||||||
import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
|
import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
|
||||||
import { FeedEntryFooter } from "./FeedEntryFooter"
|
import { FeedEntryFooter } from "./FeedEntryFooter"
|
||||||
@@ -32,69 +32,86 @@ const useStyles = tss
|
|||||||
rtl: boolean
|
rtl: boolean
|
||||||
showSelectionIndicator: boolean
|
showSelectionIndicator: boolean
|
||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
|
fontSizePercentage: number
|
||||||
}>()
|
}>()
|
||||||
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth }) => {
|
.create(
|
||||||
let backgroundColor: string
|
({
|
||||||
if (colorScheme === "dark") {
|
theme,
|
||||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
colorScheme,
|
||||||
} else {
|
read,
|
||||||
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
expanded,
|
||||||
}
|
viewMode,
|
||||||
|
rtl,
|
||||||
|
showSelectionIndicator,
|
||||||
|
maxWidth,
|
||||||
|
fontSizePercentage,
|
||||||
|
}) => {
|
||||||
|
let backgroundColor: string
|
||||||
|
if (colorScheme === "dark") {
|
||||||
|
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||||
|
} else {
|
||||||
|
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
||||||
|
}
|
||||||
|
|
||||||
let marginY = 10
|
let marginY = 10
|
||||||
if (viewMode === "title") {
|
if (viewMode === "title") {
|
||||||
marginY = 2
|
marginY = 2
|
||||||
} else if (viewMode === "cozy") {
|
} else if (viewMode === "cozy") {
|
||||||
marginY = 6
|
marginY = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
let mobileMarginY = 6
|
let mobileMarginY = 6
|
||||||
if (viewMode === "title") {
|
if (viewMode === "title") {
|
||||||
mobileMarginY = 2
|
mobileMarginY = 2
|
||||||
} else if (viewMode === "cozy") {
|
} else if (viewMode === "cozy") {
|
||||||
mobileMarginY = 4
|
mobileMarginY = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
let backgroundHoverColor = backgroundColor
|
let backgroundHoverColor = backgroundColor
|
||||||
if (!expanded && !read) {
|
if (!expanded && !read) {
|
||||||
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
let paperBorderLeftColor = ""
|
let paperBorderLeftColor = ""
|
||||||
if (showSelectionIndicator) {
|
if (showSelectionIndicator) {
|
||||||
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
const borderLeftColor =
|
||||||
paperBorderLeftColor = `${borderLeftColor} !important`
|
colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
||||||
}
|
paperBorderLeftColor = `${borderLeftColor} !important`
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paper: {
|
paper: {
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
borderLeftColor: paperBorderLeftColor,
|
borderLeftColor: paperBorderLeftColor,
|
||||||
marginTop: marginY,
|
marginTop: marginY,
|
||||||
marginBottom: marginY,
|
marginBottom: marginY,
|
||||||
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
||||||
marginTop: mobileMarginY,
|
marginTop: mobileMarginY,
|
||||||
marginBottom: mobileMarginY,
|
marginBottom: mobileMarginY,
|
||||||
},
|
},
|
||||||
"@media (hover: hover)": {
|
"@media (hover: hover)": {
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: backgroundHoverColor,
|
backgroundColor: backgroundHoverColor,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
headerLink: {
|
||||||
headerLink: {
|
fontSize: `${fontSizePercentage}%`,
|
||||||
color: "inherit",
|
color: "inherit",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
direction: rtl ? "rtl" : "ltr",
|
fontSize: `${fontSizePercentage}%`,
|
||||||
maxWidth: maxWidth ?? "100%",
|
direction: rtl ? "rtl" : "ltr",
|
||||||
},
|
maxWidth: maxWidth ?? "100%",
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
export function FeedEntry(props: FeedEntryProps) {
|
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
||||||
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
|
const fontSizePercentage = useAppSelector(state => state.user.localSettings.fontSizePercentage)
|
||||||
const { classes, cx } = useStyles({
|
const { classes, cx } = useStyles({
|
||||||
read: props.entry.read,
|
read: props.entry.read,
|
||||||
expanded: props.expanded,
|
expanded: props.expanded,
|
||||||
@@ -102,6 +119,7 @@ export function FeedEntry(props: FeedEntryProps) {
|
|||||||
rtl: props.entry.rtl,
|
rtl: props.entry.rtl,
|
||||||
showSelectionIndicator: props.showSelectionIndicator,
|
showSelectionIndicator: props.showSelectionIndicator,
|
||||||
maxWidth: props.maxWidth,
|
maxWidth: props.maxWidth,
|
||||||
|
fontSizePercentage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const externalLinkDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
const externalLinkDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
||||||
@@ -111,7 +129,9 @@ export function FeedEntry(props: FeedEntryProps) {
|
|||||||
const showExternalLinkIcon =
|
const showExternalLinkIcon =
|
||||||
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
|
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
|
||||||
const showStarIcon =
|
const showStarIcon =
|
||||||
props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
props.entry.markable &&
|
||||||
|
starIconDisplayMode &&
|
||||||
|
["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
||||||
|
|
||||||
const swipeHandlers = useSwipeable({
|
const swipeHandlers = useSwipeable({
|
||||||
onSwipedLeft: props.onSwipedLeft,
|
onSwipedLeft: props.onSwipedLeft,
|
||||||
@@ -137,6 +157,10 @@ export function FeedEntry(props: FeedEntryProps) {
|
|||||||
const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy")
|
const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy")
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
|
component="article"
|
||||||
|
id={Constants.dom.entryId(props.entry)}
|
||||||
|
data-id={props.entry.id}
|
||||||
|
data-feed-id={props.entry.feedId}
|
||||||
withBorder
|
withBorder
|
||||||
radius={borderRadius}
|
radius={borderRadius}
|
||||||
className={cx(classes.paper, {
|
className={cx(classes.paper, {
|
||||||
@@ -176,10 +200,10 @@ export function FeedEntry(props: FeedEntryProps) {
|
|||||||
</a>
|
</a>
|
||||||
{props.expanded && (
|
{props.expanded && (
|
||||||
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}>
|
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}>
|
||||||
<Box className={classes.body}>
|
<Box className={`${classes.body} cf-content`}>
|
||||||
<FeedEntryBody entry={props.entry} />
|
<FeedEntryBody entry={props.entry} />
|
||||||
</Box>
|
</Box>
|
||||||
<Divider variant="dashed" my={paddingY} />
|
<Divider variant="dashed" my={paddingY} className="cf-footer-divider" />
|
||||||
<FeedEntryFooter entry={props.entry} />
|
<FeedEntryFooter entry={props.entry} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { useAppSelector } from "app/store"
|
import { useAppSelector } from "@/app/store"
|
||||||
import type { Entry } from "app/types"
|
import type { Entry } from "@/app/types"
|
||||||
import { Content } from "./Content"
|
import { Content } from "./Content"
|
||||||
import { Enclosure } from "./Enclosure"
|
import { Enclosure } from "./Enclosure"
|
||||||
import { Media } from "./Media"
|
import { Media } from "./Media"
|
||||||
@@ -9,7 +9,7 @@ export interface FeedEntryBodyProps {
|
|||||||
entry: Entry
|
entry: Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedEntryBody(props: FeedEntryBodyProps) {
|
export function FeedEntryBody(props: Readonly<FeedEntryBodyProps>) {
|
||||||
const search = useAppSelector(state => state.entries.search)
|
const search = useAppSelector(state => state.entries.search)
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Group } from "@mantine/core"
|
import { Group } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { markEntriesUpToEntry, markEntry, starEntry } from "app/entries/thunks"
|
|
||||||
import { redirectToFeed } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import type { Entry } from "app/types"
|
|
||||||
import { truncate } from "app/utils"
|
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
|
||||||
import { useColorScheme } from "hooks/useColorScheme"
|
|
||||||
import { Item, Menu, Separator } from "react-contexify"
|
import { Item, Menu, Separator } from "react-contexify"
|
||||||
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbRss, TbStar, TbStarOff } from "react-icons/tb"
|
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbRss, TbStar, TbStarOff } from "react-icons/tb"
|
||||||
import { tss } from "tss"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { markEntriesUpToEntry, markEntry, starEntry } from "@/app/entries/thunks"
|
||||||
|
import { redirectToFeed } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
import { truncate } from "@/app/utils"
|
||||||
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
|
import { useColorScheme } from "@/hooks/useColorScheme"
|
||||||
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
interface FeedEntryContextMenuProps {
|
interface FeedEntryContextMenuProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
@@ -27,7 +27,7 @@ const useStyles = tss.create(({ theme, colorScheme }) => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>) {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const { classes } = useStyles()
|
const { classes } = useStyles()
|
||||||
const sourceType = useAppSelector(state => state.entries.source.type)
|
const sourceType = useAppSelector(state => state.entries.source.type)
|
||||||
@@ -35,7 +35,12 @@ export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
|||||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
|
<Menu
|
||||||
|
id={Constants.dom.entryContextMenuId(props.entry)}
|
||||||
|
theme={colorScheme}
|
||||||
|
animation={false}
|
||||||
|
className={classes.menu}
|
||||||
|
>
|
||||||
<Item
|
<Item
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open(props.entry.url, "_blank", "noreferrer")
|
window.open(props.entry.url, "_blank", "noreferrer")
|
||||||
@@ -61,19 +66,27 @@ export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
|
||||||
<Group>
|
|
||||||
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
|
||||||
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
|
||||||
</Group>
|
|
||||||
</Item>
|
|
||||||
{props.entry.markable && (
|
{props.entry.markable && (
|
||||||
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
<>
|
||||||
<Group>
|
<Item
|
||||||
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
onClick={async () =>
|
||||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))
|
||||||
</Group>
|
}
|
||||||
</Item>
|
>
|
||||||
|
<Group>
|
||||||
|
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||||
|
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
|
</Group>
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
||||||
|
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
||||||
|
</Group>
|
||||||
|
</Item>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}>
|
<Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
||||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/entries/thunks"
|
import {
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
TbArrowBarToDown,
|
||||||
import type { Entry } from "app/types"
|
TbExternalLink,
|
||||||
import { ActionButton } from "components/ActionButton"
|
TbMail,
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
TbMailOpened,
|
||||||
import { useMobile } from "hooks/useMobile"
|
TbShare,
|
||||||
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
TbStar,
|
||||||
|
TbStarOff,
|
||||||
|
TbTag,
|
||||||
|
} from "react-icons/tb"
|
||||||
|
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
import { ActionButton } from "@/components/ActionButton"
|
||||||
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
import { ShareButtons } from "./ShareButtons"
|
import { ShareButtons } from "./ShareButtons"
|
||||||
|
|
||||||
interface FeedEntryFooterProps {
|
interface FeedEntryFooterProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
export function FeedEntryFooter(props: Readonly<FeedEntryFooterProps>) {
|
||||||
const tags = useAppSelector(state => state.user.tags)
|
const tags = useAppSelector(state => state.user.tags)
|
||||||
const mobile = useMobile()
|
const mobile = useMobile()
|
||||||
const { spacing } = useActionButton()
|
const { spacing } = useActionButton()
|
||||||
@@ -37,7 +46,7 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between">
|
<Group justify="space-between" className="cf-footer">
|
||||||
<Group gap={spacing}>
|
<Group gap={spacing}>
|
||||||
{props.entry.markable && (
|
{props.entry.markable && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -71,7 +80,12 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
|||||||
{tags && (
|
{tags && (
|
||||||
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
|
<Indicator
|
||||||
|
label={props.entry.tags.length}
|
||||||
|
disabled={props.entry.tags.length === 0}
|
||||||
|
inline
|
||||||
|
size={16}
|
||||||
|
>
|
||||||
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
||||||
</Indicator>
|
</Indicator>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
import { useTimeout } from "@mantine/hooks"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
|
|
||||||
export interface FeedFaviconProps {
|
export interface FeedFaviconProps {
|
||||||
url: string
|
url: string
|
||||||
size?: number
|
size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) {
|
export function FeedFavicon({ url, size = 18 }: Readonly<FeedFaviconProps>) {
|
||||||
|
// the backend always returns a favicon except when the feed has never been fetched
|
||||||
|
// this can happen when the user subscribes to a feed, the feed is added to the tree but the feed has not been fetched yet
|
||||||
|
// in this case we retry every second up to 3 times until the feed is fetched and the favicon is available
|
||||||
|
const [timestamp, setTimestamp] = useState(0)
|
||||||
|
const [retryCount, setRetryCount] = useState(0)
|
||||||
|
const { start: retry } = useTimeout(() => setTimestamp(Date.now()), 1000)
|
||||||
|
|
||||||
|
const urlWithTimestamp = url + (timestamp === 0 ? "" : `?t=${timestamp}`)
|
||||||
|
const handleError = () => {
|
||||||
|
if (retryCount < 3) {
|
||||||
|
setRetryCount(c => c + 1)
|
||||||
|
retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ImageWithPlaceholderWhileLoading
|
<ImageWithPlaceholderWhileLoading
|
||||||
src={url}
|
src={urlWithTimestamp}
|
||||||
alt="feed favicon"
|
alt="feed favicon"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
@@ -16,6 +32,7 @@ export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) {
|
|||||||
placeholderHeight={size}
|
placeholderHeight={size}
|
||||||
placeholderBackgroundColor="inherit"
|
placeholderBackgroundColor="inherit"
|
||||||
placeholderIconSize={size}
|
placeholderIconSize={size}
|
||||||
|
onError={handleError}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { calculatePlaceholderSize } from "app/utils"
|
import { calculatePlaceholderSize } from "@/app/utils"
|
||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
import { Content } from "./Content"
|
import { Content } from "./Content"
|
||||||
|
|
||||||
export interface MediaProps {
|
export interface MediaProps {
|
||||||
@@ -12,7 +12,7 @@ export interface MediaProps {
|
|||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Media(props: MediaProps) {
|
export function Media(props: Readonly<MediaProps>) {
|
||||||
const width = props.thumbnailWidth
|
const width = props.thumbnailWidth
|
||||||
const height = props.thumbnailHeight
|
const height = props.thumbnailHeight
|
||||||
const placeholderSize = calculatePlaceholderSize({
|
const placeholderSize = calculatePlaceholderSize({
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { ActionIcon, Box, CopyButton, Divider, SimpleGrid } from "@mantine/core"
|
import { ActionIcon, Box, CopyButton, Divider, SimpleGrid } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { useAppSelector } from "app/store"
|
|
||||||
import type { SharingSettings } from "app/types"
|
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import type { IconType } from "react-icons"
|
import type { IconType } from "react-icons"
|
||||||
import { TbCheck, TbCopy, TbDeviceDesktopShare, TbDeviceMobileShare } from "react-icons/tb"
|
import { TbCheck, TbCopy, TbDeviceDesktopShare, TbDeviceMobileShare } from "react-icons/tb"
|
||||||
import { tss } from "tss"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useAppSelector } from "@/app/store"
|
||||||
|
import type { SharingSettings } from "@/app/types"
|
||||||
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
type Color = `#${string}`
|
type Color = `#${string}`
|
||||||
|
|
||||||
@@ -22,7 +22,15 @@ const useStyles = tss
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function ShareButton({ icon, color, onClick }: { icon: IconType; color: Color; onClick: () => void }) {
|
function ShareButton({
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
onClick,
|
||||||
|
}: Readonly<{
|
||||||
|
icon: IconType
|
||||||
|
color: Color
|
||||||
|
onClick: () => void
|
||||||
|
}>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
color,
|
color,
|
||||||
})
|
})
|
||||||
@@ -36,7 +44,15 @@ function ShareButton({ icon, color, onClick }: { icon: IconType; color: Color; o
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SiteShareButton({ url, icon, color }: { icon: IconType; color: Color; url: string }) {
|
function SiteShareButton({
|
||||||
|
url,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
}: Readonly<{
|
||||||
|
icon: IconType
|
||||||
|
color: Color
|
||||||
|
url: string
|
||||||
|
}>) {
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600")
|
window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600")
|
||||||
}
|
}
|
||||||
@@ -44,7 +60,11 @@ function SiteShareButton({ url, icon, color }: { icon: IconType; color: Color; u
|
|||||||
return <ShareButton icon={icon} color={color} onClick={onClick} />
|
return <ShareButton icon={icon} color={color} onClick={onClick} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyUrlButton({ url }: { url: string }) {
|
function CopyUrlButton({
|
||||||
|
url,
|
||||||
|
}: Readonly<{
|
||||||
|
url: string
|
||||||
|
}>) {
|
||||||
return (
|
return (
|
||||||
<CopyButton value={url}>
|
<CopyButton value={url}>
|
||||||
{({ copied, copy }) => <ShareButton icon={copied ? TbCheck : TbCopy} color="#000" onClick={copy} />}
|
{({ copied, copy }) => <ShareButton icon={copied ? TbCheck : TbCopy} color="#000" onClick={copy} />}
|
||||||
@@ -52,7 +72,13 @@ function CopyUrlButton({ url }: { url: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrowserNativeShareButton({ url, description }: { url: string; description: string }) {
|
function BrowserNativeShareButton({
|
||||||
|
url,
|
||||||
|
description,
|
||||||
|
}: Readonly<{
|
||||||
|
url: string
|
||||||
|
description: string
|
||||||
|
}>) {
|
||||||
const mobile = useMobile()
|
const mobile = useMobile()
|
||||||
const { isBrowserExtensionPopup } = useBrowserExtension()
|
const { isBrowserExtensionPopup } = useBrowserExtension()
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
@@ -71,9 +97,16 @@ function BrowserNativeShareButton({ url, description }: { url: string; descripti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShareButtons(props: { url: string; description: string }) {
|
export function ShareButtons(
|
||||||
|
props: Readonly<{
|
||||||
|
url: string
|
||||||
|
description: string
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||||
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site])
|
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(
|
||||||
|
site => sharingSettings?.[site]
|
||||||
|
)
|
||||||
const url = encodeURIComponent(props.url)
|
const url = encodeURIComponent(props.url)
|
||||||
const desc = encodeURIComponent(props.description)
|
const desc = encodeURIComponent(props.description)
|
||||||
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
|
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
|
||||||
@@ -88,7 +121,9 @@ export function ShareButtons(props: { url: string; description: string }) {
|
|||||||
{showNativeSection && (
|
{showNativeSection && (
|
||||||
<SimpleGrid cols={4}>
|
<SimpleGrid cols={4}>
|
||||||
{clipboardAvailable && <CopyUrlButton url={props.url} />}
|
{clipboardAvailable && <CopyUrlButton url={props.url} />}
|
||||||
{nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />}
|
{nativeSharingAvailable && (
|
||||||
|
<BrowserNativeShareButton url={props.url} description={props.description} />
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { useLingui } from "@lingui/react"
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, Group, Stack, TextInput } from "@mantine/core"
|
import { Box, Button, Group, Stack, TextInput } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import { reloadTree } from "app/tree/thunks"
|
|
||||||
import type { AddCategoryRequest } from "app/types"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbFolderPlus } from "react-icons/tb"
|
import { TbFolderPlus } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import { reloadTree } from "@/app/tree/thunks"
|
||||||
|
import type { AddCategoryRequest } from "@/app/types"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
import { CategorySelect } from "./CategorySelect"
|
import { CategorySelect } from "./CategorySelect"
|
||||||
|
|
||||||
export function AddCategory() {
|
export function AddCategory() {
|
||||||
@@ -36,7 +36,12 @@ export function AddCategory() {
|
|||||||
|
|
||||||
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Category</Trans>} placeholder={_(msg`Category`)} {...form.getInputProps("name")} required />
|
<TextInput
|
||||||
|
label={<Trans>Category</Trans>}
|
||||||
|
placeholder={_(msg`Category`)}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Select, type SelectProps } from "@mantine/core"
|
import { type ComboboxItem, Select, type SelectProps } from "@mantine/core"
|
||||||
import type { ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
import { Constants } from "@/app/constants"
|
||||||
import { Constants } from "app/constants"
|
import { useAppSelector } from "@/app/store"
|
||||||
import { useAppSelector } from "app/store"
|
import type { Category } from "@/app/types"
|
||||||
import type { Category } from "app/types"
|
import { flattenCategoryTree } from "@/app/utils"
|
||||||
import { flattenCategoryTree } from "app/utils"
|
|
||||||
|
|
||||||
type CategorySelectProps = Partial<SelectProps> & {
|
type CategorySelectProps = Partial<SelectProps> & {
|
||||||
withAll?: boolean
|
withAll?: boolean
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { useLingui } from "@lingui/react"
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, FileInput, Group, Stack } from "@mantine/core"
|
import { Box, Button, FileInput, Group, Stack } from "@mantine/core"
|
||||||
import { isNotEmpty, useForm } from "@mantine/form"
|
import { isNotEmpty, useForm } from "@mantine/form"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import { reloadTree } from "app/tree/thunks"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbFileImport } from "react-icons/tb"
|
import { TbFileImport } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import { reloadTree } from "@/app/tree/thunks"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
|
|
||||||
export function ImportOpml() {
|
export function ImportOpml() {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@@ -44,8 +44,8 @@ export function ImportOpml() {
|
|||||||
placeholder={_(msg`OPML file`)}
|
placeholder={_(msg`OPML file`)}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
|
An opml file is an XML file containing feed URLs and categories. You can get an OPML
|
||||||
data from other feed reading services.
|
file by exporting your data from other feed reading services.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
{...form.getInputProps("file")}
|
{...form.getInputProps("file")}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core"
|
import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { redirectToFeed, redirectToSelectedSource } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import { reloadTree } from "app/tree/thunks"
|
|
||||||
import type { FeedInfoRequest, SubscribeRequest } from "app/types"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbRss } from "react-icons/tb"
|
import { TbRss } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { redirectToFeed, redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import { reloadTree } from "@/app/tree/thunks"
|
||||||
|
import type { FeedInfoRequest, SubscribeRequest } from "@/app/types"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
import { CategorySelect } from "./CategorySelect"
|
import { CategorySelect } from "./CategorySelect"
|
||||||
|
|
||||||
export function Subscribe() {
|
export function Subscribe() {
|
||||||
@@ -39,9 +39,8 @@ export function Subscribe() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const subscribe = useAsyncCallback(client.feed.subscribe, {
|
const subscribe = useAsyncCallback(client.feed.subscribe, {
|
||||||
onSuccess: async sub => {
|
onSuccess: sub => {
|
||||||
await dispatch(reloadTree())
|
dispatch(reloadTree()).then(() => dispatch(redirectToFeed(sub.data)))
|
||||||
dispatch(redirectToFeed(sub.data))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -86,8 +85,8 @@ export function Subscribe() {
|
|||||||
placeholder="https://www.mysite.com/rss"
|
placeholder="https://www.mysite.com/rss"
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed
|
The URL for the feed you want to subscribe to. You can also use the website's url
|
||||||
will try to find the feed in the page.
|
directly and CommaFeed will try to find the feed in the page.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
@@ -102,8 +101,17 @@ export function Subscribe() {
|
|||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
||||||
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
|
<TextInput
|
||||||
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
|
label={<Trans>Feed name</Trans>}
|
||||||
|
{...step1Form.getInputProps("title")}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<CategorySelect
|
||||||
|
label={<Trans>Category</Trans>}
|
||||||
|
{...step1Form.getInputProps("categoryId")}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
@@ -118,7 +126,11 @@ export function Subscribe() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{activeStep === 1 && (
|
{activeStep === 1 && (
|
||||||
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<TbRss size={16} />}
|
||||||
|
loading={fetchFeed.loading || subscribe.loading}
|
||||||
|
>
|
||||||
<Trans>Subscribe</Trans>
|
<Trans>Subscribe</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Stack } from "@mantine/core"
|
||||||
|
import { MantineValueSelector, QueryBuilderMantine } from "@react-querybuilder/mantine"
|
||||||
|
import {
|
||||||
|
type CombinatorSelectorProps,
|
||||||
|
defaultOperators,
|
||||||
|
defaultRuleProcessorCEL,
|
||||||
|
type Field,
|
||||||
|
type FormatQueryOptions,
|
||||||
|
formatQuery,
|
||||||
|
QueryBuilder,
|
||||||
|
type RuleGroupType,
|
||||||
|
} from "react-querybuilder"
|
||||||
|
import { isCELIdentifier, isCELMember, isCELStringLiteral, parseCEL } from "react-querybuilder/parseCEL"
|
||||||
|
import "react-querybuilder/dist/query-builder.css"
|
||||||
|
|
||||||
|
const fields: Field[] = [
|
||||||
|
{ name: "title", label: "Title" },
|
||||||
|
{ name: "content", label: "Content" },
|
||||||
|
{ name: "url", label: "URL" },
|
||||||
|
{ name: "author", label: "Author" },
|
||||||
|
{ name: "categories", label: "Categories" },
|
||||||
|
{ name: "titleLower", label: "Title (lower case)" },
|
||||||
|
{ name: "contentLower", label: "Content (lower case)" },
|
||||||
|
{ name: "urlLower", label: "URL (lower case)" },
|
||||||
|
{ name: "authorLower", label: "Author (lower case)" },
|
||||||
|
{ name: "categoriesLower", label: "Categories (lower case)" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const textOperators = new Set([
|
||||||
|
"=",
|
||||||
|
"!=",
|
||||||
|
"contains",
|
||||||
|
"beginsWith",
|
||||||
|
"endsWith",
|
||||||
|
"doesNotContain",
|
||||||
|
"doesNotBeginWith",
|
||||||
|
"doesNotEndWith",
|
||||||
|
])
|
||||||
|
|
||||||
|
function toCelString(query: RuleGroupType): string {
|
||||||
|
if (query.rules.length === 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const celFormatOptions: FormatQueryOptions = {
|
||||||
|
format: "cel",
|
||||||
|
ruleProcessor: (rule, options, meta) => {
|
||||||
|
if (rule.operator === "matches") {
|
||||||
|
const escapedValue = String(rule.value).replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)
|
||||||
|
return `${rule.field}.matches("${escapedValue}")`
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultRuleProcessorCEL(rule, options, meta)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatQuery(query, celFormatOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromCelString(celString: string): RuleGroupType {
|
||||||
|
return parseCEL(celString ?? "", {
|
||||||
|
customExpressionHandler: expr => {
|
||||||
|
if (
|
||||||
|
isCELMember(expr) &&
|
||||||
|
expr.right?.value === "matches" &&
|
||||||
|
expr.left &&
|
||||||
|
isCELIdentifier(expr.left) &&
|
||||||
|
expr.list &&
|
||||||
|
isCELStringLiteral(expr.list.value[0])
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
field: expr.left.value,
|
||||||
|
operator: "matches",
|
||||||
|
value: JSON.parse(expr.list.value[0].value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOperators = () => {
|
||||||
|
const filteredDefault = defaultOperators.filter(op => textOperators.has(op.name))
|
||||||
|
return [
|
||||||
|
...filteredDefault,
|
||||||
|
{
|
||||||
|
name: "matches",
|
||||||
|
label: "matches pattern",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function CombinatorSelector(props: Readonly<CombinatorSelectorProps>) {
|
||||||
|
if (props.rules.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return <MantineValueSelector {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilteringExpressionEditorProps {
|
||||||
|
initialValue: string | undefined
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilteringExpressionEditor({ initialValue, onChange }: Readonly<FilteringExpressionEditorProps>) {
|
||||||
|
const handleQueryChange = (newQuery: RuleGroupType) => {
|
||||||
|
onChange(toCelString(newQuery))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<QueryBuilderMantine>
|
||||||
|
<QueryBuilder
|
||||||
|
fields={fields}
|
||||||
|
defaultQuery={fromCelString(initialValue ?? "")}
|
||||||
|
onQueryChange={handleQueryChange}
|
||||||
|
getOperators={getOperators}
|
||||||
|
addRuleToNewGroups
|
||||||
|
resetOnFieldChange={false}
|
||||||
|
controlClassnames={{ queryBuilder: "queryBuilder-branches" }}
|
||||||
|
controlElements={{ combinatorSelector: CombinatorSelector }}
|
||||||
|
/>
|
||||||
|
</QueryBuilderMantine>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Box, Text } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import type { Entry } from "app/types"
|
import type { Entry } from "@/app/types"
|
||||||
import { RelativeDate } from "components/RelativeDate"
|
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
import { OpenExternalLink } from "@/components/content/header/OpenExternalLink"
|
||||||
import { OpenExternalLink } from "components/content/header/OpenExternalLink"
|
import { Star } from "@/components/content/header/Star"
|
||||||
import { Star } from "components/content/header/Star"
|
import { RelativeDate } from "@/components/RelativeDate"
|
||||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
import { OnDesktop } from "@/components/responsive/OnDesktop"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||||
|
|
||||||
export interface FeedEntryHeaderProps {
|
export interface FeedEntryHeaderProps {
|
||||||
@@ -43,7 +43,7 @@ const useStyles = tss
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
export function FeedEntryCompactHeader(props: Readonly<FeedEntryHeaderProps>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
read: props.entry.read,
|
read: props.entry.read,
|
||||||
})
|
})
|
||||||
@@ -54,17 +54,17 @@ export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
|||||||
<FeedFavicon url={props.entry.iconUrl} />
|
<FeedFavicon url={props.entry.iconUrl} />
|
||||||
</Box>
|
</Box>
|
||||||
<OnDesktop>
|
<OnDesktop>
|
||||||
<Text c="dimmed" className={classes.feedName}>
|
<Box c="dimmed" className={classes.feedName}>
|
||||||
{props.entry.feedName}
|
{props.entry.feedName}
|
||||||
</Text>
|
</Box>
|
||||||
</OnDesktop>
|
</OnDesktop>
|
||||||
<Box className={classes.title}>
|
<Box className={classes.title}>
|
||||||
<FeedEntryTitle entry={props.entry} />
|
<FeedEntryTitle entry={props.entry} />
|
||||||
</Box>
|
</Box>
|
||||||
<OnDesktop>
|
<OnDesktop>
|
||||||
<Text c="dimmed" className={classes.date}>
|
<Box c="dimmed" className={classes.date}>
|
||||||
<RelativeDate date={props.entry.date} />
|
<RelativeDate date={props.entry.date} />
|
||||||
</Text>
|
</Box>
|
||||||
</OnDesktop>
|
</OnDesktop>
|
||||||
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Box, Flex, Space, Text } from "@mantine/core"
|
import { Box, Flex, Space } from "@mantine/core"
|
||||||
import type { Entry } from "app/types"
|
import type { Entry } from "@/app/types"
|
||||||
import { RelativeDate } from "components/RelativeDate"
|
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
import { OpenExternalLink } from "@/components/content/header/OpenExternalLink"
|
||||||
import { OpenExternalLink } from "components/content/header/OpenExternalLink"
|
import { Star } from "@/components/content/header/Star"
|
||||||
import { Star } from "components/content/header/Star"
|
import { RelativeDate } from "@/components/RelativeDate"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||||
|
|
||||||
export interface FeedEntryHeaderProps {
|
export interface FeedEntryHeaderProps {
|
||||||
@@ -22,18 +22,15 @@ const useStyles = tss
|
|||||||
main: {
|
main: {
|
||||||
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
||||||
},
|
},
|
||||||
details: {
|
|
||||||
fontSize: "90%",
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
export function FeedEntryHeader(props: Readonly<FeedEntryHeaderProps>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
read: props.entry.read,
|
read: props.entry.read,
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box className="cf-header">
|
||||||
<Flex align="flex-start" justify="space-between">
|
<Flex align="flex-start" justify="space-between" className="cf-header-title">
|
||||||
<Flex align="flex-start" className={classes.main}>
|
<Flex align="flex-start" className={classes.main}>
|
||||||
{props.showStarIcon && (
|
{props.showStarIcon && (
|
||||||
<Box ml={-5}>
|
<Box ml={-5}>
|
||||||
@@ -44,22 +41,20 @@ export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
|||||||
</Flex>
|
</Flex>
|
||||||
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex align="center" className={classes.details}>
|
<Flex align="center" className="cf-header-subtitle">
|
||||||
<FeedFavicon url={props.entry.iconUrl} />
|
<FeedFavicon url={props.entry.iconUrl} />
|
||||||
<Space w={6} />
|
<Space w={6} />
|
||||||
<Text c="dimmed">
|
<Box c="dimmed">
|
||||||
{props.entry.feedName}
|
{props.entry.feedName}
|
||||||
<span> · </span>
|
<span> · </span>
|
||||||
<RelativeDate date={props.entry.date} />
|
<RelativeDate date={props.entry.date} />
|
||||||
</Text>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
{props.expanded && (
|
{props.expanded && (
|
||||||
<Box className={classes.details}>
|
<Box className="cf-header-details">
|
||||||
<Text c="dimmed">
|
{props.entry.author && <span>by {props.entry.author}</span>}
|
||||||
{props.entry.author && <span>by {props.entry.author}</span>}
|
{props.entry.author && props.entry.categories && <span> · </span>}
|
||||||
{props.entry.author && props.entry.categories && <span> · </span>}
|
{props.entry.categories && <span>{props.entry.categories}</span>}
|
||||||
{props.entry.categories && <span>{props.entry.categories}</span>}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import { Highlight } from "@mantine/core"
|
import { Highlight } from "@mantine/core"
|
||||||
import { useAppSelector } from "app/store"
|
import { useAppSelector } from "@/app/store"
|
||||||
import type { Entry } from "app/types"
|
import type { Entry } from "@/app/types"
|
||||||
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
export interface FeedEntryTitleProps {
|
export interface FeedEntryTitleProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedEntryTitle(props: FeedEntryTitleProps) {
|
const useStyles = tss.create(() => ({
|
||||||
|
content: {
|
||||||
|
textWrap: "inherit",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function FeedEntryTitle(props: Readonly<FeedEntryTitleProps>) {
|
||||||
const search = useAppSelector(state => state.entries.search)
|
const search = useAppSelector(state => state.entries.search)
|
||||||
const keywords = search?.split(" ")
|
const keywords = search?.split(" ")
|
||||||
|
|
||||||
|
const { classes } = useStyles()
|
||||||
return (
|
return (
|
||||||
<Highlight
|
<Highlight
|
||||||
inherit
|
inherit
|
||||||
highlight={keywords ?? ""}
|
highlight={keywords ?? ""}
|
||||||
// make sure ellipsis is shown when title is too long
|
// make sure ellipsis is shown when title is too long
|
||||||
span
|
span
|
||||||
|
className={classes.content}
|
||||||
>
|
>
|
||||||
{props.entry.title}
|
{props.entry.title}
|
||||||
</Highlight>
|
</Highlight>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { ActionIcon, Anchor, Tooltip } from "@mantine/core"
|
import { ActionIcon, Anchor, Tooltip } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { markEntry } from "app/entries/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import type { Entry } from "app/types"
|
|
||||||
import { TbExternalLink } from "react-icons/tb"
|
import { TbExternalLink } from "react-icons/tb"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { markEntry } from "@/app/entries/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
|
||||||
export function OpenExternalLink(props: { entry: Entry }) {
|
export function OpenExternalLink(
|
||||||
|
props: Readonly<{
|
||||||
|
entry: Entry
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const onClick = (e: React.MouseEvent) => {
|
const onClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core"
|
import { ActionIcon, Tooltip } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { starEntry } from "app/entries/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import type { Entry } from "app/types"
|
|
||||||
import { TbStar, TbStarFilled } from "react-icons/tb"
|
import { TbStar, TbStarFilled } from "react-icons/tb"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { starEntry } from "@/app/entries/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
|
||||||
export function Star(props: { entry: Entry }) {
|
export function Star(
|
||||||
|
props: Readonly<{
|
||||||
|
entry: Entry
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const onClick = (e: React.MouseEvent) => {
|
const onClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -20,7 +24,10 @@ export function Star(props: { entry: Entry }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}>
|
<Tooltip
|
||||||
|
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
|
openDelay={Constants.tooltip.delay}
|
||||||
|
>
|
||||||
<ActionIcon variant="transparent" onClick={onClick}>
|
<ActionIcon variant="transparent" onClick={onClick}>
|
||||||
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
|
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -2,18 +2,11 @@ import { msg } from "@lingui/core/macro"
|
|||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { reloadEntries, search, selectNextEntry, selectPreviousEntry } from "app/entries/thunks"
|
import { type ReactNode, useEffect } from "react"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { changeReadingMode, changeReadingOrder } from "app/user/thunks"
|
|
||||||
import { ActionButton } from "components/ActionButton"
|
|
||||||
import { Loader } from "components/Loader"
|
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import { useEffect } from "react"
|
|
||||||
import {
|
import {
|
||||||
TbArrowDown,
|
TbArrowDown,
|
||||||
TbArrowUp,
|
TbArrowUp,
|
||||||
|
TbChecks,
|
||||||
TbExternalLink,
|
TbExternalLink,
|
||||||
TbEye,
|
TbEye,
|
||||||
TbEyeOff,
|
TbEyeOff,
|
||||||
@@ -24,14 +17,27 @@ import {
|
|||||||
TbSortDescending,
|
TbSortDescending,
|
||||||
TbUser,
|
TbUser,
|
||||||
} from "react-icons/tb"
|
} from "react-icons/tb"
|
||||||
import { MarkAllAsReadButton } from "./MarkAllAsReadButton"
|
import {
|
||||||
|
markAllAsReadWithConfirmationIfRequired,
|
||||||
|
reloadEntries,
|
||||||
|
search,
|
||||||
|
selectNextEntry,
|
||||||
|
selectPreviousEntry,
|
||||||
|
} from "@/app/entries/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { changeSettings } from "@/app/user/thunks"
|
||||||
|
import { ActionButton } from "@/components/ActionButton"
|
||||||
|
import { Loader } from "@/components/Loader"
|
||||||
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
import { ProfileMenu } from "./ProfileMenu"
|
import { ProfileMenu } from "./ProfileMenu"
|
||||||
|
|
||||||
function HeaderDivider() {
|
function HeaderDivider() {
|
||||||
return <Divider orientation="vertical" />
|
return <Divider orientation="vertical" />
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeaderToolbar(props: { children: React.ReactNode }) {
|
function HeaderToolbar(props: { children: ReactNode }) {
|
||||||
const { spacing } = useActionButton()
|
const { spacing } = useActionButton()
|
||||||
const mobile = useMobile("480px")
|
const mobile = useMobile("480px")
|
||||||
return mobile ? (
|
return mobile ? (
|
||||||
@@ -42,11 +48,14 @@ function HeaderToolbar(props: { children: React.ReactNode }) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
|
className="cf-toolbar"
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Group gap={spacing}>{props.children}</Group>
|
<Group gap={spacing} wrap="nowrap" className="cf-toolbar">
|
||||||
|
{props.children}
|
||||||
|
</Group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,11 +69,7 @@ export function Header() {
|
|||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const searchForm = useForm<{ search: string }>({
|
const searchForm = useForm<{ search: string }>()
|
||||||
validate: {
|
|
||||||
search: value => (value.length > 0 && value.length < 3 ? _(msg`Search requires at least 3 characters`) : null),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const { setValues } = searchForm
|
const { setValues } = searchForm
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,8 +79,27 @@ export function Header() {
|
|||||||
}, [setValues, searchFromStore])
|
}, [setValues, searchFromStore])
|
||||||
|
|
||||||
if (!settings) return <Loader />
|
if (!settings) return <Loader />
|
||||||
|
|
||||||
|
const toggleReadingMode = async () => {
|
||||||
|
await dispatch(
|
||||||
|
changeSettings({
|
||||||
|
readingMode: settings.readingMode === "all" ? "unread" : "all",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
dispatch(reloadEntries())
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleReadingOrder = async () => {
|
||||||
|
await dispatch(
|
||||||
|
changeSettings({
|
||||||
|
readingOrder: settings.readingOrder === "asc" ? "desc" : "asc",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
dispatch(reloadEntries())
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center>
|
<Center className="cf-toolbar-wrapper">
|
||||||
<HeaderToolbar>
|
<HeaderToolbar>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TbArrowUp size={iconSize} />}
|
icon={<TbArrowUp size={iconSize} />}
|
||||||
@@ -111,19 +135,29 @@ export function Header() {
|
|||||||
label={msg`Refresh`}
|
label={msg`Refresh`}
|
||||||
onClick={async () => await dispatch(reloadEntries())}
|
onClick={async () => await dispatch(reloadEntries())}
|
||||||
/>
|
/>
|
||||||
<MarkAllAsReadButton iconSize={iconSize} />
|
<ActionButton
|
||||||
|
icon={<TbChecks size={iconSize} />}
|
||||||
|
label={msg`Mark all as read`}
|
||||||
|
onClick={() => dispatch(markAllAsReadWithConfirmationIfRequired())}
|
||||||
|
/>
|
||||||
|
|
||||||
<HeaderDivider />
|
<HeaderDivider />
|
||||||
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />}
|
icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />}
|
||||||
label={settings.readingMode === "all" ? msg`All` : msg`Unread`}
|
label={settings.readingMode === "all" ? msg`All` : msg`Unread`}
|
||||||
onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))}
|
onClick={toggleReadingMode}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
|
icon={
|
||||||
|
settings.readingOrder === "asc" ? (
|
||||||
|
<TbSortAscending size={iconSize} />
|
||||||
|
) : (
|
||||||
|
<TbSortDescending size={iconSize} />
|
||||||
|
)
|
||||||
|
}
|
||||||
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
||||||
onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
|
onClick={toggleReadingOrder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -138,7 +172,11 @@ export function Header() {
|
|||||||
placeholder={_(msg`Search`)}
|
placeholder={_(msg`Search`)}
|
||||||
{...searchForm.getInputProps("search")}
|
{...searchForm.getInputProps("search")}
|
||||||
leftSection={<TbSearch size={iconSize} />}
|
leftSection={<TbSearch size={iconSize} />}
|
||||||
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
|
rightSection={
|
||||||
|
<CloseButton
|
||||||
|
onClick={async () => await (searchFromStore && dispatch(search("")))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
|
|
||||||
import { Button, Code, Group, Modal, Slider, Stack, Text } from "@mantine/core"
|
|
||||||
import { markAllEntries } from "app/entries/thunks"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { ActionButton } from "components/ActionButton"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { TbChecks } from "react-icons/tb"
|
|
||||||
|
|
||||||
export function MarkAllAsReadButton(props: { iconSize: number }) {
|
|
||||||
const [opened, setOpened] = useState(false)
|
|
||||||
const [threshold, setThreshold] = useState(0)
|
|
||||||
const source = useAppSelector(state => state.entries.source)
|
|
||||||
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
|
||||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
|
||||||
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
|
||||||
const buttonClicked = () => {
|
|
||||||
if (markAllAsReadConfirmation) {
|
|
||||||
setThreshold(0)
|
|
||||||
setOpened(true)
|
|
||||||
} else {
|
|
||||||
dispatch(
|
|
||||||
markAllEntries({
|
|
||||||
sourceType: source.type,
|
|
||||||
req: {
|
|
||||||
id: source.id,
|
|
||||||
read: true,
|
|
||||||
olderThan: Date.now(),
|
|
||||||
insertedBefore: entriesTimestamp,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal opened={opened} onClose={() => setOpened(false)} title={<Trans>Mark all entries as read</Trans>}>
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm">
|
|
||||||
{threshold === 0 && (
|
|
||||||
<Trans>
|
|
||||||
Are you sure you want to mark all entries of <Code>{sourceLabel}</Code> as read?
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
{threshold > 0 && (
|
|
||||||
<Trans>
|
|
||||||
Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read?
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Slider
|
|
||||||
py="xl"
|
|
||||||
min={0}
|
|
||||||
max={28}
|
|
||||||
marks={[
|
|
||||||
{ value: 0, label: "0" },
|
|
||||||
{ value: 7, label: "7" },
|
|
||||||
{ value: 14, label: "14" },
|
|
||||||
{ value: 21, label: "21" },
|
|
||||||
{ value: 28, label: "28" },
|
|
||||||
]}
|
|
||||||
value={threshold}
|
|
||||||
onChange={setThreshold}
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={() => setOpened(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onClick={() => {
|
|
||||||
setOpened(false)
|
|
||||||
dispatch(
|
|
||||||
markAllEntries({
|
|
||||||
sourceType: source.type,
|
|
||||||
req: {
|
|
||||||
id: source.id,
|
|
||||||
read: true,
|
|
||||||
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
|
|
||||||
insertedBefore: entriesTimestamp,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans>Confirm</Trans>
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
<ActionButton icon={<TbChecks size={props.iconSize} />} label={msg`Mark all as read`} onClick={buttonClicked} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -7,18 +7,12 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
type SegmentedControlItem,
|
type SegmentedControlItem,
|
||||||
|
Slider,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core"
|
} from "@mantine/core"
|
||||||
import { showNotification } from "@mantine/notifications"
|
import { showNotification } from "@mantine/notifications"
|
||||||
import { client } from "app/client"
|
|
||||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import type { ViewMode } from "app/types"
|
|
||||||
import { setViewMode } from "app/user/slice"
|
|
||||||
import { reloadProfile } from "app/user/thunks"
|
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { useNow } from "hooks/useNow"
|
import { type ReactNode, useEffect, useState } from "react"
|
||||||
import { type ReactNode, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
TbChartLine,
|
TbChartLine,
|
||||||
TbHeartFilled,
|
TbHeartFilled,
|
||||||
@@ -35,6 +29,20 @@ import {
|
|||||||
TbUsers,
|
TbUsers,
|
||||||
TbWorldDownload,
|
TbWorldDownload,
|
||||||
} from "react-icons/tb"
|
} from "react-icons/tb"
|
||||||
|
import { throttle } from "throttle-debounce"
|
||||||
|
import { client } from "@/app/client"
|
||||||
|
import {
|
||||||
|
redirectToAbout,
|
||||||
|
redirectToAdminUsers,
|
||||||
|
redirectToDonate,
|
||||||
|
redirectToMetrics,
|
||||||
|
redirectToSettings,
|
||||||
|
} from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { ViewMode } from "@/app/types"
|
||||||
|
import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
|
||||||
|
import { reloadProfile } from "@/app/user/thunks"
|
||||||
|
import { useNow } from "@/hooks/useNow"
|
||||||
|
|
||||||
interface ProfileMenuProps {
|
interface ProfileMenuProps {
|
||||||
control: React.ReactElement
|
control: React.ReactElement
|
||||||
@@ -93,13 +101,22 @@ const viewModeData: ViewModeControlItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ProfileMenu(props: ProfileMenuProps) {
|
export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
||||||
const [opened, setOpened] = useState(false)
|
const [opened, setOpened] = useState(false)
|
||||||
|
|
||||||
|
// close profile menu on scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = throttle(100, () => setOpened(false))
|
||||||
|
window.addEventListener("scroll", listener)
|
||||||
|
return () => window.removeEventListener("scroll", listener)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const now = useNow()
|
const now = useNow()
|
||||||
const profile = useAppSelector(state => state.user.profile)
|
const profile = useAppSelector(state => state.user.profile)
|
||||||
const admin = useAppSelector(state => state.user.profile?.admin)
|
const admin = useAppSelector(state => state.user.profile?.admin)
|
||||||
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
const forceRefreshCooldownDuration = useAppSelector(state => state.server.serverInfos?.forceRefreshCooldownDuration)
|
const forceRefreshCooldownDuration = useAppSelector(state => state.server.serverInfos?.forceRefreshCooldownDuration)
|
||||||
|
const fontSizePercentage = useAppSelector(state => state.user.localSettings.fontSizePercentage)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme()
|
const { colorScheme, setColorScheme } = useMantineColorScheme()
|
||||||
|
|
||||||
@@ -113,7 +130,13 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened}>
|
<Menu
|
||||||
|
position="bottom-end"
|
||||||
|
closeOnItemClick={false}
|
||||||
|
opened={opened}
|
||||||
|
onChange={setOpened}
|
||||||
|
preventPositionChangeWhenVisible={false}
|
||||||
|
>
|
||||||
<Menu.Target>{props.control}</Menu.Target>
|
<Menu.Target>{props.control}</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
||||||
@@ -143,7 +166,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
color: "green",
|
color: "green",
|
||||||
autoClose: 1000,
|
autoClose: 1000,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch {
|
||||||
showNotification({
|
showNotification({
|
||||||
message: <Trans>Force fetching feeds is not yet available.</Trans>,
|
message: <Trans>Force fetching feeds is not yet available.</Trans>,
|
||||||
color: "red",
|
color: "red",
|
||||||
@@ -153,7 +176,9 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Fetch all my feeds now</Trans>
|
<Trans>Fetch all my feeds now</Trans>
|
||||||
{!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>}
|
{!forceRefreshEnabled && (
|
||||||
|
<span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>
|
||||||
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -184,6 +209,22 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
mb="xs"
|
mb="xs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Menu.Label>
|
||||||
|
<Trans>Font size</Trans>
|
||||||
|
</Menu.Label>
|
||||||
|
<Slider
|
||||||
|
min={50}
|
||||||
|
max={150}
|
||||||
|
step={5}
|
||||||
|
marks={[{ value: 100 }]}
|
||||||
|
label={v => `${v}%`}
|
||||||
|
mb="xs"
|
||||||
|
value={fontSizePercentage}
|
||||||
|
onChange={value => dispatch(setFontSizePercentage(value))}
|
||||||
|
/>
|
||||||
|
|
||||||
{admin && (
|
{admin && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { NumberFormatter } from "@mantine/core"
|
import { NumberFormatter } from "@mantine/core"
|
||||||
import type { MetricGauge } from "app/types"
|
import type { MetricGauge } from "@/app/types"
|
||||||
|
|
||||||
interface MeterProps {
|
interface GaugeProps {
|
||||||
gauge: MetricGauge
|
gauge: MetricGauge
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Gauge(props: MeterProps) {
|
export function Gauge(props: Readonly<GaugeProps>) {
|
||||||
return <NumberFormatter value={props.gauge.value} thousandSeparator />
|
return <NumberFormatter value={props.gauge.value} thousandSeparator />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import type { MetricMeter } from "app/types"
|
import type { MetricMeter } from "@/app/types"
|
||||||
|
|
||||||
interface MeterProps {
|
interface MeterProps {
|
||||||
meter: MetricMeter
|
meter: MetricMeter
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Meter(props: MeterProps) {
|
export function Meter(props: Readonly<MeterProps>) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box>Mean: {props.meter.mean_rate.toFixed(2)}</Box>
|
<Box>Mean: {props.meter.mean_rate.toFixed(2)}</Box>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface MetricAccordionItemProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MetricAccordionItem({ metricKey, name, headerValue, children }: MetricAccordionItemProps) {
|
export function MetricAccordionItem({ metricKey, name, headerValue, children }: Readonly<MetricAccordionItemProps>) {
|
||||||
return (
|
return (
|
||||||
<Accordion.Item value={metricKey} key={metricKey}>
|
<Accordion.Item value={metricKey} key={metricKey}>
|
||||||
<Accordion.Control>
|
<Accordion.Control>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import type { MetricTimer } from "app/types"
|
import type { MetricTimer } from "@/app/types"
|
||||||
|
|
||||||
interface MetricTimerProps {
|
interface MetricTimerProps {
|
||||||
timer: MetricTimer
|
timer: MetricTimer
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Timer(props: MetricTimerProps) {
|
export function Timer(props: Readonly<MetricTimerProps>) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box>Mean: {props.timer.mean_rate.toFixed(2)}</Box>
|
<Box>Mean: {props.timer.mean_rate.toFixed(2)}</Box>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
export function OnDesktop(props: { children: React.ReactNode }) {
|
export function OnDesktop(
|
||||||
|
props: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const mobile = useMobile()
|
const mobile = useMobile()
|
||||||
return <Box>{!mobile && props.children}</Box>
|
return <Box>{!mobile && props.children}</Box>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
export function OnMobile(props: { children: React.ReactNode }) {
|
export function OnMobile(
|
||||||
|
props: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const mobile = useMobile()
|
const mobile = useMobile()
|
||||||
return <Box>{mobile && props.children}</Box>
|
return <Box>{mobile && props.children}</Box>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, Group, Stack } from "@mantine/core"
|
import { Anchor, Box, Button, Group, Stack } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { CodeEditor } from "components/code/CodeEditor"
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbDeviceFloppy } from "react-icons/tb"
|
import { TbDeviceFloppy } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
|
import { CodeEditor } from "@/components/code/CodeEditor"
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
customCss: string
|
customCss: string
|
||||||
@@ -57,13 +58,23 @@ export function CustomCodeSettings() {
|
|||||||
<form onSubmit={form.onSubmit(saveCustomCode.execute)}>
|
<form onSubmit={form.onSubmit(saveCustomCode.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
description={<Trans>Custom CSS rules that will be applied</Trans>}
|
label={<Trans>Custom CSS rules that will be applied</Trans>}
|
||||||
|
description={
|
||||||
|
<Anchor
|
||||||
|
href={Constants.customCssDocumentationUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ fontSize: "inherit" }}
|
||||||
|
>
|
||||||
|
<Trans>Link to the documentation</Trans>
|
||||||
|
</Anchor>
|
||||||
|
}
|
||||||
language="css"
|
language="css"
|
||||||
{...form.getInputProps("customCss")}
|
{...form.getInputProps("customCss")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
description={<Trans>Custom JS code that will be executed on page load</Trans>}
|
label={<Trans>Custom JS code that will be executed on page load</Trans>}
|
||||||
language="javascript"
|
language="javascript"
|
||||||
{...form.getInputProps("customJs")}
|
{...form.getInputProps("customJs")}
|
||||||
/>
|
/>
|
||||||
@@ -72,7 +83,11 @@ export function CustomCodeSettings() {
|
|||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveCustomCode.loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<TbDeviceFloppy size={16} />}
|
||||||
|
loading={saveCustomCode.loading}
|
||||||
|
>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,47 +1,31 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Divider, Group, NumberInput, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
|
|
||||||
import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import type { IconDisplayMode, ScrollMode, SharingSettings } from "app/types"
|
|
||||||
import {
|
import {
|
||||||
changeCustomContextMenu,
|
Box,
|
||||||
changeEntriesToKeepOnTopWhenScrolling,
|
type ComboboxData,
|
||||||
changeExternalLinkIconDisplayMode,
|
Divider,
|
||||||
changeLanguage,
|
Group,
|
||||||
changeMarkAllAsReadConfirmation,
|
Loader,
|
||||||
changeMobileFooter,
|
NumberInput,
|
||||||
changeScrollMarks,
|
Radio,
|
||||||
changeScrollMode,
|
Select,
|
||||||
changeScrollSpeed,
|
type SelectProps,
|
||||||
changeSharingSetting,
|
SimpleGrid,
|
||||||
changeShowRead,
|
Stack,
|
||||||
changeStarIconDisplayMode,
|
Switch,
|
||||||
changeUnreadCountFavicon,
|
} from "@mantine/core"
|
||||||
changeUnreadCountTitle,
|
|
||||||
} from "app/user/thunks"
|
|
||||||
import { locales } from "i18n"
|
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { IconDisplayMode, ScrollMode, SharingSettings } from "@/app/types"
|
||||||
|
import { changeSettings } from "@/app/user/thunks"
|
||||||
|
import { locales } from "@/i18n"
|
||||||
|
|
||||||
export function DisplaySettings() {
|
export function DisplaySettings() {
|
||||||
const language = useAppSelector(state => state.user.settings?.language)
|
const settings = useAppSelector(state => state.user.settings)
|
||||||
const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed)
|
|
||||||
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
|
||||||
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
|
||||||
const scrollMode = useAppSelector(state => state.user.settings?.scrollMode)
|
|
||||||
const entriesToKeepOnTop = useAppSelector(state => state.user.settings?.entriesToKeepOnTopWhenScrolling)
|
|
||||||
const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
|
|
||||||
const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
|
||||||
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
|
||||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
|
||||||
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
|
|
||||||
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
|
|
||||||
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
|
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const scrollModeOptions: Record<ScrollMode, ReactNode> = {
|
const scrollModeOptions: Record<ScrollMode, ReactNode> = {
|
||||||
always: <Trans>Always</Trans>,
|
always: <Trans>Always</Trans>,
|
||||||
@@ -68,78 +52,93 @@ export function DisplaySettings() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const colorData: ComboboxData = [
|
||||||
|
{ value: "dark", label: _(msg`Dark`) },
|
||||||
|
{ value: "gray", label: _(msg`Gray`) },
|
||||||
|
{ value: "red", label: _(msg`Red`) },
|
||||||
|
{ value: "pink", label: _(msg`Pink`) },
|
||||||
|
{ value: "grape", label: _(msg`Grape`) },
|
||||||
|
{ value: "violet", label: _(msg`Violet`) },
|
||||||
|
{ value: "indigo", label: _(msg`Indigo`) },
|
||||||
|
{ value: "blue", label: _(msg`Blue`) },
|
||||||
|
{ value: "cyan", label: _(msg`Cyan`) },
|
||||||
|
{ value: "green", label: _(msg`Green`) },
|
||||||
|
{ value: "lime", label: _(msg`Lime`) },
|
||||||
|
{ value: "yellow", label: _(msg`Yellow`) },
|
||||||
|
{ value: "orange", label: _(msg`Orange`) },
|
||||||
|
{ value: "teal", label: _(msg`Teal`) },
|
||||||
|
].sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
const colorRenderer: SelectProps["renderOption"] = ({ option }) => (
|
||||||
|
<Group>
|
||||||
|
<Box h={18} w={18} bg={option.value} />
|
||||||
|
<Box>{option.label}</Box>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!settings) return <Loader />
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
|
<Divider label={<Trans>Display</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
description={<Trans>Language</Trans>}
|
label={<Trans>Language</Trans>}
|
||||||
value={language}
|
value={settings.language}
|
||||||
data={locales.map(l => ({
|
data={locales.map(l => ({
|
||||||
value: l.key,
|
value: l.key,
|
||||||
label: l.label,
|
label: l.label,
|
||||||
}))}
|
}))}
|
||||||
onChange={async s => await (s && dispatch(changeLanguage(s)))}
|
onChange={language => language && dispatch(changeSettings({ language }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Primary color</Trans>}
|
||||||
|
data={colorData}
|
||||||
|
value={settings.primaryColor}
|
||||||
|
onChange={primaryColor => primaryColor && dispatch(changeSettings({ primaryColor }))}
|
||||||
|
renderOption={colorRenderer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>Show feeds and categories with no unread entries</Trans>}
|
label={<Trans>Show feeds and categories with no unread entries</Trans>}
|
||||||
checked={showRead}
|
checked={settings.showRead}
|
||||||
onChange={async e => await dispatch(changeShowRead(e.currentTarget.checked))}
|
onChange={e => dispatch(changeSettings({ showRead: e.currentTarget.checked }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>Show confirmation when marking all entries as read</Trans>}
|
label={<Trans>Show confirmation when marking all entries as read</Trans>}
|
||||||
checked={markAllAsReadConfirmation}
|
checked={settings.markAllAsReadConfirmation}
|
||||||
onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
|
onChange={e => dispatch(changeSettings({ markAllAsReadConfirmation: e.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={
|
||||||
|
<Trans>
|
||||||
|
Navigate to the next category/feed with unread entries when marking all entries as read
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
checked={settings.markAllAsReadNavigateToNextUnread}
|
||||||
|
onChange={e => dispatch(changeSettings({ markAllAsReadNavigateToNextUnread: e.currentTarget.checked }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>}
|
label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>}
|
||||||
checked={mobileFooter}
|
checked={settings.mobileFooter}
|
||||||
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
|
onChange={e => dispatch(changeSettings({ mobileFooter: e.currentTarget.checked }))}
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider label={<Trans>Browser tab</Trans>} labelPosition="center" />
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
label={<Trans>Show unread count in tab title</Trans>}
|
|
||||||
checked={unreadCountTitle}
|
|
||||||
onChange={async e => await dispatch(changeUnreadCountTitle(e.currentTarget.checked))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
label={<Trans>Show unread count in tab favicon</Trans>}
|
|
||||||
checked={unreadCountFavicon}
|
|
||||||
onChange={async e => await dispatch(changeUnreadCountFavicon(e.currentTarget.checked))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider label={<Trans>Entry headers</Trans>} labelPosition="center" />
|
|
||||||
|
|
||||||
<Select
|
|
||||||
description={<Trans>Show star icon</Trans>}
|
|
||||||
value={starIconDisplayMode}
|
|
||||||
data={displayModeData}
|
|
||||||
onChange={async s => await dispatch(changeStarIconDisplayMode(s as IconDisplayMode))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
description={<Trans>Show external link icon</Trans>}
|
|
||||||
value={externalLinkIconDisplayMode}
|
|
||||||
data={displayModeData}
|
|
||||||
onChange={async s => await dispatch(changeExternalLinkIconDisplayMode(s as IconDisplayMode))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
|
|
||||||
checked={customContextMenu}
|
|
||||||
onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
|
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Disable "Pull to refresh" browser behavior</Trans>}
|
||||||
|
description={<Trans>This setting can cause scrolling issues on some browsers (e.g. Safari)</Trans>}
|
||||||
|
checked={settings.disablePullToRefresh}
|
||||||
|
onChange={e => dispatch(changeSettings({ disablePullToRefresh: e.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
label={<Trans>Scroll selected entry to the top of the page</Trans>}
|
label={<Trans>Scroll selected entry to the top of the page</Trans>}
|
||||||
value={scrollMode}
|
value={settings.scrollMode}
|
||||||
onChange={async value => await dispatch(changeScrollMode(value as ScrollMode))}
|
onChange={value => dispatch(changeSettings({ scrollMode: value }))}
|
||||||
>
|
>
|
||||||
<Group mt="xs">
|
<Group mt="xs">
|
||||||
{Object.entries(scrollModeOptions).map(e => (
|
{Object.entries(scrollModeOptions).map(e => (
|
||||||
@@ -152,20 +151,56 @@ export function DisplaySettings() {
|
|||||||
label={<Trans>Entries to keep above the selected entry when scrolling</Trans>}
|
label={<Trans>Entries to keep above the selected entry when scrolling</Trans>}
|
||||||
description={<Trans>Only applies to compact, cozy and detailed modes</Trans>}
|
description={<Trans>Only applies to compact, cozy and detailed modes</Trans>}
|
||||||
min={0}
|
min={0}
|
||||||
value={entriesToKeepOnTop}
|
value={settings.entriesToKeepOnTopWhenScrolling}
|
||||||
onChange={async value => await dispatch(changeEntriesToKeepOnTopWhenScrolling(+value))}
|
onChange={value => dispatch(changeSettings({ entriesToKeepOnTopWhenScrolling: +value }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>Scroll smoothly when navigating between entries</Trans>}
|
label={<Trans>Scroll smoothly when navigating between entries</Trans>}
|
||||||
checked={scrollSpeed ? scrollSpeed > 0 : false}
|
checked={settings.scrollSpeed ? settings.scrollSpeed > 0 : false}
|
||||||
onChange={async e => await dispatch(changeScrollSpeed(e.currentTarget.checked))}
|
onChange={e => dispatch(changeSettings({ scrollSpeed: e.currentTarget.checked ? 400 : 0 }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>}
|
label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>}
|
||||||
checked={scrollMarks}
|
checked={settings.scrollMarks}
|
||||||
onChange={async e => await dispatch(changeScrollMarks(e.currentTarget.checked))}
|
onChange={e => dispatch(changeSettings({ scrollMarks: e.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider label={<Trans>Browser tab</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Show unread count in tab title</Trans>}
|
||||||
|
checked={settings.unreadCountTitle}
|
||||||
|
onChange={e => dispatch(changeSettings({ unreadCountTitle: e.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Show unread count in tab favicon</Trans>}
|
||||||
|
checked={settings.unreadCountFavicon}
|
||||||
|
onChange={e => dispatch(changeSettings({ unreadCountFavicon: e.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider label={<Trans>Entry headers</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Show star icon</Trans>}
|
||||||
|
value={settings.starIconDisplayMode}
|
||||||
|
data={displayModeData}
|
||||||
|
onChange={value => dispatch(changeSettings({ starIconDisplayMode: value as IconDisplayMode }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Show external link icon</Trans>}
|
||||||
|
value={settings.externalLinkIconDisplayMode}
|
||||||
|
data={displayModeData}
|
||||||
|
onChange={value => dispatch(changeSettings({ externalLinkIconDisplayMode: value as IconDisplayMode }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
|
||||||
|
checked={settings.customContextMenu}
|
||||||
|
onChange={e => dispatch(changeSettings({ customContextMenu: e.currentTarget.checked }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" />
|
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" />
|
||||||
@@ -175,12 +210,14 @@ export function DisplaySettings() {
|
|||||||
<Switch
|
<Switch
|
||||||
key={site}
|
key={site}
|
||||||
label={Constants.sharing[site].label}
|
label={Constants.sharing[site].label}
|
||||||
checked={sharingSettings?.[site]}
|
checked={settings.sharingSettings[site]}
|
||||||
onChange={async e =>
|
onChange={e =>
|
||||||
await dispatch(
|
dispatch(
|
||||||
changeSharingSetting({
|
changeSettings({
|
||||||
site,
|
sharingSettings: {
|
||||||
value: e.currentTarget.checked,
|
...settings.sharingSettings,
|
||||||
|
[site]: e.currentTarget.checked,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { openConfirmModal } from "@mantine/modals"
|
import { openConfirmModal } from "@mantine/modals"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import { redirectToLogin, redirectToSelectedSource } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import type { ProfileModificationRequest } from "app/types"
|
|
||||||
import { reloadProfile } from "app/user/thunks"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
|
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { redirectToLogin, redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { ProfileModificationRequest } from "@/app/types"
|
||||||
|
import { reloadProfile } from "@/app/user/thunks"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
|
import { useValidationRules } from "@/hooks/useValidationRules"
|
||||||
|
|
||||||
interface FormData extends ProfileModificationRequest {
|
interface FormData extends ProfileModificationRequest {
|
||||||
newPasswordConfirmation?: string
|
newPasswordConfirmation?: string
|
||||||
@@ -20,13 +32,17 @@ interface FormData extends ProfileModificationRequest {
|
|||||||
|
|
||||||
export function ProfileSettings() {
|
export function ProfileSettings() {
|
||||||
const profile = useAppSelector(state => state.user.profile)
|
const profile = useAppSelector(state => state.user.profile)
|
||||||
|
const serverInfos = useAppSelector(state => state.server.serverInfos)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
const validationRules = useValidationRules()
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
validate: {
|
validate: {
|
||||||
newPasswordConfirmation: (value, values) => (value !== values.newPassword ? _(msg`Passwords do not match`) : null),
|
newPassword: validationRules.password,
|
||||||
|
newPasswordConfirmation: (value, values) => validationRules.passwordConfirmation(value, values.newPassword),
|
||||||
},
|
},
|
||||||
|
validateInputOnChange: true,
|
||||||
})
|
})
|
||||||
const { setValues } = form
|
const { setValues } = form
|
||||||
|
|
||||||
@@ -52,7 +68,9 @@ export function ProfileSettings() {
|
|||||||
),
|
),
|
||||||
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
|
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
|
||||||
confirmProps: { color: "red" },
|
confirmProps: { color: "red" },
|
||||||
onConfirm: async () => await deleteProfile.execute(),
|
onConfirm: () => {
|
||||||
|
deleteProfile.execute()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,8 +103,9 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>API key</Trans>}
|
label={<Trans>API key</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
This is your API key. It can be used for some read-only API operations and grants access to the Fever API.
|
This is your API key. It can be used for some read-only API operations and grants access
|
||||||
Use the form at the bottom of the page to generate a new API key
|
to the Fever API and the Google Reader API. Use the form at the bottom of the page to
|
||||||
|
generate a new API key
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
readOnly
|
readOnly
|
||||||
@@ -97,7 +116,8 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>OPML export</Trans>}
|
label={<Trans>OPML export</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
Export your subscriptions and categories as an OPML file that can be imported in other feed reading services
|
Export your subscriptions and categories as an OPML file that can be imported in other
|
||||||
|
feed reading services
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -112,8 +132,8 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>Fever API</Trans>}
|
label={<Trans>Fever API</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client.
|
CommaFeed is compatible with the Fever API. Use the following URL in your
|
||||||
Login with your username and your <u>API key</u>.
|
Fever-compatible mobile client. Login with your username and your <u>API key</u>.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -124,6 +144,22 @@ export function ProfileSettings() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Input.Wrapper>
|
</Input.Wrapper>
|
||||||
|
|
||||||
|
<Input.Wrapper
|
||||||
|
label={<Trans>Google Reader API</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>
|
||||||
|
CommaFeed is compatible with the Google Reader API. Use the following URL in your Google
|
||||||
|
Reader-compatible mobile client. Login with your username and your <u>API key</u>.
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Anchor href="rest/googlereader" target="_blank">
|
||||||
|
<Trans>Google Reader API URL</Trans>
|
||||||
|
</Anchor>
|
||||||
|
</Box>
|
||||||
|
</Input.Wrapper>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@@ -132,14 +168,25 @@ export function ProfileSettings() {
|
|||||||
required
|
required
|
||||||
{...form.getInputProps("currentPassword")}
|
{...form.getInputProps("currentPassword")}
|
||||||
/>
|
/>
|
||||||
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} required />
|
<TextInput
|
||||||
|
type="email"
|
||||||
|
label={<Trans>E-mail</Trans>}
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
required={serverInfos?.emailAddressRequired}
|
||||||
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={<Trans>New password</Trans>}
|
label={<Trans>New password</Trans>}
|
||||||
description={<Trans>Changing password will generate a new API key</Trans>}
|
description={<Trans>Changing password will generate a new API key</Trans>}
|
||||||
{...form.getInputProps("newPassword")}
|
{...form.getInputProps("newPassword")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} />
|
<PasswordInput
|
||||||
<Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} />
|
label={<Trans>Confirm password</Trans>}
|
||||||
|
{...form.getInputProps("newPasswordConfirmation")}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={<Trans>Generate new API key</Trans>}
|
||||||
|
{...form.getInputProps("newApiKey", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { msg } from "@lingui/core/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { Button, Divider, Group, Select, Stack, TextInput } from "@mantine/core"
|
||||||
|
import { useForm } from "@mantine/form"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
|
import { TbDeviceFloppy, TbSend } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { PushNotificationSettings as PushNotificationSettingsModel } from "@/app/types"
|
||||||
|
import { changeSettings } from "@/app/user/thunks"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
|
|
||||||
|
export function PushNotificationSettings() {
|
||||||
|
const notificationSettings = useAppSelector(state => state.user.settings?.pushNotificationSettings)
|
||||||
|
const pushNotificationsEnabled = useAppSelector(state => state.server.serverInfos?.pushNotificationsEnabled)
|
||||||
|
const { _ } = useLingui()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const form = useForm<PushNotificationSettingsModel>()
|
||||||
|
useEffect(() => {
|
||||||
|
if (notificationSettings) form.initialize(notificationSettings)
|
||||||
|
}, [form.initialize, notificationSettings])
|
||||||
|
|
||||||
|
const handleSubmit = (values: PushNotificationSettingsModel) => {
|
||||||
|
dispatch(changeSettings({ pushNotificationSettings: values }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendTestPushNotification = useAsyncCallback(client.user.sendTestPushNotification)
|
||||||
|
|
||||||
|
const typeInputProps = form.getInputProps("type")
|
||||||
|
|
||||||
|
if (!pushNotificationsEnabled) {
|
||||||
|
return <Trans>Push notifications are not enabled on this CommaFeed instance.</Trans>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
{sendTestPushNotification.status === "success" && (
|
||||||
|
<Alert level="success" messages={[_(msg`Test notification sent successfully.`)]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sendTestPushNotification.status === "error" && (
|
||||||
|
<Alert level="error" messages={errorToStrings(sendTestPushNotification.error)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Push notification service</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>
|
||||||
|
Receive push notifications when new feed entries are discovered. Enable "Receive push
|
||||||
|
notifications" in the settings of each feed for which you want to receive notifications.
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
data={[
|
||||||
|
{ value: "ntfy", label: "ntfy" },
|
||||||
|
{ value: "gotify", label: "Gotify" },
|
||||||
|
{ value: "pushover", label: "Pushover" },
|
||||||
|
]}
|
||||||
|
clearable
|
||||||
|
{...typeInputProps}
|
||||||
|
onChange={value => {
|
||||||
|
typeInputProps.onChange(value)
|
||||||
|
form.setFieldValue("serverUrl", "")
|
||||||
|
form.setFieldValue("topic", "")
|
||||||
|
form.setFieldValue("userSecret", "")
|
||||||
|
form.setFieldValue("userId", "")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{form.values.type === "ntfy" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Server URL</Trans>}
|
||||||
|
placeholder="https://ntfy.sh"
|
||||||
|
required
|
||||||
|
{...form.getInputProps("serverUrl")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Topic</Trans>}
|
||||||
|
placeholder="commafeed"
|
||||||
|
required
|
||||||
|
{...form.getInputProps("topic")}
|
||||||
|
/>
|
||||||
|
<TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{form.values.type === "gotify" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Server URL</Trans>}
|
||||||
|
placeholder="https://gotify.example.com"
|
||||||
|
required
|
||||||
|
{...form.getInputProps("serverUrl")}
|
||||||
|
/>
|
||||||
|
<TextInput label={<Trans>App token</Trans>} required {...form.getInputProps("userSecret")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{form.values.type === "pushover" && (
|
||||||
|
<>
|
||||||
|
<TextInput label={<Trans>User key</Trans>} required {...form.getInputProps("userId")} />
|
||||||
|
<TextInput label={<Trans>API token</Trans>} required {...form.getInputProps("userSecret")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />}>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<TbSend size={16} />}
|
||||||
|
onClick={() => sendTestPushNotification.execute(form.values)}
|
||||||
|
loading={sendTestPushNotification.loading}
|
||||||
|
>
|
||||||
|
<Trans>Test</Trans>
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Stack } from "@mantine/core"
|
import { Box, Stack } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
import React from "react"
|
||||||
|
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
import {
|
import {
|
||||||
redirectToCategory,
|
redirectToCategory,
|
||||||
redirectToCategoryDetails,
|
redirectToCategoryDetails,
|
||||||
@@ -8,15 +10,14 @@ import {
|
|||||||
redirectToFeedDetails,
|
redirectToFeedDetails,
|
||||||
redirectToTag,
|
redirectToTag,
|
||||||
redirectToTagDetails,
|
redirectToTagDetails,
|
||||||
} from "app/redirect/thunks"
|
} from "@/app/redirect/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { collapseTreeCategory } from "app/tree/thunks"
|
import type { TreeSubscription } from "@/app/tree/slice"
|
||||||
import type { Category, Subscription } from "app/types"
|
import { collapseTreeCategory } from "@/app/tree/thunks"
|
||||||
import { categoryUnreadCount, flattenCategoryTree } from "app/utils"
|
import type { Category, Subscription } from "@/app/types"
|
||||||
import { Loader } from "components/Loader"
|
import { categoryHasNewEntries, categoryUnreadCount, flattenCategoryTree } from "@/app/utils"
|
||||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
import { Loader } from "@/components/Loader"
|
||||||
import React from "react"
|
import { OnDesktop } from "@/components/responsive/OnDesktop"
|
||||||
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
|
|
||||||
import { TreeNode } from "./TreeNode"
|
import { TreeNode } from "./TreeNode"
|
||||||
import { TreeSearch } from "./TreeSearch"
|
import { TreeSearch } from "./TreeSearch"
|
||||||
|
|
||||||
@@ -89,10 +90,12 @@ export function Tree() {
|
|||||||
name={<Trans>All</Trans>}
|
name={<Trans>All</Trans>}
|
||||||
icon={allIcon}
|
icon={allIcon}
|
||||||
unread={categoryUnreadCount(root)}
|
unread={categoryUnreadCount(root)}
|
||||||
|
hasNewEntries={categoryHasNewEntries(root)}
|
||||||
selected={source.type === "category" && source.id === Constants.categories.all.id}
|
selected={source.type === "category" && source.id === Constants.categories.all.id}
|
||||||
expanded={false}
|
expanded={false}
|
||||||
level={0}
|
level={0}
|
||||||
hasError={false}
|
hasError={false}
|
||||||
|
hasWarning={false}
|
||||||
onClick={categoryClicked}
|
onClick={categoryClicked}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -103,10 +106,12 @@ export function Tree() {
|
|||||||
name={<Trans>Starred</Trans>}
|
name={<Trans>Starred</Trans>}
|
||||||
icon={starredIcon}
|
icon={starredIcon}
|
||||||
unread={0}
|
unread={0}
|
||||||
|
hasNewEntries={false}
|
||||||
selected={source.type === "category" && source.id === Constants.categories.starred.id}
|
selected={source.type === "category" && source.id === Constants.categories.starred.id}
|
||||||
expanded={false}
|
expanded={false}
|
||||||
level={0}
|
level={0}
|
||||||
hasError={false}
|
hasError={false}
|
||||||
|
hasWarning={false}
|
||||||
onClick={categoryClicked}
|
onClick={categoryClicked}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -114,7 +119,11 @@ export function Tree() {
|
|||||||
const categoryNode = (category: Category, level = 0) => {
|
const categoryNode = (category: Category, level = 0) => {
|
||||||
if (!isCategoryDisplayed(category)) return null
|
if (!isCategoryDisplayed(category)) return null
|
||||||
|
|
||||||
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
const hasError =
|
||||||
|
!category.expanded &&
|
||||||
|
flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
||||||
|
const hasWarning =
|
||||||
|
!category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
|
||||||
return (
|
return (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
id={category.id}
|
id={category.id}
|
||||||
@@ -122,10 +131,12 @@ export function Tree() {
|
|||||||
name={category.name}
|
name={category.name}
|
||||||
icon={category.expanded ? expandedIcon : collapsedIcon}
|
icon={category.expanded ? expandedIcon : collapsedIcon}
|
||||||
unread={categoryUnreadCount(category)}
|
unread={categoryUnreadCount(category)}
|
||||||
|
hasNewEntries={categoryHasNewEntries(category)}
|
||||||
selected={source.type === "category" && source.id === category.id}
|
selected={source.type === "category" && source.id === category.id}
|
||||||
expanded={category.expanded}
|
expanded={category.expanded}
|
||||||
level={level}
|
level={level}
|
||||||
hasError={hasError}
|
hasError={hasError}
|
||||||
|
hasWarning={hasWarning}
|
||||||
onClick={categoryClicked}
|
onClick={categoryClicked}
|
||||||
onIconClick={e => categoryIconClicked(e, category)}
|
onIconClick={e => categoryIconClicked(e, category)}
|
||||||
key={category.id}
|
key={category.id}
|
||||||
@@ -133,7 +144,7 @@ export function Tree() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedNode = (feed: Subscription, level = 0) => {
|
const feedNode = (feed: TreeSubscription, level = 0) => {
|
||||||
if (!isFeedDisplayed(feed)) return null
|
if (!isFeedDisplayed(feed)) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -143,9 +154,11 @@ export function Tree() {
|
|||||||
name={feed.name}
|
name={feed.name}
|
||||||
icon={feed.iconUrl}
|
icon={feed.iconUrl}
|
||||||
unread={feed.unread}
|
unread={feed.unread}
|
||||||
|
hasNewEntries={!!feed.hasNewEntries}
|
||||||
selected={source.type === "feed" && source.id === String(feed.id)}
|
selected={source.type === "feed" && source.id === String(feed.id)}
|
||||||
level={level}
|
level={level}
|
||||||
hasError={feed.errorCount > errorThreshold}
|
hasError={feed.errorCount > errorThreshold}
|
||||||
|
hasWarning={!!feed.filterLegacy}
|
||||||
onClick={feedClicked}
|
onClick={feedClicked}
|
||||||
key={feed.id}
|
key={feed.id}
|
||||||
/>
|
/>
|
||||||
@@ -159,9 +172,11 @@ export function Tree() {
|
|||||||
name={tag}
|
name={tag}
|
||||||
icon={tagIcon}
|
icon={tagIcon}
|
||||||
unread={0}
|
unread={0}
|
||||||
|
hasNewEntries={false}
|
||||||
selected={source.type === "tag" && source.id === tag}
|
selected={source.type === "tag" && source.id === tag}
|
||||||
level={0}
|
level={0}
|
||||||
hasError={false}
|
hasError={false}
|
||||||
|
hasWarning={false}
|
||||||
onClick={tagClicked}
|
onClick={tagClicked}
|
||||||
key={tag}
|
key={tag}
|
||||||
/>
|
/>
|
||||||
@@ -182,7 +197,7 @@ export function Tree() {
|
|||||||
<OnDesktop>
|
<OnDesktop>
|
||||||
<TreeSearch feeds={feeds} />
|
<TreeSearch feeds={feeds} />
|
||||||
</OnDesktop>
|
</OnDesktop>
|
||||||
<Box>
|
<Box className="cf-tree">
|
||||||
{allCategoryNode()}
|
{allCategoryNode()}
|
||||||
{starredCategoryNode()}
|
{starredCategoryNode()}
|
||||||
{root.children.map(c => recursiveCategoryNode(c))}
|
{root.children.map(c => recursiveCategoryNode(c))}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Box, Center } from "@mantine/core"
|
import { Box, Center } from "@mantine/core"
|
||||||
import type { EntrySourceType } from "app/entries/slice"
|
|
||||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { tss } from "tss"
|
import type { EntrySourceType } from "@/app/entries/slice"
|
||||||
|
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||||
|
import { tss } from "@/tss"
|
||||||
import { UnreadCount } from "./UnreadCount"
|
import { UnreadCount } from "./UnreadCount"
|
||||||
|
|
||||||
interface TreeNodeProps {
|
interface TreeNodeProps {
|
||||||
@@ -15,6 +15,8 @@ interface TreeNodeProps {
|
|||||||
expanded?: boolean
|
expanded?: boolean
|
||||||
level: number
|
level: number
|
||||||
hasError: boolean
|
hasError: boolean
|
||||||
|
hasWarning: boolean
|
||||||
|
hasNewEntries: boolean
|
||||||
onClick: (e: React.MouseEvent, id: string) => void
|
onClick: (e: React.MouseEvent, id: string) => void
|
||||||
onIconClick?: (e: React.MouseEvent, id: string) => void
|
onIconClick?: (e: React.MouseEvent, id: string) => void
|
||||||
}
|
}
|
||||||
@@ -23,15 +25,18 @@ const useStyles = tss
|
|||||||
.withParams<{
|
.withParams<{
|
||||||
selected: boolean
|
selected: boolean
|
||||||
hasError: boolean
|
hasError: boolean
|
||||||
|
hasWarning: boolean
|
||||||
hasUnread: boolean
|
hasUnread: boolean
|
||||||
}>()
|
}>()
|
||||||
.create(({ theme, colorScheme, selected, hasError, hasUnread }) => {
|
.create(({ theme, colorScheme, selected, hasError, hasWarning, hasUnread }) => {
|
||||||
let backgroundColor = "inherit"
|
let backgroundColor = "inherit"
|
||||||
if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
|
if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
|
||||||
|
|
||||||
let color: string
|
let color: string
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
color = theme.colors.red[6]
|
color = theme.colors.red[6]
|
||||||
|
} else if (hasWarning) {
|
||||||
|
color = theme.colors.yellow[6]
|
||||||
} else if (colorScheme === "dark") {
|
} else if (colorScheme === "dark") {
|
||||||
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
|
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
|
||||||
} else {
|
} else {
|
||||||
@@ -58,29 +63,34 @@ const useStyles = tss
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export function TreeNode(props: TreeNodeProps) {
|
export function TreeNode(props: Readonly<TreeNodeProps>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
selected: props.selected,
|
selected: props.selected,
|
||||||
hasError: props.hasError,
|
hasError: props.hasError,
|
||||||
|
hasWarning: props.hasWarning,
|
||||||
hasUnread: props.unread > 0,
|
hasUnread: props.unread > 0,
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
py={1}
|
py={1}
|
||||||
pl={props.level * 20}
|
pl={props.level * 20}
|
||||||
className={classes.node}
|
className={`${classes.node} cf-treenode cf-treenode-${props.type}`}
|
||||||
onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}
|
onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}
|
||||||
data-id={props.id}
|
data-id={props.id}
|
||||||
data-type={props.type}
|
data-type={props.type}
|
||||||
data-unread-count={props.unread}
|
data-unread-count={props.unread}
|
||||||
>
|
>
|
||||||
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}>
|
<Box
|
||||||
|
mr={6}
|
||||||
|
onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}
|
||||||
|
className="cf-treenode-icon"
|
||||||
|
>
|
||||||
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
|
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className={classes.nodeText}>{props.name}</Box>
|
<Box className={classes.nodeText}>{props.name}</Box>
|
||||||
{!props.expanded && (
|
{!props.expanded && (
|
||||||
<Box>
|
<Box className="cf-treenode-unread-count">
|
||||||
<UnreadCount unreadCount={props.unread} />
|
<UnreadCount unreadCount={props.unread} showIndicator={props.hasNewEntries} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { TextInput } from "@mantine/core"
|
import { Box, TextInput } from "@mantine/core"
|
||||||
import { Spotlight, type SpotlightActionData, spotlight } from "@mantine/spotlight"
|
import { Spotlight, type SpotlightActionData, spotlight } from "@mantine/spotlight"
|
||||||
import { redirectToFeed } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import type { Subscription } from "app/types"
|
|
||||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
|
||||||
import { useMousetrap } from "hooks/useMousetrap"
|
|
||||||
import { TbSearch } from "react-icons/tb"
|
import { TbSearch } from "react-icons/tb"
|
||||||
|
import { redirectToFeed } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import type { Subscription } from "@/app/types"
|
||||||
|
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||||
|
import { useMousetrap } from "@/hooks/useMousetrap"
|
||||||
|
|
||||||
export interface TreeSearchProps {
|
export interface TreeSearchProps {
|
||||||
feeds: Subscription[]
|
feeds: Subscription[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreeSearch(props: TreeSearchProps) {
|
export function TreeSearch(props: Readonly<TreeSearchProps>) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export function TreeSearch(props: TreeSearchProps) {
|
|||||||
useMousetrap("g u", () => spotlight.open())
|
useMousetrap("g u", () => spotlight.open())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Box className="cf-treesearch">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder={_(msg`Search`)}
|
placeholder={_(msg`Search`)}
|
||||||
leftSection={searchIcon}
|
leftSection={searchIcon}
|
||||||
@@ -58,6 +58,6 @@ export function TreeSearch(props: TreeSearchProps) {
|
|||||||
}}
|
}}
|
||||||
nothingFound={<Trans>Nothing found</Trans>}
|
nothingFound={<Trans>Nothing found</Trans>}
|
||||||
/>
|
/>
|
||||||
</>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { Badge, Tooltip } from "@mantine/core"
|
import { Badge, Indicator, Tooltip } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
const useStyles = tss.create(() => ({
|
const useStyles = tss.create(() => ({
|
||||||
badge: {
|
badge: {
|
||||||
width: "3.2rem",
|
width: "3.2rem",
|
||||||
// for some reason, mantine Badge has "cursor: 'default'"
|
// for some reason, mantine Badge has "cursor: 'default'"
|
||||||
cursor: "pointer",
|
cursor: "inherit",
|
||||||
|
},
|
||||||
|
indicator: {
|
||||||
|
// ensure the indicator is not shown above the app header
|
||||||
|
zIndex: 0,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export function UnreadCount(props: { unreadCount: number }) {
|
export function UnreadCount(
|
||||||
|
props: Readonly<{
|
||||||
|
unreadCount: number
|
||||||
|
showIndicator: boolean
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const { classes } = useStyles()
|
const { classes } = useStyles()
|
||||||
|
|
||||||
if (props.unreadCount <= 0) return null
|
if (props.unreadCount <= 0) return null
|
||||||
@@ -18,9 +27,19 @@ export function UnreadCount(props: { unreadCount: number }) {
|
|||||||
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
|
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
|
||||||
return (
|
return (
|
||||||
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}>
|
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}>
|
||||||
<Badge className={classes.badge} variant="light" fullWidth>
|
<Indicator
|
||||||
{count}
|
disabled={!props.showIndicator}
|
||||||
</Badge>
|
size={4}
|
||||||
|
offset={10}
|
||||||
|
position="middle-start"
|
||||||
|
classNames={{
|
||||||
|
indicator: classes.indicator,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge className={`${classes.badge} cf-badge`} variant="light" fullWidth>
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
</Indicator>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
3
commafeed-client/src/globals.d.ts
vendored
Normal file
3
commafeed-client/src/globals.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// required because of noUncheckedSideEffectImports
|
||||||
|
// https://www.typescriptlang.org/tsconfig/#noUncheckedSideEffectImports
|
||||||
|
declare module "@fontsource/open-sans"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMantineTheme } from "@mantine/core"
|
import { useMantineTheme } from "@mantine/core"
|
||||||
import { useMobile } from "hooks/useMobile"
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
export const useActionButton = () => {
|
export const useActionButton = () => {
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { useAppSelector } from "app/store"
|
import { useAppSelector } from "@/app/store"
|
||||||
|
|
||||||
interface Step {
|
interface Step {
|
||||||
label: string
|
label: string
|
||||||
@@ -8,28 +8,28 @@ interface Step {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useAppLoading = () => {
|
export const useAppLoading = () => {
|
||||||
const profile = useAppSelector(state => state.user.profile)
|
const profileLoaded = useAppSelector(state => !!state.user.profile)
|
||||||
const settings = useAppSelector(state => state.user.settings)
|
const settingsLoaded = useAppSelector(state => !!state.user.settings)
|
||||||
const rootCategory = useAppSelector(state => state.tree.rootCategory)
|
const rootCategoryLoaded = useAppSelector(state => !!state.tree.rootCategory)
|
||||||
const tags = useAppSelector(state => state.user.tags)
|
const tagsLoaded = useAppSelector(state => !!state.user.tags)
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const steps: Step[] = [
|
const steps: Step[] = [
|
||||||
{
|
{
|
||||||
label: _(msg`Loading settings...`),
|
label: _(msg`Loading settings...`),
|
||||||
done: !!settings,
|
done: settingsLoaded,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: _(msg`Loading profile...`),
|
label: _(msg`Loading profile...`),
|
||||||
done: !!profile,
|
done: profileLoaded,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: _(msg`Loading subscriptions...`),
|
label: _(msg`Loading subscriptions...`),
|
||||||
done: !!rootCategory,
|
done: rootCategoryLoaded,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: _(msg`Loading tags...`),
|
label: _(msg`Loading tags...`),
|
||||||
done: !!tags,
|
done: tagsLoaded,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMediaQuery } from "@mantine/hooks"
|
import { useMediaQuery } from "@mantine/hooks"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
|
|
||||||
export const useMobile = (breakpoint: string | number = Constants.layout.mobileBreakpoint) => {
|
export const useMobile = (breakpoint: string | number = Constants.layout.mobileBreakpoint) => {
|
||||||
const bp = typeof breakpoint === "number" ? `${breakpoint}px` : breakpoint
|
const bp = typeof breakpoint === "number" ? `${breakpoint}px` : breakpoint
|
||||||
|
|||||||
17
commafeed-client/src/hooks/useValidationRules.ts
Normal file
17
commafeed-client/src/hooks/useValidationRules.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { msg } from "@lingui/core/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
import { useAppSelector } from "@/app/store"
|
||||||
|
|
||||||
|
export function useValidationRules() {
|
||||||
|
const minimumPasswordLength = useAppSelector(state => state.server.serverInfos?.minimumPasswordLength)
|
||||||
|
const { _ } = useLingui()
|
||||||
|
|
||||||
|
return {
|
||||||
|
password: (value: string | undefined) =>
|
||||||
|
value && minimumPasswordLength && value.length < minimumPasswordLength
|
||||||
|
? _(msg`Password must be at least ${minimumPasswordLength} characters`)
|
||||||
|
: null,
|
||||||
|
passwordConfirmation: (newPasswordConfirmation: string | undefined, newPassword: string | undefined) =>
|
||||||
|
newPasswordConfirmation && newPasswordConfirmation !== newPassword ? _(msg`Passwords do not match`) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { setWebSocketConnected } from "app/server/slice"
|
|
||||||
import { type AppDispatch, useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { newFeedEntriesDiscovered } from "app/tree/thunks"
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import WebsocketHeartbeatJs from "websocket-heartbeat-js"
|
import WebsocketHeartbeatJs from "websocket-heartbeat-js"
|
||||||
|
import { setWebSocketConnected } from "@/app/server/slice"
|
||||||
|
import { type AppDispatch, useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { newFeedEntriesDiscovered } from "@/app/tree/thunks"
|
||||||
|
|
||||||
const handleMessage = (dispatch: AppDispatch, message: string) => {
|
const handleMessage = (dispatch: AppDispatch, message: string) => {
|
||||||
const parts = message.split(":")
|
const parts = message.split(":")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type Messages, i18n } from "@lingui/core"
|
import { i18n, type Messages } from "@lingui/core"
|
||||||
import { useAppSelector } from "app/store"
|
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
import { useAppSelector } from "@/app/store"
|
||||||
|
|
||||||
interface Locale {
|
interface Locale {
|
||||||
key: string
|
key: string
|
||||||
@@ -12,34 +12,146 @@ interface Locale {
|
|||||||
// add an object to the array to add a new locale
|
// add an object to the array to add a new locale
|
||||||
// don't forget to also add it to the 'locales' array in lingui.config.ts
|
// don't forget to also add it to the 'locales' array in lingui.config.ts
|
||||||
export const locales: Locale[] = [
|
export const locales: Locale[] = [
|
||||||
{ key: "ar", label: "العربية", dayjsImportFn: async () => await import("dayjs/locale/ar") },
|
{
|
||||||
{ key: "ca", label: "Català", dayjsImportFn: async () => await import("dayjs/locale/ca") },
|
key: "ar",
|
||||||
{ key: "cs", label: "Čeština", dayjsImportFn: async () => await import("dayjs/locale/cs") },
|
label: "العربية",
|
||||||
{ key: "cy", label: "Cymraeg", dayjsImportFn: async () => await import("dayjs/locale/cy") },
|
dayjsImportFn: async () => await import("dayjs/locale/ar"),
|
||||||
{ key: "da", label: "Danish", dayjsImportFn: async () => await import("dayjs/locale/da") },
|
},
|
||||||
{ key: "de", label: "Deutsch", dayjsImportFn: async () => await import("dayjs/locale/de") },
|
{
|
||||||
{ key: "en", label: "English", dayjsImportFn: async () => await import("dayjs/locale/en") },
|
key: "ca",
|
||||||
{ key: "es", label: "Español", dayjsImportFn: async () => await import("dayjs/locale/es") },
|
label: "Català",
|
||||||
{ key: "fa", label: "فارسی", dayjsImportFn: async () => await import("dayjs/locale/fa") },
|
dayjsImportFn: async () => await import("dayjs/locale/ca"),
|
||||||
{ key: "fi", label: "Suomi", dayjsImportFn: async () => await import("dayjs/locale/fi") },
|
},
|
||||||
{ key: "fr", label: "Français", dayjsImportFn: async () => await import("dayjs/locale/fr") },
|
{
|
||||||
{ key: "gl", label: "Galician", dayjsImportFn: async () => await import("dayjs/locale/gl") },
|
key: "cs",
|
||||||
{ key: "hu", label: "Magyar", dayjsImportFn: async () => await import("dayjs/locale/hu") },
|
label: "Čeština",
|
||||||
{ key: "id", label: "Indonesian", dayjsImportFn: async () => await import("dayjs/locale/id") },
|
dayjsImportFn: async () => await import("dayjs/locale/cs"),
|
||||||
{ key: "it", label: "Italiano", dayjsImportFn: async () => await import("dayjs/locale/it") },
|
},
|
||||||
{ key: "ja", label: "日本語", dayjsImportFn: async () => await import("dayjs/locale/ja") },
|
{
|
||||||
{ key: "ko", label: "한국어", dayjsImportFn: async () => await import("dayjs/locale/ko") },
|
key: "cy",
|
||||||
{ key: "ms", label: "Bahasa Malaysian", dayjsImportFn: async () => await import("dayjs/locale/ms") },
|
label: "Cymraeg",
|
||||||
{ key: "nb", label: "Norsk (bokmål)", dayjsImportFn: async () => await import("dayjs/locale/nb") },
|
dayjsImportFn: async () => await import("dayjs/locale/cy"),
|
||||||
{ key: "nl", label: "Nederlands", dayjsImportFn: async () => await import("dayjs/locale/nl") },
|
},
|
||||||
{ key: "nn", label: "Norsk (nynorsk)", dayjsImportFn: async () => await import("dayjs/locale/nn") },
|
{
|
||||||
{ key: "pl", label: "Polski", dayjsImportFn: async () => await import("dayjs/locale/pl") },
|
key: "da",
|
||||||
{ key: "pt", label: "Português", dayjsImportFn: async () => await import("dayjs/locale/pt") },
|
label: "Danish",
|
||||||
{ key: "ru", label: "Русский", dayjsImportFn: async () => await import("dayjs/locale/ru") },
|
dayjsImportFn: async () => await import("dayjs/locale/da"),
|
||||||
{ key: "sk", label: "Slovenčina", dayjsImportFn: async () => await import("dayjs/locale/sk") },
|
},
|
||||||
{ key: "sv", label: "Svenska", dayjsImportFn: async () => await import("dayjs/locale/sv") },
|
{
|
||||||
{ key: "tr", label: "Türkçe", dayjsImportFn: async () => await import("dayjs/locale/tr") },
|
key: "de",
|
||||||
{ key: "zh", label: "简体中文", dayjsImportFn: async () => await import("dayjs/locale/zh") },
|
label: "Deutsch",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/de"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "en",
|
||||||
|
label: "English",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/en"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "es",
|
||||||
|
label: "Español",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/es"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fa",
|
||||||
|
label: "فارسی",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/fa"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fi",
|
||||||
|
label: "Suomi",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/fi"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fr",
|
||||||
|
label: "Français",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/fr"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gl",
|
||||||
|
label: "Galician",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/gl"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hu",
|
||||||
|
label: "Magyar",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/hu"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "id",
|
||||||
|
label: "Indonesian",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/id"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "it",
|
||||||
|
label: "Italiano",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/it"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ja",
|
||||||
|
label: "日本語",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/ja"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ko",
|
||||||
|
label: "한국어",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/ko"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ms",
|
||||||
|
label: "Bahasa Malaysian",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/ms"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "nb",
|
||||||
|
label: "Norsk (bokmål)",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/nb"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "nl",
|
||||||
|
label: "Nederlands",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/nl"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "nn",
|
||||||
|
label: "Norsk (nynorsk)",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/nn"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pl",
|
||||||
|
label: "Polski",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/pl"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pt",
|
||||||
|
label: "Português",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/pt"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ru",
|
||||||
|
label: "Русский",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/ru"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sk",
|
||||||
|
label: "Slovenčina",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/sk"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sv",
|
||||||
|
label: "Svenska",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/sv"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tr",
|
||||||
|
label: "Türkçe",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/tr"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "zh",
|
||||||
|
label: "简体中文",
|
||||||
|
dayjsImportFn: async () => await import("dayjs/locale/zh"),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function activateLocale(locale: string) {
|
function activateLocale(locale: string) {
|
||||||
@@ -57,8 +169,12 @@ function activateLocale(locale: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useI18n = () => {
|
export const useI18n = () => {
|
||||||
const locale = useAppSelector(state => state.user.settings?.language)
|
const locale =
|
||||||
|
useAppSelector(state => state.user.settings?.language) ??
|
||||||
|
navigator.languages.map(l => l.split("-")[0]).find(l => locales.some(locale => locale.key === l)) ??
|
||||||
|
"en"
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activateLocale(locale ?? "en")
|
activateLocale(locale)
|
||||||
}, [locale])
|
}, [locale])
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user