mirror of
https://github.com/Athou/commafeed.git
synced 2026-03-21 21:37:29 +00:00
Compare commits
1788 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc76d7e609 | ||
|
|
1b24bf33ed | ||
|
|
58ff378735 | ||
|
|
1cee04a233 | ||
|
|
ac11a0efb8 | ||
|
|
f2ea1e3f7a | ||
|
|
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 | ||
|
|
e1f6937802 | ||
|
|
0c0834b30f | ||
|
|
5ad4b97205 | ||
|
|
c4ec249bc4 | ||
|
|
cf8d3965d5 | ||
|
|
3903fd9374 | ||
|
|
77d59dabe8 | ||
|
|
56ca737297 | ||
|
|
9edb539be3 | ||
|
|
31a773d200 | ||
|
|
61355eabf7 | ||
|
|
569874e51f | ||
|
|
00d47901fc | ||
|
|
d8b4ef55ce | ||
|
|
da41a4cab9 | ||
|
|
8a90ef0471 | ||
|
|
b4ab32a578 | ||
|
|
03aa53abc8 | ||
|
|
2ae5c0cd8e | ||
|
|
cacc632443 | ||
|
|
28f865ccfa | ||
|
|
a4c949e8b3 | ||
|
|
6098994397 | ||
|
|
5763ca30d6 | ||
|
|
7d039d1001 | ||
|
|
7fe74af906 | ||
|
|
80b72aa30b | ||
|
|
3ba0d241f9 | ||
|
|
67428aa0c7 | ||
|
|
b9a0256031 | ||
|
|
f3c2296636 | ||
|
|
b6e8f21975 | ||
|
|
284f80045f | ||
|
|
f589477aa8 | ||
|
|
29cb296d09 | ||
|
|
86caa1450a | ||
|
|
9dd4b9e67f | ||
|
|
e2e654f05b | ||
|
|
72dbc62b41 | ||
|
|
0a21014668 | ||
|
|
b6d9d2a26c | ||
|
|
25c3a7748c | ||
|
|
b2bcfdd6eb | ||
|
|
2a978db406 | ||
|
|
9e40d0d066 | ||
|
|
c912650d59 | ||
|
|
464ebcb471 | ||
|
|
463e0e59d7 | ||
|
|
b4e5d8ef20 | ||
|
|
126905aeb3 | ||
|
|
1af10d3364 | ||
|
|
6ad854c019 | ||
|
|
b30117aa4d | ||
|
|
5a66482d1e | ||
|
|
2628ec49bb | ||
|
|
f3d15cf173 | ||
|
|
bbcf55ce57 | ||
|
|
72fc3716e7 | ||
|
|
81a6cfaa88 | ||
|
|
aed5165ef3 | ||
|
|
eaf2933726 | ||
|
|
39da4d9d36 | ||
|
|
e5ebd7ff39 | ||
|
|
b6ae3e4e1e | ||
|
|
32d1488352 | ||
|
|
b08d0a388f | ||
|
|
7fe004a696 | ||
|
|
f620d033b0 | ||
|
|
ba071ba71f | ||
|
|
6f3197302d | ||
|
|
131a8ebf68 | ||
|
|
8b24c125c2 | ||
|
|
52293376ec | ||
|
|
f8ac59af6a | ||
|
|
5c791e2305 | ||
|
|
6641bc0631 | ||
|
|
da690aa750 | ||
|
|
fb7f041454 | ||
|
|
ec4554c76e | ||
|
|
068e85fe6e | ||
|
|
ba926c674e | ||
|
|
836f8f14c0 | ||
|
|
eeecac96e1 | ||
|
|
ecc62f222a | ||
|
|
9022f93811 | ||
|
|
e7225d35b2 | ||
|
|
454fc03038 | ||
|
|
9c0674fd83 | ||
|
|
7a20482ddf | ||
|
|
32ad47ba16 | ||
|
|
fc562cce0f | ||
|
|
b029b251db | ||
|
|
e3e28e727f | ||
|
|
50cb728db7 | ||
|
|
c654ba4d1b | ||
|
|
846e29b15e | ||
|
|
f2b4062d73 | ||
|
|
9051e6a6db | ||
|
|
b733129043 | ||
|
|
d46b571444 | ||
|
|
7d744b4ce0 | ||
|
|
801dda912c | ||
|
|
a20005409a | ||
|
|
6f1411d075 | ||
|
|
1aa263a6c0 | ||
|
|
9d511ac7dd | ||
|
|
122e98cc76 | ||
|
|
e445e5ea39 | ||
|
|
5b9212015b | ||
|
|
293292f341 | ||
|
|
57d8a4dbb1 | ||
|
|
e104f531f9 | ||
|
|
bf1361926f | ||
|
|
cc4f4d9eb4 | ||
|
|
706bad26f1 | ||
|
|
4ecefe6491 | ||
|
|
937e7353ce | ||
|
|
1dcf76fc0a | ||
|
|
9d794dcad7 | ||
|
|
d11b666755 | ||
|
|
7a444e4861 | ||
|
|
5992795579 | ||
|
|
4441d76a7f | ||
|
|
c1305b56e3 | ||
|
|
cc0440c029 | ||
|
|
f65591c170 | ||
|
|
9a32dce9d1 | ||
|
|
789bd3edae | ||
|
|
256cd426d9 | ||
|
|
58af2da105 | ||
|
|
e0de397273 | ||
|
|
75cc3cf29c | ||
|
|
af60758e2a | ||
|
|
01180e95a2 | ||
|
|
fa683ef7e1 | ||
|
|
462d17a429 | ||
|
|
17f71a40d4 | ||
|
|
de91a3a05a | ||
|
|
ead587ee88 | ||
|
|
62b3e6fb3a | ||
|
|
037ff15045 | ||
|
|
ed35b06934 | ||
|
|
3cfb1a13a7 | ||
|
|
d04745d859 | ||
|
|
58b18f36c5 | ||
|
|
7282d18d8f | ||
|
|
8e58fa22b4 | ||
|
|
58d6eb2c5a | ||
|
|
2f7c7498e2 | ||
|
|
bcf8dcd551 | ||
|
|
511f0a60bb | ||
|
|
72db0d815f | ||
|
|
280d0b7fdd | ||
|
|
42e4575cb7 | ||
|
|
28a4bb403a | ||
|
|
cca3c907db | ||
|
|
1a5b932742 | ||
|
|
a1d3f3008a | ||
|
|
902f2efbd2 | ||
|
|
2e534af146 | ||
|
|
23ca30c3c2 | ||
|
|
517eedad00 | ||
|
|
216ea1fb42 | ||
|
|
640d1a0ce3 | ||
|
|
bba7425b5f | ||
|
|
7a1a49bfb4 | ||
|
|
e451e6698c | ||
|
|
9af3f21404 | ||
|
|
7b14a9c0c2 | ||
|
|
0b65cc9510 | ||
|
|
7879ab9b61 | ||
|
|
e6bebcafb3 | ||
|
|
3b465cebb7 | ||
|
|
aeb211be06 | ||
|
|
ad992aea7b | ||
|
|
d848f72a0b | ||
|
|
0db087908d | ||
|
|
42138d04d6 | ||
|
|
4522a9d0d5 | ||
|
|
7440fcad0e | ||
|
|
fc51c1882f | ||
|
|
e24498b31f | ||
|
|
60fdc79563 | ||
|
|
6729ebc6ea | ||
|
|
c8ff216ce5 | ||
|
|
98c4150cfe | ||
|
|
128332d710 | ||
|
|
eabcb519a4 | ||
|
|
5e14cead3d | ||
|
|
b601f938ff | ||
|
|
4acfda32d0 | ||
|
|
54da4e6839 | ||
|
|
3a6b4c588c | ||
|
|
48071b9fd1 | ||
|
|
f519aa039f | ||
|
|
dc3e5476a1 | ||
|
|
903035ecfc | ||
|
|
13ad57da10 | ||
|
|
44bc24c22a | ||
|
|
97f90405fc | ||
|
|
0fc2a0b022 | ||
|
|
89eb641704 | ||
|
|
c53da9f631 | ||
|
|
998868e63a | ||
|
|
93f22d2351 | ||
|
|
c3782bd7d2 | ||
|
|
f330349397 | ||
|
|
99c973c8c2 | ||
|
|
469420b5bf | ||
|
|
bde556d41f | ||
|
|
bf6c2d7beb | ||
|
|
fa62ca21e0 | ||
|
|
7dcf76da84 | ||
|
|
3dc80fa762 | ||
|
|
dbce12492b | ||
|
|
85f5eaffec | ||
|
|
106276351e | ||
|
|
961fb6a464 | ||
|
|
ac3d9ef57f | ||
|
|
3478ee4815 | ||
|
|
3dc02d7ba1 | ||
|
|
c886f8b83c | ||
|
|
4a2154d0b3 | ||
|
|
ba530d5019 | ||
|
|
85b6209c52 | ||
|
|
7ff86a5e31 | ||
|
|
8edd6a1e2d | ||
|
|
6e65ed49e9 | ||
|
|
711b01abfa | ||
|
|
c7014ca2a1 | ||
|
|
a3984cd959 | ||
|
|
8d85b1bcba | ||
|
|
c451eee406 | ||
|
|
8f42135996 | ||
|
|
2c26aeed17 | ||
|
|
e2c4aa998b | ||
|
|
c9e3b7f349 | ||
|
|
ebb4e52ba7 | ||
|
|
1ddfdfb12e | ||
|
|
81f16aea62 | ||
|
|
429ec193c8 | ||
|
|
732b714448 | ||
|
|
82e0405ad9 | ||
|
|
9ef002fcd1 | ||
|
|
ec938e416c | ||
|
|
37cf711cbc | ||
|
|
de441e4ff7 | ||
|
|
46251526b6 | ||
|
|
67eeea0b06 | ||
|
|
b49ccc4cd9 | ||
|
|
8586a8b57b | ||
|
|
d9f63786a8 | ||
|
|
8f0c8b68b9 | ||
|
|
15e574c5c4 | ||
|
|
fe532242b4 | ||
|
|
fb48ff0858 | ||
|
|
8d850639d7 | ||
|
|
ee73195915 | ||
|
|
72d9dad61b | ||
|
|
fde8dab8cd | ||
|
|
dae5efa787 | ||
|
|
3c067140fd | ||
|
|
4ccbe81e87 | ||
|
|
3d5d93bb72 | ||
|
|
4138b6eb9b | ||
|
|
9c39c95a9b | ||
|
|
32b2bf99a4 | ||
|
|
cf459876af | ||
|
|
6698bd74b5 | ||
|
|
c81d06e5f3 | ||
|
|
b12a78dc84 | ||
|
|
b076587e44 | ||
|
|
bb12f16bea | ||
|
|
e80caadd12 | ||
|
|
846d93f2b2 | ||
|
|
0ed6f6ef9c | ||
|
|
15992dcb80 | ||
|
|
1a5c399b54 | ||
|
|
5e92f9ffb8 | ||
|
|
71164d1b69 | ||
|
|
6947670fe6 | ||
|
|
30810e37b9 | ||
|
|
b17b2767b0 | ||
|
|
d37cf5bbcf | ||
|
|
045baba705 | ||
|
|
3623dc8e1d | ||
|
|
2610c37067 | ||
|
|
286b69a646 | ||
|
|
9673f27090 | ||
|
|
0722599f6d | ||
|
|
1df40d8370 | ||
|
|
457e4ec69e | ||
|
|
647310a45f | ||
|
|
e85c92f216 | ||
|
|
d93b0dbfd4 | ||
|
|
b4e61ef547 | ||
|
|
71dffbba46 | ||
|
|
2c0b0c4e3b | ||
|
|
d868e58e1e | ||
|
|
90eb2095bf | ||
|
|
62d3ed16e6 | ||
|
|
74f7c48818 | ||
|
|
23fe9c29ed | ||
|
|
8f7be8278a | ||
|
|
49118b6ea0 | ||
|
|
d97bd04ae2 | ||
|
|
8d11309b64 | ||
|
|
68c24e4cb8 | ||
|
|
4e43e0235f | ||
|
|
62b79a9625 | ||
|
|
cb0706808c | ||
|
|
ffd5704b1e | ||
|
|
3987077e5a | ||
|
|
2e01a76784 | ||
|
|
8254093f5f | ||
|
|
0b06526756 | ||
|
|
06731ae76d | ||
|
|
9a59453792 | ||
|
|
c195a52c89 | ||
|
|
3d7924f953 | ||
|
|
f29efd7fae | ||
|
|
157bff3c83 | ||
|
|
5c17bbc36d | ||
|
|
c85e72e70c | ||
|
|
01150f67e1 | ||
|
|
75aca7aa6f | ||
|
|
affde7e43c | ||
|
|
b9b1b53235 | ||
|
|
708ebb8abc | ||
|
|
83e763df0a | ||
|
|
0ff812c1ea | ||
|
|
3e9dd6d8e2 | ||
|
|
23af73e847 | ||
|
|
e79e4719fd | ||
|
|
23fef98432 | ||
|
|
22478252e7 | ||
|
|
76b1f3cd35 | ||
|
|
420d73ec6a | ||
|
|
e0211cfa0c | ||
|
|
25a92c651c | ||
|
|
0781205c69 | ||
|
|
5102dd5e30 | ||
|
|
6ccfc3fd67 | ||
|
|
2791ed91ab | ||
|
|
f40c198233 | ||
|
|
003dc63121 | ||
|
|
f8ef1e2a99 | ||
|
|
14c7078940 | ||
|
|
074836d3e8 | ||
|
|
0cdbc144b3 | ||
|
|
dc63ec24c0 | ||
|
|
6d4c6c36a5 | ||
|
|
464af5f4d9 | ||
|
|
aa94a46a3d | ||
|
|
8542197dc3 | ||
|
|
64d77eaef4 | ||
|
|
675ef8794c | ||
|
|
4bcdbeb516 | ||
|
|
a9f37739fb | ||
|
|
5ab0fc19da | ||
|
|
7b232425f3 | ||
|
|
c0e7668140 | ||
|
|
ae3f059257 | ||
|
|
d44c7c1e95 | ||
|
|
6cd9d134cf | ||
|
|
6f21ba8afc | ||
|
|
b2fe13c117 | ||
|
|
03ece7a262 | ||
|
|
697fde2d0e | ||
|
|
7f0f85b356 | ||
|
|
a7d41debfe | ||
|
|
57bf758108 | ||
|
|
b37d933047 | ||
|
|
80ffef4555 | ||
|
|
af5a0002aa | ||
|
|
cd24e412e3 | ||
|
|
a073d843ab | ||
|
|
8ccb59ed18 | ||
|
|
e6dc7d2d0d | ||
|
|
f7b21ca3f6 | ||
|
|
df3a1bcdb6 | ||
|
|
5bec494a7c | ||
|
|
d8eef4dd9f | ||
|
|
d80138caf3 | ||
|
|
d26c103aa5 | ||
|
|
249231f57e | ||
|
|
7a838cddad | ||
|
|
477f2cd6db | ||
|
|
9915f05f73 | ||
|
|
0a16bb2fba | ||
|
|
3d4faf2406 | ||
|
|
63de6fe833 | ||
|
|
9c6219a58a | ||
|
|
3e664d4287 | ||
|
|
4c4ffd84f3 | ||
|
|
f555f0e392 | ||
|
|
124166738b | ||
|
|
8b32dcc576 | ||
|
|
105651215a | ||
|
|
d9c6cbd072 | ||
|
|
b4c52e06fe | ||
|
|
2565dfe528 | ||
|
|
b5036c9148 | ||
|
|
e2c8ddb8f7 | ||
|
|
85fbd284fa | ||
|
|
559fb69a64 | ||
|
|
054c76716a | ||
|
|
ba17c9350f | ||
|
|
781015eea4 | ||
|
|
13e5c0e8d2 | ||
|
|
4d88a30848 | ||
|
|
19c03de9e4 | ||
|
|
e3169c9f2d | ||
|
|
e90fb0b56f | ||
|
|
69607a5122 | ||
|
|
21eb8e6d9f | ||
|
|
a28a6b9dc4 | ||
|
|
a9cadbafeb | ||
|
|
d491af5a8d | ||
|
|
39c7934fb8 | ||
|
|
76eba8cc63 | ||
|
|
7549ff2491 | ||
|
|
66cd18bc4b | ||
|
|
44d7a2c340 | ||
|
|
d6ee63a01f | ||
|
|
a495c9cacd | ||
|
|
530185d15c | ||
|
|
8dd28c25a7 | ||
|
|
50884b236c | ||
|
|
da9fe09e58 | ||
|
|
3c24c9aa7a | ||
|
|
9d10a4b46f | ||
|
|
d56ed3bd06 | ||
|
|
41c1c429d0 | ||
|
|
1a3d890b40 | ||
|
|
e3ec9b2ccd | ||
|
|
f69878a242 | ||
|
|
ea766706fb | ||
|
|
306507af80 | ||
|
|
67084783b2 | ||
|
|
7cbb75f717 | ||
|
|
1c335492d5 | ||
|
|
de92e74c8e | ||
|
|
9cbbd30618 | ||
|
|
f14f1493c4 | ||
|
|
e68c8fdbe1 | ||
|
|
e094972aa2 | ||
|
|
ff831c6d2b | ||
|
|
9957cda11a | ||
|
|
6809822000 | ||
|
|
8048b1a9aa | ||
|
|
8557bd018a | ||
|
|
183d5fd162 | ||
|
|
411f86fbeb | ||
|
|
5493046f25 | ||
|
|
42015015a5 | ||
|
|
f3d2808f7d | ||
|
|
906c353a7f | ||
|
|
93dea83cd3 | ||
|
|
1fc76ce1ad | ||
|
|
a337b01bc7 | ||
|
|
689e5c0004 | ||
|
|
d5a3c81c85 | ||
|
|
8230fde5d2 | ||
|
|
b35513ea84 | ||
|
|
42a7785ca1 | ||
|
|
ea5ee4f04f | ||
|
|
3e14b12d4f | ||
|
|
78cc30f828 | ||
|
|
6091c84e60 | ||
|
|
6ea95ad254 | ||
|
|
7f888d926e | ||
|
|
5e4e02474f | ||
|
|
bff8611b42 | ||
|
|
f674048af3 | ||
|
|
0265c24cf9 | ||
|
|
f8c3a229ec | ||
|
|
c424f40420 | ||
|
|
b77666cfe5 | ||
|
|
193d1604d9 | ||
|
|
4efc6296b5 | ||
|
|
f753a4bdda | ||
|
|
afaaaf9657 | ||
|
|
4d46896bf0 | ||
|
|
2ad28c927f | ||
|
|
b9680a66ef | ||
|
|
4f687d5857 | ||
|
|
9cca026834 | ||
|
|
058a9cd192 | ||
|
|
57d2ede86e | ||
|
|
e3abea4ec5 | ||
|
|
b831f1f35c | ||
|
|
74bce1308c | ||
|
|
98cfa6d2c8 | ||
|
|
99a02a2186 | ||
|
|
3431a813b1 | ||
|
|
e9e0e8d32b | ||
|
|
2d14409d35 | ||
|
|
a8200e5c58 | ||
|
|
79a8df8b06 | ||
|
|
061a5f0262 | ||
|
|
821bdb3b0f | ||
|
|
606dfa9299 | ||
|
|
131357c616 | ||
|
|
f6d3493bad | ||
|
|
0c6104e25b | ||
|
|
d73735a35d | ||
|
|
e725d2d6b6 | ||
|
|
f0e1279d68 | ||
|
|
c74c74d2c4 | ||
|
|
aa70cf5dcd | ||
|
|
1055259627 | ||
|
|
302d37b6ef | ||
|
|
8532a73d94 | ||
|
|
ffafb272cb | ||
|
|
22e0171a34 | ||
|
|
2b410f040c | ||
|
|
259e8ad4e5 | ||
|
|
21244dd9f5 | ||
|
|
bc6206180d | ||
|
|
6e22d21358 | ||
|
|
95bdb4e700 | ||
|
|
9b7dbc68ab | ||
|
|
dc86c9b0db | ||
|
|
cb92ed753f | ||
|
|
10a085e24e | ||
|
|
3400a39edf | ||
|
|
21efffa345 | ||
|
|
e2e80ba7e5 | ||
|
|
d988dba66e | ||
|
|
403201fbff | ||
|
|
3cc93b51bb | ||
|
|
6a7d83bb45 | ||
|
|
19c8db8b31 | ||
|
|
0d75688ec8 | ||
|
|
e01dcb2f5b | ||
|
|
57757e2c14 | ||
|
|
779cd2fcfe | ||
|
|
94919f22e4 | ||
|
|
d5cf690703 | ||
|
|
191574dace | ||
|
|
ee7c6792c9 | ||
|
|
e2962dc2eb | ||
|
|
8c335cb8fd | ||
|
|
461c18a591 | ||
|
|
363837ab26 | ||
|
|
a184485421 | ||
|
|
f992c3f1a6 | ||
|
|
3219a9e313 | ||
|
|
4717c31058 | ||
|
|
693844828b | ||
|
|
ef4b479638 | ||
|
|
8eefb1bcfb | ||
|
|
ada9a5039b | ||
|
|
cca2d49cc3 | ||
|
|
f4a43e9950 | ||
|
|
9a89b39b62 | ||
|
|
2dba844b6c | ||
|
|
3101dc91de | ||
|
|
83cacd97f3 | ||
|
|
aa179c21f8 | ||
|
|
31cf4d8bb2 | ||
|
|
19bcc2c0da | ||
|
|
ca803ff7ce | ||
|
|
0e26d975aa | ||
|
|
86a3cb67f2 | ||
|
|
6297463445 | ||
|
|
1a3a3076b1 | ||
|
|
7fb9cfeaf1 | ||
|
|
5c7dbe6304 | ||
|
|
c41fd9216a | ||
|
|
91a9ad79f0 | ||
|
|
906458dc96 | ||
|
|
2f4fddf539 | ||
|
|
a8157143b9 | ||
|
|
92576e28e9 | ||
|
|
a6e34a2960 | ||
|
|
306cf7aab7 | ||
|
|
f3b806686d | ||
|
|
6634cfb991 | ||
|
|
9930bb68b2 | ||
|
|
37722131e5 | ||
|
|
5f2d213419 | ||
|
|
e119941762 | ||
|
|
ba496c1395 | ||
|
|
9c3fc84542 | ||
|
|
b017ce936a | ||
|
|
d696b0581b | ||
|
|
e4b2880f2b | ||
|
|
e071049969 | ||
|
|
5e1f592951 | ||
|
|
231f82da28 | ||
|
|
9a28bc7334 | ||
|
|
00907e92ff | ||
|
|
5669798881 | ||
|
|
f3a62a5f75 | ||
|
|
3b20ed222c | ||
|
|
1f40f3f59c | ||
|
|
a8d890524f | ||
|
|
b635799e80 | ||
|
|
50ea66620d | ||
|
|
46581d0e22 | ||
|
|
a3562867a6 | ||
|
|
c0117ada93 | ||
|
|
a3dcb2c03e | ||
|
|
8f8aaa5a1d | ||
|
|
85482422fd | ||
|
|
643c969faf | ||
|
|
85f9469d6d | ||
|
|
0df0d50695 | ||
|
|
5e9256c197 | ||
|
|
b67e1a92f5 | ||
|
|
d250e4bc26 | ||
|
|
dcf1f41f2d | ||
|
|
3df6ba1457 | ||
|
|
b89928f6c6 | ||
|
|
2e014484e3 | ||
|
|
3b2b18fd2e | ||
|
|
ebf1e592ff | ||
|
|
88404b91d8 | ||
|
|
9cbb60313c | ||
|
|
b95d417f5e | ||
|
|
994f1fb121 | ||
|
|
e533c1fa4b | ||
|
|
d0d946ffe9 | ||
|
|
e3bcc824c7 | ||
|
|
357e4e207f | ||
|
|
2aee961600 | ||
|
|
3aa1987319 | ||
|
|
ae15f61fc2 | ||
|
|
e58f92a812 | ||
|
|
46383924b1 | ||
|
|
071920e864 | ||
|
|
012238e6a9 | ||
|
|
a565566c50 | ||
|
|
550804c666 | ||
|
|
f7a4a33f5e | ||
|
|
f1b19ebae3 | ||
|
|
4049fa2e17 | ||
|
|
28808cf4f5 | ||
|
|
870b46cf9d | ||
|
|
9c20dea99c | ||
|
|
63c7679067 | ||
|
|
764c1a6430 | ||
|
|
bb6578bdd0 | ||
|
|
748c8531ad | ||
|
|
a734fe68d2 | ||
|
|
cc5ebc55a4 | ||
|
|
aa396c1e1c | ||
|
|
fbf87ff291 | ||
|
|
e9f3ffddf4 | ||
|
|
695518d68b | ||
|
|
5d96c1e12b | ||
|
|
3a72a1cc04 | ||
|
|
54f5714108 | ||
|
|
04811c7eca | ||
|
|
6856736ddb | ||
|
|
db6c43993a | ||
|
|
508a22576a | ||
|
|
8fb012b3a1 | ||
|
|
133781d314 | ||
|
|
50cb12896e | ||
|
|
79a4315941 | ||
|
|
33a2f76521 | ||
|
|
d4041a1d88 | ||
|
|
09f2f56446 | ||
|
|
a0c3eda506 | ||
|
|
84de3199cc | ||
|
|
a7e8309d63 |
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
* text eol=lf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.png binary
|
||||||
7
.github/FUNDING.yml
vendored
7
.github/FUNDING.yml
vendored
@@ -1,2 +1,5 @@
|
|||||||
github: [athou]
|
github: [ athou ]
|
||||||
custom: ['https://www.paypal.com/donate/?business=9CNQHMJG2ZJVY&no_recurring=0&item_name=CommaFeed¤cy_code=EUR']
|
custom: [
|
||||||
|
'https://github.com/sponsors/Athou',
|
||||||
|
'https://www.paypal.com/donate/?business=9CNQHMJG2ZJVY&no_recurring=0&item_name=CommaFeed¤cy_code=EUR'
|
||||||
|
]
|
||||||
|
|||||||
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -25,7 +25,8 @@ If applicable, add screenshots to help explain your problem.
|
|||||||
|
|
||||||
**Environment (please complete the following information):**
|
**Environment (please complete the following information):**
|
||||||
|
|
||||||
- CommaFeed version (or "commafeed.com"): 3.2.1
|
- commafeed.com or self-hosted:
|
||||||
|
- If self-hosted, CommaFeed version [e.g. 5.1.0 (a3dcb2c)]:
|
||||||
- Browser [e.g. chrome, firefox]:
|
- Browser [e.g. chrome, firefox]:
|
||||||
- Device [e.g. desktop, mobile]:
|
- Device [e.g. desktop, mobile]:
|
||||||
|
|
||||||
|
|||||||
37
.github/stale.yml
vendored
37
.github/stale.yml
vendored
@@ -1,19 +1,20 @@
|
|||||||
# Number of days of inactivity before an issue becomes stale
|
# Number of days of inactivity before an issue becomes stale
|
||||||
daysUntilStale: 60
|
daysUntilStale: 60
|
||||||
# Number of days of inactivity before a stale issue is closed
|
# Number of days of inactivity before a stale issue is closed
|
||||||
daysUntilClose: 7
|
daysUntilClose: 7
|
||||||
# Issues with these labels will never be considered stale
|
# Issues with these labels will never be considered stale
|
||||||
exemptLabels:
|
exemptLabels:
|
||||||
- pinned
|
- pinned
|
||||||
- security
|
- security
|
||||||
- enhancement
|
- enhancement
|
||||||
- bug
|
- feature-request
|
||||||
# Label to use when marking an issue as stale
|
- bug
|
||||||
staleLabel: wontfix
|
# Label to use when marking an issue as stale
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
staleLabel: wontfix
|
||||||
markComment: >
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
This issue has been automatically marked as stale because it has not had
|
markComment: >
|
||||||
recent activity. It will be closed if no further activity occurs. Thank you
|
This issue has been automatically marked as stale because it has not had
|
||||||
for your contributions.
|
recent activity. It will be closed if no further activity occurs. Thank you
|
||||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
for your contributions.
|
||||||
|
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||||
closeComment: false
|
closeComment: false
|
||||||
461
.github/workflows/ci.yml
vendored
461
.github/workflows/ci.yml
vendored
@@ -1,183 +1,278 @@
|
|||||||
name: ci
|
name: ci
|
||||||
|
permissions:
|
||||||
on: [ push ]
|
contents: read
|
||||||
|
|
||||||
env:
|
on:
|
||||||
JAVA_VERSION: 21
|
push:
|
||||||
DOCKER_BUILD_SUMMARY: false
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
env:
|
||||||
build-linux:
|
JAVA_VERSION: 25
|
||||||
runs-on: ubuntu-latest
|
DOCKER_BUILD_SUMMARY: false
|
||||||
strategy:
|
|
||||||
matrix:
|
jobs:
|
||||||
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
build:
|
||||||
|
if: github.event_name != 'pull_request' || github.actor != 'renovate[bot]' # renovate already triggers the build on pushes
|
||||||
steps:
|
|
||||||
# Checkout
|
strategy:
|
||||||
- name: Checkout
|
matrix:
|
||||||
uses: actions/checkout@v4
|
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
|
||||||
with:
|
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||||
fetch-depth: 0
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
# Setup
|
steps:
|
||||||
- name: Set up QEMU
|
# Checkout
|
||||||
uses: docker/setup-qemu-action@v3
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- name: Set up Docker Buildx
|
with:
|
||||||
uses: docker/setup-buildx-action@v3
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up GraalVM
|
# Setup
|
||||||
uses: graalvm/setup-graalvm@v1
|
- name: Set up GraalVM
|
||||||
with:
|
uses: graalvm/setup-graalvm@03e8abf916fd0e281b2efe7b2da3378bb0a1d085 # v1
|
||||||
java-version: ${{ env.JAVA_VERSION }}
|
with:
|
||||||
distribution: "graalvm"
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
cache: "maven"
|
distribution: "graalvm"
|
||||||
|
cache: "maven"
|
||||||
# Build & Test
|
|
||||||
- name: Build with Maven
|
- name: Install Playwright dependencies
|
||||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }}
|
run: sudo apt-get install -y libgbm1
|
||||||
|
if: matrix.os != 'windows-latest'
|
||||||
# Upload artifacts
|
|
||||||
- name: Upload cross-platform app
|
# Build & Test
|
||||||
uses: actions/upload-artifact@v4
|
- name: Build with Maven
|
||||||
with:
|
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
||||||
name: commafeed-${{ matrix.database }}-jvm
|
|
||||||
path: commafeed-server/target/commafeed-*.zip
|
# Build pages
|
||||||
|
- name: Create pages directory structure
|
||||||
- name: Upload native executable
|
run: mkdir -p target/pages/documentation/custom-css
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
- name: Convert readme file to html
|
||||||
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
path: commafeed-server/target/commafeed-*-runner
|
with:
|
||||||
|
source: README.md
|
||||||
# Docker
|
output: target/pages/index.html
|
||||||
- name: Login to Container Registry
|
|
||||||
uses: docker/login-action@v3
|
- name: Convert config documentation to html
|
||||||
if: ${{ github.ref_type == 'tag' || github.ref_name == 'master' }}
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
source: commafeed-server/target/quarkus-generated-doc/config/commafeed-server.md
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
output: target/pages/documentation/index.html
|
||||||
|
|
||||||
## tags
|
- name: Convert custom css documentation to html
|
||||||
- name: Docker build and push tag - native
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
uses: docker/build-push-action@v6
|
with:
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
source: documentation/CUSTOMCSS.md
|
||||||
with:
|
output: target/pages/documentation/custom-css/index.html
|
||||||
context: .
|
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
# Upload artifacts
|
||||||
push: true
|
- name: Upload cross-platform app
|
||||||
platforms: linux/amd64
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||||
tags: |
|
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database
|
||||||
athou/commafeed:latest-${{ matrix.database }}
|
with:
|
||||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
name: commafeed-${{ matrix.database }}-jvm
|
||||||
|
path: commafeed-server/target/commafeed-*.zip
|
||||||
- name: Docker build and push tag - jvm
|
|
||||||
uses: docker/build-push-action@v6
|
- name: Upload native executable
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
path: commafeed-server/target/commafeed-*-runner*
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
- name: Upload pages
|
||||||
tags: |
|
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
|
||||||
athou/commafeed:latest-${{ matrix.database }}-jvm
|
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once
|
||||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm
|
with:
|
||||||
|
path: target/pages
|
||||||
## master
|
|
||||||
- name: Docker build and push master - native
|
docker:
|
||||||
uses: docker/build-push-action@v6
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.ref_name == 'master' }}
|
needs: build
|
||||||
with:
|
env:
|
||||||
context: .
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
|
||||||
push: true
|
strategy:
|
||||||
platforms: linux/amd64
|
matrix:
|
||||||
tags: athou/commafeed:master-${{ matrix.database }}
|
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||||
|
|
||||||
- name: Docker build and push master - jvm
|
steps:
|
||||||
uses: docker/build-push-action@v6
|
# Checkout
|
||||||
if: ${{ github.ref_name == 'master' }}
|
- name: Checkout
|
||||||
with:
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
context: .
|
with:
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
fetch-depth: 0
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
# Setup
|
||||||
tags: athou/commafeed:master-${{ matrix.database }}-jvm
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
|
||||||
build-windows:
|
|
||||||
runs-on: windows-latest
|
- name: Set up Docker Buildx
|
||||||
strategy:
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||||
matrix:
|
|
||||||
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
- name: Install required packages
|
||||||
|
run: sudo apt-get install -y rename unzip
|
||||||
steps:
|
|
||||||
# Checkout
|
# Prepare artifacts
|
||||||
- name: Configure git to checkout as-is
|
- name: Download artifacts
|
||||||
run: git config --global core.autocrlf false
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||||
|
with:
|
||||||
- name: Checkout
|
pattern: commafeed-${{ matrix.database }}-*
|
||||||
uses: actions/checkout@v4
|
path: ./artifacts
|
||||||
with:
|
merge-multiple: true
|
||||||
fetch-depth: 0
|
|
||||||
|
- name: Set the exec flag on the native executables
|
||||||
# Setup
|
run: chmod +x artifacts/*-runner
|
||||||
- name: Set up GraalVM
|
|
||||||
uses: graalvm/setup-graalvm@v1
|
- name: Rename native executables to match buildx TARGETARCH
|
||||||
with:
|
run: |
|
||||||
java-version: ${{ env.JAVA_VERSION }}
|
rename 's/x86_64/amd64/g' artifacts/*
|
||||||
distribution: "graalvm"
|
rename 's/aarch_64/arm64/g' artifacts/*
|
||||||
cache: "maven"
|
|
||||||
|
- name: Unzip jvm package
|
||||||
# Build & Test
|
run: |
|
||||||
- name: Build with Maven
|
unzip artifacts/*-jvm.zip -d artifacts/extracted-jvm-package
|
||||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.database != 'h2' }}
|
rename 's/commafeed-.*/quarkus-app/g' artifacts/extracted-jvm-package/*
|
||||||
|
|
||||||
# Upload artifacts
|
# Docker
|
||||||
- name: Upload native executable
|
- name: Login to Container Registry
|
||||||
uses: actions/upload-artifact@v4
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||||
with:
|
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
with:
|
||||||
path: commafeed-server/target/commafeed-*-runner.exe
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
## build but don't push for PRs and renovate
|
||||||
needs:
|
- name: Docker build - native
|
||||||
- build-linux
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
- build-windows
|
with:
|
||||||
if: github.ref_type == 'tag'
|
context: .
|
||||||
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
steps:
|
push: false
|
||||||
- name: Checkout
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
- name: Docker build - jvm
|
||||||
fetch-depth: 0
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
|
with:
|
||||||
- name: Download artifacts
|
context: .
|
||||||
uses: actions/download-artifact@v4
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
with:
|
push: false
|
||||||
pattern: commafeed-*
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
path: ./artifacts
|
|
||||||
merge-multiple: true
|
## build and push tag
|
||||||
|
- name: Docker build and push tag - native
|
||||||
- name: Extract Changelog Entry
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
uses: mindsers/changelog-reader-action@v2
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
id: changelog_reader
|
with:
|
||||||
with:
|
context: .
|
||||||
version: ${{ github.ref_name }}
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
- name: Create GitHub release
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
uses: ncipollo/release-action@v1
|
tags: |
|
||||||
with:
|
athou/commafeed:latest-${{ matrix.database }}
|
||||||
name: CommaFeed ${{ github.ref_name }}
|
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
||||||
body: ${{ steps.changelog_reader.outputs.changes }}
|
|
||||||
artifacts: ./artifacts/*
|
- name: Docker build and push tag - jvm
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
- name: Update Docker Hub Description
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
uses: peter-evans/dockerhub-description@v4
|
with:
|
||||||
with:
|
context: .
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
repository: athou/commafeed
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
short-description: ${{ github.event.repository.description }}
|
tags: |
|
||||||
readme-filepath: commafeed-server/src/main/docker/README.md
|
athou/commafeed:latest-${{ matrix.database }}-jvm
|
||||||
|
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm
|
||||||
|
|
||||||
|
## build and push master
|
||||||
|
- name: Docker build and push master - native
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
|
if: ${{ github.ref_name == 'master' }}
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
tags: athou/commafeed:master-${{ matrix.database }}
|
||||||
|
|
||||||
|
- name: Docker build and push master - jvm
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
|
if: ${{ github.ref_name == 'master' }}
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
tags: athou/commafeed:master-${{ matrix.database }}-jvm
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
- docker
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||||
|
with:
|
||||||
|
pattern: commafeed-*
|
||||||
|
path: ./artifacts
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set the exec flag on the native executables
|
||||||
|
run: chmod +x artifacts/*-runner
|
||||||
|
|
||||||
|
- name: Extract Changelog Entry
|
||||||
|
uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2
|
||||||
|
id: changelog_reader
|
||||||
|
with:
|
||||||
|
version: ${{ github.ref_name }}
|
||||||
|
|
||||||
|
- name: Create GitHub release
|
||||||
|
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1
|
||||||
|
with:
|
||||||
|
name: CommaFeed ${{ github.ref_name }}
|
||||||
|
body: ${{ steps.changelog_reader.outputs.changes }}
|
||||||
|
artifacts: ./artifacts/*
|
||||||
|
|
||||||
|
|
||||||
|
update-dockerhub-description:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: release
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Update Docker Hub Description
|
||||||
|
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
repository: athou/commafeed
|
||||||
|
short-description: ${{ github.event.repository.description }}
|
||||||
|
readme-filepath: commafeed-server/src/main/docker/README.md
|
||||||
|
|
||||||
|
|
||||||
|
deploy-pages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: release
|
||||||
|
permissions:
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||||
|
id: deployment
|
||||||
|
|||||||
21
.mvn/wrapper/maven-wrapper.properties
vendored
21
.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
|
distributionType=only-script
|
||||||
# distributed with this work for additional information
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||||
# 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
|
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
|
||||||
|
|||||||
944
CHANGELOG.md
944
CHANGELOG.md
@@ -1,392 +1,552 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [5.0.2]
|
## [7.0.0]
|
||||||
|
|
||||||
- Fix favicon fetching for Youtube channels in native mode when Google auth key is set
|
- 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.
|
||||||
- Fix an error that appears in the logs when fetching some favicons
|
- 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)
|
||||||
## [5.0.1]
|
- 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.
|
||||||
- Configure native compilation to support older CPU architectures (#1524)
|
|
||||||
|
## [6.2.0]
|
||||||
## [5.0.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)
|
||||||
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).
|
## [6.1.1]
|
||||||
The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around
|
|
||||||
0.3s) and very low memory footprint (< 50M).
|
- Fix old starred entries not loading if they were marked as read (#2031)
|
||||||
|
|
||||||
- CommaFeed now has a different package for each supported database.
|
## [6.1.0]
|
||||||
- If you are deploying CommaFeed with a precompiled package, please
|
|
||||||
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#download-a-precompiled-package).
|
- 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)
|
||||||
- If you are building CommaFeed from sources, please
|
- Use browser preferred language instead of English when using CommaFeed for the first time (#2018)
|
||||||
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#build-from-sources).
|
- The profile menu is now closed when scrolling the page (#2019)
|
||||||
- If you are using the Docker image, please read the instructions on
|
- The "disable pull to refresh" feature is now disabled by default (#2030)
|
||||||
the [Docker Hub page](https://hub.docker.com/r/athou/commafeed).
|
|
||||||
- Due to the switch to Quarkus, the way CommaFeed is configured is very different (the `config.yml` file is gone).
|
## [6.0.0]
|
||||||
Please
|
|
||||||
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#configuration).
|
- 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
|
||||||
Note that a lot of configuration elements have been removed or renamed and are now nested/grouped by feature.
|
- 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)
|
||||||
- Added a setting to prevent parsing large feeds to avoid out of memory errors. The default is 5MB.
|
- 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)
|
||||||
- Use a different icon for filtering unread entries and marking an entry as read (#1506)
|
- Java 25+ is now required to build and run CommaFeed
|
||||||
- Added various HTML attributes to ease custom JS/CSS customization (#1507)
|
|
||||||
- The Redis cache has been removed. There have been multiple enhancements to the feed refresh engine and it is no longer
|
## [5.12.1]
|
||||||
needed, even for instances with a large number of feeds.
|
|
||||||
- The H2 migration tool that automatically upgrades H2 databases from format 2 to 3 has been removed. If you're using
|
- The favicon is now crispier (#1978)
|
||||||
the H2 embedded database, please upgrade to at least version 4.3.0 before upgrading to CommaFeed 5.0.0.
|
- The ReadKit iOS app now works via the Fever API (#1602)
|
||||||
|
|
||||||
## [4.6.0]
|
## [5.12.0]
|
||||||
|
|
||||||
- switched from Temurin to OpenJ9 as the JVM used in the Docker image, resulting in memory usage reduction by up to 50%
|
- Added a setting to disable the "disable pull to refresh" feature because it messes with some browsers (#1168)
|
||||||
- fix an issue that could cause old entries to reappear if they were updated by their author (#1486)
|
- Emojis in feeds are now correctly displayed (#1955)
|
||||||
- show all entries regardless of their read status when searching with keywords, even if the ui is configured to show
|
- Don't show "Star/Unstar" in the context menu if the entry is too old to be starred (#1935)
|
||||||
unread entries only
|
- 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)
|
||||||
## [4.5.0]
|
- 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"
|
||||||
- significantly reduce the time needed to retrieve entries or mark them as read, especially when there are a lot of
|
|
||||||
entries (#1452)
|
## [5.11.1]
|
||||||
- fix a race condition where a feed could be refreshed before it was created in the database
|
|
||||||
- fix an issue that could cause the websocket notification to contain the wrong number of unread entries when using
|
- The search limit of 3 characters has been removed (#1887)
|
||||||
mysql/mariadb
|
- Fix an issue that caused feed filtering expressions to be incorrectly converted to lowercase when saving them (#1899)
|
||||||
- fix an error when trying to mark all starred entries as read
|
|
||||||
- remove the `onlyIds` parameter from REST endpoints since retrieving all the entries is now just as fast
|
## [5.11.0]
|
||||||
- remove support for microsoft sqlserver because it's not covered with integration tests (please open an issue if you'd
|
|
||||||
like it back)
|
- Add an option to navigate to the next unread category/feed when marking all entries as read (#1807)
|
||||||
|
- Google Analytics support has been removed
|
||||||
## [4.4.1]
|
|
||||||
|
## [5.10.0]
|
||||||
- fix vertical scrolling issues with Safari (#1168)
|
|
||||||
- the default value for new users for the "star entry" button and the "open in new tab" button in the entry headers is
|
- 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)
|
||||||
now "on desktop" instead of "always"
|
- Feeds with uppercase HTTP:// or HTTPS:// URLs are now correctly handled again
|
||||||
- the "keyboard shortcuts" help page now shows "Cmd" instead of "Ctrl" on macOS (#1389)
|
- The aarch64 native executable now also works on the Raspberry Pi 5 (#1795)
|
||||||
- remove a superfluous feed fetch when subscribing to a feed (#1431)
|
- Improve general performance of the UI by reducing the number of re-renders, especially when a lot of entries are displayed (#1087)
|
||||||
- the Docker image now uses Java 21
|
|
||||||
|
## [5.9.0]
|
||||||
## [4.4.0]
|
|
||||||
|
- A lot of CSS classes have been added to the elements of the application to ease custom CSS rules (#1757)
|
||||||
- add support for sharing using the browser native capabilities if available (#1255)
|
- Added a link in the README to the [documentation](https://athou.github.io/commafeed/documentation/custom-css/) of the new CSS classes
|
||||||
- add a button in the entry headers to star an entry (#1025)
|
- Static resources are now cached for much longer (#1782)
|
||||||
- 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
|
## [5.8.0]
|
||||||
- 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 (
|
- A color picker is now available on the settings page to change the orange accent of the application (#1598)
|
||||||
older than `keepStatusDays`) (#1303)
|
- 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)
|
||||||
## [4.3.3]
|
- 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
|
||||||
- fix OPML import (#1279)
|
|
||||||
|
## [5.7.0]
|
||||||
## [4.3.2]
|
|
||||||
|
- Add Shift+J/Shift+K keyboard shortcuts to navigate to the next/previous feed or category with unread entries (#1558)
|
||||||
- added support for unix sockets (#1278)
|
- Add the referrer "no-referrer" meta to index.html (#1724)
|
||||||
|
- Load custom JS code when the app is done loading (#1724)
|
||||||
## [4.3.1]
|
- 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
|
||||||
- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database and the database
|
- Fix tooltips not showing up in mobile view
|
||||||
timezone is not UTC (#1239)
|
- Fix the bookmarklet generator on the About page
|
||||||
- videos in enclosures can no longer have a width larger than the page (#1240)
|
|
||||||
|
## [5.6.1]
|
||||||
## [4.3.0]
|
|
||||||
|
- Restore support for iframes in feed entries (#1688)
|
||||||
- h2 (the embedded database) has been upgraded to 2.2.224
|
- There is now a package available for Arch Linux thanks to @dcelasun (#1691)
|
||||||
- this version uses a different file format than 2.1.x, the first time you start CommaFeed with this version, the
|
|
||||||
database will be automatically converted to the new format
|
## [5.6.0]
|
||||||
- add a setting to completely disable scrolling to selected entry (#1157)
|
|
||||||
- add a css class reflecting the current view mode to ease custom css rules (#1232)
|
- 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)
|
||||||
- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database (#1239)
|
- 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
|
||||||
## [4.2.1]
|
- 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
|
||||||
|
|
||||||
- fix an issue that caused the tree to show an incorrect unread count after a websocket notification because entries
|
## [5.5.0]
|
||||||
that were already marked as read by a filtering expression were not ignored (#1191)
|
|
||||||
|
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header
|
||||||
## [4.2.0]
|
- Audio enclosures (e.g. podcasts) now fill available entry width
|
||||||
|
- Fix an issue with some labels not correctly internationalized
|
||||||
- add a setting to display the action buttons in the footer instead of in the header on mobile (#1121)
|
|
||||||
- the websocket notification now contains everything needed to update the UI, the client no longer needs to make an API
|
## [5.4.0]
|
||||||
call to get the latest data when receiving the notification
|
|
||||||
- add a workaround to the Fever API for the Unread iOS app (#1188)
|
- An arm64 native executable is now available for download on the releases page
|
||||||
- fix an issue that caused dates to be saved incorrectly if the database server and the application server were in
|
- The native executable Docker image now supports arm64
|
||||||
different timezones (#1187)
|
- Fixed an issue with feeds that declared an invalid DOCTYPE (#1260)
|
||||||
|
|
||||||
## [4.1.0]
|
## [5.3.6]
|
||||||
|
|
||||||
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
- Ignore invalid Cache-Control header values (#1619)
|
||||||
- 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)
|
## [5.3.5]
|
||||||
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup.
|
|
||||||
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
|
- Fixed an issue with the aspect ratio of images of some feeds (#1595)
|
||||||
to 365 days
|
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615)
|
||||||
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
- 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)
|
||||||
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
|
## [5.3.4]
|
||||||
- when using the system color scheme and the system is using a dark theme, feed entries no longer flicker on load
|
|
||||||
- the demo account (if enabled) cannot register custom javascript code anymore
|
- Added support for Internationalized Domain Names (#1588)
|
||||||
- removed the usage of `toSorted` in the client because older browsers do not support it (#1183)
|
|
||||||
- the openapi documentation is no longer cached by the browser so you always have access to the latest version
|
## [5.3.3]
|
||||||
- added a memory management section to the readme, reading it is recommended if you are running CommaFeed on a server
|
|
||||||
with limited memory
|
- Removed image bottom margins (#1587)
|
||||||
- fixed an issue that caused users without an email address set to be unable to edit their profile (#1184)
|
|
||||||
|
## [5.3.2]
|
||||||
## [4.0.0]
|
|
||||||
|
- Fixed an issue that could cause some images from not being rendered correctly (#1587)
|
||||||
- migrated from dropwizard 2 to dropwizard 4, Java 17+ is now required
|
|
||||||
- entries that were fetched and inserted in the database but not yet shown in the UI are no longer marked as read when
|
## [5.3.1]
|
||||||
marking all entries as read
|
|
||||||
- your custom sidebar width is now persisted in the local storage of your browser
|
- Fixed an issue that could cause some HTTP feeds to return a 400 error (#1572)
|
||||||
- there is now a third color scheme option in addition to light and dark: system (follows the system color scheme)
|
|
||||||
- added support for youtube playlist favicons
|
## [5.3.0]
|
||||||
- custom JS code is now executed when the app is done loading instead of when the page is loaded
|
|
||||||
- the favicon is now correctly returned for feeds that return an invalid content type
|
- Added a setting to set a cooldown on the "fetch all my feeds" action, disabled by default (#1556)
|
||||||
- the feed refresh engine now uses httpclient5 with connection pooling and no longer creates a new client for each
|
- Fixed an issue that could cause entries to not correctly load when using the "next" header button (#1557)
|
||||||
request, reducing CPU usage
|
|
||||||
- updated UI library Mantine to 7.0, improving performance
|
## [5.2.0]
|
||||||
- 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
|
- Added an option to keep a number of entries above the selected entry when scrolling
|
||||||
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431)
|
||||||
- migrated documentation from swagger 2 to openapi 3
|
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour)
|
||||||
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
|
||||||
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
|
||||||
configured (see config.yml.example)
|
## [5.1.1]
|
||||||
- the websocket connection now works correctly when the context root of the application is not "/"
|
|
||||||
- unstable pubsubhubbub support was removed
|
- 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)
|
||||||
## [3.10.1]
|
|
||||||
|
## [5.1.0]
|
||||||
- swap next and previous buttons (#1159)
|
|
||||||
- unread count for subscriptions will now be shortened starting at 10k instead of 1k
|
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)
|
||||||
- increased websocket ping interval to just under a minute to reduce data and battery usage on mobile
|
- Fixed an issue that could prevent the app from starting on some systems (#1532)
|
||||||
- only refresh subscription tree on a timer if websocket connection is unavailable
|
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date
|
||||||
- the Docker image now uses less memory by returning unused memory to the OS
|
- Reduced database cleanup log verbosity
|
||||||
- add support for Java 21
|
|
||||||
|
## [5.0.2]
|
||||||
## [3.10.0]
|
|
||||||
|
- Fix favicon fetching for Youtube channels in native mode when Google auth key is set
|
||||||
- added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in
|
- Fix an error that appears in the logs when fetching some favicons
|
||||||
Settings -> Profile)
|
|
||||||
- long entry titles are no longer shortened in the detailed view
|
## [5.0.1]
|
||||||
- added the "s" keyboard shortcut to star/unstar entries
|
|
||||||
- http sessions are now stored in the database (they were stored on disk before)
|
- Configure native compilation to support older CPU architectures (#1524)
|
||||||
- fixed an issue that made it impossible to override the database url in a config.yml mounted in the Docker image
|
|
||||||
|
## [5.0.0]
|
||||||
## [3.9.0]
|
|
||||||
|
CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in
|
||||||
- improve performance by disabling the loader when nothing is loading (most noticeable on mobile)
|
the [announcement](https://github.com/Athou/commafeed/discussions/1517).
|
||||||
- added a setting to disable the 'mark all as read' confirmation
|
The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around
|
||||||
- added a setting to disable the custom context menu
|
0.3s) and very low memory footprint (< 50M).
|
||||||
- if the custom context is enabled, it can still be disabled by pressing the shift key
|
|
||||||
- the announcement feature is now working again and supports html ('announcement' configuration element in config.yml)
|
- CommaFeed now has a different package for each supported database.
|
||||||
- add support for MariaDB 11+
|
- If you are deploying CommaFeed with a precompiled package, please
|
||||||
- fix entry header shortly rendered as mobile on desktop, causing a small visual glitch
|
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#download-a-precompiled-package).
|
||||||
- fix an issue that could cause a feed to not refresh correctly if the url was very long
|
- If you are building CommaFeed from sources, please
|
||||||
- database cleanup batch size is now configurable
|
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#build-from-sources).
|
||||||
- css parsing errors are no longer logged to the standard output
|
- If you are using the Docker image, please read the instructions on
|
||||||
- fix small errors in the api documentation
|
the [Docker Hub page](https://hub.docker.com/r/athou/commafeed).
|
||||||
|
- Due to the switch to Quarkus, the way CommaFeed is configured is very different (the `config.yml` file is gone).
|
||||||
## [3.8.1]
|
Please
|
||||||
|
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#configuration).
|
||||||
- in expanded mode, don't scroll when clicking on the body of the current entry
|
Note that a lot of configuration elements have been removed or renamed and are now nested/grouped by feature.
|
||||||
- improve content cleanup task performance for instances with a very large number of feeds
|
- Added a setting to prevent parsing large feeds to avoid out of memory errors. The default is 5MB.
|
||||||
|
- Use a different icon for filtering unread entries and marking an entry as read (#1506)
|
||||||
## [3.8.0]
|
- Added various HTML attributes to ease custom JS/CSS customization (#1507)
|
||||||
|
- The Redis cache has been removed. There have been multiple enhancements to the feed refresh engine and it is no longer
|
||||||
- add previous and next buttons in the toolbar
|
needed, even for instances with a large number of feeds.
|
||||||
- add a setting to always scroll selected entry to the top of the page, even if it fits entirely on screen
|
- The H2 migration tool that automatically upgrades H2 databases from format 2 to 3 has been removed. If you're using
|
||||||
- clicking on the body of an entry in expanded mode selects it and marks it as read
|
the H2 embedded database, please upgrade to at least version 4.3.0 before upgrading to CommaFeed 5.0.0.
|
||||||
- add rich text editor with autocomplete for custom css and js code in settings (desktop only)
|
|
||||||
- dramatically improve performance while scrolling
|
## [4.6.0]
|
||||||
- fix broken welcome page mobile layout
|
|
||||||
- format dates in user locale instead of GMT in relative date popups
|
- switched from Temurin to OpenJ9 as the JVM used in the Docker image, resulting in memory usage reduction by up to 50%
|
||||||
|
- fix an issue that could cause old entries to reappear if they were updated by their author (#1486)
|
||||||
## [3.7.0]
|
- show all entries regardless of their read status when searching with keywords, even if the ui is configured to show
|
||||||
|
unread entries only
|
||||||
- the sidebar is now resizable
|
|
||||||
- added the "f" keyboard shortcut to hide the sidebar
|
## [4.5.0]
|
||||||
- added tooltips to relative dates with the exact date
|
|
||||||
- add a setting to hide commafeed from search engines (exposes a robots.txt file, enabled by default)
|
- significantly reduce the time needed to retrieve entries or mark them as read, especially when there are a lot of
|
||||||
- the browser extension unread count now updates when articles are marked as read/unread in the app
|
entries (#1452)
|
||||||
- The "b" keyboard shortcut now works as expected on Chrome but requires the browser extension to be installed
|
- fix a race condition where a feed could be refreshed before it was created in the database
|
||||||
- dark mode has been disabled on the api documentation page as it was unreadable
|
- fix an issue that could cause the websocket notification to contain the wrong number of unread entries when using
|
||||||
- improvement to the feed refresh queuing logic when "heavy load" mode is enabled
|
mysql/mariadb
|
||||||
- fix a bug that could prevent feeds and categories from being edited
|
- fix an error when trying to mark all starred entries as read
|
||||||
|
- remove the `onlyIds` parameter from REST endpoints since retrieving all the entries is now just as fast
|
||||||
## [3.6.0]
|
- remove support for microsoft sqlserver because it's not covered with integration tests (please open an issue if you'd
|
||||||
|
like it back)
|
||||||
- add a button to open CommaFeed in a new tab and a button to open options when using the browser extension
|
|
||||||
- clicking on the entry title in expanded mode now opens the link instead of doing nothing
|
## [4.4.1]
|
||||||
- add tooltips to buttons when the mobile layout is used on desktop
|
|
||||||
- redirect the user to the welcome page if the user was deleted from the database
|
- fix vertical scrolling issues with Safari (#1168)
|
||||||
- add link to api documentation on welcome page
|
- the default value for new users for the "star entry" button and the "open in new tab" button in the entry headers is
|
||||||
- the unread count is now correctly updated when using the "/next" bookmarklet while redis cache is enabled
|
now "on desktop" instead of "always"
|
||||||
|
- the "keyboard shortcuts" help page now shows "Cmd" instead of "Ctrl" on macOS (#1389)
|
||||||
## [3.5.0]
|
- remove a superfluous feed fetch when subscribing to a feed (#1431)
|
||||||
|
- the Docker image now uses Java 21
|
||||||
- add compatibility with the new version of the CommaFeed browser extension
|
|
||||||
- disable pull-to-refresh on mobile as it messes with vertical scrolling
|
## [4.4.0]
|
||||||
- add css classes to feed entries to help with custom css rules
|
|
||||||
- api documentation page no longer requires users to be authenticated
|
- add support for sharing using the browser native capabilities if available (#1255)
|
||||||
- add a setting to limit the number of feeds a user can subscribe to
|
- add a button in the entry headers to star an entry (#1025)
|
||||||
- add a setting to disable strict password policy
|
- add a button in the entry headers to open links in a new tab (#1333)
|
||||||
- add feed refresh engine metrics
|
- add two options in the settings to toggle those buttons
|
||||||
- fix redis timeouts
|
- 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 (
|
||||||
## [3.4.0]
|
older than `keepStatusDays`) (#1303)
|
||||||
|
|
||||||
- add support for arm64 docker images
|
## [4.3.3]
|
||||||
- add divider to visually separate read-only information from form on the profile settings page
|
|
||||||
- reduce javascript bundle size by 30% by loading only the necessary translations
|
- fix OPML import (#1279)
|
||||||
- add a standalone donate page with all ways to support CommaFeed
|
|
||||||
- fix an issue introduced in 3.1.0 that could make CommaFeed not refresh feeds as fast as before on instances with lots
|
## [4.3.2]
|
||||||
of feeds
|
|
||||||
- fix alignment of icon with text for category tree nodes
|
- added support for unix sockets (#1278)
|
||||||
- fix alignment of burger button with the rest of the header on mobile
|
|
||||||
|
## [4.3.1]
|
||||||
## [3.3.2]
|
|
||||||
|
- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database and the database
|
||||||
- restore entry selection indicator (left orange border) that was lost with the mantine 6.x upgrade (3.3.0)
|
timezone is not UTC (#1239)
|
||||||
- add dividers to visually separate read-only information from forms on feed and category details pages
|
- videos in enclosures can no longer have a width larger than the page (#1240)
|
||||||
- reduced javascript bundle size by 10%
|
|
||||||
|
## [4.3.0]
|
||||||
## [3.3.1]
|
|
||||||
|
- h2 (the embedded database) has been upgraded to 2.2.224
|
||||||
- fix long feed names not being shortened to respect tree max width
|
- this version uses a different file format than 2.1.x, the first time you start CommaFeed with this version, the
|
||||||
|
database will be automatically converted to the new format
|
||||||
## [3.3.0]
|
- add a setting to completely disable scrolling to selected entry (#1157)
|
||||||
|
- add a css class reflecting the current view mode to ease custom css rules (#1232)
|
||||||
- there are now database changes, rolling back to 2.x will no longer be possible
|
- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database (#1239)
|
||||||
- restore support for user custom CSS rules
|
|
||||||
- add support for user custom JS code that will be executed on page load
|
## [4.2.1]
|
||||||
|
|
||||||
## [3.2.0]
|
- fix an issue that caused the tree to show an incorrect unread count after a websocket notification because entries
|
||||||
|
that were already marked as read by a filtering expression were not ignored (#1191)
|
||||||
- restore the welcome page
|
|
||||||
- only apply hover effect for unread entries (same as commafeed v2)
|
## [4.2.0]
|
||||||
- move notifications at the bottom of the screen
|
|
||||||
- always use https for sharing urls
|
- add a setting to display the action buttons in the footer instead of in the header on mobile (#1121)
|
||||||
- add support for redis ACLs
|
- the websocket notification now contains everything needed to update the UI, the client no longer needs to make an API
|
||||||
- transition to google analytics v4
|
call to get the latest data when receiving the notification
|
||||||
|
- add a workaround to the Fever API for the Unread iOS app (#1188)
|
||||||
## [3.1.0]
|
- fix an issue that caused dates to be saved incorrectly if the database server and the application server were in
|
||||||
|
different timezones (#1187)
|
||||||
- add an even more compact layout
|
|
||||||
- restore hover effect from commafeed 2.x
|
## [4.1.0]
|
||||||
- view mode (compact, expanded, ...) is now stored on the device so you can have a different view mode on desktop and
|
|
||||||
mobile
|
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
||||||
- fix for the "Illegal attempt to associate a collection with two open sessions." error
|
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
|
||||||
- feed fetching workflow is now orchestrated with rxjava, removing a lot of code
|
- 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.
|
||||||
## [3.0.1]
|
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
|
||||||
|
to 365 days
|
||||||
- allow env variable substitution in config.yml
|
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
||||||
- e.g. having a custom config.yml file with `app.session.path=${SOME_ENV_VAR}` will substitute `SOME_ENV_VAR` with its
|
page instead of the welcome page when not logged in (#1185)
|
||||||
value
|
- the sidebar resizer is no longer shown in the middle of the screen on mobile
|
||||||
- allow env variable prefixed with `CF_` to override config.yml properties
|
- when using the system color scheme and the system is using a dark theme, feed entries no longer flicker on load
|
||||||
- e.g. setting `CF_APP_ALLOWREGISTRATIONS=true` will set `app.allowRegistrations` to `true`
|
- the demo account (if enabled) cannot register custom javascript code anymore
|
||||||
|
- removed the usage of `toSorted` in the client because older browsers do not support it (#1183)
|
||||||
## [3.0.0]
|
- the openapi documentation is no longer cached by the browser so you always have access to the latest version
|
||||||
|
- added a memory management section to the readme, reading it is recommended if you are running CommaFeed on a server
|
||||||
- complete overhaul of the UI
|
with limited memory
|
||||||
- backend and frontend are now in separate maven modules
|
- fixed an issue that caused users without an email address set to be unable to edit their profile (#1184)
|
||||||
- no changes to the api or the database
|
|
||||||
- Docker images are now automatically built and available at https://hub.docker.com/r/athou/commafeed
|
## [4.0.0]
|
||||||
|
|
||||||
## [2.6.0]
|
- migrated from dropwizard 2 to dropwizard 4, Java 17+ is now required
|
||||||
|
- entries that were fetched and inserted in the database but not yet shown in the UI are no longer marked as read when
|
||||||
- add support for media content as a backup for missing content (useful for youtube feeds)
|
marking all entries as read
|
||||||
- correctly follow http error code 308 redirects
|
- your custom sidebar width is now persisted in the local storage of your browser
|
||||||
- fixed a bug that prevented users from deleting their account
|
- there is now a third color scheme option in addition to light and dark: system (follows the system color scheme)
|
||||||
- fixed a bug that made commafeed store entry contents multiple times
|
- added support for youtube playlist favicons
|
||||||
- fixed a bug that prevented the app to be used as an installed app on mobile devices if the context path of commafeed
|
- custom JS code is now executed when the app is done loading instead of when the page is loaded
|
||||||
was not "/"
|
- the favicon is now correctly returned for feeds that return an invalid content type
|
||||||
- fixed a bug that prevented entries from being "marked as read older than xxx" for a feed that was just added
|
- the feed refresh engine now uses httpclient5 with connection pooling and no longer creates a new client for each
|
||||||
- removed support for google+ and readability as those services no longer exist
|
request, reducing CPU usage
|
||||||
- removed support for deploying on openshift
|
- updated UI library Mantine to 7.0, improving performance
|
||||||
- removed alphabetical sorting of entries because of really poor performance (title cannot be indexed)
|
- the h2 embedded database is now compacted on shutdown to reclaim unused space
|
||||||
- improve performance for instances with the heavy load setting enabled by preventing CommaFeed from fetching feeds from
|
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is
|
||||||
users that did not log in for a long time
|
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
||||||
- various dependencies upgrades (notably dropwizard from 1.3 to 2.1)
|
- migrated documentation from swagger 2 to openapi 3
|
||||||
- add support for mariadb
|
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
||||||
- add support for java17+ runtime
|
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
||||||
- various security improvements
|
configured (see config.yml.example)
|
||||||
|
- the websocket connection now works correctly when the context root of the application is not "/"
|
||||||
## [2.5.0]
|
- unstable pubsubhubbub support was removed
|
||||||
|
|
||||||
- unread count is now displayed in a favicon badge when supported
|
## [3.10.1]
|
||||||
- the user agent string for the bot fetching feeds is now configurable
|
|
||||||
- feed parsing performance improvements
|
- swap next and previous buttons (#1159)
|
||||||
- support for java9+ runtime
|
- unread count for subscriptions will now be shortened starting at 10k instead of 1k
|
||||||
- can now properly start from an empty postgresql database
|
- increased websocket ping interval to just under a minute to reduce data and battery usage on mobile
|
||||||
|
- only refresh subscription tree on a timer if websocket connection is unavailable
|
||||||
## [2.4.0]
|
- the Docker image now uses less memory by returning unused memory to the OS
|
||||||
|
- add support for Java 21
|
||||||
- users were not able to change password or delete account
|
|
||||||
- fix api key generation
|
## [3.10.0]
|
||||||
- feed entries can now be sorted alphabetically
|
|
||||||
- fix facebook sharing
|
- added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in
|
||||||
- fix layout on iOS
|
Settings -> Profile)
|
||||||
- postgresql driver update (fix for postgres 9.6)
|
- long entry titles are no longer shortened in the detailed view
|
||||||
- various internationalization fixes
|
- added the "s" keyboard shortcut to star/unstar entries
|
||||||
- security fixes
|
- http sessions are now stored in the database (they were stored on disk before)
|
||||||
|
- fixed an issue that made it impossible to override the database url in a config.yml mounted in the Docker image
|
||||||
## [2.3.0]
|
|
||||||
|
## [3.9.0]
|
||||||
- dropwizard upgrade 0.9.1
|
|
||||||
- feed enclosures are hidden if they already displayed in the content
|
- improve performance by disabling the loader when nothing is loading (most noticeable on mobile)
|
||||||
- fix youtube favicons
|
- added a setting to disable the 'mark all as read' confirmation
|
||||||
- various internationalization fixes
|
- added a setting to disable the custom context menu
|
||||||
|
- if the custom context is enabled, it can still be disabled by pressing the shift key
|
||||||
## [2.2.0]
|
- the announcement feature is now working again and supports html ('announcement' configuration element in config.yml)
|
||||||
|
- add support for MariaDB 11+
|
||||||
- fix youtube and instagram favicon fetching
|
- fix entry header shortly rendered as mobile on desktop, causing a small visual glitch
|
||||||
- mark as read filter was lost when a feed was rearranged with drag&drop
|
- fix an issue that could cause a feed to not refresh correctly if the url was very long
|
||||||
- feed entry categories are now displayed if available
|
- database cleanup batch size is now configurable
|
||||||
- various performance and dependencies upgrades
|
- css parsing errors are no longer logged to the standard output
|
||||||
- java8 is now required
|
- fix small errors in the api documentation
|
||||||
|
|
||||||
## [2.1.0]
|
## [3.8.1]
|
||||||
|
|
||||||
- dropwizard upgrade to 0.8.0
|
- in expanded mode, don't scroll when clicking on the body of the current entry
|
||||||
- you have to remove the "app.contextPath" setting from your yml file, you can optionally use
|
- improve content cleanup task performance for instances with a very large number of feeds
|
||||||
server.applicationContextPath instead
|
|
||||||
- new setting app.maxFeedCapacity for deleting old entries
|
## [3.8.0]
|
||||||
- ability to set filtering expressions for subscriptions to automatically mark new entries as read based on title,
|
|
||||||
content, author or url.
|
- add previous and next buttons in the toolbar
|
||||||
- ability to use !keyword or -keyword to exclude a keyword from a search query
|
- add a setting to always scroll selected entry to the top of the page, even if it fits entirely on screen
|
||||||
- facebook feeds now show user favicon instead of facebook favicon
|
- clicking on the body of an entry in expanded mode selects it and marks it as read
|
||||||
- new dark theme 'nightsky'
|
- add rich text editor with autocomplete for custom css and js code in settings (desktop only)
|
||||||
|
- dramatically improve performance while scrolling
|
||||||
## [2.0.3]
|
- fix broken welcome page mobile layout
|
||||||
|
- format dates in user locale instead of GMT in relative date popups
|
||||||
- internet explorer ajax cache workaround
|
|
||||||
- categories are now deletable again
|
## [3.7.0]
|
||||||
- openshift support is back
|
|
||||||
- youtube feeds now show user favicon instead of youtube favicon
|
- the sidebar is now resizable
|
||||||
|
- added the "f" keyboard shortcut to hide the sidebar
|
||||||
## [2.0.2]
|
- added tooltips to relative dates with the exact date
|
||||||
|
- add a setting to hide commafeed from search engines (exposes a robots.txt file, enabled by default)
|
||||||
- api using the api key is now working again
|
- the browser extension unread count now updates when articles are marked as read/unread in the app
|
||||||
- context path is now configurable in config.yml (see app.contextPath in config.yml.example)
|
- The "b" keyboard shortcut now works as expected on Chrome but requires the browser extension to be installed
|
||||||
- fix login on firefox when fields are autofilled by the browser
|
- dark mode has been disabled on the api documentation page as it was unreadable
|
||||||
- fix scrolling of subscriptions list on mobile
|
- improvement to the feed refresh queuing logic when "heavy load" mode is enabled
|
||||||
- user is now logged in after registration
|
- fix a bug that could prevent feeds and categories from being edited
|
||||||
- fix link to documentation on home page and about page
|
|
||||||
- fields autocomplete is disabled on the profile page
|
## [3.6.0]
|
||||||
- users are able to delete their account again
|
|
||||||
- chinese and malaysian translation files are now correctly loaded
|
- add a button to open CommaFeed in a new tab and a button to open options when using the browser extension
|
||||||
- software version in user-agent when fetching feeds is no longer hardcoded
|
- clicking on the entry title in expanded mode now opens the link instead of doing nothing
|
||||||
- admin settings page is now read only, settings are configured in config.yml
|
- add tooltips to buttons when the mobile layout is used on desktop
|
||||||
- added link to metrics on the admin settings page
|
- redirect the user to the welcome page if the user was deleted from the database
|
||||||
- Rome (rss library) upgrade to 1.5.0
|
- add link to api documentation on welcome page
|
||||||
|
- the unread count is now correctly updated when using the "/next" bookmarklet while redis cache is enabled
|
||||||
## [2.0.1]
|
|
||||||
|
## [3.5.0]
|
||||||
- the redis pool no longer throws an exception when it is unable to aquire a new connection
|
|
||||||
|
- add compatibility with the new version of the CommaFeed browser extension
|
||||||
## [2.0.0]
|
- disable pull-to-refresh on mobile as it messes with vertical scrolling
|
||||||
|
- add css classes to feed entries to help with custom css rules
|
||||||
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
- api documentation page no longer requires users to be authenticated
|
||||||
consumption and better overall performances.
|
- add a setting to limit the number of feeds a user can subscribe to
|
||||||
See the README on how to build CommaFeed from now on.
|
- add a setting to disable strict password policy
|
||||||
- CommaFeed should no longer fetch the same feed multiple times in a row
|
- add feed refresh engine metrics
|
||||||
- Users can use their username or email to log in
|
- fix redis timeouts
|
||||||
|
|
||||||
|
## [3.4.0]
|
||||||
|
|
||||||
|
- add support for arm64 docker images
|
||||||
|
- add divider to visually separate read-only information from form on the profile settings page
|
||||||
|
- reduce javascript bundle size by 30% by loading only the necessary translations
|
||||||
|
- add a standalone donate page with all ways to support CommaFeed
|
||||||
|
- fix an issue introduced in 3.1.0 that could make CommaFeed not refresh feeds as fast as before on instances with lots
|
||||||
|
of feeds
|
||||||
|
- fix alignment of icon with text for category tree nodes
|
||||||
|
- fix alignment of burger button with the rest of the header on mobile
|
||||||
|
|
||||||
|
## [3.3.2]
|
||||||
|
|
||||||
|
- restore entry selection indicator (left orange border) that was lost with the mantine 6.x upgrade (3.3.0)
|
||||||
|
- add dividers to visually separate read-only information from forms on feed and category details pages
|
||||||
|
- reduced javascript bundle size by 10%
|
||||||
|
|
||||||
|
## [3.3.1]
|
||||||
|
|
||||||
|
- fix long feed names not being shortened to respect tree max width
|
||||||
|
|
||||||
|
## [3.3.0]
|
||||||
|
|
||||||
|
- there are now database changes, rolling back to 2.x will no longer be possible
|
||||||
|
- restore support for user custom CSS rules
|
||||||
|
- add support for user custom JS code that will be executed on page load
|
||||||
|
|
||||||
|
## [3.2.0]
|
||||||
|
|
||||||
|
- restore the welcome page
|
||||||
|
- only apply hover effect for unread entries (same as commafeed v2)
|
||||||
|
- move notifications at the bottom of the screen
|
||||||
|
- always use https for sharing urls
|
||||||
|
- add support for redis ACLs
|
||||||
|
- transition to google analytics v4
|
||||||
|
|
||||||
|
## [3.1.0]
|
||||||
|
|
||||||
|
- add an even more compact layout
|
||||||
|
- restore hover effect from commafeed 2.x
|
||||||
|
- view mode (compact, expanded, ...) is now stored on the device so you can have a different view mode on desktop and
|
||||||
|
mobile
|
||||||
|
- fix for the "Illegal attempt to associate a collection with two open sessions." error
|
||||||
|
- feed fetching workflow is now orchestrated with rxjava, removing a lot of code
|
||||||
|
|
||||||
|
## [3.0.1]
|
||||||
|
|
||||||
|
- allow env variable substitution in config.yml
|
||||||
|
- e.g. having a custom config.yml file with `app.session.path=${SOME_ENV_VAR}` will substitute `SOME_ENV_VAR` with its
|
||||||
|
value
|
||||||
|
- allow env variable prefixed with `CF_` to override config.yml properties
|
||||||
|
- e.g. setting `CF_APP_ALLOWREGISTRATIONS=true` will set `app.allowRegistrations` to `true`
|
||||||
|
|
||||||
|
## [3.0.0]
|
||||||
|
|
||||||
|
- complete overhaul of the UI
|
||||||
|
- backend and frontend are now in separate maven modules
|
||||||
|
- no changes to the api or the database
|
||||||
|
- Docker images are now automatically built and available at https://hub.docker.com/r/athou/commafeed
|
||||||
|
|
||||||
|
## [2.6.0]
|
||||||
|
|
||||||
|
- add support for media content as a backup for missing content (useful for youtube feeds)
|
||||||
|
- correctly follow http error code 308 redirects
|
||||||
|
- fixed a bug that prevented users from deleting their account
|
||||||
|
- fixed a bug that made commafeed store entry contents multiple times
|
||||||
|
- fixed a bug that prevented the app to be used as an installed app on mobile devices if the context path of commafeed
|
||||||
|
was not "/"
|
||||||
|
- fixed a bug that prevented entries from being "marked as read older than xxx" for a feed that was just added
|
||||||
|
- removed support for google+ and readability as those services no longer exist
|
||||||
|
- removed support for deploying on openshift
|
||||||
|
- removed alphabetical sorting of entries because of really poor performance (title cannot be indexed)
|
||||||
|
- improve performance for instances with the heavy load setting enabled by preventing CommaFeed from fetching feeds from
|
||||||
|
users that did not log in for a long time
|
||||||
|
- various dependencies upgrades (notably dropwizard from 1.3 to 2.1)
|
||||||
|
- add support for mariadb
|
||||||
|
- add support for java17+ runtime
|
||||||
|
- various security improvements
|
||||||
|
|
||||||
|
## [2.5.0]
|
||||||
|
|
||||||
|
- unread count is now displayed in a favicon badge when supported
|
||||||
|
- the user agent string for the bot fetching feeds is now configurable
|
||||||
|
- feed parsing performance improvements
|
||||||
|
- support for java9+ runtime
|
||||||
|
- can now properly start from an empty postgresql database
|
||||||
|
|
||||||
|
## [2.4.0]
|
||||||
|
|
||||||
|
- users were not able to change password or delete account
|
||||||
|
- fix api key generation
|
||||||
|
- feed entries can now be sorted alphabetically
|
||||||
|
- fix facebook sharing
|
||||||
|
- fix layout on iOS
|
||||||
|
- postgresql driver update (fix for postgres 9.6)
|
||||||
|
- various internationalization fixes
|
||||||
|
- security fixes
|
||||||
|
|
||||||
|
## [2.3.0]
|
||||||
|
|
||||||
|
- dropwizard upgrade 0.9.1
|
||||||
|
- feed enclosures are hidden if they already displayed in the content
|
||||||
|
- fix youtube favicons
|
||||||
|
- various internationalization fixes
|
||||||
|
|
||||||
|
## [2.2.0]
|
||||||
|
|
||||||
|
- fix youtube and instagram favicon fetching
|
||||||
|
- mark as read filter was lost when a feed was rearranged with drag&drop
|
||||||
|
- feed entry categories are now displayed if available
|
||||||
|
- various performance and dependencies upgrades
|
||||||
|
- java8 is now required
|
||||||
|
|
||||||
|
## [2.1.0]
|
||||||
|
|
||||||
|
- dropwizard upgrade to 0.8.0
|
||||||
|
- you have to remove the "app.contextPath" setting from your yml file, you can optionally use
|
||||||
|
server.applicationContextPath instead
|
||||||
|
- new setting app.maxFeedCapacity for deleting old entries
|
||||||
|
- ability to set filtering expressions for subscriptions to automatically mark new entries as read based on title,
|
||||||
|
content, author or url.
|
||||||
|
- ability to use !keyword or -keyword to exclude a keyword from a search query
|
||||||
|
- facebook feeds now show user favicon instead of facebook favicon
|
||||||
|
- new dark theme 'nightsky'
|
||||||
|
|
||||||
|
## [2.0.3]
|
||||||
|
|
||||||
|
- internet explorer ajax cache workaround
|
||||||
|
- categories are now deletable again
|
||||||
|
- openshift support is back
|
||||||
|
- youtube feeds now show user favicon instead of youtube favicon
|
||||||
|
|
||||||
|
## [2.0.2]
|
||||||
|
|
||||||
|
- api using the api key is now working again
|
||||||
|
- context path is now configurable in config.yml (see app.contextPath in config.yml.example)
|
||||||
|
- fix login on firefox when fields are autofilled by the browser
|
||||||
|
- fix scrolling of subscriptions list on mobile
|
||||||
|
- user is now logged in after registration
|
||||||
|
- fix link to documentation on home page and about page
|
||||||
|
- fields autocomplete is disabled on the profile page
|
||||||
|
- users are able to delete their account again
|
||||||
|
- chinese and malaysian translation files are now correctly loaded
|
||||||
|
- software version in user-agent when fetching feeds is no longer hardcoded
|
||||||
|
- admin settings page is now read only, settings are configured in config.yml
|
||||||
|
- added link to metrics on the admin settings page
|
||||||
|
- Rome (rss library) upgrade to 1.5.0
|
||||||
|
|
||||||
|
## [2.0.1]
|
||||||
|
|
||||||
|
- the redis pool no longer throws an exception when it is unable to aquire a new connection
|
||||||
|
|
||||||
|
## [2.0.0]
|
||||||
|
|
||||||
|
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
||||||
|
consumption and better overall performances.
|
||||||
|
See the README on how to build CommaFeed from now on.
|
||||||
|
- CommaFeed should no longer fetch the same feed multiple times in a row
|
||||||
|
- Users can use their username or email to log in
|
||||||
|
|||||||
60
LICENSE
60
LICENSE
@@ -1,31 +1,31 @@
|
|||||||
Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/
|
Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
1. Definitions.
|
1. Definitions.
|
||||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||||
2. Grant of Copyright License.
|
2. Grant of Copyright License.
|
||||||
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||||
3. Grant of Patent License.
|
3. Grant of Patent License.
|
||||||
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||||
4. Redistribution.
|
4. Redistribution.
|
||||||
You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||||
You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||||
5. Submission of Contributions.
|
5. Submission of Contributions.
|
||||||
Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||||
6. Trademarks.
|
6. Trademarks.
|
||||||
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||||
7. Disclaimer of Warranty.
|
7. Disclaimer of Warranty.
|
||||||
Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||||
8. Limitation of Liability.
|
8. Limitation of Liability.
|
||||||
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||||
9. Accepting Warranty or Additional Liability.
|
9. Accepting Warranty or Additional Liability.
|
||||||
While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||||
END OF TERMS AND CONDITIONS
|
END OF TERMS AND CONDITIONS
|
||||||
45
README.md
45
README.md
@@ -17,6 +17,8 @@ Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeSc
|
|||||||
- REST API
|
- REST API
|
||||||
- Fever-compatible API for native mobile apps
|
- Fever-compatible 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
|
||||||
|
|
||||||
@@ -48,17 +57,17 @@ system and database of choice.
|
|||||||
|
|
||||||
There are two types of packages:
|
There are two types of packages:
|
||||||
|
|
||||||
- The `linux-x86_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run
|
- The `linux-x86_64`, `linux-aarch_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run
|
||||||
directly.
|
directly.
|
||||||
- The `jvm` package is a zip file containing all `.jar` files required to run the application. This package works on all
|
- The `jvm` package is a zip file containing all `.jar` files required to run the application. This package works on all
|
||||||
platforms and is started with `java -jar quarkus-run.jar`.
|
platforms but requires a JRE and is started with `java -jar quarkus-run.jar`.
|
||||||
|
|
||||||
If available for your operating system, the native package is recommended because it has a faster startup time and lower
|
If available for your operating system, the native package is recommended because it has a faster startup time and lower
|
||||||
memory usage.
|
memory usage.
|
||||||
|
|
||||||
### Build from sources
|
### Build from sources
|
||||||
|
|
||||||
./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 GraalVM to be installed (`GRAALVM_HOME` environment
|
||||||
@@ -73,6 +82,10 @@ When the build is complete:
|
|||||||
- if you used the native profile, the executable is located at
|
- if you used the native profile, the executable is located at
|
||||||
`commafeed-server/target/commafeed-<version>-<database>-<platform>-<arch>-runner[.exe]`
|
`commafeed-server/target/commafeed-<version>-<database>-<platform>-<arch>-runner[.exe]`
|
||||||
|
|
||||||
|
### Distribution packages
|
||||||
|
|
||||||
|
- Arch Linux users can use [the CommaFeed package on AUR](https://aur.archlinux.org/pkgbase/commafeed), which builds native binaries with GraalVM for all supported databases.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
CommaFeed doesn't require any configuration to run with its embedded database (H2). The database file will be stored in
|
CommaFeed doesn't require any configuration to run with its embedded database (H2). The database file will be stored in
|
||||||
@@ -94,13 +107,13 @@ There are multiple ways to configure CommaFeed:
|
|||||||
|
|
||||||
- a `config/application.properties` [properties](https://en.wikipedia.org/wiki/.properties) file relative to the working
|
- a `config/application.properties` [properties](https://en.wikipedia.org/wiki/.properties) file relative to the working
|
||||||
directory (keys in kebab-case)
|
directory (keys in kebab-case)
|
||||||
- Command line arguments prefixed with `-D` (keys in kebab-case)
|
- Command line arguments each prefixed with `-D` (keys in kebab-case)
|
||||||
- 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](commafeed-server/doc/commafeed.adoc) are optional and have sensible default values.
|
All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are optional and have sensible default values.
|
||||||
|
|
||||||
When logging in, credentials are stored in an encrypted cookie. The encryption key is randomly generated at startup,
|
When logging in, credentials are stored in an encrypted cookie. The encryption key is randomly generated at startup,
|
||||||
meaning that you will have to log back in after each restart of the application. To prevent this, you can set the
|
meaning that you will have to log back in after each restart of the application. To prevent this, you can set the
|
||||||
@@ -108,20 +121,23 @@ 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`.
|
|
||||||
|
|
||||||
### Memory management (`jvm` package only)
|
### Updates
|
||||||
|
|
||||||
|
When CommaFeed is up and running, you can subscribe to [this feed](https://github.com/Athou/commafeed/releases.atom) to be notified of new releases.
|
||||||
|
|
||||||
|
### Memory management
|
||||||
|
|
||||||
The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the
|
The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the
|
||||||
operating system. This is because acquiring memory from the operating system is a relatively expensive operation.
|
operating system. This is because acquiring memory from the operating system is a relatively expensive operation.
|
||||||
This can be problematic on systems with limited memory.
|
This can be problematic on systems with limited memory.
|
||||||
|
|
||||||
#### Hard limit
|
#### Hard limit (`native` and `jvm` packages)
|
||||||
|
|
||||||
The JVM can be configured to use a maximum amount of memory with the `-Xmx` parameter.
|
The JVM can be configured to use a maximum amount of memory with the `-Xmx` parameter.
|
||||||
For example, to limit the JVM to 256MB of memory, use `-Xmx256m`.
|
For example, to limit the JVM to 256MB of memory, use `-Xmx256m`.
|
||||||
|
|
||||||
#### Dynamic sizing
|
#### Dynamic sizing (`jvm` package)
|
||||||
|
|
||||||
In addition to the previous setting, the JVM can be configured to release unused memory to the operating system with the
|
In addition to the previous setting, the JVM can be configured to release unused memory to the operating system with the
|
||||||
following parameters:
|
following parameters:
|
||||||
@@ -133,14 +149,15 @@ and [here](https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-
|
|||||||
more
|
more
|
||||||
information.
|
information.
|
||||||
|
|
||||||
#### OpenJ9
|
#### OpenJ9 (`jvm` package)
|
||||||
|
|
||||||
The [OpenJ9](https://eclipse.dev/openj9/) JVM is a more memory-efficient alternative to the HotSpot JVM, at the cost of
|
The [OpenJ9](https://eclipse.dev/openj9/) JVM is a more memory-efficient alternative to the HotSpot JVM, at the cost of
|
||||||
slightly slower throughput.
|
slightly slower throughput.
|
||||||
|
|
||||||
IBM provides precompiled binaries for OpenJ9
|
IBM provides precompiled binaries for OpenJ9
|
||||||
named [Semeru](https://developer.ibm.com/languages/java/semeru-runtimes/downloads/).
|
named [Semeru](https://developer.ibm.com/languages/java/semeru-runtimes/downloads/).
|
||||||
This is the JVM used in the [Docker image](https://github.com/Athou/commafeed/blob/master/Dockerfile).
|
This is the JVM used in
|
||||||
|
the [Docker image](https://github.com/Athou/commafeed/blob/master/commafeed-server/src/main/docker/Dockerfile.jvm).
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
|
|||||||
@@ -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/.husky/pre-commit
Normal file
1
commafeed-client/.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
cd commafeed-client && npx lint-staged
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": ["dist", "node_modules", "target", "target-ide"]
|
"includes": ["**", "!**/dist", "!**/node_modules", "!**/target", "!**/target-ide"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<title>CommaFeed</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
|
||||||
|
<title>CommaFeed</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
3
commafeed-client/lint-staged.config.js
Normal file
3
commafeed-client/lint-staged.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
"src/**/*.{js,jsx,ts,tsx}": () => ["npm run i18n:extract", "git diff --exit-code commafeed-client/src/locales/en/messages.po"],
|
||||||
|
}
|
||||||
9348
commafeed-client/package-lock.json
generated
9348
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,76 +4,83 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite",
|
||||||
"dev:typescript": "tsc --watch",
|
"dev:host": "vite --host",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ci": "vitest run",
|
"test:ci": "vitest run",
|
||||||
"lint": "biome check ./src",
|
"lint": "biome check",
|
||||||
"lint:fix": "biome check --write ./src",
|
"lint:fix": "biome check --write",
|
||||||
"i18n:extract": "lingui extract --clean"
|
"i18n:extract": "lingui extract --clean",
|
||||||
|
"prepare": "cd .. && husky ./commafeed-client/.husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@fontsource/open-sans": "^5.0.29",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@lingui/core": "^4.11.3",
|
"@lingui/core": "^5.9.3",
|
||||||
"@lingui/macro": "^4.11.3",
|
"@lingui/react": "^5.9.3",
|
||||||
"@lingui/react": "^4.11.3",
|
"@mantine/core": "^8.3.16",
|
||||||
"@mantine/core": "^7.12.1",
|
"@mantine/form": "^8.3.16",
|
||||||
"@mantine/form": "^7.12.1",
|
"@mantine/hooks": "^8.3.16",
|
||||||
"@mantine/hooks": "^7.12.1",
|
"@mantine/modals": "^8.3.16",
|
||||||
"@mantine/modals": "^7.12.1",
|
"@mantine/notifications": "^8.3.16",
|
||||||
"@mantine/notifications": "^7.12.1",
|
"@mantine/spotlight": "^8.3.16",
|
||||||
"@mantine/spotlight": "^7.12.1",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@react-querybuilder/mantine": "^8.14.0",
|
||||||
"@reduxjs/toolkit": "^2.2.7",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"axios": "^1.7.4",
|
"@rolldown/plugin-babel": "^0.2.2",
|
||||||
"dayjs": "^1.11.12",
|
"axios": "^1.13.6",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
"interweave": "^13.1.0",
|
"interweave": "^13.1.1",
|
||||||
"monaco-editor": "^0.50.0",
|
"monaco-editor": "^0.55.1",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.4",
|
||||||
"react-async-hook": "^4.0.0",
|
"react-async-hook": "^4.0.0",
|
||||||
"react-contexify": "^6.0.0",
|
"react-contexify": "^6.0.0",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-dom": "^19.2.4",
|
||||||
"react-dom": "^18.3.1",
|
"react-draggable": "^4.5.0",
|
||||||
"react-draggable": "^4.4.6",
|
"react-icons": "^5.6.0",
|
||||||
"react-ga4": "^2.1.0",
|
|
||||||
"react-helmet": "^6.1.0",
|
|
||||||
"react-icons": "^5.3.0",
|
|
||||||
"react-infinite-scroller": "^1.2.6",
|
"react-infinite-scroller": "^1.2.6",
|
||||||
"react-redux": "^9.1.2",
|
"react-querybuilder": "^8.14.0",
|
||||||
"react-router-dom": "^6.26.1",
|
"react-redux": "^9.2.0",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"redoc": "^2.1.5",
|
"react-swipeable": "^7.0.2",
|
||||||
|
"style-to-object": "^1.0.14",
|
||||||
"throttle-debounce": "^5.0.2",
|
"throttle-debounce": "^5.0.2",
|
||||||
"tinycon": "^0.6.8",
|
"tinycon": "^0.6.8",
|
||||||
"tss-react": "^4.9.12",
|
"tss-react": "^4.9.20",
|
||||||
"use-local-storage": "^3.0.0",
|
|
||||||
"vite-plugin-biome": "^1.0.12",
|
|
||||||
"websocket-heartbeat-js": "^1.1.3"
|
"websocket-heartbeat-js": "^1.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.8.3",
|
"@biomejs/biome": "^2.4.7",
|
||||||
"@lingui/cli": "^4.11.3",
|
"@lingui/babel-plugin-lingui-macro": "^5.9.3",
|
||||||
"@lingui/vite-plugin": "^4.11.3",
|
"@lingui/cli": "^5.9.3",
|
||||||
|
"@lingui/vite-plugin": "^5.9.3",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/mousetrap": "^1.6.15",
|
"@types/mousetrap": "^1.6.15",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-helmet": "^6.1.11",
|
|
||||||
"@types/react-infinite-scroller": "^1.2.5",
|
"@types/react-infinite-scroller": "^1.2.5",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"@types/tinycon": "^0.6.5",
|
"@types/tinycon": "^0.6.7",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"rollup-plugin-visualizer": "^5.12.0",
|
"husky": "^9.1.7",
|
||||||
"typescript": "^5.5.4",
|
"jsdom": "^29.0.0",
|
||||||
"vite": "^5.4.1",
|
"lint-staged": "^16.4.0",
|
||||||
"vite-tsconfig-paths": "^5.0.1",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^2.0.5",
|
"vite": "^8.0.0",
|
||||||
"vitest-mock-extended": "^2.0.0"
|
"vite-plugin-checker": "^0.12.0",
|
||||||
|
"vitest": "^4.1.0",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"react-infinite-scroller": {
|
||||||
|
"react": "^19.2.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,142 @@
|
|||||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>com.commafeed</groupId>
|
<groupId>com.commafeed</groupId>
|
||||||
<artifactId>commafeed</artifactId>
|
<artifactId>commafeed</artifactId>
|
||||||
<version>5.0.2</version>
|
<version>7.0.0</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>v20.16.0</node.version>
|
<node.version>v24.14.0</node.version>
|
||||||
<!-- renovate: datasource=npm depName=npm -->
|
<!-- renovate: datasource=npm depName=npm -->
|
||||||
<npm.version>10.8.2</npm.version>
|
<npm.version>11.11.1</npm.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
<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.0</version>
|
<version>2.0.0</version>
|
||||||
<?m2e ignore?>
|
<?m2e ignore?>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<id>install node and npm</id>
|
<id>install node and npm</id>
|
||||||
<goals>
|
<goals>
|
||||||
<goal>install-node-and-npm</goal>
|
<goal>install-node-and-npm</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<phase>compile</phase>
|
<phase>compile</phase>
|
||||||
<configuration>
|
<configuration>
|
||||||
<nodeVersion>${node.version}</nodeVersion>
|
<nodeVersion>${node.version}</nodeVersion>
|
||||||
<npmVersion>${npm.version}</npmVersion>
|
<npmVersion>${npm.version}</npmVersion>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution>
|
<execution>
|
||||||
<id>npm install</id>
|
<id>npm install</id>
|
||||||
<goals>
|
<goals>
|
||||||
<goal>npm</goal>
|
<goal>npm</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<phase>compile</phase>
|
<phase>compile</phase>
|
||||||
<configuration>
|
<configuration>
|
||||||
<arguments>ci</arguments>
|
<arguments>ci</arguments>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution>
|
<execution>
|
||||||
<id>npm run test</id>
|
<id>npm run test</id>
|
||||||
<goals>
|
<goals>
|
||||||
<goal>npm</goal>
|
<goal>npm</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<phase>compile</phase>
|
<phase>compile</phase>
|
||||||
<configuration>
|
<configuration>
|
||||||
<arguments>run test:ci</arguments>
|
<arguments>run test:ci</arguments>
|
||||||
</configuration>
|
<skip>${skipTests}</skip>
|
||||||
</execution>
|
</configuration>
|
||||||
<execution>
|
</execution>
|
||||||
<id>npm run build</id>
|
<execution>
|
||||||
<goals>
|
<id>npm run build</id>
|
||||||
<goal>npm</goal>
|
<goals>
|
||||||
</goals>
|
<goal>npm</goal>
|
||||||
<phase>compile</phase>
|
</goals>
|
||||||
<configuration>
|
<phase>compile</phase>
|
||||||
<arguments>run build</arguments>
|
<configuration>
|
||||||
</configuration>
|
<arguments>run build</arguments>
|
||||||
</execution>
|
</configuration>
|
||||||
</executions>
|
</execution>
|
||||||
</plugin>
|
</executions>
|
||||||
<plugin>
|
</plugin>
|
||||||
<artifactId>maven-resources-plugin</artifactId>
|
<plugin>
|
||||||
<version>3.3.1</version>
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
<executions>
|
<version>3.5.0</version>
|
||||||
<execution>
|
<executions>
|
||||||
<id>copy web interface to resources</id>
|
<execution>
|
||||||
<phase>prepare-package</phase>
|
<id>copy web interface to resources</id>
|
||||||
<goals>
|
<phase>prepare-package</phase>
|
||||||
<goal>copy-resources</goal>
|
<goals>
|
||||||
</goals>
|
<goal>copy-resources</goal>
|
||||||
<configuration>
|
</goals>
|
||||||
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
|
<configuration>
|
||||||
<resources>
|
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
|
||||||
<resource>
|
<resources>
|
||||||
<directory>dist</directory>
|
<resource>
|
||||||
<filtering>false</filtering>
|
<directory>dist</directory>
|
||||||
</resource>
|
<filtering>false</filtering>
|
||||||
</resources>
|
</resource>
|
||||||
</configuration>
|
</resources>
|
||||||
</execution>
|
</configuration>
|
||||||
</executions>
|
</execution>
|
||||||
</plugin>
|
</executions>
|
||||||
</plugins>
|
</plugin>
|
||||||
</build>
|
</plugins>
|
||||||
|
</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.3</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>
|
||||||
62
commafeed-client/public/favicon.svg
Normal file
62
commafeed-client/public/favicon.svg
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?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"
|
||||||
|
sodipodi:docname="favicon.svg"
|
||||||
|
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs3" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.21875"
|
||||||
|
inkscape:cx="207.17949"
|
||||||
|
inkscape:cy="187.07692"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="855"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg3" />
|
||||||
|
<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.9 KiB |
@@ -3,46 +3,53 @@ import { I18nProvider } from "@lingui/react"
|
|||||||
import { MantineProvider } from "@mantine/core"
|
import { MantineProvider } 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, useState } 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 { categoryUnreadCount } from "app/utils"
|
|
||||||
import { DisablePullToRefresh } from "components/DisablePullToRefresh"
|
|
||||||
import { ErrorBoundary } from "components/ErrorBoundary"
|
|
||||||
import { Header } from "components/header/Header"
|
|
||||||
import { Tree } from "components/sidebar/Tree"
|
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
|
||||||
import { useI18n } from "i18n"
|
|
||||||
import { WelcomePage } from "pages/WelcomePage"
|
|
||||||
import { AdminUsersPage } from "pages/admin/AdminUsersPage"
|
|
||||||
import { MetricsPage } from "pages/admin/MetricsPage"
|
|
||||||
import { AboutPage } from "pages/app/AboutPage"
|
|
||||||
import { AddPage } from "pages/app/AddPage"
|
|
||||||
import { CategoryDetailsPage } from "pages/app/CategoryDetailsPage"
|
|
||||||
import { DonatePage } from "pages/app/DonatePage"
|
|
||||||
import { FeedDetailsPage } from "pages/app/FeedDetailsPage"
|
|
||||||
import { FeedEntriesPage } from "pages/app/FeedEntriesPage"
|
|
||||||
import Layout from "pages/app/Layout"
|
|
||||||
import { SettingsPage } from "pages/app/SettingsPage"
|
|
||||||
import { TagDetailsPage } from "pages/app/TagDetailsPage"
|
|
||||||
import { LoginPage } from "pages/auth/LoginPage"
|
|
||||||
import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage"
|
|
||||||
import { RegistrationPage } from "pages/auth/RegistrationPage"
|
|
||||||
import React, { useEffect } from "react"
|
|
||||||
import { isSafari } from "react-device-detect"
|
|
||||||
import ReactGA from "react-ga4"
|
|
||||||
import { Helmet } from "react-helmet"
|
|
||||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
|
|
||||||
import Tinycon from "tinycon"
|
import Tinycon from "tinycon"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { redirectTo } from "@/app/redirect/slice"
|
||||||
|
import { redirectToInitialSetup } from "@/app/redirect/thunks"
|
||||||
|
import { reloadServerInfos } from "@/app/server/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { categoryUnreadCount } from "@/app/utils"
|
||||||
|
import { DisablePullToRefresh } from "@/components/DisablePullToRefresh"
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary"
|
||||||
|
import { Header } from "@/components/header/Header"
|
||||||
|
import { Tree } from "@/components/sidebar/Tree"
|
||||||
|
import { useAppLoading } from "@/hooks/useAppLoading"
|
||||||
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
|
import { useI18n } from "@/i18n"
|
||||||
|
import { AdminUsersPage } from "@/pages/admin/AdminUsersPage"
|
||||||
|
import { MetricsPage } from "@/pages/admin/MetricsPage"
|
||||||
|
import { AboutPage } from "@/pages/app/AboutPage"
|
||||||
|
import { AddPage } from "@/pages/app/AddPage"
|
||||||
|
import { CategoryDetailsPage } from "@/pages/app/CategoryDetailsPage"
|
||||||
|
import { DonatePage } from "@/pages/app/DonatePage"
|
||||||
|
import { FeedDetailsPage } from "@/pages/app/FeedDetailsPage"
|
||||||
|
import { FeedEntriesPage } from "@/pages/app/FeedEntriesPage"
|
||||||
|
import Layout from "@/pages/app/Layout"
|
||||||
|
import { SettingsPage } from "@/pages/app/SettingsPage"
|
||||||
|
import { TagDetailsPage } from "@/pages/app/TagDetailsPage"
|
||||||
|
import { InitialSetupPage } from "@/pages/auth/InitialSetupPage"
|
||||||
|
import { LoginPage } from "@/pages/auth/LoginPage"
|
||||||
|
import { PasswordRecoveryPage } from "@/pages/auth/PasswordRecoveryPage"
|
||||||
|
import { PasswordResetPage } from "@/pages/auth/PasswordResetPage"
|
||||||
|
import { RegistrationPage } from "@/pages/auth/RegistrationPage"
|
||||||
|
import { WelcomePage } from "@/pages/WelcomePage"
|
||||||
|
|
||||||
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"
|
||||||
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 +78,6 @@ function Providers(props: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// swagger-ui 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,10 +85,11 @@ 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" />} />
|
||||||
@@ -112,6 +117,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,31 +144,26 @@ function RedirectHandler() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function GoogleAnalyticsHandler() {
|
function UnreadCountTitleHandler({
|
||||||
const location = useLocation()
|
enabled,
|
||||||
const googleAnalyticsCode = useAppSelector(state => state.server.serverInfos?.googleAnalyticsCode)
|
}: Readonly<{
|
||||||
|
enabled?: boolean
|
||||||
useEffect(() => {
|
}>) {
|
||||||
if (googleAnalyticsCode) ReactGA.initialize(googleAnalyticsCode)
|
const root = useAppSelector(state => state.tree.rootCategory)
|
||||||
}, [googleAnalyticsCode])
|
const unreadCount = categoryUnreadCount(root)
|
||||||
|
return <title>{enabled && unreadCount > 0 ? `(${unreadCount}) CommaFeed` : "CommaFeed"}</title>
|
||||||
useEffect(() => {
|
|
||||||
if (ReactGA.isInitialized) ReactGA.send({ hitType: "pageview", page: location.pathname })
|
|
||||||
}, [location])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FaviconHandler() {
|
function UnreadCountFaviconHandler({ enabled }: { enabled?: boolean }) {
|
||||||
const root = useAppSelector(state => state.tree.rootCategory)
|
const root = useAppSelector(state => state.tree.rootCategory)
|
||||||
|
const unreadCount = categoryUnreadCount(root)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unreadCount = categoryUnreadCount(root)
|
if (enabled && unreadCount > 0) {
|
||||||
if (unreadCount === 0) {
|
|
||||||
Tinycon.reset()
|
|
||||||
} else {
|
|
||||||
Tinycon.setBubble(unreadCount)
|
Tinycon.setBubble(unreadCount)
|
||||||
|
} else {
|
||||||
|
Tinycon.reset()
|
||||||
}
|
}
|
||||||
}, [root])
|
}, [unreadCount, enabled])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -168,17 +180,47 @@ function BrowserExtensionBadgeUnreadCountHandler() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomCode() {
|
function CustomJsHandler() {
|
||||||
return (
|
const [scriptLoaded, setScriptLoaded] = useState(false)
|
||||||
<Helmet>
|
const { loading } = useAppLoading()
|
||||||
<link rel="stylesheet" type="text/css" href="custom_css.css" />
|
|
||||||
<script type="text/javascript" src="custom_js.js" />
|
useEffect(() => {
|
||||||
</Helmet>
|
if (scriptLoaded || loading) {
|
||||||
)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script")
|
||||||
|
script.src = "custom_js.js"
|
||||||
|
script.async = true
|
||||||
|
document.body.appendChild(script)
|
||||||
|
|
||||||
|
setScriptLoaded(true)
|
||||||
|
|
||||||
|
return () => script.remove()
|
||||||
|
}, [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 () => link.remove()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
useI18n()
|
useI18n()
|
||||||
|
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
|
||||||
|
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
|
||||||
|
const disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -187,21 +229,18 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<>
|
<UnreadCountTitleHandler enabled={unreadCountTitle} />
|
||||||
<FaviconHandler />
|
<UnreadCountFaviconHandler enabled={unreadCountFavicon} />
|
||||||
<BrowserExtensionBadgeUnreadCountHandler />
|
<BrowserExtensionBadgeUnreadCountHandler />
|
||||||
<HashRouter>
|
<CustomJsHandler />
|
||||||
<GoogleAnalyticsHandler />
|
<CustomCssHandler />
|
||||||
<RedirectHandler />
|
<DisablePullToRefresh enabled={disablePullToRefresh} />
|
||||||
<AppRoutes />
|
|
||||||
<CustomCode />
|
<HashRouter>
|
||||||
{/* disable pull-to-refresh as it messes with vertical scrolling
|
<InitialSetupHandler />
|
||||||
safari behaves weirdly when overscroll-behavior is set to none so we disable it only for other browsers
|
<RedirectHandler />
|
||||||
https://github.com/Athou/commafeed/issues/1168
|
<AppRoutes />
|
||||||
*/}
|
</HashRouter>
|
||||||
{!isSafari && <DisablePullToRefresh />}
|
|
||||||
</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
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import type {
|
|||||||
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,
|
||||||
@@ -32,16 +35,17 @@ 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 = {
|
||||||
@@ -93,9 +97,13 @@ 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 +113,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"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { t } from "@lingui/macro"
|
|
||||||
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, SiPocket, SiTumblr, SiX } from "react-icons/si"
|
||||||
import type { Category, Entry, SharingSettings } from "./types"
|
import type { Category, Entry, SharingSettings } from "./types"
|
||||||
|
|
||||||
const categories: Record<string, Category> = {
|
const categories: Record<string, Omit<Category, "name">> = {
|
||||||
all: {
|
all: {
|
||||||
id: "all",
|
id: "all",
|
||||||
name: t`All`,
|
|
||||||
expanded: false,
|
expanded: false,
|
||||||
children: [],
|
children: [],
|
||||||
feeds: [],
|
feeds: [],
|
||||||
@@ -15,7 +13,6 @@ const categories: Record<string, Category> = {
|
|||||||
},
|
},
|
||||||
starred: {
|
starred: {
|
||||||
id: "starred",
|
id: "starred",
|
||||||
name: t`Starred`,
|
|
||||||
expanded: false,
|
expanded: false,
|
||||||
children: [],
|
children: [],
|
||||||
feeds: [],
|
feeds: [],
|
||||||
@@ -90,23 +87,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,25 +1,20 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit"
|
import { configureStore } from "@reduxjs/toolkit"
|
||||||
import type { 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 { any, mockReset } from "vitest-mock-extended"
|
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"
|
||||||
|
|
||||||
const mockClient = await vi.hoisted(async () => {
|
vi.mock(import("@/app/client"))
|
||||||
const mockModule = await import("vitest-mock-extended")
|
|
||||||
return mockModule.mockDeep<typeof client>()
|
|
||||||
})
|
|
||||||
vi.mock("app/client", () => ({ client: mockClient }))
|
|
||||||
|
|
||||||
describe("entries", () => {
|
describe("entries", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockReset(mockClient)
|
vi.resetAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("loads entries", async () => {
|
it("loads entries", async () => {
|
||||||
mockClient.feed.getEntries.calledWith(any()).mockResolvedValue({
|
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
entries: [{ id: "3" } as Entry],
|
entries: [{ id: "3" } as Entry],
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
@@ -32,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")
|
||||||
@@ -53,7 +53,7 @@ describe("entries", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("loads more entries", async () => {
|
it("loads more entries", async () => {
|
||||||
mockClient.category.getEntries.calledWith(any()).mockResolvedValue({
|
vi.mocked(client.category.getEntries).mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
entries: [{ id: "4" } as Entry],
|
entries: [{ id: "4" } as Entry],
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
@@ -113,7 +113,7 @@ describe("entries", () => {
|
|||||||
{ id: "3", read: true },
|
{ id: "3", read: true },
|
||||||
{ id: "4", read: false },
|
{ id: "4", read: false },
|
||||||
])
|
])
|
||||||
expect(mockClient.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
expect(client.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it("marks all entries as read", () => {
|
it("marks all entries as read", () => {
|
||||||
@@ -135,11 +135,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(mockClient.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true })
|
expect(client.category.markEntries).toHaveBeenCalledWith({
|
||||||
|
id: "all",
|
||||||
|
read: true,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
import { createSlice, 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 { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||||
import type { Entry } from "app/types"
|
import type { Entry } from "@/app/types"
|
||||||
|
|
||||||
export type EntrySourceType = "category" | "feed" | "tag"
|
export type EntrySourceType = "category" | "feed" | "tag"
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ interface EntriesState {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
search?: string
|
search?: string
|
||||||
scrollingToEntry: boolean
|
scrollingToEntry: boolean
|
||||||
|
markAllAsReadConfirmationDialogOpen: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: EntriesState = {
|
const initialState: EntriesState = {
|
||||||
@@ -41,6 +42,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,6 +63,9 @@ 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) => {
|
||||||
@@ -119,4 +124,4 @@ export const entriesSlice = createSlice({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setSearch } = entriesSlice.actions
|
export const { setSearch, setMarkAllAsReadConfirmationDialogOpen } = entriesSlice.actions
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
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
|
||||||
|
|
||||||
export const loadEntries = createAppAsyncThunk(
|
export const loadEntries = createAppAsyncThunk(
|
||||||
"entries/load",
|
"entries/load",
|
||||||
async (
|
async (
|
||||||
@@ -28,15 +35,19 @@ export const loadEntries = createAppAsyncThunk(
|
|||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
|
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
|
||||||
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" || (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
|
||||||
})
|
})
|
||||||
|
|
||||||
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
||||||
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
||||||
order: state.user.settings?.readingOrder,
|
order: state.user.settings?.readingOrder,
|
||||||
@@ -46,15 +57,18 @@ const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource,
|
|||||||
tag: source.type === "tag" ? source.id : undefined,
|
tag: source.type === "tag" ? source.id : undefined,
|
||||||
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 }))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => {
|
export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => {
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
thunkApi.dispatch(setSearch(arg))
|
thunkApi.dispatch(setSearch(arg))
|
||||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const markEntry = createAppAsyncThunk(
|
export const markEntry = createAppAsyncThunk(
|
||||||
"entries/entry/mark",
|
"entries/entry/mark",
|
||||||
(arg: { entry: Entry; read: boolean }) => {
|
(arg: { entry: Entry; read: boolean }) => {
|
||||||
@@ -67,6 +81,7 @@ export const markEntry = createAppAsyncThunk(
|
|||||||
condition: arg => arg.entry.markable && arg.entry.read !== arg.read,
|
condition: arg => arg.entry.markable && arg.entry.read !== arg.read,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const markMultipleEntries = createAppAsyncThunk(
|
export const markMultipleEntries = createAppAsyncThunk(
|
||||||
"entries/entry/markMultiple",
|
"entries/entry/markMultiple",
|
||||||
async (
|
async (
|
||||||
@@ -84,6 +99,7 @@ export const markMultipleEntries = createAppAsyncThunk(
|
|||||||
thunkApi.dispatch(reloadTree())
|
thunkApi.dispatch(reloadTree())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => {
|
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => {
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const { entries } = state.entries
|
const { entries } = state.entries
|
||||||
@@ -98,6 +114,7 @@ export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const markAllEntries = createAppAsyncThunk(
|
export const markAllEntries = createAppAsyncThunk(
|
||||||
"entries/entry/markAll",
|
"entries/entry/markAll",
|
||||||
async (
|
async (
|
||||||
@@ -113,6 +130,37 @@ export const markAllEntries = createAppAsyncThunk(
|
|||||||
thunkApi.dispatch(reloadTree())
|
thunkApi.dispatch(reloadTree())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
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 }) => {
|
||||||
@@ -126,6 +174,7 @@ export const starEntry = createAppAsyncThunk(
|
|||||||
condition: arg => arg.entry.markable && arg.entry.starred !== arg.starred,
|
condition: arg => arg.entry.markable && arg.entry.starred !== arg.starred,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const selectEntry = createAppAsyncThunk(
|
export const selectEntry = createAppAsyncThunk(
|
||||||
"entries/entry/select",
|
"entries/entry/select",
|
||||||
(
|
(
|
||||||
@@ -166,22 +215,35 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (arg.scrollToEntry) {
|
if (arg.scrollToEntry) {
|
||||||
|
const viewMode = state.user.localSettings.viewMode
|
||||||
|
|
||||||
|
const entryIndex = state.entries.entries.indexOf(entry)
|
||||||
|
const entriesToKeepOnTopWhenScrolling =
|
||||||
|
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
||||||
|
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
||||||
|
|
||||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||||
if (entryElement) {
|
const entryElementToScrollTo = document.getElementById(Constants.dom.entryId(entryToScrollTo))
|
||||||
|
if (entryElement && entryElementToScrollTo) {
|
||||||
const scrollMode = state.user.settings?.scrollMode
|
const scrollMode = state.user.settings?.scrollMode
|
||||||
const entryEntirelyVisible = Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement)
|
const entryEntirelyVisible =
|
||||||
|
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
|
||||||
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
|
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
|
||||||
scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false)))
|
scrollToEntry(entryElementToScrollTo, margin, scrollSpeed, () =>
|
||||||
|
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
|
||||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
||||||
const offset = (header?.bottom ?? 0) + 3
|
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||||
|
const offset = (header?.bottom ?? 0) + margin
|
||||||
scrollToWithCallback({
|
scrollToWithCallback({
|
||||||
options: {
|
options: {
|
||||||
top: entryElement.offsetTop - offset,
|
top: entryElement.offsetTop - offset,
|
||||||
@@ -216,9 +278,10 @@ export const selectPreviousEntry = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const selectNextEntry = createAppAsyncThunk(
|
export const selectNextEntry = createAppAsyncThunk(
|
||||||
"entries/entry/selectNext",
|
"entries/entry/selectNext",
|
||||||
(
|
async (
|
||||||
arg: {
|
arg: {
|
||||||
expand: boolean
|
expand: boolean
|
||||||
markAsRead: boolean
|
markAsRead: boolean
|
||||||
@@ -227,12 +290,20 @@ export const selectNextEntry = createAppAsyncThunk(
|
|||||||
thunkApi
|
thunkApi
|
||||||
) => {
|
) => {
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const { entries } = state.entries
|
const { entries, hasMore, loading } = state.entries
|
||||||
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
|
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
|
||||||
if (nextIndex < entries.length) {
|
|
||||||
|
// load more entries if needed
|
||||||
|
// this can happen if the last entry is too large to fit on the screen and the infinite loader doesn't trigger
|
||||||
|
if (nextIndex >= entries.length && hasMore && !loading) {
|
||||||
|
await thunkApi.dispatch(loadMoreEntries())
|
||||||
|
}
|
||||||
|
|
||||||
|
const entriesAfterLoading = thunkApi.getState().entries.entries
|
||||||
|
if (nextIndex < entriesAfterLoading.length) {
|
||||||
thunkApi.dispatch(
|
thunkApi.dispatch(
|
||||||
selectEntry({
|
selectEntry({
|
||||||
entry: entries[nextIndex],
|
entry: entriesAfterLoading[nextIndex],
|
||||||
expand: arg.expand,
|
expand: arg.expand,
|
||||||
markAsRead: arg.markAsRead,
|
markAsRead: arg.markAsRead,
|
||||||
scrollToEntry: arg.scrollToEntry,
|
scrollToEntry: arg.scrollToEntry,
|
||||||
@@ -241,6 +312,7 @@ export const selectNextEntry = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
|
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
|
||||||
await client.entry.tag(arg)
|
await client.entry.tag(arg)
|
||||||
thunkApi.dispatch(reloadTags())
|
thunkApi.dispatch(reloadTags())
|
||||||
|
|||||||
@@ -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,45 +1,61 @@
|
|||||||
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 redirectToPasswordRecovery = createAppAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
|
|
||||||
thunkApi.dispatch(redirectTo("/passwordRecovery"))
|
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) => thunkApi.dispatch(redirectTo("/setup")))
|
||||||
)
|
|
||||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
|
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
|
||||||
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToRootCategory = createAppAsyncThunk(
|
export const redirectToRootCategory = createAppAsyncThunk(
|
||||||
"redirect/category/root",
|
"redirect/category/root",
|
||||||
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
||||||
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"))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
|
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
|
||||||
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,4 @@
|
|||||||
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,10 +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 { userSlice } from "app/user/slice"
|
import { treeSlice } from "@/app/tree/slice"
|
||||||
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
import type { LocalSettings } from "@/app/types"
|
||||||
|
import { initialLocalSettings, userSlice } from "@/app/user/slice"
|
||||||
|
|
||||||
export const reducers = {
|
export const reducers = {
|
||||||
entries: entriesSlice.reducer,
|
entries: entriesSlice.reducer,
|
||||||
@@ -14,10 +15,30 @@ export const reducers = {
|
|||||||
user: userSlice.reducer,
|
user: userSlice.reducer,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const store = configureStore({ reducer: reducers })
|
const loadLocalSettings = (): LocalSettings => {
|
||||||
|
const json = localStorage.getItem("commafeed-local-settings")
|
||||||
|
return {
|
||||||
|
...initialLocalSettings,
|
||||||
|
...(json ? JSON.parse(json) : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
user: {
|
||||||
|
localSettings: loadLocalSettings(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
store.subscribe(() => {
|
||||||
|
const localSettings = store.getState().user.localSettings
|
||||||
|
localStorage.setItem("commafeed-local-settings", JSON.stringify(localSettings))
|
||||||
|
})
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>
|
export type RootState = ReturnType<typeof store.getState>
|
||||||
export type AppDispatch = typeof store.dispatch
|
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,9 +1,84 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "app/client"
|
import { client } from "@/app/client"
|
||||||
import type { CollapseRequest } from "app/types"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { redirectToCategory, redirectToFeed } from "@/app/redirect/thunks"
|
||||||
|
import { incrementUnreadCount } from "@/app/tree/slice"
|
||||||
|
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(
|
||||||
|
"tree/selectNextUnreadItem",
|
||||||
|
(
|
||||||
|
arg: {
|
||||||
|
direction: "forward" | "backward"
|
||||||
|
},
|
||||||
|
thunkApi
|
||||||
|
) => {
|
||||||
|
const state = thunkApi.getState()
|
||||||
|
const root = state.tree.rootCategory
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const { source } = state.entries
|
||||||
|
if (source.type === "category") {
|
||||||
|
const categories = flattenCategoryTree(root)
|
||||||
|
if (arg.direction === "backward") categories.reverse()
|
||||||
|
|
||||||
|
const index = categories.findIndex(c => c.id === source.id)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
for (let i = index + 1; i < categories.length; i++) {
|
||||||
|
const c = categories[i]
|
||||||
|
if (c.feeds.some(f => f.unread > 0)) {
|
||||||
|
return thunkApi.dispatch(redirectToCategory(String(c.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (source.type === "feed") {
|
||||||
|
const feeds: Subscription[] = []
|
||||||
|
visitCategoryTree(root, c => feeds.push(...c.feeds), { childrenFirst: true })
|
||||||
|
if (arg.direction === "backward") feeds.reverse()
|
||||||
|
|
||||||
|
const index = feeds.findIndex(f => f.id === +source.id)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
for (let i = index + 1; i < feeds.length; i++) {
|
||||||
|
const f = feeds[i]
|
||||||
|
if (f.unread > 0) {
|
||||||
|
return thunkApi.dispatch(redirectToFeed(String(f.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const newFeedEntriesDiscovered = createAppAsyncThunk(
|
||||||
|
"tree/new-feed-entries-discovered",
|
||||||
|
async ({ feedId, amount }: { feedId: number; amount: number }, thunkApi) => {
|
||||||
|
const root = thunkApi.getState().tree.rootCategory
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const feed = flattenCategoryTree(root)
|
||||||
|
.flatMap(c => c.feeds)
|
||||||
|
.some(f => f.id === feedId)
|
||||||
|
if (!feed) {
|
||||||
|
// feed not found in the tree, reload the tree completely
|
||||||
|
thunkApi.dispatch(reloadTree())
|
||||||
|
} else {
|
||||||
|
thunkApi.dispatch(
|
||||||
|
incrementUnreadCount({
|
||||||
|
feedId,
|
||||||
|
amount,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
173
commafeed-client/src/app/tree/tree.test.ts
Normal file
173
commafeed-client/src/app/tree/tree.test.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { configureStore } from "@reduxjs/toolkit"
|
||||||
|
import type { AxiosResponse } from "axios"
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { client } from "@/app/client"
|
||||||
|
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 => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
children: [],
|
||||||
|
feeds: [],
|
||||||
|
expanded: true,
|
||||||
|
position: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createFeed = (id: number, unread: number): Subscription => ({
|
||||||
|
id,
|
||||||
|
name: String(id),
|
||||||
|
unread,
|
||||||
|
errorCount: 0,
|
||||||
|
position: 0,
|
||||||
|
feedUrl: "",
|
||||||
|
feedLink: "",
|
||||||
|
iconUrl: "",
|
||||||
|
pushNotificationsEnabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = createCategory("root")
|
||||||
|
|
||||||
|
const catA = createCategory("catA")
|
||||||
|
catA.feeds.push(createFeed(1, 0), createFeed(2, 0), createFeed(3, 1))
|
||||||
|
|
||||||
|
const catB = createCategory("catB")
|
||||||
|
|
||||||
|
const catC = createCategory("catC")
|
||||||
|
catC.feeds.push(createFeed(4, 1))
|
||||||
|
|
||||||
|
root.children.push(catA, catB, catC)
|
||||||
|
|
||||||
|
describe("selectNextUnreadTreeItem", () => {
|
||||||
|
it("selects the next unread category", async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
tree: {
|
||||||
|
rootCategory: root,
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
source: {
|
||||||
|
type: "category",
|
||||||
|
id: "catA",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as RootState,
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
|
expect(store.getState().redirect.to).toBe("/app/category/catC")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selects the previous unread category", async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
tree: {
|
||||||
|
rootCategory: root,
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
source: {
|
||||||
|
type: "category",
|
||||||
|
id: "catC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as RootState,
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.dispatch(selectNextUnreadTreeItem({ direction: "backward" }))
|
||||||
|
expect(store.getState().redirect.to).toBe("/app/category/catA")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selects the next unread feed", async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
tree: {
|
||||||
|
rootCategory: root,
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
source: {
|
||||||
|
type: "feed",
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as RootState,
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
|
expect(store.getState().redirect.to).toBe("/app/feed/3")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selects the previous unread feed", async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
tree: {
|
||||||
|
rootCategory: root,
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
source: {
|
||||||
|
type: "feed",
|
||||||
|
id: "4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as RootState,
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.dispatch(selectNextUnreadTreeItem({ direction: "backward" }))
|
||||||
|
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,17 +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
|
||||||
|
initialSetupRequired: boolean
|
||||||
|
minimumPasswordLength: number
|
||||||
|
pushNotificationsEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SharingSettings {
|
export interface SharingSettings {
|
||||||
@@ -233,8 +254,18 @@ export interface SharingSettings {
|
|||||||
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
|
||||||
@@ -243,12 +274,26 @@ export interface Settings {
|
|||||||
customJs?: string
|
customJs?: string
|
||||||
scrollSpeed: number
|
scrollSpeed: number
|
||||||
scrollMode: ScrollMode
|
scrollMode: ScrollMode
|
||||||
|
entriesToKeepOnTopWhenScrolling: number
|
||||||
starIconDisplayMode: IconDisplayMode
|
starIconDisplayMode: IconDisplayMode
|
||||||
externalLinkIconDisplayMode: IconDisplayMode
|
externalLinkIconDisplayMode: IconDisplayMode
|
||||||
markAllAsReadConfirmation: boolean
|
markAllAsReadConfirmation: boolean
|
||||||
|
markAllAsReadNavigateToNextUnread: boolean
|
||||||
customContextMenu: boolean
|
customContextMenu: boolean
|
||||||
mobileFooter: boolean
|
mobileFooter: boolean
|
||||||
|
unreadCountTitle: boolean
|
||||||
|
unreadCountFavicon: boolean
|
||||||
|
disablePullToRefresh: boolean
|
||||||
|
primaryColor?: string
|
||||||
sharingSettings: SharingSettings
|
sharingSettings: SharingSettings
|
||||||
|
pushNotificationSettings: PushNotificationSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalSettings {
|
||||||
|
viewMode: ViewMode
|
||||||
|
sidebarWidth: number
|
||||||
|
announcementHash: string
|
||||||
|
fontSizePercentage: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StarRequest {
|
export interface StarRequest {
|
||||||
@@ -278,6 +323,7 @@ export interface UserModel {
|
|||||||
created: number
|
created: number
|
||||||
lastLogin?: number
|
lastLogin?: number
|
||||||
admin: boolean
|
admin: boolean
|
||||||
|
lastForceRefresh?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSaveUserRequest {
|
export interface AdminSaveUserRequest {
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { t } from "@lingui/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { showNotification } from "@mantine/notifications"
|
import { showNotification } from "@mantine/notifications"
|
||||||
import { createSlice, isAnyOf } from "@reduxjs/toolkit"
|
import { createSlice, isAnyOf, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import type { Settings, UserModel } from "app/types"
|
import type { LocalSettings, Settings, UserModel, ViewMode } from "@/app/types"
|
||||||
import {
|
import {
|
||||||
changeCustomContextMenu,
|
changeCustomContextMenu,
|
||||||
|
changeDisablePullToRefresh,
|
||||||
|
changeEntriesToKeepOnTopWhenScrolling,
|
||||||
changeExternalLinkIconDisplayMode,
|
changeExternalLinkIconDisplayMode,
|
||||||
changeLanguage,
|
changeLanguage,
|
||||||
changeMarkAllAsReadConfirmation,
|
changeMarkAllAsReadConfirmation,
|
||||||
|
changeMarkAllAsReadNavigateToUnread,
|
||||||
changeMobileFooter,
|
changeMobileFooter,
|
||||||
|
changePrimaryColor,
|
||||||
|
changePushNotificationSettings,
|
||||||
changeReadingMode,
|
changeReadingMode,
|
||||||
changeReadingOrder,
|
changeReadingOrder,
|
||||||
changeScrollMarks,
|
changeScrollMarks,
|
||||||
@@ -16,6 +21,8 @@ import {
|
|||||||
changeSharingSetting,
|
changeSharingSetting,
|
||||||
changeShowRead,
|
changeShowRead,
|
||||||
changeStarIconDisplayMode,
|
changeStarIconDisplayMode,
|
||||||
|
changeUnreadCountFavicon,
|
||||||
|
changeUnreadCountTitle,
|
||||||
reloadProfile,
|
reloadProfile,
|
||||||
reloadSettings,
|
reloadSettings,
|
||||||
reloadTags,
|
reloadTags,
|
||||||
@@ -23,16 +30,39 @@ import {
|
|||||||
|
|
||||||
interface UserState {
|
interface UserState {
|
||||||
settings?: Settings
|
settings?: Settings
|
||||||
|
localSettings: LocalSettings
|
||||||
profile?: UserModel
|
profile?: UserModel
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UserState = {}
|
export const initialLocalSettings: LocalSettings = {
|
||||||
|
viewMode: "detailed",
|
||||||
|
sidebarWidth: 360,
|
||||||
|
announcementHash: "no-hash",
|
||||||
|
fontSizePercentage: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UserState = {
|
||||||
|
localSettings: initialLocalSettings,
|
||||||
|
}
|
||||||
|
|
||||||
export const userSlice = createSlice({
|
export const userSlice = createSlice({
|
||||||
name: "user",
|
name: "user",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {},
|
reducers: {
|
||||||
|
setViewMode: (state, action: PayloadAction<ViewMode>) => {
|
||||||
|
state.localSettings.viewMode = action.payload
|
||||||
|
},
|
||||||
|
setFontSizePercentage: (state, action: PayloadAction<number>) => {
|
||||||
|
state.localSettings.fontSizePercentage = action.payload
|
||||||
|
},
|
||||||
|
setSidebarWidth: (state, action: PayloadAction<number>) => {
|
||||||
|
state.localSettings.sidebarWidth = action.payload
|
||||||
|
},
|
||||||
|
setAnnouncementHash: (state, action: PayloadAction<string>) => {
|
||||||
|
state.localSettings.announcementHash = action.payload
|
||||||
|
},
|
||||||
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(reloadSettings.fulfilled, (state, action) => {
|
builder.addCase(reloadSettings.fulfilled, (state, action) => {
|
||||||
state.settings = action.payload
|
state.settings = action.payload
|
||||||
@@ -71,6 +101,10 @@ export const userSlice = createSlice({
|
|||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.scrollMode = action.meta.arg
|
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) => {
|
builder.addCase(changeStarIconDisplayMode.pending, (state, action) => {
|
||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.starIconDisplayMode = action.meta.arg
|
state.settings.starIconDisplayMode = action.meta.arg
|
||||||
@@ -83,6 +117,10 @@ export const userSlice = createSlice({
|
|||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.markAllAsReadConfirmation = action.meta.arg
|
state.settings.markAllAsReadConfirmation = action.meta.arg
|
||||||
})
|
})
|
||||||
|
builder.addCase(changeMarkAllAsReadNavigateToUnread.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.markAllAsReadNavigateToNextUnread = action.meta.arg
|
||||||
|
})
|
||||||
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.customContextMenu = action.meta.arg
|
state.settings.customContextMenu = action.meta.arg
|
||||||
@@ -91,10 +129,31 @@ export const userSlice = createSlice({
|
|||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.mobileFooter = action.meta.arg
|
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(changeDisablePullToRefresh.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.disablePullToRefresh = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changePrimaryColor.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.primaryColor = action.meta.arg
|
||||||
|
})
|
||||||
builder.addCase(changeSharingSetting.pending, (state, action) => {
|
builder.addCase(changeSharingSetting.pending, (state, action) => {
|
||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
||||||
})
|
})
|
||||||
|
builder.addCase(changePushNotificationSettings.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.pushNotificationSettings = action.meta.arg
|
||||||
|
})
|
||||||
|
|
||||||
builder.addMatcher(
|
builder.addMatcher(
|
||||||
isAnyOf(
|
isAnyOf(
|
||||||
changeLanguage.fulfilled,
|
changeLanguage.fulfilled,
|
||||||
@@ -102,12 +161,19 @@ export const userSlice = createSlice({
|
|||||||
changeShowRead.fulfilled,
|
changeShowRead.fulfilled,
|
||||||
changeScrollMarks.fulfilled,
|
changeScrollMarks.fulfilled,
|
||||||
changeScrollMode.fulfilled,
|
changeScrollMode.fulfilled,
|
||||||
|
changeEntriesToKeepOnTopWhenScrolling.fulfilled,
|
||||||
changeStarIconDisplayMode.fulfilled,
|
changeStarIconDisplayMode.fulfilled,
|
||||||
changeExternalLinkIconDisplayMode.fulfilled,
|
changeExternalLinkIconDisplayMode.fulfilled,
|
||||||
changeMarkAllAsReadConfirmation.fulfilled,
|
changeMarkAllAsReadConfirmation.fulfilled,
|
||||||
|
changeMarkAllAsReadNavigateToUnread.fulfilled,
|
||||||
changeCustomContextMenu.fulfilled,
|
changeCustomContextMenu.fulfilled,
|
||||||
changeMobileFooter.fulfilled,
|
changeMobileFooter.fulfilled,
|
||||||
changeSharingSetting.fulfilled
|
changeUnreadCountTitle.fulfilled,
|
||||||
|
changeUnreadCountFavicon.fulfilled,
|
||||||
|
changeDisablePullToRefresh.fulfilled,
|
||||||
|
changePrimaryColor.fulfilled,
|
||||||
|
changeSharingSetting.fulfilled,
|
||||||
|
changePushNotificationSettings.fulfilled
|
||||||
),
|
),
|
||||||
() => {
|
() => {
|
||||||
showNotification({
|
showNotification({
|
||||||
@@ -118,3 +184,5 @@ export const userSlice = createSlice({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const { setViewMode, setSidebarWidth, setAnnouncementHash, setFontSizePercentage } = userSlice.actions
|
||||||
|
|||||||
@@ -1,48 +1,67 @@
|
|||||||
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 { reloadEntries } from "@/app/entries/thunks"
|
||||||
import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "app/types"
|
import type { IconDisplayMode, PushNotificationSettings, 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", async () => await client.user.getSettings().then(r => r.data))
|
||||||
|
|
||||||
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
||||||
|
|
||||||
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().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) => {
|
export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, readingMode })
|
client.user.saveSettings({ ...settings, readingMode })
|
||||||
thunkApi.dispatch(reloadEntries())
|
thunkApi.dispatch(reloadEntries())
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, readingOrder })
|
client.user.saveSettings({ ...settings, readingOrder })
|
||||||
thunkApi.dispatch(reloadEntries())
|
thunkApi.dispatch(reloadEntries())
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
|
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, language })
|
client.user.saveSettings({ ...settings, language })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
|
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, showRead })
|
client.user.saveSettings({ ...settings, showRead })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, scrollMarks })
|
client.user.saveSettings({ ...settings, scrollMarks })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
|
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, scrollMode })
|
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 changeStarIconDisplayMode = createAppAsyncThunk(
|
||||||
"settings/starIconDisplayMode",
|
"settings/starIconDisplayMode",
|
||||||
(starIconDisplayMode: IconDisplayMode, thunkApi) => {
|
(starIconDisplayMode: IconDisplayMode, thunkApi) => {
|
||||||
@@ -51,6 +70,7 @@ export const changeStarIconDisplayMode = createAppAsyncThunk(
|
|||||||
client.user.saveSettings({ ...settings, starIconDisplayMode })
|
client.user.saveSettings({ ...settings, starIconDisplayMode })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
||||||
"settings/externalLinkIconDisplayMode",
|
"settings/externalLinkIconDisplayMode",
|
||||||
(externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => {
|
(externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => {
|
||||||
@@ -59,6 +79,7 @@ export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
|||||||
client.user.saveSettings({ ...settings, externalLinkIconDisplayMode })
|
client.user.saveSettings({ ...settings, externalLinkIconDisplayMode })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
||||||
"settings/markAllAsReadConfirmation",
|
"settings/markAllAsReadConfirmation",
|
||||||
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
||||||
@@ -67,16 +88,55 @@ export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
|||||||
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const changeMarkAllAsReadNavigateToUnread = createAppAsyncThunk(
|
||||||
|
"settings/markAllAsReadNavigateToUnread",
|
||||||
|
(markAllAsReadNavigateToNextUnread: boolean, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, markAllAsReadNavigateToNextUnread })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
|
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, customContextMenu })
|
client.user.saveSettings({ ...settings, customContextMenu })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
|
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, mobileFooter })
|
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 changeDisablePullToRefresh = createAppAsyncThunk(
|
||||||
|
"settings/disablePullToRefresh",
|
||||||
|
(disablePullToRefresh: boolean, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, disablePullToRefresh })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const changePrimaryColor = createAppAsyncThunk("settings/primaryColor", (primaryColor: string, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, primaryColor })
|
||||||
|
})
|
||||||
|
|
||||||
export const changeSharingSetting = createAppAsyncThunk(
|
export const changeSharingSetting = createAppAsyncThunk(
|
||||||
"settings/sharingSetting",
|
"settings/sharingSetting",
|
||||||
(
|
(
|
||||||
@@ -97,3 +157,15 @@ export const changeSharingSetting = createAppAsyncThunk(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const changePushNotificationSettings = createAppAsyncThunk(
|
||||||
|
"settings/pushNotificationSettings",
|
||||||
|
(pushNotificationSettings: PushNotificationSettings, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({
|
||||||
|
...settings,
|
||||||
|
pushNotificationSettings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
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(category: Category, visitor: (category: Category) => void): void {
|
export function visitCategoryTree(
|
||||||
visitor(category)
|
category: TreeCategory,
|
||||||
for (const child of category.children) {
|
visitor: (category: TreeCategory) => void,
|
||||||
visitCategoryTree(child, visitor)
|
options?: {
|
||||||
|
childrenFirst?: boolean
|
||||||
}
|
}
|
||||||
|
): void {
|
||||||
|
const childrenFirst = options?.childrenFirst
|
||||||
|
|
||||||
|
if (!childrenFirst) visitor(category)
|
||||||
|
|
||||||
|
for (const child of category.children) {
|
||||||
|
visitCategoryTree(child, visitor, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -23,6 +35,14 @@ export function categoryUnreadCount(category?: Category): number {
|
|||||||
.reduce((total, current) => total + current, 0)
|
.reduce((total, current) => total + current, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }) => {
|
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
|
||||||
|
|||||||
53
commafeed-client/src/components/ActionButton.test.tsx
Normal file
53
commafeed-client/src/components/ActionButton.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { I18nContext } from "@lingui/react"
|
||||||
|
import { MantineProvider } from "@mantine/core"
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
import { ActionButton } from "./ActionButton"
|
||||||
|
|
||||||
|
vi.mock(import("@lingui/react"), () => ({
|
||||||
|
useLingui: vi.fn().mockReturnValue({
|
||||||
|
_: msg => msg,
|
||||||
|
} as I18nContext),
|
||||||
|
}))
|
||||||
|
vi.mock(import("@/hooks/useActionButton"))
|
||||||
|
|
||||||
|
const label = "Test Label"
|
||||||
|
const icon = "Test Icon"
|
||||||
|
describe("ActionButton", () => {
|
||||||
|
it("renders Button with label on desktop", () => {
|
||||||
|
vi.mocked(useActionButton).mockReturnValue({ mobile: false, spacing: 0 })
|
||||||
|
|
||||||
|
render(<ActionButton label={label} icon={icon} />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
|
expect(screen.getByText(label)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(icon)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders ActionIcon with tooltip on mobile", async () => {
|
||||||
|
vi.mocked(useActionButton).mockReturnValue({ mobile: true, spacing: 0 })
|
||||||
|
|
||||||
|
render(<ActionButton label={label} icon={icon} />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
|
expect(screen.queryByText(label)).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(icon)).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(screen.getByRole("button"))
|
||||||
|
const tooltip = await waitFor(() => screen.getByRole("tooltip"))
|
||||||
|
expect(tooltip).toContainHTML(label)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("calls onClick handler when clicked", () => {
|
||||||
|
vi.mocked(useActionButton).mockReturnValue({ mobile: false, spacing: 0 })
|
||||||
|
const clickListener = vi.fn()
|
||||||
|
|
||||||
|
render(<ActionButton label={label} icon={icon} onClick={clickListener} />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole("button"))
|
||||||
|
|
||||||
|
expect(clickListener).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { MessageDescriptor } from "@lingui/core"
|
import type { MessageDescriptor } from "@lingui/core"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
import { ActionIcon, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
||||||
import type { ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
import type { ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
||||||
import { Constants } from "app/constants"
|
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
import { Constants } from "@/app/constants"
|
||||||
import { type MouseEventHandler, type ReactNode, forwardRef } from "react"
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
|
||||||
interface ActionButtonProps {
|
interface ActionButtonProps {
|
||||||
|
icon: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
icon?: ReactNode
|
|
||||||
label?: string | MessageDescriptor
|
label?: string | MessageDescriptor
|
||||||
onClick?: MouseEventHandler
|
onClick?: MouseEventHandler
|
||||||
variant?: ActionIconVariant & ButtonVariant
|
variant?: ActionIconVariant & ButtonVariant
|
||||||
@@ -19,7 +19,7 @@ interface ActionButtonProps {
|
|||||||
/**
|
/**
|
||||||
* Switches between Button with label (desktop) and ActionIcon (mobile)
|
* Switches between Button with label (desktop) and ActionIcon (mobile)
|
||||||
*/
|
*/
|
||||||
export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
|
export const ActionButton = forwardRef<HTMLDivElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
|
||||||
const { mobile } = useActionButton()
|
const { mobile } = useActionButton()
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
@@ -27,31 +27,36 @@ export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((pr
|
|||||||
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 iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
||||||
return iconOnly ? (
|
|
||||||
<Tooltip label={label} openDelay={Constants.tooltip.delay}>
|
return (
|
||||||
<ActionIcon
|
<Box ref={ref} className="cf-action-button">
|
||||||
ref={ref}
|
{iconOnly && (
|
||||||
color={theme.primaryColor}
|
<Tooltip label={label} openDelay={Constants.tooltip.delay}>
|
||||||
variant={variant}
|
<ActionIcon
|
||||||
className={props.className}
|
color={theme.primaryColor}
|
||||||
onClick={props.onClick}
|
variant={variant}
|
||||||
aria-label={label}
|
className={props.className}
|
||||||
>
|
onClick={props.onClick}
|
||||||
{props.icon}
|
aria-label={label}
|
||||||
</ActionIcon>
|
>
|
||||||
</Tooltip>
|
{props.icon}
|
||||||
) : (
|
</ActionIcon>
|
||||||
<Button
|
</Tooltip>
|
||||||
ref={ref}
|
)}
|
||||||
variant={variant}
|
{!iconOnly && (
|
||||||
size="xs"
|
<Button
|
||||||
className={props.className}
|
variant={variant}
|
||||||
leftSection={props.icon}
|
size="xs"
|
||||||
onClick={props.onClick}
|
className={props.className}
|
||||||
aria-label={label}
|
leftSection={props.icon}
|
||||||
>
|
onClick={props.onClick}
|
||||||
{label}
|
aria-label={label}
|
||||||
</Button>
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
ActionButton.displayName = "HeaderButton"
|
ActionButton.displayName = "HeaderButton"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Trans } from "@lingui/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Alert as MantineAlert } from "@mantine/core"
|
import { Box, Alert as MantineAlert } from "@mantine/core"
|
||||||
import { Fragment } from "react"
|
import { Fragment } from "react"
|
||||||
import { TbAlertCircle, TbAlertTriangle, TbCircleCheck } from "react-icons/tb"
|
import { TbAlertCircle, TbAlertTriangle, TbCircleCheck } from "react-icons/tb"
|
||||||
@@ -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/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Dialog, Text } from "@mantine/core"
|
import { Box, Dialog, Text } from "@mantine/core"
|
||||||
import { useAppSelector } from "app/store"
|
|
||||||
import { Content } from "components/content/Content"
|
|
||||||
import { useAsync } from "react-async-hook"
|
import { useAsync } from "react-async-hook"
|
||||||
import useLocalStorage from "use-local-storage"
|
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)
|
||||||
@@ -15,10 +15,11 @@ const sha256Hex = async (input: string | undefined) => {
|
|||||||
export function AnnouncementDialog() {
|
export function AnnouncementDialog() {
|
||||||
const announcement = useAppSelector(state => state.server.serverInfos?.announcement)
|
const announcement = useAppSelector(state => state.server.serverInfos?.announcement)
|
||||||
const announcementHash = useAsync(sha256Hex, [announcement]).result
|
const announcementHash = useAsync(sha256Hex, [announcement]).result
|
||||||
const [localStorageHash, setLocalStorageHash] = useLocalStorage("announcement-hash", "no-hash")
|
const existingAnnouncementHash = useAppSelector(state => state.user.localSettings.announcementHash)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const opened = !!announcementHash && announcementHash !== localStorageHash
|
const opened = !!announcementHash && announcementHash !== existingAnnouncementHash
|
||||||
const onClosed = () => setLocalStorageHash(announcementHash)
|
const onClosed = () => announcementHash && dispatch(setAnnouncementHash(announcementHash))
|
||||||
|
|
||||||
if (!announcement) return null
|
if (!announcement) return null
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,15 +1,3 @@
|
|||||||
import { Helmet } from "react-helmet"
|
export const DisablePullToRefresh = ({ enabled }: { enabled: boolean | undefined }) => {
|
||||||
|
return enabled ? <style>{`html, body { overscroll-behavior: none; }`}</style> : null
|
||||||
export const DisablePullToRefresh = () => {
|
|
||||||
return (
|
|
||||||
<Helmet>
|
|
||||||
<style type="text/css">
|
|
||||||
{`
|
|
||||||
html, body {
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
</Helmet>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,6 +9,7 @@ interface ImageWithPlaceholderWhileLoadingProps {
|
|||||||
title?: string
|
title?: string
|
||||||
width?: number
|
width?: number
|
||||||
height?: number | "auto"
|
height?: number | "auto"
|
||||||
|
style?: React.CSSProperties
|
||||||
placeholderWidth?: number
|
placeholderWidth?: number
|
||||||
placeholderHeight?: number
|
placeholderHeight?: number
|
||||||
placeholderBackgroundColor?: string
|
placeholderBackgroundColor?: string
|
||||||
@@ -42,7 +43,8 @@ export function ImageWithPlaceholderWhileLoading({
|
|||||||
src,
|
src,
|
||||||
title,
|
title,
|
||||||
width,
|
width,
|
||||||
}: ImageWithPlaceholderWhileLoadingProps) {
|
style,
|
||||||
|
}: Readonly<ImageWithPlaceholderWhileLoadingProps>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
placeholderWidth,
|
placeholderWidth,
|
||||||
placeholderHeight,
|
placeholderHeight,
|
||||||
@@ -68,7 +70,11 @@ export function ImageWithPlaceholderWhileLoading({
|
|||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
onLoad={() => setLoading(false)}
|
onLoad={() => setLoading(false)}
|
||||||
style={{ display: loading ? "none" : "block" }}
|
style={{
|
||||||
|
...style,
|
||||||
|
display: loading ? "none" : (style?.display ?? "initial"),
|
||||||
|
height: style?.width ? "auto" : style?.height,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Trans } from "@lingui/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"
|
||||||
@@ -33,6 +33,26 @@ export function KeyboardShortcutsHelp() {
|
|||||||
<Kbd>K</Kbd>
|
<Kbd>K</Kbd>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Trans>Select next unread feed/category</Trans>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Kbd>Shift</Kbd>
|
||||||
|
<span> + </span>
|
||||||
|
<Kbd>J</Kbd>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Trans>Select previous unread feed/category</Trans>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Kbd>Shift</Kbd>
|
||||||
|
<span> + </span>
|
||||||
|
<Kbd>K</Kbd>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Trans>Set focus on next entry without opening it</Trans>
|
<Trans>Set focus on next entry without opening it</Trans>
|
||||||
|
|||||||
@@ -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,83 @@
|
|||||||
|
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 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
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,19 @@
|
|||||||
|
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,15 +1,15 @@
|
|||||||
import { Trans } from "@lingui/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 { useEffect, useState } from "react"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useNow } from "@/hooks/useNow"
|
||||||
|
|
||||||
export function RelativeDate(props: { date: Date | number | undefined }) {
|
export function RelativeDate(
|
||||||
const [now, setNow] = useState(new Date())
|
props: Readonly<{
|
||||||
useEffect(() => {
|
date: Date | number | undefined
|
||||||
const interval = setInterval(() => setNow(new Date()), 60 * 1000)
|
}>
|
||||||
return () => clearInterval(interval)
|
) {
|
||||||
}, [])
|
const now = useNow(60 * 1000)
|
||||||
|
|
||||||
if (!props.date) return <Trans>N/A</Trans>
|
if (!props.date) return <Trans>N/A</Trans>
|
||||||
const date = dayjs(props.date)
|
const date = dayjs(props.date)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Trans } from "@lingui/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: "",
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
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 = {
|
||||||
@@ -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,11 +1,24 @@
|
|||||||
import { TypographyStylesProvider } from "@mantine/core"
|
import { Typography } from "@mantine/core"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
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.
|
||||||
*
|
*
|
||||||
* see https://mantine.dev/core/typography-styles-provider/
|
* see https://mantine.dev/core/typography-styles-provider/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const useStyles = tss.create(() => ({
|
||||||
|
// override mantine default typography styles
|
||||||
|
content: {
|
||||||
|
paddingLeft: 0,
|
||||||
|
"& img": {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
||||||
return <TypographyStylesProvider pl={0}>{props.children}</TypographyStylesProvider>
|
const { classes } = useStyles()
|
||||||
|
return <Typography className={classes.content}>{props.children}</Typography>
|
||||||
}
|
}
|
||||||
|
|||||||
26
commafeed-client/src/components/content/Content.test.tsx
Normal file
26
commafeed-client/src/components/content/Content.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core"
|
||||||
|
import { render } from "@testing-library/react"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { Content } from "@/components/content/Content"
|
||||||
|
|
||||||
|
describe("Content component", () => {
|
||||||
|
it("renders basic content", () => {
|
||||||
|
const { container } = render(<Content content="<p>Hello World</p>" />, { wrapper: MantineProvider })
|
||||||
|
expect(container.querySelector("p")).toHaveTextContent("Hello World")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders highlighted text when highlight prop is provided", () => {
|
||||||
|
const { container } = render(<Content content="Hello World" highlight="World" />, { wrapper: MantineProvider })
|
||||||
|
expect(container.querySelector("mark")).toHaveTextContent("World")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders iframe tag when included in content", () => {
|
||||||
|
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, { wrapper: MantineProvider })
|
||||||
|
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not render unsupported tags", () => {
|
||||||
|
const { container } = render(<Content content='<script>alert("test")</script>' />, { wrapper: MantineProvider })
|
||||||
|
expect(container.querySelector("script")).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
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 { 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 { tss } from "tss"
|
import styleToObject from "style-to-object"
|
||||||
|
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
|
||||||
@@ -42,6 +43,7 @@ const transform: TransformCallback = node => {
|
|||||||
const nodeHeight = node.getAttribute("height")
|
const nodeHeight = node.getAttribute("height")
|
||||||
const width = nodeWidth ? Number.parseInt(nodeWidth, 10) : undefined
|
const width = nodeWidth ? Number.parseInt(nodeWidth, 10) : undefined
|
||||||
const height = nodeHeight ? Number.parseInt(nodeHeight, 10) : undefined
|
const height = nodeHeight ? Number.parseInt(nodeHeight, 10) : undefined
|
||||||
|
const style = styleToObject(node.getAttribute("style") ?? "") ?? undefined
|
||||||
const placeholderSize = calculatePlaceholderSize({
|
const placeholderSize = calculatePlaceholderSize({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -55,6 +57,7 @@ const transform: TransformCallback = node => {
|
|||||||
title={title}
|
title={title}
|
||||||
width={width}
|
width={width}
|
||||||
height="auto"
|
height="auto"
|
||||||
|
style={style}
|
||||||
placeholderWidth={placeholderSize.width}
|
placeholderWidth={placeholderSize.width}
|
||||||
placeholderHeight={placeholderSize.height}
|
placeholderHeight={placeholderSize.height}
|
||||||
/>
|
/>
|
||||||
@@ -64,20 +67,19 @@ const transform: TransformCallback = node => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class HighlightMatcher extends Matcher {
|
class HighlightMatcher extends Matcher {
|
||||||
private readonly search: string
|
private readonly regexp: RegExp
|
||||||
|
|
||||||
constructor(search: string) {
|
constructor(search: string) {
|
||||||
super("highlight")
|
super("highlight")
|
||||||
this.search = escapeStringRegexp(search)
|
this.regexp = new RegExp(escapeStringRegexp(search).split(" ").join("|"), "i")
|
||||||
}
|
}
|
||||||
|
|
||||||
match(string: string): MatchResponse<unknown> | null {
|
match(string: string): MatchResponse<unknown> | null {
|
||||||
const pattern = this.search.split(" ").join("|")
|
return this.doMatch(string, this.regexp, () => ({}))
|
||||||
return this.doMatch(string, new RegExp(pattern, "i"), () => ({}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceWith(children: ChildrenNode): Node {
|
replaceWith(children: ChildrenNode): Node {
|
||||||
return <Mark>{children}</Mark>
|
return <Mark key={0}>{children}</Mark>
|
||||||
}
|
}
|
||||||
|
|
||||||
asTag(): string {
|
asTag(): string {
|
||||||
@@ -85,6 +87,9 @@ class HighlightMatcher extends Matcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allow iframe tag
|
||||||
|
const allowList = [...ALLOWED_TAG_LIST, "iframe"]
|
||||||
|
|
||||||
// memoize component because Interweave is costly
|
// memoize component because Interweave is costly
|
||||||
const Content = React.memo((props: ContentProps) => {
|
const Content = React.memo((props: ContentProps) => {
|
||||||
const { classes } = useStyles()
|
const { classes } = useStyles()
|
||||||
@@ -93,7 +98,7 @@ const Content = React.memo((props: ContentProps) => {
|
|||||||
return (
|
return (
|
||||||
<BasicHtmlStyles>
|
<BasicHtmlStyles>
|
||||||
<Box className={classes.content}>
|
<Box className={classes.content}>
|
||||||
<Interweave content={props.content} transform={transform} matchers={matchers} />
|
<Interweave content={props.content} transform={transform} matchers={matchers} allowList={allowList} />
|
||||||
</Box>
|
</Box>
|
||||||
</BasicHtmlStyles>
|
</BasicHtmlStyles>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -19,7 +21,7 @@ export function Enclosure(props: {
|
|||||||
)}
|
)}
|
||||||
{hasAudio && (
|
{hasAudio && (
|
||||||
// biome-ignore lint/a11y/useMediaCaption: we don't have any captions for audio
|
// biome-ignore lint/a11y/useMediaCaption: we don't have any captions for audio
|
||||||
<audio controls>
|
<audio controls style={{ width: "100%" }}>
|
||||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||||
</audio>
|
</audio>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,36 +1,34 @@
|
|||||||
import { Trans } from "@lingui/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 { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
|
import { selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
import { Loader } from "components/Loader"
|
import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
import { Loader } from "@/components/Loader"
|
||||||
import { useMousetrap } from "hooks/useMousetrap"
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
import { useViewMode } from "hooks/useViewMode"
|
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)
|
||||||
@@ -38,7 +36,7 @@ export function FeedEntries() {
|
|||||||
const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry)
|
const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry)
|
||||||
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
||||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
||||||
const { viewMode } = useViewMode()
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||||
|
|
||||||
@@ -173,6 +171,8 @@ export function FeedEntries() {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
useMousetrap("shift+j", async () => await dispatch(selectNextUnreadTreeItem({ direction: "forward" })))
|
||||||
|
useMousetrap("shift+k", async () => await dispatch(selectNextUnreadTreeItem({ direction: "backward" })))
|
||||||
useMousetrap("space", () => {
|
useMousetrap("space", () => {
|
||||||
if (selectedEntry) {
|
if (selectedEntry) {
|
||||||
if (selectedEntry.expanded) {
|
if (selectedEntry.expanded) {
|
||||||
@@ -273,17 +273,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()))
|
||||||
@@ -297,33 +287,25 @@ 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")}
|
||||||
>
|
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
||||||
<FeedEntry
|
onHeaderClick={event => headerClicked(entry, event)}
|
||||||
entry={entry}
|
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
||||||
expanded={!!entry.expanded || viewMode === "expanded"}
|
onBodyClick={() => bodyClicked(entry)}
|
||||||
selected={entry.id === selectedEntryId}
|
onSwipedLeft={async () => await swipedLeft(entry)}
|
||||||
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
/>
|
||||||
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
|
||||||
onHeaderClick={event => headerClicked(entry, event)}
|
|
||||||
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
|
||||||
onBodyClick={() => bodyClicked(entry)}
|
|
||||||
onSwipedLeft={async () => await swipedLeft(entry)}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +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 { useViewMode } from "hooks/useViewMode"
|
|
||||||
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"
|
||||||
@@ -33,8 +32,9 @@ 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(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth, fontSizePercentage }) => {
|
||||||
let backgroundColor: string
|
let backgroundColor: string
|
||||||
if (colorScheme === "dark") {
|
if (colorScheme === "dark") {
|
||||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||||
@@ -84,18 +84,21 @@ const useStyles = tss
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
headerLink: {
|
headerLink: {
|
||||||
|
fontSize: `${fontSizePercentage}%`,
|
||||||
color: "inherit",
|
color: "inherit",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
|
fontSize: `${fontSizePercentage}%`,
|
||||||
direction: rtl ? "rtl" : "ltr",
|
direction: rtl ? "rtl" : "ltr",
|
||||||
maxWidth: maxWidth ?? "100%",
|
maxWidth: maxWidth ?? "100%",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export function FeedEntry(props: FeedEntryProps) {
|
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
||||||
const { viewMode } = useViewMode()
|
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,
|
||||||
@@ -103,6 +106,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)
|
||||||
@@ -138,6 +142,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, {
|
||||||
@@ -177,10 +185,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/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)
|
||||||
@@ -61,19 +61,21 @@ 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 onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
||||||
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
<Group>
|
||||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||||
</Group>
|
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
</Item>
|
</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,20 @@
|
|||||||
import { msg } from "@lingui/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 { 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 { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, 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 +37,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
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
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>) {
|
||||||
return (
|
return (
|
||||||
<ImageWithPlaceholderWhileLoading
|
<ImageWithPlaceholderWhileLoading
|
||||||
src={url}
|
src={url}
|
||||||
|
|||||||
@@ -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/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,7 +97,12 @@ 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)
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Trans, msg } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
|
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() {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { msg } from "@lingui/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 { Select, type SelectProps } from "@mantine/core"
|
||||||
import type { ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
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
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Trans, msg } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
|
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()
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Trans } from "@lingui/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() {
|
||||||
@@ -40,8 +40,7 @@ export function Subscribe() {
|
|||||||
})
|
})
|
||||||
const subscribe = useAsyncCallback(client.feed.subscribe, {
|
const subscribe = useAsyncCallback(client.feed.subscribe, {
|
||||||
onSuccess: sub => {
|
onSuccess: sub => {
|
||||||
dispatch(reloadTree())
|
dispatch(reloadTree()).then(() => dispatch(redirectToFeed(sub.data)))
|
||||||
dispatch(redirectToFeed(sub.data))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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,12 +1,12 @@
|
|||||||
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"
|
||||||
|
|
||||||
export interface FeedEntryTitleProps {
|
export interface FeedEntryTitleProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedEntryTitle(props: FeedEntryTitleProps) {
|
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(" ")
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Trans } from "@lingui/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/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()
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { msg } from "@lingui/macro"
|
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 { 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 { useEffect } from "react"
|
||||||
import {
|
import {
|
||||||
TbArrowDown,
|
TbArrowDown,
|
||||||
TbArrowUp,
|
TbArrowUp,
|
||||||
|
TbChecks,
|
||||||
TbExternalLink,
|
TbExternalLink,
|
||||||
TbEye,
|
TbEye,
|
||||||
TbEyeOff,
|
TbEyeOff,
|
||||||
@@ -24,7 +17,14 @@ 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 { 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 { ProfileMenu } from "./ProfileMenu"
|
import { ProfileMenu } from "./ProfileMenu"
|
||||||
|
|
||||||
function HeaderDivider() {
|
function HeaderDivider() {
|
||||||
@@ -42,11 +42,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} className="cf-toolbar">
|
||||||
|
{props.children}
|
||||||
|
</Group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,11 +63,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(() => {
|
||||||
@@ -75,7 +74,7 @@ export function Header() {
|
|||||||
|
|
||||||
if (!settings) return <Loader />
|
if (!settings) return <Loader />
|
||||||
return (
|
return (
|
||||||
<Center>
|
<Center className="cf-toolbar-wrapper">
|
||||||
<HeaderToolbar>
|
<HeaderToolbar>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TbArrowUp size={iconSize} />}
|
icon={<TbArrowUp size={iconSize} />}
|
||||||
@@ -111,7 +110,11 @@ 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 />
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import { Trans, msg } from "@lingui/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} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Trans } from "@lingui/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -7,15 +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 dayjs from "dayjs"
|
||||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/redirect/thunks"
|
import { type ReactNode, useEffect, useState } from "react"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import type { ViewMode } from "app/types"
|
|
||||||
import { useViewMode } from "hooks/useViewMode"
|
|
||||||
import { type ReactNode, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
TbChartLine,
|
TbChartLine,
|
||||||
TbHeartFilled,
|
TbHeartFilled,
|
||||||
@@ -32,6 +29,14 @@ 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
|
||||||
@@ -90,14 +95,30 @@ const viewModeData: ViewModeControlItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ProfileMenu(props: ProfileMenuProps) {
|
export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
||||||
const [opened, setOpened] = useState(false)
|
const [opened, setOpened] = useState(false)
|
||||||
const { viewMode, setViewMode } = useViewMode()
|
|
||||||
|
// 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 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 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()
|
||||||
|
|
||||||
|
const nextAvailableForceRefresh = profile?.lastForceRefresh
|
||||||
|
? profile.lastForceRefresh + (forceRefreshCooldownDuration ?? 0)
|
||||||
|
: now.getTime()
|
||||||
|
const forceRefreshEnabled = nextAvailableForceRefresh <= now.getTime()
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
window.location.href = "logout"
|
window.location.href = "logout"
|
||||||
}
|
}
|
||||||
@@ -118,18 +139,32 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<TbWorldDownload size={iconSize} />}
|
leftSection={<TbWorldDownload size={iconSize} />}
|
||||||
onClick={async () =>
|
disabled={!forceRefreshEnabled}
|
||||||
await client.feed.refreshAll().then(() => {
|
onClick={async () => {
|
||||||
|
setOpened(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.feed.refreshAll()
|
||||||
|
|
||||||
|
// reload profile to update last force refresh timestamp
|
||||||
|
await dispatch(reloadProfile())
|
||||||
|
|
||||||
showNotification({
|
showNotification({
|
||||||
message: <Trans>Your feeds have been queued for refresh.</Trans>,
|
message: <Trans>Your feeds have been queued for refresh.</Trans>,
|
||||||
color: "green",
|
color: "green",
|
||||||
autoClose: 1000,
|
autoClose: 1000,
|
||||||
})
|
})
|
||||||
setOpened(false)
|
} catch {
|
||||||
})
|
showNotification({
|
||||||
}
|
message: <Trans>Force fetching feeds is not yet available.</Trans>,
|
||||||
|
color: "red",
|
||||||
|
autoClose: 2000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<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>}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -156,10 +191,26 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
data={viewModeData}
|
data={viewModeData}
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
onChange={e => setViewMode(e as ViewMode)}
|
onChange={e => dispatch(setViewMode(e as ViewMode))}
|
||||||
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,9 +1,10 @@
|
|||||||
import type { MetricGauge } from "app/types"
|
import { NumberFormatter } from "@mantine/core"
|
||||||
|
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 <span>{props.gauge.value}</span>
|
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/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")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
import { Trans, msg } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Divider, Group, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { Box, Divider, Group, NumberInput, Radio, Select, type SelectProps, SimpleGrid, Stack, Switch } from "@mantine/core"
|
||||||
import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
||||||
import { Constants } from "app/constants"
|
import type { ReactNode } from "react"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { Constants } from "@/app/constants"
|
||||||
import type { IconDisplayMode, ScrollMode, SharingSettings } from "app/types"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { IconDisplayMode, ScrollMode, SharingSettings } from "@/app/types"
|
||||||
import {
|
import {
|
||||||
changeCustomContextMenu,
|
changeCustomContextMenu,
|
||||||
|
changeDisablePullToRefresh,
|
||||||
|
changeEntriesToKeepOnTopWhenScrolling,
|
||||||
changeExternalLinkIconDisplayMode,
|
changeExternalLinkIconDisplayMode,
|
||||||
changeLanguage,
|
changeLanguage,
|
||||||
changeMarkAllAsReadConfirmation,
|
changeMarkAllAsReadConfirmation,
|
||||||
|
changeMarkAllAsReadNavigateToUnread,
|
||||||
changeMobileFooter,
|
changeMobileFooter,
|
||||||
|
changePrimaryColor,
|
||||||
changeScrollMarks,
|
changeScrollMarks,
|
||||||
changeScrollMode,
|
changeScrollMode,
|
||||||
changeScrollSpeed,
|
changeScrollSpeed,
|
||||||
changeSharingSetting,
|
changeSharingSetting,
|
||||||
changeShowRead,
|
changeShowRead,
|
||||||
changeStarIconDisplayMode,
|
changeStarIconDisplayMode,
|
||||||
} from "app/user/thunks"
|
changeUnreadCountFavicon,
|
||||||
import { locales } from "i18n"
|
changeUnreadCountTitle,
|
||||||
import type { ReactNode } from "react"
|
} from "@/app/user/thunks"
|
||||||
|
import { locales } from "@/i18n"
|
||||||
|
|
||||||
export function DisplaySettings() {
|
export function DisplaySettings() {
|
||||||
const language = useAppSelector(state => state.user.settings?.language)
|
const language = useAppSelector(state => state.user.settings?.language)
|
||||||
@@ -27,14 +34,20 @@ export function DisplaySettings() {
|
|||||||
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
||||||
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
||||||
const scrollMode = useAppSelector(state => state.user.settings?.scrollMode)
|
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 starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
|
||||||
const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
||||||
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
||||||
|
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
|
||||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
||||||
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
|
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 disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh)
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||||
const dispatch = useAppDispatch()
|
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
||||||
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>,
|
||||||
@@ -61,10 +74,35 @@ 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>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
|
<Divider label={<Trans>Display</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
description={<Trans>Language</Trans>}
|
label={<Trans>Language</Trans>}
|
||||||
value={language}
|
value={language}
|
||||||
data={locales.map(l => ({
|
data={locales.map(l => ({
|
||||||
value: l.key,
|
value: l.key,
|
||||||
@@ -73,6 +111,14 @@ export function DisplaySettings() {
|
|||||||
onChange={async s => await (s && dispatch(changeLanguage(s)))}
|
onChange={async s => await (s && dispatch(changeLanguage(s)))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Primary color</Trans>}
|
||||||
|
data={colorData}
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={async value => value && (await dispatch(changePrimaryColor(value)))}
|
||||||
|
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={showRead}
|
||||||
@@ -85,36 +131,27 @@ export function DisplaySettings() {
|
|||||||
onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
|
onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Navigate to the next category/feed with unread entries when marking all entries as read</Trans>}
|
||||||
|
checked={markAllAsReadNavigateToNextUnread}
|
||||||
|
onChange={async e => await dispatch(changeMarkAllAsReadNavigateToUnread(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={mobileFooter}
|
||||||
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
|
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider label={<Trans>Entry headers</Trans>} labelPosition="center" />
|
<Divider label={<Trans>Scrolling</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
|
<Switch
|
||||||
label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
|
label={<Trans>Disable "Pull to refresh" browser behavior</Trans>}
|
||||||
checked={customContextMenu}
|
description={<Trans>This setting can cause scrolling issues on some browsers (e.g. Safari)</Trans>}
|
||||||
onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))}
|
checked={disablePullToRefresh}
|
||||||
|
onChange={async e => await dispatch(changeDisablePullToRefresh(e.currentTarget.checked))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
|
|
||||||
|
|
||||||
<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={scrollMode}
|
||||||
@@ -127,6 +164,14 @@ export function DisplaySettings() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={<Trans>Entries to keep above the selected entry when scrolling</Trans>}
|
||||||
|
description={<Trans>Only applies to compact, cozy and detailed modes</Trans>}
|
||||||
|
min={0}
|
||||||
|
value={entriesToKeepOnTop}
|
||||||
|
onChange={async value => await dispatch(changeEntriesToKeepOnTopWhenScrolling(+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={scrollSpeed ? scrollSpeed > 0 : false}
|
||||||
@@ -139,6 +184,42 @@ export function DisplaySettings() {
|
|||||||
onChange={async e => await dispatch(changeScrollMarks(e.currentTarget.checked))}
|
onChange={async e => await dispatch(changeScrollMarks(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
|
||||||
|
label={<Trans>Show star icon</Trans>}
|
||||||
|
value={starIconDisplayMode}
|
||||||
|
data={displayModeData}
|
||||||
|
onChange={async s => await dispatch(changeStarIconDisplayMode(s as IconDisplayMode))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<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>Sharing sites</Trans>} labelPosition="center" />
|
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Trans, msg } from "@lingui/macro"
|
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
|
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
|
||||||
@@ -19,13 +20,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
|
||||||
|
|
||||||
@@ -51,7 +56,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(() => {
|
||||||
@@ -131,7 +138,12 @@ 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>}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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 { changePushNotificationSettings } 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(changePushNotificationSettings(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/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"
|
||||||
|
|
||||||
@@ -35,6 +36,21 @@ export function Tree() {
|
|||||||
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const isFeedDisplayed = (feed: Subscription) => {
|
||||||
|
const isCurrentFeed = source.type === "feed" && source.id === String(feed.id)
|
||||||
|
return isCurrentFeed || feed.unread > 0 || showRead
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCategoryDisplayed = (category: Category): boolean => {
|
||||||
|
const isCurrentCategory = source.type === "category" && source.id === category.id
|
||||||
|
return (
|
||||||
|
isCurrentCategory ||
|
||||||
|
showRead ||
|
||||||
|
category.children.some(c => isCategoryDisplayed(c)) ||
|
||||||
|
category.feeds.some(f => isFeedDisplayed(f))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const feedClicked = (e: React.MouseEvent, id: string) => {
|
const feedClicked = (e: React.MouseEvent, id: string) => {
|
||||||
if (e.detail === 2) {
|
if (e.detail === 2) {
|
||||||
dispatch(redirectToFeedDetails(id))
|
dispatch(redirectToFeedDetails(id))
|
||||||
@@ -74,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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -88,30 +106,34 @@ 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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const categoryNode = (category: Category, level = 0) => {
|
const categoryNode = (category: Category, level = 0) => {
|
||||||
const unreadCount = categoryUnreadCount(category)
|
if (!isCategoryDisplayed(category)) return null
|
||||||
if (unreadCount === 0 && !showRead) 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}
|
||||||
type="category"
|
type="category"
|
||||||
name={category.name}
|
name={category.name}
|
||||||
icon={category.expanded ? expandedIcon : collapsedIcon}
|
icon={category.expanded ? expandedIcon : collapsedIcon}
|
||||||
unread={unreadCount}
|
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}
|
||||||
@@ -119,8 +141,8 @@ export function Tree() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedNode = (feed: Subscription, level = 0) => {
|
const feedNode = (feed: TreeSubscription, level = 0) => {
|
||||||
if (feed.unread === 0 && !showRead) return null
|
if (!isFeedDisplayed(feed)) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
@@ -129,9 +151,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}
|
||||||
/>
|
/>
|
||||||
@@ -145,9 +169,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}
|
||||||
/>
|
/>
|
||||||
@@ -168,7 +194,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,30 @@ 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,19 +1,20 @@
|
|||||||
import { Trans, msg } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { TextInput } from "@mantine/core"
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -32,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}
|
||||||
@@ -57,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/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
|
||||||
|
|||||||
10
commafeed-client/src/hooks/useNow.ts
Normal file
10
commafeed-client/src/hooks/useNow.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export const useNow = (interval = 1000): Date => {
|
||||||
|
const [time, setTime] = useState(new Date())
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setTime(new Date()), interval)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [interval])
|
||||||
|
return time
|
||||||
|
}
|
||||||
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,7 +0,0 @@
|
|||||||
import type { ViewMode } from "app/types"
|
|
||||||
import useLocalStorage from "use-local-storage"
|
|
||||||
|
|
||||||
export function useViewMode() {
|
|
||||||
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("view-mode", "detailed")
|
|
||||||
return { viewMode, setViewMode }
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { setWebSocketConnected } from "app/server/slice"
|
|
||||||
import { type AppDispatch, useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { incrementUnreadCount } from "app/tree/slice"
|
|
||||||
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(":")
|
||||||
const type = parts[0]
|
const type = parts[0]
|
||||||
if (type === "new-feed-entries") {
|
if (type === "new-feed-entries") {
|
||||||
dispatch(
|
dispatch(
|
||||||
incrementUnreadCount({
|
newFeedEntriesDiscovered({
|
||||||
feedId: +parts[1],
|
feedId: +parts[1],
|
||||||
amount: +parts[2],
|
amount: +parts[2],
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user