Compare commits
807 Commits
techhub
...
techhub-v4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d785a9344e | ||
|
|
26c78392f8 | ||
|
|
048430f4e8 | ||
|
|
d45b4db1d7 | ||
|
|
ef3a95affc | ||
|
|
3e6a9371b0 | ||
|
|
e91c764590 | ||
|
|
cfdd9396c0 | ||
|
|
ba498ae779 | ||
|
|
5bae08d1ff | ||
|
|
5253527ec4 | ||
|
|
0b50789c5b | ||
|
|
a978e37f4c | ||
|
|
dd708298a8 | ||
|
|
449eb03f11 | ||
|
|
1baede0a7c | ||
|
|
a7ecfc1ca5 | ||
|
|
e62baacfc1 | ||
|
|
b5a6feb3bf | ||
|
|
05964f571b | ||
|
|
16a54f7158 | ||
|
|
6d53ca63d6 | ||
|
|
93acfdd7d3 | ||
|
|
a209b8e544 | ||
|
|
af4c372ab2 | ||
|
|
aa579ce286 | ||
|
|
adfabf8c80 | ||
|
|
ea710df180 | ||
|
|
e1b6e28829 | ||
|
|
214d59bd37 | ||
|
|
e4291e9b05 | ||
|
|
2a9c7d2b9e | ||
|
|
51877081b4 | ||
|
|
7b66eefd3e | ||
|
|
e437bb919f | ||
|
|
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 | ||
|
|
1ba579b0a1 | ||
|
|
6b2051b7b3 | ||
|
|
2fa5dd6d1f | ||
|
|
f7b99cd48a | ||
|
|
92aeecfbdc | ||
|
|
7774cd6670 | ||
|
|
9f7075a0ce | ||
|
|
c40648f7b3 | ||
|
|
2bd5c2f528 | ||
|
|
1e28ec628b | ||
|
|
7538bc77b7 | ||
|
|
7ea2af6ae2 | ||
|
|
6adbd9ce52 | ||
|
|
08ae77fd9c | ||
|
|
17eb1a7e66 | ||
|
|
aba30a85be | ||
|
|
de80a54555 | ||
|
|
b80ec3721d | ||
|
|
118ff13bd0 | ||
|
|
405a49df44 | ||
|
|
2b9e4294fe | ||
|
|
2eccd7b53c | ||
|
|
74172ced81 | ||
|
|
341ea7f462 | ||
|
|
168cba35e3 | ||
|
|
6e2973aa2d | ||
|
|
97c8cc5606 | ||
|
|
50dfab30c2 | ||
|
|
53e20d5c83 | ||
|
|
0a1111d5a5 | ||
|
|
95be29d700 | ||
|
|
843c43c97a | ||
|
|
82483ed8b0 | ||
|
|
612771de46 | ||
|
|
950e7beeea | ||
|
|
1018a4def4 | ||
|
|
811c1eaf7e | ||
|
|
6dad80eb8c | ||
|
|
c96e28a41d | ||
|
|
ccac6da3e8 | ||
|
|
20961c7538 | ||
|
|
63dc426fae | ||
|
|
210b389643 | ||
|
|
51d0bfcb38 | ||
|
|
7530f06dee | ||
|
|
28339cad6d | ||
|
|
5404f92cee | ||
|
|
05244c335d | ||
|
|
241ad1c587 | ||
|
|
ef53dcfd8c | ||
|
|
869eeecfee | ||
|
|
28a42bb62c | ||
|
|
905aa9434d | ||
|
|
5a8ab0a3e6 | ||
|
|
c5fb080ab8 | ||
|
|
fab0dd0bcf | ||
|
|
c6de46d12d | ||
|
|
3160f5746d | ||
|
|
d8bdce2835 | ||
|
|
339673d533 | ||
|
|
d0d1fcd034 | ||
|
|
3550508421 | ||
|
|
e3e5067772 | ||
|
|
5fd380096f | ||
|
|
d788e45628 | ||
|
|
264d068d8d | ||
|
|
50743cc35b | ||
|
|
fd516347cb | ||
|
|
3232eee358 | ||
|
|
484225895f | ||
|
|
156f031ed0 | ||
|
|
ad858ebe81 | ||
|
|
f2f711deeb | ||
|
|
44ecc4b1e3 | ||
|
|
0c64e7f75e | ||
|
|
9a001e7839 | ||
|
|
edd7fd9872 | ||
|
|
33f739da44 | ||
|
|
254fff93ca | ||
|
|
2971ac9863 | ||
|
|
7e98fa9b47 | ||
|
|
24dcb18013 | ||
|
|
8d09e4ef23 | ||
|
|
692cfe27fa | ||
|
|
8b78c033e8 | ||
|
|
ab93e9fc8a | ||
|
|
0219b7cad7 | ||
|
|
3f2ee09827 | ||
|
|
8898f120dc | ||
|
|
81350c7cfb | ||
|
|
c858fc77ef | ||
|
|
258869278e | ||
|
|
d4a4a7177a | ||
|
|
b7c5e60426 | ||
|
|
a459ccf616 | ||
|
|
ba70dcf827 | ||
|
|
0152659245 | ||
|
|
5bc7c4b7e8 | ||
|
|
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 | ||
|
|
9027d60420 | ||
|
|
474fbb2770 | ||
|
|
4a40f81067 | ||
|
|
cda07686df | ||
|
|
68a36d5a57 | ||
|
|
ffac4cb05f | ||
|
|
4dc21d7afd | ||
|
|
2d2c525097 | ||
|
|
62f91eddf4 | ||
|
|
80c8a84740 | ||
|
|
1561517387 | ||
|
|
5543967e5c | ||
|
|
0d7af7e1fe | ||
|
|
4809b38f6e | ||
|
|
554dd1a76a | ||
|
|
f69ca085db | ||
|
|
3ee1378932 | ||
|
|
f03d1bb21f | ||
|
|
669738ef3b | ||
|
|
94a4e9d5a9 | ||
|
|
9114e72c50 | ||
|
|
6c3c2714d7 | ||
|
|
e4094d9fb2 | ||
|
|
d51723bb57 | ||
|
|
5af40ff960 | ||
|
|
473bd84c24 | ||
|
|
c12b8f51c1 | ||
|
|
ac50e5eebc | ||
|
|
589af7a1cc | ||
|
|
45219dbf64 | ||
|
|
a6236148d8 | ||
|
|
5b97f25a15 | ||
|
|
150f0fcba5 | ||
|
|
4d7c208da3 | ||
|
|
dc72719f4c | ||
|
|
9f1a12b749 | ||
|
|
adbd57e5a9 | ||
|
|
8779bbc4c1 | ||
|
|
bbb698937a | ||
|
|
4d8e848c6a | ||
|
|
aae9a5528a | ||
|
|
a44a3f6d40 | ||
|
|
cb5bbbfb05 | ||
|
|
e07b9dfdc1 | ||
|
|
1571514e49 | ||
|
|
238d74fe81 | ||
|
|
7431c50566 | ||
|
|
c2d426a565 | ||
|
|
f61d8cb02a | ||
|
|
11bd515648 | ||
|
|
d801cf8e59 | ||
|
|
6d2493ca7c | ||
|
|
66686994c1 | ||
|
|
719b2de3c3 | ||
|
|
d2bdb03da0 | ||
|
|
507e6dc473 | ||
|
|
52d5e628a4 | ||
|
|
85213dab47 | ||
|
|
8fac87d77c | ||
|
|
fda3589498 | ||
|
|
0798d0c95a | ||
|
|
33fd8c774b | ||
|
|
e0f7aedf41 | ||
|
|
cc54b33720 | ||
|
|
df72a2dbbe | ||
|
|
28be5a199f | ||
|
|
3a81ee8f5b | ||
|
|
29d9f81e42 | ||
|
|
059bf1e980 | ||
|
|
23a69e3bd7 | ||
|
|
e1f7847b64 | ||
|
|
6cbc857ee0 | ||
|
|
37cec638df | ||
|
|
82f5901a3f | ||
|
|
6bd90940b6 | ||
|
|
24ddf80ff7 | ||
|
|
8e6c0fdf5a | ||
|
|
bdadfe60cb | ||
|
|
64895e5f6d | ||
|
|
dc808054d2 | ||
|
|
4b1d7490d2 | ||
|
|
107d2a9f93 | ||
|
|
6c2c485638 | ||
|
|
99d5af9914 | ||
|
|
45a044cad0 | ||
|
|
63a2ef6274 | ||
|
|
91e666bcaa | ||
|
|
da272d13e2 | ||
|
|
28264c5c86 | ||
|
|
0ac2d11ac8 | ||
|
|
5f2091d8d2 | ||
|
|
e473583da0 | ||
|
|
4a99025d02 | ||
|
|
5398dd9ee1 | ||
|
|
4abc442add | ||
|
|
1f2a84b3d0 | ||
|
|
00cc6c40eb | ||
|
|
187afeaee7 | ||
|
|
b1c91d885a | ||
|
|
66afc13b7f | ||
|
|
6689040fc6 | ||
|
|
9bd151808c | ||
|
|
a42258eca2 | ||
|
|
a95deddeab | ||
|
|
854aaec6fe | ||
|
|
f316cd51c8 | ||
|
|
ea7371c183 | ||
|
|
4ff215f19b | ||
|
|
938b54dff0 | ||
|
|
a0a56a4c7b | ||
|
|
b6bc42aaa6 | ||
|
|
23efdf12cb | ||
|
|
48005c55ff | ||
|
|
e2171f5083 | ||
|
|
16a6e5c118 | ||
|
|
c368a16dc1 | ||
|
|
bb3f9ed13b | ||
|
|
90765342a3 | ||
|
|
085e9ea676 | ||
|
|
db0cd9489c | ||
|
|
fbf093a87f | ||
|
|
2664bb628b | ||
|
|
8a6ef2ebdf | ||
|
|
de3692ca00 | ||
|
|
72fff2e54f | ||
|
|
dfef7d9407 | ||
|
|
ff03938808 | ||
|
|
3055afd1d2 | ||
|
|
d9b70bbde1 | ||
|
|
d1402af0b2 | ||
|
|
41ab10f88c | ||
|
|
96d0f6f049 | ||
|
|
6c5a4702d9 | ||
|
|
38fa0102c1 | ||
|
|
943cdc5b21 | ||
|
|
887e982aa2 | ||
|
|
e4bb0fc43a | ||
|
|
f5591346cc | ||
|
|
94dcf8c96c | ||
|
|
4702e369e9 | ||
|
|
681a9cfda1 | ||
|
|
38c0c9ba3b | ||
|
|
93c977eb3d | ||
|
|
8e46252862 | ||
|
|
06803422da | ||
|
|
82b26603fe | ||
|
|
30b31a89e6 | ||
|
|
b59e06fba7 | ||
|
|
45a996f12b | ||
|
|
946721fd0b | ||
|
|
84d1ba980b | ||
|
|
872044484c | ||
|
|
e35dfbfdef | ||
|
|
aacc829dc6 | ||
|
|
f7289b251f | ||
|
|
75f78244d5 | ||
|
|
b60ee191ac | ||
|
|
8a0d0025ff | ||
|
|
2314583606 | ||
|
|
841212710b | ||
|
|
ffaa672fd6 | ||
|
|
eb118d8523 | ||
|
|
e1dc960219 | ||
|
|
377e870348 | ||
|
|
66d73fc213 | ||
|
|
e2cbef7edb | ||
|
|
beca0faed0 | ||
|
|
cf20c5db9c | ||
|
|
3c79c512fe | ||
|
|
1c8990927a | ||
|
|
13d970b5ae | ||
|
|
e7d7da9511 | ||
|
|
2347354bba | ||
|
|
9d0e73d308 | ||
|
|
3ca4793e68 | ||
|
|
e9e107c5a5 | ||
|
|
c0af4581aa | ||
|
|
0153b49ef7 | ||
|
|
24fb862a65 | ||
|
|
b99c94537b | ||
|
|
a5fbe2f5c1 | ||
|
|
65b4a0a6f1 | ||
|
|
9463a31107 | ||
|
|
7ddb8814e1 | ||
|
|
1b664cf20d | ||
|
|
05a655f33e | ||
|
|
3efba15b3c | ||
|
|
350a802851 | ||
|
|
de09e33c92 | ||
|
|
9a2be25199 | ||
|
|
279405f2a7 | ||
|
|
497bfbc483 | ||
|
|
dd6bd681ea | ||
|
|
0d93801bde | ||
|
|
e7c30cd072 | ||
|
|
42be0ca0eb | ||
|
|
d7d83d44e6 | ||
|
|
5cb85acf2b | ||
|
|
27969c3077 | ||
|
|
4c2a2c27c1 | ||
|
|
14cb5ff881 | ||
|
|
bc952ebde9 | ||
|
|
c1542643f5 | ||
|
|
624c024766 | ||
|
|
927468bce5 | ||
|
|
75fca715e9 | ||
|
|
5d9a9c76fb | ||
|
|
0e5cac8c8b | ||
|
|
e665cc68f4 | ||
|
|
bdff970a5e | ||
|
|
bf860c39c2 | ||
|
|
b829d89757 | ||
|
|
5844fd3e3d | ||
|
|
f51b733109 | ||
|
|
2138f3e40f | ||
|
|
fb736eaed5 | ||
|
|
786891c333 | ||
|
|
7fc884ba00 | ||
|
|
6e09dd10a7 | ||
|
|
314d5f0d7a | ||
|
|
15401e6988 | ||
|
|
6f8187e595 | ||
|
|
ab698ff521 | ||
|
|
f21d9f64db | ||
|
|
e95e6064ae | ||
|
|
70da14871f | ||
|
|
40242fafee | ||
|
|
ee4b0a223c | ||
|
|
3c578dbdcd | ||
|
|
4fa203e69e | ||
|
|
8b7685d956 | ||
|
|
4ecbaea8bb | ||
|
|
5f1e6a5886 | ||
|
|
97d80265b4 | ||
|
|
d93572ea90 | ||
|
|
d880f397df | ||
|
|
3ab2d14782 | ||
|
|
229cbc6a24 | ||
|
|
dccd29fe25 | ||
|
|
b6cd32281f | ||
|
|
02de05dc27 | ||
|
|
9c55b2fbe4 | ||
|
|
a6a0d982ef | ||
|
|
c2b7b28919 | ||
|
|
5cb7dfafcc | ||
|
|
35a06319fa | ||
|
|
6f34db9bb7 | ||
|
|
3f1d78f3c6 | ||
|
|
c00ed9c913 | ||
|
|
66f5ad42e2 | ||
|
|
8dcd195527 | ||
|
|
4180f754d0 | ||
|
|
6ad0ddebe4 | ||
|
|
3c9bde31f7 | ||
|
|
a8166d28ed | ||
|
|
460043a969 | ||
|
|
4f41b7c089 | ||
|
|
4738a18e52 | ||
|
|
3c17ccab37 | ||
|
|
496370801a | ||
|
|
f3a932d8a1 | ||
|
|
7aba79ade9 | ||
|
|
7a76f71d99 | ||
|
|
012450e87d | ||
|
|
d8c07be021 | ||
|
|
2560242972 | ||
|
|
94ad088482 | ||
|
|
b771fc0880 | ||
|
|
61a21d6a36 | ||
|
|
d85743576c | ||
|
|
a1ca52ed8f | ||
|
|
f6e822e1f5 | ||
|
|
3fd629cf84 | ||
|
|
20bc34ca52 | ||
|
|
abe5413638 | ||
|
|
4df50b9c7e | ||
|
|
a1c7b853ec | ||
|
|
118c30fbc7 | ||
|
|
511e10df34 | ||
|
|
4f494781c1 | ||
|
|
f85f0eee1b | ||
|
|
e770303968 | ||
|
|
23de9c7e6c | ||
|
|
f696f794cf | ||
|
|
83f151947e | ||
|
|
20a71a5479 | ||
|
|
181134153d | ||
|
|
45ec4c93c0 | ||
|
|
50cf327819 | ||
|
|
8268323d7f | ||
|
|
54da7ff12b | ||
|
|
e28fe4199d | ||
|
|
8d3bca3bb8 | ||
|
|
f16f8b51b8 | ||
|
|
831a24ae15 | ||
|
|
736751e5de | ||
|
|
421dbf9a7f | ||
|
|
7ac16582be | ||
|
|
5d24cb7514 | ||
|
|
a81b6beeca | ||
|
|
10dc66be64 | ||
|
|
ce813ad144 | ||
|
|
d4b2e7f771 | ||
|
|
28bf811a07 | ||
|
|
255d8f3f8c | ||
|
|
95111e88e3 | ||
|
|
c2fcf4183c | ||
|
|
f5754f2a36 | ||
|
|
72bd1ed4b3 | ||
|
|
9d15b85d3b | ||
|
|
a18e6199ef | ||
|
|
83fcd1cf4f | ||
|
|
25f1a515f8 | ||
|
|
4de21056ff | ||
|
|
651e51a82e | ||
|
|
a2cddb9eac | ||
|
|
b0ce1ce49d | ||
|
|
ac0581fd22 | ||
|
|
93923a4af2 | ||
|
|
9fc81adc7b | ||
|
|
734dbbcb14 | ||
|
|
2112416761 | ||
|
|
c0f64a6603 | ||
|
|
e2e19544ae | ||
|
|
2648bbdc51 | ||
|
|
49a6e4cbb5 | ||
|
|
7cf53dbf63 | ||
|
|
613cbf720c | ||
|
|
bce4a572cd | ||
|
|
c893b82ace | ||
|
|
eda8ddddd6 | ||
|
|
63d3f28b20 | ||
|
|
783b33e2da | ||
|
|
5847117573 | ||
|
|
258e5c4938 | ||
|
|
69ee043f9d | ||
|
|
28b5477c6f | ||
|
|
5ee83a680b | ||
|
|
d9d7914a8d | ||
|
|
ca3d67e88d | ||
|
|
61f0ce654f | ||
|
|
379f12ea00 | ||
|
|
bf15b1d65d | ||
|
|
4cabc031e6 | ||
|
|
9c5b4b2639 | ||
|
|
8a7e84a475 | ||
|
|
0e99d428b2 | ||
|
|
dbab3912bd | ||
|
|
3c6c9d650d | ||
|
|
b827a0a6a8 | ||
|
|
ce1680e6f9 | ||
|
|
b8982cb881 | ||
|
|
5d934c2835 | ||
|
|
868c46bc76 | ||
|
|
1fd147bf2b | ||
|
|
8ee4b3f906 | ||
|
|
a485f97d21 | ||
|
|
496a5f423e | ||
|
|
836a2bfee0 | ||
|
|
39a3ffaf2f | ||
|
|
d4e0784182 | ||
|
|
e615d2f069 | ||
|
|
ac59772dc6 | ||
|
|
4838085d66 | ||
|
|
9ec99ffef1 | ||
|
|
6e48322055 | ||
|
|
55a98580aa | ||
|
|
c8f263c419 | ||
|
|
6f6e7d8d49 | ||
|
|
fcbd4b7afb | ||
|
|
4c2ddbf2c4 | ||
|
|
a4c05c694f | ||
|
|
a968849e9c | ||
|
|
ffeb5da991 | ||
|
|
edece2a197 | ||
|
|
1fd3510b32 | ||
|
|
9c0a10f662 | ||
|
|
54fd1c1f9b | ||
|
|
8131268256 | ||
|
|
5318957ab3 | ||
|
|
081d38679f | ||
|
|
570c9d16be | ||
|
|
28b0e5ee78 | ||
|
|
cb0b608fa7 | ||
|
|
32791c9745 | ||
|
|
0153a239db | ||
|
|
49dcbd22d6 | ||
|
|
5ed9410de0 | ||
|
|
eb273f904f | ||
|
|
d8397040d7 | ||
|
|
c8ec649830 | ||
|
|
80aadc55df | ||
|
|
f68bd21600 | ||
|
|
59e729e3fe | ||
|
|
895975e2ab | ||
|
|
1228e000a1 | ||
|
|
3d3d2c93d6 | ||
|
|
3caa318dfe | ||
|
|
927cfea5ae | ||
|
|
05cdd3f6eb | ||
|
|
bf46cffd9e | ||
|
|
ab1a5b4822 | ||
|
|
591df1f205 | ||
|
|
483da67204 | ||
|
|
fba24cc4eb | ||
|
|
bcab6a9318 | ||
|
|
6268321316 | ||
|
|
29a5f059d2 | ||
|
|
d09f866daa | ||
|
|
1d86df685b | ||
|
|
6bca52453a | ||
|
|
0e249cba4b | ||
|
|
19db4cb7c1 | ||
|
|
b81670776f | ||
|
|
8452ec6f3b | ||
|
|
f7388af721 | ||
|
|
2dfdcc7dcb | ||
|
|
572a0e128d | ||
|
|
2131d1ff23 | ||
|
|
fc1abed0dc | ||
|
|
e5826777b6 | ||
|
|
b80e95b2aa | ||
|
|
2257612deb | ||
|
|
92bf55afd0 | ||
|
|
39250ab961 | ||
|
|
efc0d237af | ||
|
|
31ba52a57b | ||
|
|
e8e6cf9510 | ||
|
|
139025fce0 | ||
|
|
3146109b08 | ||
|
|
8896d6c1b1 | ||
|
|
25add0af31 | ||
|
|
027657b590 | ||
|
|
7e6b134222 | ||
|
|
4042bc959b | ||
|
|
6dc55a2f4e | ||
|
|
15b72591d4 | ||
|
|
fd779c25b9 | ||
|
|
ece49baa38 | ||
|
|
ba9fa54f9c | ||
|
|
1c89309db0 | ||
|
|
a368b29e27 | ||
|
|
20bbd20ef1 | ||
|
|
8cf7a77808 | ||
|
|
d121007927 | ||
|
|
3eca8cce1c | ||
|
|
d299b0d576 | ||
|
|
ea976a5ffb | ||
|
|
bedbab74b9 | ||
|
|
c587c44975 | ||
|
|
f1b9868980 | ||
|
|
8d6f033326 | ||
|
|
b5cebf45ea | ||
|
|
513b6289d6 | ||
|
|
040a638ab9 | ||
|
|
eb73ae2f86 | ||
|
|
916cc1365e | ||
|
|
86ef4d4884 | ||
|
|
456c3bda0b | ||
|
|
63daf6b317 | ||
|
|
e183d7dd9a | ||
|
|
2acc942bb4 | ||
|
|
038de44110 | ||
|
|
3b01f98c11 | ||
|
|
7cd3738c19 | ||
|
|
018e5e303f | ||
|
|
a57a9505d4 | ||
|
|
720ee96969 | ||
|
|
73f72ec8fe | ||
|
|
5d69157e62 | ||
|
|
b464b87c2b | ||
|
|
9d0d6f011c | ||
|
|
f3786e0816 | ||
|
|
e93efe0e13 | ||
|
|
5a88b7f683 | ||
|
|
81da377d8e | ||
|
|
d950298d29 | ||
|
|
2e35defeec | ||
|
|
960f693219 | ||
|
|
e5e977c24f | ||
|
|
a863e68d17 | ||
|
|
847b37552a | ||
|
|
dfaca794bf | ||
|
|
6fc77a545b | ||
|
|
c871c7398e | ||
|
|
8baed8b90e | ||
|
|
8a1c43bf3b | ||
|
|
5c01ccc31f | ||
|
|
67be8208db | ||
|
|
7d136feccf | ||
|
|
e54e96d61f | ||
|
|
469304359a | ||
|
|
290e36d7e8 | ||
|
|
4241ce9888 | ||
|
|
7f9ad7eabf | ||
|
|
a6794c066d | ||
|
|
7d3ef27a8d | ||
|
|
14a781fa24 | ||
|
|
cec26d58c8 | ||
|
|
593cdae404 | ||
|
|
d2ef9ac04a | ||
|
|
d065ec9298 | ||
|
|
b19131202f | ||
|
|
70058ae49d | ||
|
|
62a23b1985 | ||
|
|
6917cd2f40 | ||
|
|
d36236cbcd | ||
|
|
760d00b7f7 | ||
|
|
0af2c4829f | ||
|
|
be3dc5b508 | ||
|
|
ae13063460 | ||
|
|
1ed58aaaf2 | ||
|
|
bf17895d19 | ||
|
|
20b3c43dde | ||
|
|
ee21f72211 | ||
|
|
4de5cbd6f5 | ||
|
|
fab95b8dae | ||
|
|
4d2655490c | ||
|
|
6bb4113d0a | ||
|
|
3e76f01db4 | ||
|
|
cf580d8c90 | ||
|
|
dbd0c3cbd9 | ||
|
|
3771f9e04b | ||
|
|
a842b14c84 | ||
|
|
138746bdcc | ||
|
|
9e6a9efe10 | ||
|
|
19626ad89f | ||
|
|
7e2d92284c | ||
|
|
20fb6bd788 | ||
|
|
faffb73cbd | ||
|
|
02a4e30594 | ||
|
|
f10b522f0c | ||
|
|
331599fa2b | ||
|
|
558b9c90a6 | ||
|
|
7d2dda97b3 | ||
|
|
74fc4dbacf | ||
|
|
07912a1cb7 | ||
|
|
d36bf3b6fb | ||
|
|
594976a538 | ||
|
|
0efb889a9c | ||
|
|
c0eabe289b | ||
|
|
5bbc3c5ebb | ||
|
|
d5e2cf5d3c | ||
|
|
82a6ff091f | ||
|
|
4b8e60682d | ||
|
|
6c2db9b1cf | ||
|
|
30344d6abf | ||
|
|
1637297085 | ||
|
|
dec1fb71f4 | ||
|
|
7273f6c03c | ||
|
|
a3ffd2edf8 | ||
|
|
a2c5eace88 | ||
|
|
a643d9d498 | ||
|
|
3b52dca405 | ||
|
|
853a0c466e | ||
|
|
94bceb8683 | ||
|
|
88b0f3a172 | ||
|
|
b69b5ba775 | ||
|
|
c442589593 | ||
|
|
28633a504a | ||
|
|
ad78701b6f | ||
|
|
1496488771 | ||
|
|
dd3d958e75 | ||
|
|
b363a3651d | ||
|
|
86645fc14c | ||
|
|
f9beecb343 | ||
|
|
4ecfbd3920 | ||
|
|
a315934314 | ||
|
|
e9170e2de1 | ||
|
|
5cfc1fabcf | ||
|
|
786b12e379 | ||
|
|
e7c5c25de8 | ||
|
|
a1e8813522 | ||
|
|
76c1446416 | ||
|
|
8bd2c87399 | ||
|
|
1e2d77f2c7 | ||
|
|
fb6c22f5c2 | ||
|
|
f7259f625f | ||
|
|
b628a98d32 | ||
|
|
d8fa807998 | ||
|
|
ef66d8379c | ||
|
|
8ee6cee36e | ||
|
|
71b2120e5c | ||
|
|
b10078633c | ||
|
|
b5eebd4d2b | ||
|
|
fdefc4d2b4 | ||
|
|
f6b2609353 | ||
|
|
bdffdcb12f | ||
|
|
1ebb87a6a8 | ||
|
|
83660ee381 | ||
|
|
1fa72d6c44 | ||
|
|
5a7c0d42f7 | ||
|
|
e8d2432e6a | ||
|
|
2af17adc34 | ||
|
|
e97f43399b | ||
|
|
c66c5fd73d | ||
|
|
3c0767f543 | ||
|
|
70cd1fdc63 | ||
|
|
39028dde40 | ||
|
|
6e39b5ef04 | ||
|
|
49db8a9662 | ||
|
|
2cfa6cb0e0 | ||
|
|
1ae3510ede | ||
|
|
6f1135d763 | ||
|
|
52bc2f64f4 | ||
|
|
b1375328e1 | ||
|
|
9443e2cc4b | ||
|
|
3a533c6c8d | ||
|
|
c047014214 | ||
|
|
68b05e994f |
@@ -5,6 +5,7 @@
|
|||||||
.gitattributes
|
.gitattributes
|
||||||
.gitignore
|
.gitignore
|
||||||
.github
|
.github
|
||||||
|
.vscode
|
||||||
public/system
|
public/system
|
||||||
public/assets
|
public/assets
|
||||||
public/packs
|
public/packs
|
||||||
@@ -20,6 +21,7 @@ postgres14
|
|||||||
redis
|
redis
|
||||||
elasticsearch
|
elasticsearch
|
||||||
chart
|
chart
|
||||||
|
storybook-static
|
||||||
.yarn/
|
.yarn/
|
||||||
!.yarn/patches
|
!.yarn/patches
|
||||||
!.yarn/plugins
|
!.yarn/plugins
|
||||||
|
|||||||
@@ -88,24 +88,3 @@ S3_ALIAS_HOST=files.example.com
|
|||||||
# -----------------------
|
# -----------------------
|
||||||
IP_RETENTION_PERIOD=31556952
|
IP_RETENTION_PERIOD=31556952
|
||||||
SESSION_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
|
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: Bug Report (Web Interface)
|
name: Bug Report (Web Interface)
|
||||||
description: There is a problem using Mastodon's web interface.
|
description: There is a problem using Mastodon's web interface.
|
||||||
labels: ['status/to triage', 'area/web interface']
|
labels: ['area/web interface']
|
||||||
type: Bug
|
type: Bug
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
name: Bug Report (server / API)
|
name: Bug Report (server / API)
|
||||||
description: |
|
description: |
|
||||||
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
||||||
labels: ['status/to triage']
|
|
||||||
type: 'Bug'
|
type: 'Bug'
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|||||||
21
.github/renovate.json5
vendored
21
.github/renovate.json5
vendored
@@ -6,6 +6,7 @@
|
|||||||
':labels(dependencies)',
|
':labels(dependencies)',
|
||||||
':prConcurrentLimitNone', // Remove limit for open PRs at any time.
|
':prConcurrentLimitNone', // Remove limit for open PRs at any time.
|
||||||
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
|
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
|
||||||
|
':enableVulnerabilityAlertsWithLabel(security)',
|
||||||
],
|
],
|
||||||
rebaseWhen: 'conflicted',
|
rebaseWhen: 'conflicted',
|
||||||
minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it
|
minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it
|
||||||
@@ -23,7 +24,6 @@
|
|||||||
matchManagers: ['npm'],
|
matchManagers: ['npm'],
|
||||||
matchPackageNames: [
|
matchPackageNames: [
|
||||||
'tesseract.js', // Requires code changes
|
'tesseract.js', // Requires code changes
|
||||||
'react-hotkeys', // Requires code changes
|
|
||||||
|
|
||||||
// react-router: Requires manual upgrade
|
// react-router: Requires manual upgrade
|
||||||
'history',
|
'history',
|
||||||
@@ -94,6 +94,19 @@
|
|||||||
matchUpdateTypes: ['patch', 'minor'],
|
matchUpdateTypes: ['patch', 'minor'],
|
||||||
groupName: 'eslint (non-major)',
|
groupName: 'eslint (non-major)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Group all Storybook-related packages in the same PR
|
||||||
|
matchManagers: ['npm'],
|
||||||
|
matchPackageNames: [
|
||||||
|
'chromatic',
|
||||||
|
'storybook',
|
||||||
|
'@storybook/*',
|
||||||
|
'msw',
|
||||||
|
'msw-storybook-addon',
|
||||||
|
],
|
||||||
|
matchUpdateTypes: ['patch', 'minor'],
|
||||||
|
groupName: 'storybook (non-major)',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Group actions/*-artifact in the same PR
|
// Group actions/*-artifact in the same PR
|
||||||
matchManagers: ['github-actions'],
|
matchManagers: ['github-actions'],
|
||||||
@@ -142,6 +155,12 @@
|
|||||||
matchUpdateTypes: ['patch', 'minor'],
|
matchUpdateTypes: ['patch', 'minor'],
|
||||||
groupName: 'opentelemetry-ruby (non-major)',
|
groupName: 'opentelemetry-ruby (non-major)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Group Playwright Ruby & JS deps in the same PR, as they need to be in sync
|
||||||
|
matchManagers: ['bundler', 'npm'],
|
||||||
|
matchPackageNames: ['playwright-ruby-client', 'playwright'],
|
||||||
|
groupName: 'Playwright',
|
||||||
|
},
|
||||||
// Add labels depending on package manager
|
// Add labels depending on package manager
|
||||||
{ matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] },
|
{ matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] },
|
||||||
{ matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] },
|
{ matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] },
|
||||||
|
|||||||
4
.github/workflows/build-releases.yml
vendored
4
.github/workflows/build-releases.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
# Only tag with latest when ran against the latest stable branch
|
# Only tag with latest when ran against the latest stable branch
|
||||||
# This needs to be updated after each minor version release
|
# This needs to be updated after each minor version release
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
|
||||||
tags: |
|
tags: |
|
||||||
type=pep440,pattern={{raw}}
|
type=pep440,pattern={{raw}}
|
||||||
type=pep440,pattern=v{{major}}.{{minor}}
|
type=pep440,pattern=v{{major}}.{{minor}}
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
# Only tag with latest when ran against the latest stable branch
|
# Only tag with latest when ran against the latest stable branch
|
||||||
# This needs to be updated after each minor version release
|
# This needs to be updated after each minor version release
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
|
||||||
tags: |
|
tags: |
|
||||||
type=pep440,pattern={{raw}}
|
type=pep440,pattern={{raw}}
|
||||||
type=pep440,pattern=v{{major}}.{{minor}}
|
type=pep440,pattern=v{{major}}.{{minor}}
|
||||||
|
|||||||
1
.github/workflows/build-security.yml
vendored
1
.github/workflows/build-security.yml
vendored
@@ -9,7 +9,6 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
compute-suffix:
|
compute-suffix:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.repository == 'mastodon/mastodon'
|
|
||||||
steps:
|
steps:
|
||||||
- id: version_vars
|
- id: version_vars
|
||||||
env:
|
env:
|
||||||
|
|||||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -25,8 +25,8 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
language: ['javascript', 'ruby']
|
language: ['actions', 'javascript', 'ruby']
|
||||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
# CodeQL supports [ 'actions', 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
|
|
||||||
# Create or update the pull request
|
# Create or update the pull request
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v7.0.6
|
uses: peter-evans/create-pull-request@v7.0.8
|
||||||
with:
|
with:
|
||||||
commit-message: 'New Crowdin translations'
|
commit-message: 'New Crowdin translations'
|
||||||
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
||||||
|
|||||||
1
.github/workflows/crowdin-upload.yml
vendored
1
.github/workflows/crowdin-upload.yml
vendored
@@ -14,6 +14,7 @@ on:
|
|||||||
- config/locales/devise.en.yml
|
- config/locales/devise.en.yml
|
||||||
- config/locales/doorkeeper.en.yml
|
- config/locales/doorkeeper.en.yml
|
||||||
- .github/workflows/crowdin-upload.yml
|
- .github/workflows/crowdin-upload.yml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
upload-translations:
|
upload-translations:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@
|
|||||||
/public/packs
|
/public/packs
|
||||||
/public/packs-dev
|
/public/packs-dev
|
||||||
/public/packs-test
|
/public/packs-test
|
||||||
|
stats.html
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
---
|
---
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
Exclude:
|
Enabled: false
|
||||||
- lib/mastodon/cli/*.rb
|
|
||||||
|
|
||||||
Metrics/BlockLength:
|
Metrics/BlockLength:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Metrics/BlockNesting:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
Metrics/ClassLength:
|
Metrics/ClassLength:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Metrics/CollectionLiteralLength:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
Metrics/CyclomaticComplexity:
|
Metrics/CyclomaticComplexity:
|
||||||
Exclude:
|
Enabled: false
|
||||||
- lib/mastodon/cli/*.rb
|
|
||||||
|
|
||||||
Metrics/MethodLength:
|
Metrics/MethodLength:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
@@ -20,4 +24,7 @@ Metrics/ModuleLength:
|
|||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Metrics/ParameterLists:
|
Metrics/ParameterLists:
|
||||||
CountKeywordArgs: false
|
Enabled: false
|
||||||
|
|
||||||
|
Metrics/PerceivedComplexity:
|
||||||
|
Enabled: false
|
||||||
|
|||||||
@@ -1,32 +1,11 @@
|
|||||||
# This configuration was generated by
|
# This configuration was generated by
|
||||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||||
# using RuboCop version 1.77.0.
|
# using RuboCop version 1.80.2.
|
||||||
# The point is for the user to remove these configuration records
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the offenses are removed from the code base.
|
# one by one as the offenses are removed from the code base.
|
||||||
# Note that changes in the inspected code, or installation of new
|
# Note that changes in the inspected code, or installation of new
|
||||||
# versions of RuboCop, may require this file to be generated again.
|
# versions of RuboCop, may require this file to be generated again.
|
||||||
|
|
||||||
Lint/NonLocalExitFromIterator:
|
|
||||||
Exclude:
|
|
||||||
- 'app/helpers/json_ld_helper.rb'
|
|
||||||
|
|
||||||
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
|
||||||
Metrics/AbcSize:
|
|
||||||
Max: 82
|
|
||||||
|
|
||||||
# Configuration parameters: CountBlocks, CountModifierForms, Max.
|
|
||||||
Metrics/BlockNesting:
|
|
||||||
Exclude:
|
|
||||||
- 'lib/tasks/mastodon.rake'
|
|
||||||
|
|
||||||
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
|
||||||
Metrics/CyclomaticComplexity:
|
|
||||||
Max: 25
|
|
||||||
|
|
||||||
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
|
||||||
Metrics/PerceivedComplexity:
|
|
||||||
Max: 27
|
|
||||||
|
|
||||||
# This cop supports safe autocorrection (--autocorrect).
|
# This cop supports safe autocorrection (--autocorrect).
|
||||||
# Configuration parameters: AllowedVars, DefaultToNil.
|
# Configuration parameters: AllowedVars, DefaultToNil.
|
||||||
Style/FetchEnvVar:
|
Style/FetchEnvVar:
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3.4.4
|
3.4.7
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
import type { StorybookConfig } from '@storybook/react-vite';
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
@@ -26,6 +28,12 @@ const config: StorybookConfig = {
|
|||||||
'oops.png',
|
'oops.png',
|
||||||
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
||||||
],
|
],
|
||||||
|
viteFinal(config) {
|
||||||
|
// For an unknown reason, Storybook does not use the root
|
||||||
|
// from the Vite config so we need to set it manually.
|
||||||
|
config.root = resolve(__dirname, '../app/javascript');
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
2
.storybook/preview-body.html
Normal file
2
.storybook/preview-body.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<html class="no-reduce-motion">
|
||||||
|
</html>
|
||||||
@@ -12,13 +12,14 @@ import { initialize, mswLoader } from 'msw-storybook-addon';
|
|||||||
import { action } from 'storybook/actions';
|
import { action } from 'storybook/actions';
|
||||||
|
|
||||||
import type { LocaleData } from '@/mastodon/locales';
|
import type { LocaleData } from '@/mastodon/locales';
|
||||||
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
|
import { reducerWithInitialState } from '@/mastodon/reducers';
|
||||||
import { defaultMiddleware } from '@/mastodon/store/store';
|
import { defaultMiddleware } from '@/mastodon/store/store';
|
||||||
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
||||||
|
|
||||||
// If you want to run the dark theme during development,
|
// If you want to run the dark theme during development,
|
||||||
// you can change the below to `/application.scss`
|
// you can change the below to `/application.scss`
|
||||||
import '../app/javascript/styles/mastodon-light.scss';
|
import '../app/javascript/styles/mastodon-light.scss';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
||||||
query: { as: 'json' },
|
query: { as: 'json' },
|
||||||
@@ -49,12 +50,23 @@ const preview: Preview = {
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story, { parameters }) => {
|
(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 = {} } = parameters;
|
||||||
let reducer = rootReducer;
|
const { state: argsState = {} } = args;
|
||||||
if (typeof state === 'object' && state) {
|
|
||||||
reducer = reducerWithInitialState(state as Record<string, unknown>);
|
const reducer = reducerWithInitialState(
|
||||||
}
|
{
|
||||||
|
meta: {
|
||||||
|
locale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state as Record<string, unknown>,
|
||||||
|
argsState as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer,
|
reducer,
|
||||||
middleware(getDefaultMiddleware) {
|
middleware(getDefaultMiddleware) {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
* - Please do NOT modify this file.
|
* - Please do NOT modify this file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PACKAGE_VERSION = '2.10.2'
|
const PACKAGE_VERSION = '2.11.3'
|
||||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
const activeClientIds = new Set()
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
@@ -71,11 +71,6 @@ addEventListener('message', async function (event) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'MOCK_DEACTIVATE': {
|
|
||||||
activeClientIds.delete(clientId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'CLIENT_CLOSED': {
|
case 'CLIENT_CLOSED': {
|
||||||
activeClientIds.delete(clientId)
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
@@ -94,6 +89,8 @@ addEventListener('message', async function (event) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
addEventListener('fetch', function (event) {
|
addEventListener('fetch', function (event) {
|
||||||
|
const requestInterceptedAt = Date.now()
|
||||||
|
|
||||||
// Bypass navigation requests.
|
// Bypass navigation requests.
|
||||||
if (event.request.mode === 'navigate') {
|
if (event.request.mode === 'navigate') {
|
||||||
return
|
return
|
||||||
@@ -110,23 +107,29 @@ addEventListener('fetch', function (event) {
|
|||||||
|
|
||||||
// Bypass all requests when there are no active clients.
|
// Bypass all requests when there are no active clients.
|
||||||
// Prevents the self-unregistered worked from handling requests
|
// Prevents the self-unregistered worked from handling requests
|
||||||
// after it's been deleted (still remains active until the next reload).
|
// after it's been terminated (still remains active until the next reload).
|
||||||
if (activeClientIds.size === 0) {
|
if (activeClientIds.size === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = crypto.randomUUID()
|
const requestId = crypto.randomUUID()
|
||||||
event.respondWith(handleRequest(event, requestId))
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {FetchEvent} event
|
* @param {FetchEvent} event
|
||||||
* @param {string} requestId
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
*/
|
*/
|
||||||
async function handleRequest(event, requestId) {
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
const client = await resolveMainClient(event)
|
const client = await resolveMainClient(event)
|
||||||
const requestCloneForEvents = event.request.clone()
|
const requestCloneForEvents = event.request.clone()
|
||||||
const response = await getResponse(event, client, requestId)
|
const response = await getResponse(
|
||||||
|
event,
|
||||||
|
client,
|
||||||
|
requestId,
|
||||||
|
requestInterceptedAt,
|
||||||
|
)
|
||||||
|
|
||||||
// Send back the response clone for the "response:*" life-cycle events.
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
// Ensure MSW is active and ready to handle the message, otherwise
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
@@ -204,7 +207,7 @@ async function resolveMainClient(event) {
|
|||||||
* @param {string} requestId
|
* @param {string} requestId
|
||||||
* @returns {Promise<Response>}
|
* @returns {Promise<Response>}
|
||||||
*/
|
*/
|
||||||
async function getResponse(event, client, requestId) {
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||||
// Clone the request because it might've been already used
|
// Clone the request because it might've been already used
|
||||||
// (i.e. its body has been read and sent to the client).
|
// (i.e. its body has been read and sent to the client).
|
||||||
const requestClone = event.request.clone()
|
const requestClone = event.request.clone()
|
||||||
@@ -255,6 +258,7 @@ async function getResponse(event, client, requestId) {
|
|||||||
type: 'REQUEST',
|
type: 'REQUEST',
|
||||||
payload: {
|
payload: {
|
||||||
id: requestId,
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
...serializedRequest,
|
...serializedRequest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
8
.storybook/styles.css
Normal file
8
.storybook/styles.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
258
CHANGELOG.md
258
CHANGELOG.md
@@ -2,7 +2,252 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [4.4.0] - UNRELEASED
|
## [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
|
||||||
|
|
||||||
|
- Update dependencies
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix missing memoization in `Web::PushNotificationWorker` (#36085 by @ClearlyClaire)
|
||||||
|
- Fix unresponsive areas around GIFV modals in some cases (#36059 by @ClearlyClaire)
|
||||||
|
- Fix missing `beforeUnload` confirmation when a poll is being authored (#36030 by @ClearlyClaire)
|
||||||
|
- Fix processing of remote edited statuses with new media and no text (#35970 by @unfokus)
|
||||||
|
- Fix polls not being displayed in moderation interface (#35644 and #35933 by @ThisIsMissEm)
|
||||||
|
- Fix WebUI handling of deleted quoted posts (#35909 and #35918 by @ClearlyClaire and @diondiondion)
|
||||||
|
- Fix “Edit” and “Delete & Redraft” on a poll not inserting empty option (#35892 by @ClearlyClaire)
|
||||||
|
- Fix loading of some compatibility CSS on some configurations (#35876 by @shleeable)
|
||||||
|
- Fix HttpLog not being enabled with `RAILS_LOG_LEVEL=debug` (#35833 by @mjankowski)
|
||||||
|
- Fix self-destruct scheduler behavior on some Redis setups (#35823 by @ClearlyClaire)
|
||||||
|
- Fix `tootctl admin create` not bypassing reserved username checks (#35779 by @ClearlyClaire)
|
||||||
|
- Fix interaction policy changes in implicit updates not being saved (#35751 by @ClearlyClaire)
|
||||||
|
- Fix quote revocation not being streamed (#35710 by @ClearlyClaire)
|
||||||
|
- Fix export of large user archives by enabling Zip64 (#35850 by @ClearlyClaire)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change labels for quote policy settings (#35893 by @ClearlyClaire)
|
||||||
|
- Change standalone “Share” page to redirect to web interface after posting (#35763 by @ChaosExAnima)
|
||||||
|
|
||||||
|
## [4.4.3] - 2025-08-05
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Update dependencies
|
||||||
|
- Fix incorrect rate-limit handling [GHSA-84ch-6436-c7mg](https://github.com/mastodon/mastodon/security/advisories/GHSA-84ch-6436-c7mg)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix race condition caused by ActiveRecord query cache in `Create` critical path (#35662 by @ClearlyClaire)
|
||||||
|
- Fix race condition caused by quote post processing (#35657 by @ClearlyClaire)
|
||||||
|
- Fix WebUI crashing for accounts with `null` URL (#35651 by @ClearlyClaire)
|
||||||
|
- Fix friends-of-friends recommendations suggesting already-requested accounts (#35604 by @ClearlyClaire)
|
||||||
|
- Fix synchronous recursive fetching of deeply-nested quoted posts (#35600 by @ClearlyClaire)
|
||||||
|
- Fix “Expand this post” link including user `@undefined` (#35478 by @ClearlyClaire)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change `StatusReachFinder` to consider quotes as well as reblogs (#35601 by @ClearlyClaire)
|
||||||
|
- Add restrictions on which quote posts can trend (#35507 by @ClearlyClaire)
|
||||||
|
- Change quote verification to not bypass authorization flow for mentions (#35528 by @ClearlyClaire)
|
||||||
|
|
||||||
|
## [4.4.2] - 2025-07-23
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Update dependencies
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix menu not clickable in Firefox (#35390 and #35414 by @diondiondion)
|
||||||
|
- Add `lang` attribute to current composer language in alt text modal (#35412 by @diondiondion)
|
||||||
|
- Fix quote posts styling on notifications page (#35411 by @diondiondion)
|
||||||
|
- Improve a11y of custom select menus in notifications settings (#35403 by @diondiondion)
|
||||||
|
- Fix selected item in poll select menus is unreadable in Firefox (#35402 by @diondiondion)
|
||||||
|
- Update age limit wording (#35387 by @diondiondion)
|
||||||
|
- Fix support for quote verification in implicit status updates (#35384 by @ClearlyClaire)
|
||||||
|
- Improve `Dropdown` component accessibility (#35373 by @diondiondion)
|
||||||
|
- Fix processing some incoming quotes failing because of missing JSON-LD context (#35354 and #35380 by @ClearlyClaire)
|
||||||
|
- Make bio hashtags open the local page instead of the remote instance (#35349 by @ChaosExAnima)
|
||||||
|
- Fix styling of external log-in button (#35320 by @ClearlyClaire)
|
||||||
|
|
||||||
|
## [4.4.1] - 2025-07-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix nearly every sub-directory being crawled as part of Vite build (#35323 by @ClearlyClaire)
|
||||||
|
- Fix assets not building when Redis is unavailable (#35321 by @oneiros)
|
||||||
|
- Fix replying from media modal or pop-in-player tagging user `@undefined` (#35317 by @ClearlyClaire)
|
||||||
|
- Fix support for special characters in various environment variables (#35314 by @mjankowski and @ClearlyClaire)
|
||||||
|
- Fix some database migrations failing for indexes manually removed by admins (#35309 by @mjankowski)
|
||||||
|
|
||||||
|
## [4.4.0] - 2025-07-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -38,7 +283,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
|
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
|
||||||
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
|
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
|
||||||
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
|
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
|
||||||
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126 and #35127 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
||||||
Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
|
Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
|
||||||
- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\
|
- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\
|
||||||
This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\
|
This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\
|
||||||
@@ -51,7 +296,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
|
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
|
||||||
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
|
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
|
||||||
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
|
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
|
||||||
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033 and #35218 by @oneiros)\
|
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\
|
||||||
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
|
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
|
||||||
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
|
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
|
||||||
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
|
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
|
||||||
@@ -64,7 +309,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
|
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
|
||||||
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
|
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
|
||||||
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
|
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
|
||||||
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033 and #35109 by @oneiros)\
|
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033, #35109 and #35278 by @oneiros)\
|
||||||
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
|
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
|
||||||
- Add experimental Async Refreshes API (#34918 by @oneiros)
|
- Add experimental Async Refreshes API (#34918 by @oneiros)
|
||||||
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
|
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
|
||||||
@@ -218,6 +463,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
|
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
|
||||||
- Fix OIDC account creation failing for long display names (#34639 by @defnull)
|
- Fix OIDC account creation failing for long display names (#34639 by @defnull)
|
||||||
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
|
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
|
||||||
|
- Fix inaccessible “Clear search” button (#35152 and #35281 by @diondiondion)
|
||||||
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
|
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
|
||||||
- Fix directory scroll position reset (#34560 by @przucidlo)
|
- Fix directory scroll position reset (#34560 by @przucidlo)
|
||||||
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
|
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
|
||||||
@@ -232,7 +478,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
|
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
|
||||||
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
|
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
|
||||||
- Fix zoomed images being blurry in Safari (#35052 by @diondiondion)
|
- Fix zoomed images being blurry in Safari (#35052 by @diondiondion)
|
||||||
- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096 and #35150 by @diondiondion)
|
- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096, #35150 and #35251 by @diondiondion)
|
||||||
- Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion)
|
- Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion)
|
||||||
- Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion)
|
- Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion)
|
||||||
- Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)
|
- Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)
|
||||||
@@ -530,7 +776,6 @@ The following changelog entries focus on changes visible to users, administrator
|
|||||||
You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\
|
You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\
|
||||||
Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\
|
Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\
|
||||||
This adds the following REST API endpoints:
|
This adds the following REST API endpoints:
|
||||||
|
|
||||||
- `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy
|
- `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy
|
||||||
- `PATCH /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#update-the-filtering-policy-for-notifications
|
- `PATCH /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#update-the-filtering-policy-for-notifications
|
||||||
- `GET /api/v1/notifications/requests`: https://docs.joinmastodon.org/methods/notifications/#get-requests
|
- `GET /api/v1/notifications/requests`: https://docs.joinmastodon.org/methods/notifications/#get-requests
|
||||||
@@ -542,7 +787,6 @@ The following changelog entries focus on changes visible to users, administrator
|
|||||||
- `GET /api/v1/notifications/requests/merged`: https://docs.joinmastodon.org/methods/notifications/#requests-merged
|
- `GET /api/v1/notifications/requests/merged`: https://docs.joinmastodon.org/methods/notifications/#requests-merged
|
||||||
|
|
||||||
In addition, accepting one or more notification requests generates a new streaming event:
|
In addition, accepting one or more notification requests generates a new streaming event:
|
||||||
|
|
||||||
- `notifications_merged`: an event of this type indicates accepted notification requests have finished merging, and the notifications list should be refreshed
|
- `notifications_merged`: an event of this type indicates accepted notification requests have finished merging, and the notifications list should be refreshed
|
||||||
|
|
||||||
- **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\
|
- **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\
|
||||||
|
|||||||
51
Dockerfile
51
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
# syntax=docker/dockerfile:1.12
|
# syntax=docker/dockerfile:1.18
|
||||||
|
|
||||||
# This file is designed for production server deployment, not local development work
|
# This file is designed for production server deployment, not local development work
|
||||||
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/docs/DEVELOPMENT.md#docker
|
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/docs/DEVELOPMENT.md#docker
|
||||||
@@ -13,15 +13,15 @@ ARG BASE_REGISTRY="docker.io"
|
|||||||
|
|
||||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
||||||
# renovate: datasource=docker depName=docker.io/ruby
|
# renovate: datasource=docker depName=docker.io/ruby
|
||||||
ARG RUBY_VERSION="3.4.4"
|
ARG RUBY_VERSION="3.4.7"
|
||||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"]
|
||||||
# renovate: datasource=node-version depName=node
|
# 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="bookworm"]
|
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"]
|
||||||
ARG DEBIAN_VERSION="bookworm"
|
ARG DEBIAN_VERSION="trixie"
|
||||||
# Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim)
|
# Node.js image to use for base image based on combined variables (ex: 20-trixie-slim)
|
||||||
FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
|
FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
|
||||||
# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm)
|
# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-trixie)
|
||||||
FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
|
FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
|
||||||
|
|
||||||
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
|
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
|
||||||
@@ -96,9 +96,6 @@ RUN \
|
|||||||
# Set /opt/mastodon as working directory
|
# Set /opt/mastodon as working directory
|
||||||
WORKDIR /opt/mastodon
|
WORKDIR /opt/mastodon
|
||||||
|
|
||||||
# Add backport repository for some specific packages where we need the latest version
|
|
||||||
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list
|
|
||||||
|
|
||||||
# hadolint ignore=DL3008,DL3005
|
# hadolint ignore=DL3008,DL3005
|
||||||
RUN \
|
RUN \
|
||||||
# Mount Apt cache and lib directories from Docker buildx caches
|
# Mount Apt cache and lib directories from Docker buildx caches
|
||||||
@@ -161,11 +158,11 @@ RUN \
|
|||||||
libexif-dev \
|
libexif-dev \
|
||||||
libexpat1-dev \
|
libexpat1-dev \
|
||||||
libgirepository1.0-dev \
|
libgirepository1.0-dev \
|
||||||
libheif-dev/bookworm-backports \
|
libheif-dev \
|
||||||
|
libhwy-dev \
|
||||||
libimagequant-dev \
|
libimagequant-dev \
|
||||||
libjpeg62-turbo-dev \
|
libjpeg62-turbo-dev \
|
||||||
liblcms2-dev \
|
liblcms2-dev \
|
||||||
liborc-dev \
|
|
||||||
libspng-dev \
|
libspng-dev \
|
||||||
libtiff-dev \
|
libtiff-dev \
|
||||||
libwebp-dev \
|
libwebp-dev \
|
||||||
@@ -186,7 +183,7 @@ FROM build AS libvips
|
|||||||
|
|
||||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||||
ARG VIPS_VERSION=8.17.0
|
ARG VIPS_VERSION=8.17.3
|
||||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
# 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
|
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||||
|
|
||||||
@@ -209,14 +206,14 @@ FROM build AS ffmpeg
|
|||||||
|
|
||||||
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
|
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
|
||||||
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
||||||
ARG FFMPEG_VERSION=7.1
|
ARG FFMPEG_VERSION=8.0
|
||||||
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
# 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
|
WORKDIR /usr/local/ffmpeg/src
|
||||||
# Download and extract ffmpeg source code
|
# Download and extract ffmpeg source code
|
||||||
ADD ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz /usr/local/ffmpeg/src/
|
ADD ${FFMPEG_URL}/n${FFMPEG_VERSION}.tar.gz /usr/local/ffmpeg/src/
|
||||||
RUN tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz;
|
RUN tar xf n${FFMPEG_VERSION}.tar.gz && mv FFmpeg-n${FFMPEG_VERSION} ffmpeg-${FFMPEG_VERSION};
|
||||||
|
|
||||||
WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION}
|
WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION}
|
||||||
|
|
||||||
@@ -327,28 +324,28 @@ RUN \
|
|||||||
# Apt update install non-dev versions of necessary components
|
# Apt update install non-dev versions of necessary components
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
libexpat1 \
|
libexpat1 \
|
||||||
libglib2.0-0 \
|
libglib2.0-0t64 \
|
||||||
libicu72 \
|
libicu76 \
|
||||||
libidn12 \
|
libidn12 \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
libreadline8 \
|
libreadline8t64 \
|
||||||
libssl3 \
|
libssl3t64 \
|
||||||
libyaml-0-2 \
|
libyaml-0-2 \
|
||||||
# libvips components
|
# libvips components
|
||||||
libcgif0 \
|
libcgif0 \
|
||||||
libexif12 \
|
libexif12 \
|
||||||
libheif1/bookworm-backports \
|
libheif1 \
|
||||||
|
libhwy1t64 \
|
||||||
libimagequant0 \
|
libimagequant0 \
|
||||||
libjpeg62-turbo \
|
libjpeg62-turbo \
|
||||||
liblcms2-2 \
|
liblcms2-2 \
|
||||||
liborc-0.4-0 \
|
|
||||||
libspng0 \
|
libspng0 \
|
||||||
libtiff6 \
|
libtiff6 \
|
||||||
libwebp7 \
|
libwebp7 \
|
||||||
libwebpdemux2 \
|
libwebpdemux2 \
|
||||||
libwebpmux3 \
|
libwebpmux3 \
|
||||||
# ffmpeg components
|
# ffmpeg components
|
||||||
libdav1d6 \
|
libdav1d7 \
|
||||||
libmp3lame0 \
|
libmp3lame0 \
|
||||||
libopencore-amrnb0 \
|
libopencore-amrnb0 \
|
||||||
libopencore-amrwb0 \
|
libopencore-amrwb0 \
|
||||||
@@ -358,9 +355,9 @@ RUN \
|
|||||||
libvorbis0a \
|
libvorbis0a \
|
||||||
libvorbisenc2 \
|
libvorbisenc2 \
|
||||||
libvorbisfile3 \
|
libvorbisfile3 \
|
||||||
libvpx7 \
|
libvpx9 \
|
||||||
libx264-164 \
|
libx264-164 \
|
||||||
libx265-199 \
|
libx265-215 \
|
||||||
;
|
;
|
||||||
|
|
||||||
# Copy Mastodon sources into final layer
|
# Copy Mastodon sources into final layer
|
||||||
|
|||||||
50
Gemfile
50
Gemfile
@@ -4,12 +4,12 @@ source 'https://rubygems.org'
|
|||||||
ruby '>= 3.2.0', '< 3.5.0'
|
ruby '>= 3.2.0', '< 3.5.0'
|
||||||
|
|
||||||
gem 'propshaft'
|
gem 'propshaft'
|
||||||
gem 'puma', '~> 6.3'
|
gem 'puma', '~> 7.0'
|
||||||
gem 'rails', '~> 8.0'
|
gem 'rails', '~> 8.0'
|
||||||
gem 'thor', '~> 1.2'
|
gem 'thor', '~> 1.2'
|
||||||
|
|
||||||
gem 'dotenv'
|
gem 'dotenv'
|
||||||
gem 'haml-rails', '~>2.0'
|
gem 'haml-rails', '~>3.0'
|
||||||
gem 'pg', '~> 1.5'
|
gem 'pg', '~> 1.5'
|
||||||
gem 'pghero'
|
gem 'pghero'
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ gem 'inline_svg'
|
|||||||
gem 'irb', '~> 1.8'
|
gem 'irb', '~> 1.8'
|
||||||
gem 'kaminari', '~> 1.2'
|
gem 'kaminari', '~> 1.2'
|
||||||
gem 'link_header', '~> 0.0'
|
gem 'link_header', '~> 0.0'
|
||||||
gem 'linzer', '~> 0.7.2'
|
gem 'linzer', '~> 0.7.7'
|
||||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||||
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
||||||
gem 'mutex_m'
|
gem 'mutex_m'
|
||||||
@@ -82,13 +82,13 @@ gem 'rqrcode', '~> 3.0'
|
|||||||
gem 'ruby-progressbar', '~> 1.13'
|
gem 'ruby-progressbar', '~> 1.13'
|
||||||
gem 'sanitize', '~> 7.0'
|
gem 'sanitize', '~> 7.0'
|
||||||
gem 'scenic', '~> 1.7'
|
gem 'scenic', '~> 1.7'
|
||||||
gem 'sidekiq', '< 8'
|
gem 'sidekiq', '< 9'
|
||||||
gem 'sidekiq-bulk', '~> 0.2.0'
|
gem 'sidekiq-bulk', '~> 0.2.0'
|
||||||
gem 'sidekiq-scheduler', '~> 5.0'
|
gem 'sidekiq-scheduler', '~> 6.0'
|
||||||
gem 'sidekiq-unique-jobs', '> 8'
|
gem 'sidekiq-unique-jobs', '> 8'
|
||||||
gem 'simple_form', '~> 5.2'
|
gem 'simple_form', '~> 5.2'
|
||||||
gem 'simple-navigation', '~> 4.4'
|
gem 'simple-navigation', '~> 4.4'
|
||||||
gem 'stoplight', '~> 4.1'
|
gem 'stoplight'
|
||||||
gem 'strong_migrations'
|
gem 'strong_migrations'
|
||||||
gem 'tty-prompt', '~> 0.23', require: false
|
gem 'tty-prompt', '~> 0.23', require: false
|
||||||
gem 'twitter-text', '~> 3.1.0'
|
gem 'twitter-text', '~> 3.1.0'
|
||||||
@@ -102,23 +102,23 @@ gem 'rdf-normalize', '~> 0.5'
|
|||||||
|
|
||||||
gem 'prometheus_exporter', '~> 2.2', require: false
|
gem 'prometheus_exporter', '~> 2.2', require: false
|
||||||
|
|
||||||
gem 'opentelemetry-api', '~> 1.5.0'
|
gem 'opentelemetry-api', '~> 1.7.0'
|
||||||
|
|
||||||
group :opentelemetry do
|
group :opentelemetry do
|
||||||
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
|
gem 'opentelemetry-exporter-otlp', '~> 0.31.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false
|
gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
|
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
|
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false
|
gem 'opentelemetry-instrumentation-excon', '~> 0.26.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false
|
gem 'opentelemetry-instrumentation-faraday', '~> 0.30.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
|
gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false
|
gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
|
gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
|
gem 'opentelemetry-instrumentation-pg', '~> 0.32.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rails', '~> 0.36.0', require: false
|
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.28.0', require: false
|
||||||
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -138,6 +138,7 @@ group :test do
|
|||||||
# Browser integration testing
|
# Browser integration testing
|
||||||
gem 'capybara', '~> 3.39'
|
gem 'capybara', '~> 3.39'
|
||||||
gem 'capybara-playwright-driver'
|
gem 'capybara-playwright-driver'
|
||||||
|
gem 'playwright-ruby-client', '1.55.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package
|
||||||
|
|
||||||
# Used to reset the database between system tests
|
# Used to reset the database between system tests
|
||||||
gem 'database_cleaner-active_record'
|
gem 'database_cleaner-active_record'
|
||||||
@@ -146,7 +147,7 @@ group :test do
|
|||||||
gem 'climate_control'
|
gem 'climate_control'
|
||||||
|
|
||||||
# Validate schemas in specs
|
# Validate schemas in specs
|
||||||
gem 'json-schema', '~> 5.0'
|
gem 'json-schema', '~> 6.0'
|
||||||
|
|
||||||
# Test harness fo rack components
|
# Test harness fo rack components
|
||||||
gem 'rack-test', '~> 2.1'
|
gem 'rack-test', '~> 2.1'
|
||||||
@@ -159,6 +160,9 @@ group :test do
|
|||||||
|
|
||||||
# Stub web requests for specs
|
# Stub web requests for specs
|
||||||
gem 'webmock', '~> 3.18'
|
gem 'webmock', '~> 3.18'
|
||||||
|
|
||||||
|
# Websocket driver for testing integration between rails/sidekiq and streaming
|
||||||
|
gem 'websocket-driver', '~> 0.8', require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
@@ -223,7 +227,7 @@ gem 'connection_pool', require: false
|
|||||||
gem 'xorcist', '~> 1.1'
|
gem 'xorcist', '~> 1.1'
|
||||||
|
|
||||||
gem 'net-http', '~> 0.6.0'
|
gem 'net-http', '~> 0.6.0'
|
||||||
gem 'rubyzip', '~> 2.3'
|
gem 'rubyzip', '~> 3.0'
|
||||||
|
|
||||||
gem 'hcaptcha', '~> 7.1'
|
gem 'hcaptcha', '~> 7.1'
|
||||||
|
|
||||||
|
|||||||
564
Gemfile.lock
564
Gemfile.lock
File diff suppressed because it is too large
Load Diff
66
README.md
66
README.md
@@ -17,71 +17,71 @@
|
|||||||
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
Mastodon is a **free, open-source social network server** based on [ActivityPub](https://www.w3.org/TR/activitypub/) where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
- [Project homepage 🐘](https://joinmastodon.org)
|
- [Project homepage 🐘](https://joinmastodon.org)
|
||||||
- [Support the development via Patreon][patreon]
|
- [Donate to support development 🎁](https://joinmastodon.org/sponsors#donate)
|
||||||
- [View sponsors](https://joinmastodon.org/sponsors)
|
- [View sponsors](https://joinmastodon.org/sponsors)
|
||||||
- [Blog](https://blog.joinmastodon.org)
|
- [Blog 📰](https://blog.joinmastodon.org)
|
||||||
- [Documentation](https://docs.joinmastodon.org)
|
- [Documentation 📚](https://docs.joinmastodon.org)
|
||||||
- [Roadmap](https://joinmastodon.org/roadmap)
|
- [Official container image 🚢](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
||||||
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
|
||||||
- [Browse Mastodon servers](https://joinmastodon.org/communities)
|
|
||||||
- [Browse Mastodon apps](https://joinmastodon.org/apps)
|
|
||||||
|
|
||||||
[patreon]: https://www.patreon.com/mastodon
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
<img src="./app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
||||||
|
|
||||||
**No vendor lock-in: Fully interoperable with any conforming platform** - It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
|
**Part of the Fediverse. Based on open standards, with no vendor lock-in.** - the network goes beyond just Mastodon; anything that implements ActivityPub is part of a broader social network known as [the Fediverse](https://jointhefediverse.net/). You can follow and interact with users on other servers (including those running different software), and they can follow you back.
|
||||||
|
|
||||||
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
|
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI.
|
||||||
|
|
||||||
**Media attachments like images and short videos** - upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously!
|
**Media attachments** - upload and view images and videos attached to the updates. Videos with no audio track are treated like animated GIFs; normal videos loop continuously.
|
||||||
|
|
||||||
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
|
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and many other features, along with a reporting and moderation system.
|
||||||
|
|
||||||
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
|
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, and third party apps can use the REST and Streaming APIs. This results in a [rich app ecosystem](https://joinmastodon.org/apps) with a variety of choices!
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Tech stack
|
### Tech stack
|
||||||
|
|
||||||
- **Ruby on Rails** powers the REST API and other web pages
|
- [Ruby on Rails](https://github.com/rails/rails) powers the REST API and other web pages.
|
||||||
- **React.js** and **Redux** are used for the dynamic parts of the interface
|
- [PostgreSQL](https://www.postgresql.org/) is the main database.
|
||||||
- **Node.js** powers the streaming API
|
- [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing.
|
||||||
|
- [Node.js](https://nodejs.org/) powers the streaming API.
|
||||||
|
- [React.js](https://reactjs.org/) and [Redux](https://redux.js.org/) are used for the dynamic parts of the interface.
|
||||||
|
- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers. (This project is tested with BrowserStack)
|
||||||
|
- [Chromatic](https://www.chromatic.com/) provides visual regression testing. (This project is tested with Chromatic)
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- **PostgreSQL** 13+
|
|
||||||
- **Redis** 6.2+
|
|
||||||
- **Ruby** 3.2+
|
- **Ruby** 3.2+
|
||||||
|
- **PostgreSQL** 14+
|
||||||
|
- **Redis** 7.0+
|
||||||
- **Node.js** 20+
|
- **Node.js** 20+
|
||||||
|
|
||||||
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
|
This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Mastodon is **free, open-source software** licensed under **AGPLv3**.
|
Mastodon is **free, open-source software** licensed under **AGPLv3**. We welcome contributions and help from anyone who wants to improve the project.
|
||||||
|
|
||||||
You can open issues for bugs you've found or features you think are missing. You
|
You should read the overall [CONTRIBUTING](https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md) guide, which covers our development processes.
|
||||||
can also submit pull requests to this repository or translations via Crowdin. To
|
|
||||||
get started, look at the [CONTRIBUTING] and [DEVELOPMENT] guides. For changes
|
|
||||||
accepted into Mastodon, you can request to be paid through our [OpenCollective].
|
|
||||||
|
|
||||||
**IRC channel**: #mastodon on [`irc.libera.chat`](https://libera.chat)
|
You should also read and understand the [CODE OF CONDUCT](https://github.com/mastodon/.github/blob/main/CODE_OF_CONDUCT.md) that enables us to maintain a welcoming and inclusive community. Collaboration begins with mutual respect and understanding.
|
||||||
|
|
||||||
## License
|
You can learn about setting up a development environment in the [DEVELOPMENT](docs/DEVELOPMENT.md) documentation.
|
||||||
|
|
||||||
|
If you would like to help with translations 🌐 you can do so on [Crowdin](https://crowdin.com/project/mastodon).
|
||||||
|
|
||||||
|
## LICENSE
|
||||||
|
|
||||||
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
||||||
|
|
||||||
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
||||||
|
|
||||||
```
|
```text
|
||||||
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under
|
This program is free software: you can redistribute it and/or modify it under
|
||||||
@@ -97,7 +97,3 @@ details.
|
|||||||
You should have received a copy of the GNU Affero General Public License along
|
You should have received a copy of the GNU Affero General Public License along
|
||||||
with this program. If not, see https://www.gnu.org/licenses/
|
with this program. If not, see https://www.gnu.org/licenses/
|
||||||
```
|
```
|
||||||
|
|
||||||
[CONTRIBUTING]: CONTRIBUTING.md
|
|
||||||
[DEVELOPMENT]: docs/DEVELOPMENT.md
|
|
||||||
[OpenCollective]: https://opencollective.com/mastodon
|
|
||||||
|
|||||||
11
SECURITY.md
11
SECURITY.md
@@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
|||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | --------- |
|
| ------- | ---------------- |
|
||||||
| 4.3.x | Yes |
|
| 4.4.x | Yes |
|
||||||
| 4.2.x | Yes |
|
| 4.3.x | Until 2026-05-06 |
|
||||||
| < 4.2 | No |
|
| 4.2.x | Until 2026-01-08 |
|
||||||
|
| < 4.2 | No |
|
||||||
|
|||||||
3
Vagrantfile
vendored
3
Vagrantfile
vendored
@@ -54,6 +54,7 @@ sudo apt-get install \
|
|||||||
pkg-config \
|
pkg-config \
|
||||||
protobuf-compiler \
|
protobuf-compiler \
|
||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
|
libvips42t64 \
|
||||||
-y
|
-y
|
||||||
|
|
||||||
# Install rvm
|
# Install rvm
|
||||||
@@ -134,7 +135,7 @@ VAGRANTFILE_API_VERSION = "2"
|
|||||||
|
|
||||||
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||||
|
|
||||||
config.vm.box = "ubuntu/focal64"
|
config.vm.box = "bento/ubuntu-24.04"
|
||||||
|
|
||||||
config.vm.provider :virtualbox do |vb|
|
config.vm.provider :virtualbox do |vb|
|
||||||
vb.name = "mastodon"
|
vb.name = "mastodon"
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ class AccountsController < ApplicationController
|
|||||||
params[:username]
|
params[:username]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def account_id_param
|
||||||
|
params[:id]
|
||||||
|
end
|
||||||
|
|
||||||
def skip_temporary_suspension_response?
|
def skip_temporary_suspension_response?
|
||||||
request.format == :json
|
request.format == :json
|
||||||
end
|
end
|
||||||
|
|||||||
82
app/controllers/activitypub/contexts_controller.rb
Normal file
82
app/controllers/activitypub/contexts_controller.rb
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::ContextsController < ActivityPub::BaseController
|
||||||
|
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||||
|
|
||||||
|
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||||
|
before_action :set_conversation
|
||||||
|
before_action :set_items
|
||||||
|
|
||||||
|
DESCENDANTS_LIMIT = 60
|
||||||
|
|
||||||
|
def show
|
||||||
|
expires_in 3.minutes, public: public_fetch_mode?
|
||||||
|
render_with_cache json: context_presenter, serializer: ActivityPub::ContextSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||||
|
end
|
||||||
|
|
||||||
|
def items
|
||||||
|
expires_in 3.minutes, public: public_fetch_mode?
|
||||||
|
render_with_cache json: items_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def account_required?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_conversation
|
||||||
|
account_id, status_id = params[:id].split('-')
|
||||||
|
@conversation = Conversation.local.find_by(parent_account_id: account_id, parent_status_id: status_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_items
|
||||||
|
@items = @conversation.statuses.distributable_visibility.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def context_presenter
|
||||||
|
first_page = ActivityPub::CollectionPresenter.new(
|
||||||
|
id: items_context_url(@conversation, page_params),
|
||||||
|
type: :unordered,
|
||||||
|
part_of: items_context_url(@conversation),
|
||||||
|
next: next_page,
|
||||||
|
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
|
||||||
|
)
|
||||||
|
|
||||||
|
ActivityPub::ContextPresenter.from_conversation(@conversation).tap do |presenter|
|
||||||
|
presenter.first = first_page
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def items_collection_presenter
|
||||||
|
page = ActivityPub::CollectionPresenter.new(
|
||||||
|
id: items_context_url(@conversation, page_params),
|
||||||
|
type: :unordered,
|
||||||
|
part_of: items_context_url(@conversation),
|
||||||
|
next: next_page,
|
||||||
|
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
|
||||||
|
)
|
||||||
|
|
||||||
|
return page if page_requested?
|
||||||
|
|
||||||
|
ActivityPub::CollectionPresenter.new(
|
||||||
|
id: items_context_url(@conversation),
|
||||||
|
type: :unordered,
|
||||||
|
first: page
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def page_requested?
|
||||||
|
truthy_param?(:page)
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_page
|
||||||
|
return nil if @items.size < DESCENDANTS_LIMIT
|
||||||
|
|
||||||
|
items_context_url(@conversation, page: true, min_id: @items.last.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def page_params
|
||||||
|
params.permit(:page, :min_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -28,7 +28,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
|
|||||||
|
|
||||||
def likes_collection_presenter
|
def likes_collection_presenter
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_status_likes_url(@account, @status),
|
id: ActivityPub::TagManager.instance.likes_uri_for(@status),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
size: @status.favourites_count
|
size: @status.favourites_count
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_account
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||||
|
|
||||||
|
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||||
|
before_action :set_quote_authorization
|
||||||
|
|
||||||
|
def show
|
||||||
|
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
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def pundit_user
|
||||||
|
signed_request_account
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_quote_authorization
|
||||||
|
@quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
|
||||||
|
return not_found unless @quote.status.present? && @quote.quoted_status.present?
|
||||||
|
|
||||||
|
authorize @quote.quoted_status, :show?
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
not_found
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -37,7 +37,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
|||||||
|
|
||||||
def replies_collection_presenter
|
def replies_collection_presenter
|
||||||
page = ActivityPub::CollectionPresenter.new(
|
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,
|
type: :unordered,
|
||||||
part_of: account_status_replies_url(@account, @status),
|
part_of: account_status_replies_url(@account, @status),
|
||||||
next: next_page,
|
next: next_page,
|
||||||
@@ -47,7 +47,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
|||||||
return page if page_requested?
|
return page if page_requested?
|
||||||
|
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_status_replies_url(@account, @status),
|
id: ActivityPub::TagManager.instance.replies_uri_for(@status),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
first: page
|
first: page
|
||||||
)
|
)
|
||||||
@@ -66,8 +66,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
|||||||
# Only consider remote accounts
|
# Only consider remote accounts
|
||||||
return nil if @replies.size < DESCENDANTS_LIMIT
|
return nil if @replies.size < DESCENDANTS_LIMIT
|
||||||
|
|
||||||
account_status_replies_url(
|
ActivityPub::TagManager.instance.replies_uri_for(
|
||||||
@account,
|
|
||||||
@status,
|
@status,
|
||||||
page: true,
|
page: true,
|
||||||
min_id: @replies&.last&.id,
|
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
|
# 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
|
next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT
|
||||||
|
|
||||||
account_status_replies_url(
|
ActivityPub::TagManager.instance.replies_uri_for(
|
||||||
@account,
|
|
||||||
@status,
|
@status,
|
||||||
page: true,
|
page: true,
|
||||||
min_id: next_only_other_accounts ? nil : @replies&.last&.id,
|
min_id: next_only_other_accounts ? nil : @replies&.last&.id,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
|
|||||||
|
|
||||||
def shares_collection_presenter
|
def shares_collection_presenter
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_status_shares_url(@account, @status),
|
id: ActivityPub::TagManager.instance.shares_uri_for(@status),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
size: @status.reblogs_count
|
size: @status.reblogs_count
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,16 +14,20 @@ module Admin
|
|||||||
def create
|
def create
|
||||||
authorize @account, :show?
|
authorize @account, :show?
|
||||||
|
|
||||||
account_action = Admin::AccountAction.new(resource_params)
|
@account_action = Admin::AccountAction.new(resource_params)
|
||||||
account_action.target_account = @account
|
@account_action.target_account = @account
|
||||||
account_action.current_account = current_account
|
@account_action.current_account = current_account
|
||||||
|
|
||||||
account_action.save!
|
if @account_action.save
|
||||||
|
if @account_action.with_report?
|
||||||
if account_action.with_report?
|
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
||||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
else
|
||||||
|
redirect_to admin_account_path(@account.id)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
redirect_to admin_account_path(@account.id)
|
@warning_presets = AccountWarningPreset.all
|
||||||
|
|
||||||
|
render :new
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ module Admin
|
|||||||
def batch
|
def batch
|
||||||
authorize :account, :index?
|
authorize :account, :index?
|
||||||
|
|
||||||
@form = Form::AccountBatch.new(form_account_batch_params)
|
@form = Form::AccountBatch.new(
|
||||||
@form.current_account = current_account
|
form_account_batch_params.merge(
|
||||||
@form.action = action_from_button
|
action: action_from_button,
|
||||||
@form.select_all_matching = params[:select_all_matching]
|
current_account:,
|
||||||
@form.query = filtered_accounts
|
query: filtered_accounts,
|
||||||
|
select_all_matching: params[:select_all_matching]
|
||||||
|
)
|
||||||
|
)
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ module Admin
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :audit_log, :index?
|
authorize :audit_log, :index?
|
||||||
@auditable_accounts = Account.auditable.select(:id, :username)
|
@auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -19,15 +19,13 @@ module Admin
|
|||||||
|
|
||||||
log_action :resend, @user
|
log_action :resend, @user
|
||||||
|
|
||||||
flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success')
|
redirect_to admin_accounts_path, notice: t('admin.accounts.resend_confirmation.success')
|
||||||
redirect_to admin_accounts_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_confirmed_user
|
def redirect_confirmed_user
|
||||||
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
|
redirect_to admin_accounts_path, flash: { error: t('admin.accounts.resend_confirmation.already_confirmed') }
|
||||||
redirect_to admin_accounts_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_confirmed?
|
def user_confirmed?
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ module Admin
|
|||||||
|
|
||||||
@pending_appeals_count = Appeal.pending.async_count
|
@pending_appeals_count = Appeal.pending.async_count
|
||||||
@pending_reports_count = Report.unresolved.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
|
@pending_users_count = User.pending.async_count
|
||||||
@system_checks = Admin::SystemCheck.perform(current_user)
|
@system_checks = Admin::SystemCheck.perform(current_user)
|
||||||
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
|
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def pending_tags
|
||||||
|
::Trends::TagFilter.new(status: :pending_review).results
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def reject
|
def reject
|
||||||
authorize @appeal, :approve?
|
authorize @appeal, :reject?
|
||||||
log_action :reject, @appeal
|
log_action :reject, @appeal
|
||||||
@appeal.reject!(current_account)
|
@appeal.reject!(current_account)
|
||||||
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later
|
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
authorize :domain_block, :create?
|
authorize :domain_block, :update?
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -129,7 +129,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def requires_confirmation?
|
def requires_confirmation?
|
||||||
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.severity.to_s == 'suspend' && !params[:confirm]
|
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.suspend? && !params[:confirm]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ module Admin
|
|||||||
|
|
||||||
def export_data
|
def export_data
|
||||||
CSV.generate(headers: export_headers, write_headers: true) do |content|
|
CSV.generate(headers: export_headers, write_headers: true) do |content|
|
||||||
DomainAllow.allowed_domains.each do |instance|
|
DomainAllow.allowed_domains.each do |domain|
|
||||||
content << [instance.domain]
|
content << [domain]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,27 +13,9 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||||||
|
|
||||||
case action_from_button
|
case action_from_button
|
||||||
when 'delete', 'mark_as_sensitive'
|
when 'delete', 'mark_as_sensitive'
|
||||||
status_batch_action = Admin::StatusBatchAction.new(
|
Admin::StatusBatchAction.new(status_batch_action_params).save!
|
||||||
type: action_from_button,
|
|
||||||
status_ids: @report.status_ids,
|
|
||||||
current_account: current_account,
|
|
||||||
report_id: @report.id,
|
|
||||||
send_email_notification: !@report.spam?,
|
|
||||||
text: params[:text]
|
|
||||||
)
|
|
||||||
|
|
||||||
status_batch_action.save!
|
|
||||||
when 'silence', 'suspend'
|
when 'silence', 'suspend'
|
||||||
account_action = Admin::AccountAction.new(
|
Admin::AccountAction.new(account_action_params).save!
|
||||||
type: action_from_button,
|
|
||||||
report_id: @report.id,
|
|
||||||
target_account: @report.target_account,
|
|
||||||
current_account: current_account,
|
|
||||||
send_email_notification: !@report.spam?,
|
|
||||||
text: params[:text]
|
|
||||||
)
|
|
||||||
|
|
||||||
account_action.save!
|
|
||||||
else
|
else
|
||||||
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
||||||
end
|
end
|
||||||
@@ -43,6 +25,26 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def status_batch_action_params
|
||||||
|
shared_params
|
||||||
|
.merge(status_ids: @report.status_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_action_params
|
||||||
|
shared_params
|
||||||
|
.merge(target_account: @report.target_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def shared_params
|
||||||
|
{
|
||||||
|
current_account: current_account,
|
||||||
|
report_id: @report.id,
|
||||||
|
send_email_notification: !@report.spam?,
|
||||||
|
text: params[:text],
|
||||||
|
type: action_from_button,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def set_report
|
def set_report
|
||||||
@report = Report.find(params[:report_id])
|
@report = Report.find(params[:report_id])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ module Admin
|
|||||||
@admin_settings = Form::AdminSettings.new(settings_params)
|
@admin_settings = Form::AdminSettings.new(settings_params)
|
||||||
|
|
||||||
if @admin_settings.save
|
if @admin_settings.save
|
||||||
flash[:notice] = I18n.t('generic.changes_saved_msg')
|
redirect_to after_update_redirect_path, notice: t('generic.changes_saved_msg')
|
||||||
redirect_to after_update_redirect_path
|
|
||||||
else
|
else
|
||||||
render :show
|
render :show
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ module Admin
|
|||||||
before_action :set_tag, except: [:index]
|
before_action :set_tag, except: [:index]
|
||||||
|
|
||||||
PER_PAGE = 20
|
PER_PAGE = 20
|
||||||
|
PERIOD_DAYS = 6.days
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :tag, :index?
|
authorize :tag, :index?
|
||||||
@@ -15,7 +16,7 @@ module Admin
|
|||||||
def show
|
def show
|
||||||
authorize @tag, :show?
|
authorize @tag, :show?
|
||||||
|
|
||||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
@time_period = report_range
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -24,7 +25,7 @@ module Admin
|
|||||||
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
|
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
|
||||||
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
|
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
|
||||||
else
|
else
|
||||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
@time_period = report_range
|
||||||
|
|
||||||
render :show
|
render :show
|
||||||
end
|
end
|
||||||
@@ -36,6 +37,10 @@ module Admin
|
|||||||
@tag = Tag.find(params[:id])
|
@tag = Tag.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def report_range
|
||||||
|
(PERIOD_DAYS.ago.to_date...Time.now.utc.to_date)
|
||||||
|
end
|
||||||
|
|
||||||
def tag_params
|
def tag_params
|
||||||
params
|
params
|
||||||
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
|
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
|
||||||
|
|||||||
77
app/controllers/admin/username_blocks_controller.rb
Normal file
77
app/controllers/admin/username_blocks_controller.rb
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::UsernameBlocksController < Admin::BaseController
|
||||||
|
before_action :set_username_block, only: [:edit, :update]
|
||||||
|
|
||||||
|
def index
|
||||||
|
authorize :username_block, :index?
|
||||||
|
@username_blocks = UsernameBlock.order(username: :asc).page(params[:page])
|
||||||
|
@form = Form::UsernameBlockBatch.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch
|
||||||
|
authorize :username_block, :index?
|
||||||
|
|
||||||
|
@form = Form::UsernameBlockBatch.new(form_username_block_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
|
@form.save
|
||||||
|
rescue ActionController::ParameterMissing
|
||||||
|
flash[:alert] = I18n.t('admin.username_blocks.no_username_block_selected')
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
flash[:alert] = I18n.t('admin.username_blocks.not_permitted')
|
||||||
|
ensure
|
||||||
|
redirect_to admin_username_blocks_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
authorize :username_block, :create?
|
||||||
|
@username_block = UsernameBlock.new(exact: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
authorize @username_block, :update?
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize :username_block, :create?
|
||||||
|
|
||||||
|
@username_block = UsernameBlock.new(resource_params)
|
||||||
|
|
||||||
|
if @username_block.save
|
||||||
|
log_action :create, @username_block
|
||||||
|
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.created_msg')
|
||||||
|
else
|
||||||
|
render :new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @username_block, :update?
|
||||||
|
|
||||||
|
if @username_block.update(resource_params)
|
||||||
|
log_action :update, @username_block
|
||||||
|
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.updated_msg')
|
||||||
|
else
|
||||||
|
render :new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_username_block
|
||||||
|
@username_block = UsernameBlock.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def form_username_block_batch_params
|
||||||
|
params
|
||||||
|
.expect(form_username_block_batch: [username_block_ids: []])
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params
|
||||||
|
.expect(username_block: [:username, :comparison, :allow_with_approval])
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_from_button
|
||||||
|
'delete' if params[:delete]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -48,6 +48,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
|
|||||||
default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy),
|
default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy),
|
||||||
default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
|
default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
|
||||||
default_language: source_params.fetch(:language, @account.user.setting_default_language),
|
default_language: source_params.fetch(:language, @account.user.setting_default_language),
|
||||||
|
default_quote_policy: source_params.fetch(:quote_policy, @account.user.setting_default_quote_policy),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
class Api::V1::Admin::TagsController < Api::BaseController
|
class Api::V1::Admin::TagsController < Api::BaseController
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
|
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
|
||||||
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update
|
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class Api::V1::InvitesController < Api::BaseController
|
|||||||
skip_around_action :set_locale
|
skip_around_action :set_locale
|
||||||
|
|
||||||
before_action :set_invite
|
before_action :set_invite
|
||||||
|
before_action :check_valid_usage!
|
||||||
before_action :check_enabled_registrations!
|
before_action :check_enabled_registrations!
|
||||||
|
|
||||||
# Override `current_user` to avoid reading session cookies
|
# Override `current_user` to avoid reading session cookies
|
||||||
@@ -22,9 +23,11 @@ class Api::V1::InvitesController < Api::BaseController
|
|||||||
@invite = Invite.find_by!(code: params[:invite_code])
|
@invite = Invite.find_by!(code: params[:invite_code])
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_enabled_registrations!
|
def check_valid_usage!
|
||||||
return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_enabled_registrations!
|
||||||
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
|
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,16 +16,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
|||||||
def create
|
def create
|
||||||
with_redis_lock("push_subscription:#{current_user.id}") do
|
with_redis_lock("push_subscription:#{current_user.id}") do
|
||||||
destroy_web_push_subscriptions!
|
destroy_web_push_subscriptions!
|
||||||
|
@push_subscription = Web::PushSubscription.create!(web_push_subscription_params)
|
||||||
@push_subscription = Web::PushSubscription.create!(
|
|
||||||
endpoint: subscription_params[:endpoint],
|
|
||||||
key_p256dh: subscription_params[:keys][:p256dh],
|
|
||||||
key_auth: subscription_params[:keys][:auth],
|
|
||||||
standard: subscription_params[:standard] || false,
|
|
||||||
data: data_params,
|
|
||||||
user_id: current_user.id,
|
|
||||||
access_token_id: doorkeeper_token.id
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
||||||
@@ -55,6 +46,18 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
|||||||
not_found if @push_subscription.nil?
|
not_found if @push_subscription.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def web_push_subscription_params
|
||||||
|
{
|
||||||
|
access_token_id: doorkeeper_token.id,
|
||||||
|
data: data_params,
|
||||||
|
endpoint: subscription_params[:endpoint],
|
||||||
|
key_auth: subscription_params[:keys][:auth],
|
||||||
|
key_p256dh: subscription_params[:keys][:p256dh],
|
||||||
|
standard: subscription_params[:standard] || false,
|
||||||
|
user_id: current_user.id,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def subscription_params
|
def subscription_params
|
||||||
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
|
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::BaseController
|
||||||
|
include Api::InteractionPoliciesConcern
|
||||||
|
|
||||||
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @status, :update?
|
||||||
|
|
||||||
|
@status.update!(quote_approval_policy: quote_approval_policy)
|
||||||
|
|
||||||
|
broadcast_updates! if @status.quote_approval_policy_previously_changed?
|
||||||
|
|
||||||
|
render json: @status, serializer: REST::StatusSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def status_params
|
||||||
|
params.permit(:quote_approval_policy)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_updates!
|
||||||
|
DistributionWorker.perform_async(@status.id, { 'update' => true, 'skip_notifications' => true })
|
||||||
|
ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 })
|
||||||
|
end
|
||||||
|
end
|
||||||
74
app/controllers/api/v1/statuses/quotes_controller.rb
Normal file
74
app/controllers/api/v1/statuses/quotes_controller.rb
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
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 :set_statuses, only: :index
|
||||||
|
|
||||||
|
before_action :set_quote, only: :revoke
|
||||||
|
after_action :insert_pagination_headers, only: :index
|
||||||
|
|
||||||
|
def index
|
||||||
|
cache_if_unauthenticated!
|
||||||
|
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke
|
||||||
|
authorize @quote, :revoke?
|
||||||
|
|
||||||
|
RevokeQuoteService.new.call(@quote)
|
||||||
|
|
||||||
|
render json: @quote.status, serializer: REST::StatusSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_quote
|
||||||
|
@quote = @status.quotes.find_by!(status_id: params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_statuses
|
||||||
|
scope = default_statuses
|
||||||
|
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
|
||||||
|
@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
|
||||||
|
Status.includes(:quote).references(:quote)
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginated_quotes
|
||||||
|
@status.quotes.accepted.paginate_by_max_id(
|
||||||
|
limit_param(DEFAULT_STATUSES_LIMIT),
|
||||||
|
params[:max_id],
|
||||||
|
params[:since_id]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_path
|
||||||
|
api_v1_status_quotes_url pagination_params(max_id: pagination_max_id) if records_continue?
|
||||||
|
end
|
||||||
|
|
||||||
|
def prev_path
|
||||||
|
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :pagination_max_id, :pagination_since_id
|
||||||
|
|
||||||
|
def records_continue?
|
||||||
|
@records_continue
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
class Api::V1::StatusesController < Api::BaseController
|
class Api::V1::StatusesController < Api::BaseController
|
||||||
include Authorization
|
include Authorization
|
||||||
|
include AsyncRefreshesConcern
|
||||||
|
include Api::InteractionPoliciesConcern
|
||||||
|
|
||||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
||||||
@@ -9,6 +11,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
before_action :set_statuses, only: [:index]
|
before_action :set_statuses, only: [:index]
|
||||||
before_action :set_status, only: [:show, :context]
|
before_action :set_status, only: [:show, :context]
|
||||||
before_action :set_thread, only: [:create]
|
before_action :set_thread, only: [:create]
|
||||||
|
before_action :set_quoted_status, only: [:create]
|
||||||
before_action :check_statuses_limit, only: [:index]
|
before_action :check_statuses_limit, only: [:index]
|
||||||
|
|
||||||
override_rate_limit_headers :create, family: :statuses
|
override_rate_limit_headers :create, family: :statuses
|
||||||
@@ -57,9 +60,21 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||||
statuses = [@status] + @context.ancestors + @context.descendants
|
statuses = [@status] + @context.ancestors + @context.descendants
|
||||||
|
|
||||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
refresh_key = "context:#{@status.id}:refresh"
|
||||||
|
async_refresh = AsyncRefresh.new(refresh_key)
|
||||||
|
|
||||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies?
|
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))
|
||||||
|
|
||||||
|
WorkerBatch.new.within do |batch|
|
||||||
|
batch.connect(refresh_key, threshold: 1.0)
|
||||||
|
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -67,6 +82,8 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
current_user.account,
|
current_user.account,
|
||||||
text: status_params[:status],
|
text: status_params[:status],
|
||||||
thread: @thread,
|
thread: @thread,
|
||||||
|
quoted_status: @quoted_status,
|
||||||
|
quote_approval_policy: quote_approval_policy,
|
||||||
media_ids: status_params[:media_ids],
|
media_ids: status_params[:media_ids],
|
||||||
sensitive: status_params[:sensitive],
|
sensitive: status_params[:sensitive],
|
||||||
spoiler_text: status_params[:spoiler_text],
|
spoiler_text: status_params[:spoiler_text],
|
||||||
@@ -98,7 +115,8 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
sensitive: status_params[:sensitive],
|
sensitive: status_params[:sensitive],
|
||||||
language: status_params[:language],
|
language: status_params[:language],
|
||||||
spoiler_text: status_params[:spoiler_text],
|
spoiler_text: status_params[:spoiler_text],
|
||||||
poll: status_params[:poll]
|
poll: status_params[:poll],
|
||||||
|
quote_approval_policy: quote_approval_policy
|
||||||
)
|
)
|
||||||
|
|
||||||
render json: @status, serializer: REST::StatusSerializer
|
render json: @status, serializer: REST::StatusSerializer
|
||||||
@@ -138,6 +156,14 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
|
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_quoted_status
|
||||||
|
@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
|
||||||
|
render json: { error: I18n.t('statuses.errors.quoted_status_not_found') }, status: 404
|
||||||
|
end
|
||||||
|
|
||||||
def check_statuses_limit
|
def check_statuses_limit
|
||||||
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
|
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
|
||||||
end
|
end
|
||||||
@@ -154,6 +180,8 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
params.permit(
|
params.permit(
|
||||||
:status,
|
:status,
|
||||||
:in_reply_to_id,
|
:in_reply_to_id,
|
||||||
|
:quoted_status_id,
|
||||||
|
:quote_approval_policy,
|
||||||
:sensitive,
|
:sensitive,
|
||||||
:spoiler_text,
|
:spoiler_text,
|
||||||
:visibility,
|
:visibility,
|
||||||
|
|||||||
@@ -3,14 +3,8 @@
|
|||||||
class Api::V1::Timelines::BaseController < Api::BaseController
|
class Api::V1::Timelines::BaseController < Api::BaseController
|
||||||
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
||||||
|
|
||||||
before_action :require_user!, if: :require_auth?
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def require_auth?
|
|
||||||
!Setting.timeline_preview
|
|
||||||
end
|
|
||||||
|
|
||||||
def pagination_collection
|
def pagination_collection
|
||||||
@statuses
|
@statuses
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
|
class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
|
||||||
include AsyncRefreshesConcern
|
include AsyncRefreshesConcern
|
||||||
|
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show]
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
|
||||||
before_action :require_user!, only: [:show]
|
before_action :require_user!
|
||||||
|
|
||||||
PERMITTED_PARAMS = %i(local limit).freeze
|
PERMITTED_PARAMS = %i(local limit).freeze
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# 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 -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
before_action :set_preview_card
|
before_action :set_preview_card
|
||||||
before_action :set_statuses
|
before_action :set_statuses
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
||||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
|
before_action :require_user!, if: :require_auth?
|
||||||
|
|
||||||
PERMITTED_PARAMS = %i(local remote limit only_media).freeze
|
PERMITTED_PARAMS = %i(local remote limit only_media).freeze
|
||||||
|
|
||||||
@@ -13,6 +14,16 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
|||||||
|
|
||||||
private
|
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
|
def load_statuses
|
||||||
preloaded_public_statuses_page
|
preloaded_public_statuses_page
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# 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 -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
before_action :load_tag
|
before_action :load_tag
|
||||||
|
|
||||||
@@ -14,10 +14,6 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def require_auth?
|
|
||||||
!Setting.timeline_preview
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_tag
|
def load_tag
|
||||||
@tag = Tag.find_normalized(params[:id])
|
@tag = Tag.find_normalized(params[:id])
|
||||||
end
|
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
|
||||||
@@ -20,7 +20,7 @@ class Api::V2::SearchController < Api::BaseController
|
|||||||
@search = Search.new(search_results)
|
@search = Search.new(search_results)
|
||||||
render json: @search, serializer: REST::SearchSerializer
|
render json: @search, serializer: REST::SearchSerializer
|
||||||
rescue Mastodon::SyntaxError
|
rescue Mastodon::SyntaxError
|
||||||
unprocessable_entity
|
unprocessable_content
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
not_found
|
not_found
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
|||||||
{
|
{
|
||||||
policy: 'all',
|
policy: 'all',
|
||||||
alerts: Notification::TYPES.index_with { alerts_enabled },
|
alerts: Notification::TYPES.index_with { alerts_enabled },
|
||||||
}
|
}.deep_stringify_keys
|
||||||
end
|
end
|
||||||
|
|
||||||
def alerts_enabled
|
def alerts_enabled
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base
|
|||||||
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
||||||
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
|
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
|
||||||
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
||||||
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
|
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content
|
||||||
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
|
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
|
||||||
|
|
||||||
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
||||||
@@ -123,7 +123,7 @@ class ApplicationController < ActionController::Base
|
|||||||
respond_with_error(410)
|
respond_with_error(410)
|
||||||
end
|
end
|
||||||
|
|
||||||
def unprocessable_entity
|
def unprocessable_content
|
||||||
respond_with_error(422)
|
respond_with_error(422)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def record_login_activity
|
def record_login_activity
|
||||||
LoginActivity.create(
|
@user.login_activities.create(
|
||||||
user: @user,
|
|
||||||
success: true,
|
success: true,
|
||||||
authentication_method: :omniauth,
|
authentication_method: :omniauth,
|
||||||
provider: @provider,
|
provider: @provider,
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ class Auth::PasswordsController < Devise::PasswordsController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def redirect_invalid_reset_token
|
def redirect_invalid_reset_token
|
||||||
flash[:error] = I18n.t('auth.invalid_reset_password_token')
|
redirect_to new_password_path(resource_name), flash: { error: t('auth.invalid_reset_password_token') }
|
||||||
redirect_to new_password_path(resource_name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset_password_token_is_valid?
|
def reset_password_token_is_valid?
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||||||
super(&:build_invite_request)
|
super(&:build_invite_request)
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit # rubocop:disable Lint/UselessMethodDefinition
|
def edit
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
def create # rubocop:disable Lint/UselessMethodDefinition
|
def create
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_enabled_registrations
|
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
|
end
|
||||||
|
|
||||||
def invite_code
|
def invite_code
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
skip_before_action :update_user_sign_in
|
skip_before_action :update_user_sign_in
|
||||||
|
|
||||||
|
around_action :preserve_stored_location, only: :destroy, if: :continue_after?
|
||||||
|
|
||||||
prepend_before_action :check_suspicious!, only: [:create]
|
prepend_before_action :check_suspicious!, only: [:create]
|
||||||
|
|
||||||
include Auth::TwoFactorAuthenticationConcern
|
include Auth::TwoFactorAuthenticationConcern
|
||||||
@@ -31,11 +33,9 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
tmp_stored_location = stored_location_for(:user)
|
|
||||||
super
|
super
|
||||||
session.delete(:challenge_passed_at)
|
session.delete(:challenge_passed_at)
|
||||||
flash.delete(:notice)
|
flash.delete(:notice)
|
||||||
store_location_for(:user, tmp_stored_location) if continue_after?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def webauthn_options
|
def webauthn_options
|
||||||
@@ -96,6 +96,12 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def preserve_stored_location
|
||||||
|
original_stored_location = stored_location_for(:user)
|
||||||
|
yield
|
||||||
|
store_location_for(:user, original_stored_location)
|
||||||
|
end
|
||||||
|
|
||||||
def check_suspicious!
|
def check_suspicious!
|
||||||
user = find_user
|
user = find_user
|
||||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||||
@@ -151,12 +157,11 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
sign_in(user)
|
sign_in(user)
|
||||||
flash.delete(:notice)
|
flash.delete(:notice)
|
||||||
|
|
||||||
LoginActivity.create(
|
user.login_activities.create(
|
||||||
user: user,
|
request_details.merge(
|
||||||
success: true,
|
authentication_method: security_measure,
|
||||||
authentication_method: security_measure,
|
success: true
|
||||||
ip: request.remote_ip,
|
)
|
||||||
user_agent: request.user_agent
|
|
||||||
)
|
)
|
||||||
|
|
||||||
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
||||||
@@ -167,13 +172,12 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def on_authentication_failure(user, security_measure, failure_reason)
|
def on_authentication_failure(user, security_measure, failure_reason)
|
||||||
LoginActivity.create(
|
user.login_activities.create(
|
||||||
user: user,
|
request_details.merge(
|
||||||
success: false,
|
authentication_method: security_measure,
|
||||||
authentication_method: security_measure,
|
failure_reason: failure_reason,
|
||||||
failure_reason: failure_reason,
|
success: false
|
||||||
ip: request.remote_ip,
|
)
|
||||||
user_agent: request.user_agent
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only send a notification email every hour at most
|
# Only send a notification email every hour at most
|
||||||
@@ -182,6 +186,13 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def request_details
|
||||||
|
{
|
||||||
|
ip: request.remote_ip,
|
||||||
|
user_agent: request.user_agent,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def second_factor_attempts_key(user)
|
def second_factor_attempts_key(user)
|
||||||
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ module AccountOwnedConcern
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_account
|
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
|
end
|
||||||
|
|
||||||
def username_param
|
def username_param
|
||||||
|
|||||||
19
app/controllers/concerns/api/interaction_policies_concern.rb
Normal file
19
app/controllers/concerns/api/interaction_policies_concern.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api::InteractionPoliciesConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def quote_approval_policy
|
||||||
|
case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
|
||||||
|
when 'public'
|
||||||
|
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
||||||
|
when 'followers'
|
||||||
|
Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16
|
||||||
|
when 'nobody'
|
||||||
|
0
|
||||||
|
else
|
||||||
|
# TODO: raise more useful message
|
||||||
|
raise ActiveRecord::RecordInvalid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,6 +6,9 @@ module AsyncRefreshesConcern
|
|||||||
def add_async_refresh_header(async_refresh, retry_seconds: 3)
|
def add_async_refresh_header(async_refresh, retry_seconds: 3)
|
||||||
return unless async_refresh.running?
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ module Auth::CaptchaConcern
|
|||||||
|
|
||||||
include Hcaptcha::Adapters::ViewMethods
|
include Hcaptcha::Adapters::ViewMethods
|
||||||
|
|
||||||
|
CAPTCHA_DIRECTIVES = %w(
|
||||||
|
connect_src
|
||||||
|
frame_src
|
||||||
|
script_src
|
||||||
|
style_src
|
||||||
|
).freeze
|
||||||
|
|
||||||
|
CAPTCHA_SOURCES = %w(
|
||||||
|
https://*.hcaptcha.com
|
||||||
|
https://hcaptcha.com
|
||||||
|
).freeze
|
||||||
|
|
||||||
included do
|
included do
|
||||||
helper_method :render_captcha
|
helper_method :render_captcha
|
||||||
end
|
end
|
||||||
@@ -42,20 +54,9 @@ module Auth::CaptchaConcern
|
|||||||
end
|
end
|
||||||
|
|
||||||
def extend_csp_for_captcha!
|
def extend_csp_for_captcha!
|
||||||
policy = request.content_security_policy&.clone
|
return unless captcha_required? && request.content_security_policy.present?
|
||||||
|
|
||||||
return unless captcha_required? && policy.present?
|
request.content_security_policy = captcha_adjusted_policy
|
||||||
|
|
||||||
%w(script_src frame_src style_src connect_src).each do |directive|
|
|
||||||
values = policy.send(directive)
|
|
||||||
|
|
||||||
values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
|
|
||||||
values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
|
|
||||||
|
|
||||||
policy.send(directive, *values)
|
|
||||||
end
|
|
||||||
|
|
||||||
request.content_security_policy = policy
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_captcha
|
def render_captcha
|
||||||
@@ -63,4 +64,24 @@ module Auth::CaptchaConcern
|
|||||||
|
|
||||||
hcaptcha_tags
|
hcaptcha_tags
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def captcha_adjusted_policy
|
||||||
|
request.content_security_policy.clone.tap do |policy|
|
||||||
|
populate_captcha_policy(policy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def populate_captcha_policy(policy)
|
||||||
|
CAPTCHA_DIRECTIVES.each do |directive|
|
||||||
|
values = policy.send(directive)
|
||||||
|
|
||||||
|
CAPTCHA_SOURCES.each do |source|
|
||||||
|
values << source unless values.include?(source) || values.include?('https:')
|
||||||
|
end
|
||||||
|
|
||||||
|
policy.send(directive, *values)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ module SignatureVerification
|
|||||||
|
|
||||||
EXPIRATION_WINDOW_LIMIT = 12.hours
|
EXPIRATION_WINDOW_LIMIT = 12.hours
|
||||||
CLOCK_SKEW_MARGIN = 1.hour
|
CLOCK_SKEW_MARGIN = 1.hour
|
||||||
|
STOPLIGHT_COOL_OFF_TIME = 5.minutes.seconds
|
||||||
|
STOPLIGHT_THRESHOLD = 1
|
||||||
|
|
||||||
def require_account_signature!
|
def require_account_signature!
|
||||||
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
||||||
@@ -64,6 +66,9 @@ module SignatureVerification
|
|||||||
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
||||||
|
|
||||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
|
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
|
||||||
|
rescue Mastodon::MalformedHeaderError => e
|
||||||
|
@signature_verification_failure_code = 400
|
||||||
|
fail_with! e.message
|
||||||
rescue Mastodon::SignatureVerificationError => e
|
rescue Mastodon::SignatureVerificationError => e
|
||||||
fail_with! e.message
|
fail_with! e.message
|
||||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||||
@@ -104,10 +109,12 @@ module SignatureVerification
|
|||||||
end
|
end
|
||||||
|
|
||||||
def stoplight_wrapper
|
def stoplight_wrapper
|
||||||
Stoplight("source:#{request.remote_ip}")
|
Stoplight(
|
||||||
.with_threshold(1)
|
"source:#{request.remote_ip}",
|
||||||
.with_cool_off_time(5.minutes.seconds)
|
cool_off_time: STOPLIGHT_COOL_OFF_TIME,
|
||||||
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
|
threshold: STOPLIGHT_THRESHOLD,
|
||||||
|
tracked_errors: [HTTP::Error, OpenSSL::SSL::SSLError]
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def actor_refresh_key!(actor)
|
def actor_refresh_key!(actor)
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ module WebAppControllerConcern
|
|||||||
return unless current_user&.require_tos_interstitial?
|
return unless current_user&.require_tos_interstitial?
|
||||||
|
|
||||||
@terms_of_service = TermsOfService.published.first
|
@terms_of_service = TermsOfService.published.first
|
||||||
|
|
||||||
|
# Handle case where terms of service have been removed from the database
|
||||||
|
if @terms_of_service.nil?
|
||||||
|
current_user.update(require_tos_interstitial: false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
render 'terms_of_service_interstitial/show', layout: 'auth'
|
render 'terms_of_service_interstitial/show', layout: 'auth'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -60,17 +60,17 @@ class FollowerAccountsController < ApplicationController
|
|||||||
def collection_presenter
|
def collection_presenter
|
||||||
if page_requested?
|
if page_requested?
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_followers_url(@account, page: params.fetch(:page, 1)),
|
id: page_url(params.fetch(:page, 1)),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @account.followers_count,
|
size: @account.followers_count,
|
||||||
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
|
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,
|
next: next_page_url,
|
||||||
prev: prev_page_url
|
prev: prev_page_url
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_followers_url(@account),
|
id: ActivityPub::TagManager.instance.followers_uri_for(@account),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @account.followers_count,
|
size: @account.followers_count,
|
||||||
first: page_url(1)
|
first: page_url(1)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class FollowingAccountsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def page_url(page)
|
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
|
end
|
||||||
|
|
||||||
def next_page_url
|
def next_page_url
|
||||||
@@ -63,17 +63,17 @@ class FollowingAccountsController < ApplicationController
|
|||||||
def collection_presenter
|
def collection_presenter
|
||||||
if page_requested?
|
if page_requested?
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_following_index_url(@account, page: params.fetch(:page, 1)),
|
id: page_url(params.fetch(:page, 1)),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @account.following_count,
|
size: @account.following_count,
|
||||||
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
|
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,
|
next: next_page_url,
|
||||||
prev: prev_page_url
|
prev: prev_page_url
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_following_index_url(@account),
|
id: ActivityPub::TagManager.instance.following_uri_for(@account),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @account.following_count,
|
size: @account.following_count,
|
||||||
first: page_url(1)
|
first: page_url(1)
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
|
|||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
|
@login_activities = current_user.login_activities.order(id: :desc).page(params[:page])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
if current_account.moved_to_account_id.present?
|
if current_account.moved?
|
||||||
current_account.update!(moved_to_account: nil)
|
current_account.update!(moved_to_account: nil)
|
||||||
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Settings::Preferences::PostingDefaultsController < Settings::Preferences::BaseController
|
||||||
|
private
|
||||||
|
|
||||||
|
def after_update_redirect_path
|
||||||
|
settings_preferences_posting_defaults_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_params
|
||||||
|
super.tap do |params|
|
||||||
|
params[:settings_attributes][:default_quote_policy] = 'nobody' if params[:settings_attributes][:default_privacy] == 'private'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,8 +8,7 @@ class Settings::SessionsController < Settings::BaseController
|
|||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@session.destroy!
|
@session.destroy!
|
||||||
flash[:notice] = I18n.t('sessions.revoke_success')
|
redirect_to edit_user_registration_path, notice: t('sessions.revoke_success')
|
||||||
redirect_to edit_user_registration_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ module Settings
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
flash[:error] = I18n.t('webauthn_credentials.create.error')
|
flash[:error] = I18n.t('webauthn_credentials.create.error')
|
||||||
status = :unprocessable_entity
|
status = :unprocessable_content
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
flash[:error] = t('webauthn_credentials.create.error')
|
flash[:error] = t('webauthn_credentials.create.error')
|
||||||
@@ -86,13 +86,11 @@ module Settings
|
|||||||
private
|
private
|
||||||
|
|
||||||
def redirect_invalid_otp
|
def redirect_invalid_otp
|
||||||
flash[:error] = t('webauthn_credentials.otp_required')
|
redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.otp_required') }
|
||||||
redirect_to settings_two_factor_authentication_methods_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def redirect_invalid_webauthn
|
def redirect_invalid_webauthn
|
||||||
flash[:error] = t('webauthn_credentials.not_enabled')
|
redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.not_enabled') }
|
||||||
redirect_to settings_two_factor_authentication_methods_path
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class StatusesController < ApplicationController
|
|||||||
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
||||||
before_action :set_status
|
before_action :set_status
|
||||||
before_action :redirect_to_original, only: :show
|
before_action :redirect_to_original, only: :show
|
||||||
|
before_action :verify_embed_allowed, only: :embed
|
||||||
|
|
||||||
after_action :set_link_headers
|
after_action :set_link_headers
|
||||||
|
|
||||||
@@ -40,8 +41,6 @@ class StatusesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def embed
|
def embed
|
||||||
return not_found if @status.hidden? || @status.reblog?
|
|
||||||
|
|
||||||
expires_in 180, public: true
|
expires_in 180, public: true
|
||||||
response.headers.delete('X-Frame-Options')
|
response.headers.delete('X-Frame-Options')
|
||||||
|
|
||||||
@@ -50,6 +49,10 @@ class StatusesController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def verify_embed_allowed
|
||||||
|
not_found if @status.hidden? || @status.reblog?
|
||||||
|
end
|
||||||
|
|
||||||
def set_link_headers
|
def set_link_headers
|
||||||
response.headers['Link'] = LinkHeader.new(
|
response.headers['Link'] = LinkHeader.new(
|
||||||
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ module Admin::ActionLogsHelper
|
|||||||
end
|
end
|
||||||
when 'UserRole'
|
when 'UserRole'
|
||||||
link_to log.human_identifier, admin_roles_path(log.target_id)
|
link_to log.human_identifier, admin_roles_path(log.target_id)
|
||||||
|
when 'UsernameBlock'
|
||||||
|
link_to log.human_identifier, edit_admin_username_block_path(log.target_id)
|
||||||
when 'Report'
|
when 'Report'
|
||||||
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
||||||
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ module ApplicationHelper
|
|||||||
|
|
||||||
def provider_sign_in_link(provider)
|
def provider_sign_in_link(provider)
|
||||||
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
|
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
|
||||||
link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
|
link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post
|
||||||
end
|
end
|
||||||
|
|
||||||
def locale_direction
|
def locale_direction
|
||||||
@@ -102,7 +102,18 @@ module ApplicationHelper
|
|||||||
policy(record).public_send(:"#{action}?")
|
policy(record).public_send(:"#{action}?")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def conditional_link_to(condition, name, options = {}, html_options = {}, &block)
|
||||||
|
if condition && !current_page?(block_given? ? name : options)
|
||||||
|
link_to(name, options, html_options, &block)
|
||||||
|
elsif block_given?
|
||||||
|
content_tag(:span, options, html_options, &block)
|
||||||
|
else
|
||||||
|
content_tag(:span, name, html_options)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def material_symbol(icon, attributes = {})
|
def material_symbol(icon, attributes = {})
|
||||||
|
whitespace = attributes.delete(:whitespace) { true }
|
||||||
safe_join(
|
safe_join(
|
||||||
[
|
[
|
||||||
inline_svg_tag(
|
inline_svg_tag(
|
||||||
@@ -111,7 +122,7 @@ module ApplicationHelper
|
|||||||
role: :img,
|
role: :img,
|
||||||
data: attributes[:data]
|
data: attributes[:data]
|
||||||
),
|
),
|
||||||
' ',
|
whitespace ? ' ' : '',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -233,6 +244,10 @@ module ApplicationHelper
|
|||||||
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
|
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def recent_tag_users(tag)
|
||||||
|
tag.statuses.public_visibility.joins(:account).merge(Account.without_suspended.without_silenced).includes(:account).limit(3).map(&:account)
|
||||||
|
end
|
||||||
|
|
||||||
def recent_tag_usage(tag)
|
def recent_tag_usage(tag)
|
||||||
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts
|
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts
|
||||||
I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people
|
I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people
|
||||||
@@ -246,6 +261,10 @@ module ApplicationHelper
|
|||||||
'https://play.google.com/store/apps/details?id=org.joinmastodon.android'
|
'https://play.google.com/store/apps/details?id=org.joinmastodon.android'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def within_authorization_flow?
|
||||||
|
session[:user_return_to].present? && Rails.application.routes.recognize_path(session[:user_return_to])[:controller] == 'oauth/authorizations'
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def storage_host_var
|
def storage_host_var
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ module ContextHelper
|
|||||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||||
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
|
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
|
||||||
|
quotes: {
|
||||||
|
'quote' => 'https://w3id.org/fep/044f#quote',
|
||||||
|
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
|
||||||
|
'_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
|
||||||
|
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||||
|
},
|
||||||
interaction_policies: {
|
interaction_policies: {
|
||||||
'gts' => 'https://gotosocial.org/ns#',
|
'gts' => 'https://gotosocial.org/ns#',
|
||||||
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
||||||
@@ -33,6 +39,12 @@ module ContextHelper
|
|||||||
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
||||||
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
||||||
},
|
},
|
||||||
|
quote_authorizations: {
|
||||||
|
'gts' => 'https://gotosocial.org/ns#',
|
||||||
|
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||||
|
'interactingObject' => { '@id' => 'gts:interactingObject' },
|
||||||
|
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
|
||||||
|
},
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def full_context
|
def full_context
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module EmailHelper
|
|
||||||
def self.included(base)
|
|
||||||
base.extend(self)
|
|
||||||
end
|
|
||||||
|
|
||||||
def email_to_canonical_email(str)
|
|
||||||
username, domain = str.downcase.split('@', 2)
|
|
||||||
username, = username.delete('.').split('+', 2)
|
|
||||||
|
|
||||||
"#{username}@#{domain}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def email_to_canonical_email_hash(str)
|
|
||||||
Digest::SHA2.new(256).hexdigest(email_to_canonical_email(str))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -27,7 +27,9 @@ module FormattingHelper
|
|||||||
module_function :extract_status_plain_text
|
module_function :extract_status_plain_text
|
||||||
|
|
||||||
def status_content_format(status)
|
def status_content_format(status)
|
||||||
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []))
|
quoted_status = status.quote&.quoted_status if status.local?
|
||||||
|
|
||||||
|
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), quoted_status: quoted_status)
|
||||||
end
|
end
|
||||||
|
|
||||||
def rss_status_content_format(status)
|
def rss_status_content_format(status)
|
||||||
@@ -65,12 +67,12 @@ module FormattingHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def rss_content_preroll(status)
|
def rss_content_preroll(status)
|
||||||
if status.spoiler_text?
|
return unless status.spoiler_text?
|
||||||
safe_join [
|
|
||||||
tag.p { spoiler_with_warning(status) },
|
safe_join [
|
||||||
tag.hr,
|
tag.p { spoiler_with_warning(status) },
|
||||||
]
|
tag.hr,
|
||||||
end
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def spoiler_with_warning(status)
|
def spoiler_with_warning(status)
|
||||||
@@ -81,10 +83,10 @@ module FormattingHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def rss_content_postroll(status)
|
def rss_content_postroll(status)
|
||||||
if status.preloadable_poll
|
return unless status.preloadable_poll
|
||||||
tag.p do
|
|
||||||
poll_option_tags(status)
|
tag.p do
|
||||||
end
|
poll_option_tags(status)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ module HomeHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
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
|
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)
|
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 +
|
end +
|
||||||
@@ -39,18 +45,8 @@ module HomeHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def obscured_counter(count)
|
def field_verified_class(verified)
|
||||||
if count <= 0
|
if verified
|
||||||
'0'
|
|
||||||
elsif count == 1
|
|
||||||
'1'
|
|
||||||
else
|
|
||||||
'1+'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def custom_field_classes(field)
|
|
||||||
if field.verified?
|
|
||||||
'verified'
|
'verified'
|
||||||
else
|
else
|
||||||
'emojify'
|
'emojify'
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ module JsonLdHelper
|
|||||||
patch_for_forwarding!(value, compacted_value)
|
patch_for_forwarding!(value, compacted_value)
|
||||||
elsif value.is_a?(Array)
|
elsif value.is_a?(Array)
|
||||||
compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
|
compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
|
||||||
return if value.size != compacted_value.size
|
return nil if value.size != compacted_value.size
|
||||||
|
|
||||||
compacted[key] = value.zip(compacted_value).map do |v, vc|
|
compacted[key] = value.zip(compacted_value).map do |v, vc|
|
||||||
if v.is_a?(Hash) && vc.is_a?(Hash)
|
if v.is_a?(Hash) && vc.is_a?(Hash)
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ module LanguagesHelper
|
|||||||
mk: ['Macedonian', 'македонски јазик'].freeze,
|
mk: ['Macedonian', 'македонски јазик'].freeze,
|
||||||
ml: ['Malayalam', 'മലയാളം'].freeze,
|
ml: ['Malayalam', 'മലയാളം'].freeze,
|
||||||
mn: ['Mongolian', 'Монгол хэл'].freeze,
|
mn: ['Mongolian', 'Монгол хэл'].freeze,
|
||||||
|
'mn-Mong': ['Traditional Mongolian', 'ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ'].freeze,
|
||||||
mr: ['Marathi', 'मराठी'].freeze,
|
mr: ['Marathi', 'मराठी'].freeze,
|
||||||
ms: ['Malay', 'Bahasa Melayu'].freeze,
|
ms: ['Malay', 'Bahasa Melayu'].freeze,
|
||||||
'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze,
|
'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze,
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ module StatusesHelper
|
|||||||
status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n")
|
status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n")
|
||||||
end
|
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)
|
def status_description(status)
|
||||||
components = [[media_summary(status), status_text_summary(status)].compact_blank.join(' · ')]
|
components = [[media_summary(status), status_text_summary(status)].compact_blank.join(' · ')]
|
||||||
|
|
||||||
@@ -57,6 +65,20 @@ module StatusesHelper
|
|||||||
components.compact_blank.join("\n\n")
|
components.compact_blank.join("\n\n")
|
||||||
end
|
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)
|
def visibility_icon(status)
|
||||||
VISIBLITY_ICONS[status.visibility.to_sym]
|
VISIBLITY_ICONS[status.visibility.to_sym]
|
||||||
end
|
end
|
||||||
@@ -64,4 +86,16 @@ module StatusesHelper
|
|||||||
def prefers_autoplay?
|
def prefers_autoplay?
|
||||||
ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif
|
ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render_seo_schema(status)
|
||||||
|
json = ActiveModelSerializers::SerializableResource.new(
|
||||||
|
status,
|
||||||
|
serializer: SEO::SocialMediaPostingSerializer,
|
||||||
|
adapter: SEO::Adapter
|
||||||
|
).to_json
|
||||||
|
|
||||||
|
# rubocop:disable Rails/OutputSafety
|
||||||
|
content_tag(:script, json_escape(json).html_safe, type: 'application/ld+json')
|
||||||
|
# rubocop:enable Rails/OutputSafety
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,24 +24,24 @@ module ThemeHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def custom_stylesheet
|
def custom_stylesheet
|
||||||
if active_custom_stylesheet.present?
|
return if active_custom_stylesheet.blank?
|
||||||
stylesheet_link_tag(
|
|
||||||
custom_css_path(active_custom_stylesheet),
|
stylesheet_link_tag(
|
||||||
host: root_url,
|
custom_css_path(active_custom_stylesheet),
|
||||||
media: :all,
|
host: root_url,
|
||||||
skip_pipeline: true
|
media: :all,
|
||||||
)
|
skip_pipeline: true
|
||||||
end
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def active_custom_stylesheet
|
def active_custom_stylesheet
|
||||||
if cached_custom_css_digest.present?
|
return if cached_custom_css_digest.blank?
|
||||||
[:custom, cached_custom_css_digest.to_s.first(8)]
|
|
||||||
.compact_blank
|
[:custom, cached_custom_css_digest.to_s.first(8)]
|
||||||
.join('-')
|
.compact_blank
|
||||||
end
|
.join('-')
|
||||||
end
|
end
|
||||||
|
|
||||||
def cached_custom_css_digest
|
def cached_custom_css_digest
|
||||||
|
|||||||
61
app/javascript/config/html-tags.json
Normal file
61
app/javascript/config/html-tags.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"del": {},
|
||||||
|
"s": {},
|
||||||
|
"pre": {},
|
||||||
|
"blockquote": {},
|
||||||
|
"code": {},
|
||||||
|
"b": {},
|
||||||
|
"strong": {},
|
||||||
|
"u": {},
|
||||||
|
"i": {},
|
||||||
|
"img": {
|
||||||
|
"children": false,
|
||||||
|
"attributes": {
|
||||||
|
"src": true,
|
||||||
|
"alt": true,
|
||||||
|
"title": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"em": {},
|
||||||
|
"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 { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import Rails from '@rails/ujs';
|
import Rails from '@rails/ujs';
|
||||||
|
import { decode, ValidationError } from 'blurhash';
|
||||||
|
|
||||||
import ready from '../mastodon/ready';
|
import ready from '../mastodon/ready';
|
||||||
|
|
||||||
@@ -362,6 +363,46 @@ ready(() => {
|
|||||||
document.querySelectorAll('[data-admin-component]').forEach((element) => {
|
document.querySelectorAll('[data-admin-component]').forEach((element) => {
|
||||||
void mountReactComponent(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) => {
|
}).catch((reason: unknown) => {
|
||||||
throw reason;
|
throw reason;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ function loaded() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateDefaultQuotePrivacyFromPrivacy(
|
||||||
|
document.querySelector('#user_settings_attributes_default_privacy'),
|
||||||
|
);
|
||||||
|
|
||||||
const reactComponents = document.querySelectorAll('[data-component]');
|
const reactComponents = document.querySelectorAll('[data-component]');
|
||||||
|
|
||||||
if (reactComponents.length > 0) {
|
if (reactComponents.length > 0) {
|
||||||
@@ -347,6 +351,31 @@ const setInputDisabled = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setInputHint = (
|
||||||
|
input: HTMLInputElement | HTMLSelectElement,
|
||||||
|
hintPrefix: string,
|
||||||
|
) => {
|
||||||
|
const fieldWrapper = input.closest<HTMLElement>('.fields-group > .input');
|
||||||
|
if (!fieldWrapper) return;
|
||||||
|
|
||||||
|
const hint = fieldWrapper.dataset[`${hintPrefix}Hint`];
|
||||||
|
const hintElement =
|
||||||
|
fieldWrapper.querySelector<HTMLSpanElement>(':scope > .hint');
|
||||||
|
|
||||||
|
if (hint) {
|
||||||
|
if (hintElement) {
|
||||||
|
hintElement.textContent = hint;
|
||||||
|
} else {
|
||||||
|
const newHintElement = document.createElement('span');
|
||||||
|
newHintElement.className = 'hint';
|
||||||
|
newHintElement.textContent = hint;
|
||||||
|
fieldWrapper.appendChild(newHintElement);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hintElement?.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Rails.delegate(
|
Rails.delegate(
|
||||||
document,
|
document,
|
||||||
'#account_statuses_cleanup_policy_enabled',
|
'#account_statuses_cleanup_policy_enabled',
|
||||||
@@ -364,6 +393,36 @@ Rails.delegate(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateDefaultQuotePrivacyFromPrivacy = (
|
||||||
|
privacySelect: EventTarget | null,
|
||||||
|
) => {
|
||||||
|
if (!(privacySelect instanceof HTMLSelectElement) || !privacySelect.form)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const select = privacySelect.form.querySelector<HTMLSelectElement>(
|
||||||
|
'select#user_settings_attributes_default_quote_policy',
|
||||||
|
);
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
setInputHint(select, privacySelect.value);
|
||||||
|
|
||||||
|
if (privacySelect.value === 'private') {
|
||||||
|
select.value = 'nobody';
|
||||||
|
setInputDisabled(select, true);
|
||||||
|
} else {
|
||||||
|
setInputDisabled(select, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Rails.delegate(
|
||||||
|
document,
|
||||||
|
'#user_settings_attributes_default_privacy',
|
||||||
|
'change',
|
||||||
|
({ target }) => {
|
||||||
|
updateDefaultQuotePrivacyFromPrivacy(target);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Empty the honeypot fields in JS in case something like an extension
|
// Empty the honeypot fields in JS in case something like an extension
|
||||||
// automatically filled them.
|
// automatically filled them.
|
||||||
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
Images in this folder are based on [Tabler.io icons](https://tabler.io/icons).
|
Images in this folder are based on [Tabler.io icons](https://tabler.io/icons).
|
||||||
|
|
||||||
|
Seems to be 1.5 width icons scaled to 64×64px and centered above a blue square with round corners (24px).
|
||||||
|
|||||||
BIN
app/javascript/images/mailer-new/heading/quote.png
Normal file
BIN
app/javascript/images/mailer-new/heading/quote.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -5,6 +5,7 @@ import { throttle } from 'lodash';
|
|||||||
|
|
||||||
import api from 'mastodon/api';
|
import api from 'mastodon/api';
|
||||||
import { browserHistory } from 'mastodon/components/router';
|
import { browserHistory } from 'mastodon/components/router';
|
||||||
|
import { countableText } from 'mastodon/features/compose/util/counter';
|
||||||
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
|
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
|
||||||
import { tagHistory } from 'mastodon/settings';
|
import { tagHistory } from 'mastodon/settings';
|
||||||
|
|
||||||
@@ -55,7 +56,6 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
|
|||||||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
||||||
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
||||||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_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_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
|
||||||
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
||||||
|
|
||||||
@@ -84,9 +84,11 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
|
|||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
|
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
|
||||||
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
|
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
|
||||||
|
uploadQuote: { id: 'upload_error.quote', defaultMessage: 'File upload not allowed with quotes.' },
|
||||||
open: { id: 'compose.published.open', defaultMessage: 'Open' },
|
open: { id: 'compose.published.open', defaultMessage: 'Open' },
|
||||||
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
|
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
|
||||||
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
|
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
|
||||||
|
blankPostError: { id: 'compose.error.blank_post', defaultMessage: 'Post can\'t be blank.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ensureComposeIsVisible = (getState) => {
|
export const ensureComposeIsVisible = (getState) => {
|
||||||
@@ -96,12 +98,17 @@ export const ensureComposeIsVisible = (getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function setComposeToStatus(status, text, spoiler_text) {
|
export function setComposeToStatus(status, text, spoiler_text) {
|
||||||
return{
|
return (dispatch, getState) => {
|
||||||
type: COMPOSE_SET_STATUS,
|
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||||
status,
|
|
||||||
text,
|
dispatch({
|
||||||
spoiler_text,
|
type: COMPOSE_SET_STATUS,
|
||||||
};
|
status,
|
||||||
|
text,
|
||||||
|
spoiler_text,
|
||||||
|
maxOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeCompose(text) {
|
export function changeCompose(text) {
|
||||||
@@ -146,7 +153,7 @@ export function resetCompose() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const focusCompose = (defaultText) => (dispatch, getState) => {
|
export const focusCompose = (defaultText = '') => (dispatch, getState) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: COMPOSE_FOCUS,
|
type: COMPOSE_FOCUS,
|
||||||
defaultText,
|
defaultText,
|
||||||
@@ -183,13 +190,23 @@ export function directCompose(account) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitCompose() {
|
export function submitCompose(successCallback) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
const status = getState().getIn(['compose', 'text'], '');
|
const status = getState().getIn(['compose', 'text'], '');
|
||||||
const media = getState().getIn(['compose', 'media_attachments']);
|
const media = getState().getIn(['compose', 'media_attachments']);
|
||||||
const statusId = getState().getIn(['compose', 'id'], null);
|
const statusId = getState().getIn(['compose', 'id'], null);
|
||||||
|
const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']);
|
||||||
|
const spoiler_text = getState().getIn(['compose', 'spoiler']) ? 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,19 +232,22 @@ export function submitCompose() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibility = getState().getIn(['compose', 'privacy']);
|
||||||
api().request({
|
api().request({
|
||||||
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
||||||
method: statusId === null ? 'post' : 'put',
|
method: statusId === null ? 'post' : 'put',
|
||||||
data: {
|
data: {
|
||||||
status,
|
status,
|
||||||
|
spoiler_text,
|
||||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||||
media_ids: media.map(item => item.get('id')),
|
media_ids: media.map(item => item.get('id')),
|
||||||
media_attributes,
|
media_attributes,
|
||||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
visibility: visibility,
|
||||||
visibility: getState().getIn(['compose', 'privacy']),
|
|
||||||
poll: getState().getIn(['compose', 'poll'], null),
|
poll: getState().getIn(['compose', 'poll'], null),
|
||||||
language: getState().getIn(['compose', 'language']),
|
language: getState().getIn(['compose', 'language']),
|
||||||
|
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
|
||||||
|
quote_approval_policy: visibility === 'private' || visibility === 'direct' ? 'nobody' : getState().getIn(['compose', 'quote_policy']),
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||||
@@ -239,6 +259,9 @@ export function submitCompose() {
|
|||||||
|
|
||||||
dispatch(insertIntoTagHistory(response.data.tags, status));
|
dispatch(insertIntoTagHistory(response.data.tags, status));
|
||||||
dispatch(submitComposeSuccess({ ...response.data }));
|
dispatch(submitComposeSuccess({ ...response.data }));
|
||||||
|
if (typeof successCallback === 'function') {
|
||||||
|
successCallback(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
// To make the app more responsive, immediately push the status
|
// To make the app more responsive, immediately push the status
|
||||||
// into the columns
|
// into the columns
|
||||||
@@ -298,6 +321,11 @@ export function submitComposeFail(error) {
|
|||||||
|
|
||||||
export function uploadCompose(files) {
|
export function uploadCompose(files) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
|
// Exit if there's a quote.
|
||||||
|
if (getState().compose.get('quoted_status_id')) {
|
||||||
|
dispatch(showAlert({ message: messages.uploadQuote }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']);
|
const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']);
|
||||||
const media = getState().getIn(['compose', 'media_attachments']);
|
const media = getState().getIn(['compose', 'media_attachments']);
|
||||||
const pending = getState().getIn(['compose', 'pending_media_attachments']);
|
const pending = getState().getIn(['compose', 'pending_media_attachments']);
|
||||||
@@ -603,6 +631,7 @@ export function fetchComposeSuggestions(token) {
|
|||||||
fetchComposeSuggestionsEmojis(dispatch, getState, token);
|
fetchComposeSuggestionsEmojis(dispatch, getState, token);
|
||||||
break;
|
break;
|
||||||
case '#':
|
case '#':
|
||||||
|
case '#':
|
||||||
fetchComposeSuggestionsTags(dispatch, getState, token);
|
fetchComposeSuggestionsTags(dispatch, getState, token);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -644,11 +673,11 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
|||||||
|
|
||||||
dispatch(useEmoji(suggestion));
|
dispatch(useEmoji(suggestion));
|
||||||
} else if (suggestion.type === 'hashtag') {
|
} else if (suggestion.type === 'hashtag') {
|
||||||
completion = `#${suggestion.name}`;
|
completion = suggestion.name.slice(token.length - 1);
|
||||||
startPosition = position - 1;
|
startPosition = position + token.length;
|
||||||
} else if (suggestion.type === 'account') {
|
} else if (suggestion.type === 'account') {
|
||||||
completion = getState().getIn(['accounts', suggestion.id, 'acct']);
|
completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`;
|
||||||
startPosition = position;
|
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
|
// 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
|
||||||
@@ -708,7 +737,7 @@ function insertIntoTagHistory(recognizedTags, text) {
|
|||||||
// complicated because of new normalization rules, it's no longer just
|
// complicated because of new normalization rules, it's no longer just
|
||||||
// a case sensitivity issue
|
// a case sensitivity issue
|
||||||
const names = recognizedTags.map(tag => {
|
const names = recognizedTags.map(tag => {
|
||||||
const matches = text.match(new RegExp(`#${tag.name}`, 'i'));
|
const matches = text.match(new RegExp(`[##]${tag.name}`, 'i'));
|
||||||
|
|
||||||
if (matches && matches.length > 0) {
|
if (matches && matches.length > 0) {
|
||||||
return matches[0].slice(1);
|
return matches[0].slice(1);
|
||||||
@@ -764,13 +793,6 @@ export function changeComposeSpoilerText(text) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeComposeVisibility(value) {
|
|
||||||
return {
|
|
||||||
type: COMPOSE_VISIBILITY_CHANGE,
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function insertEmojiCompose(position, emoji, needsSpace) {
|
export function insertEmojiCompose(position, emoji, needsSpace) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_EMOJI_INSERT,
|
type: COMPOSE_EMOJI_INSERT,
|
||||||
|
|||||||
@@ -1,9 +1,52 @@
|
|||||||
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { apiUpdateMedia } from 'mastodon/api/compose';
|
import { apiUpdateMedia } from 'mastodon/api/compose';
|
||||||
|
import { apiGetSearch } from 'mastodon/api/search';
|
||||||
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
||||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
import {
|
||||||
|
createDataLoadingThunk,
|
||||||
|
createAppThunk,
|
||||||
|
} from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||||
|
import type { Status, StatusVisibility } from '../models/status';
|
||||||
|
import type { RootState } from '../store';
|
||||||
|
|
||||||
|
import { showAlert } from './alerts';
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
quoteErrorPoll: {
|
||||||
|
id: 'quote_error.poll',
|
||||||
|
defaultMessage: 'Quoting is not allowed with polls.',
|
||||||
|
},
|
||||||
|
quoteErrorQuote: {
|
||||||
|
id: 'quote_error.quote',
|
||||||
|
defaultMessage: 'Only one quote at a time is allowed.',
|
||||||
|
},
|
||||||
|
quoteErrorUnauthorized: {
|
||||||
|
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 & {
|
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||||
unattached?: boolean;
|
unattached?: boolean;
|
||||||
@@ -29,6 +72,39 @@ const simulateModifiedApiResponse = (
|
|||||||
return data;
|
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(
|
export const changeUploadCompose = createDataLoadingThunk(
|
||||||
'compose/changeUpload',
|
'compose/changeUpload',
|
||||||
async (
|
async (
|
||||||
@@ -68,3 +144,132 @@ export const changeUploadCompose = createDataLoadingThunk(
|
|||||||
useLoadingBar: false,
|
useLoadingBar: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const quoteCompose = createAppThunk(
|
||||||
|
'compose/quoteComposeStatus',
|
||||||
|
(status: Status, { dispatch }) => {
|
||||||
|
dispatch(focusCompose());
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const quoteComposeByStatus = createAppThunk(
|
||||||
|
(status: Status, { dispatch, getState }) => {
|
||||||
|
const state = getState();
|
||||||
|
const composeState = state.compose;
|
||||||
|
const mediaAttachments = composeState.get('media_attachments');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const wasQuietPostHintModalDismissed: boolean =
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||||
|
state.settings.getIn(
|
||||||
|
['dismissed_banners', 'quote/quiet_post_hint'],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
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') ||
|
||||||
|
(mediaAttachments &&
|
||||||
|
typeof mediaAttachments !== 'string' &&
|
||||||
|
typeof mediaAttachments !== 'number' &&
|
||||||
|
typeof mediaAttachments !== 'boolean' &&
|
||||||
|
mediaAttachments.size !== 0)
|
||||||
|
) {
|
||||||
|
dispatch(showAlert({ message: messages.quoteErrorUpload }));
|
||||||
|
} else if (composeState.get('quoted_status_id')) {
|
||||||
|
dispatch(showAlert({ message: messages.quoteErrorQuote }));
|
||||||
|
} else if (
|
||||||
|
status.getIn(['quote_approval', 'current_user']) !== 'automatic' &&
|
||||||
|
status.getIn(['quote_approval', 'current_user']) !== 'manual'
|
||||||
|
) {
|
||||||
|
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
|
||||||
|
} else if (
|
||||||
|
status.get('visibility') === 'unlisted' &&
|
||||||
|
!wasQuietPostHintModalDismissed
|
||||||
|
) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM_QUIET_QUOTE',
|
||||||
|
modalProps: { status },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dispatch(quoteCompose(status));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const quoteComposeById = createAppThunk(
|
||||||
|
(statusId: string, { dispatch, getState }) => {
|
||||||
|
const status = getState().statuses.get(statusId);
|
||||||
|
if (status) {
|
||||||
|
dispatch(quoteComposeByStatus(status));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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>(
|
||||||
|
'compose/setQuotePolicy',
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import escapeTextContentForBrowser from 'escape-html';
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
|
|
||||||
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
|
|
||||||
|
|
||||||
import emojify from '../../features/emoji/emoji';
|
|
||||||
import { expandSpoilers } from '../../initial_state';
|
import { expandSpoilers } from '../../initial_state';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
@@ -21,6 +18,15 @@ export function normalizeFilterResult(result) {
|
|||||||
return normalResult;
|
return normalResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripQuoteFallback(text) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = text;
|
||||||
|
|
||||||
|
wrapper.querySelector('.quote-inline')?.remove();
|
||||||
|
|
||||||
|
return wrapper.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeStatus(status, normalOldStatus) {
|
export function normalizeStatus(status, normalOldStatus) {
|
||||||
const normalStatus = { ...status };
|
const normalStatus = { ...status };
|
||||||
|
|
||||||
@@ -72,20 +78,24 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||||||
} else {
|
} else {
|
||||||
// If the status has a CW but no contents, treat the CW as if it were the
|
// If the status has a CW but no contents, treat the CW as if it were the
|
||||||
// status' contents, to avoid having a CW toggle with seemingly no effect.
|
// status' contents, to avoid having a CW toggle with seemingly no effect.
|
||||||
if (normalStatus.spoiler_text && !normalStatus.content) {
|
if (normalStatus.spoiler_text && !normalStatus.content && !normalStatus.quote) {
|
||||||
normalStatus.content = normalStatus.spoiler_text;
|
normalStatus.content = normalStatus.spoiler_text;
|
||||||
normalStatus.spoiler_text = '';
|
normalStatus.spoiler_text = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const spoilerText = normalStatus.spoiler_text || '';
|
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 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.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
||||||
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
normalStatus.contentHtml = normalStatus.content;
|
||||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
normalStatus.spoilerHtml = escapeTextContentForBrowser(spoilerText);
|
||||||
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
||||||
|
|
||||||
|
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||||
|
if (normalStatus.quote) {
|
||||||
|
normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml);
|
||||||
|
}
|
||||||
|
|
||||||
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
|
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
|
||||||
normalStatus.url = null;
|
normalStatus.url = null;
|
||||||
}
|
}
|
||||||
@@ -114,25 +124,27 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeStatusTranslation(translation, status) {
|
export function normalizeStatusTranslation(translation, status) {
|
||||||
const emojiMap = makeEmojiMap(status.get('emojis').toJS());
|
|
||||||
|
|
||||||
const normalTranslation = {
|
const normalTranslation = {
|
||||||
detected_source_language: translation.detected_source_language,
|
detected_source_language: translation.detected_source_language,
|
||||||
language: translation.language,
|
language: translation.language,
|
||||||
provider: translation.provider,
|
provider: translation.provider,
|
||||||
contentHtml: emojify(translation.content, emojiMap),
|
contentHtml: translation.content,
|
||||||
spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap),
|
spoilerHtml: escapeTextContentForBrowser(translation.spoiler_text),
|
||||||
spoiler_text: translation.spoiler_text,
|
spoiler_text: translation.spoiler_text,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||||
|
if (status.get('quote')) {
|
||||||
|
normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml);
|
||||||
|
}
|
||||||
|
|
||||||
return normalTranslation;
|
return normalTranslation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAnnouncement(announcement) {
|
export function normalizeAnnouncement(announcement) {
|
||||||
const normalAnnouncement = { ...announcement };
|
const normalAnnouncement = { ...announcement };
|
||||||
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
|
|
||||||
|
|
||||||
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
|
normalAnnouncement.contentHtml = normalAnnouncement.content;
|
||||||
|
|
||||||
return normalAnnouncement;
|
return normalAnnouncement;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { apiReblog, apiUnreblog } from 'mastodon/api/interactions';
|
import {
|
||||||
|
apiReblog,
|
||||||
|
apiUnreblog,
|
||||||
|
apiRevokeQuote,
|
||||||
|
apiGetQuotes,
|
||||||
|
} from 'mastodon/api/interactions';
|
||||||
import type { StatusVisibility } from 'mastodon/models/status';
|
import type { StatusVisibility } from 'mastodon/models/status';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
import { importFetchedStatus } from './importer';
|
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||||
|
|
||||||
export const reblog = createDataLoadingThunk(
|
export const reblog = createDataLoadingThunk(
|
||||||
'status/reblog',
|
'status/reblog',
|
||||||
@@ -33,3 +38,35 @@ export const unreblog = createDataLoadingThunk(
|
|||||||
return discardLoadData;
|
return discardLoadData;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const revokeQuote = createDataLoadingThunk(
|
||||||
|
'status/revoke_quote',
|
||||||
|
({
|
||||||
|
statusId,
|
||||||
|
quotedStatusId,
|
||||||
|
}: {
|
||||||
|
statusId: string;
|
||||||
|
quotedStatusId: string;
|
||||||
|
}) => apiRevokeQuote(quotedStatusId, statusId),
|
||||||
|
(data, { dispatch, discardLoadData }) => {
|
||||||
|
dispatch(importFetchedStatus(data));
|
||||||
|
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchQuotes = createDataLoadingThunk(
|
||||||
|
'status/fetch_quotes',
|
||||||
|
async ({ statusId, next }: { statusId: string; next?: string }) => {
|
||||||
|
const { links, statuses } = await apiGetQuotes(statusId, next);
|
||||||
|
|
||||||
|
return {
|
||||||
|
links,
|
||||||
|
statuses,
|
||||||
|
replace: !next,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
(payload, { dispatch }) => {
|
||||||
|
dispatch(importFetchedStatuses(payload.statuses));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -30,8 +30,21 @@ import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
|||||||
import { NOTIFICATIONS_FILTER_SET } from './notifications';
|
import { NOTIFICATIONS_FILTER_SET } from './notifications';
|
||||||
import { saveSettings } from './settings';
|
import { saveSettings } from './settings';
|
||||||
|
|
||||||
|
function notificationTypeForFilter(type: NotificationType) {
|
||||||
|
if (type === 'quoted_update') return 'update';
|
||||||
|
else return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationTypeForQuickFilter(type: NotificationType) {
|
||||||
|
if (type === 'quoted_update') return 'update';
|
||||||
|
else if (type === 'quote') return 'mention';
|
||||||
|
else return type;
|
||||||
|
}
|
||||||
|
|
||||||
function excludeAllTypesExcept(filter: string) {
|
function excludeAllTypesExcept(filter: string) {
|
||||||
return allNotificationTypes.filter((item) => item !== filter);
|
return allNotificationTypes.filter(
|
||||||
|
(item) => notificationTypeForQuickFilter(item) !== filter,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExcludedTypes(state: RootState) {
|
function getExcludedTypes(state: RootState) {
|
||||||
@@ -155,13 +168,17 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
|||||||
|
|
||||||
const showInColumn =
|
const showInColumn =
|
||||||
activeFilter === 'all'
|
activeFilter === 'all'
|
||||||
? notificationShows[notification.type] !== false
|
? notificationShows[notificationTypeForFilter(notification.type)] !==
|
||||||
: activeFilter === notification.type;
|
false
|
||||||
|
: activeFilter === notificationTypeForQuickFilter(notification.type);
|
||||||
|
|
||||||
if (!showInColumn) return;
|
if (!showInColumn) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(notification.type === 'mention' || notification.type === 'update') &&
|
(notification.type === 'mention' ||
|
||||||
|
notification.type === 'quote' ||
|
||||||
|
notification.type === 'update' ||
|
||||||
|
notification.type === 'quoted_update') &&
|
||||||
notification.status?.filtered
|
notification.status?.filtered
|
||||||
) {
|
) {
|
||||||
const filters = notification.status.filtered.filter((result) =>
|
const filters = notification.status.filtered.filter((result) =>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user