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