mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Compare commits
605 Commits
revert-320
...
v4.5.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd493378dc | ||
|
|
d609819aa7 | ||
|
|
4f6a53c22c | ||
|
|
ab872f28b9 | ||
|
|
1103ebdc55 | ||
|
|
93eb2ac28a | ||
|
|
04e0b85f5b | ||
|
|
4046affea9 | ||
|
|
96a96a79ca | ||
|
|
aec9ccba3d | ||
|
|
9c927683db | ||
|
|
fbbf8b9a8c | ||
|
|
b7e34ade1d | ||
|
|
e68754d2a2 | ||
|
|
31316aa082 | ||
|
|
27c1e13aa8 | ||
|
|
17c04fe04b | ||
|
|
ffddcc7c1d | ||
|
|
a602cc9126 | ||
|
|
4d4611beba | ||
|
|
e8045de79b | ||
|
|
5f30206c5e | ||
|
|
6fd034cb77 | ||
|
|
527bed86b5 | ||
|
|
a1e0fbfb67 | ||
|
|
68a26ce7c6 | ||
|
|
ff20ce9acf | ||
|
|
1ba2b1cdc1 | ||
|
|
4c1fbe4e2e | ||
|
|
569ff6c8ad | ||
|
|
81716f7e27 | ||
|
|
8935137526 | ||
|
|
dcc5c2b6f6 | ||
|
|
f1c32f6a11 | ||
|
|
23f04c2623 | ||
|
|
ada1d32394 | ||
|
|
db943c43c8 | ||
|
|
1a74b74a40 | ||
|
|
9a25b12f0c | ||
|
|
6f9b32b137 | ||
|
|
1b3ef035b9 | ||
|
|
6698901d57 | ||
|
|
ba0609bbaf | ||
|
|
d545e55b86 | ||
|
|
25d572e9b9 | ||
|
|
3479b453e5 | ||
|
|
c96eebde37 | ||
|
|
723b2601b8 | ||
|
|
66c06a0655 | ||
|
|
ded7f50f2c | ||
|
|
85eda5b46f | ||
|
|
f1c9c89c39 | ||
|
|
57e0c6562f | ||
|
|
f7b6e57151 | ||
|
|
57f658dc5c | ||
|
|
0cda068918 | ||
|
|
deeaf50472 | ||
|
|
adea0b7b31 | ||
|
|
1eb8d1b967 | ||
|
|
f354bbe8aa | ||
|
|
53437c4653 | ||
|
|
617926742c | ||
|
|
5799d5d306 | ||
|
|
b5d868018d | ||
|
|
55a7b1ea58 | ||
|
|
c1fb6893c5 | ||
|
|
71ae4cf2cf | ||
|
|
2ffe03457d | ||
|
|
c1f5a9db23 | ||
|
|
7c0701d906 | ||
|
|
b134c6a8ef | ||
|
|
a846ed17ff | ||
|
|
3013039720 | ||
|
|
ad4ba5aa00 | ||
|
|
1c5461fffe | ||
|
|
725c1a159d | ||
|
|
b52efea5cb | ||
|
|
a0bdfc46c7 | ||
|
|
afcdc19730 | ||
|
|
80aa3bc8ad | ||
|
|
92955f7e6e | ||
|
|
b868e598bc | ||
|
|
3de59a9344 | ||
|
|
32c3376d84 | ||
|
|
962ae88caf | ||
|
|
7d9d3de972 | ||
|
|
546a95349e | ||
|
|
df1ab0ab90 | ||
|
|
8d1ea4c531 | ||
|
|
8233295e3b | ||
|
|
4eb0a506d3 | ||
|
|
75739a5a9b | ||
|
|
54e08a54e9 | ||
|
|
12ec21a95f | ||
|
|
fe9a71975c | ||
|
|
e1f145973b | ||
|
|
66f25c6709 | ||
|
|
e4e4ffb08d | ||
|
|
e016e2a31e | ||
|
|
6c1e892dd7 | ||
|
|
86cff1abca | ||
|
|
e6d2fc869b | ||
|
|
a9f8268a75 | ||
|
|
dfe269439a | ||
|
|
9bc9ebc59e | ||
|
|
a6d31c0ccf | ||
|
|
1e2cf6c964 | ||
|
|
c42c71c90a | ||
|
|
782e410719 | ||
|
|
b0c141e658 | ||
|
|
1ef4bbd88d | ||
|
|
240d38b7c0 | ||
|
|
770d1212bb | ||
|
|
86e463c0e8 | ||
|
|
a04a210e14 | ||
|
|
300d62f1c4 | ||
|
|
27d33d1233 | ||
|
|
b35c691ab2 | ||
|
|
19588756ef | ||
|
|
e398ff40b2 | ||
|
|
96eb687524 | ||
|
|
05c624cfa7 | ||
|
|
dba811952a | ||
|
|
8836c4fc84 | ||
|
|
1da82862f1 | ||
|
|
8c725777ed | ||
|
|
126823986a | ||
|
|
f04b06a44f | ||
|
|
c7481cb2ca | ||
|
|
7141917943 | ||
|
|
6c7a9b8311 | ||
|
|
8ae06fdbcd | ||
|
|
553bb4673e | ||
|
|
fe6d3690fd | ||
|
|
9746c5eb6b | ||
|
|
b5ae2f07a1 | ||
|
|
9b91852f93 | ||
|
|
0bb7711225 | ||
|
|
86445f45fc | ||
|
|
5fe316b2e9 | ||
|
|
1dbf10198d | ||
|
|
c6ccacdf7b | ||
|
|
6ccd9c2f1f | ||
|
|
261d9b33fe | ||
|
|
4ee21c2e29 | ||
|
|
c08cd6d62a | ||
|
|
44d45e5705 | ||
|
|
27c67f1750 | ||
|
|
bb28552859 | ||
|
|
6486c092f6 | ||
|
|
a7b45682a6 | ||
|
|
5a57c0844a | ||
|
|
1d081250f4 | ||
|
|
e6c8958d07 | ||
|
|
b2506cc110 | ||
|
|
d18491b7a7 | ||
|
|
13330030cd | ||
|
|
f9012a774c | ||
|
|
375af385e7 | ||
|
|
7e5224a3c0 | ||
|
|
140d782cba | ||
|
|
c7a7ce8ce7 | ||
|
|
afbe0a4860 | ||
|
|
febde69d0b | ||
|
|
85b9a5944d | ||
|
|
585827c14f | ||
|
|
bb6093c315 | ||
|
|
058f704c21 | ||
|
|
6baa8f2466 | ||
|
|
e742eff044 | ||
|
|
55b9d21537 | ||
|
|
59f0134578 | ||
|
|
28b9e9087a | ||
|
|
fa2cc409ce | ||
|
|
8a100d84c5 | ||
|
|
9ae0464e8f | ||
|
|
9eea4479e1 | ||
|
|
30103fd2c8 | ||
|
|
a9a7ad62f1 | ||
|
|
ea663cf7c7 | ||
|
|
fbe05d42fb | ||
|
|
29ae9c9c4b | ||
|
|
4684d5e69b | ||
|
|
3ca92c4ae2 | ||
|
|
81b1a34d96 | ||
|
|
26c78392f8 | ||
|
|
caecc88247 | ||
|
|
bed4ca26e2 | ||
|
|
5d108e95d7 | ||
|
|
8a2d38a47b | ||
|
|
048430f4e8 | ||
|
|
d45b4db1d7 | ||
|
|
ef3a95affc | ||
|
|
3e6a9371b0 | ||
|
|
7ab0cfd637 | ||
|
|
b33bcc8be6 | ||
|
|
cdad6ee0c9 | ||
|
|
2d958cb909 | ||
|
|
949f15e200 | ||
|
|
105a2d64a7 | ||
|
|
1c17990413 | ||
|
|
0a8f96d3be | ||
|
|
1ec8e42dbb | ||
|
|
7ea3c6a039 | ||
|
|
e91c764590 | ||
|
|
cfdd9396c0 | ||
|
|
ba498ae779 | ||
|
|
5bae08d1ff | ||
|
|
5253527ec4 | ||
|
|
0b50789c5b | ||
|
|
a978e37f4c | ||
|
|
dd708298a8 | ||
|
|
449eb03f11 | ||
|
|
1baede0a7c | ||
|
|
a7ecfc1ca5 | ||
|
|
e62baacfc1 | ||
|
|
b5a6feb3bf | ||
|
|
05964f571b | ||
|
|
16a54f7158 | ||
|
|
6d53ca63d6 | ||
|
|
93acfdd7d3 | ||
|
|
a209b8e544 | ||
|
|
ca0c5e7a79 | ||
|
|
10a81a2f43 | ||
|
|
9f8fce3c47 | ||
|
|
5f5e6ca031 | ||
|
|
af4c372ab2 | ||
|
|
aa579ce286 | ||
|
|
adfabf8c80 | ||
|
|
ea710df180 | ||
|
|
e1b6e28829 | ||
|
|
214d59bd37 | ||
|
|
e4291e9b05 | ||
|
|
868d782b2b | ||
|
|
1b60f597d7 | ||
|
|
2a9c7d2b9e | ||
|
|
9db64d6908 | ||
|
|
074b3fe57e | ||
|
|
002e592667 | ||
|
|
51877081b4 | ||
|
|
7b66eefd3e | ||
|
|
e437bb919f | ||
|
|
8f00874a0e | ||
|
|
ac920eb364 | ||
|
|
d04e6ec597 | ||
|
|
24234e6632 | ||
|
|
8cd8e69c4b | ||
|
|
293b8f6744 | ||
|
|
48f2597a36 | ||
|
|
12c487cc3e | ||
|
|
adfa407f6b | ||
|
|
c43a5a1834 | ||
|
|
52e2d24a4b | ||
|
|
f94353e1e3 | ||
|
|
0565eb62d6 | ||
|
|
48ec31bec8 | ||
|
|
3bd56b92c1 | ||
|
|
70b8281730 | ||
|
|
fb9e33099f | ||
|
|
79169408b0 | ||
|
|
5a051d07c6 | ||
|
|
5f3f75559f | ||
|
|
4896d2c4c6 | ||
|
|
795aaa14bf | ||
|
|
e1bd9b944a | ||
|
|
26ec19a649 | ||
|
|
b01d21c4d4 | ||
|
|
3ccb6632f2 | ||
|
|
8fb524e07f | ||
|
|
9c7d09993d | ||
|
|
3efc747be3 | ||
|
|
1f5cdb30c7 | ||
|
|
3cace4098a | ||
|
|
ccfac2716d | ||
|
|
422fa1cf9f | ||
|
|
2b5f6838ed | ||
|
|
85d0cdb5f7 | ||
|
|
e4fc18abfd | ||
|
|
e322c1777b | ||
|
|
f53c4db05c | ||
|
|
4905c194b8 | ||
|
|
7ba06a661c | ||
|
|
5d00ae7eb3 | ||
|
|
4b42fe6aba | ||
|
|
3bf99b8a4a | ||
|
|
d0d09fd3a5 | ||
|
|
76053fb4a9 | ||
|
|
402686c76c | ||
|
|
dc851c9efc | ||
|
|
1dead10312 | ||
|
|
e8382c7332 | ||
|
|
bfcf21e915 | ||
|
|
b60bae6361 | ||
|
|
38f15a89fe | ||
|
|
ab5b7e3776 | ||
|
|
1230d05b18 | ||
|
|
779a1f8448 | ||
|
|
e40ca321ed | ||
|
|
5f837001e6 | ||
|
|
2640cf5317 | ||
|
|
7f19b5ca2b | ||
|
|
305f1e5757 | ||
|
|
b11bd2bdbb | ||
|
|
deed31ba8c | ||
|
|
1ba579b0a1 | ||
|
|
6b2051b7b3 | ||
|
|
2fa5dd6d1f | ||
|
|
f7b99cd48a | ||
|
|
92aeecfbdc | ||
|
|
7774cd6670 | ||
|
|
c6e2ac5af9 | ||
|
|
ee87afd6a4 | ||
|
|
2d8b7a7fd8 | ||
|
|
cbc07af929 | ||
|
|
ee9a15031b | ||
|
|
9f7075a0ce | ||
|
|
c40648f7b3 | ||
|
|
2bd5c2f528 | ||
|
|
1e28ec628b | ||
|
|
7538bc77b7 | ||
|
|
7ea2af6ae2 | ||
|
|
6adbd9ce52 | ||
|
|
08ae77fd9c | ||
|
|
a0aa5fe8ea | ||
|
|
209434cb1d | ||
|
|
17eb1a7e66 | ||
|
|
aba30a85be | ||
|
|
de80a54555 | ||
|
|
b80ec3721d | ||
|
|
f4ca3e6c74 | ||
|
|
5674100f0b | ||
|
|
118ff13bd0 | ||
|
|
405a49df44 | ||
|
|
2b9e4294fe | ||
|
|
2eccd7b53c | ||
|
|
74172ced81 | ||
|
|
a74b3c549a | ||
|
|
8b3ff4f8b1 | ||
|
|
af1b658c20 | ||
|
|
7b44fd6d3c | ||
|
|
9080083263 | ||
|
|
341ea7f462 | ||
|
|
168cba35e3 | ||
|
|
6e2973aa2d | ||
|
|
97c8cc5606 | ||
|
|
50dfab30c2 | ||
|
|
53e20d5c83 | ||
|
|
0a1111d5a5 | ||
|
|
95be29d700 | ||
|
|
843c43c97a | ||
|
|
82483ed8b0 | ||
|
|
4c5e9e2419 | ||
|
|
5123f8aa94 | ||
|
|
460222e8e1 | ||
|
|
22e3c0e745 | ||
|
|
84563e54cf | ||
|
|
e1b109f074 | ||
|
|
d81bdb96e0 | ||
|
|
2b2de5cdb1 | ||
|
|
4c0104084d | ||
|
|
cfee957dd1 | ||
|
|
90ab9fe13c | ||
|
|
515ce38a52 | ||
|
|
612771de46 | ||
|
|
950e7beeea | ||
|
|
1018a4def4 | ||
|
|
811c1eaf7e | ||
|
|
6dad80eb8c | ||
|
|
c96e28a41d | ||
|
|
ccac6da3e8 | ||
|
|
ff8f0135b3 | ||
|
|
be5c1cceea | ||
|
|
614dd00457 | ||
|
|
323c6e51fe | ||
|
|
cf16039ea1 | ||
|
|
383445c977 | ||
|
|
2fb14cbb2b | ||
|
|
0512ffcbcd | ||
|
|
f56fc1bab4 | ||
|
|
20961c7538 | ||
|
|
63dc426fae | ||
|
|
210b389643 | ||
|
|
51d0bfcb38 | ||
|
|
7530f06dee | ||
|
|
28339cad6d | ||
|
|
5404f92cee | ||
|
|
05244c335d | ||
|
|
241ad1c587 | ||
|
|
ef53dcfd8c | ||
|
|
869eeecfee | ||
|
|
24089589b7 | ||
|
|
28a42bb62c | ||
|
|
905aa9434d | ||
|
|
ef3f2ced09 | ||
|
|
2ccab68636 | ||
|
|
ef413ef713 | ||
|
|
5a8ab0a3e6 | ||
|
|
c5fb080ab8 | ||
|
|
fab0dd0bcf | ||
|
|
c6de46d12d | ||
|
|
3160f5746d | ||
|
|
d8bdce2835 | ||
|
|
339673d533 | ||
|
|
d0d1fcd034 | ||
|
|
3550508421 | ||
|
|
e3e5067772 | ||
|
|
5fd380096f | ||
|
|
d788e45628 | ||
|
|
b4aae2262b | ||
|
|
264d068d8d | ||
|
|
c4c906eb0c | ||
|
|
7330ec670b | ||
|
|
8d1e67b6b2 | ||
|
|
a7f207604a | ||
|
|
302e3fe8be | ||
|
|
50743cc35b | ||
|
|
fd516347cb | ||
|
|
3232eee358 | ||
|
|
484225895f | ||
|
|
156f031ed0 | ||
|
|
ad858ebe81 | ||
|
|
f2f711deeb | ||
|
|
44ecc4b1e3 | ||
|
|
0c64e7f75e | ||
|
|
b0ef7ee922 | ||
|
|
9a001e7839 | ||
|
|
ed456a6446 | ||
|
|
af82db5271 | ||
|
|
edd7fd9872 | ||
|
|
33f739da44 | ||
|
|
12c5e892f8 | ||
|
|
be762da45f | ||
|
|
254fff93ca | ||
|
|
2971ac9863 | ||
|
|
3354f4ce8b | ||
|
|
8921494571 | ||
|
|
7e98fa9b47 | ||
|
|
24dcb18013 | ||
|
|
8d09e4ef23 | ||
|
|
692cfe27fa | ||
|
|
8b78c033e8 | ||
|
|
ab93e9fc8a | ||
|
|
807a4bdec9 | ||
|
|
5dff3414ce | ||
|
|
adc0e15167 | ||
|
|
ee88f8550c | ||
|
|
bcccfb19df | ||
|
|
2575ff5ce2 | ||
|
|
0219b7cad7 | ||
|
|
3f2ee09827 | ||
|
|
8898f120dc | ||
|
|
81350c7cfb | ||
|
|
8aee7fa400 | ||
|
|
8a50fb02ce | ||
|
|
3c3a812a9c | ||
|
|
48bb64cde3 | ||
|
|
490c89a5a9 | ||
|
|
c858fc77ef | ||
|
|
258869278e | ||
|
|
d4a4a7177a | ||
|
|
b7c5e60426 | ||
|
|
a459ccf616 | ||
|
|
ba70dcf827 | ||
|
|
0152659245 | ||
|
|
44b45ab0b7 | ||
|
|
5bc7c4b7e8 | ||
|
|
62fc92dfd8 | ||
|
|
c08a874ba9 | ||
|
|
c4ef050eb6 | ||
|
|
c52473eebc | ||
|
|
fc1b407d89 | ||
|
|
f870904e5f | ||
|
|
1c278df424 | ||
|
|
7f5232c377 | ||
|
|
da99ec0eea | ||
|
|
d34b4f3fd0 | ||
|
|
d5ce785267 | ||
|
|
0281768cfd | ||
|
|
f800057008 | ||
|
|
b8444d9bb7 | ||
|
|
babb7b2b9d | ||
|
|
5c92312d4d | ||
|
|
0c1ca6c969 | ||
|
|
2b213e9b1b | ||
|
|
4fd5b6e73b | ||
|
|
0be0a8898a | ||
|
|
d8f0326b02 | ||
|
|
987f1e897b | ||
|
|
6abda76d13 | ||
|
|
3867f3bc61 | ||
|
|
4fce4337d8 | ||
|
|
092f46f61a | ||
|
|
e4c3854ae8 | ||
|
|
aa7bcd3ae3 | ||
|
|
bc7119b3cb | ||
|
|
e02ea3e110 | ||
|
|
3c9b828c71 | ||
|
|
c578a0cb74 | ||
|
|
da6ae98e57 | ||
|
|
fb6fd7b7e1 | ||
|
|
d51717c101 | ||
|
|
63bbe4ee16 | ||
|
|
adcbab527a | ||
|
|
a7f89d13d2 | ||
|
|
e8dab026bb | ||
|
|
503c4d16bb | ||
|
|
f7fa3300ae | ||
|
|
e82def7dad | ||
|
|
3b659cfb48 | ||
|
|
73a625c284 | ||
|
|
9027d60420 | ||
|
|
474fbb2770 | ||
|
|
4a40f81067 | ||
|
|
cda07686df | ||
|
|
68a36d5a57 | ||
|
|
ffac4cb05f | ||
|
|
4dc21d7afd | ||
|
|
2d2c525097 | ||
|
|
62f91eddf4 | ||
|
|
d5097398fe | ||
|
|
64393e0878 | ||
|
|
432af3c25d | ||
|
|
80c8a84740 | ||
|
|
1561517387 | ||
|
|
5543967e5c | ||
|
|
0d7af7e1fe | ||
|
|
4809b38f6e | ||
|
|
554dd1a76a | ||
|
|
ade87f395c | ||
|
|
97065cd266 | ||
|
|
74ee0960a1 | ||
|
|
be62874934 | ||
|
|
0e97992940 | ||
|
|
4f13515468 | ||
|
|
7430e3c74d | ||
|
|
752214622c | ||
|
|
17e0c745c6 | ||
|
|
f69ca085db | ||
|
|
3ee1378932 | ||
|
|
f03d1bb21f | ||
|
|
669738ef3b | ||
|
|
94a4e9d5a9 | ||
|
|
9114e72c50 | ||
|
|
6c3c2714d7 | ||
|
|
e4094d9fb2 | ||
|
|
a9234d99cd | ||
|
|
cbb94bdfde | ||
|
|
d51723bb57 | ||
|
|
5af40ff960 | ||
|
|
473bd84c24 | ||
|
|
c12b8f51c1 | ||
|
|
ac50e5eebc | ||
|
|
589af7a1cc | ||
|
|
45219dbf64 | ||
|
|
a6236148d8 | ||
|
|
6deeeec2ed | ||
|
|
5b97f25a15 | ||
|
|
c4cd2c955b | ||
|
|
b6c53c1129 | ||
|
|
150f0fcba5 | ||
|
|
4d7c208da3 | ||
|
|
dc72719f4c | ||
|
|
9f1a12b749 | ||
|
|
adbd57e5a9 | ||
|
|
8779bbc4c1 | ||
|
|
bbb698937a | ||
|
|
4d8e848c6a | ||
|
|
aae9a5528a | ||
|
|
e871d4ebbb | ||
|
|
a4dc785de3 | ||
|
|
8cccabb714 | ||
|
|
106b0a9d93 | ||
|
|
eea86088cf | ||
|
|
381fc173c7 | ||
|
|
364aced99e | ||
|
|
3cd021d4d9 | ||
|
|
8fad4926c5 | ||
|
|
a44a3f6d40 | ||
|
|
cb5bbbfb05 | ||
|
|
e07b9dfdc1 | ||
|
|
1571514e49 | ||
|
|
5110c6006a | ||
|
|
238d74fe81 | ||
|
|
7431c50566 | ||
|
|
c2d426a565 | ||
|
|
f61d8cb02a | ||
|
|
40f9873504 | ||
|
|
3eed83b6ff | ||
|
|
4053209a05 | ||
|
|
2c6de7dca4 | ||
|
|
11bd515648 | ||
|
|
d801cf8e59 | ||
|
|
6d2493ca7c | ||
|
|
66686994c1 | ||
|
|
719b2de3c3 | ||
|
|
d2bdb03da0 | ||
|
|
507e6dc473 | ||
|
|
52d5e628a4 | ||
|
|
85213dab47 | ||
|
|
8fac87d77c | ||
|
|
fda3589498 | ||
|
|
0798d0c95a | ||
|
|
33fd8c774b | ||
|
|
e0f7aedf41 | ||
|
|
cc54b33720 |
@@ -318,24 +318,3 @@ MAX_POLL_OPTION_CHARS=100
|
||||
# -----------------------
|
||||
IP_RETENTION_PERIOD=31556952
|
||||
SESSION_RETENTION_PERIOD=31556952
|
||||
|
||||
# Fetch All Replies Behavior
|
||||
# --------------------------
|
||||
# When a user expands a post (DetailedStatus view), fetch all of its replies
|
||||
# (default: false)
|
||||
FETCH_REPLIES_ENABLED=false
|
||||
|
||||
# Period to wait between fetching replies (in minutes)
|
||||
FETCH_REPLIES_COOLDOWN_MINUTES=15
|
||||
|
||||
# Period to wait after a post is first created before fetching its replies (in minutes)
|
||||
FETCH_REPLIES_INITIAL_WAIT_MINUTES=5
|
||||
|
||||
# Max number of replies to fetch - total, recursively through a whole reply tree
|
||||
FETCH_REPLIES_MAX_GLOBAL=1000
|
||||
|
||||
# Max number of replies to fetch - for a single post
|
||||
FETCH_REPLIES_MAX_SINGLE=500
|
||||
|
||||
# Max number of replies Collection pages to fetch - total
|
||||
FETCH_REPLIES_MAX_PAGES=500
|
||||
|
||||
42
.github/workflows/build-releases.yml
vendored
42
.github/workflows/build-releases.yml
vendored
@@ -9,7 +9,44 @@ permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
check-latest-stable:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
latest: ${{ steps.check.outputs.is_latest_stable }}
|
||||
steps:
|
||||
# Repository needs to be cloned to list branches
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check latest stable
|
||||
shell: bash
|
||||
id: check
|
||||
run: |
|
||||
ref="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
if [[ "$ref" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then
|
||||
current="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
else
|
||||
echo "tag $ref is not semver"
|
||||
echo "is_latest_stable=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
latest=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/stable-*.*" \
|
||||
| sed -E 's#^origin/stable-##' \
|
||||
| sort -Vr \
|
||||
| head -n1)
|
||||
|
||||
if [[ "$current" == "$latest" ]]; then
|
||||
echo "is_latest_stable=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_latest_stable=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build-image:
|
||||
needs: check-latest-stable
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
file_to_build: Dockerfile
|
||||
@@ -20,13 +57,14 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
latest=${{ needs.check-latest-stable.outputs.latest }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
secrets: inherit
|
||||
|
||||
build-image-streaming:
|
||||
needs: check-latest-stable
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
file_to_build: streaming/Dockerfile
|
||||
@@ -37,7 +75,7 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
latest=${{ needs.check-latest-stable.outputs.latest }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
|
||||
@@ -9,7 +9,7 @@ permissions:
|
||||
jobs:
|
||||
download-translations-stable:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'mastodon/mastodon'
|
||||
if: github.repository == 'glitch-soc/mastodon'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@
|
||||
/public/packs
|
||||
/public/packs-dev
|
||||
/public/packs-test
|
||||
stats.html
|
||||
.env
|
||||
.env.production
|
||||
node_modules/
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.4.6
|
||||
3.4.7
|
||||
|
||||
@@ -50,9 +50,13 @@ const preview: Preview = {
|
||||
locale: 'en',
|
||||
},
|
||||
decorators: [
|
||||
(Story, { parameters, globals }) => {
|
||||
(Story, { parameters, globals, args }) => {
|
||||
// Get the locale from the global toolbar
|
||||
// and merge it with any parameters or args state.
|
||||
const { locale } = globals as { locale: string };
|
||||
const { state = {} } = parameters;
|
||||
const { state: argsState = {} } = args;
|
||||
|
||||
const reducer = reducerWithInitialState(
|
||||
{
|
||||
meta: {
|
||||
@@ -60,7 +64,9 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
state as Record<string, unknown>,
|
||||
argsState as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
middleware(getDefaultMiddleware) {
|
||||
|
||||
312
CHANGELOG.md
312
CHANGELOG.md
@@ -2,6 +2,318 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.5.7] - 2026-02-24
|
||||
|
||||
### Security
|
||||
|
||||
- Reject unconfirmed FASPs (#37926 by @oneiros, [GHSA-qgmm-vr4c-ggjg](https://github.com/mastodon/mastodon/security/advisories/GHSA-qgmm-vr4c-ggjg))
|
||||
- Re-use custom socket class for FASP requests (#37925 by @oneiros, [GHSA-46w6-g98f-wxqm](https://github.com/mastodon/mastodon/security/advisories/GHSA-46w6-g98f-wxqm))
|
||||
|
||||
### Added
|
||||
|
||||
- Add `--suspended-only` option to `tootctl emoji purge` (#37828 and #37861 by @ClearlyClaire and @mjankowski)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix emoji data not being properly cached (#37858 by @ChaosExAnima)
|
||||
- Fix delete & redraft of pending posts (#37839 by @ClearlyClaire)
|
||||
- Fix processing separate key documents without the ActivityStreams context (#37826 by @ClearlyClaire)
|
||||
- Fix custom emojis not being purged on domain suspension (#37808 by @ClearlyClaire)
|
||||
- Fix users without special permissions being able to stream disabled timelines (#37791 by @ClearlyClaire)
|
||||
- Fix processing of object updates with duplicate hashtags (#37756 by @ClearlyClaire)
|
||||
|
||||
## [4.5.6] - 2026-02-03
|
||||
|
||||
### Security
|
||||
|
||||
- Fix ActivityPub collection caching logic for pinned posts and featured tags not checking blocked accounts ([GHSA-ccpr-m53r-mfwr](https://github.com/mastodon/mastodon/security/advisories/GHSA-ccpr-m53r-mfwr))
|
||||
|
||||
### Changed
|
||||
|
||||
- Shorten caching of quote posts pending approval (#37570 and #37592 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix relationship cache not being cleared when handling account migrations (#37664 by @ClearlyClaire)
|
||||
- Fix quote cancel button not appearing after edit then delete-and-redraft (#37066 by @PGrayCS)
|
||||
- Fix followers with profile subscription (bell icon) being notified of post edits (#37646 by @ClearlyClaire)
|
||||
- Fix error when encountering invalid tag in updated object (#37635 by @ClearlyClaire)
|
||||
- Fix cross-server conversation tracking (#37559 by @ClearlyClaire)
|
||||
- Fix recycled connections not being immediately closed (#37335 and #37674 by @ClearlyClaire and @shleeable)
|
||||
|
||||
## [4.5.5] - 2026-01-20
|
||||
|
||||
### Security
|
||||
|
||||
- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g)
|
||||
- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp)
|
||||
- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3)
|
||||
- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4)
|
||||
|
||||
### Changed
|
||||
|
||||
- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire)
|
||||
- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire)
|
||||
- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire)
|
||||
- Fix `quote_approval_policy` being reset to user defaults when omitted in status update (#37436 and #37474 by @mjankowski and @shleeable)
|
||||
- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec)
|
||||
- Fix missing URI scheme test in `QuoteRequest` handling (#37425 by @MegaManSec)
|
||||
- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec)
|
||||
- Fix URI generation for reblogs by accounts with numerical ActivityPub identifiers (#37415 by @oneiros)
|
||||
- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable)
|
||||
- Fix emoji with variant selector not being rendered properly (#37320 by @ChaosExAnima)
|
||||
- Fix mobile admin sidebar displaying under batch table toolbar (#37307 by @diondiondion)
|
||||
|
||||
## [4.5.4] - 2026-01-07
|
||||
|
||||
### Security
|
||||
|
||||
- Fix SSRF protection bypass ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-xfrj-c749-jxxq))
|
||||
- Fix missing ownership check in severed relationships controller ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-ww85-x9cp-5v24))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change HTTP Signature verification status from 401 to 503 on temporary failure to get remote actor (#37221 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix custom emojis not being rendered in profile fields (#37365 by @ClearlyClaire)
|
||||
- Fix serialization of context pages (#37376 by @ClearlyClaire)
|
||||
- Fix quotes with CWs but no text not having fallback link (#37361 by @ClearlyClaire)
|
||||
- Fix outdated link target for “locked” warning (#37366 by @ClearlyClaire)
|
||||
- Fix local custom emojis sometimes being rendered in remote posts (#37284 by @ChaosExAnima)
|
||||
- Fix some assets not being loaded from configured CDN (#37310 by @ChaosExAnima)
|
||||
- Fix notifications page error in Tor browser (#37285 by @diondiondion)
|
||||
- Fix custom emojis not being displayed in CWs and fav/boost notifications (#37272 and #37306 by @ChaosExAnima and @ClearlyClaire)
|
||||
- Fix default `Admin` role not including `view_feeds` permission (#37301 by @ClearlyClaire)
|
||||
- Fix hashtag autocomplete replacing suggestion's first characters with input (#37281 by @ClearlyClaire)
|
||||
- Fix mentions of domain-blocked users being processed (#37257 by @ClearlyClaire)
|
||||
|
||||
## [4.5.3] - 2025-12-08
|
||||
|
||||
### Security
|
||||
|
||||
- Fix inconsistent error handling leaking information on existence of private posts ([GHSA-gwhw-gcjx-72v8](https://github.com/mastodon/mastodon/security/advisories/GHSA-gwhw-gcjx-72v8))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix “Delete and Redraft” on a non-quote being treated as a quote post in some cases (#37140 by @ClearlyClaire)
|
||||
- Fix YouTube embeds by sending referer (#37126 by @ChaosExAnima)
|
||||
- Fix streamed quoted polls not being hydrated correctly (#37118 by @ClearlyClaire)
|
||||
- Fix creation of duplicate conversations (#37108 by @oneiros)
|
||||
- Fix extraneous `noreferrer` in external links (#37107 by @ChaosExAnima)
|
||||
- Fix edge case error handling in some database migrations (#37079 by @ClearlyClaire)
|
||||
- Fix error handling when re-fetching already-known statuses (#37077 by @ClearlyClaire)
|
||||
- Fix post navigation in single-column mode when Advanced UI is enabled (#37044 by @diondiondion)
|
||||
- Fix `tootctl status remove` removing quoted posts and remote quotes of local posts (#37009 by @ClearlyClaire)
|
||||
- Fix known expensive S3 batch delete operation failing because of short timeouts (#37004 by @ClearlyClaire)
|
||||
- Fix compose autosuggest always lowercasing input token (#36995 by @ClearlyClaire)
|
||||
|
||||
## [4.5.2] - 2025-11-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Change private quote education modal to not show up on self-quotes (#36926 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix missing fallback link in CW-only quote posts (#36963 by @ClearlyClaire)
|
||||
- Fix statuses without text being hidden while loading (#36962 by @ClearlyClaire)
|
||||
- Fix `g` + `h` keyboard shortcut not working when a post is focused (#36935 by @diondiondion)
|
||||
- Fix quoting overwriting current content warning (#36934 by @ClearlyClaire)
|
||||
- Fix scroll-to-status in threaded view being unreliable (#36927 by @ClearlyClaire)
|
||||
- Fix path resolution for emoji worker (#36897 by @ChaosExAnima)
|
||||
- Fix `tootctl upgrade storage-schema` failing with `ArgumentError` (#36914 by @shugo)
|
||||
- Fix cross-origin handling of CSS modules (#36890 by @ClearlyClaire)
|
||||
- Fix error with remote tags including percent signs (#36886 and #36925 by @ChaosExAnima and @ClearlyClaire)
|
||||
- Fix bogus quote approval policy not always being replaced correctly (#36885 by @ClearlyClaire)
|
||||
- Fix hashtag completion not being inserted correctly (#36884 by @ClearlyClaire)
|
||||
- Fix Cmd/Ctrl + Enter in the composer triggering confirmation dialog action (#36870 by @diondiondion)
|
||||
|
||||
## [4.5.1] - 2025-11-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers (#36866 by @diondiondion)
|
||||
- Fix posts coming from public/hashtag streaming being marked as unquotable (#36860 and #36869 by @ClearlyClaire)
|
||||
- Fix old previously-undiscovered posts being treated as new when receiving an `Update` (#36848 by @ClearlyClaire)
|
||||
- Fix blank screen in browsers that don't support `Intl.DisplayNames` (#36847 by @diondiondion)
|
||||
- Fix filters not being applied to quotes in detailed view (#36843 by @ClearlyClaire)
|
||||
- Fix scroll shift caused by fetch-all-replies alerts (#36807 by @diondiondion)
|
||||
- Fix dropdown menu not focusing first item when opened via keyboard (#36804 by @diondiondion)
|
||||
- Fix assets build issue on arch64 (#36781 by @ClearlyClaire)
|
||||
- Fix `/api/v1/statuses/:id/context` sometimes returing `Mastodon-Async-Refresh` without `result_count` (#36779 by @ClearlyClaire)
|
||||
- Fix prepared quote not being discarded with contents when replying (#36778 by @ClearlyClaire)
|
||||
|
||||
## [4.5.0] - 2025-11-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Add support for allowing and authoring quotes** (#35355, #35578, #35614, #35618, #35624, #35626, #35652, #35629, #35665, #35653, #35670, #35677, #35690, #35697, #35689, #35699, #35700, #35701, #35709, #35714, #35713, #35715, #35725, #35749, #35769, #35780, #35762, #35804, #35808, #35805, #35819, #35824, #35828, #35822, #35835, #35865, #35860, #35832, #35891, #35894, #35895, #35820, #35917, #35924, #35925, #35914, #35930, #35941, #35939, #35948, #35955, #35967, #35990, #35991, #35975, #35971, #36002, #35986, #36031, #36034, #36038, #36054, #36052, #36055, #36065, #36068, #36083, #36087, #36080, #36091, #36090, #36118, #36119, #36128, #36094, #36129, #36138, #36132, #36151, #36158, #36171, #36194, #36220, #36169, #36130, #36249, #36153, #36299, #36291, #36301, #36315, #36317, #36364, #36383, #36381, #36459, #36464, #36461, #36516, #36528, #36549, #36550, #36559, #36693, #36704, #36690, #36689, #36696, #36721, #36695 and #36736 by @ChaosExAnima, @ClearlyClaire, @Lycolia, @diondiondion, and @tribela)\
|
||||
This includes a revamp of the composer interface.\
|
||||
See https://blog.joinmastodon.org/2025/09/introducing-quote-posts/ for a user-centric overview of the feature, and https://docs.joinmastodon.org/client/quotes/ for API documentation.
|
||||
- **Add support for fetching and refreshing replies to the web UI** (#35210, #35496, #35575, #35500, #35577, #35602, #35603, #35654, #36141, #36237, #36172, #36256, #36271, #36334, #36382, #36239, #36484, #36481, #36583, #36627 and #36547 by @ClearlyClaire, @diondiondion, @Gargron and @renchap)
|
||||
- **Add ability to block words in usernames** (#35407, #35655, and #35806 by @ClearlyClaire and @Gargron)
|
||||
- Add ability to individually disable local or remote feeds for visitors or logged-in users `disabled` value to server setting for live and topic feeds, as well as user permission to bypass that (#36338, #36467, #36497, #36563, #36577, #36585, #36607 and #36703 by @ClearlyClaire)\
|
||||
This splits the `timeline_preview` setting into four more granular settings controlling live feeds and topic (hashtag, trending link) feeds.\
|
||||
The setting for local topic feeds has 2 values: `public` and `authenticated`. Every other setting has 3 values: `public`, `authenticated`, `disabled`.\
|
||||
When `disabled`, users with the “View live and topic feeds” will still be able to view them.
|
||||
- Add support for displaying of quote posts in Moderator UI (#35964 by @ThisIsMissEm)
|
||||
- Add support for displaying link previews for Admin UI (#35958 by @ThisIsMissEm)
|
||||
- Add a new server setting to choose the server landing page (#36588 and #36602 by @ClearlyClaire and @renchap)
|
||||
- Add support for `Update` activities on converted object types (#36322 by @ClearlyClaire)
|
||||
- Add support for dynamic viewport height (#36272 by @e1berd)
|
||||
- Add support for numeric-based URIs for new local accounts (#32724, #36304, #36316, and #36365 by @ClearlyClaire)
|
||||
- Add default visualizer for audio upload without poster (#36734 by @ChaosExAnima)
|
||||
- Add Traditional Mongolian to posting languages (#36196 by @shimon1024)
|
||||
- Add example post with manual quote approval policy to `dev:populate_sample_data` (#36099 by @ClearlyClaire)
|
||||
- Add server-side support for handling posts with a quote policy allowing followers to quote (#36093 and #36127 by @ClearlyClaire)
|
||||
- Add schema.org markup to SEO-enabled posts (#36075 by @Gargron)
|
||||
- Add migration to fill unset default quote policy based on default post privacy (#36041 by @ClearlyClaire)
|
||||
- Add “Posting defaults” setting page, moving existing settings from “Other” (#35896, #36033, #35966, #35969, and #36084 by @ClearlyClaire and @diondiondion)
|
||||
- Added emoji from Twemoji v16 (#36501 and #36530 by @ChaosExAnima)
|
||||
- Add feature to select custom emoji rendering (#35229, #35282, #35253, #35424, #35473, #35483, #35505, #35568, #35605, #35659, #35664, #35739, #35985, #36051, #36071, #36137, #36165, #36248, #36262, #36275, #36293, #36341, #36342, #36366, #36377, #36378, #36385, #36393, #36397, #36403, #36413, #36410, #36454, #36402, #36503, #36502, #36532, #36603, #36409, #36638 and #36750 by @ChaosExAnima, @ClearlyClaire and @braddunbar)\
|
||||
This also completely reworks the processing and rendering of emojis and server-rendered HTML in statuses and other places.
|
||||
- Add support for exposing conversation context for new public conversations according to FEP-7888 (#35959 and #36064 by @ClearlyClaire and @jesseplusplus)
|
||||
- Add digest re-check before removing followers in synchronization mechanism (#34273 by @ClearlyClaire)
|
||||
- Add support for displaying Valkey version on admin dashboard (#35785 by @ykzts)
|
||||
- Add delivery failure tracking and handling to FASP jobs (#35625, #35628, and #35723 by @oneiros)
|
||||
- Add example of quote post with a preview card to development sample data (#35616 by @ClearlyClaire)
|
||||
- Add second set of blocked text that applies to accounts regardless of account age for spam-blocking (#35563 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change confirmation dialogs for follow button actions “unfollow”, “unblock”, and “withdraw request” (#36289 by @diondiondion)
|
||||
- Change “Follow” button labels (#36264 by @diondiondion)
|
||||
- Change appearance settings to introduce new Advanced settings section (#36496 and #36506 by @diondiondion)
|
||||
- Change display of blocked and muted quoted users (#36619 by @ClearlyClaire)\
|
||||
This adds `blocked_account`, `blocked_domain` and `muted_account` values to the `state` attribute of `Quote` and `ShallowQuote` REST API entities.
|
||||
- Change submitting an empty post to show an error rather than failing silently (#36650 by @diondiondion)
|
||||
- Change "Privacy and reach" settings from "Public profile" to their own top-level category (#27294 by @ChaelCodes)
|
||||
- Change number of times quote verification is retried to better deal with temporary failures (#36698 by @ClearlyClaire)
|
||||
- Change display of content warnings in Admin UI (#35935 by @ThisIsMissEm)
|
||||
- Change styling of column banners (#36531 by @ClearlyClaire)
|
||||
- Change recommended Node version to 24 (LTS) (#36539 by @renchap)
|
||||
- Change min. characters required for logged-out account search from 5 to 3 (#36487 by @Gargron)
|
||||
- Change browser target to Vite legacy plugin defaults (#36611 by @larouxn)
|
||||
- Change index on `follows` table to improve performance of some queries (#36374 by @ClearlyClaire)
|
||||
- Change links to accounts in settings and moderation views to link to local view unless account is suspended (#36340 by @diondiondion)
|
||||
- Change redirection for denied registration from web app to sign-in page with error message (#36384 by @ClearlyClaire)
|
||||
- Change support for RFC9421 HTTP signatures to be enabled unconditionally (#36610 by @oneiros)
|
||||
- Change wording and design of interaction dialog to simplify it (#36124 by @diondiondion)
|
||||
- Change dropdown menus to allow disabled items to be focused (#36078 by @diondiondion)
|
||||
- Change modal background colours in light mode (#36069 by @diondiondion)
|
||||
- Change “Posting defaults” settings page to enforce `nobody` quote policy for `private` default visibility (#36040 by @ClearlyClaire)
|
||||
- Change description of “Quiet public” (#36032 by @ClearlyClaire)
|
||||
- Change “Boost with original visibility” to “Share again with your followers” (#36035 by @ClearlyClaire)
|
||||
- Change handling of push subscriptions to automatically delete invalid ones on delivery (#35987 by @ThisIsMissEm)
|
||||
- Change design of quote posts in web UI (#35584 and #35834 by @Gargron)
|
||||
- Change auditable accounts to be sorted by username in admin action logs interface (#35272 by @breadtk)
|
||||
- Change order of translation restoration and service credit on post card (#33619 by @colindean)
|
||||
- Change position of ‘add more’ to be inside table toolbar on reports (#35963 by @ThisIsMissEm)
|
||||
- Change docker-compose.yml sidekiq health check to work for both 4.4 and 4.5 (#36498 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix relationship not being fetched to evaluate whether to show a quote post (#36517 by @ClearlyClaire)
|
||||
- Fix rendering of poll options in status history modal (#35633 by @ThisIsMissEm)
|
||||
- Fix “mute” button being displayed to unauthenticated visitors in hashtag dropdown (#36353 by @mkljczk)
|
||||
- Fix initially selected language in Rules panel, hide selector when no alternative translations exist (#36672 by @diondiondion)
|
||||
- Fix URL comparison for mentions in case of empty path (#36613 and #36626 by @ClearlyClaire)
|
||||
- Fix hashtags not being picked up when full-width hash sign is used (#36103 and #36625 by @ClearlyClaire and @Gargron)
|
||||
- Fix layout of severed relationships when purged events are listed (#36593 by @mejofi)
|
||||
- Fix Skeleton placeholders being animated when setting to reduce animations is enabled (#36716 by @ClearlyClaire)
|
||||
- Fix vacuum tasks being interrupted by a single batch failure (#36606 by @Gargron)
|
||||
- Fix handling of unreachable network error for search services (#36587 by @mjankowski)
|
||||
- Fix bookmarks export when a bookmarked status is soft-deleted (#36576 by @ClearlyClaire)
|
||||
- Fix text overflow alignment for long author names in News (#36562 by @diondiondion)
|
||||
- Fix discovery preamble missing word in admin settings (#36560 by @belatedly)
|
||||
- Fix overflow handling of `.more-from-author` (#36310 by @edent)
|
||||
- Fix unfortunate action button wrapping in admin area (#36247 by @diondiondion)
|
||||
- Fix translate button width in Safari (#36164 and #36216 by @diondiondion)
|
||||
- Fix login page linking to other pages within OAuth authorization flow (#36115 by @Gargron)
|
||||
- Fix stale search results being displayed in Web UI while new query is in progress (#36053 by @ChaosExAnima)
|
||||
- Fix YouTube iframe not being able to start at a defined time (#26584 by @BrunoViveiros)
|
||||
- Fix banned text being able to be circumvented via unicode (#35978 by @Gargron)
|
||||
- Fix batch table toolbar displaying under status media (#35962 by @ThisIsMissEm)
|
||||
- Fix incorrect RSS feed MIME type in gzip_types directive (#35562 by @iioflow)
|
||||
- Fix 404 error after deleting status from detail view (#35800) (#35881 by @crafkaz)
|
||||
- Fix feeds keyboard navigation issues (#35853, #35864, and #36267 by @braddunbar and @diondiondion)
|
||||
- Fix layout shift caused by “Who to follow” widget (#35861 by @diondiondion)
|
||||
- Fix Vagrantfile (#35765 by @ClearlyClaire)
|
||||
- Fix reply indicator displaying wrong avatar in rare cases (#35756 by @ClearlyClaire)
|
||||
- Fix `Chewy::UndefinedUpdateStrategy` in `dev:populate_sample_data` task when Elasticsearch is enabled (#35615 by @ClearlyClaire)
|
||||
- Fix unnecessary account note addition for already-muted moved-to users (#35566 by @mjankowski)
|
||||
- Fix seeded admin user creation failing on specific configurations (#35565 by @oneiros)
|
||||
- Fix media modal images in Web UI having redundant `title` attribute (#35468 by @mayank99)
|
||||
- Fix inconsistent default privacy post setting when unset in settings (#35422 by @oneiros)
|
||||
- Fix glitchy status keyboard navigation (#35455 and #35504 by @diondiondion)
|
||||
- Fix post being submitted when pressing “Enter” in the CW field (#35445 by @diondiondion)
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove support for PostgreSQL 13 (#36540 by @renchap)
|
||||
|
||||
## [4.4.8] - 2025-10-21
|
||||
|
||||
### Security
|
||||
|
||||
- Fix quote control bypass ([GHSA-8h43-rcqj-wpc6](https://github.com/mastodon/mastodon/security/advisories/GHSA-8h43-rcqj-wpc6))
|
||||
|
||||
## [4.4.7] - 2025-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix forwarder being called with `nil` status when quote post is soft-deleted (#36463 by @ClearlyClaire)
|
||||
- Fix moderation warning e-mails that include posts (#36462 by @ClearlyClaire)
|
||||
- Fix allow_referrer_origin typo (#36460 by @ShadowJonathan)
|
||||
|
||||
## [4.4.6] - 2025-10-13
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies `rack` and `uri`
|
||||
- Fix streaming server connection not being closed on user suspension (by @ThisIsMissEm, [GHSA-r2fh-jr9c-9pxh](https://github.com/mastodon/mastodon/security/advisories/GHSA-r2fh-jr9c-9pxh))
|
||||
- Fix password change through admin CLI not invalidating existing sessions and access tokens (by @ThisIsMissEm, [GHSA-f3q3-rmf7-9655](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q3-rmf7-9655))
|
||||
- Fix streaming server allowing access to public timelines even without the `read` or `read:statuses` OAuth scopes (by @ThisIsMissEm, [GHSA-7gwh-mw97-qjgp](https://github.com/mastodon/mastodon/security/advisories/GHSA-7gwh-mw97-qjgp))
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for processing quotes of deleted posts signaled through a `Tombstone` (#36381 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix quote post state sometimes not being updated through streaming server (#36408 by @ClearlyClaire)
|
||||
- Fix inconsistent “pending tags” count on admin dashboard (#36404 by @mjankowski)
|
||||
- Fix JSON payload being potentially mutated when processing interaction policies (#36392 by @ClearlyClaire)
|
||||
- Fix quotes not being displayed in email notifications (#36379 by @diondiondion)
|
||||
- Fix redirect to external object when URL is missing or malformed (#36347 by @ClearlyClaire)
|
||||
- Fix quotes not being displayed in the featured carousel (#36335 by @diondiondion)
|
||||
|
||||
## [4.4.5] - 2025-09-23
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for `has:quote` in search (#36217 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change quoted posts from silenced accounts to use a click-through rather than being hidden (#36166 and #36167 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix processing of out-of-order `Update` as implicit updates (#36190 by @ClearlyClaire)
|
||||
- Fix getting `Create` and `Update` out of order (#36176 by @ClearlyClaire)
|
||||
- Fix quotes with Content Warnings but no text being shown without Content Warnings (#36150 by @ClearlyClaire)
|
||||
|
||||
## [4.4.4] - 2025-09-16
|
||||
|
||||
### Security
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -13,10 +13,10 @@ ARG BASE_REGISTRY="docker.io"
|
||||
|
||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
||||
# renovate: datasource=docker depName=docker.io/ruby
|
||||
ARG RUBY_VERSION="3.4.6"
|
||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||
ARG RUBY_VERSION="3.4.7"
|
||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"]
|
||||
# renovate: datasource=node-version depName=node
|
||||
ARG NODE_MAJOR_VERSION="22"
|
||||
ARG NODE_MAJOR_VERSION="24"
|
||||
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"]
|
||||
ARG DEBIAN_VERSION="trixie"
|
||||
# Node.js image to use for base image based on combined variables (ex: 20-trixie-slim)
|
||||
@@ -183,7 +183,7 @@ FROM build AS libvips
|
||||
|
||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||
ARG VIPS_VERSION=8.17.2
|
||||
ARG VIPS_VERSION=8.17.3
|
||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||
|
||||
@@ -208,12 +208,12 @@ FROM build AS ffmpeg
|
||||
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
||||
ARG FFMPEG_VERSION=8.0
|
||||
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
||||
ARG FFMPEG_URL=https://ffmpeg.org/releases
|
||||
ARG FFMPEG_URL=https://github.com/FFmpeg/FFmpeg/archive/refs/tags
|
||||
|
||||
WORKDIR /usr/local/ffmpeg/src
|
||||
# Download and extract ffmpeg source code
|
||||
ADD ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz /usr/local/ffmpeg/src/
|
||||
RUN tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz;
|
||||
ADD ${FFMPEG_URL}/n${FFMPEG_VERSION}.tar.gz /usr/local/ffmpeg/src/
|
||||
RUN tar xf n${FFMPEG_VERSION}.tar.gz && mv FFmpeg-n${FFMPEG_VERSION} ffmpeg-${FFMPEG_VERSION};
|
||||
|
||||
WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION}
|
||||
|
||||
|
||||
@@ -48,3 +48,22 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques
|
||||
### Additional documentation
|
||||
|
||||
- [Mastodon documentation](https://docs.joinmastodon.org/)
|
||||
|
||||
## Size limits
|
||||
|
||||
Mastodon imposes a few hard limits on federated content.
|
||||
These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accomodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons.
|
||||
The following table attempts to summary those limits.
|
||||
|
||||
| Limited property | Size limit | Consequence of exceeding the limit |
|
||||
| ------------------------------------------------------------- | ---------- | ---------------------------------- |
|
||||
| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** |
|
||||
| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated |
|
||||
| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated |
|
||||
| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated |
|
||||
| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** |
|
||||
| Account display name (actor `name`) length | 2048 | Display name will be truncated |
|
||||
| Account note (actor `summary`) length | 20kB | Account note will be truncated |
|
||||
| Account `attributionDomains` | 256 | List will be truncated |
|
||||
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
|
||||
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
|
||||
|
||||
33
Gemfile
33
Gemfile
@@ -9,7 +9,7 @@ gem 'rails', '~> 8.0'
|
||||
gem 'thor', '~> 1.2'
|
||||
|
||||
gem 'dotenv'
|
||||
gem 'haml-rails', '~>2.0'
|
||||
gem 'haml-rails', '~>3.0'
|
||||
gem 'pg', '~> 1.5'
|
||||
gem 'pghero'
|
||||
|
||||
@@ -105,20 +105,20 @@ gem 'prometheus_exporter', '~> 2.2', require: false
|
||||
gem 'opentelemetry-api', '~> 1.7.0'
|
||||
|
||||
group :opentelemetry do
|
||||
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.28.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.37.0', require: false
|
||||
gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-exporter-otlp', '~> 0.31.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.30.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.32.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
|
||||
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
|
||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.28.0', require: false
|
||||
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
||||
end
|
||||
|
||||
@@ -160,6 +160,9 @@ group :test do
|
||||
|
||||
# Stub web requests for specs
|
||||
gem 'webmock', '~> 3.18'
|
||||
|
||||
# Websocket driver for testing integration between rails/sidekiq and streaming
|
||||
gem 'websocket-driver', '~> 0.8', require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
||||
424
Gemfile.lock
424
Gemfile.lock
@@ -10,29 +10,29 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actioncable (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actionmailbox (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activestorage (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actionmailer (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
actionview (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actionpack (8.0.3)
|
||||
actionview (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -40,15 +40,15 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actiontext (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activestorage (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actionview (8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
@@ -58,22 +58,22 @@ GEM
|
||||
activemodel (>= 4.1)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activejob (8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activerecord (8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activemodel (8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
activerecord (8.0.3)
|
||||
activemodel (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activestorage (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2.1)
|
||||
activesupport (8.0.3)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -90,13 +90,13 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotaterb (4.19.0)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1135.0)
|
||||
aws-partitions (1.1168.0)
|
||||
aws-sdk-core (3.215.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
@@ -116,19 +116,19 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcp47_spec (0.2.1)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
benchmark (0.5.0)
|
||||
better_errors (2.10.1)
|
||||
erubi (>= 1.0.0)
|
||||
rack (>= 0.9.0)
|
||||
rouge (>= 1.0.0)
|
||||
bigdecimal (3.2.3)
|
||||
bigdecimal (3.3.1)
|
||||
bindata (2.5.1)
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
brakeman (7.1.1)
|
||||
racc
|
||||
browser (6.2.0)
|
||||
builder (3.3.0)
|
||||
@@ -150,7 +150,7 @@ GEM
|
||||
playwright-ruby-client (>= 1.16.0)
|
||||
case_transform (0.2)
|
||||
activesupport
|
||||
cbor (0.5.9.8)
|
||||
cbor (0.5.10.1)
|
||||
cgi (0.4.2)
|
||||
charlock_holmes (0.7.9)
|
||||
chewy (7.6.0)
|
||||
@@ -168,7 +168,7 @@ GEM
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
crack (1.0.0)
|
||||
crack (1.0.1)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
@@ -190,10 +190,10 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (6.1.0)
|
||||
activesupport (>= 7.0, < 8.1)
|
||||
devise-two-factor (6.2.0)
|
||||
activesupport (>= 7.0, < 8.2)
|
||||
devise (~> 4.0)
|
||||
railties (>= 7.0, < 8.1)
|
||||
railties (>= 7.0, < 8.2)
|
||||
rotp (~> 6.0)
|
||||
devise_pam_authenticatable2 (9.2.0)
|
||||
devise (>= 4.0.0)
|
||||
@@ -207,7 +207,7 @@ GEM
|
||||
railties (>= 5)
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
dry-cli (1.2.0)
|
||||
dry-cli (1.3.0)
|
||||
elasticsearch (7.17.11)
|
||||
elasticsearch-api (= 7.17.11)
|
||||
elasticsearch-transport (= 7.17.11)
|
||||
@@ -224,20 +224,20 @@ GEM
|
||||
mail (~> 2.7)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (5.0.2)
|
||||
erb (5.1.3)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
excon (1.2.8)
|
||||
excon (1.3.0)
|
||||
logger
|
||||
fabrication (3.0.0)
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.4)
|
||||
faraday (2.14.0)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday-follow_redirects (0.4.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (2.0.2)
|
||||
httpclient (>= 2.2)
|
||||
@@ -266,23 +266,24 @@ GEM
|
||||
fog-openstack (1.1.5)
|
||||
fog-core (~> 2.1)
|
||||
fog-json (>= 1.0)
|
||||
formatador (1.1.1)
|
||||
formatador (1.2.1)
|
||||
reline
|
||||
forwardable (1.3.3)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
fugit (1.12.0)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.31.1)
|
||||
google-protobuf (4.32.1)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
googleapis-common-protos-types (1.20.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
googleapis-common-protos-types (1.22.0)
|
||||
google-protobuf (~> 4.26)
|
||||
haml (6.3.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
haml-rails (2.1.0)
|
||||
haml-rails (3.0.0)
|
||||
actionpack (>= 5.1)
|
||||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
@@ -293,15 +294,15 @@ GEM
|
||||
rainbow
|
||||
rubocop (>= 1.0)
|
||||
sysexits (~> 1.1)
|
||||
hashdiff (1.2.0)
|
||||
hashdiff (1.2.1)
|
||||
hashie (5.0.0)
|
||||
hcaptcha (7.1.0)
|
||||
json
|
||||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.25.3)
|
||||
redis-client (= 0.25.3)
|
||||
hiredis-client (0.26.1)
|
||||
redis-client (= 0.26.1)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
@@ -309,7 +310,7 @@ GEM
|
||||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 2.2)
|
||||
llhttp-ffi (~> 0.5.0)
|
||||
http-cookie (1.0.8)
|
||||
http-cookie (1.1.0)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
http_accept_language (2.1.1)
|
||||
@@ -336,7 +337,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.2)
|
||||
irb (1.15.3)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
@@ -345,9 +346,9 @@ GEM
|
||||
azure-blob (~> 0.5.2)
|
||||
hashie (~> 5.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.13.2)
|
||||
json (2.15.1)
|
||||
json-canonicalization (1.0.0)
|
||||
json-jwt (1.16.7)
|
||||
json-jwt (1.17.0)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
base64
|
||||
@@ -425,7 +426,8 @@ GEM
|
||||
loofah (2.24.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
@@ -438,16 +440,16 @@ GEM
|
||||
mime-types (3.7.0)
|
||||
logger
|
||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||
mime-types-data (3.2025.0916)
|
||||
mime-types-data (3.2025.0924)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
minitest (5.26.0)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.17.0)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.9)
|
||||
net-imap (0.5.12)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.20.0)
|
||||
@@ -466,8 +468,9 @@ GEM
|
||||
oj (3.16.11)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.3)
|
||||
omniauth (2.1.4)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-cas (3.0.2)
|
||||
@@ -496,102 +499,77 @@ GEM
|
||||
tzinfo
|
||||
validate_url
|
||||
webfinger (~> 2.0)
|
||||
openssl (3.3.0)
|
||||
openssl (3.3.2)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
opentelemetry-api (1.7.0)
|
||||
opentelemetry-common (0.22.0)
|
||||
opentelemetry-common (0.23.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-exporter-otlp (0.30.0)
|
||||
opentelemetry-exporter-otlp (0.31.1)
|
||||
google-protobuf (>= 3.18)
|
||||
googleapis-common-protos-types (~> 1.3)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-sdk (~> 1.2)
|
||||
opentelemetry-sdk (~> 1.10)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-helpers-sql (0.1.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-helpers-sql-obfuscation (0.3.0)
|
||||
opentelemetry-helpers-sql (0.2.0)
|
||||
opentelemetry-api (~> 1.7)
|
||||
opentelemetry-helpers-sql-obfuscation (0.4.0)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-instrumentation-action_mailer (0.4.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-action_pack (0.13.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.21)
|
||||
opentelemetry-instrumentation-action_view (0.9.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-active_job (0.8.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-active_model_serializers (0.22.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-action_mailer (0.6.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
opentelemetry-instrumentation-action_pack (0.15.1)
|
||||
opentelemetry-instrumentation-rack (~> 0.29)
|
||||
opentelemetry-instrumentation-action_view (0.11.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
opentelemetry-instrumentation-active_job (0.10.1)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-active_model_serializers (0.24.0)
|
||||
opentelemetry-instrumentation-active_support (>= 0.7.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-active_record (0.9.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-active_storage (0.1.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-active_support (0.8.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-base (0.23.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_record (0.11.1)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-active_storage (0.3.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
opentelemetry-instrumentation-active_support (0.10.1)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-base (0.25.0)
|
||||
opentelemetry-api (~> 1.7)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-registry (~> 0.1)
|
||||
opentelemetry-instrumentation-concurrent_ruby (0.22.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-excon (0.24.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-faraday (0.28.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http (0.25.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http_client (0.24.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-net_http (0.24.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-pg (0.30.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (0.24.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-excon (0.26.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-faraday (0.30.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-http (0.27.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-http_client (0.26.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-net_http (0.26.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-pg (0.32.0)
|
||||
opentelemetry-helpers-sql
|
||||
opentelemetry-helpers-sql-obfuscation
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-rack (0.27.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-rails (0.37.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-action_mailer (~> 0.4.0)
|
||||
opentelemetry-instrumentation-action_pack (~> 0.13.0)
|
||||
opentelemetry-instrumentation-action_view (~> 0.9.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
||||
opentelemetry-instrumentation-active_record (~> 0.9.0)
|
||||
opentelemetry-instrumentation-active_storage (~> 0.1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.8.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
||||
opentelemetry-instrumentation-redis (0.26.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-sidekiq (0.26.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-rack (0.29.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-rails (0.39.1)
|
||||
opentelemetry-instrumentation-action_mailer (~> 0.6)
|
||||
opentelemetry-instrumentation-action_pack (~> 0.15)
|
||||
opentelemetry-instrumentation-action_view (~> 0.11)
|
||||
opentelemetry-instrumentation-active_job (~> 0.10)
|
||||
opentelemetry-instrumentation-active_record (~> 0.11)
|
||||
opentelemetry-instrumentation-active_storage (~> 0.3)
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.23)
|
||||
opentelemetry-instrumentation-redis (0.28.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-sidekiq (0.28.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-registry (0.4.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-sdk (1.9.0)
|
||||
opentelemetry-sdk (1.10.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-registry (~> 0.2)
|
||||
@@ -615,7 +593,7 @@ GEM
|
||||
playwright-ruby-client (1.55.0)
|
||||
concurrent-ruby (>= 1.1.6)
|
||||
mime-types (>= 3.0)
|
||||
pp (0.6.2)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
premailer (1.27.0)
|
||||
addressable
|
||||
@@ -626,10 +604,10 @@ GEM
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
prism (1.5.2)
|
||||
prometheus_exporter (2.3.0)
|
||||
webrick
|
||||
propshaft (1.2.1)
|
||||
propshaft (1.3.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
@@ -637,14 +615,14 @@ GEM
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
puma (7.0.3)
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.1)
|
||||
pundit (2.5.2)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.16)
|
||||
rack-attack (6.7.0)
|
||||
rack (3.2.4)
|
||||
rack-attack (6.8.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-cors (3.0.0)
|
||||
logger
|
||||
@@ -669,20 +647,20 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.2.1)
|
||||
actioncable (= 8.0.2.1)
|
||||
actionmailbox (= 8.0.2.1)
|
||||
actionmailer (= 8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actiontext (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
rails (8.0.3)
|
||||
actioncable (= 8.0.3)
|
||||
actionmailbox (= 8.0.3)
|
||||
actionmailer (= 8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
actiontext (= 8.0.3)
|
||||
actionview (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activemodel (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activestorage (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2.1)
|
||||
railties (= 8.0.3)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -693,13 +671,14 @@ GEM
|
||||
rails-i18n (8.0.2)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
railties (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
@@ -712,26 +691,27 @@ GEM
|
||||
readline (~> 0.0)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.14.2)
|
||||
rdoc (6.15.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
readline (0.0.4)
|
||||
reline
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.25.3)
|
||||
redis-client (0.26.1)
|
||||
connection_pool
|
||||
regexp_parser (2.11.2)
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.2)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
rack (>= 1.4)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rouge (4.6.0)
|
||||
rouge (4.6.1)
|
||||
rpam2 (4.0.2)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
@@ -764,8 +744,8 @@ GEM
|
||||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.4)
|
||||
rubocop (1.80.2)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.81.6)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -773,10 +753,10 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.46.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.46.0)
|
||||
rubocop-ast (1.47.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-capybara (2.22.1)
|
||||
@@ -785,11 +765,11 @@ GEM
|
||||
rubocop-i18n (3.2.3)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.72.1)
|
||||
rubocop-performance (1.26.0)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rails (2.33.3)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.33.4)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
@@ -811,11 +791,11 @@ GEM
|
||||
ruby-vips (2.2.5)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.1.0)
|
||||
rubyzip (3.2.2)
|
||||
rufus-scheduler (3.9.2)
|
||||
fugit (~> 1.1, >= 1.11.1)
|
||||
safety_net_attestation (0.4.0)
|
||||
jwt (~> 2.0)
|
||||
safety_net_attestation (0.5.0)
|
||||
jwt (>= 2.0, < 4.0)
|
||||
sanitize (7.0.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.16.8)
|
||||
@@ -825,7 +805,7 @@ GEM
|
||||
securerandom (0.4.1)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (8.0.7)
|
||||
sidekiq (8.0.9)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
@@ -842,9 +822,9 @@ GEM
|
||||
thor (>= 1.0, < 3.0)
|
||||
simple-navigation (4.4.0)
|
||||
activesupport (>= 2.3.2)
|
||||
simple_form (5.3.1)
|
||||
actionpack (>= 5.2)
|
||||
activemodel (>= 5.2)
|
||||
simple_form (5.4.0)
|
||||
actionpack (>= 7.0)
|
||||
activemodel (>= 7.0)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
@@ -855,10 +835,10 @@ GEM
|
||||
stackprof (0.2.27)
|
||||
starry (0.2.0)
|
||||
base64
|
||||
stoplight (5.3.8)
|
||||
stoplight (5.4.0)
|
||||
zeitwerk
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.5.0)
|
||||
strong_migrations (2.5.1)
|
||||
activerecord (>= 7.1)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
@@ -879,6 +859,7 @@ GEM
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
tsort (0.2.0)
|
||||
tty-color (0.6.0)
|
||||
tty-cursor (0.7.1)
|
||||
tty-prompt (0.23.1)
|
||||
@@ -899,10 +880,10 @@ GEM
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.9.1)
|
||||
unicode-display_width (3.1.5)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.3)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.4)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
@@ -918,19 +899,19 @@ GEM
|
||||
zeitwerk (~> 2.2)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
webauthn (3.4.1)
|
||||
webauthn (3.4.3)
|
||||
android_key_attestation (~> 0.3.0)
|
||||
bindata (~> 2.4)
|
||||
cbor (~> 0.5.9)
|
||||
cose (~> 1.1)
|
||||
openssl (>= 2.2)
|
||||
safety_net_attestation (~> 0.4.0)
|
||||
safety_net_attestation (~> 0.5.0)
|
||||
tpm-key_attestation (~> 0.14.0)
|
||||
webfinger (2.1.3)
|
||||
activesupport
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
webmock (3.25.1)
|
||||
webmock (3.26.0)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -988,7 +969,7 @@ DEPENDENCIES
|
||||
flatware-rspec
|
||||
fog-core (<= 2.6.0)
|
||||
fog-openstack (~> 1.0)
|
||||
haml-rails (~> 2.0)
|
||||
haml-rails (~> 3.0)
|
||||
haml_lint
|
||||
hcaptcha (~> 7.1)
|
||||
hiredis (~> 0.6)
|
||||
@@ -1028,20 +1009,20 @@ DEPENDENCIES
|
||||
omniauth-saml (~> 2.0)
|
||||
omniauth_openid_connect (~> 0.8.0)
|
||||
opentelemetry-api (~> 1.7.0)
|
||||
opentelemetry-exporter-otlp (~> 0.30.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.24.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.28.0)
|
||||
opentelemetry-instrumentation-http (~> 0.25.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.24.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.24.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.30.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.27.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.37.0)
|
||||
opentelemetry-instrumentation-redis (~> 0.26.0)
|
||||
opentelemetry-instrumentation-sidekiq (~> 0.26.0)
|
||||
opentelemetry-exporter-otlp (~> 0.31.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.10.0)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.24.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.26.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.30.0)
|
||||
opentelemetry-instrumentation-http (~> 0.27.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.26.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.26.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.32.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.29.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.39.0)
|
||||
opentelemetry-instrumentation-redis (~> 0.28.0)
|
||||
opentelemetry-instrumentation-sidekiq (~> 0.28.0)
|
||||
opentelemetry-sdk (~> 1.4)
|
||||
ox (~> 2.14)
|
||||
parslet
|
||||
@@ -1100,10 +1081,11 @@ DEPENDENCIES
|
||||
webauthn (~> 3.0)
|
||||
webmock (~> 3.18)
|
||||
webpush!
|
||||
websocket-driver (~> 0.8)
|
||||
xorcist (~> 1.1)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.7.1
|
||||
2.7.2
|
||||
|
||||
@@ -73,7 +73,7 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub]
|
||||
### Requirements
|
||||
|
||||
- **Ruby** 3.2+
|
||||
- **PostgreSQL** 13+
|
||||
- **PostgreSQL** 14+
|
||||
- **Redis** 7.0+
|
||||
- **Node.js** 20+
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ---------------- |
|
||||
| 4.5.x | Yes |
|
||||
| 4.4.x | Yes |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Until 2026-01-08 |
|
||||
| < 4.2 | No |
|
||||
| 4.3.x | Until 2026-05-06 |
|
||||
| < 4.3 | No |
|
||||
|
||||
@@ -71,6 +71,10 @@ class AccountsController < ApplicationController
|
||||
params[:username]
|
||||
end
|
||||
|
||||
def account_id_param
|
||||
params[:id]
|
||||
end
|
||||
|
||||
def skip_temporary_suspension_response?
|
||||
request.format == :json
|
||||
end
|
||||
|
||||
@@ -4,17 +4,31 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :check_authorization
|
||||
before_action :set_items
|
||||
before_action :set_size
|
||||
before_action :set_type
|
||||
|
||||
def show
|
||||
expires_in 3.minutes, public: public_fetch_mode?
|
||||
render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
|
||||
if @unauthorized
|
||||
render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
else
|
||||
render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
# Because in public fetch mode we cache the response, there would be no
|
||||
# benefit from performing the check below, since a blocked account or domain
|
||||
# would likely be served the cache from the reverse proxy anyway
|
||||
|
||||
@unauthorized = authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||
end
|
||||
|
||||
def set_items
|
||||
case params[:id]
|
||||
when 'featured'
|
||||
@@ -57,11 +71,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
end
|
||||
|
||||
def for_signed_account
|
||||
# Because in public fetch mode we cache the response, there would be no
|
||||
# benefit from performing the check below, since a blocked account or domain
|
||||
# would likely be served the cache from the reverse proxy anyway
|
||||
|
||||
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||
if @unauthorized
|
||||
[]
|
||||
else
|
||||
yield
|
||||
|
||||
@@ -36,9 +36,8 @@ class ActivityPub::ContextsController < ActivityPub::BaseController
|
||||
|
||||
def context_presenter
|
||||
first_page = ActivityPub::CollectionPresenter.new(
|
||||
id: items_context_url(@conversation, page_params),
|
||||
type: :unordered,
|
||||
part_of: items_context_url(@conversation),
|
||||
part_of: context_url(@conversation),
|
||||
next: next_page,
|
||||
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
|
||||
)
|
||||
@@ -52,7 +51,7 @@ class ActivityPub::ContextsController < ActivityPub::BaseController
|
||||
page = ActivityPub::CollectionPresenter.new(
|
||||
id: items_context_url(@conversation, page_params),
|
||||
type: :unordered,
|
||||
part_of: items_context_url(@conversation),
|
||||
part_of: context_url(@conversation),
|
||||
next: next_page,
|
||||
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
include JsonLdHelper
|
||||
|
||||
before_action :skip_large_payload
|
||||
before_action :skip_unknown_actor_activity
|
||||
before_action :require_actor_signature!
|
||||
skip_before_action :authenticate_user!
|
||||
@@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
|
||||
private
|
||||
|
||||
def skip_large_payload
|
||||
head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE
|
||||
end
|
||||
|
||||
def skip_unknown_actor_activity
|
||||
head 202 if unknown_affected_account?
|
||||
end
|
||||
|
||||
@@ -22,13 +22,13 @@ class ActivityPub::LikesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def likes_collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_status_likes_url(@account, @status),
|
||||
id: ActivityPub::TagManager.instance.likes_uri_for(@status),
|
||||
type: :unordered,
|
||||
size: @status.favourites_count
|
||||
)
|
||||
|
||||
@@ -73,6 +73,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative
|
||||
return super if params[:account_username].present? || params[:account_id].present?
|
||||
|
||||
@account = Account.representative
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
||||
before_action :set_quote_authorization
|
||||
|
||||
def show
|
||||
expires_in 30.seconds, public: true if @quote.status.distributable? && public_fetch_mode?
|
||||
expires_in 30.seconds, public: true if @quote.quoted_status.distributable? && public_fetch_mode?
|
||||
render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
end
|
||||
|
||||
@@ -23,8 +23,8 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
||||
@quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
|
||||
return not_found unless @quote.status.present? && @quote.quoted_status.present?
|
||||
|
||||
authorize @quote.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
authorize @quote.quoted_status, :show?
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
@@ -37,7 +37,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||
|
||||
def replies_collection_presenter
|
||||
page = ActivityPub::CollectionPresenter.new(
|
||||
id: account_status_replies_url(@account, @status, page_params),
|
||||
id: ActivityPub::TagManager.instance.replies_uri_for(@status, page_params),
|
||||
type: :unordered,
|
||||
part_of: account_status_replies_url(@account, @status),
|
||||
next: next_page,
|
||||
@@ -47,7 +47,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||
return page if page_requested?
|
||||
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_status_replies_url(@account, @status),
|
||||
id: ActivityPub::TagManager.instance.replies_uri_for(@status),
|
||||
type: :unordered,
|
||||
first: page
|
||||
)
|
||||
@@ -66,8 +66,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||
# Only consider remote accounts
|
||||
return nil if @replies.size < DESCENDANTS_LIMIT
|
||||
|
||||
account_status_replies_url(
|
||||
@account,
|
||||
ActivityPub::TagManager.instance.replies_uri_for(
|
||||
@status,
|
||||
page: true,
|
||||
min_id: @replies&.last&.id,
|
||||
@@ -77,8 +76,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||
# For now, we're serving only self-replies, but next page might be other accounts
|
||||
next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT
|
||||
|
||||
account_status_replies_url(
|
||||
@account,
|
||||
ActivityPub::TagManager.instance.replies_uri_for(
|
||||
@status,
|
||||
page: true,
|
||||
min_id: next_only_other_accounts ? nil : @replies&.last&.id,
|
||||
|
||||
@@ -22,13 +22,13 @@ class ActivityPub::SharesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def shares_collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_status_shares_url(@account, @status),
|
||||
id: ActivityPub::TagManager.instance.shares_uri_for(@status),
|
||||
type: :unordered,
|
||||
size: @status.reblogs_count
|
||||
)
|
||||
|
||||
@@ -9,10 +9,16 @@ module Admin
|
||||
|
||||
@pending_appeals_count = Appeal.pending.async_count
|
||||
@pending_reports_count = Report.unresolved.async_count
|
||||
@pending_tags_count = Tag.pending_review.async_count
|
||||
@pending_tags_count = pending_tags.async_count
|
||||
@pending_users_count = User.pending.async_count
|
||||
@system_checks = Admin::SystemCheck.perform(current_user)
|
||||
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pending_tags
|
||||
::Trends::TagFilter.new(status: :pending_review).results
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,7 +47,7 @@ class Api::Fasp::BaseController < ApplicationController
|
||||
provider = nil
|
||||
|
||||
Linzer.verify!(request.rack_request, no_older_than: 5.minutes) do |keyid|
|
||||
provider = Fasp::Provider.find(keyid)
|
||||
provider = Fasp::Provider.confirmed.find(keyid)
|
||||
Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid)
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
|
||||
def set_poll
|
||||
@poll = Poll.find(params[:poll_id])
|
||||
authorize @poll.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Api::V1::PollsController < Api::BaseController
|
||||
def set_poll
|
||||
@poll = Poll.find(params[:id])
|
||||
authorize @poll.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
|
||||
bookmark&.destroy!
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false })
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class Api::V1::Statuses::FavouritesController < Api::V1::Statuses::BaseControlle
|
||||
|
||||
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } })
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: relationships
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base
|
||||
end
|
||||
|
||||
def broadcast_updates!
|
||||
DistributionWorker.perform_async(@status.id, { 'update' => true })
|
||||
DistributionWorker.perform_async(@status.id, { 'update' => true, 'skip_notifications' => true })
|
||||
ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,13 +4,13 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke
|
||||
|
||||
before_action :check_owner!
|
||||
before_action :set_statuses, only: :index
|
||||
|
||||
before_action :set_quote, only: :revoke
|
||||
after_action :insert_pagination_headers, only: :index
|
||||
|
||||
def index
|
||||
cache_if_unauthenticated!
|
||||
@statuses = load_statuses
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||
end
|
||||
|
||||
@@ -24,18 +24,26 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
||||
|
||||
private
|
||||
|
||||
def check_owner!
|
||||
authorize @status, :list_quotes?
|
||||
end
|
||||
|
||||
def set_quote
|
||||
@quote = @status.quotes.find_by!(status_id: params[:id])
|
||||
end
|
||||
|
||||
def load_statuses
|
||||
def set_statuses
|
||||
scope = default_statuses
|
||||
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
|
||||
scope.merge(paginated_quotes).to_a
|
||||
@statuses = scope.merge(paginated_quotes).to_a
|
||||
|
||||
# Store next page info before filtering
|
||||
@records_continue = @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
||||
@pagination_since_id = @statuses.first.quote.id unless @statuses.empty?
|
||||
@pagination_max_id = @statuses.last.quote.id if @records_continue
|
||||
|
||||
if current_account&.id != @status.account_id
|
||||
domains = @statuses.filter_map(&:account_domain).uniq
|
||||
account_ids = @statuses.map(&:account_id).uniq
|
||||
relations = current_account&.relations_map(account_ids, domains) || {}
|
||||
@statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? }
|
||||
end
|
||||
end
|
||||
|
||||
def default_statuses
|
||||
@@ -58,15 +66,9 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
||||
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
|
||||
end
|
||||
|
||||
def pagination_max_id
|
||||
@statuses.last.quote.id
|
||||
end
|
||||
|
||||
def pagination_since_id
|
||||
@statuses.first.quote.id
|
||||
end
|
||||
attr_reader :pagination_max_id, :pagination_since_id
|
||||
|
||||
def records_continue?
|
||||
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
||||
@records_continue
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
|
||||
|
||||
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } })
|
||||
render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
@@ -45,7 +45,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
|
||||
def set_reblog
|
||||
@reblog = Status.find(params[:status_id])
|
||||
authorize @reblog, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
if async_refresh.running?
|
||||
add_async_refresh_header(async_refresh)
|
||||
elsif !current_account.nil? && @status.should_fetch_replies?
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key))
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true))
|
||||
|
||||
WorkerBatch.new.within do |batch|
|
||||
batch.connect(refresh_key, threshold: 1.0)
|
||||
@@ -93,6 +93,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
application: doorkeeper_token.application,
|
||||
poll: status_params[:poll],
|
||||
content_type: status_params[:content_type],
|
||||
local_only: status_params[:local_only],
|
||||
allowed_mentions: status_params[:allowed_mentions],
|
||||
idempotency: request.headers['Idempotency-Key'],
|
||||
with_rate_limit: true
|
||||
@@ -107,9 +108,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
@status = Status.where(account: current_account).find(params[:id])
|
||||
authorize @status, :update?
|
||||
|
||||
UpdateStatusService.new.call(
|
||||
@status,
|
||||
current_account.id,
|
||||
update_options = {
|
||||
text: status_params[:status],
|
||||
media_ids: status_params[:media_ids],
|
||||
media_attributes: status_params[:media_attributes],
|
||||
@@ -117,9 +116,12 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
language: status_params[:language],
|
||||
spoiler_text: status_params[:spoiler_text],
|
||||
poll: status_params[:poll],
|
||||
quote_approval_policy: quote_approval_policy,
|
||||
content_type: status_params[:content_type]
|
||||
)
|
||||
content_type: status_params[:content_type],
|
||||
}
|
||||
|
||||
update_options[:quote_approval_policy] = quote_approval_policy if status_params[:quote_approval_policy].present?
|
||||
|
||||
UpdateStatusService.new.call(@status, current_account.id, update_options)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
end
|
||||
@@ -147,7 +149,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
@@ -159,7 +161,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
end
|
||||
|
||||
def set_quoted_status
|
||||
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
|
||||
@quoted_status = Status.find(status_params[:quoted_status_id])&.proper if status_params[:quoted_status_id].present?
|
||||
authorize(@quoted_status, :quote?) if @quoted_status.present?
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
# TODO: distinguish between non-existing and non-quotable posts
|
||||
@@ -190,6 +192,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
:language,
|
||||
:scheduled_at,
|
||||
:content_type,
|
||||
:local_only,
|
||||
allowed_mentions: [],
|
||||
media_ids: [],
|
||||
media_attributes: [
|
||||
|
||||
@@ -3,14 +3,8 @@
|
||||
class Api::V1::Timelines::BaseController < Api::BaseController
|
||||
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
||||
|
||||
before_action :require_user!, if: :require_auth?
|
||||
|
||||
private
|
||||
|
||||
def require_auth?
|
||||
!Setting.timeline_preview
|
||||
end
|
||||
|
||||
def pagination_collection
|
||||
@statuses
|
||||
end
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
|
||||
include AsyncRefreshesConcern
|
||||
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show]
|
||||
before_action :require_user!, only: [:show]
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
|
||||
before_action :require_user!
|
||||
|
||||
PERMITTED_PARAMS = %i(local limit).freeze
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
|
||||
class Api::V1::Timelines::LinkController < Api::V1::Timelines::TopicController
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||
before_action :set_preview_card
|
||||
before_action :set_statuses
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||
before_action :require_user!, if: :require_auth?
|
||||
|
||||
PERMITTED_PARAMS = %i(local remote limit only_media allow_local_only).freeze
|
||||
|
||||
@@ -13,6 +14,16 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
||||
|
||||
private
|
||||
|
||||
def require_auth?
|
||||
if truthy_param?(:local)
|
||||
Setting.local_live_feed_access != 'public'
|
||||
elsif truthy_param?(:remote)
|
||||
Setting.remote_live_feed_access != 'public'
|
||||
else
|
||||
Setting.local_live_feed_access != 'public' || Setting.remote_live_feed_access != 'public'
|
||||
end
|
||||
end
|
||||
|
||||
def load_statuses
|
||||
preloaded_public_statuses_page
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
|
||||
class Api::V1::Timelines::TagController < Api::V1::Timelines::TopicController
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||
before_action :load_tag
|
||||
|
||||
@@ -14,10 +14,6 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
|
||||
|
||||
private
|
||||
|
||||
def require_auth?
|
||||
!Setting.timeline_preview
|
||||
end
|
||||
|
||||
def load_tag
|
||||
@tag = Tag.find_normalized(params[:id])
|
||||
end
|
||||
|
||||
17
app/controllers/api/v1/timelines/topic_controller.rb
Normal file
17
app/controllers/api/v1/timelines/topic_controller.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Timelines::TopicController < Api::V1::Timelines::BaseController
|
||||
before_action :require_user!, if: :require_auth?
|
||||
|
||||
private
|
||||
|
||||
def require_auth?
|
||||
if truthy_param?(:local)
|
||||
Setting.local_topic_feed_access != 'public'
|
||||
elsif truthy_param?(:remote)
|
||||
Setting.remote_topic_feed_access != 'public'
|
||||
else
|
||||
Setting.local_topic_feed_access != 'public' || Setting.remote_topic_feed_access != 'public'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -30,7 +30,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||
end
|
||||
|
||||
def set_push_subscription
|
||||
@push_subscription = ::Web::PushSubscription.find(params[:id])
|
||||
@push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id])
|
||||
end
|
||||
|
||||
def subscription_params
|
||||
|
||||
@@ -89,7 +89,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
end
|
||||
|
||||
def check_enabled_registrations
|
||||
redirect_to root_path unless allowed_registration?(request.remote_ip, @invite)
|
||||
redirect_to new_user_session_path, alert: I18n.t('devise.failure.closed_registrations', email: Setting.site_contact_email) unless allowed_registration?(request.remote_ip, @invite)
|
||||
end
|
||||
|
||||
def invite_code
|
||||
|
||||
@@ -21,7 +21,7 @@ class AuthorizeInteractionsController < ApplicationController
|
||||
def set_resource
|
||||
@resource = located_resource
|
||||
authorize(@resource, :show?) if @resource.is_a?(Status)
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@ module AccountOwnedConcern
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Account.find_local!(username_param)
|
||||
@account = username_param.present? ? Account.find_local!(username_param) : Account.local.find(account_id_param)
|
||||
end
|
||||
|
||||
def account_id_param
|
||||
params[:account_id]
|
||||
end
|
||||
|
||||
def username_param
|
||||
|
||||
@@ -6,6 +6,9 @@ module AsyncRefreshesConcern
|
||||
def add_async_refresh_header(async_refresh, retry_seconds: 3)
|
||||
return unless async_refresh.running?
|
||||
|
||||
response.headers['Mastodon-Async-Refresh'] = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}"
|
||||
value = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}"
|
||||
value += ", result_count=#{async_refresh.result_count}" unless async_refresh.result_count.nil?
|
||||
|
||||
response.headers['Mastodon-Async-Refresh'] = value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,7 @@ module CacheConcern
|
||||
# from being used as cache keys, while allowing to `Vary` on them (to not serve
|
||||
# anonymous cached data to authenticated requests when authentication matters)
|
||||
def enforce_cache_control!
|
||||
vary = response.headers['Vary']&.split&.map { |x| x.strip.downcase }
|
||||
vary = response.headers['Vary'].to_s.split(',').map { |x| x.strip.downcase }.reject(&:empty?)
|
||||
return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? }
|
||||
|
||||
response.cache_control.replace(private: true, no_store: true)
|
||||
|
||||
@@ -72,10 +72,13 @@ module SignatureVerification
|
||||
rescue Mastodon::SignatureVerificationError => e
|
||||
fail_with! e.message
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! "Failed to fetch remote data: #{e.message}"
|
||||
rescue Mastodon::UnexpectedResponseError
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
|
||||
rescue Stoplight::Error::RedLight
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Fetching attempt skipped because of recent connection failure'
|
||||
end
|
||||
|
||||
|
||||
@@ -58,20 +58,22 @@ class FollowerAccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def collection_presenter
|
||||
options = { type: :ordered }
|
||||
options = {}
|
||||
options[:size] = @account.followers_count unless Setting.hide_followers_count || @account.user&.setting_hide_followers_count
|
||||
if page_requested?
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_followers_url(@account, page: params.fetch(:page, 1)),
|
||||
id: page_url(params.fetch(:page, 1)),
|
||||
type: :ordered,
|
||||
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
|
||||
part_of: account_followers_url(@account),
|
||||
part_of: ActivityPub::TagManager.instance.followers_uri_for(@account),
|
||||
next: next_page_url,
|
||||
prev: prev_page_url,
|
||||
**options
|
||||
)
|
||||
else
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_followers_url(@account),
|
||||
id: ActivityPub::TagManager.instance.followers_uri_for(@account),
|
||||
type: :ordered,
|
||||
first: page_url(1),
|
||||
**options
|
||||
)
|
||||
|
||||
@@ -49,7 +49,7 @@ class FollowingAccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def page_url(page)
|
||||
account_following_index_url(@account, page: page) unless page.nil?
|
||||
ActivityPub::TagManager.instance.following_uri_for(@account, page: page) unless page.nil?
|
||||
end
|
||||
|
||||
def next_page_url
|
||||
@@ -63,17 +63,17 @@ class FollowingAccountsController < ApplicationController
|
||||
def collection_presenter
|
||||
if page_requested?
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_following_index_url(@account, page: params.fetch(:page, 1)),
|
||||
id: page_url(params.fetch(:page, 1)),
|
||||
type: :ordered,
|
||||
size: @account.following_count,
|
||||
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
|
||||
part_of: account_following_index_url(@account),
|
||||
part_of: ActivityPub::TagManager.instance.following_uri_for(@account),
|
||||
next: next_page_url,
|
||||
prev: prev_page_url
|
||||
)
|
||||
else
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_following_index_url(@account),
|
||||
id: ActivityPub::TagManager.instance.following_uri_for(@account),
|
||||
type: :ordered,
|
||||
size: @account.following_count,
|
||||
first: page_url(1)
|
||||
|
||||
@@ -34,7 +34,7 @@ class MediaController < ApplicationController
|
||||
|
||||
def verify_permitted_status!
|
||||
authorize @media_attachment.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class SeveredRelationshipsController < ApplicationController
|
||||
private
|
||||
|
||||
def set_event
|
||||
@event = AccountRelationshipSeveranceEvent.find(params[:id])
|
||||
@event = AccountRelationshipSeveranceEvent.where(account: current_account).find(params[:id])
|
||||
end
|
||||
|
||||
def following_data
|
||||
|
||||
@@ -29,7 +29,7 @@ class StatusesController < ApplicationController
|
||||
end
|
||||
|
||||
format.json do
|
||||
expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode?
|
||||
expires_in @status.quote&.pending? ? 5.seconds : 3.minutes, public: true if @status.distributable? && public_fetch_mode?
|
||||
render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
@@ -62,7 +62,7 @@ class StatusesController < ApplicationController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ module ApplicationHelper
|
||||
end
|
||||
|
||||
def material_symbol(icon, attributes = {})
|
||||
whitespace = attributes.delete(:whitespace) { true }
|
||||
safe_join(
|
||||
[
|
||||
inline_svg_tag(
|
||||
@@ -121,7 +122,7 @@ module ApplicationHelper
|
||||
role: :img,
|
||||
data: attributes[:data]
|
||||
),
|
||||
' ',
|
||||
whitespace ? ' ' : '',
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
@@ -21,7 +21,13 @@ module HomeHelper
|
||||
end
|
||||
end
|
||||
else
|
||||
link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do
|
||||
account_url = if account.suspended?
|
||||
ActivityPub::TagManager.instance.url_for(account)
|
||||
else
|
||||
web_url("@#{account.pretty_acct}")
|
||||
end
|
||||
|
||||
link_to(path || account_url, class: 'account__display-name') do
|
||||
content_tag(:div, class: 'account__avatar-wrapper') do
|
||||
image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar', width: 46, height: 46)
|
||||
end +
|
||||
|
||||
@@ -70,6 +70,10 @@ module JsonLdHelper
|
||||
!json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
|
||||
end
|
||||
|
||||
def supported_security_context?(json)
|
||||
!json.nil? && equals_or_includes?(json['@context'], 'https://w3id.org/security/v1')
|
||||
end
|
||||
|
||||
def unsupported_uri_scheme?(uri)
|
||||
uri.nil? || !uri.start_with?('http://', 'https://')
|
||||
end
|
||||
|
||||
@@ -46,6 +46,14 @@ module StatusesHelper
|
||||
status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n")
|
||||
end
|
||||
|
||||
def status_classnames(status, is_quote)
|
||||
if is_quote
|
||||
'status--is-quote'
|
||||
elsif status.quote.present?
|
||||
'status--has-quote'
|
||||
end
|
||||
end
|
||||
|
||||
def status_description(status)
|
||||
components = [[media_summary(status), status_text_summary(status)].compact_blank.join(' · ')]
|
||||
|
||||
@@ -57,6 +65,20 @@ module StatusesHelper
|
||||
components.compact_blank.join("\n\n")
|
||||
end
|
||||
|
||||
# This logic should be kept in sync with https://github.com/mastodon/mastodon/blob/425311e1d95c8a64ddac6c724fca247b8b893a82/app/javascript/mastodon/features/status/components/card.jsx#L160
|
||||
def preview_card_aspect_ratio_classname(preview_card)
|
||||
interactive = preview_card.type == 'video'
|
||||
large_image = (preview_card.image.present? && preview_card.width > preview_card.height) || interactive
|
||||
|
||||
if large_image && interactive
|
||||
'status-card__image--video'
|
||||
elsif large_image
|
||||
'status-card__image--large'
|
||||
else
|
||||
'status-card__image--normal'
|
||||
end
|
||||
end
|
||||
|
||||
def visibility_icon(status)
|
||||
VISIBLITY_ICONS[status.visibility.to_sym]
|
||||
end
|
||||
|
||||
78
app/javascript/config/html-tags.json
Normal file
78
app/javascript/config/html-tags.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"global": {
|
||||
"class": "className",
|
||||
"id": true,
|
||||
"title": true,
|
||||
"dir": true,
|
||||
"lang": true
|
||||
},
|
||||
"tags": {
|
||||
"p": {},
|
||||
"br": {
|
||||
"children": false
|
||||
},
|
||||
"span": {
|
||||
"attributes": {
|
||||
"translate": true
|
||||
}
|
||||
},
|
||||
"a": {
|
||||
"attributes": {
|
||||
"href": true,
|
||||
"rel": true,
|
||||
"translate": true,
|
||||
"target": true,
|
||||
"title": true
|
||||
}
|
||||
},
|
||||
"abbr": {
|
||||
"attributes": {
|
||||
"title": true
|
||||
}
|
||||
},
|
||||
"del": {},
|
||||
"s": {},
|
||||
"pre": {},
|
||||
"blockquote": {
|
||||
"attributes": {
|
||||
"cite": true
|
||||
}
|
||||
},
|
||||
"code": {},
|
||||
"b": {},
|
||||
"strong": {},
|
||||
"u": {},
|
||||
"sub": {},
|
||||
"sup": {},
|
||||
"i": {},
|
||||
"img": {
|
||||
"children": false,
|
||||
"attributes": {
|
||||
"src": true,
|
||||
"alt": true,
|
||||
"title": true
|
||||
}
|
||||
},
|
||||
"em": {},
|
||||
"h1": {},
|
||||
"h2": {},
|
||||
"h3": {},
|
||||
"h4": {},
|
||||
"h5": {},
|
||||
"ul": {},
|
||||
"ol": {
|
||||
"attributes": {
|
||||
"start": true,
|
||||
"reversed": true
|
||||
}
|
||||
},
|
||||
"li": {
|
||||
"attributes": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
"ruby": {},
|
||||
"rt": {},
|
||||
"rp": {}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import Rails from '@rails/ujs';
|
||||
import { decode, ValidationError } from 'blurhash';
|
||||
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
@@ -362,6 +363,46 @@ ready(() => {
|
||||
document.querySelectorAll('[data-admin-component]').forEach((element) => {
|
||||
void mountReactComponent(element);
|
||||
});
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLCanvasElement>('canvas[data-blurhash]')
|
||||
.forEach((canvas) => {
|
||||
const blurhash = canvas.dataset.blurhash;
|
||||
if (blurhash) {
|
||||
try {
|
||||
// decode returns a Uint8ClampedArray<ArrayBufferLike> not Uint8ClampedArray<ArrayBuffer>
|
||||
const pixels = decode(
|
||||
blurhash,
|
||||
32,
|
||||
32,
|
||||
) as Uint8ClampedArray<ArrayBuffer>;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imageData = new ImageData(pixels, 32, 32);
|
||||
|
||||
ctx?.putImageData(imageData, 0, 0);
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) {
|
||||
// ignore blurhash validation errors
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLDivElement>('.preview-card')
|
||||
.forEach((previewCard) => {
|
||||
const spoilerButton = previewCard.querySelector('.spoiler-button');
|
||||
if (!spoilerButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
spoilerButton.addEventListener('click', () => {
|
||||
previewCard.classList.toggle('preview-card--image-visible');
|
||||
});
|
||||
});
|
||||
}).catch((reason: unknown) => {
|
||||
throw reason;
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { throttle } from 'lodash';
|
||||
|
||||
import api from 'flavours/glitch/api';
|
||||
import { browserHistory } from 'flavours/glitch/components/router';
|
||||
import { countableText } from 'flavours/glitch/features/compose/util/counter';
|
||||
import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light';
|
||||
import { tagHistory } from 'flavours/glitch/settings';
|
||||
import { recoverHashtags } from 'flavours/glitch/utils/hashtag';
|
||||
@@ -57,7 +58,6 @@ export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE'
|
||||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
||||
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
||||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
|
||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
|
||||
export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
|
||||
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
||||
@@ -93,6 +93,7 @@ const messages = defineMessages({
|
||||
open: { id: 'compose.published.open', defaultMessage: 'Open' },
|
||||
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
|
||||
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
|
||||
blankPostError: { id: 'compose.error.blank_post', defaultMessage: 'Post can\'t be blank.' },
|
||||
});
|
||||
|
||||
export const ensureComposeIsVisible = (getState) => {
|
||||
@@ -197,26 +198,36 @@ export function directCompose(account) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @callback ComposeSuccessCallback
|
||||
* @param {Object} status
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {null | string} overridePrivacy
|
||||
* @param {undefined | Function} successCallback
|
||||
* @param {undefined | ComposeSuccessCallback} successCallback
|
||||
*/
|
||||
export function submitCompose(overridePrivacy = null, successCallback = undefined) {
|
||||
return function (dispatch, getState) {
|
||||
let status = getState().getIn(['compose', 'text'], '');
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
const statusId = getState().getIn(['compose', 'id'], null);
|
||||
const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']);
|
||||
const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
|
||||
let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
|
||||
const spoiler_text = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
|
||||
|
||||
const fulltext = `${spoiler_text ?? ''}${countableText(status ?? '')}`;
|
||||
const hasText = fulltext.trim().length > 0;
|
||||
|
||||
if (!(hasText || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
|
||||
dispatch(showAlert({
|
||||
message: messages.blankPostError,
|
||||
}));
|
||||
dispatch(focusCompose());
|
||||
|
||||
if ((!status || !status.length) && media.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
|
||||
status = status + ' 👁️';
|
||||
}
|
||||
|
||||
dispatch(submitComposeRequest());
|
||||
|
||||
// If we're editing a post with media attachments, those have not
|
||||
@@ -245,12 +256,13 @@ export function submitCompose(overridePrivacy = null, successCallback = undefine
|
||||
method: statusId === null ? 'post' : 'put',
|
||||
data: {
|
||||
status,
|
||||
spoiler_text,
|
||||
content_type: getState().getIn(['compose', 'content_type']),
|
||||
local_only: getState().getIn(['compose', 'advanced_options', 'do_not_federate']),
|
||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||
media_ids: media.map(item => item.get('id')),
|
||||
media_attributes,
|
||||
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
|
||||
spoiler_text: spoilerText,
|
||||
sensitive: getState().getIn(['compose', 'sensitive']) || (spoiler_text.length > 0 && media.size !== 0),
|
||||
visibility: visibility,
|
||||
poll: getState().getIn(['compose', 'poll'], null),
|
||||
language: getState().getIn(['compose', 'language']),
|
||||
@@ -652,6 +664,7 @@ export function fetchComposeSuggestions(token) {
|
||||
fetchComposeSuggestionsEmojis(dispatch, getState, token);
|
||||
break;
|
||||
case '#':
|
||||
case '#':
|
||||
fetchComposeSuggestionsTags(dispatch, getState, token);
|
||||
break;
|
||||
default:
|
||||
@@ -693,11 +706,20 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||
|
||||
dispatch(useEmoji(suggestion));
|
||||
} else if (suggestion.type === 'hashtag') {
|
||||
completion = `#${suggestion.name}`;
|
||||
// TODO: it could make sense to keep the “most capitalized” of the two
|
||||
const tokenName = token.slice(1); // strip leading '#'
|
||||
const suggestionPrefix = suggestion.name.slice(0, tokenName.length);
|
||||
const prefixMatchesSuggestion = suggestionPrefix.localeCompare(tokenName, undefined, { sensitivity: 'accent' }) === 0;
|
||||
if (prefixMatchesSuggestion) {
|
||||
completion = token + suggestion.name.slice(tokenName.length);
|
||||
} else {
|
||||
completion = `${token.slice(0, 1)}${suggestion.name}`;
|
||||
}
|
||||
|
||||
startPosition = position - 1;
|
||||
} else if (suggestion.type === 'account') {
|
||||
completion = getState().getIn(['accounts', suggestion.id, 'acct']);
|
||||
startPosition = position;
|
||||
completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`;
|
||||
startPosition = position - 1;
|
||||
}
|
||||
|
||||
// We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
|
||||
@@ -808,13 +830,6 @@ export function changeComposeSpoilerText(text) {
|
||||
};
|
||||
}
|
||||
|
||||
export function changeComposeVisibility(value) {
|
||||
return {
|
||||
type: COMPOSE_VISIBILITY_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertEmojiCompose(position, emoji, needsSpace) {
|
||||
return {
|
||||
type: COMPOSE_EMOJI_INSERT,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createAction } from '@reduxjs/toolkit';
|
||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { apiUpdateMedia } from 'flavours/glitch/api/compose';
|
||||
import { apiGetSearch } from 'flavours/glitch/api/search';
|
||||
import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments';
|
||||
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||
import {
|
||||
@@ -12,13 +13,19 @@ import {
|
||||
} from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||
import type { Status } from '../models/status';
|
||||
import type { Status, StatusVisibility } from '../models/status';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
import { showAlert } from './alerts';
|
||||
import { focusCompose } from './compose';
|
||||
import { changeCompose, focusCompose } from './compose';
|
||||
import { importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
quoteErrorEdit: {
|
||||
id: 'quote_error.edit',
|
||||
defaultMessage: 'Quotes cannot be added when editing a post.',
|
||||
},
|
||||
quoteErrorUpload: {
|
||||
id: 'quote_error.upload',
|
||||
defaultMessage: 'Quoting is not allowed with media attachments.',
|
||||
@@ -35,6 +42,10 @@ const messages = defineMessages({
|
||||
id: 'quote_error.unauthorized',
|
||||
defaultMessage: 'You are not authorized to quote this post.',
|
||||
},
|
||||
quoteErrorPrivateMention: {
|
||||
id: 'quote_error.private_mentions',
|
||||
defaultMessage: 'Quoting is not allowed with direct mentions.',
|
||||
},
|
||||
});
|
||||
|
||||
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||
@@ -61,6 +72,39 @@ const simulateModifiedApiResponse = (
|
||||
return data;
|
||||
};
|
||||
|
||||
export const changeComposeVisibility = createAppThunk(
|
||||
'compose/visibility_change',
|
||||
(visibility: StatusVisibility, { dispatch, getState }) => {
|
||||
if (visibility !== 'direct') {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
const quotedStatusId = state.compose.get('quoted_status_id') as
|
||||
| string
|
||||
| null;
|
||||
if (!quotedStatusId) {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
// Remove the quoted status
|
||||
dispatch(quoteComposeCancel());
|
||||
const quotedStatus = state.statuses.get(quotedStatusId) as Status | null;
|
||||
if (!quotedStatus) {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
// Append the quoted status URL to the compose text
|
||||
const url = quotedStatus.get('url') as string;
|
||||
const text = state.compose.get('text') as string;
|
||||
if (!text.includes(url)) {
|
||||
const newText = text.trim() ? `${text}\n\n${url}` : url;
|
||||
dispatch(changeCompose(newText));
|
||||
}
|
||||
return visibility;
|
||||
},
|
||||
);
|
||||
|
||||
export const changeUploadCompose = createDataLoadingThunk(
|
||||
'compose/changeUpload',
|
||||
async (
|
||||
@@ -122,7 +166,11 @@ export const quoteComposeByStatus = createAppThunk(
|
||||
false,
|
||||
);
|
||||
|
||||
if (composeState.get('poll')) {
|
||||
if (composeState.get('id')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorEdit }));
|
||||
} else if (composeState.get('privacy') === 'direct') {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPrivateMention }));
|
||||
} else if (composeState.get('poll')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||
} else if (
|
||||
composeState.get('is_uploading') ||
|
||||
@@ -165,6 +213,61 @@ export const quoteComposeById = createAppThunk(
|
||||
},
|
||||
);
|
||||
|
||||
const composeStateForbidsLink = (composeState: RootState['compose']) => {
|
||||
return (
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id') ||
|
||||
composeState.get('privacy') === 'direct'
|
||||
);
|
||||
};
|
||||
|
||||
export const pasteLinkCompose = createDataLoadingThunk(
|
||||
'compose/pasteLink',
|
||||
async ({ url }: { url: string }) => {
|
||||
return await apiGetSearch({
|
||||
q: url,
|
||||
type: 'statuses',
|
||||
resolve: true,
|
||||
limit: 2,
|
||||
});
|
||||
},
|
||||
(data, { dispatch, getState, requestId }) => {
|
||||
const composeState = getState().compose;
|
||||
|
||||
if (
|
||||
composeStateForbidsLink(composeState) ||
|
||||
composeState.get('fetching_link') !== requestId // Request has been cancelled
|
||||
)
|
||||
return;
|
||||
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
|
||||
if (
|
||||
data.statuses.length === 1 &&
|
||||
data.statuses[0] &&
|
||||
['automatic', 'manual'].includes(
|
||||
data.statuses[0].quote_approval?.current_user ?? 'denied',
|
||||
)
|
||||
) {
|
||||
dispatch(quoteComposeById(data.statuses[0].id));
|
||||
}
|
||||
},
|
||||
{
|
||||
useLoadingBar: false,
|
||||
condition: (_, { getState }) =>
|
||||
!getState().compose.get('fetching_link') &&
|
||||
!composeStateForbidsLink(getState().compose),
|
||||
},
|
||||
);
|
||||
|
||||
// Ideally this would cancel the action and the HTTP request, but this is good enough
|
||||
export const cancelPasteLinkCompose = createAction(
|
||||
'compose/cancelPasteLinkCompose',
|
||||
);
|
||||
|
||||
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
||||
|
||||
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
|
||||
|
||||
@@ -46,11 +46,11 @@ export function importFetchedAccounts(accounts) {
|
||||
return importAccounts({ accounts: normalAccounts });
|
||||
}
|
||||
|
||||
export function importFetchedStatus(status) {
|
||||
return importFetchedStatuses([status]);
|
||||
export function importFetchedStatus(status, options = {}) {
|
||||
return importFetchedStatuses([status], options);
|
||||
}
|
||||
|
||||
export function importFetchedStatuses(statuses) {
|
||||
export function importFetchedStatuses(statuses, options = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const accounts = [];
|
||||
const normalStatuses = [];
|
||||
@@ -58,7 +58,7 @@ export function importFetchedStatuses(statuses) {
|
||||
const filters = [];
|
||||
|
||||
function processStatus(status) {
|
||||
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), getState().get('local_settings')));
|
||||
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), { ...options, settings: getState().get('local_settings') }));
|
||||
pushUnique(accounts, status.account);
|
||||
|
||||
if (status.filtered) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
|
||||
import { makeEmojiMap } from 'flavours/glitch/models/custom_emoji';
|
||||
|
||||
import emojify from '../../features/emoji/emoji';
|
||||
import { autoHideCW } from '../../utils/content_warning';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
@@ -30,9 +27,12 @@ function stripQuoteFallback(text) {
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
|
||||
export function normalizeStatus(status, normalOldStatus, settings) {
|
||||
export function normalizeStatus(status, normalOldStatus, { settings, bogusQuotePolicy = false }) {
|
||||
const normalStatus = { ...status };
|
||||
|
||||
if (bogusQuotePolicy)
|
||||
normalStatus.quote_approval = null;
|
||||
|
||||
normalStatus.account = status.account.id;
|
||||
|
||||
if (status.reblog && status.reblog.id) {
|
||||
@@ -80,11 +80,10 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
||||
} else {
|
||||
const spoilerText = normalStatus.spoiler_text || '';
|
||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
const emojiMap = makeEmojiMap(normalStatus.emojis);
|
||||
|
||||
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
||||
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||
normalStatus.contentHtml = normalStatus.content;
|
||||
normalStatus.spoilerHtml = escapeTextContentForBrowser(spoilerText);
|
||||
normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
|
||||
|
||||
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||
@@ -105,6 +104,8 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
||||
}
|
||||
|
||||
if (normalOldStatus) {
|
||||
normalStatus.quote_approval ||= normalOldStatus.get('quote_approval');
|
||||
|
||||
const list = normalOldStatus.get('media_attachments');
|
||||
if (normalStatus.media_attachments && list) {
|
||||
normalStatus.media_attachments.forEach(item => {
|
||||
@@ -120,14 +121,12 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
||||
}
|
||||
|
||||
export function normalizeStatusTranslation(translation, status) {
|
||||
const emojiMap = makeEmojiMap(status.get('emojis').toJS());
|
||||
|
||||
const normalTranslation = {
|
||||
detected_source_language: translation.detected_source_language,
|
||||
language: translation.language,
|
||||
provider: translation.provider,
|
||||
contentHtml: emojify(translation.content, emojiMap),
|
||||
spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap),
|
||||
contentHtml: translation.content,
|
||||
spoilerHtml: escapeTextContentForBrowser(translation.spoiler_text),
|
||||
spoiler_text: translation.spoiler_text,
|
||||
};
|
||||
|
||||
@@ -141,9 +140,8 @@ export function normalizeStatusTranslation(translation, status) {
|
||||
|
||||
export function normalizeAnnouncement(announcement) {
|
||||
const normalAnnouncement = { ...announcement };
|
||||
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
|
||||
|
||||
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
|
||||
normalAnnouncement.contentHtml = normalAnnouncement.content;
|
||||
|
||||
return normalAnnouncement;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ export function fetchStatus(id, {
|
||||
dispatch(fetchStatusSuccess(skipLoading));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
|
||||
if (error.status === 404)
|
||||
dispatch(deleteFromTimelines(id));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -107,7 +109,7 @@ export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function redraft(status, raw_text, content_type) {
|
||||
export function redraft(status, raw_text, content_type, quoted_status_id = null) {
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
|
||||
@@ -115,6 +117,7 @@ export function redraft(status, raw_text, content_type) {
|
||||
type: REDRAFT,
|
||||
status,
|
||||
raw_text,
|
||||
quoted_status_id,
|
||||
content_type,
|
||||
maxOptions,
|
||||
});
|
||||
@@ -133,7 +136,7 @@ export const editStatus = (id) => (dispatch, getState) => {
|
||||
api().get(`/api/v1/statuses/${id}/source`).then(response => {
|
||||
dispatch(fetchStatusSourceSuccess());
|
||||
ensureComposeIsVisible(getState);
|
||||
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type));
|
||||
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type, response.data.quote?.quoted_status?.id));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusSourceFail(error));
|
||||
});
|
||||
@@ -204,8 +207,8 @@ export function deleteStatusFail(id, error) {
|
||||
};
|
||||
}
|
||||
|
||||
export const updateStatus = status => dispatch =>
|
||||
dispatch(importFetchedStatus(status));
|
||||
export const updateStatus = (status, { bogusQuotePolicy }) => dispatch =>
|
||||
dispatch(importFetchedStatus(status, { bogusQuotePolicy }));
|
||||
|
||||
export function muteStatus(id) {
|
||||
return (dispatch) => {
|
||||
|
||||
@@ -9,8 +9,9 @@ import { importFetchedStatuses } from './importer';
|
||||
|
||||
export const fetchContext = createDataLoadingThunk(
|
||||
'status/context',
|
||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||
({ context, refresh }, { dispatch }) => {
|
||||
({ statusId }: { statusId: string; prefetchOnly?: boolean }) =>
|
||||
apiGetContext(statusId),
|
||||
({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => {
|
||||
const statuses = context.ancestors.concat(context.descendants);
|
||||
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
@@ -18,6 +19,7 @@ export const fetchContext = createDataLoadingThunk(
|
||||
return {
|
||||
context,
|
||||
refresh,
|
||||
prefetchOnly,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -26,6 +28,14 @@ export const completeContextRefresh = createAction<{ statusId: string }>(
|
||||
'status/context/complete',
|
||||
);
|
||||
|
||||
export const showPendingReplies = createAction<{ statusId: string }>(
|
||||
'status/context/showPendingReplies',
|
||||
);
|
||||
|
||||
export const clearPendingReplies = createAction<{ statusId: string }>(
|
||||
'status/context/clearPendingReplies',
|
||||
);
|
||||
|
||||
export const setStatusQuotePolicy = createDataLoadingThunk(
|
||||
'status/setQuotePolicy',
|
||||
({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {
|
||||
|
||||
@@ -32,27 +32,38 @@ import {
|
||||
const randomUpTo = max =>
|
||||
Math.floor(Math.random() * Math.floor(max));
|
||||
|
||||
/**
|
||||
* @typedef {import('flavours/glitch/store').AppDispatch} Dispatch
|
||||
* @typedef {import('flavours/glitch/store').GetState} GetState
|
||||
* @typedef {import('redux').UnknownAction} UnknownAction
|
||||
* @typedef {function(Dispatch, GetState): Promise<void>} FallbackFunction
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} timelineId
|
||||
* @param {string} channelName
|
||||
* @param {Object.<string, string>} params
|
||||
* @param {Object} options
|
||||
* @param {function(Function, Function): Promise<void>} [options.fallback]
|
||||
* @param {function(): void} [options.fillGaps]
|
||||
* @param {FallbackFunction} [options.fallback]
|
||||
* @param {function(): UnknownAction} [options.fillGaps]
|
||||
* @param {function(object): boolean} [options.accept]
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => {
|
||||
const { messages } = getLocale();
|
||||
|
||||
// Public streams are currently not returning personalized quote policies
|
||||
const bogusQuotePolicy = channelName.startsWith('public') || channelName.startsWith('hashtag');
|
||||
|
||||
return connectStream(channelName, params, (dispatch, getState) => {
|
||||
// @ts-ignore
|
||||
const locale = getState().getIn(['meta', 'locale']);
|
||||
|
||||
// @ts-expect-error
|
||||
let pollingId;
|
||||
|
||||
/**
|
||||
* @param {function(Function, Function): Promise<void>} fallback
|
||||
* @param {FallbackFunction} fallback
|
||||
*/
|
||||
|
||||
const useFallback = async fallback => {
|
||||
@@ -89,11 +100,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
switch (data.event) {
|
||||
case 'update':
|
||||
// @ts-expect-error
|
||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
|
||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), { accept: options.accept, bogusQuotePolicy }));
|
||||
break;
|
||||
case 'status.update':
|
||||
// @ts-expect-error
|
||||
dispatch(updateStatus(JSON.parse(data.payload)));
|
||||
dispatch(updateStatus(JSON.parse(data.payload), { bogusQuotePolicy }));
|
||||
break;
|
||||
case 'delete':
|
||||
dispatch(deleteFromTimelines(data.payload));
|
||||
@@ -132,7 +143,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Function} dispatch
|
||||
* @param {Dispatch} dispatch
|
||||
*/
|
||||
async function refreshHomeTimelineAndNotification(dispatch) {
|
||||
await dispatch(expandHomeTimeline({ maxId: undefined }));
|
||||
@@ -151,7 +162,11 @@ async function refreshHomeTimelineAndNotification(dispatch) {
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
export const connectUserStream = () =>
|
||||
connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps });
|
||||
connectTimelineStream('home', 'user', {}, {
|
||||
fallback: refreshHomeTimelineAndNotification,
|
||||
// @ts-expect-error
|
||||
fillGaps: fillHomeTimelineGaps
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
@@ -159,7 +174,10 @@ export const connectUserStream = () =>
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
export const connectCommunityStream = ({ onlyMedia } = {}) =>
|
||||
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) });
|
||||
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, {
|
||||
// @ts-expect-error
|
||||
fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia }))
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
@@ -169,7 +187,10 @@ export const connectCommunityStream = ({ onlyMedia } = {}) =>
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) =>
|
||||
connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote, allowLocalOnly }) });
|
||||
connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, {}, {
|
||||
// @ts-expect-error
|
||||
fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote, allowLocalOnly })
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} columnId
|
||||
@@ -192,4 +213,7 @@ export const connectDirectStream = () =>
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
export const connectListStream = listId =>
|
||||
connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) });
|
||||
connectTimelineStream(`list:${listId}`, 'list', { list: listId }, {
|
||||
// @ts-expect-error
|
||||
fillGaps: () => fillListTimelineGaps(listId)
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ export const loadPending = timeline => ({
|
||||
timeline,
|
||||
});
|
||||
|
||||
export function updateTimeline(timeline, status, accept) {
|
||||
export function updateTimeline(timeline, status, { accept = undefined, bogusQuotePolicy = false } = {}) {
|
||||
return (dispatch, getState) => {
|
||||
if (typeof accept === 'function' && !accept(status)) {
|
||||
return;
|
||||
@@ -55,7 +55,7 @@ export function updateTimeline(timeline, status, accept) {
|
||||
filtered = filters.length > 0;
|
||||
}
|
||||
|
||||
dispatch(importFetchedStatus(status));
|
||||
dispatch(importFetchedStatus(status, { bogusQuotePolicy }));
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_UPDATE,
|
||||
|
||||
28
app/javascript/flavours/glitch/api_types/announcements.ts
Normal file
28
app/javascript/flavours/glitch/api_types/announcements.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// See app/serializers/rest/announcement_serializer.rb
|
||||
|
||||
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||
import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses';
|
||||
|
||||
export interface ApiAnnouncementJSON {
|
||||
id: string;
|
||||
content: string;
|
||||
starts_at: null | string;
|
||||
ends_at: null | string;
|
||||
all_day: boolean;
|
||||
published_at: string;
|
||||
updated_at: null | string;
|
||||
read: boolean;
|
||||
mentions: ApiMentionJSON[];
|
||||
statuses: ApiStatusJSON[];
|
||||
tags: ApiTagJSON[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
reactions: ApiAnnouncementReactionJSON[];
|
||||
}
|
||||
|
||||
export interface ApiAnnouncementReactionJSON {
|
||||
name: string;
|
||||
count: number;
|
||||
me: boolean;
|
||||
url?: string;
|
||||
static_url?: string;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import {
|
||||
blockAccount,
|
||||
@@ -33,7 +34,7 @@ import { me } from 'flavours/glitch/initial_state';
|
||||
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { Permalink } from './permalink';
|
||||
import { Permalink } from '../permalink';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
@@ -333,9 +334,10 @@ export const Account: React.FC<AccountProps> = ({
|
||||
{account &&
|
||||
withBio &&
|
||||
(account.note.length > 0 ? (
|
||||
<div
|
||||
<EmojiHTML
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||
htmlString={account.note_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'>
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||
|
||||
import { EmojiHTML } from '../features/emoji/emoji_html';
|
||||
import { useAppSelector } from '../store';
|
||||
import { isModernEmojiEnabled } from '../utils/environment';
|
||||
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
interface AccountBioProps {
|
||||
className: string;
|
||||
@@ -17,22 +16,16 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
||||
accountId,
|
||||
showDropdown = false,
|
||||
}) => {
|
||||
const handleClick = useLinks(showDropdown);
|
||||
const handleNodeChange = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!showDropdown || !node || node.childNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
addDropdownToHashtags(node, accountId);
|
||||
},
|
||||
[showDropdown, accountId],
|
||||
);
|
||||
const htmlHandlers = useElementHandledLink({
|
||||
hashtagAccountId: showDropdown ? accountId : undefined,
|
||||
});
|
||||
|
||||
const note = useAppSelector((state) => {
|
||||
const account = state.accounts.get(accountId);
|
||||
if (!account) {
|
||||
return '';
|
||||
}
|
||||
return isModernEmojiEnabled() ? account.note : account.note_emojified;
|
||||
return account.note_emojified;
|
||||
});
|
||||
const extraEmojis = useAppSelector((state) => {
|
||||
const account = state.accounts.get(accountId);
|
||||
@@ -44,33 +37,11 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${className} translate`}
|
||||
onClickCapture={handleClick}
|
||||
ref={handleNodeChange}
|
||||
>
|
||||
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
||||
</div>
|
||||
<EmojiHTML
|
||||
htmlString={note}
|
||||
extraEmojis={extraEmojis}
|
||||
className={classNames(className, 'translate')}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
for (const childNode of node.childNodes) {
|
||||
if (!(childNode instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
childNode instanceof HTMLAnchorElement &&
|
||||
(childNode.classList.contains('hashtag') ||
|
||||
childNode.innerText.startsWith('#')) &&
|
||||
!childNode.dataset.menuHashtag
|
||||
) {
|
||||
childNode.dataset.menuHashtag = accountId;
|
||||
} else if (childNode.childNodes.length > 0) {
|
||||
addDropdownToHashtags(childNode, accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,71 @@
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||
import type { Account } from 'flavours/glitch/models/account';
|
||||
|
||||
export const AccountFields: React.FC<{
|
||||
fields: Account['fields'];
|
||||
limit: number;
|
||||
}> = ({ fields, limit = -1 }) => {
|
||||
const handleClick = useLinks();
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||
fields,
|
||||
emojis,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const htmlHandlers = useElementHandledLink();
|
||||
|
||||
if (fields.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account-fields' onClickCapture={handleClick}>
|
||||
{fields.take(limit).map((pair, i) => (
|
||||
<dl
|
||||
key={i}
|
||||
className={classNames({ verified: pair.get('verified_at') })}
|
||||
>
|
||||
<dt
|
||||
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
|
||||
<>
|
||||
{fields.map((pair, i) => (
|
||||
<dl key={i} className={classNames({ verified: pair.verified_at })}>
|
||||
<EmojiHTML
|
||||
as='dt'
|
||||
htmlString={pair.name_emojified}
|
||||
extraEmojis={emojis}
|
||||
className='translate'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
|
||||
<dd className='translate' title={pair.get('value_plain') ?? ''}>
|
||||
{pair.get('verified_at') && (
|
||||
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||
)}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
|
||||
<dd className='translate' title={pair.value_plain ?? ''}>
|
||||
{pair.verified_at && (
|
||||
<span
|
||||
title={intl.formatMessage(
|
||||
{
|
||||
id: 'account.link_verified_on',
|
||||
defaultMessage:
|
||||
'Ownership of this link was checked on {date}',
|
||||
},
|
||||
{
|
||||
date: intl.formatDate(pair.verified_at, dateFormatOptions),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||
</span>
|
||||
)}{' '}
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={pair.value_emojified}
|
||||
extraEmojis={emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase();
|
||||
word = word.trim();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
@@ -61,7 +61,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
||||
|
||||
static defaultProps = {
|
||||
autoFocus: true,
|
||||
searchTokens: ['@', ':', '#'],
|
||||
searchTokens: ['@', '@', ':', '#', '#'],
|
||||
};
|
||||
|
||||
state = {
|
||||
|
||||
@@ -25,11 +25,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
|
||||
if (!word || word.trim().length < 3 || ['@', '@', ':', '#', '#'].indexOf(word[0]) === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase();
|
||||
word = word.trim();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
@@ -150,10 +150,7 @@ const AutosuggestTextarea = forwardRef(({
|
||||
}, [suggestions, onSuggestionSelected, textareaRef]);
|
||||
|
||||
const handlePaste = useCallback((e) => {
|
||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
onPaste(e.clipboardData.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
onPaste(e);
|
||||
}, [onPaste]);
|
||||
|
||||
// Show the suggestions again whenever they change and the textarea is focused
|
||||
|
||||
@@ -30,9 +30,12 @@ const Blurhash: React.FC<Props> = ({
|
||||
try {
|
||||
const pixels = decode(hash, width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imageData = new ImageData(pixels, width, height);
|
||||
const imageData = ctx?.createImageData(width, height);
|
||||
imageData?.data.set(pixels);
|
||||
|
||||
ctx?.putImageData(imageData, 0, 0);
|
||||
if (imageData) {
|
||||
ctx?.putImageData(imageData, 0, 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Blurhash decoding failure', { err, hash });
|
||||
}
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
import type { List } from 'immutable';
|
||||
|
||||
import type { CustomEmoji } from '../models/custom_emoji';
|
||||
import type { Status } from '../models/status';
|
||||
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import type { IconName } from './media_icon';
|
||||
import { MediaIcon } from './media_icon';
|
||||
import { StatusBanner, BannerVariant } from './status_banner';
|
||||
|
||||
export const ContentWarning: React.FC<{
|
||||
text: string;
|
||||
status: Status;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
icons?: IconName[];
|
||||
}> = ({ text, expanded, onClick, icons }) => (
|
||||
<StatusBanner
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
variant={BannerVariant.Warning}
|
||||
>
|
||||
{icons?.map((icon) => (
|
||||
<MediaIcon
|
||||
className='status__content__spoiler-icon'
|
||||
icon={icon}
|
||||
key={`icon-${icon}`}
|
||||
}> = ({ status, expanded, onClick, icons }) => {
|
||||
const hasSpoiler = !!status.get('spoiler_text');
|
||||
if (!hasSpoiler) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text =
|
||||
status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml');
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBanner
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
variant={BannerVariant.Warning}
|
||||
>
|
||||
{icons?.map((icon) => (
|
||||
<MediaIcon
|
||||
className='status__content__spoiler-icon'
|
||||
icon={icon}
|
||||
key={`icon-${icon}`}
|
||||
/>
|
||||
))}
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={text}
|
||||
extraEmojis={status.get('emojis') as List<CustomEmoji>}
|
||||
/>
|
||||
))}
|
||||
<span dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</StatusBanner>
|
||||
);
|
||||
</StatusBanner>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,33 +2,28 @@ import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { EmojiHTML } from '@/flavours/glitch/features/emoji/emoji_html';
|
||||
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
||||
|
||||
import { AnimateEmojiProvider } from '../emoji/context';
|
||||
import { EmojiHTML } from '../emoji/html';
|
||||
import { Skeleton } from '../skeleton';
|
||||
|
||||
import type { DisplayNameProps } from './index';
|
||||
|
||||
export const DisplayNameWithoutDomain: FC<
|
||||
Omit<DisplayNameProps, 'variant' | 'localDomain'> &
|
||||
ComponentPropsWithoutRef<'span'>
|
||||
> = ({ account, className, children, ...props }) => {
|
||||
Omit<DisplayNameProps, 'variant'> & ComponentPropsWithoutRef<'span'>
|
||||
> = ({ account, className, children, localDomain: _, ...props }) => {
|
||||
return (
|
||||
<span
|
||||
<AnimateEmojiProvider
|
||||
{...props}
|
||||
className={classNames('display-name animate-parent', className)}
|
||||
as='span'
|
||||
className={classNames('display-name', className)}
|
||||
>
|
||||
<bdi>
|
||||
{account ? (
|
||||
<EmojiHTML
|
||||
className='display-name__html'
|
||||
htmlString={
|
||||
isModernEmojiEnabled()
|
||||
? account.get('display_name')
|
||||
: account.get('display_name_html')
|
||||
}
|
||||
shallow
|
||||
htmlString={account.get('display_name_html')}
|
||||
as='strong'
|
||||
extraEmojis={account.get('emojis')}
|
||||
/>
|
||||
) : (
|
||||
<strong className='display-name__html'>
|
||||
@@ -37,6 +32,6 @@ export const DisplayNameWithoutDomain: FC<
|
||||
)}
|
||||
</bdi>
|
||||
{children}
|
||||
</span>
|
||||
</AnimateEmojiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import { EmojiHTML } from '@/flavours/glitch/features/emoji/emoji_html';
|
||||
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
||||
import { EmojiHTML } from '../emoji/html';
|
||||
|
||||
import type { DisplayNameProps } from './index';
|
||||
|
||||
export const DisplayNameSimple: FC<
|
||||
Omit<DisplayNameProps, 'variant' | 'localDomain'> &
|
||||
ComponentPropsWithoutRef<'span'>
|
||||
> = ({ account, ...props }) => {
|
||||
Omit<DisplayNameProps, 'variant'> & ComponentPropsWithoutRef<'span'>
|
||||
> = ({ account, localDomain: _, ...props }) => {
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
const accountName = isModernEmojiEnabled()
|
||||
? account.get('display_name')
|
||||
: account.get('display_name_html');
|
||||
|
||||
return (
|
||||
<bdi>
|
||||
<EmojiHTML {...props} htmlString={accountName} shallow as='span' />
|
||||
<EmojiHTML
|
||||
{...props}
|
||||
as='span'
|
||||
htmlString={account.get('display_name_html')}
|
||||
extraEmojis={account.get('emojis')}
|
||||
/>
|
||||
</bdi>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
closeDropdownMenu,
|
||||
} from 'flavours/glitch/actions/dropdown_menu';
|
||||
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
|
||||
import { fetchStatus } from 'flavours/glitch/actions/statuses';
|
||||
import { CircularProgress } from 'flavours/glitch/components/circular_progress';
|
||||
import { isUserTouching } from 'flavours/glitch/is_mobile';
|
||||
import {
|
||||
@@ -42,16 +43,10 @@ import { IconButton } from './icon_button';
|
||||
|
||||
let id = 0;
|
||||
|
||||
export interface RenderItemFnHandlers {
|
||||
onClick: React.MouseEventHandler;
|
||||
onKeyUp: React.KeyboardEventHandler;
|
||||
}
|
||||
|
||||
export type RenderItemFn<Item = MenuItem> = (
|
||||
item: Item,
|
||||
index: number,
|
||||
handlers: RenderItemFnHandlers,
|
||||
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void,
|
||||
onClick: React.MouseEventHandler,
|
||||
) => React.ReactNode;
|
||||
|
||||
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
|
||||
@@ -101,7 +96,6 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
onItemClick,
|
||||
}: DropdownMenuProps<Item>) => {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const focusedItemRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (e: MouseEvent) => {
|
||||
@@ -163,8 +157,11 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
|
||||
if (focusedItemRef.current && openedViaKeyboard) {
|
||||
focusedItemRef.current.focus({ preventScroll: true });
|
||||
if (openedViaKeyboard) {
|
||||
const firstMenuItem = nodeRef.current?.querySelector<
|
||||
HTMLAnchorElement | HTMLButtonElement
|
||||
>('li:first-child > :is(a, button)');
|
||||
firstMenuItem?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -175,13 +172,6 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
};
|
||||
}, [onClose, openedViaKeyboard]);
|
||||
|
||||
const handleFocusedItemRef = useCallback(
|
||||
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
|
||||
focusedItemRef.current = c as HTMLElement;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
@@ -207,15 +197,6 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
[onClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const handleItemKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleItemClick(e);
|
||||
}
|
||||
},
|
||||
[handleItemClick],
|
||||
);
|
||||
|
||||
const nativeRenderItem = (option: Item, i: number) => {
|
||||
if (!isMenuItem(option)) {
|
||||
return null;
|
||||
@@ -232,9 +213,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
if (isActionItem(option)) {
|
||||
element = (
|
||||
<button
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
@@ -248,9 +227,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
target={option.target ?? '_target'}
|
||||
data-method={option.method}
|
||||
rel='noopener'
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
<DropdownMenuItemContent item={option} />
|
||||
@@ -258,13 +235,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
);
|
||||
} else {
|
||||
element = (
|
||||
<Link
|
||||
to={option.to}
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
<Link to={option.to} onClick={handleItemClick} data-index={i}>
|
||||
<DropdownMenuItemContent item={option} />
|
||||
</Link>
|
||||
);
|
||||
@@ -307,15 +278,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
})}
|
||||
>
|
||||
{items.map((option, i) =>
|
||||
renderItemMethod(
|
||||
option,
|
||||
i,
|
||||
{
|
||||
onClick: handleItemClick,
|
||||
onKeyUp: handleItemKeyUp,
|
||||
},
|
||||
i === 0 ? handleFocusedItemRef : undefined,
|
||||
),
|
||||
renderItemMethod(option, i, handleItemClick),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
@@ -340,6 +303,7 @@ interface DropdownProps<Item extends object | null = MenuItem> {
|
||||
*/
|
||||
scrollKey?: string;
|
||||
status?: ImmutableMap<string, unknown>;
|
||||
needsStatusRefresh?: boolean;
|
||||
forceDropdown?: boolean;
|
||||
renderItem?: RenderItemFn<Item>;
|
||||
renderHeader?: RenderHeaderFn<Item>;
|
||||
@@ -363,6 +327,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
placement = 'bottom',
|
||||
offset = [5, 5],
|
||||
status,
|
||||
needsStatusRefresh,
|
||||
forceDropdown = false,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
@@ -382,6 +347,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
const prefetchAccountId = status
|
||||
? status.getIn(['account', 'id'])
|
||||
: undefined;
|
||||
const statusId = status?.get('id') as string | undefined;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (buttonRef.current) {
|
||||
@@ -399,7 +365,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
}, [dispatch, currentId]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
(e: React.MouseEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = items?.[i];
|
||||
|
||||
@@ -420,10 +386,20 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
[handleClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const toggleDropdown = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const { type } = e;
|
||||
const isKeypressRef = useRef(false);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
isKeypressRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unsetIsKeypress = useCallback(() => {
|
||||
isKeypressRef.current = false;
|
||||
}, []);
|
||||
|
||||
const toggleDropdown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (open) {
|
||||
handleClose();
|
||||
} else {
|
||||
@@ -436,6 +412,15 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
dispatch(fetchRelationships([prefetchAccountId]));
|
||||
}
|
||||
|
||||
if (needsStatusRefresh && statusId) {
|
||||
dispatch(
|
||||
fetchStatus(statusId, {
|
||||
forceFetch: true,
|
||||
alsoFetchContext: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (isUserTouching() && !forceDropdown) {
|
||||
dispatch(
|
||||
openModal({
|
||||
@@ -450,10 +435,11 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
dispatch(
|
||||
openDropdownMenu({
|
||||
id: currentId,
|
||||
keyboard: type !== 'click',
|
||||
keyboard: isKeypressRef.current,
|
||||
scrollKey,
|
||||
}),
|
||||
);
|
||||
isKeypressRef.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -468,6 +454,8 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
items,
|
||||
forceDropdown,
|
||||
handleClose,
|
||||
statusId,
|
||||
needsStatusRefresh,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -484,6 +472,9 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
const buttonProps = {
|
||||
disabled,
|
||||
onClick: toggleDropdown,
|
||||
onKeyDown: handleKeyDown,
|
||||
onKeyUp: unsetIsKeypress,
|
||||
onBlur: unsetIsKeypress,
|
||||
'aria-expanded': open,
|
||||
'aria-controls': menuId,
|
||||
ref: buttonRef,
|
||||
|
||||
@@ -58,17 +58,7 @@ export const EditedTimestamp: React.FC<{
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(
|
||||
item: HistoryItem,
|
||||
index: number,
|
||||
{
|
||||
onClick,
|
||||
onKeyUp,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler;
|
||||
onKeyUp: React.KeyboardEventHandler;
|
||||
},
|
||||
) => {
|
||||
(item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
|
||||
const formattedDate = (
|
||||
<RelativeTimestamp
|
||||
timestamp={item.get('created_at') as string}
|
||||
@@ -98,7 +88,7 @@ export const EditedTimestamp: React.FC<{
|
||||
className='dropdown-menu__item edited-timestamp__history__item'
|
||||
key={item.get('created_at') as string}
|
||||
>
|
||||
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}>
|
||||
<button data-index={index} onClick={onClick} type='button'>
|
||||
{label}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
102
app/javascript/flavours/glitch/components/emoji/context.tsx
Normal file
102
app/javascript/flavours/glitch/components/emoji/context.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { MouseEventHandler, PropsWithChildren } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { cleanExtraEmojis } from '@/flavours/glitch/features/emoji/normalize';
|
||||
import { autoPlayGif } from '@/flavours/glitch/initial_state';
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
import type {
|
||||
CustomEmojiMapArg,
|
||||
ExtraCustomEmojiMap,
|
||||
} from 'flavours/glitch/features/emoji/types';
|
||||
|
||||
// Animation context
|
||||
export const AnimateEmojiContext = createContext<boolean | null>(null);
|
||||
|
||||
// Polymorphic provider component
|
||||
type AnimateEmojiProviderProps = Required<PropsWithChildren> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AnimateEmojiProvider = polymorphicForwardRef<
|
||||
'div',
|
||||
AnimateEmojiProviderProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
as: Wrapper = 'div',
|
||||
className,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [animate, setAnimate] = useState(autoPlayGif ?? false);
|
||||
|
||||
const handleEnter: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(event) => {
|
||||
onMouseEnter?.(event);
|
||||
if (!autoPlayGif) {
|
||||
setAnimate(true);
|
||||
}
|
||||
},
|
||||
[onMouseEnter],
|
||||
);
|
||||
const handleLeave: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(event) => {
|
||||
onMouseLeave?.(event);
|
||||
if (!autoPlayGif) {
|
||||
setAnimate(false);
|
||||
}
|
||||
},
|
||||
[onMouseLeave],
|
||||
);
|
||||
|
||||
// If there's a parent context or GIFs autoplay, we don't need handlers.
|
||||
const parentContext = useContext(AnimateEmojiContext);
|
||||
if (parentContext !== null) {
|
||||
return (
|
||||
<Wrapper {...props} className={className} ref={ref}>
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
{...props}
|
||||
className={className}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
ref={ref}
|
||||
>
|
||||
<AnimateEmojiContext.Provider value={animate}>
|
||||
{children}
|
||||
</AnimateEmojiContext.Provider>
|
||||
</Wrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
AnimateEmojiProvider.displayName = 'AnimateEmojiProvider';
|
||||
|
||||
// Handle custom emoji
|
||||
export const CustomEmojiContext = createContext<ExtraCustomEmojiMap>({});
|
||||
|
||||
export const CustomEmojiProvider = ({
|
||||
children,
|
||||
emojis: rawEmojis,
|
||||
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => {
|
||||
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]);
|
||||
return (
|
||||
<CustomEmojiContext.Provider value={emojis}>
|
||||
{children}
|
||||
</CustomEmojiContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { importCustomEmojiData } from '@/flavours/glitch/features/emoji/loader';
|
||||
|
||||
import { Emoji } from './index';
|
||||
|
||||
type EmojiProps = ComponentProps<typeof Emoji> & { state: string };
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Emoji',
|
||||
component: Emoji,
|
||||
args: {
|
||||
code: '🖤',
|
||||
state: 'auto',
|
||||
},
|
||||
argTypes: {
|
||||
code: {
|
||||
name: 'Emoji',
|
||||
},
|
||||
state: {
|
||||
control: {
|
||||
type: 'select',
|
||||
labels: {
|
||||
auto: 'Auto',
|
||||
native: 'Native',
|
||||
twemoji: 'Twemoji',
|
||||
},
|
||||
},
|
||||
options: ['auto', 'native', 'twemoji'],
|
||||
name: 'Emoji Style',
|
||||
mapping: {
|
||||
auto: { meta: { emoji_style: 'auto' } },
|
||||
native: { meta: { emoji_style: 'native' } },
|
||||
twemoji: { meta: { emoji_style: 'twemoji' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
render(args) {
|
||||
void importCustomEmojiData();
|
||||
return <Emoji {...args} />;
|
||||
},
|
||||
} satisfies Meta<EmojiProps>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const CustomEmoji: Story = {
|
||||
args: {
|
||||
code: ':custom:',
|
||||
},
|
||||
};
|
||||
59
app/javascript/flavours/glitch/components/emoji/html.tsx
Normal file
59
app/javascript/flavours/glitch/components/emoji/html.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types';
|
||||
import type {
|
||||
OnAttributeHandler,
|
||||
OnElementHandler,
|
||||
} from '@/flavours/glitch/utils/html';
|
||||
import { htmlStringToComponents } from '@/flavours/glitch/utils/html';
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
||||
import { textToEmojis } from './index';
|
||||
|
||||
export interface EmojiHTMLProps {
|
||||
htmlString: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
className?: string;
|
||||
onElement?: OnElementHandler;
|
||||
onAttribute?: OnAttributeHandler;
|
||||
}
|
||||
|
||||
export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||
(
|
||||
{
|
||||
extraEmojis,
|
||||
htmlString,
|
||||
as: asProp = 'div', // Rename for syntax highlighting
|
||||
className = '',
|
||||
onElement,
|
||||
onAttribute,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const contents = useMemo(
|
||||
() =>
|
||||
htmlStringToComponents(htmlString, {
|
||||
onText: textToEmojis,
|
||||
onElement,
|
||||
onAttribute,
|
||||
}),
|
||||
[htmlString, onAttribute, onElement],
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomEmojiProvider emojis={extraEmojis}>
|
||||
<AnimateEmojiProvider
|
||||
{...props}
|
||||
as={asProp}
|
||||
className={className}
|
||||
ref={ref}
|
||||
>
|
||||
{contents}
|
||||
</AnimateEmojiProvider>
|
||||
</CustomEmojiProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
EmojiHTML.displayName = 'EmojiHTML';
|
||||
99
app/javascript/flavours/glitch/components/emoji/index.tsx
Normal file
99
app/javascript/flavours/glitch/components/emoji/index.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { FC } from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { EMOJI_TYPE_CUSTOM } from '@/flavours/glitch/features/emoji/constants';
|
||||
import { useEmojiAppState } from '@/flavours/glitch/features/emoji/mode';
|
||||
import { unicodeHexToUrl } from '@/flavours/glitch/features/emoji/normalize';
|
||||
import {
|
||||
isStateLoaded,
|
||||
loadEmojiDataToState,
|
||||
shouldRenderImage,
|
||||
stringToEmojiState,
|
||||
tokenizeText,
|
||||
} from '@/flavours/glitch/features/emoji/render';
|
||||
|
||||
import { AnimateEmojiContext, CustomEmojiContext } from './context';
|
||||
|
||||
interface EmojiProps {
|
||||
code: string;
|
||||
showFallback?: boolean;
|
||||
showLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Emoji: FC<EmojiProps> = ({
|
||||
code,
|
||||
showFallback = true,
|
||||
showLoading = true,
|
||||
}) => {
|
||||
const customEmoji = useContext(CustomEmojiContext);
|
||||
|
||||
// First, set the emoji state based on the input code.
|
||||
const [state, setState] = useState(() =>
|
||||
stringToEmojiState(code, customEmoji),
|
||||
);
|
||||
|
||||
// If we don't have data, then load emoji data asynchronously.
|
||||
const appState = useEmojiAppState();
|
||||
useEffect(() => {
|
||||
if (state !== null) {
|
||||
void loadEmojiDataToState(state, appState.currentLocale).then(setState);
|
||||
}
|
||||
}, [appState.currentLocale, state]);
|
||||
|
||||
const animate = useContext(AnimateEmojiContext);
|
||||
const fallback = showFallback ? code : null;
|
||||
|
||||
// If the code is invalid or we otherwise know it's not valid, show the fallback.
|
||||
if (!state) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (!shouldRenderImage(state, appState.mode)) {
|
||||
return code;
|
||||
}
|
||||
|
||||
if (!isStateLoaded(state)) {
|
||||
if (showLoading) {
|
||||
return <span className='emojione emoji-loading' title={code} />;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (state.type === EMOJI_TYPE_CUSTOM) {
|
||||
const shortcode = `:${state.code}:`;
|
||||
return (
|
||||
<img
|
||||
src={animate ? state.data.url : state.data.static_url}
|
||||
alt={shortcode}
|
||||
title={shortcode}
|
||||
className='emojione custom-emoji'
|
||||
loading='lazy'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const src = unicodeHexToUrl(state.code, appState.darkTheme);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={state.data.unicode}
|
||||
title={state.data.label}
|
||||
className='emojione'
|
||||
loading='lazy'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a text string and converts it to an array of React nodes.
|
||||
* @param text The text to be tokenized and converted.
|
||||
*/
|
||||
export function textToEmojis(text: string) {
|
||||
return tokenizeText(text).map((token, index) => {
|
||||
if (typeof token === 'string') {
|
||||
return token;
|
||||
}
|
||||
return <Emoji code={token.code} key={`emoji-${token.code}-${index}`} />;
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { useDrag } from '@use-gesture/react';
|
||||
import { expandAccountFeaturedTimeline } from '@/flavours/glitch/actions/timelines';
|
||||
import { Icon } from '@/flavours/glitch/components/icon';
|
||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||
import StatusContainer from '@/flavours/glitch/containers/status_container';
|
||||
import { StatusQuoteManager } from '@/flavours/glitch/components/status_quoted';
|
||||
import { usePrevious } from '@/flavours/glitch/hooks/usePrevious';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
@@ -218,12 +218,7 @@ const FeaturedCarouselItem: React.FC<
|
||||
ref={handleRef}
|
||||
{...props}
|
||||
>
|
||||
<StatusContainer
|
||||
// @ts-expect-error inferred props are wrong
|
||||
id={statusId}
|
||||
contextType='account'
|
||||
withCounters
|
||||
/>
|
||||
<StatusQuoteManager id={statusId} contextType='account' withCounters />
|
||||
</animated.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useIdentity } from '@/flavours/glitch/identity_context';
|
||||
import {
|
||||
fetchRelationships,
|
||||
followAccount,
|
||||
unmuteAccount,
|
||||
} from 'flavours/glitch/actions/accounts';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
@@ -15,17 +16,50 @@ import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
import { useBreakpoint } from '../features/ui/hooks/useBreakpoint';
|
||||
|
||||
const longMessages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||
followRequest: {
|
||||
id: 'account.follow_request',
|
||||
defaultMessage: 'Request to follow',
|
||||
},
|
||||
followRequestCancel: {
|
||||
id: 'account.follow_request_cancel',
|
||||
defaultMessage: 'Cancel request',
|
||||
},
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
});
|
||||
|
||||
const shortMessages = {
|
||||
...longMessages, // Align type signature of shortMessages and longMessages
|
||||
...defineMessages({
|
||||
followBack: {
|
||||
id: 'account.follow_back_short',
|
||||
defaultMessage: 'Follow back',
|
||||
},
|
||||
followRequest: {
|
||||
id: 'account.follow_request_short',
|
||||
defaultMessage: 'Request',
|
||||
},
|
||||
followRequestCancel: {
|
||||
id: 'account.follow_request_cancel_short',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
editProfile: { id: 'account.edit_profile_short', defaultMessage: 'Edit' },
|
||||
}),
|
||||
};
|
||||
|
||||
export const FollowButton: React.FC<{
|
||||
accountId?: string;
|
||||
compact?: boolean;
|
||||
}> = ({ accountId, compact }) => {
|
||||
labelLength?: 'auto' | 'short' | 'long';
|
||||
className?: string;
|
||||
}> = ({ accountId, compact, labelLength = 'auto', className }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { signedIn } = useIdentity();
|
||||
@@ -60,29 +94,60 @@ export const FollowButton: React.FC<{
|
||||
|
||||
if (accountId === me) {
|
||||
return;
|
||||
} else if (account && (relationship.following || relationship.requested)) {
|
||||
} else if (relationship.muting) {
|
||||
dispatch(unmuteAccount(accountId));
|
||||
} else if (account && relationship.following) {
|
||||
dispatch(
|
||||
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
||||
);
|
||||
} else if (account && relationship.requested) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_WITHDRAW_REQUEST',
|
||||
modalProps: { account },
|
||||
}),
|
||||
);
|
||||
} else if (relationship.blocking) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_UNBLOCK',
|
||||
modalProps: { account },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(followAccount(accountId));
|
||||
}
|
||||
}, [dispatch, accountId, relationship, account, signedIn]);
|
||||
|
||||
const isNarrow = useBreakpoint('narrow');
|
||||
const useShortLabel =
|
||||
labelLength === 'short' || (labelLength === 'auto' && isNarrow);
|
||||
const messages = useShortLabel ? shortMessages : longMessages;
|
||||
|
||||
const followMessage = account?.locked
|
||||
? messages.followRequest
|
||||
: messages.follow;
|
||||
|
||||
let label;
|
||||
|
||||
if (!signedIn) {
|
||||
label = intl.formatMessage(messages.follow);
|
||||
label = intl.formatMessage(followMessage);
|
||||
} else if (accountId === me) {
|
||||
label = intl.formatMessage(messages.edit_profile);
|
||||
} else if (!relationship) {
|
||||
label = <LoadingIndicator />;
|
||||
} else if (relationship.following || relationship.requested) {
|
||||
} else if (relationship.muting) {
|
||||
label = intl.formatMessage(messages.unmute);
|
||||
} else if (relationship.following) {
|
||||
label = intl.formatMessage(messages.unfollow);
|
||||
} else if (relationship.followed_by) {
|
||||
} else if (relationship.blocking) {
|
||||
label = intl.formatMessage(messages.unblock);
|
||||
} else if (relationship.requested) {
|
||||
label = intl.formatMessage(messages.followRequestCancel);
|
||||
} else if (relationship.followed_by && !account?.locked) {
|
||||
label = intl.formatMessage(messages.followBack);
|
||||
} else {
|
||||
label = intl.formatMessage(messages.follow);
|
||||
label = intl.formatMessage(followMessage);
|
||||
}
|
||||
|
||||
if (accountId === me) {
|
||||
@@ -91,7 +156,7 @@ export const FollowButton: React.FC<{
|
||||
href='/settings/profile'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
className={classNames('button button-secondary', {
|
||||
className={classNames(className, 'button button-secondary', {
|
||||
'button--compact': compact,
|
||||
})}
|
||||
>
|
||||
@@ -105,13 +170,12 @@ export const FollowButton: React.FC<{
|
||||
onClick={handleClick}
|
||||
disabled={
|
||||
relationship?.blocked_by ||
|
||||
relationship?.blocking ||
|
||||
(!(relationship?.following || relationship?.requested) &&
|
||||
(account?.suspended || !!account?.moved))
|
||||
}
|
||||
secondary={following}
|
||||
compact={compact}
|
||||
className={following ? 'button--destructive' : undefined}
|
||||
className={classNames(className, { 'button--destructive': following })}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,7 @@ function isNodeLinkHashtag(element: Node): element is HTMLLinkElement {
|
||||
return (
|
||||
element instanceof HTMLAnchorElement &&
|
||||
// it may be a <a> starting with a hashtag
|
||||
(element.textContent?.[0] === '#' ||
|
||||
(element.textContent.startsWith('#') ||
|
||||
// or a #<a>
|
||||
element.previousSibling?.textContent?.[
|
||||
element.previousSibling.textContent.length - 1
|
||||
|
||||
@@ -105,6 +105,7 @@ const hotkeyMatcherMap = {
|
||||
reply: just('r'),
|
||||
favourite: just('f'),
|
||||
boost: just('b'),
|
||||
bookmark: just('d'),
|
||||
quote: just('q'),
|
||||
mention: just('m'),
|
||||
open: any('enter', 'o'),
|
||||
@@ -180,25 +181,24 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
|
||||
|
||||
if (shouldHandleEvent) {
|
||||
const matchCandidates: {
|
||||
handler: (event: KeyboardEvent) => void;
|
||||
// A candidate will be have an undefined handler if it's matched,
|
||||
// but handled in a parent component rather than this one.
|
||||
handler: ((event: KeyboardEvent) => void) | undefined;
|
||||
priority: number;
|
||||
}[] = [];
|
||||
|
||||
(Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach(
|
||||
(handlerName) => {
|
||||
const handler = handlersRef.current[handlerName];
|
||||
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
|
||||
|
||||
if (handler) {
|
||||
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
|
||||
const { isMatch, priority } = hotkeyMatcher(
|
||||
event,
|
||||
bufferedKeys.current,
|
||||
);
|
||||
|
||||
const { isMatch, priority } = hotkeyMatcher(
|
||||
event,
|
||||
bufferedKeys.current,
|
||||
);
|
||||
|
||||
if (isMatch) {
|
||||
matchCandidates.push({ handler, priority });
|
||||
}
|
||||
if (isMatch) {
|
||||
matchCandidates.push({ handler, priority });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -109,7 +109,14 @@ export const HoverCardAccount = forwardRef<
|
||||
accountId={account.id}
|
||||
className='hover-card__bio'
|
||||
/>
|
||||
<AccountFields fields={account.fields} limit={2} />
|
||||
|
||||
<div className='account-fields'>
|
||||
<AccountFields
|
||||
fields={account.fields.take(2)}
|
||||
emojis={account.emojis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{note && note.length > 0 && (
|
||||
<dl className='hover-card__note'>
|
||||
<dt className='hover-card__note-label'>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
import { HTMLBlock } from './index';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/HTMLBlock',
|
||||
component: HTMLBlock,
|
||||
args: {
|
||||
htmlString: `<p>Hello, world!</p>
|
||||
<p><a href="#">A link</a></p>
|
||||
<p>This should be filtered out: <button>Bye!</button></p>
|
||||
<p>This also has emoji: 🖤</p>`,
|
||||
},
|
||||
argTypes: {
|
||||
extraEmojis: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
onElement: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
onAttribute: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
// Just for visual clarity in Storybook.
|
||||
<HTMLBlock
|
||||
{...args}
|
||||
style={{
|
||||
border: '1px solid black',
|
||||
padding: '1rem',
|
||||
minWidth: '300px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Force Twemoji to demonstrate emoji rendering.
|
||||
parameters: {
|
||||
state: {
|
||||
meta: {
|
||||
emoji_style: 'twemoji',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof HTMLBlock>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
async play({ canvas }) {
|
||||
const link = canvas.queryByRole('link');
|
||||
await expect(link).toBeInTheDocument();
|
||||
const button = canvas.queryByRole('button');
|
||||
await expect(button).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import type { EmojiHTMLProps } from '../emoji/html';
|
||||
import { EmojiHTML } from '../emoji/html';
|
||||
import { useElementHandledLink } from '../status/handled_link';
|
||||
|
||||
export const HTMLBlock = polymorphicForwardRef<
|
||||
'div',
|
||||
EmojiHTMLProps & Parameters<typeof useElementHandledLink>[0]
|
||||
>(
|
||||
({
|
||||
onElement: onParentElement,
|
||||
hrefToMention,
|
||||
hashtagAccountId,
|
||||
...props
|
||||
}) => {
|
||||
const { onElement: onLinkElement } = useElementHandledLink({
|
||||
hrefToMention,
|
||||
hashtagAccountId,
|
||||
});
|
||||
const onElement: OnElementHandler = useCallback(
|
||||
(...args) => onParentElement?.(...args) ?? onLinkElement(...args),
|
||||
[onLinkElement, onParentElement],
|
||||
);
|
||||
return <EmojiHTML {...props} onElement={onElement} />;
|
||||
},
|
||||
);
|
||||
@@ -8,13 +8,12 @@ import classNames from 'classnames';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
|
||||
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import emojify from 'flavours/glitch/features/emoji/emoji';
|
||||
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||
import { makeEmojiMap } from 'flavours/glitch/models/custom_emoji';
|
||||
import type * as Model from 'flavours/glitch/models/poll';
|
||||
import type { Status } from 'flavours/glitch/models/status';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
@@ -234,12 +233,11 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
||||
let titleHtml = option.translation?.titleHtml ?? option.titleHtml;
|
||||
|
||||
if (!titleHtml) {
|
||||
const emojiMap = makeEmojiMap(poll.emojis);
|
||||
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
|
||||
titleHtml = escapeTextContentForBrowser(title);
|
||||
}
|
||||
|
||||
return titleHtml;
|
||||
}, [option, poll, title]);
|
||||
}, [option, title]);
|
||||
|
||||
// Handlers
|
||||
const handleOptionChange = useCallback(() => {
|
||||
@@ -305,10 +303,11 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
<EmojiHTML
|
||||
className='poll__option__text translate'
|
||||
lang={lang}
|
||||
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
||||
htmlString={titleHtml}
|
||||
extraEmojis={poll.emojis}
|
||||
/>
|
||||
|
||||
{!!voted && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
import type { useLocation } from 'react-router';
|
||||
import { Router as OriginalRouter, useHistory } from 'react-router';
|
||||
|
||||
import type {
|
||||
@@ -18,7 +19,9 @@ interface MastodonLocationState {
|
||||
mastodonModalKey?: string;
|
||||
}
|
||||
|
||||
type LocationState = MastodonLocationState | null | undefined;
|
||||
export type LocationState = MastodonLocationState | null | undefined;
|
||||
|
||||
export type MastodonLocation = ReturnType<typeof useLocation<LocationState>>;
|
||||
|
||||
type HistoryPath = Path | LocationDescriptor<LocationState>;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { connect } from 'react-redux';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||
import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
|
||||
|
||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
||||
@@ -399,7 +399,7 @@ class ScrollableList extends PureComponent {
|
||||
|
||||
if (trackScroll) {
|
||||
return (
|
||||
<ScrollContainer scrollKey={scrollKey}>
|
||||
<ScrollContainer scrollKey={scrollKey} childRef={this.setRef}>
|
||||
{scrollableArea}
|
||||
</ScrollContainer>
|
||||
);
|
||||
|
||||
@@ -118,7 +118,7 @@ class Status extends ImmutablePureComponent {
|
||||
prepend: PropTypes.string,
|
||||
withDismiss: PropTypes.bool,
|
||||
isQuotedPost: PropTypes.bool,
|
||||
shouldHighlightOnMount: PropTypes.bool,
|
||||
shouldHighlightOnMount: PropTypes.bool,
|
||||
getScrollPosition: PropTypes.func,
|
||||
updateScrollBottom: PropTypes.func,
|
||||
expanded: PropTypes.bool,
|
||||
@@ -159,6 +159,7 @@ class Status extends ImmutablePureComponent {
|
||||
'expanded',
|
||||
'unread',
|
||||
'pictureInPicture',
|
||||
'onQuoteCancel',
|
||||
'previousId',
|
||||
'nextInReplyToId',
|
||||
'rootId',
|
||||
@@ -739,7 +740,7 @@ class Status extends ImmutablePureComponent {
|
||||
</header>
|
||||
)}
|
||||
|
||||
{status.get('spoiler_text').length > 0 && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} icons={mediaIcons} />}
|
||||
<ContentWarning status={status} expanded={expanded} onClick={this.handleExpandedToggle} icons={mediaIcons} />
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
@@ -750,8 +751,6 @@ class Status extends ImmutablePureComponent {
|
||||
collapsible
|
||||
media={media}
|
||||
onCollapsedToggle={this.handleCollapsedToggle}
|
||||
tagLinks={settings.get('tag_misleading_links')}
|
||||
rewriteMentions={settings.get('rewrite_mentions')}
|
||||
{...statusContentProps}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { FC, KeyboardEvent, MouseEvent } from 'react';
|
||||
import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
@@ -8,12 +8,14 @@ import classNames from 'classnames';
|
||||
import { quoteComposeById } from '@/flavours/glitch/actions/compose_typed';
|
||||
import { toggleReblog } from '@/flavours/glitch/actions/interactions';
|
||||
import { openModal } from '@/flavours/glitch/actions/modal';
|
||||
import { fetchStatus } from '@/flavours/glitch/actions/statuses';
|
||||
import { quickBoosting } from '@/flavours/glitch/initial_state';
|
||||
import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu';
|
||||
import type { Status } from '@/flavours/glitch/models/status';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||
import type { SomeRequired } from '@/flavours/glitch/utils/types';
|
||||
|
||||
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
|
||||
import type { RenderItemFn } from '../dropdown_menu';
|
||||
import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu';
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
@@ -24,18 +26,61 @@ import {
|
||||
selectStatusState,
|
||||
} from './boost_button_utils';
|
||||
|
||||
const renderMenuItem: RenderItemFn<ActionMenuItem> = (
|
||||
item,
|
||||
index,
|
||||
handlers,
|
||||
focusRefCallback,
|
||||
) => (
|
||||
const StandaloneBoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const statusState = useAppSelector((state) =>
|
||||
selectStatusState(state, status),
|
||||
);
|
||||
const { title, meta, iconComponent, disabled } = useMemo(
|
||||
() => boostItemState(statusState),
|
||||
[statusState],
|
||||
);
|
||||
|
||||
const handleClick: MouseEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (statusState.isLoggedIn) {
|
||||
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, status, statusState.isLoggedIn],
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
active={!!status.get('reblogged')}
|
||||
title={intl.formatMessage(meta ?? title)}
|
||||
icon='retweet'
|
||||
iconComponent={iconComponent}
|
||||
onClick={!disabled ? handleClick : undefined}
|
||||
counter={
|
||||
counters
|
||||
? (status.get('reblogs_count') as number) +
|
||||
(status.get('quotes_count') as number)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMenuItem: RenderItemFn<ActionMenuItem> = (item, index, onClick) => (
|
||||
<ReblogMenuItem
|
||||
index={index}
|
||||
item={item}
|
||||
handlers={handlers}
|
||||
onClick={onClick}
|
||||
key={`${item.text}-${index}`}
|
||||
focusRefCallback={focusRefCallback}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -46,7 +91,7 @@ interface ReblogButtonProps {
|
||||
|
||||
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
|
||||
|
||||
export const BoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const statusState = useAppSelector((state) =>
|
||||
@@ -67,6 +112,7 @@ export const BoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
|
||||
const statusId = status.get('id') as string;
|
||||
const wasBoosted = !!status.get('reblogged');
|
||||
const quoteApproval = status.get('quote_approval');
|
||||
|
||||
const showLoginPrompt = useCallback(() => {
|
||||
dispatch(
|
||||
@@ -123,9 +169,16 @@ export const BoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
dispatch(toggleReblog(status.get('id'), true));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quoteApproval === null) {
|
||||
dispatch(
|
||||
fetchStatus(statusId, { forceFetch: true, alsoFetchContext: false }),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[dispatch, isLoggedIn, showLoginPrompt, status],
|
||||
[dispatch, isLoggedIn, showLoginPrompt, status, quoteApproval, statusId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -158,16 +211,10 @@ export const BoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
interface ReblogMenuItemProps {
|
||||
item: ActionMenuItem;
|
||||
index: number;
|
||||
handlers: RenderItemFnHandlers;
|
||||
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
|
||||
onClick: React.MouseEventHandler;
|
||||
}
|
||||
|
||||
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
index,
|
||||
item,
|
||||
handlers,
|
||||
focusRefCallback,
|
||||
}) => {
|
||||
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({ index, item, onClick }) => {
|
||||
const { text, highlighted, disabled } = item;
|
||||
|
||||
return (
|
||||
@@ -177,14 +224,15 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
})}
|
||||
key={`${text}-${index}`}
|
||||
>
|
||||
<button
|
||||
{...handlers}
|
||||
ref={focusRefCallback}
|
||||
aria-disabled={disabled}
|
||||
data-index={index}
|
||||
>
|
||||
<button onClick={onClick} aria-disabled={disabled} data-index={index}>
|
||||
<DropdownMenuItemContent item={item} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
// Switch between the standalone boost button or the
|
||||
// "Boost or quote" menu based on the quickBoosting preference
|
||||
export const BoostButton = quickBoosting
|
||||
? StandaloneBoostButton
|
||||
: BoostOrQuoteMenu;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { HashtagMenuController } from '@/flavours/glitch/features/ui/components/hashtag_menu_controller';
|
||||
import { accountFactoryState } from '@/testing/factories';
|
||||
|
||||
import { HoverCardController } from '../hover_card_controller';
|
||||
|
||||
import type { HandledLinkProps } from './handled_link';
|
||||
import { HandledLink } from './handled_link';
|
||||
|
||||
type HandledLinkStoryProps = Pick<
|
||||
HandledLinkProps,
|
||||
'href' | 'text' | 'prevText'
|
||||
> & {
|
||||
mentionAccount: 'local' | 'remote' | 'none';
|
||||
hashtagAccount: boolean;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Status/HandledLink',
|
||||
render({ mentionAccount, hashtagAccount, ...args }) {
|
||||
let mention: HandledLinkProps['mention'] | undefined;
|
||||
if (mentionAccount === 'local') {
|
||||
mention = { id: '1', acct: 'testuser', username: 'testuser' };
|
||||
} else if (mentionAccount === 'remote') {
|
||||
mention = {
|
||||
id: '2',
|
||||
acct: 'remoteuser@mastodon.social',
|
||||
username: 'remoteuser',
|
||||
};
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<HandledLink
|
||||
{...args}
|
||||
mention={mention}
|
||||
hashtagAccountId={hashtagAccount ? '1' : undefined}
|
||||
>
|
||||
<span>{args.text}</span>
|
||||
</HandledLink>
|
||||
<HashtagMenuController />
|
||||
<HoverCardController />
|
||||
</>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
href: 'https://example.com/path/subpath?query=1#hash',
|
||||
text: 'https://example.com',
|
||||
mentionAccount: 'none',
|
||||
hashtagAccount: false,
|
||||
},
|
||||
argTypes: {
|
||||
mentionAccount: {
|
||||
control: { type: 'select' },
|
||||
options: ['local', 'remote', 'none'],
|
||||
defaultValue: 'none',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
state: {
|
||||
accounts: {
|
||||
'1': accountFactoryState({ id: '1', acct: 'hashtaguser' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<HandledLinkStoryProps>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Simple: Story = {
|
||||
args: {
|
||||
href: 'https://example.com/test',
|
||||
},
|
||||
};
|
||||
|
||||
export const Hashtag: Story = {
|
||||
args: {
|
||||
text: '#example',
|
||||
hashtagAccount: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Mention: Story = {
|
||||
args: {
|
||||
text: '@user',
|
||||
mentionAccount: 'local',
|
||||
},
|
||||
};
|
||||
|
||||
export const InternalLink: Story = {
|
||||
args: {
|
||||
href: '/about',
|
||||
text: 'About',
|
||||
},
|
||||
};
|
||||
|
||||
export const InvalidURL: Story = {
|
||||
args: {
|
||||
href: 'ht!tp://invalid-url',
|
||||
text: 'ht!tp://invalid-url -- invalid!',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { ComponentProps, FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
|
||||
import { useAppSelector } from '@/flavours/glitch/store';
|
||||
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
|
||||
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
|
||||
|
||||
export interface HandledLinkProps {
|
||||
href: string;
|
||||
text: string;
|
||||
prevText?: string;
|
||||
hashtagAccountId?: string;
|
||||
mention?: Pick<ApiMentionJSON, 'id' | 'acct' | 'username'>;
|
||||
}
|
||||
|
||||
const textMatchesTarget = (text: string, origin: string, host: string) => {
|
||||
return (
|
||||
text === origin ||
|
||||
text === host ||
|
||||
text.startsWith(origin + '/') ||
|
||||
text.startsWith(host + '/') ||
|
||||
'www.' + text === host ||
|
||||
('www.' + text).startsWith(host + '/')
|
||||
);
|
||||
};
|
||||
|
||||
export const isLinkMisleading = (link: HTMLAnchorElement) => {
|
||||
const linkTextParts: string[] = [];
|
||||
|
||||
// Reconstruct visible text, as we do not have much control over how links
|
||||
// from remote software look, and we can't rely on `innerText` because the
|
||||
// `invisible` class does not set `display` to `none`.
|
||||
|
||||
const walk = (node: Node) => {
|
||||
if (node instanceof Text) {
|
||||
linkTextParts.push(node.textContent);
|
||||
} else if (node instanceof HTMLElement) {
|
||||
if (node.classList.contains('invisible')) return;
|
||||
for (const child of node.childNodes) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(link);
|
||||
|
||||
const linkText = linkTextParts.join('');
|
||||
const targetURL = new URL(link.href);
|
||||
|
||||
if (targetURL.protocol === 'magnet:') {
|
||||
return !linkText.startsWith('magnet:');
|
||||
}
|
||||
|
||||
if (targetURL.protocol === 'xmpp:') {
|
||||
return !(
|
||||
linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href
|
||||
);
|
||||
}
|
||||
|
||||
// The following may not work with international domain names
|
||||
if (
|
||||
textMatchesTarget(linkText, targetURL.origin, targetURL.host) ||
|
||||
textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The link hasn't been recognized, maybe it features an international domain name
|
||||
const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC');
|
||||
const host = targetURL.host.replace(targetURL.hostname, hostname);
|
||||
const origin = targetURL.origin.replace(targetURL.host, host);
|
||||
const text = linkText.normalize('NFKC');
|
||||
return !(
|
||||
textMatchesTarget(text, origin, host) ||
|
||||
textMatchesTarget(text.toLowerCase(), origin, host)
|
||||
);
|
||||
};
|
||||
|
||||
export const tagMisleadingLink = (link: HTMLAnchorElement) => {
|
||||
try {
|
||||
if (isLinkMisleading(link)) {
|
||||
const url = new URL(link.href);
|
||||
const tag = document.createElement('span');
|
||||
tag.classList.add('link-origin-tag');
|
||||
switch (url.protocol) {
|
||||
case 'xmpp:':
|
||||
tag.textContent = `[${url.href}]`;
|
||||
break;
|
||||
case 'magnet:':
|
||||
tag.textContent = '(magnet)';
|
||||
break;
|
||||
default:
|
||||
tag.textContent = `[${url.host}]`;
|
||||
}
|
||||
link.insertAdjacentText('beforeend', ' ');
|
||||
link.insertAdjacentElement('beforeend', tag);
|
||||
}
|
||||
} catch (e) {
|
||||
// The URL is invalid, remove the href just to be safe
|
||||
if (e instanceof TypeError) link.removeAttribute('href');
|
||||
}
|
||||
};
|
||||
|
||||
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
href,
|
||||
text,
|
||||
prevText,
|
||||
hashtagAccountId,
|
||||
mention,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const rewriteMentions = useAppSelector(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
(state) => state.local_settings.get('rewrite_mentions', 'no') as string,
|
||||
);
|
||||
const tagLinks = useAppSelector(
|
||||
(state) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
state.local_settings.get('tag_misleading_links', false) as string,
|
||||
);
|
||||
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tagLinks && linkRef.current) tagMisleadingLink(linkRef.current);
|
||||
}, [tagLinks]);
|
||||
|
||||
// Handle hashtags
|
||||
if (
|
||||
(text.startsWith('#') ||
|
||||
prevText?.endsWith('#') ||
|
||||
text.startsWith('#') ||
|
||||
prevText?.endsWith('#')) &&
|
||||
!text.includes('%')
|
||||
) {
|
||||
const hashtag = text.slice(1).trim();
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames('mention hashtag', className)}
|
||||
to={`/tags/${encodeURIComponent(hashtag)}`}
|
||||
rel='tag'
|
||||
data-menu-hashtag={hashtagAccountId}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
} else if (mention) {
|
||||
// glitch-soc feature to rewrite mentions
|
||||
if (rewriteMentions !== 'no') {
|
||||
return (
|
||||
<Link
|
||||
className={classNames('mention', className)}
|
||||
to={`/@${mention.acct}`}
|
||||
title={`@${mention.acct}`}
|
||||
data-hover-card-account={mention.id}
|
||||
>
|
||||
@
|
||||
<span>
|
||||
{rewriteMentions === 'acct' ? mention.acct : mention.username}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle mentions
|
||||
return (
|
||||
<Link
|
||||
className={classNames('mention', className)}
|
||||
to={`/@${mention.acct}`}
|
||||
title={`@${mention.acct}`}
|
||||
data-hover-card-account={mention.id}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Non-absolute paths treated as internal links. This shouldn't happen, but just in case.
|
||||
if (href.startsWith('/')) {
|
||||
return (
|
||||
<Link className={classNames('unhandled-link', className)} to={href}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
title={href}
|
||||
className={classNames('unhandled-link', className)}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
translate='no'
|
||||
ref={linkRef}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const useElementHandledLink = ({
|
||||
hashtagAccountId,
|
||||
hrefToMention,
|
||||
}: {
|
||||
hashtagAccountId?: string;
|
||||
hrefToMention?: (href: string) => ApiMentionJSON | undefined;
|
||||
} = {}) => {
|
||||
const onElement = useCallback<OnElementHandler>(
|
||||
(element, { key, ...props }, children) => {
|
||||
if (element instanceof HTMLAnchorElement) {
|
||||
const mention = hrefToMention?.(element.href);
|
||||
return (
|
||||
<HandledLink
|
||||
{...props}
|
||||
key={key as string} // React requires keys to not be part of spread props.
|
||||
href={element.href}
|
||||
text={element.innerText}
|
||||
prevText={element.previousSibling?.textContent ?? undefined}
|
||||
hashtagAccountId={hashtagAccountId}
|
||||
mention={mention}
|
||||
>
|
||||
{children}
|
||||
</HandledLink>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[hashtagAccountId, hrefToMention],
|
||||
);
|
||||
return { onElement };
|
||||
};
|
||||
@@ -22,12 +22,13 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend
|
||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||
import { me } from '../../initial_state';
|
||||
import { me, quickBoosting } from '../../initial_state';
|
||||
|
||||
import { IconButton } from '../icon_button';
|
||||
import { RelativeTimestamp } from '../relative_timestamp';
|
||||
import { BoostButton } from '../status/boost_button';
|
||||
import { RemoveQuoteHint } from './remove_quote_hint';
|
||||
import { quoteItemState, selectStatusState } from '../status/boost_button_utils';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
@@ -68,6 +69,7 @@ const mapStateToProps = (state, { status }) => {
|
||||
const quotedStatusId = status.getIn(['quote', 'quoted_status']);
|
||||
return ({
|
||||
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
|
||||
statusQuoteState: selectStatusState(state, status),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -75,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
statusQuoteState: PropTypes.object,
|
||||
quotedAccountId: PropTypes.string,
|
||||
contextType: PropTypes.string,
|
||||
onReply: PropTypes.func,
|
||||
@@ -122,6 +125,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
handleQuoteClick = () => {
|
||||
this.props.onQuote(this.props.status);
|
||||
};
|
||||
|
||||
handleShareClick = () => {
|
||||
navigator.share({
|
||||
url: this.props.status.get('url'),
|
||||
@@ -214,7 +221,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { status, quotedAccountId, contextType, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
|
||||
const { status, statusQuoteState, quotedAccountId, contextType, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
|
||||
const { signedIn, permissions } = this.props.identity;
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
@@ -243,6 +250,19 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
|
||||
}
|
||||
|
||||
if (quickBoosting && signedIn) {
|
||||
const quoteItem = quoteItemState(statusQuoteState);
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(quoteItem.title),
|
||||
description: quoteItem.meta
|
||||
? intl.formatMessage(quoteItem.meta)
|
||||
: undefined,
|
||||
disabled: quoteItem.disabled,
|
||||
action: this.handleQuoteClick,
|
||||
});
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
menu.push(null);
|
||||
|
||||
@@ -354,6 +374,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
needsStatusRefresh={quickBoosting && status.get('quote_approval') === null}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
size={18}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.inlineIcon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { Button } from '../button';
|
||||
import { useDismissableBannerState } from '../dismissable_banner';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
import classes from './remove_quote_hint.module.css';
|
||||
|
||||
const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint';
|
||||
|
||||
/**
|
||||
@@ -93,7 +95,7 @@ export const RemoveQuoteHint: React.FC<{
|
||||
id: 'status.more',
|
||||
defaultMessage: 'More',
|
||||
})}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
className={classes.inlineIcon}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user