Compare commits

...

237 Commits

Author SHA1 Message Date
Claire
54e08a54e9 Merge pull request #3308 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to 86cff1abca into stable-4.5
2025-12-08 17:20:59 +01:00
Claire
12ec21a95f [Glitch] Fix “Delete and Redraft” on a non-quote being treated as a quote post in some cases
Port 1ae3b4672b to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-08 16:40:40 +01:00
Echo
fe9a71975c [Glitch] Fixes YouTube embeds
Port 9bc9ebc59e to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-08 16:40:11 +01:00
Echo
e1f145973b [Glitch] Remove noreferrer from external links
Port 234990cc37 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-08 16:39:17 +01:00
Claire
66f25c6709 [Glitch] Fix error handling when re-fetching already-known statuses
Port edfbcfb3f5 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-08 16:39:03 +01:00
diondiondion
e4e4ffb08d [Glitch] Fix post navigation in single-column mode when Advanced UI is enabled
Port f12f198f61 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-08 16:38:49 +01:00
Claire
e016e2a31e [Glitch] Fix compose autosuggest always lowercasing token
Port a26636ff1f to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-08 16:38:31 +01:00
Claire
6c1e892dd7 Merge commit '86cff1abca6af7ffb1a6ca004ae308c0df6d45ba' into glitch-soc/merge-4.5 2025-12-08 16:37:11 +01:00
Claire
86cff1abca Bump version to v4.5.3 (#37142) 2025-12-08 16:20:15 +01:00
Claire
e6d2fc869b Merge commit from fork 2025-12-08 15:44:08 +01:00
github-actions[bot]
a9f8268a75 New Crowdin Translations for stable-4.5 (automated) (#37158)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-12-08 12:56:49 +01:00
Claire
dfe269439a Fix “Delete and Redraft” on a non-quote being treated as a quote post in some cases (#37140) 2025-12-05 16:50:07 +01:00
Echo
9bc9ebc59e Fixes YouTube embeds (#37126) 2025-12-05 11:15:16 +01:00
Claire
a6d31c0ccf Fix streamed quoted polls not being hydrated correctly (#37118) 2025-12-05 11:15:16 +01:00
David Roetzel
1e2cf6c964 Fix creation of duplicate conversations (#37108) 2025-12-05 11:15:16 +01:00
Echo
c42c71c90a Remove noreferrer from external links (#37107) 2025-12-05 11:15:16 +01:00
Claire
782e410719 Make settings-related database migrations more robust (#37079) 2025-12-05 11:15:16 +01:00
Claire
b0c141e658 Fix error handling when re-fetching already-known statuses (#37077) 2025-12-05 11:15:16 +01:00
diondiondion
1ef4bbd88d Fix post navigation in single-column mode when Advanced UI is enabled (#37044) 2025-12-05 11:15:16 +01:00
Claire
240d38b7c0 Fix tootctl status remove removing quoted posts and remote quotes of local posts (#37009) 2025-12-05 11:15:16 +01:00
Claire
770d1212bb Increase HTTP read timeout for expensive S3 batch delete operation (#37004) 2025-12-05 11:15:16 +01:00
Claire
86e463c0e8 Fix compose autosuggest always lowercasing token (#36995) 2025-12-05 11:15:16 +01:00
Matt Jankowski
a04a210e14 Suggest ES image version 7.17.29 in docker compose (#36972) 2025-12-05 11:15:16 +01:00
Claire
300d62f1c4 Merge pull request #3291 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to 19588756ef into stable-4.5
2025-11-20 15:07:17 +01:00
Claire
27d33d1233 [Glitch] Fix statuses without text disappearing on reload
Port 05c624cfa7 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-20 14:49:45 +01:00
Claire
b35c691ab2 Merge commit '19588756ef62847a01486fa360d4e4ee00307948' into glitch-soc/merge-4.5 2025-11-20 14:49:19 +01:00
Claire
19588756ef Bump version to v4.5.2 (#36944) 2025-11-20 14:41:09 +01:00
github-actions[bot]
e398ff40b2 New Crowdin Translations for stable-4.5 (automated) (#36945)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-11-20 13:40:31 +01:00
Claire
96eb687524 Fix missing fallback link in CW-only quote posts (#36963) 2025-11-20 12:56:41 +01:00
Claire
05c624cfa7 Fix statuses without text disappearing on reload (#36962) 2025-11-20 12:56:41 +01:00
Claire
dba811952a Merge pull request #3289 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to 5fe316b2e9 into stable-4.5
2025-11-19 22:59:47 +01:00
diondiondion
8836c4fc84 [Glitch] Fix g + h keyboard shortcut not working when a post is focused
Port 1dbf10198d to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:25:09 +01:00
Claire
1da82862f1 [Glitch] Fix quoting overwriting current content warning
Port c6ccacdf7b to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:24:40 +01:00
Claire
8c725777ed [Glitch] Fix scroll-to-status in threaded view being unreliable
Port 6ccd9c2f1f to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:24:15 +01:00
Claire
126823986a [Glitch] Change private quote education modal to not show up on self-quotes
Port 261d9b33fe to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:23:42 +01:00
Claire
f04b06a44f [Glitch] Fix double encoding in links
Port 4ee21c2e29 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:23:13 +01:00
Echo
c7481cb2ca [Glitch] Emoji: Fix path resolution for emoji worker
Port c08cd6d62a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:22:37 +01:00
Echo
7141917943 [Glitch] Fix error with remote tags including percent signs
Port 6486c092f6 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:19:45 +01:00
Claire
6c7a9b8311 [Glitch] Fix bogus quote approval policy not always being replaced correctly
Port a7b45682a6 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:19:16 +01:00
Claire
8ae06fdbcd [Glitch] Fix hashtag completion not being inserted correctly
Port 5a57c0844a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:18:46 +01:00
diondiondion
553bb4673e [Glitch] Fix Cmd/Ctrl + Enter in the composer triggering confirmation dialog action
Port 1d081250f4 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:18:16 +01:00
Claire
fe6d3690fd Merge commit '5fe316b2e94bccb91732a1d6675854abb60e9a4b' into glitch-soc/merge-4.5 2025-11-19 22:15:12 +01:00
Claire
9746c5eb6b Merge pull request #3287 from ClearlyClaire/glitch-soc/backports/4.5
Backport changes to stable-4.5
2025-11-19 22:14:32 +01:00
Claire
b5ae2f07a1 Change quotes to inherit local-only status of quoted post in composer (#3286) 2025-11-19 21:29:00 +01:00
Claire
9b91852f93 Fix d bookmark keyboard shortcut (#3285) 2025-11-19 21:28:26 +01:00
Claire
0bb7711225 Fix threaded mode not resetting quote (#3284) 2025-11-19 21:26:10 +01:00
Claire
86445f45fc Clean up CSS differences with upstream (#3283) 2025-11-19 21:26:10 +01:00
Claire
5fe316b2e9 Update dependency glob (#36941) 2025-11-19 16:29:35 +01:00
diondiondion
1dbf10198d Fix g + h keyboard shortcut not working when a post is focused (#36935) 2025-11-19 15:20:01 +01:00
Claire
c6ccacdf7b Fix quoting overwriting current content warning (#36934) 2025-11-19 15:20:01 +01:00
Claire
6ccd9c2f1f Fix scroll-to-status in threaded view being unreliable (#36927) 2025-11-19 15:20:01 +01:00
Claire
261d9b33fe Change private quote education modal to not show up on self-quotes (#36926) 2025-11-19 15:20:01 +01:00
Claire
4ee21c2e29 Fix double encoding in links (#36925) 2025-11-19 15:20:01 +01:00
Echo
c08cd6d62a Emoji: Fix path resolution for emoji worker (#36897) 2025-11-19 15:20:01 +01:00
Shugo Maeda
44d45e5705 Fix ArgumentError of tootctl upgrade storage-schema (#36914) 2025-11-19 15:20:01 +01:00
Claire
27c67f1750 Fix cross-origin handling of CSS modules (#36890) 2025-11-19 15:20:01 +01:00
renovate[bot]
bb28552859 chore(deps): update dependency js-yaml to v4.1.1 [security] (#36891)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-19 15:20:01 +01:00
Echo
6486c092f6 Fix error with remote tags including percent signs (#36886) 2025-11-19 15:20:01 +01:00
Claire
a7b45682a6 Fix bogus quote approval policy not always being replaced correctly (#36885) 2025-11-19 15:20:01 +01:00
Claire
5a57c0844a Fix hashtag completion not being inserted correctly (#36884) 2025-11-19 15:20:01 +01:00
diondiondion
1d081250f4 Fix Cmd/Ctrl + Enter in the composer triggering confirmation dialog action (#36870) 2025-11-19 15:20:01 +01:00
Claire
e6c8958d07 Fix missing alt-text confirmation modal not opening (#3281) 2025-11-15 16:48:12 +01:00
Claire
b2506cc110 Merge pull request #3276 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to bb6093c315 into stable-4.5
2025-11-13 17:58:54 +01:00
Claire
d18491b7a7 [Glitch] Fix error when sending new posts
Port 058f704c21 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:36:50 +01:00
Claire
13330030cd [Glitch] Fix posts coming from public/hashtag streaming being marked as unquotable
Port 55b9d21537 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:36:21 +01:00
diondiondion
f9012a774c [Glitch] Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers
Port 6baa8f2466 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:31:18 +01:00
Echo
375af385e7 [Glitch] Fix deprecation warning in Vite
Port 28b9e9087a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:30:32 +01:00
diondiondion
7e5224a3c0 [Glitch] Fixes blank screen in browsers that don't support Intl.DisplayNames
Port fa2cc409ce to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:29:41 +01:00
Claire
140d782cba [Glitch] Fix filters not being applied to quotes in detailed view
Port 8a100d84c5 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:28:58 +01:00
Echo
c7a7ce8ce7 [Glitch] Emoji: Load emoji with hash in URL
Port 9ae0464e8f to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:28:15 +01:00
diondiondion
afbe0a4860 [Glitch] Fix scroll shift caused by fetch-all-replies alerts
Port 9eea4479e1 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:27:30 +01:00
diondiondion
febde69d0b [Glitch] Fix dropdown menu not focusing first item when opened via keyboard
Port 30103fd2c8 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:26:57 +01:00
Claire
85b9a5944d [Glitch] Fix prepared quote not being discarded with contents when replying
Port fbe05d42fb to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:26:34 +01:00
Claire
585827c14f Merge commit 'bb6093c3153f092736e2f25959e3dcdceeb7bce1' into glitch-soc/merge-4.5 2025-11-13 17:22:33 +01:00
Claire
bb6093c315 Bump version to v4.5.1 2025-11-13 17:12:07 +01:00
Claire
058f704c21 Fix error when sending new posts (#36869) 2025-11-13 17:12:07 +01:00
diondiondion
6baa8f2466 Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers (#36866) 2025-11-13 17:12:07 +01:00
github-actions[bot]
e742eff044 New Crowdin Translations for stable-4.5 (automated) (#36864)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-11-13 15:46:34 +01:00
Claire
55b9d21537 Fix posts coming from public/hashtag streaming being marked as unquotable (#36860) 2025-11-13 15:24:58 +01:00
Claire
59f0134578 Fix Update importing old previously-unknown activities and treating them as recent ones (#36848) 2025-11-13 15:24:58 +01:00
Echo
28b9e9087a Fix deprecation warning in Vite (#36849) 2025-11-13 15:24:58 +01:00
diondiondion
fa2cc409ce Fixes blank screen in browsers that don't support Intl.DisplayNames (#36847) 2025-11-13 15:24:58 +01:00
Claire
8a100d84c5 Fix filters not being applied to quotes in detailed view (#36843) 2025-11-13 15:24:58 +01:00
Echo
9ae0464e8f Emoji: Load emoji with hash in URL (#36808) 2025-11-13 15:24:58 +01:00
diondiondion
9eea4479e1 Fix scroll shift caused by fetch-all-replies alerts (#36807) 2025-11-13 15:24:58 +01:00
diondiondion
30103fd2c8 Fix dropdown menu not focusing first item when opened via keyboard (#36804) 2025-11-13 15:24:58 +01:00
Claire
a9a7ad62f1 Update dependency rollup from 4.46.2 to 4.46.4 (#36781) 2025-11-13 15:24:58 +01:00
Claire
ea663cf7c7 Fix /api/v1/statuses/:id/context sometimes returing Mastodon-Async-Refresh without result_count (#36779) 2025-11-13 15:24:58 +01:00
Claire
fbe05d42fb Fix prepared quote not being discarded with contents when replying (#36778) 2025-11-13 15:24:58 +01:00
Claire
29ae9c9c4b Add 4.5.x to the list of supported branches (#36761) 2025-11-06 17:12:41 +01:00
Claire
4684d5e69b Merge pull request #3269 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to 26c78392f8
2025-11-06 13:09:16 +01:00
Claire
3ca92c4ae2 Merge commit '26c78392f88afccebb8a88b68bc9994a9ce12648' into glitch-soc/merge-4.5 2025-11-06 12:49:01 +01:00
Claire
81b1a34d96 Merge pull request #3267 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to 048430f4e8
2025-11-06 12:45:08 +01:00
Claire
26c78392f8 Bump version to v4.5.0 (#36732) 2025-11-06 12:39:07 +01:00
Echo
caecc88247 [Glitch] Fix: correctly dismisses announcement when viewed
Port d45b4db1d7 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-06 12:20:19 +01:00
Echo
bed4ca26e2 [Glitch] Add default visualizer for audio upload without poster
Port ef3a95affc to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-06 12:19:58 +01:00
diondiondion
5d108e95d7 [Glitch] Fix spoiler toggle button being able to submit compose form
Port 3e6a9371b0 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-06 12:18:21 +01:00
Claire
8a2d38a47b Merge commit '048430f4e8010672eb667f1ccf2372571cfb97dd' into glitch-soc/merge-4.5 2025-11-06 12:16:55 +01:00
github-actions[bot]
048430f4e8 New Crowdin Translations for stable-4.5 (automated) (#36745)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-11-06 11:37:54 +01:00
Echo
d45b4db1d7 Fix: correctly dismisses announcement when viewed (#36750) 2025-11-06 11:23:46 +01:00
Echo
ef3a95affc Add default visualizer for audio upload without poster (#36734) 2025-11-06 10:34:12 +01:00
diondiondion
3e6a9371b0 Fix spoiler toggle button being able to submit compose form (#36736) 2025-11-06 10:34:12 +01:00
Claire
7ab0cfd637 Merge pull request #3266 from ClearlyClaire/glitch-soc/merge-4.5
Port missing changes to stable-4.5
2025-11-05 11:27:29 +01:00
Claire
b33bcc8be6 Merge pull request #3265 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to e91c764590
2025-11-05 10:52:40 +01:00
Claire
cdad6ee0c9 [Glitch] Change paste-link-to-quote loading state from generic loading bar to compose placeholder
Port cfdd9396c0 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:09:28 +01:00
Claire
2d958cb909 [Glitch] Change quote action to error instead of insert link in Private Mentions
Port ba498ae779 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:09:12 +01:00
Echo
949f15e200 [Glitch] Quote Posts: Add notifications for DMs and private posts
Port 5bae08d1ff to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:09:02 +01:00
Claire
105a2d64a7 [Glitch] Fix Skeleton placeholders being animated when setting to reduce animations is enabled
Port 0b50789c5b to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:08:49 +01:00
Claire
1c17990413 [Glitch] Fix quote dropdown menu item in detailed status view
Port a978e37f4c to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:08:37 +01:00
Claire
0a8f96d3be [Glitch] Remove option to disable access to local topic feeds for logged-in users
Port dd708298a8 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:08:24 +01:00
Claire
1ec8e42dbb [Glitch] Disable paste-link-to-quote flow when composing Private Mentions
Port 6d53ca63d6 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:08:08 +01:00
Claire
7ea3c6a039 Merge commit 'e91c7645905972d9663a0d944b133ff24670bce2' into glitch-soc/merge-4.5 2025-11-05 10:02:33 +01:00
Claire
e91c764590 Bump version to v4.5.0-rc.3 2025-11-05 09:59:00 +01:00
Claire
cfdd9396c0 Change paste-link-to-quote loading state from generic loading bar to compose placeholder (#36695) 2025-11-05 09:59:00 +01:00
Claire
ba498ae779 Change quote action to error instead of insert link in Private Mentions (#36721) 2025-11-05 09:59:00 +01:00
Echo
5bae08d1ff Quote Posts: Add notifications for DMs and private posts (#36696) 2025-11-05 09:59:00 +01:00
Echo
5253527ec4 Add CSS Module support (#36637) 2025-11-05 09:59:00 +01:00
Claire
0b50789c5b Fix Skeleton placeholders being animated when setting to reduce animations is enabled (#36716) 2025-11-05 09:59:00 +01:00
Claire
a978e37f4c Fix quote dropdown menu item in detailed status view (#36704) 2025-11-05 09:59:00 +01:00
Claire
dd708298a8 Remove option to disable access to local topic feeds for logged-in users (#36703) 2025-11-05 09:59:00 +01:00
renovate[bot]
449eb03f11 chore(deps): update dependency sidekiq to v8.0.9 (#36699)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:59:00 +01:00
renovate[bot]
1baede0a7c chore(deps): update dependency brakeman to v7.1.1 (#35434)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:59:00 +01:00
renovate[bot]
a7ecfc1ca5 fix(deps): update dependency @rails/ujs to v7.1.600 (#36634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:59:00 +01:00
Claire
e62baacfc1 Increase number of quote approval job retries (#36698) 2025-11-05 09:59:00 +01:00
Rachael Wright-Munn
b5a6feb3bf Move "Privacy and reach" from "Public profile" to top-level navigation (#27294) 2025-11-05 09:59:00 +01:00
Claire
05964f571b Prevent creation of Private Mentions quoting someone who is not mentioned (#36689) 2025-11-05 09:59:00 +01:00
Claire
16a54f7158 Fix issuance of quote approval for remote private statuses (#36693) 2025-11-05 09:59:00 +01:00
Claire
6d53ca63d6 Disable paste-link-to-quote flow when composing Private Mentions (#36690) 2025-11-05 09:59:00 +01:00
renovate[bot]
93acfdd7d3 chore(deps): update dependency irb to v1.15.3 (#36682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:59:00 +01:00
renovate[bot]
a209b8e544 chore(deps): update dependency rubyzip to v3.2.2 (#36687)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:59:00 +01:00
Claire
ca0c5e7a79 Merge pull request #3258 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to af4c372ab2 into stable-4.5
2025-10-31 16:35:41 +01:00
diondiondion
10a81a2f43 [Glitch] Fix initially selected language in Rules panel, hide selector when no alternative translations exist
Port aa579ce286 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-31 16:12:38 +01:00
diondiondion
9f8fce3c47 [Glitch] Show error when submitting empty post rather than failing silently
Port 214d59bd37 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-31 16:12:03 +01:00
Claire
5f5e6ca031 Merge commit 'af4c372ab22b636742833592235d18418604fcc1' into glitch-soc/merge-4.5 2025-10-31 16:04:05 +01:00
Claire
af4c372ab2 Bump version to v4.5.0-rc.2 2025-10-31 16:01:06 +01:00
diondiondion
aa579ce286 Fix initially selected language in Rules panel, hide selector when no alternative translations exist (#36672) 2025-10-31 16:01:06 +01:00
github-actions[bot]
adfabf8c80 New Crowdin Translations for stable-4.5 (automated) (#36670)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-10-31 14:51:18 +01:00
renovate[bot]
ea710df180 chore(deps): update dependency axios to v1.13.1 (#36633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-31 14:09:36 +01:00
renovate[bot]
e1b6e28829 chore(deps): update dependency libvips to v8.17.3 (#36654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-31 14:09:36 +01:00
diondiondion
214d59bd37 Show error when submitting empty post rather than failing silently (#36650) 2025-10-31 14:09:36 +01:00
Claire
e4291e9b05 Fix SMTP configuration with mail 2.9.0 (#36646) 2025-10-31 14:09:36 +01:00
Claire
868d782b2b Merge pull request #3257 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 2a9c7d2b9e
2025-10-29 14:07:12 +01:00
Claire
1b60f597d7 Merge commit '2a9c7d2b9e51cdfbc636972c0f9ffdbe06c02d59' into glitch-soc/merge-upstream 2025-10-29 13:42:26 +01:00
Claire
2a9c7d2b9e Fix quote-inline fallback being removed even for legacy quotes (#36638) 2025-10-29 11:56:34 +00:00
Claire
9db64d6908 Merge pull request #3256 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 51877081b4
2025-10-29 12:45:31 +01:00
Claire
074b3fe57e [Glitch] Change display of blocked and muted quoted users
Port e437bb919f to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-29 12:25:47 +01:00
Claire
002e592667 Merge commit '51877081b435b38e1c5bd449087279469fa7c667' into glitch-soc/merge-upstream 2025-10-29 12:24:49 +01:00
Claire
51877081b4 Bump version to v4.5.0-rc.1 (#36635) 2025-10-29 11:11:33 +00:00
github-actions[bot]
7b66eefd3e New Crowdin Translations (automated) (#36632)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-10-29 10:13:42 +00:00
Claire
e437bb919f Change display of blocked and muted quoted users (#36619) 2025-10-29 09:13:12 +00:00
Claire
8f00874a0e Merge pull request #3255 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 4896d2c4c6
2025-10-28 23:20:31 +01:00
Claire
ac920eb364 Fix javascript linting error 2025-10-28 23:04:36 +01:00
Claire
d04e6ec597 Remove glitch-soc system emoji font option now that it's superseded by upstream 2025-10-28 22:57:31 +01:00
diondiondion
24234e6632 [Glitch] chore(deps): update dependency eslint-plugin-jsdoc to v60
Port e1bd9b944a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:46:27 +01:00
Claire
8cd8e69c4b [Glitch] Change firehose labels depending on which feeds are accessible
Port 4896d2c4c6 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:38:23 +01:00
Claire
293b8f6744 [Glitch] Change styling of column banners
Port 26ec19a649 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:36:11 +01:00
Claire
48f2597a36 [Glitch] Hashtag fixes
Port b01d21c4d4 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:35:41 +01:00
Echo
12c487cc3e [Glitch] Fix props in DisplayName component
Port 9c7d09993d to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:34:56 +01:00
Echo
adfa407f6b [Glitch] Emoji: Remove final flag
Port 85d0cdb5f7 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:34:14 +01:00
Claire
c43a5a1834 [Glitch] Fix mention matching ignoring path
Port 3ccb6632f2 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:20:52 +01:00
Claire
52e2d24a4b [Glitch] Fix URL comparison for mentions in case of empty path
Port 3bf99b8a4a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:20:27 +01:00
Echo
f94353e1e3 [Glitch] Emoji: Fix Web Worker import
Port d0d09fd3a5 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:19:21 +01:00
Eugen Rochko
0565eb62d6 [Glitch] Fix hashtags not being picked up when full-width hash sign is used
Port 779a1f8448 to glitch-soc

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:19:01 +01:00
Claire
48ec31bec8 Merge commit '4896d2c4c6d3bd6b878c5a075b6611c65d4203b2' into glitch-soc/merge-upstream
Conflicts:
- `app/views/settings/preferences/appearance/show.html.haml`:
  Upstream changed stuff too close to glitch-soc's theming system changes.
  Applied upstream's changes.
- `streaming/index.js`:
  Upstream refactored a bunch of stuff where our code was different due to
  local-only posts.
  Applied upstream's changes while taking care of local-only posts.
2025-10-28 22:10:12 +01:00
Claire
3bd56b92c1 Reimplement misleading link tagging in new HTML handling code (#3254) 2025-10-28 21:59:53 +01:00
Claire
70b8281730 Reimplement mention rewriting in new HTML handling code (#3247) 2025-10-28 21:22:36 +01:00
Claire
fb9e33099f Merge pull request #3253 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 1dead10312
2025-10-28 20:54:49 +01:00
Claire
79169408b0 Fix tests on glitch-soc 2025-10-28 20:39:10 +01:00
Renaud Chaput
5a051d07c6 [Glitch] Add a new setting to choose the server landing page
Port 779a1f8448 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 20:26:40 +01:00
Claire
5f3f75559f Merge commit '1dead10312caa0cc7719cb80052af549ddf3e6a1' into glitch-soc/merge-upstream 2025-10-28 20:25:09 +01:00
Claire
4896d2c4c6 Change firehose labels depending on which feeds are accessible (#36607) 2025-10-28 16:59:37 +00:00
Renaud Chaput
795aaa14bf Remove environment variables to config Fetch All Replies behaviour (#36627) 2025-10-28 15:58:18 +00:00
diondiondion
e1bd9b944a chore(deps): update dependency eslint-plugin-jsdoc to v60 (#36466) 2025-10-28 15:17:33 +00:00
Claire
26ec19a649 Change styling of column banners (#36531) 2025-10-28 14:45:46 +00:00
Claire
b01d21c4d4 Hashtag fixes (#36625) 2025-10-28 14:26:08 +00:00
Claire
3ccb6632f2 Fix mention matching ignoring path (#36626) 2025-10-28 14:05:39 +00:00
Claire
8fb524e07f Add support for Update of converted object types (#36322) 2025-10-28 14:05:14 +00:00
Echo
9c7d09993d Fix props in DisplayName component (#36622) 2025-10-28 14:02:37 +00:00
renovate[bot]
3efc747be3 Update dependency axios to v1.13.0 (#36612)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 13:26:05 +00:00
renovate[bot]
1f5cdb30c7 Update dependency @vitejs/plugin-react to v5.1.0 (#36600)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 13:23:38 +00:00
renovate[bot]
3cace4098a Update dependency devise-two-factor to v6.2.0 (#36574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 13:23:32 +00:00
Claire
ccfac2716d Add streaming server side filtering for live/topic feed settings (#36585) 2025-10-28 13:23:05 +00:00
diondiondion
422fa1cf9f Revert "Fix custom emoji width (#27969)" (#36620) 2025-10-28 12:36:22 +00:00
renovate[bot]
2b5f6838ed Update dependency annotaterb to v4.20.0 (#36527)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 11:59:21 +00:00
Echo
85d0cdb5f7 Emoji: Remove final flag (#36409) 2025-10-28 11:33:27 +00:00
renovate[bot]
e4fc18abfd Update dependency simple_form to v5.4.0 (#36604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 11:29:07 +00:00
renovate[bot]
e322c1777b Update dependency webmock to v3.26.0 (#36605)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 11:27:54 +00:00
Nicholas La Roux
f53c4db05c [Vite] Remove overridden build.target in favor of legacy plugin defaults (#36611) 2025-10-28 11:22:41 +00:00
renovate[bot]
4905c194b8 Update dependency mail to v2.9.0 (#36575)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 08:32:36 +00:00
renovate[bot]
7ba06a661c Update dependency @reduxjs/toolkit to v2.9.2 (#36572)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 08:32:29 +00:00
github-actions[bot]
5d00ae7eb3 New Crowdin Translations (automated) (#36617)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-10-28 08:29:36 +00:00
Claire
4b42fe6aba Change API behavior of reblogs wrt. quotes for consistency (#36559) 2025-10-28 08:05:23 +00:00
Claire
3bf99b8a4a Fix URL comparison for mentions in case of empty path (#36613) 2025-10-27 18:19:52 +00:00
Echo
d0d09fd3a5 Emoji: Fix Web Worker import (#36603) 2025-10-27 17:36:01 +00:00
Eugen Rochko
76053fb4a9 Fix hashtags not being picked up when full-width hash sign is used (#36103)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2025-10-27 17:18:01 +00:00
David Roetzel
402686c76c Remove http_message_signatures feature flag (#36610) 2025-10-27 16:06:44 +00:00
marousta
dc851c9efc Fix custom emoji width (#27969) 2025-10-27 15:56:06 +00:00
Eugen Rochko
1dead10312 Change min. characters required for logged-out account search from 5 to 3 (#36487) 2025-10-27 15:52:21 +00:00
M.J. Fieggen (Joni)
e8382c7332 Fix layout of severed relationships when purged events are listed (#36593) 2025-10-27 15:19:38 +00:00
Eugen Rochko
bfcf21e915 Fix vacuums being interrupted by a single batch failure (#36606) 2025-10-27 14:22:54 +00:00
Matt Jankowski
b60bae6361 Handle unreachable network error for search services (#36587) 2025-10-27 13:28:56 +00:00
Claire
38f15a89fe Fix recent settings migrations (#36602) 2025-10-27 12:24:24 +00:00
renovate[bot]
ab5b7e3776 Update dependency webauthn to v3.4.3 (#36599)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 10:21:12 +00:00
renovate[bot]
1230d05b18 Update dependency rubyzip to v3.2.1 (#36598)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 10:21:08 +00:00
Renaud Chaput
779a1f8448 Add a new setting to choose the server landing page (#36588) 2025-10-27 10:16:59 +00:00
github-actions[bot]
e40ca321ed New Crowdin Translations (automated) (#36590)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-10-27 09:45:37 +00:00
renovate[bot]
5f837001e6 Update opentelemetry-ruby (non-major) (#36557)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 09:40:13 +00:00
Matt Jankowski
2640cf5317 Update stoplight to version 5.4.0 (#36581) 2025-10-27 09:38:01 +00:00
Claire
7f19b5ca2b Merge pull request #3252 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 1ba579b0a1
2025-10-24 11:27:13 +02:00
diondiondion
305f1e5757 [Glitch] Fix "new post highlighting" in threads being applied when navigating between posts
Port 1ba579b0a1 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-23 18:45:53 +02:00
Claire
b11bd2bdbb [Glitch] Add UI support for disabled live feeds
Port 2fa5dd6d1f to glitch-soc

Co-authored-by: diondiondion <mail@diondiondion.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-23 18:45:27 +02:00
Claire
deed31ba8c Merge commit '1ba579b0a181fbfff514ef32b50179d2ab1fc342' into glitch-soc/merge-upstream
Conflicts:
- `app/models/public_feed.rb`:
  Minor conflict due to glitch-soc's local-only posts.
  Adopted upstream's changes.
- `spec/models/tag_feed_spec.rb`:
  Minor conflict due to glitch-soc's local-only posts.
  Adopted upstream's changes.
2025-10-23 18:33:52 +02:00
diondiondion
1ba579b0a1 Fix "new post highlighting" in threads being applied when navigating between posts (#36583) 2025-10-23 15:52:07 +00:00
Claire
6b2051b7b3 Fix bookmarks export when one bookmarked status is soft-deleted (#36576) 2025-10-23 11:51:23 +00:00
Claire
2fa5dd6d1f Add UI support for disabled live feeds (#36577)
Co-authored-by: diondiondion <mail@diondiondion.com>
2025-10-23 09:59:43 +00:00
github-actions[bot]
f7b99cd48a New Crowdin Translations (automated) (#36569)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-10-23 09:29:02 +00:00
renovate[bot]
92aeecfbdc Update dependency vite to v7.1.12 (#36573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 08:41:34 +00:00
Claire
7774cd6670 Add disabled setting for live and topic feeds, as well as user permission to bypass that (#36563) 2025-10-23 08:37:05 +00:00
Claire
c6e2ac5af9 Merge pull request #3251 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 9f7075a0ce
2025-10-23 10:36:11 +02:00
Claire
ee87afd6a4 [Glitch] Remove unnecessary restrictions on HTML handling
Port 9f7075a0ce to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-22 18:08:21 +02:00
diondiondion
2d8b7a7fd8 [Glitch] Fix text overflow alignment for long author names in News
Port 7538bc77b7 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-22 18:08:00 +02:00
diondiondion
cbc07af929 [Glitch] Refresh thread replies periodically & when refocusing window
Port 7ea2af6ae2 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-22 18:07:34 +02:00
Claire
ee9a15031b Merge commit '9f7075a0ce2b6ecef8d92ef318785fa8ce708688' into glitch-soc/merge-upstream 2025-10-22 18:06:32 +02:00
Claire
9f7075a0ce Remove unnecessary restrictions on HTML handling (#36548) 2025-10-22 13:55:41 +00:00
renovate[bot]
c40648f7b3 Update dependency pino to v9.14.0 (#36529)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-22 13:53:20 +00:00
renovate[bot]
2bd5c2f528 Update dependency ioredis to v5.8.2 (#36544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-22 13:52:59 +00:00
renovate[bot]
1e28ec628b Update dependency rubocop to v1.81.6 (#36541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-22 13:52:43 +00:00
diondiondion
7538bc77b7 Fix text overflow alignment for long author names in News (#36562) 2025-10-22 13:08:51 +00:00
diondiondion
7ea2af6ae2 Refresh thread replies periodically & when refocusing window (#36547) 2025-10-22 09:43:03 +00:00
belatedly
6adbd9ce52 Fix discovery preamble missing word in EN and EN-GB locales (#36560) 2025-10-22 09:18:02 +00:00
github-actions[bot]
08ae77fd9c New Crowdin Translations (automated) (#36556)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-10-22 08:24:40 +00:00
Claire
a0aa5fe8ea Merge pull request #3250 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 17eb1a7e66
2025-10-21 21:36:50 +02:00
Claire
209434cb1d Merge commit '17eb1a7e668dbba6e79612395b99407e8e8de6b9' into glitch-soc/merge-upstream 2025-10-21 18:15:56 +02:00
Claire
17eb1a7e66 Fix scheduled quote posts being posted as non-quote posts (#36550) 2025-10-21 16:00:40 +00:00
Claire
aba30a85be Fix value of quote_approval_policy and quoted_status_id in ScheduledStatus serializer (#36549) 2025-10-21 16:00:30 +00:00
Renaud Chaput
de80a54555 Update recommended Node version to 24 (LTS) (#36539) 2025-10-21 14:26:24 +00:00
Renaud Chaput
b80ec3721d Drop support for PostgreSQL 13 (#36540) 2025-10-21 14:26:00 +00:00
526 changed files with 10780 additions and 5802 deletions

View File

@@ -318,21 +318,3 @@ MAX_POLL_OPTION_CHARS=100
# -----------------------
IP_RETENTION_PERIOD=31556952
SESSION_RETENTION_PERIOD=31556952
# Fetch All Replies Behavior
# --------------------------
# 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

View File

@@ -20,7 +20,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=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
tags: |
type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}}
@@ -37,7 +37,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=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
tags: |
type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}}

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@
/public/packs
/public/packs-dev
/public/packs-test
stats.html
.env
.env.production
node_modules/

2
.nvmrc
View File

@@ -1 +1 @@
22.20
24.10

View File

@@ -2,45 +2,117 @@
All notable changes to this project will be documented in this file.
## [4.5.0] - UNRELEASED
## [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 and #36528 by @ChaosExAnima, @ClearlyClaire, @Lycolia, @diondiondion, and @tribela)\
- **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 and #36481 by @ClearlyClaire, @Gargron, and @diondiondion)
- **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 “Posting defaults” setting page, moving existing settings from “Other” (#35896, #36033, #35966, #35969, and #36084 by @ClearlyClaire and @diondiondion)
- 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)
- Added emoji from Twemoji v16 (#36501 and #36530 by @ChaosExAnima)
- Add experimental 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 and #36532 by @ChaosExAnima and @braddunbar)\
This also completely reworks the processing and rendering of emojis and server-rendered HTML in statuses and other places.
### 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 `timeline_preview` setting into four more granular settings (#36338, #36467 and #36497 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)
@@ -48,7 +120,7 @@ All notable changes to this project will be documented in this file.
- 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 @ClearlyClaire and @Gargron)
- 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)
@@ -59,6 +131,16 @@ All notable changes to this project will be documented in this file.
- 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)
@@ -81,6 +163,10 @@ All notable changes to this project will be documented in this file.
- 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

View File

@@ -14,9 +14,9 @@ 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.7"
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"]
# renovate: datasource=node-version depName=node
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

26
Gemfile
View File

@@ -106,19 +106,19 @@ gem 'opentelemetry-api', '~> 1.7.0'
group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.31.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.9.0', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-http', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.31.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.38.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.27.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

View File

@@ -90,7 +90,7 @@ 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)
@@ -116,7 +116,7 @@ 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)
@@ -128,7 +128,7 @@ GEM
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)
@@ -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)
@@ -224,7 +224,7 @@ 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.4.0)
tzinfo
@@ -337,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)
@@ -426,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
@@ -442,7 +443,7 @@ GEM
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)
@@ -498,74 +499,74 @@ GEM
tzinfo
validate_url
webfinger (~> 2.0)
openssl (3.3.1)
openssl (3.3.2)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.7.0)
opentelemetry-common (0.23.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.31.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.2.0)
opentelemetry-api (~> 1.7)
opentelemetry-helpers-sql-obfuscation (0.3.0)
opentelemetry-helpers-sql-obfuscation (0.4.0)
opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.5.0)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-action_pack (0.14.1)
opentelemetry-instrumentation-rack (~> 0.21)
opentelemetry-instrumentation-action_view (0.10.0)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-active_job (0.9.2)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-active_model_serializers (0.23.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-active_record (0.10.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-active_storage (0.2.0)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-active_support (0.9.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-base (0.24.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.23.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-excon (0.25.2)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-faraday (0.29.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-http (0.26.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-http_client (0.25.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-net_http (0.25.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-pg (0.31.1)
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.24)
opentelemetry-instrumentation-rack (0.28.2)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-rails (0.38.0)
opentelemetry-instrumentation-action_mailer (~> 0.4)
opentelemetry-instrumentation-action_pack (~> 0.13)
opentelemetry-instrumentation-action_view (~> 0.9)
opentelemetry-instrumentation-active_job (~> 0.8)
opentelemetry-instrumentation-active_record (~> 0.9)
opentelemetry-instrumentation-active_storage (~> 0.1)
opentelemetry-instrumentation-active_support (~> 0.8)
opentelemetry-instrumentation-concurrent_ruby (~> 0.22)
opentelemetry-instrumentation-redis (0.27.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-sidekiq (0.27.1)
opentelemetry-instrumentation-base (~> 0.24)
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.10.0)
@@ -620,7 +621,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.3)
rack (3.2.4)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-cors (3.0.0)
@@ -690,7 +691,7 @@ GEM
readline (~> 0.0)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (6.15.0)
rdoc (6.15.1)
erb
psych (>= 4.0.0)
tsort
@@ -705,9 +706,9 @@ GEM
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.1)
@@ -744,7 +745,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.6)
rubocop (1.81.1)
rubocop (1.81.6)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -790,7 +791,7 @@ GEM
ruby-vips (2.2.5)
ffi (~> 1.12)
logger
rubyzip (3.2.0)
rubyzip (3.2.2)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
safety_net_attestation (0.5.0)
@@ -804,7 +805,7 @@ GEM
securerandom (0.4.1)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (8.0.8)
sidekiq (8.0.9)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
@@ -821,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)
@@ -834,7 +835,7 @@ 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.1)
@@ -898,7 +899,7 @@ GEM
zeitwerk (~> 2.2)
warden (1.2.9)
rack (>= 2.0.9)
webauthn (3.4.2)
webauthn (3.4.3)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
cbor (~> 0.5.9)
@@ -910,7 +911,7 @@ GEM
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)
@@ -1009,19 +1010,19 @@ DEPENDENCIES
omniauth_openid_connect (~> 0.8.0)
opentelemetry-api (~> 1.7.0)
opentelemetry-exporter-otlp (~> 0.31.0)
opentelemetry-instrumentation-active_job (~> 0.9.0)
opentelemetry-instrumentation-active_model_serializers (~> 0.23.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.23.0)
opentelemetry-instrumentation-excon (~> 0.25.0)
opentelemetry-instrumentation-faraday (~> 0.29.0)
opentelemetry-instrumentation-http (~> 0.26.0)
opentelemetry-instrumentation-http_client (~> 0.25.0)
opentelemetry-instrumentation-net_http (~> 0.25.0)
opentelemetry-instrumentation-pg (~> 0.31.0)
opentelemetry-instrumentation-rack (~> 0.28.0)
opentelemetry-instrumentation-rails (~> 0.38.0)
opentelemetry-instrumentation-redis (~> 0.27.0)
opentelemetry-instrumentation-sidekiq (~> 0.27.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

View File

@@ -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+

View File

@@ -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 |

View File

@@ -22,7 +22,7 @@ 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

View File

@@ -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

View File

@@ -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

View File

@@ -22,7 +22,7 @@ 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,7 +159,7 @@ class Api::V1::StatusesController < Api::BaseController
end
def set_quoted_status
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
@quoted_status = Status.find(status_params[:quoted_status_id])&.proper if status_params[:quoted_status_id].present?
authorize(@quoted_status, :quote?) if @quoted_status.present?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
# TODO: distinguish between non-existing and non-quotable posts

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -70,7 +70,7 @@ function loaded() {
};
document.querySelectorAll('.emojify').forEach((content) => {
content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system.
content.innerHTML = emojify(content.innerHTML);
});
document

View File

@@ -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,9 +198,14 @@ 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) {
@@ -210,7 +216,15 @@ export function submitCompose(overridePrivacy = null, successCallback = undefine
const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
const spoiler_text = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
if (!(status?.length || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
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());
return;
}
@@ -653,6 +667,7 @@ export function fetchComposeSuggestions(token) {
fetchComposeSuggestionsEmojis(dispatch, getState, token);
break;
case '#':
case '':
fetchComposeSuggestionsTags(dispatch, getState, token);
break;
default:
@@ -694,11 +709,11 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
dispatch(useEmoji(suggestion));
} else if (suggestion.type === 'hashtag') {
completion = `#${suggestion.name}`;
completion = token + suggestion.name.slice(token.length - 1);
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
@@ -809,13 +824,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,

View File

@@ -13,10 +13,11 @@ 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';
@@ -41,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 & {
@@ -67,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 (
@@ -130,6 +168,8 @@ export const quoteComposeByStatus = createAppThunk(
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 (
@@ -173,6 +213,17 @@ 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 }) => {
@@ -183,15 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk(
limit: 2,
});
},
(data, { dispatch, getState }) => {
(data, { dispatch, getState, requestId }) => {
const composeState = getState().compose;
if (
composeState.get('quoted_status_id') ||
composeState.get('is_submitting') ||
composeState.get('poll') ||
composeState.get('is_uploading') ||
composeState.get('id')
composeStateForbidsLink(composeState) ||
composeState.get('fetching_link') !== requestId // Request has been cancelled
)
return;
@@ -207,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk(
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');

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -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)
});

View File

@@ -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,

View File

@@ -1,11 +1,6 @@
import { useCallback } from 'react';
import classNames from 'classnames';
import { useLinks } from 'flavours/glitch/hooks/useLinks';
import { useAppSelector } from '../store';
import { isModernEmojiEnabled } from '../utils/environment';
import { EmojiHTML } from './emoji/html';
import { useElementHandledLink } from './status/handled_link';
@@ -21,22 +16,6 @@ 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 ||
isModernEmojiEnabled()
) {
return;
}
addDropdownToHashtags(node, accountId);
},
[showDropdown, accountId],
);
const htmlHandlers = useElementHandledLink({
hashtagAccountId: showDropdown ? accountId : undefined,
});
@@ -62,30 +41,7 @@ export const AccountBio: React.FC<AccountBioProps> = ({
htmlString={note}
extraEmojis={extraEmojis}
className={classNames(className, 'translate')}
onClickCapture={handleClick}
ref={handleNodeChange}
{...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);
}
}
}

View File

@@ -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 = {

View File

@@ -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];

View File

@@ -9,9 +9,8 @@ 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 (
<AnimateEmojiProvider
{...props}

View File

@@ -5,9 +5,8 @@ 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;
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -7,8 +7,6 @@ import {
useState,
} from 'react';
import classNames from 'classnames';
import { cleanExtraEmojis } from '@/flavours/glitch/features/emoji/normalize';
import { autoPlayGif } from '@/flavours/glitch/initial_state';
import { polymorphicForwardRef } from '@/types/polymorphic';
@@ -65,11 +63,7 @@ export const AnimateEmojiProvider = polymorphicForwardRef<
const parentContext = useContext(AnimateEmojiContext);
if (parentContext !== null) {
return (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
ref={ref}
>
<Wrapper {...props} className={className} ref={ref}>
{children}
</Wrapper>
);
@@ -78,7 +72,7 @@ export const AnimateEmojiProvider = polymorphicForwardRef<
return (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
className={className}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
ref={ref}

View File

@@ -1,9 +1,6 @@
import { useMemo } from 'react';
import classNames from 'classnames';
import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import type {
OnAttributeHandler,
OnElementHandler,
@@ -22,7 +19,7 @@ export interface EmojiHTMLProps {
onAttribute?: OnAttributeHandler;
}
export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
(
{
extraEmojis,
@@ -59,32 +56,4 @@ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
);
},
);
ModernEmojiHTML.displayName = 'ModernEmojiHTML';
export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
(props, ref) => {
const {
as: asElement,
htmlString,
extraEmojis,
className,
onElement,
onAttribute,
...rest
} = props;
const Wrapper = asElement ?? 'div';
return (
<Wrapper
{...rest}
ref={ref}
dangerouslySetInnerHTML={{ __html: htmlString }}
className={classNames(className, 'animate-parent')}
/>
);
},
);
LegacyEmojiHTML.displayName = 'LegacyEmojiHTML';
export const EmojiHTML = isModernEmojiEnabled()
? ModernEmojiHTML
: LegacyEmojiHTML;
EmojiHTML.displayName = 'EmojiHTML';

View File

@@ -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 });
}
},
);

View File

@@ -23,8 +23,6 @@ import { domain } from 'flavours/glitch/initial_state';
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { useLinks } from '../hooks/useLinks';
export const HoverCardAccount = forwardRef<
HTMLDivElement,
{ accountId?: string }
@@ -66,8 +64,6 @@ export const HoverCardAccount = forwardRef<
!isMutual &&
!isFollower;
const handleClick = useLinks();
return (
<div
ref={ref}
@@ -114,7 +110,7 @@ export const HoverCardAccount = forwardRef<
className='hover-card__bio'
/>
<div className='account-fields' onClickCapture={handleClick}>
<div className='account-fields'>
<AccountFields
fields={account.fields.take(2)}
emojis={account.emojis}

View File

@@ -4,7 +4,7 @@ import type { OnElementHandler } from '@/flavours/glitch/utils/html';
import { polymorphicForwardRef } from '@/types/polymorphic';
import type { EmojiHTMLProps } from '../emoji/html';
import { ModernEmojiHTML } from '../emoji/html';
import { EmojiHTML } from '../emoji/html';
import { useElementHandledLink } from '../status/handled_link';
export const HTMLBlock = polymorphicForwardRef<
@@ -25,6 +25,6 @@ export const HTMLBlock = polymorphicForwardRef<
(...args) => onParentElement?.(...args) ?? onLinkElement(...args),
[onLinkElement, onParentElement],
);
return <ModernEmojiHTML {...props} onElement={onElement} />;
return <EmojiHTML {...props} onElement={onElement} />;
},
);

View File

@@ -13,9 +13,7 @@ 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';
@@ -235,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(() => {

View File

@@ -750,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}
/>

View File

@@ -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 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';
@@ -74,18 +75,12 @@ const StandaloneBoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
);
};
const renderMenuItem: RenderItemFn<ActionMenuItem> = (
item,
index,
handlers,
focusRefCallback,
) => (
const renderMenuItem: RenderItemFn<ActionMenuItem> = (item, index, onClick) => (
<ReblogMenuItem
index={index}
item={item}
handlers={handlers}
onClick={onClick}
key={`${item.text}-${index}`}
focusRefCallback={focusRefCallback}
/>
);
@@ -117,6 +112,7 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
const statusId = status.get('id') as string;
const wasBoosted = !!status.get('reblogged');
const quoteApproval = status.get('quote_approval');
const showLoginPrompt = useCallback(() => {
dispatch(
@@ -173,9 +169,16 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
dispatch(toggleReblog(status.get('id'), true));
return false;
}
if (quoteApproval === null) {
dispatch(
fetchStatus(statusId, { forceFetch: true, alsoFetchContext: false }),
);
}
return true;
},
[dispatch, isLoggedIn, showLoginPrompt, status],
[dispatch, isLoggedIn, showLoginPrompt, status, quoteApproval, statusId],
);
return (
@@ -208,16 +211,10 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
interface ReblogMenuItemProps {
item: ActionMenuItem;
index: number;
handlers: RenderItemFnHandlers;
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
onClick: React.MouseEventHandler;
}
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
index,
item,
handlers,
focusRefCallback,
}) => {
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({ index, item, onClick }) => {
const { text, highlighted, disabled } = item;
return (
@@ -227,12 +224,7 @@ 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>

View File

@@ -21,9 +21,13 @@ const meta = {
render({ mentionAccount, hashtagAccount, ...args }) {
let mention: HandledLinkProps['mention'] | undefined;
if (mentionAccount === 'local') {
mention = { id: '1', acct: 'testuser' };
mention = { id: '1', acct: 'testuser', username: 'testuser' };
} else if (mentionAccount === 'remote') {
mention = { id: '2', acct: 'remoteuser@mastodon.social' };
mention = {
id: '2',
acct: 'remoteuser@mastodon.social',
username: 'remoteuser',
};
}
return (
<>

View File

@@ -1,20 +1,110 @@
import { useCallback } from 'react';
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'>;
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,
@@ -25,20 +115,60 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
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('#')) {
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/${hashtag}`}
to={`/tags/${encodeURIComponent(hashtag)}`}
rel='tag'
data-menu-hashtag={hashtagAccountId}
>
{children}
</Link>
);
} else if ((text.startsWith('@') || prevText?.endsWith('@')) && mention) {
} 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
@@ -68,8 +198,9 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
title={href}
className={classNames('unhandled-link', className)}
target='_blank'
rel='noreferrer noopener'
rel='noopener'
translate='no'
ref={linkRef}
>
{children}
</a>

View File

@@ -374,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}

View File

@@ -49,6 +49,7 @@ export const StatusBanner: React.FC<{
<button
ref={buttonRef}
type='button'
className='link-button'
onClick={onClick}
aria-describedby={descriptionId}

View File

@@ -14,70 +14,12 @@ import { Icon } from 'flavours/glitch/components/icon';
import { Poll } from 'flavours/glitch/components/poll';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
import { isModernEmojiEnabled } from '../utils/environment';
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
@@ -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,14 +98,8 @@ 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;
@@ -172,74 +117,6 @@ class StatusContent extends PureComponent {
onCollapsedToggle(collapsed);
}
// Exit if modern emoji is enabled, as it handles links using the HandledLink component.
if (isModernEmojiEnabled()) {
return;
}
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');
}
}
}
}
componentDidMount () {
@@ -250,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];
};
@@ -303,7 +164,7 @@ class StatusContent extends PureComponent {
handleElement = (element, { key, ...props }, children) => {
if (element instanceof HTMLAnchorElement) {
const mention = this.props.status.get('mentions').find(item => element.href === item.get('url'));
const mention = this.props.status.get('mentions').find(item => compareUrls(element.href, item.get('url')));
return (
<HandledLink
{...props}
@@ -316,7 +177,7 @@ class StatusContent extends PureComponent {
{children}
</HandledLink>
);
} else if (element instanceof HTMLParagraphElement && element.classList.contains('quote-inline')) {
} else if (element.classList.contains('quote-inline')) {
return null;
}
return undefined;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
@@ -83,6 +83,62 @@ const LimitedAccountHint: React.FC<{ accountId: string }> = ({ accountId }) => {
);
};
const FilteredQuote: React.FC<{
reveal: VoidFunction;
quotedAccountId: string;
quoteState: string;
}> = ({ reveal, quotedAccountId, quoteState }) => {
const account = useAppSelector((state) =>
quotedAccountId ? state.accounts.get(quotedAccountId) : undefined,
);
const quoteAuthorName = account?.acct;
const domain = quoteAuthorName?.split('@')[1];
let message;
switch (quoteState) {
case 'blocked_account':
message = (
<FormattedMessage
id='status.quote_error.blocked_account_hint.title'
defaultMessage="This post is hidden because you've blocked @{name}."
values={{ name: quoteAuthorName }}
/>
);
break;
case 'blocked_domain':
message = (
<FormattedMessage
id='status.quote_error.blocked_domain_hint.title'
defaultMessage="This post is hidden because you've blocked {domain}."
values={{ domain }}
/>
);
break;
case 'muted_account':
message = (
<FormattedMessage
id='status.quote_error.muted_account_hint.title'
defaultMessage="This post is hidden because you've muted @{name}."
values={{ name: quoteAuthorName }}
/>
);
}
return (
<>
{message}
<button onClick={reveal} className='link-button'>
<FormattedMessage
id='status.quote_error.limited_account_hint.action'
defaultMessage='Show anyway'
/>
</button>
</>
);
};
interface QuotedStatusProps {
quote: QuoteMap;
contextType?: string;
@@ -130,6 +186,11 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
const isLoaded = loadingState === 'complete';
const isFetchingQuoteRef = useRef(false);
const [revealed, setRevealed] = useState(false);
const reveal = useCallback(() => {
setRevealed(true);
}, [setRevealed]);
useEffect(() => {
if (isLoaded) {
@@ -189,6 +250,20 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
defaultMessage='Post removed by author'
/>
);
} else if (
(quoteState === 'blocked_account' ||
quoteState === 'blocked_domain' ||
quoteState === 'muted_account') &&
!revealed &&
accountId
) {
quoteError = (
<FilteredQuote
quoteState={quoteState}
reveal={reveal}
quotedAccountId={accountId}
/>
);
} else if (
!status ||
!quotedStatusId ||

View File

@@ -1,30 +1,10 @@
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { isModernEmojiEnabled } from '../utils/environment';
import type { OnAttributeHandler } from '../utils/html';
import { Icon } from './icon';
const domParser = new DOMParser();
const stripRelMe = (html: string) => {
if (isModernEmojiEnabled()) {
return html;
}
const document = domParser.parseFromString(html, 'text/html').documentElement;
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
link.rel = link.rel
.split(' ')
.filter((x: string) => x !== 'me')
.join(' ');
});
const body = document.querySelector('body');
return body?.innerHTML ?? '';
};
const onAttribute: OnAttributeHandler = (name, value, tagName) => {
if (name === 'rel' && tagName === 'a') {
if (value === 'me') {
@@ -47,10 +27,6 @@ interface Props {
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
<span className='verified-badge'>
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
<EmojiHTML
as='span'
htmlString={stripRelMe(link)}
onAttribute={onAttribute}
/>
<EmojiHTML as='span' htmlString={link} onAttribute={onAttribute} />
</span>
);

View File

@@ -70,7 +70,7 @@ function loaded() {
};
document.querySelectorAll('.emojify').forEach((content) => {
content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system.
content.innerHTML = emojify(content.innerHTML);
});
document

View File

@@ -32,16 +32,38 @@ interface Rule extends BaseRule {
translations?: Record<string, BaseRule>;
}
function getDefaultSelectedLocale(
currentUiLocale: string,
localeOptions: SelectItem[],
) {
const preciseMatch = localeOptions.find(
(option) => option.value === currentUiLocale,
);
if (preciseMatch) {
return preciseMatch.value;
}
const partialLocale = currentUiLocale.split('-')[0];
const partialMatch = localeOptions.find(
(option) => option.value.split('-')[0] === partialLocale,
);
return partialMatch?.value ?? 'default';
}
export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
const intl = useIntl();
const [locale, setLocale] = useState(intl.locale);
const rules = useAppSelector((state) => rulesSelector(state, locale));
const localeOptions = useAppSelector((state) =>
localeOptionsSelector(state, intl),
);
const [selectedLocale, setSelectedLocale] = useState(() =>
getDefaultSelectedLocale(intl.locale, localeOptions),
);
const rules = useAppSelector((state) => rulesSelector(state, selectedLocale));
const handleLocaleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
(e) => {
setLocale(e.currentTarget.value);
setSelectedLocale(e.currentTarget.value);
},
[],
);
@@ -74,25 +96,27 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
))}
</ol>
<div className='rules-languages'>
<label htmlFor='language-select'>
<FormattedMessage
id='about.language_label'
defaultMessage='Language'
/>
</label>
<select onChange={handleLocaleChange} id='language-select'>
{localeOptions.map((option) => (
<option
key={option.value}
value={option.value}
selected={option.value === locale}
>
{option.text}
</option>
))}
</select>
</div>
{localeOptions.length > 1 && (
<div className='rules-languages'>
<label htmlFor='language-select'>
<FormattedMessage
id='about.language_label'
defaultMessage='Language'
/>
</label>
<select onChange={handleLocaleChange} id='language-select'>
{localeOptions.map((option) => (
<option
key={option.value}
value={option.value}
selected={option.value === selectedLocale}
>
{option.text}
</option>
))}
</select>
</div>
)}
</Section>
);
};
@@ -145,9 +169,13 @@ const localeOptionsSelector = createSelector(
},
};
// Use the default locale as a target to translate language names.
const intlLocale = new Intl.DisplayNames(intl.locale, {
type: 'language',
});
const intlLocale =
// Intl.DisplayNames can be undefined in old browsers
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
Intl.DisplayNames &&
(new Intl.DisplayNames(intl.locale, {
type: 'language',
}) as Intl.DisplayNames | undefined);
for (const { translations } of rules) {
for (const locale in translations) {
if (langs[locale]) {
@@ -155,7 +183,7 @@ const localeOptionsSelector = createSelector(
}
langs[locale] = {
value: locale,
text: intlLocale.of(locale) ?? locale,
text: intlLocale?.of(locale) ?? locale,
};
}
}

View File

@@ -47,7 +47,6 @@ import { IconButton } from 'flavours/glitch/components/icon_button';
import { AccountNote } from 'flavours/glitch/features/account/components/account_note';
import { DomainPill } from 'flavours/glitch/features/account/components/domain_pill';
import FollowRequestNoteContainer from 'flavours/glitch/features/account/containers/follow_request_note_container';
import { useLinks } from 'flavours/glitch/hooks/useLinks';
import { useIdentity } from 'flavours/glitch/identity_context';
import {
autoPlayGif,
@@ -202,7 +201,6 @@ export const AccountHeader: React.FC<{
state.relationships.get(accountId),
);
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
const handleLinkClick = useLinks();
const handleBlock = useCallback(() => {
if (!account) {
@@ -856,10 +854,7 @@ export const AccountHeader: React.FC<{
{!(suspended || hidden) && (
<div className='account__header__extra'>
<div
className='account__header__bio'
onClickCapture={handleLinkClick}
>
<div className='account__header__bio'>
{account.id !== me && signedIn && (
<AccountNote accountId={accountId} />
)}

View File

@@ -330,7 +330,7 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
});
}, [dispatch, setIsSaving, mediaId, onClose, position, description]);
const handleKeyUp = useCallback(
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
@@ -457,7 +457,7 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
id='description'
value={isDetecting ? ' ' : description}
onChange={handleDescriptionChange}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
lang={lang}
placeholder={intl.formatMessage(
type === 'audio'

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback, useState, useId } from 'react';
import { useEffect, useRef, useCallback, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
@@ -22,6 +22,8 @@ import { useAudioVisualizer } from 'flavours/glitch/hooks/useAudioVisualizer';
import { displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
import { playerSettings } from 'flavours/glitch/settings';
import { AudioVisualizer } from './visualizer';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
@@ -116,7 +118,6 @@ export const Audio: React.FC<{
const seekRef = useRef<HTMLDivElement>(null);
const volumeRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
const accessibilityId = useId();
const { audioContextRef, sourceRef, gainNodeRef, playAudio, pauseAudio } =
useAudioContext({ audioElementRef: audioRef });
@@ -538,19 +539,6 @@ export const Audio: React.FC<{
[togglePlay, toggleMute],
);
const springForBand0 = useSpring({
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand1 = useSpring({
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand2 = useSpring({
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
config: config.wobbly,
});
const progress = Math.min((currentTime / loadedDuration) * 100, 100);
const effectivelyMuted = muted || volume === 0;
@@ -641,81 +629,7 @@ export const Audio: React.FC<{
</div>
<div className='audio-player__controls__play'>
<svg
className='audio-player__visualizer'
viewBox='0 0 124 124'
xmlns='http://www.w3.org/2000/svg'
>
<animated.circle
opacity={0.5}
cx={57}
cy={62.5}
r={springForBand0.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={65}
cy={57.5}
r={springForBand1.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={63}
cy={66.5}
r={springForBand2.r}
fill='var(--player-accent-color)'
/>
<g clipPath={`url(#${accessibilityId}-clip)`}>
<rect
x={14}
y={14}
width={96}
height={96}
fill={`url(#${accessibilityId}-pattern)`}
/>
<rect
x={14}
y={14}
width={96}
height={96}
fill='var(--player-background-color'
opacity={0.45}
/>
</g>
<defs>
<pattern
id={`${accessibilityId}-pattern`}
patternContentUnits='objectBoundingBox'
width='1'
height='1'
>
<use href={`#${accessibilityId}-image`} />
</pattern>
<clipPath id={`${accessibilityId}-clip`}>
<rect
x={14}
y={14}
width={96}
height={96}
rx={48}
fill='white'
/>
</clipPath>
<image
id={`${accessibilityId}-image`}
href={poster}
width={1}
height={1}
preserveAspectRatio='none'
/>
</defs>
</svg>
<AudioVisualizer frequencyBands={frequencyBands} poster={poster} />
<button
type='button'

View File

@@ -0,0 +1,100 @@
import { useId } from 'react';
import type { FC } from 'react';
import { animated, config, useSpring } from '@react-spring/web';
interface AudioVisualizerProps {
frequencyBands?: number[];
poster?: string;
}
export const AudioVisualizer: FC<AudioVisualizerProps> = ({
frequencyBands = [],
poster,
}) => {
const accessibilityId = useId();
const springForBand0 = useSpring({
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand1 = useSpring({
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand2 = useSpring({
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
config: config.wobbly,
});
return (
<svg
className='audio-player__visualizer'
viewBox='0 0 124 124'
xmlns='http://www.w3.org/2000/svg'
>
<animated.circle
opacity={0.5}
cx={57}
cy={62.5}
r={springForBand0.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={65}
cy={57.5}
r={springForBand1.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={63}
cy={66.5}
r={springForBand2.r}
fill='var(--player-accent-color)'
/>
<g clipPath={`url(#${accessibilityId}-clip)`}>
<rect
x={14}
y={14}
width={96}
height={96}
fill={`url(#${accessibilityId}-pattern)`}
/>
<rect
x={14}
y={14}
width={96}
height={96}
fill='var(--player-background-color'
opacity={0.45}
/>
</g>
<defs>
<pattern
id={`${accessibilityId}-pattern`}
patternContentUnits='objectBoundingBox'
width='1'
height='1'
>
<use href={`#${accessibilityId}-image`} />
</pattern>
<clipPath id={`${accessibilityId}-clip`}>
<rect x={14} y={14} width={96} height={96} rx={48} fill='white' />
</clipPath>
<image
id={`${accessibilityId}-image`}
href={poster}
width={1}
height={1}
preserveAspectRatio='none'
/>
</defs>
</svg>
);
};

View File

@@ -10,7 +10,8 @@ import { connect } from 'react-redux';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { domain } from 'flavours/glitch/initial_state';
import { domain, localLiveFeedAccess } from 'flavours/glitch/initial_state';
import { canViewFeed } from 'flavours/glitch/permissions';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { connectCommunityStream } from '../../actions/streaming';
@@ -123,8 +124,21 @@ class CommunityTimeline extends PureComponent {
render () {
const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
const { signedIn, permissions } = this.props.identity;
const pinned = !!columnId;
const emptyMessage = canViewFeed(signedIn, permissions, localLiveFeedAccess) ? (
<FormattedMessage
id='empty_column.community'
defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!'
/>
) : (
<FormattedMessage
id='empty_column.disabled_feed'
defaultMessage='This feed has been disabled by your server administrators.'
/>
);
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
@@ -147,7 +161,7 @@ class CommunityTimeline extends PureComponent {
scrollKey={`community_timeline-${columnId}`}
timelineId={`community${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
regex={this.props.regex}
/>

View File

@@ -110,10 +110,12 @@ class ComposeForm extends ImmutablePureComponent {
handleKeyDownPost = (e) => {
if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) {
this.handleSubmit(e);
e.preventDefault();
}
if (e.key.toLowerCase() === 'enter' && e.altKey) {
this.handleSecondarySubmit(e);
e.preventDefault();
}
this.blurOnEscape(e);
@@ -129,20 +131,19 @@ class ComposeForm extends ImmutablePureComponent {
e.preventDefault();
this.textareaRef.current?.focus();
}
}
}
this.blurOnEscape(e);
}
};
getFulltextForCharacterCounting = () => {
return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join('');
};
canSubmit = () => {
const { isSubmitting, isChangingUpload, isUploading, anyMedia, maxChars } = this.props;
const { isSubmitting, isChangingUpload, isUploading, maxChars } = this.props;
const fulltext = this.getFulltextForCharacterCounting();
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia));
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars);
};
handleSubmit = (e, overridePrivacy = null) => {
@@ -156,7 +157,11 @@ class ComposeForm extends ImmutablePureComponent {
return;
}
this.props.onSubmit(missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct', overridePrivacy);
this.props.onSubmit({
missingAltText: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct',
quoteToPrivate: this.props.quoteToPrivate,
overridePrivacy,
});
if (e) {
e.preventDefault();

View File

@@ -12,7 +12,6 @@ import Overlay from 'react-overlays/Overlay';
import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
@@ -99,12 +98,12 @@ class ModifierPickerMenu extends PureComponent {
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button type='button' onClick={this.handleClick} data-index={1}><Emoji emoji='fist' size={22} skin={1} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' size={22} skin={2} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' size={22} skin={3} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' size={22} skin={4} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' size={22} skin={5} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' size={22} skin={6} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={1}><Emoji emoji='fist' size={22} skin={1} /></button>
<button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' size={22} skin={2} /></button>
<button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' size={22} skin={3} /></button>
<button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' size={22} skin={4} /></button>
<button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' size={22} skin={5} /></button>
<button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' size={22} skin={6} /></button>
</div>
);
}
@@ -134,12 +133,12 @@ class ModifierPicker extends PureComponent {
this.props.onClose();
};
render () {
render() {
const { active, modifier } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='fist' size={22} skin={modifier} onClick={this.handleClick} native={useSystemEmojiFont} />
<Emoji emoji='fist' size={22} skin={modifier} onClick={this.handleClick} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div>
);
@@ -291,7 +290,6 @@ class EmojiPickerMenuImpl extends PureComponent {
notFound={notFoundFn}
autoFocus={this.state.readyToFocus}
emojiTooltip
native={useSystemEmojiFont}
/>
<ModifierPicker

View File

@@ -0,0 +1,48 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { cancelPasteLinkCompose } from '@/flavours/glitch/actions/compose_typed';
import { useAppDispatch } from '@/flavours/glitch/store';
import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { Skeleton } from 'flavours/glitch/components/skeleton';
const messages = defineMessages({
quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
});
export const QuotePlaceholder: FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleQuoteCancel = useCallback(() => {
dispatch(cancelPasteLinkCompose());
}, [dispatch]);
return (
<div className='status__quote'>
<div className='status'>
<div className='status__info'>
<div className='status__avatar'>
<Skeleton width='32px' height='32px' />
</div>
<div className='status__display-name'>
<DisplayName />
</div>
<IconButton
onClick={handleQuoteCancel}
className='status__quote-cancel'
title={intl.formatMessage(messages.quote_cancel)}
icon='cancel-fill'
iconComponent={CancelFillIcon}
/>
</div>
<div className='status__content'>
<Skeleton />
</div>
</div>
</div>
);
};

View File

@@ -7,11 +7,17 @@ import { quoteComposeCancel } from '@/flavours/glitch/actions/compose_typed';
import { QuotedStatus } from '@/flavours/glitch/components/status_quoted';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { QuotePlaceholder } from './quote_placeholder';
export const ComposeQuotedStatus: FC = () => {
const quotedStatusId = useAppSelector(
(state) => state.compose.get('quoted_status_id') as string | null,
);
const isFetchingLink = useAppSelector(
(state) => !!state.compose.get('fetching_link'),
);
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
const quote = useMemo(
@@ -30,7 +36,9 @@ export const ComposeQuotedStatus: FC = () => {
dispatch(quoteComposeCancel());
}, [dispatch]);
if (!quote) {
if (isFetchingLink && !quote) {
return <QuotePlaceholder />;
} else if (!quote) {
return null;
}

View File

@@ -10,6 +10,7 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
import SoundIcon from '@/material-icons/400-24px/audio.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { undoUploadCompose } from 'flavours/glitch/actions/compose';
@@ -17,7 +18,18 @@ import { openModal } from 'flavours/glitch/actions/modal';
import { Blurhash } from 'flavours/glitch/components/blurhash';
import { Icon } from 'flavours/glitch/components/icon';
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from 'flavours/glitch/store';
import { AudioVisualizer } from '../../audio/visualizer';
const selectUserAvatar = createAppSelector(
[(state) => state.accounts, (state) => state.meta.get('me') as string],
(accounts, myId) => accounts.get(myId)?.avatar_static,
);
export const Upload: React.FC<{
id: string;
@@ -38,6 +50,7 @@ export const Upload: React.FC<{
const sensitive = useAppSelector(
(state) => state.compose.get('sensitive') as boolean,
);
const userAvatar = useAppSelector(selectUserAvatar);
const handleUndoClick = useCallback(() => {
dispatch(undoUploadCompose(id));
@@ -67,6 +80,8 @@ export const Upload: React.FC<{
transform: CSS.Transform.toString(transform),
transition,
};
const preview_url = media.get('preview_url') as string | null;
const blurhash = media.get('blurhash') as string | null;
return (
<div
@@ -85,17 +100,19 @@ export const Upload: React.FC<{
<div
className='compose-form__upload__thumbnail'
style={{
backgroundImage: !sensitive
? `url(${media.get('preview_url') as string})`
: undefined,
backgroundImage:
!sensitive && preview_url ? `url(${preview_url})` : undefined,
backgroundPosition: `${x}% ${y}%`,
}}
>
{sensitive && (
<Blurhash
hash={media.get('blurhash') as string}
className='compose-form__upload__preview'
/>
{sensitive && blurhash && (
<Blurhash hash={blurhash} className='compose-form__upload__preview' />
)}
{!sensitive && !preview_url && (
<div className='compose-form__upload__visualizer'>
<AudioVisualizer poster={userAvatar} />
<Icon id='sound' icon={SoundIcon} />
</div>
)}
<div className='compose-form__upload__actions'>

View File

@@ -5,8 +5,10 @@ import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { changeComposeVisibility } from '@/flavours/glitch/actions/compose';
import { setComposeQuotePolicy } from '@/flavours/glitch/actions/compose_typed';
import {
changeComposeVisibility,
setComposeQuotePolicy,
} from '@/flavours/glitch/actions/compose_typed';
import { openModal } from '@/flavours/glitch/actions/modal';
import type { ApiQuotePolicy } from '@/flavours/glitch/api_types/quotes';
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';

View File

@@ -12,6 +12,8 @@ import {
} from 'flavours/glitch/actions/compose';
import { pasteLinkCompose } from 'flavours/glitch/actions/compose_typed';
import { openModal } from 'flavours/glitch/actions/modal';
import { PRIVATE_QUOTE_MODAL_ID } from 'flavours/glitch/features/ui/components/confirmation_modals/private_quote_notify';
import { me } from 'flavours/glitch/initial_state';
import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
import ComposeForm from '../components/compose_form';
@@ -52,6 +54,11 @@ const mapStateToProps = state => ({
isUploading: state.getIn(['compose', 'is_uploading']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
missingAltText: state.getIn(['compose', 'media_attachments']).some(media => ['image', 'gifv'].includes(media.get('type')) && (media.get('description') ?? '').length === 0),
quoteToPrivate:
!!state.getIn(['compose', 'quoted_status_id'])
&& state.getIn(['compose', 'privacy']) === 'private'
&& state.getIn(['statuses', state.getIn(['compose', 'quoted_status_id']), 'account']) !== me
&& !state.getIn(['settings', 'dismissed_banners', PRIVATE_QUOTE_MODAL_ID]),
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']),
sideArm: sideArmPrivacy(state),
@@ -65,12 +72,17 @@ const mapDispatchToProps = (dispatch, props) => ({
dispatch(changeCompose(text));
},
onSubmit (missingAltText, overridePrivacy = null) {
onSubmit ({ missingAltText, quoteToPrivate, overridePrivacy = null }) {
if (missingAltText) {
dispatch(openModal({
modalType: 'CONFIRM_MISSING_ALT_TEXT',
modalProps: { overridePrivacy },
}));
} else if (quoteToPrivate) {
dispatch(openModal({
modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY',
modalProps: {},
}));
} else {
dispatch(submitCompose(overridePrivacy, (status) => {
if (props.redirectOnSuccess) {

View File

@@ -1,8 +1,7 @@
import { connect } from 'react-redux';
import { changeComposeVisibility } from '../../../actions/compose';
import { openModal, closeModal } from '../../../actions/modal';
import { isUserTouching } from '../../../is_mobile';
import { changeComposeVisibility } from '@/flavours/glitch/actions/compose_typed';
import PrivacyDropdown from '../components/privacy_dropdown';
const mapStateToProps = state => ({

View File

@@ -1,9 +1,8 @@
import Trie from 'substring-trie';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { assetHost } from 'flavours/glitch/utils/config';
import { autoPlayGif, useSystemEmojiFont } from '../../initial_state';
import { autoPlayGif } from '../../initial_state';
import { unicodeMapping } from './emoji_unicode_mapping_light';
@@ -40,13 +39,13 @@ const emojifyTextNode = (node, customEmojis) => {
for (;;) {
let unicode_emoji;
// Skip to the next potential emoji to replace (either custom emoji or custom emoji :shortcode:)
// Skip to the next potential emoji to replace (either custom emoji or custom emoji :shortcode:
if (customEmojis === null) {
while (i < str.length && (useSystemEmojiFont || !(unicode_emoji = trie.search(str.slice(i))))) {
while (i < str.length && !(unicode_emoji = trie.search(str.slice(i)))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
} else {
while (i < str.length && str[i] !== ':' && (useSystemEmojiFont || !(unicode_emoji = trie.search(str.slice(i))))) {
while (i < str.length && str[i] !== ':' && !(unicode_emoji = trie.search(str.slice(i)))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
}
@@ -153,13 +152,9 @@ const emojifyNode = (node, customEmojis) => {
* Legacy emoji processing function.
* @param {string} str
* @param {object} customEmojis
* @param {boolean} force If true, always emojify even if modern emoji is enabled
* @returns {string}
*/
const emojify = (str, customEmojis = {}, force = false) => {
if (isModernEmojiEnabled() && !force) {
return str;
}
const emojify = (str, customEmojis = {}) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = str;

View File

@@ -14,8 +14,7 @@ import { uncompress as emojiMartUncompress } from 'emoji-mart/dist/utils/data';
import data from './emoji_data.json';
import emojiMap from './emoji_map.json';
import { unicodeToFilename } from './unicode_to_filename';
import { unicodeToUnifiedName } from './unicode_to_unified_name';
import { unicodeToFilename, unicodeToUnifiedName } from './unicode_utils';
emojiMartUncompress(data);

View File

@@ -9,7 +9,7 @@ import type {
ShortCodesToEmojiData,
} from 'virtual:mastodon-emoji-compressed';
import { unicodeToUnifiedName } from './unicode_to_unified_name';
import { unicodeToUnifiedName } from './unicode_utils';
type Emojis = Record<
NonNullable<keyof ShortCodesToEmojiData>,
@@ -23,7 +23,7 @@ type Emojis = Record<
const [
shortCodesToEmojiData,
skins,
_skins,
categories,
short_names,
_emojisWithoutShortCodes,
@@ -47,4 +47,4 @@ Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
};
});
export { emojis, skins, categories, short_names };
export { emojis, categories, short_names };

View File

@@ -1,7 +1,7 @@
// This code is largely borrowed from:
// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
import * as data from './emoji_mart_data_light';
import { emojis, categories } from './emoji_mart_data_light';
import { getData, getSanitizedData, uniq, intersect } from './emoji_utils';
let originalPool = {};
@@ -10,8 +10,8 @@ let emojisList = {};
let emoticonsList = {};
let customEmojisList = [];
for (let emoji in data.emojis) {
let emojiData = data.emojis[emoji];
for (let emoji in emojis) {
let emojiData = emojis[emoji];
let { short_names, emoticons } = emojiData;
let id = short_names[0];
@@ -84,14 +84,14 @@ function search(value, { emojisToShowFilter, maxResults, include, exclude, custo
if (include.length || exclude.length) {
pool = {};
data.categories.forEach(category => {
categories.forEach(category => {
let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true;
let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false;
if (!isIncluded || isExcluded) {
return;
}
category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]);
category.emojis.forEach(emojiId => pool[emojiId] = emojis[emojiId]);
});
if (custom.length) {
@@ -171,7 +171,7 @@ function search(value, { emojisToShowFilter, maxResults, include, exclude, custo
if (results) {
if (emojisToShowFilter) {
results = results.filter((result) => emojisToShowFilter(data.emojis[result.id]));
results = results.filter((result) => emojisToShowFilter(emojis[result.id]));
}
if (results && results.length > maxResults) {

View File

@@ -2,7 +2,6 @@ import type { EmojiProps, PickerProps } from 'emoji-mart';
import EmojiRaw from 'emoji-mart/dist-es/components/emoji/nimble-emoji';
import PickerRaw from 'emoji-mart/dist-es/components/picker/nimble-picker';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { assetHost } from 'flavours/glitch/utils/config';
import { EMOJI_MODE_NATIVE } from './constants';
@@ -27,7 +26,7 @@ const Emoji = ({
sheetSize={sheetSize}
sheetColumns={sheetColumns}
sheetRows={sheetRows}
native={mode === EMOJI_MODE_NATIVE && isModernEmojiEnabled()}
native={mode === EMOJI_MODE_NATIVE}
backgroundImageFn={backgroundImageFn}
{...props}
/>
@@ -51,7 +50,7 @@ const Picker = ({
sheetColumns={sheetColumns}
sheetRows={sheetRows}
backgroundImageFn={backgroundImageFn}
native={mode === EMOJI_MODE_NATIVE && isModernEmojiEnabled()}
native={mode === EMOJI_MODE_NATIVE}
{...props}
/>
);

View File

@@ -8,7 +8,7 @@ import type {
ShortCodesToEmojiDataKey,
} from 'virtual:mastodon-emoji-compressed';
import { unicodeToFilename } from './unicode_to_filename';
import { unicodeToFilename } from './unicode_utils';
type UnicodeMapping = Record<
FilenameData[number][0],

View File

@@ -209,50 +209,9 @@ function intersect(a, b) {
return uniqA.filter(item => uniqB.indexOf(item) >= 0);
}
function deepMerge(a, b) {
let o = {};
for (let key in a) {
let originalValue = a[key],
value = originalValue;
if (Object.hasOwn(b, key)) {
value = b[key];
}
if (typeof value === 'object') {
value = deepMerge(originalValue, value);
}
o[key] = value;
}
return o;
}
// https://github.com/sonicdoe/measure-scrollbar
function measureScrollbar() {
const div = document.createElement('div');
div.style.width = '100px';
div.style.height = '100px';
div.style.overflow = 'scroll';
div.style.position = 'absolute';
div.style.top = '-9999px';
document.body.appendChild(div);
const scrollbarWidth = div.offsetWidth - div.clientWidth;
document.body.removeChild(div);
return scrollbarWidth;
}
export {
getData,
getSanitizedData,
uniq,
intersect,
deepMerge,
unifiedToNative,
measureScrollbar,
};

View File

@@ -1,61 +0,0 @@
import { autoPlayGif } from '@/flavours/glitch/initial_state';
const PARENT_MAX_DEPTH = 10;
export function handleAnimateGif(event: MouseEvent) {
// We already check this in ui/index.jsx, but just to be sure.
if (autoPlayGif) {
return;
}
const { target, type } = event;
const animate = type === 'mouseover'; // Mouse over = animate, mouse out = don't animate.
if (target instanceof HTMLImageElement) {
setAnimateGif(target, animate);
} else if (!(target instanceof HTMLElement) || target === document.body) {
return;
}
let parent: HTMLElement | null = null;
let iter = 0;
if (target.classList.contains('animate-parent')) {
parent = target;
} else {
// Iterate up to PARENT_MAX_DEPTH levels up the DOM tree to find a parent with the class 'animate-parent'.
let current: HTMLElement | null = target;
while (current) {
if (iter >= PARENT_MAX_DEPTH) {
return; // We can just exit right now.
}
current = current.parentElement;
if (current?.classList.contains('animate-parent')) {
parent = current;
break;
}
iter++;
}
}
// Affect all animated children within the parent.
if (parent) {
const animatedChildren =
parent.querySelectorAll<HTMLImageElement>('img.custom-emoji');
for (const child of animatedChildren) {
setAnimateGif(child, animate);
}
}
}
function setAnimateGif(image: HTMLImageElement, animate: boolean) {
const { classList, dataset } = image;
if (
!classList.contains('custom-emoji') ||
!dataset.static ||
!dataset.original
) {
return;
}
image.src = animate ? dataset.original : dataset.static;
}

View File

@@ -1,8 +1,10 @@
import { initialState } from '@/flavours/glitch/initial_state';
import { loadWorker } from '@/flavours/glitch/utils/workers';
import { toSupportedLocale } from './locale';
import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
// eslint-disable-next-line import/default -- Importing via worker loader.
import EmojiWorker from './worker?worker&inline';
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
@@ -16,28 +18,24 @@ export function initializeEmoji() {
log('initializing emojis');
if (!worker && 'Worker' in window) {
try {
worker = loadWorker(new URL('./worker', import.meta.url), {
type: 'module',
});
worker = new EmojiWorker();
} catch (err) {
console.warn('Error creating web worker:', err);
}
}
if (worker) {
// Assign worker to const to make TS happy inside the event listener.
const thisWorker = worker;
const timeoutId = setTimeout(() => {
log('worker is not ready after timeout');
worker = null;
void fallbackLoad();
}, WORKER_TIMEOUT);
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
worker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === 'ready') {
log('worker ready, loading data');
clearTimeout(timeoutId);
thisWorker.postMessage('custom');
messageWorker('custom');
void loadEmojiLocale(userLocale);
// Load English locale as well, because people are still used to
// using it from before we supported other locales.
@@ -56,20 +54,35 @@ export function initializeEmoji() {
async function fallbackLoad() {
log('falling back to main thread for loading');
const { importCustomEmojiData } = await import('./loader');
await importCustomEmojiData();
const emojis = await importCustomEmojiData();
if (emojis) {
log('loaded %d custom emojis', emojis.length);
}
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
}
}
export async function loadEmojiLocale(localeString: string) {
async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
const { importEmojiData, localeToPath } = await import('./loader');
if (worker) {
worker.postMessage(locale);
const path = await localeToPath(locale);
log('asking worker to load locale %s from %s', locale, path);
messageWorker(locale, path);
} else {
const { importEmojiData } = await import('./loader');
await importEmojiData(locale);
const emojis = await importEmojiData(locale);
if (emojis) {
log('loaded %d emojis to locale %s', emojis.length, locale);
}
}
}
function messageWorker(locale: LocaleOrCustom, path?: string) {
if (!worker) {
return;
}
worker.postMessage({ locale, path });
}

View File

@@ -1,5 +1,5 @@
import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji, Locale } from 'emojibase';
import {
putEmojiData,
@@ -8,45 +8,64 @@ import {
putLatestEtag,
} from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { CustomEmojiData, LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
import type { CustomEmojiData } from './types';
const log = emojiLogger('loader');
export async function importEmojiData(localeString: string) {
export async function importEmojiData(localeString: string, path?: string) {
const locale = toSupportedLocale(localeString);
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale);
// Validate the provided path.
if (path && !/^[/a-z]*\/packs\/assets\/compact-\w+\.json$/.test(path)) {
throw new Error('Invalid path for emoji data');
} else {
// Otherwise get the path if not provided.
path ??= await localeToPath(locale);
}
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale, path);
if (!emojis) {
return;
}
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
log('loaded %d for %s locale', flattenedEmojis.length, locale);
await putEmojiData(flattenedEmojis, locale);
return flattenedEmojis;
}
export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>('custom');
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>(
'custom',
'/api/v1/custom_emojis',
);
if (!emojis) {
return;
}
log('loaded %d custom emojis', emojis.length);
await putCustomEmojiData(emojis);
return emojis;
}
async function fetchAndCheckEtag<ResultType extends object[]>(
localeOrCustom: LocaleOrCustom,
const modules = import.meta.glob<string>(
'../../../../../../node_modules/emojibase-data/**/compact.json',
{
query: '?url',
import: 'default',
},
);
export function localeToPath(locale: Locale) {
const key = `../../../../../../node_modules/emojibase-data/${locale}/compact.json`;
if (!modules[key] || typeof modules[key] !== 'function') {
throw new Error(`Unsupported locale: ${locale}`);
}
return modules[key]();
}
export async function fetchAndCheckEtag<ResultType extends object[]>(
localeString: string,
path: string,
): Promise<ResultType | null> {
const locale = toSupportedLocaleOrCustom(localeOrCustom);
const locale = toSupportedLocaleOrCustom(localeString);
// Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(location.origin);
if (locale === 'custom') {
url.pathname = '/api/v1/custom_emojis';
} else {
// This doesn't use isDevelopment() as that module loads initial state
// which breaks workers, as they cannot access the DOM.
url.pathname = `/packs${import.meta.env.DEV ? '-dev' : ''}/emoji/${locale}.json`;
}
const url = new URL(path, location.origin);
const oldEtag = await loadLatestEtag(locale);
const response = await fetch(url, {
@@ -61,21 +80,19 @@ async function fetchAndCheckEtag<ResultType extends object[]>(
}
if (!response.ok) {
throw new Error(
`Failed to fetch emoji data for ${localeOrCustom}: ${response.statusText}`,
`Failed to fetch emoji data for ${locale}: ${response.statusText}`,
);
}
const data = (await response.json()) as ResultType;
if (!Array.isArray(data)) {
throw new Error(
`Unexpected data format for ${localeOrCustom}: expected an array`,
);
throw new Error(`Unexpected data format for ${locale}: expected an array`);
}
// Store the ETag for future requests
const etag = response.headers.get('ETag');
if (etag) {
await putLatestEtag(etag, localeOrCustom);
await putLatestEtag(etag, localeString);
}
return data;

View File

@@ -162,7 +162,7 @@ describe('loadEmojiDataToState', () => {
const dbCall = vi
.spyOn(db, 'loadEmojiByHexcode')
.mockRejectedValue(new db.LocaleNotLoadedError('en'));
vi.spyOn(loader, 'importEmojiData').mockResolvedValueOnce();
vi.spyOn(loader, 'importEmojiData').mockResolvedValueOnce(undefined);
const consoleCall = vi
.spyOn(console, 'warn')
.mockImplementationOnce(() => null);

View File

@@ -1,26 +0,0 @@
// taken from:
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
export const unicodeToFilename = (str) => {
let result = '';
let charCode = 0;
let p = 0;
let i = 0;
while (i < str.length) {
charCode = str.charCodeAt(i++);
if (p) {
if (result.length > 0) {
result += '-';
}
result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16);
p = 0;
} else if (0xD800 <= charCode && charCode <= 0xDBFF) {
p = charCode;
} else {
if (result.length > 0) {
result += '-';
}
result += charCode.toString(16);
}
}
return result;
};

View File

@@ -1,21 +0,0 @@
function padLeft(str, num) {
while (str.length < num) {
str = '0' + str;
}
return str;
}
export const unicodeToUnifiedName = (str) => {
let output = '';
for (let i = 0; i < str.length; i += 2) {
if (i > 0) {
output += '-';
}
output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4);
}
return output;
};

View File

@@ -0,0 +1,43 @@
// taken from:
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
export function unicodeToFilename(str: string) {
let result = '';
let charCode = 0;
let p = 0;
let i = 0;
while (i < str.length) {
charCode = str.charCodeAt(i++);
if (p) {
if (result.length > 0) {
result += '-';
}
result += (0x10000 + ((p - 0xd800) << 10) + (charCode - 0xdc00)).toString(
16,
);
p = 0;
} else if (0xd800 <= charCode && charCode <= 0xdbff) {
p = charCode;
} else {
if (result.length > 0) {
result += '-';
}
result += charCode.toString(16);
}
}
return result;
}
export function unicodeToUnifiedName(str: string) {
let output = '';
for (let i = 0; i < str.length; i += 2) {
if (i > 0) {
output += '-';
}
output +=
str.codePointAt(i)?.toString(16).toUpperCase().padStart(4, '0') ?? '';
}
return output;
}

View File

@@ -1,18 +1,25 @@
import { importEmojiData, importCustomEmojiData } from './loader';
import { importCustomEmojiData, importEmojiData } from './loader';
addEventListener('message', handleMessage);
self.postMessage('ready'); // After the worker is ready, notify the main thread
function handleMessage(event: MessageEvent<string>) {
const { data: locale } = event;
void loadData(locale);
function handleMessage(event: MessageEvent<{ locale: string; path?: string }>) {
const {
data: { locale, path },
} = event;
void loadData(locale, path);
}
async function loadData(locale: string) {
if (locale !== 'custom') {
await importEmojiData(locale);
async function loadData(locale: string, path?: string) {
let importCount: number | undefined;
if (locale === 'custom') {
importCount = (await importCustomEmojiData())?.length;
} else if (path) {
importCount = (await importEmojiData(locale, path))?.length;
} else {
await importCustomEmojiData();
throw new Error('Path is required for loading locale emoji data');
}
if (importCount) {
self.postMessage(`loaded ${importCount} emojis into ${locale}`);
}
self.postMessage(`loaded ${locale}`);
}

View File

@@ -14,7 +14,8 @@ import { connectPublicStream, connectCommunityStream } from 'flavours/glitch/act
import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import SettingText from 'flavours/glitch/components/setting_text';
import { localLiveFeedAccess, remoteLiveFeedAccess, me, domain } from 'flavours/glitch/initial_state';
import { localLiveFeedAccess, remoteLiveFeedAccess, domain } from 'flavours/glitch/initial_state';
import { canViewFeed } from 'flavours/glitch/permissions';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import Column from '../../components/column';
@@ -24,6 +25,14 @@ import StatusListContainer from '../ui/containers/status_list_container';
const messages = defineMessages({
title: { id: 'column.firehose', defaultMessage: 'Live feeds' },
title_local: {
id: 'column.firehose_local',
defaultMessage: 'Live feed for this server',
},
title_singular: {
id: 'column.firehose_singular',
defaultMessage: 'Live feed',
},
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
});
@@ -75,7 +84,7 @@ const ColumnSettings = () => {
const Firehose = ({ feedType, multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { signedIn } = useIdentity();
const { signedIn, permissions } = useIdentity();
const columnRef = useRef(null);
const allowLocalOnly = useAppSelector((state) => state.getIn(['settings', 'firehose', 'allowLocalOnly']));
@@ -177,13 +186,32 @@ const Firehose = ({ feedType, multiColumn }) => {
/>
);
const canViewSelectedFeed = canViewFeed(signedIn, permissions, feedType === 'community' ? localLiveFeedAccess : remoteLiveFeedAccess);
const disabledTimelineMessage = (
<FormattedMessage
id='empty_column.disabled_feed'
defaultMessage='This feed has been disabled by your server administrators.'
/>
);
let title;
if (canViewFeed(signedIn, permissions, localLiveFeedAccess) && canViewFeed(signedIn, permissions, remoteLiveFeedAccess)) {
title = messages.title;
} else if (canViewFeed(signedIn, permissions, localLiveFeedAccess)) {
title = messages.title_local;
} else {
title = messages.title_singular;
}
return (
<Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='globe'
iconComponent={PublicIcon}
active={hasUnread}
title={intl.formatMessage(messages.title)}
title={intl.formatMessage(title)}
onPin={handlePin}
onClick={handleHeaderClick}
multiColumn={multiColumn}
@@ -191,7 +219,7 @@ const Firehose = ({ feedType, multiColumn }) => {
<ColumnSettings />
</ColumnHeader>
{(signedIn || (localLiveFeedAccess === 'public' && remoteLiveFeedAccess === 'public')) && (
{(canViewFeed(signedIn, permissions, localLiveFeedAccess) && canViewFeed(signedIn, permissions, remoteLiveFeedAccess)) && (
<div className='account__section-headline'>
<NavLink exact to='/public/local'>
<FormattedMessage tagName='div' id='firehose.local' defaultMessage='This server' />
@@ -213,7 +241,7 @@ const Firehose = ({ feedType, multiColumn }) => {
onLoadMore={handleLoadMore}
trackScroll
scrollKey='firehose'
emptyMessage={emptyMessage}
emptyMessage={canViewSelectedFeed ? emptyMessage : disabledTimelineMessage}
bindToDocument={!multiColumn}
regex={regex}
/>

View File

@@ -1,444 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent, useCallback, useMemo } from 'react';
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { animated, useTransition } from '@react-spring/web';
import ReactSwipeableViews from 'react-swipeable-views';
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import { AnimatedNumber } from 'flavours/glitch/components/animated_number';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import EmojiPickerDropdown from 'flavours/glitch/features/compose/containers/emoji_picker_dropdown_container';
import { unicodeMapping } from 'flavours/glitch/features/emoji/emoji_unicode_mapping_light';
import { autoPlayGif, reduceMotion, disableSwiping, mascot } from 'flavours/glitch/initial_state';
import { assetHost } from 'flavours/glitch/utils/config';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
});
class ContentWithRouter extends ImmutablePureComponent {
static propTypes = {
announcement: ImmutablePropTypes.map.isRequired,
...WithRouterPropTypes,
};
setRef = c => {
this.node = c;
};
componentDidMount () {
this._updateLinks();
}
componentDidUpdate () {
this._updateLinks();
}
_updateLinks () {
const node = this.node;
if (!node) {
return;
}
const links = node.querySelectorAll('a');
for (var i = 0; i < links.length; ++i) {
let link = links[i];
if (link.classList.contains('status-link')) {
continue;
}
link.classList.add('status-link');
let mention = this.props.announcement.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'));
} 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);
} else {
let status = this.props.announcement.get('statuses').find(item => link.href === item.get('url'));
if (status) {
link.addEventListener('click', this.onStatusClick.bind(this, status), false);
}
link.setAttribute('title', link.href);
link.classList.add('unhandled-link');
}
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
}
}
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}`);
}
};
onStatusClick = (status, e) => {
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
}
};
render () {
const { announcement } = this.props;
return (
<div
className='announcements__item__content translate animate-parent'
ref={this.setRef}
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
/>
);
}
}
const Content = withRouter(ContentWithRouter);
class Emoji extends PureComponent {
static propTypes = {
emoji: PropTypes.string.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
hovered: PropTypes.bool.isRequired,
};
render () {
const { emoji, emojiMap, hovered } = this.props;
if (unicodeMapping[emoji]) {
const { filename, shortCode } = unicodeMapping[this.props.emoji];
const title = shortCode ? `:${shortCode}:` : '';
return (
<img
draggable='false'
className='emojione'
alt={emoji}
title={title}
src={`${assetHost}/emoji/${filename}.svg`}
/>
);
} else if (emojiMap.get(emoji)) {
const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
const shortCode = `:${emoji}:`;
return (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
} else {
return null;
}
}
}
class Reaction extends ImmutablePureComponent {
static propTypes = {
announcementId: PropTypes.string.isRequired,
reaction: ImmutablePropTypes.map.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
style: PropTypes.object,
};
state = {
hovered: false,
};
handleClick = () => {
const { reaction, announcementId, addReaction, removeReaction } = this.props;
if (reaction.get('me')) {
removeReaction(announcementId, reaction.get('name'));
} else {
addReaction(announcementId, reaction.get('name'));
}
};
handleMouseEnter = () => this.setState({ hovered: true });
handleMouseLeave = () => this.setState({ hovered: false });
render () {
const { reaction } = this.props;
let shortCode = reaction.get('name');
if (unicodeMapping[shortCode]) {
shortCode = unicodeMapping[shortCode].shortCode;
}
return (
<animated.button
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
onClick={this.handleClick}
title={`:${shortCode}:`}
style={this.props.style}
// This does not use animate-parent as this component is directly rendered by React.
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
>
<span className='reactions-bar__item__emoji'>
<Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} />
</span>
<span className='reactions-bar__item__count'>
<AnimatedNumber value={reaction.get('count')} />
</span>
</animated.button>
);
}
}
const ReactionsBar = ({
announcementId,
reactions,
emojiMap,
addReaction,
removeReaction,
}) => {
const visibleReactions = useMemo(() => reactions.filter(x => x.get('count') > 0).toArray(), [reactions]);
const handleEmojiPick = useCallback((emoji) => {
addReaction(announcementId, emoji.native.replaceAll(/:/g, ''));
}, [addReaction, announcementId]);
const transitions = useTransition(visibleReactions, {
from: {
scale: 0,
},
enter: {
scale: 1,
},
leave: {
scale: 0,
},
keys: visibleReactions.map(x => x.get('name')),
});
return (
<div
className={classNames('reactions-bar', {
'reactions-bar--empty': visibleReactions.length === 0
})}
>
{transitions(({ scale }, reaction) => (
<Reaction
key={reaction.get('name')}
reaction={reaction}
style={{ transform: scale.to((s) => `scale(${s})`) }}
addReaction={addReaction}
removeReaction={removeReaction}
announcementId={announcementId}
emojiMap={emojiMap}
/>
))}
{visibleReactions.length < 8 && (
<EmojiPickerDropdown
onPickEmoji={handleEmojiPick}
button={<Icon id='plus' icon={AddIcon} />}
/>
)}
</div>
);
};
ReactionsBar.propTypes = {
announcementId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
};
class Announcement extends ImmutablePureComponent {
static propTypes = {
announcement: ImmutablePropTypes.map.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
selected: PropTypes.bool,
};
state = {
unread: !this.props.announcement.get('read'),
};
componentDidUpdate () {
const { selected, announcement } = this.props;
if (!selected && this.state.unread !== !announcement.get('read')) {
this.setState({ unread: !announcement.get('read') });
}
}
render () {
const { announcement } = this.props;
const { unread } = this.state;
const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
const now = new Date();
const hasTimeRange = startsAt && endsAt;
const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
const skipTime = announcement.get('all_day');
return (
<div className='announcements__item'>
<strong className='announcements__item__range'>
<FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
{hasTimeRange && <span> · <FormattedDate value={startsAt} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>}
</strong>
<Content announcement={announcement} />
<ReactionsBar
reactions={announcement.get('reactions')}
announcementId={announcement.get('id')}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
emojiMap={this.props.emojiMap}
/>
{unread && <span className='announcements__item__unread' />}
</div>
);
}
}
class Announcements extends ImmutablePureComponent {
static propTypes = {
announcements: ImmutablePropTypes.list,
emojiMap: ImmutablePropTypes.map.isRequired,
dismissAnnouncement: PropTypes.func.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
index: 0,
};
static getDerivedStateFromProps(props, state) {
if (props.announcements.size > 0 && state.index >= props.announcements.size) {
return { index: props.announcements.size - 1 };
} else {
return null;
}
}
componentDidMount () {
this._markAnnouncementAsRead();
}
componentDidUpdate () {
this._markAnnouncementAsRead();
}
_markAnnouncementAsRead () {
const { dismissAnnouncement, announcements } = this.props;
const { index } = this.state;
const announcement = announcements.get(announcements.size - 1 - index);
if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
}
handleChangeIndex = index => {
this.setState({ index: index % this.props.announcements.size });
};
handleNextClick = () => {
this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
};
handlePrevClick = () => {
this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
};
render () {
const { announcements, intl } = this.props;
const { index } = this.state;
if (announcements.isEmpty()) {
return null;
}
return (
<div className='announcements'>
<img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
<div className='announcements__container'>
<ReactSwipeableViews animateHeight animateTransitions={!reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}>
{announcements.map((announcement, idx) => (
<Announcement
key={announcement.get('id')}
announcement={announcement}
emojiMap={this.props.emojiMap}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
intl={intl}
selected={index === idx}
disabled={disableSwiping}
/>
)).reverse()}
</ReactSwipeableViews>
{announcements.size > 1 && (
<div className='announcements__pagination'>
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' iconComponent={ChevronLeftIcon} onClick={this.handlePrevClick} size={13} />
<span>{index + 1} / {announcements.size}</span>
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' iconComponent={ChevronRightIcon} onClick={this.handleNextClick} size={13} />
</div>
)}
</div>
</div>
);
}
}
export default injectIntl(Announcements);

View File

@@ -1,23 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { Map as ImmutableMap } from 'immutable';
import { connect } from 'react-redux';
import { addReaction, removeReaction, dismissAnnouncement } from 'flavours/glitch/actions/announcements';
import Announcements from '../components/announcements';
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
const mapStateToProps = state => ({
announcements: state.getIn(['announcements', 'items']),
emojiMap: customEmojiMap(state),
});
const mapDispatchToProps = dispatch => ({
dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
addReaction: (id, name) => dispatch(addReaction(id, name)),
removeReaction: (id, name) => dispatch(removeReaction(id, name)),
});
export default connect(mapStateToProps, mapDispatchToProps)(Announcements);

View File

@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import { dismissAnnouncement } from '@/flavours/glitch/actions/announcements';
import type { ApiAnnouncementJSON } from '@/flavours/glitch/api_types/announcements';
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { useAppDispatch } from '@/flavours/glitch/store';
import { ReactionsBar } from './reactions';
@@ -22,13 +24,23 @@ export const Announcement: FC<AnnouncementProps> = ({
announcement,
selected,
}) => {
const [unread, setUnread] = useState(!announcement.read);
const { read, id } = announcement;
// Dismiss announcement when it becomes active.
const dispatch = useAppDispatch();
useEffect(() => {
// Only update `unread` marker once the announcement is out of view
if (!selected && unread !== !announcement.read) {
setUnread(!announcement.read);
if (selected && !read) {
dispatch(dismissAnnouncement(id));
}
}, [announcement.read, selected, unread]);
}, [selected, id, dispatch, read]);
// But visually show the announcement as read only when it goes out of view.
const [unread, setUnread] = useState(!read);
useEffect(() => {
if (!selected && unread !== !read) {
setUnread(!read);
}
}, [selected, unread, read]);
return (
<AnimateEmojiProvider className='announcements__item'>

View File

@@ -9,10 +9,8 @@ import ReactSwipeableViews from 'react-swipeable-views';
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import { IconButton } from '@/flavours/glitch/components/icon_button';
import LegacyAnnouncements from '@/flavours/glitch/features/getting_started/containers/announcements_container';
import { mascot, reduceMotion } from '@/flavours/glitch/initial_state';
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
@@ -32,7 +30,7 @@ const announcementSelector = createAppSelector(
(announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [],
);
export const ModernAnnouncements: FC = () => {
export const Announcements: FC = () => {
const intl = useIntl();
const announcements = useAppSelector(announcementSelector);
@@ -112,7 +110,3 @@ export const ModernAnnouncements: FC = () => {
</div>
);
};
export const Announcements = isModernEmojiEnabled()
? ModernAnnouncements
: LegacyAnnouncements;

View File

@@ -47,6 +47,7 @@ import {
me,
} from 'flavours/glitch/initial_state';
import { transientSingleColumn } from 'flavours/glitch/is_mobile';
import { canViewFeed } from 'flavours/glitch/permissions';
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
@@ -65,6 +66,10 @@ const messages = defineMessages({
},
explore: { id: 'explore.title', defaultMessage: 'Trending' },
firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
firehose_singular: {
id: 'column.firehose_singular',
defaultMessage: 'Live feed',
},
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
@@ -208,7 +213,7 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
multiColumn = false,
}) => {
const intl = useIntl();
const { signedIn, disabledAccountId } = useIdentity();
const { signedIn, permissions, disabledAccountId } = useIdentity();
const location = useLocation();
const showSearch = useBreakpoint('full') && !multiColumn;
const dispatch = useAppDispatch();
@@ -286,20 +291,24 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
/>
)}
{(signedIn ||
localLiveFeedAccess === 'public' ||
remoteLiveFeedAccess === 'public') && (
{(canViewFeed(signedIn, permissions, localLiveFeedAccess) ||
canViewFeed(signedIn, permissions, remoteLiveFeedAccess)) && (
<ColumnLink
transparent
to={
signedIn || localLiveFeedAccess === 'public'
canViewFeed(signedIn, permissions, localLiveFeedAccess)
? '/public/local'
: '/public/remote'
}
icon='globe'
iconComponent={PublicIcon}
isActive={isFirehoseActive}
text={intl.formatMessage(messages.firehose)}
text={intl.formatMessage(
canViewFeed(signedIn, permissions, localLiveFeedAccess) &&
canViewFeed(signedIn, permissions, remoteLiveFeedAccess)
? messages.firehose
: messages.firehose_singular,
)}
/>
)}

View File

@@ -1,47 +1,17 @@
import { useCallback, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import type { List } from 'immutable';
import type { History } from 'history';
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
import type { Status } from '@/flavours/glitch/models/status';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import type { Mention } from './embedded_status';
const handleMentionClick = (
history: History,
mention: ApiMentionJSON,
e: MouseEvent,
) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
history.push(`/@${mention.acct}`);
}
};
const handleHashtagClick = (
history: History,
hashtag: string,
e: MouseEvent,
) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
history.push(`/tags/${hashtag.replace(/^#/, '')}`);
}
};
export const EmbeddedStatusContent: React.FC<{
status: Status;
className?: string;
}> = ({ status, className }) => {
const history = useHistory();
const mentions = useMemo(
() => (status.get('mentions') as List<Mention>).toJS(),
[status],
@@ -57,55 +27,10 @@ export const EmbeddedStatusContent: React.FC<{
hrefToMention,
});
const handleContentRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || isModernEmojiEnabled()) {
return;
}
const links = node.querySelectorAll<HTMLAnchorElement>('a');
for (const link of links) {
if (link.classList.contains('status-link')) {
continue;
}
link.classList.add('status-link');
const mention = mentions.find((item) => link.href === item.url);
if (mention) {
link.addEventListener(
'click',
handleMentionClick.bind(null, history, mention),
false,
);
link.setAttribute('title', `@${mention.acct}`);
link.setAttribute('href', `/@${mention.acct}`);
} else if (
link.textContent.startsWith('#') ||
link.previousSibling?.textContent?.endsWith('#')
) {
link.addEventListener(
'click',
handleHashtagClick.bind(null, history, link.text),
false,
);
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
} else {
link.setAttribute('title', link.href);
link.classList.add('unhandled-link');
}
}
},
[mentions, history],
);
return (
<EmojiHTML
{...htmlHandlers}
className={className}
ref={handleContentRef}
lang={status.get('language') as string}
htmlString={status.get('contentHtml') as string}
/>

View File

@@ -10,7 +10,8 @@ import { connect } from 'react-redux';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { domain } from 'flavours/glitch/initial_state';
import { domain, localLiveFeedAccess, remoteLiveFeedAccess } from 'flavours/glitch/initial_state';
import { canViewFeed } from 'flavours/glitch/permissions';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { connectPublicStream } from '../../actions/streaming';
@@ -128,8 +129,21 @@ class PublicTimeline extends PureComponent {
render () {
const { intl, columnId, hasUnread, multiColumn, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
const { signedIn, permissions } = this.props.identity;
const pinned = !!columnId;
const emptyMessage = (canViewFeed(signedIn, permissions, localLiveFeedAccess) || canViewFeed(signedIn, permissions, remoteLiveFeedAccess)) ? (
<FormattedMessage
id='empty_column.public'
defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up'
/>
) : (
<FormattedMessage
id='empty_column.disabled_feed'
defaultMessage='This feed has been disabled by your server administrators.'
/>
);
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
@@ -152,7 +166,7 @@ class PublicTimeline extends PureComponent {
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}
scrollKey={`public_timeline-${columnId}`}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
regex={this.props.regex}
/>

View File

@@ -37,7 +37,10 @@ const handleIframeUrl = (html, url, providerName) => {
iframeUrl.searchParams.set('autoplay', 1)
iframeUrl.searchParams.set('auto_play', 1)
if (startTime && providerName === "YouTube") iframeUrl.searchParams.set('start', startTime)
if (providerName === 'YouTube') {
iframeUrl.searchParams.set('start', startTime || '');
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
}
iframe.src = iframeUrl.href

View File

@@ -4,7 +4,7 @@
@typescript-eslint/no-unsafe-assignment */
import type { CSSProperties } from 'react';
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
@@ -57,6 +57,8 @@ export const DetailedStatus: React.FC<{
pictureInPicture: any;
onToggleHidden?: (status: any) => void;
onToggleMediaVisibility?: () => void;
ancestors?: number;
multiColumn?: boolean;
expanded: boolean;
}> = ({
status,
@@ -72,6 +74,8 @@ export const DetailedStatus: React.FC<{
pictureInPicture,
onToggleMediaVisibility,
onToggleHidden,
ancestors = 0,
multiColumn = false,
expanded,
}) => {
const properStatus = status?.get('reblog') ?? status;
@@ -79,13 +83,6 @@ export const DetailedStatus: React.FC<{
const [showDespiteFilter, setShowDespiteFilter] = useState(false);
const nodeRef = useRef<HTMLDivElement>();
const rewriteMentions = useAppSelector(
(state) => state.local_settings.get('rewrite_mentions', false) as boolean,
);
const tagMisleadingLinks = useAppSelector(
(state) =>
state.local_settings.get('tag_misleading_links', false) as boolean,
);
const letterboxMedia = useAppSelector(
(state) =>
state.local_settings.getIn(['media', 'letterbox'], false) as boolean,
@@ -143,6 +140,30 @@ export const DetailedStatus: React.FC<{
if (onTranslate) onTranslate(status);
}, [onTranslate, status]);
// The component is managed and will change if the status changes
// Ancestors can increase when loading a thread, in which case we want to scroll,
// or decrease if a post is deleted, in which case we don't want to mess with it
const previousAncestors = useRef(-1);
useEffect(() => {
if (nodeRef.current && previousAncestors.current < ancestors) {
nodeRef.current.scrollIntoView(true);
// In the single-column interface, `scrollIntoView` will put the post behind the header, so compensate for that.
if (!multiColumn) {
const offset = document
.querySelector('.column-header__wrapper')
?.getBoundingClientRect().bottom;
if (offset) {
const scrollingElement = document.scrollingElement ?? document.body;
scrollingElement.scrollBy(0, -offset);
}
}
}
previousAncestors.current = ancestors;
}, [ancestors, multiColumn]);
if (!properStatus) {
return null;
}
@@ -442,8 +463,6 @@ export const DetailedStatus: React.FC<{
<StatusContent
status={status}
onTranslate={handleTranslate}
tagLinks={tagMisleadingLinks}
rewriteMentions={rewriteMentions}
{...(statusContentProps as any)}
/>
@@ -454,6 +473,7 @@ export const DetailedStatus: React.FC<{
<QuotedStatus
quote={status.get('quote')}
parentQuotePostId={status.get('id')}
contextType='thread'
/>
)}
</>

View File

@@ -1,7 +1,9 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { useDebouncedCallback } from 'use-debounce';
import {
fetchContext,
completeContextRefresh,
@@ -13,6 +15,8 @@ import { apiGetAsyncRefresh } from 'flavours/glitch/api/async_refreshes';
import { Alert } from 'flavours/glitch/components/alert';
import { ExitAnimationWrapper } from 'flavours/glitch/components/exit_animation_wrapper';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { useInterval } from 'flavours/glitch/hooks/useInterval';
import { useIsDocumentVisible } from 'flavours/glitch/hooks/useIsDocumentVisible';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
const AnimatedAlert: React.FC<
@@ -52,14 +56,151 @@ const messages = defineMessages({
type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error';
/**
* Age of thread below which we consider it new & fetch
* replies more frequently
*/
const NEW_THREAD_AGE_THRESHOLD = 30 * 60_000;
/**
* Interval at which we check for new replies for old threads
*/
const LONG_AUTO_FETCH_REPLIES_INTERVAL = 5 * 60_000;
/**
* Interval at which we check for new replies for new threads.
* Also used as a threshold to throttle repeated fetch calls
*/
const SHORT_AUTO_FETCH_REPLIES_INTERVAL = 60_000;
/**
* Number of refresh_async checks at which an early fetch
* will be triggered if there are results
*/
const LONG_RUNNING_FETCH_THRESHOLD = 3;
/**
* Returns whether the thread is new, based on NEW_THREAD_AGE_THRESHOLD
*/
function getIsThreadNew(statusCreatedAt: string) {
const now = new Date();
const newThreadThreshold = new Date(now.getTime() - NEW_THREAD_AGE_THRESHOLD);
return new Date(statusCreatedAt) > newThreadThreshold;
}
/**
* This hook kicks off a background check for the async refresh job
* and loads any newly found replies once the job has finished,
* and when LONG_RUNNING_FETCH_THRESHOLD was reached and replies were found
*/
function useCheckForRemoteReplies({
statusId,
refreshHeader,
isEnabled,
onChangeLoadingState,
}: {
statusId: string;
refreshHeader?: AsyncRefreshHeader;
isEnabled: boolean;
onChangeLoadingState: React.Dispatch<React.SetStateAction<LoadingState>>;
}) {
const dispatch = useAppDispatch();
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
const scheduleRefresh = (
refresh: AsyncRefreshHeader,
iteration: number,
) => {
timeoutId = setTimeout(() => {
void apiGetAsyncRefresh(refresh.id).then((result) => {
const { status, result_count } = result.async_refresh;
// At three scheduled refreshes, we consider the job
// long-running and attempt to fetch any new replies so far
const isLongRunning = iteration === LONG_RUNNING_FETCH_THRESHOLD;
// If the refresh status is not finished and not long-running,
// we just schedule another refresh and exit
if (status === 'running' && !isLongRunning) {
scheduleRefresh(refresh, iteration + 1);
return;
}
// If refresh status is finished, clear `refreshHeader`
// (we don't want to do this if it's just a long-running job)
if (status === 'finished') {
dispatch(completeContextRefresh({ statusId }));
}
// Exit if there's nothing to fetch
if (result_count === 0) {
if (status === 'finished') {
onChangeLoadingState('idle');
} else {
scheduleRefresh(refresh, iteration + 1);
}
return;
}
// A positive result count means there _might_ be new replies,
// so we fetch the context in the background to check if there
// are any new replies.
// If so, they will populate `contexts.pendingReplies[statusId]`
void dispatch(fetchContext({ statusId, prefetchOnly: true }))
.then(() => {
// Reset loading state to `idle`. If the fetch has
// resulted in new pending replies, the `hasPendingReplies`
// flag will switch the loading state to 'more-available'
if (status === 'finished') {
onChangeLoadingState('idle');
} else {
// Keep background fetch going if `isLongRunning` is true
scheduleRefresh(refresh, iteration + 1);
}
})
.catch(() => {
// Show an error if the fetch failed
onChangeLoadingState('error');
});
});
}, refresh.retry * 1000);
};
// Initialise a refresh
if (refreshHeader && isEnabled) {
scheduleRefresh(refreshHeader, 1);
onChangeLoadingState('loading');
}
return () => {
clearTimeout(timeoutId);
};
}, [onChangeLoadingState, dispatch, statusId, refreshHeader, isEnabled]);
}
/**
* This component fetches new post replies in the background
* and gives users the option to show them.
*
* The following three scenarios are handled:
*
* 1. When the browser tab is visible, replies are refetched periodically
* (more frequently for new posts, less frequently for old ones)
* 2. Replies are refetched when the browser tab is refocused
* after it was hidden or minimised
* 3. For remote posts, remote replies that might not yet be known to the
* server are imported & fetched using the AsyncRefresh API.
*/
export const RefreshController: React.FC<{
statusId: string;
}> = ({ statusId }) => {
statusCreatedAt: string;
isLocal: boolean;
}> = ({ statusId, statusCreatedAt, isLocal }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const refreshHeader = useAppSelector(
(state) => state.contexts.refreshing[statusId],
const refreshHeader = useAppSelector((state) =>
isLocal ? undefined : state.contexts.refreshing[statusId],
);
const hasPendingReplies = useAppSelector(
(state) => !!state.contexts.pendingReplies[statusId]?.length,
@@ -78,78 +219,52 @@ export const RefreshController: React.FC<{
dispatch(clearPendingReplies({ statusId }));
}, [dispatch, statusId]);
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
// Prevent too-frequent context calls
const debouncedFetchContext = useDebouncedCallback(
() => {
void dispatch(fetchContext({ statusId, prefetchOnly: true }));
},
// Ensure the debounce is a bit shorter than the auto-fetch interval
SHORT_AUTO_FETCH_REPLIES_INTERVAL - 500,
{
leading: true,
trailing: false,
},
);
const scheduleRefresh = (
refresh: AsyncRefreshHeader,
iteration: number,
) => {
timeoutId = setTimeout(() => {
void apiGetAsyncRefresh(refresh.id).then((result) => {
// At three scheduled refreshes, we consider the job
// long-running and attempt to fetch any new replies so far
const isLongRunning = iteration === 3;
const isDocumentVisible = useIsDocumentVisible({
onChange: (isVisible) => {
// Auto-fetch new replies when the page is refocused
if (isVisible && partialLoadingState !== 'loading' && !wasDismissed) {
debouncedFetchContext();
}
},
});
const { status, result_count } = result.async_refresh;
// Check for remote replies
useCheckForRemoteReplies({
statusId,
refreshHeader,
isEnabled: isDocumentVisible && !isLocal && !wasDismissed,
onChangeLoadingState: setLoadingState,
});
// If the refresh status is not finished and not long-running,
// we just schedule another refresh and exit
if (status === 'running' && !isLongRunning) {
scheduleRefresh(refresh, iteration + 1);
return;
}
// Only auto-fetch new replies if there's no ongoing remote replies check
const shouldAutoFetchReplies =
isDocumentVisible && partialLoadingState !== 'loading' && !wasDismissed;
// If refresh status is finished, clear `refreshHeader`
// (we don't want to do this if it's just a long-running job)
if (status === 'finished') {
dispatch(completeContextRefresh({ statusId }));
}
const autoFetchInterval = useMemo(
() =>
getIsThreadNew(statusCreatedAt)
? SHORT_AUTO_FETCH_REPLIES_INTERVAL
: LONG_AUTO_FETCH_REPLIES_INTERVAL,
[statusCreatedAt],
);
// Exit if there's nothing to fetch
if (result_count === 0) {
if (status === 'finished') {
setLoadingState('idle');
} else {
scheduleRefresh(refresh, iteration + 1);
}
return;
}
// A positive result count means there _might_ be new replies,
// so we fetch the context in the background to check if there
// are any new replies.
// If so, they will populate `contexts.pendingReplies[statusId]`
void dispatch(fetchContext({ statusId, prefetchOnly: true }))
.then(() => {
// Reset loading state to `idle`. If the fetch has
// resulted in new pending replies, the `hasPendingReplies`
// flag will switch the loading state to 'more-available'
if (status === 'finished') {
setLoadingState('idle');
} else {
// Keep background fetch going if `isLongRunning` is true
scheduleRefresh(refresh, iteration + 1);
}
})
.catch(() => {
// Show an error if the fetch failed
setLoadingState('error');
});
});
}, refresh.retry * 1000);
};
// Initialise a refresh
if (refreshHeader && !wasDismissed) {
scheduleRefresh(refreshHeader, 1);
setLoadingState('loading');
}
return () => {
clearTimeout(timeoutId);
};
}, [dispatch, statusId, refreshHeader, wasDismissed]);
useInterval(debouncedFetchContext, {
delay: autoFetchInterval,
isEnabled: shouldAutoFetchReplies,
});
useEffect(() => {
// Hide success message after a short delay
@@ -172,7 +287,7 @@ export const RefreshController: React.FC<{
};
}, [dispatch, statusId]);
const handleClick = useCallback(() => {
const showPending = useCallback(() => {
dispatch(showPendingReplies({ statusId }));
setLoadingState('success');
}, [dispatch, statusId]);
@@ -180,7 +295,7 @@ export const RefreshController: React.FC<{
if (loadingState === 'loading') {
return (
<div
className='load-more load-gap'
className='load-more load-more--large'
aria-busy
aria-live='polite'
aria-label={intl.formatMessage(messages.loadingInitial)}
@@ -196,7 +311,7 @@ export const RefreshController: React.FC<{
isActive={loadingState === 'more-available'}
message={intl.formatMessage(messages.moreFound)}
action={intl.formatMessage(messages.show)}
onActionClick={handleClick}
onActionClick={showPending}
onDismiss={dismissPrompt}
animateFrom='below'
/>
@@ -205,7 +320,7 @@ export const RefreshController: React.FC<{
isActive={loadingState === 'error'}
message={intl.formatMessage(messages.error)}
action={intl.formatMessage(messages.retry)}
onActionClick={handleClick}
onActionClick={showPending}
onDismiss={dismissPrompt}
animateFrom='below'
/>

View File

@@ -161,8 +161,7 @@ class Status extends ImmutablePureComponent {
componentDidMount () {
attachFullscreenListener(this.onFullScreenChange);
this.props.dispatch(fetchStatus(this.props.params.statusId));
this._scrollStatusIntoView();
this.props.dispatch(fetchStatus(this.props.params.statusId, { forceFetch: true }));
}
static getDerivedStateFromProps(props, state) {
@@ -170,7 +169,7 @@ class Status extends ImmutablePureComponent {
let updated = false;
if (props.params.statusId && state.statusId !== props.params.statusId) {
props.dispatch(fetchStatus(props.params.statusId));
props.dispatch(fetchStatus(props.params.statusId, { forceFetch: true }));
update.threadExpanded = undefined;
update.statusId = props.params.statusId;
updated = true;
@@ -329,6 +328,12 @@ class Status extends ImmutablePureComponent {
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
};
handleQuote = (status) => {
const { dispatch } = this.props;
dispatch(quoteComposeById(status.get('id')));
};
handleEditClick = (status) => {
const { dispatch, askReplyConfirmation } = this.props;
@@ -506,35 +511,13 @@ class Status extends ImmutablePureComponent {
this.statusNode = c;
};
_scrollStatusIntoView () {
const { status, multiColumn } = this.props;
if (status) {
requestIdleCallback(() => {
this.statusNode?.scrollIntoView(true);
// In the single-column interface, `scrollIntoView` will put the post behind the header,
// so compensate for that.
if (!multiColumn) {
const offset = document.querySelector('.column-header__wrapper')?.getBoundingClientRect()?.bottom;
if (offset) {
const scrollingElement = document.scrollingElement || document.body;
scrollingElement.scrollBy(0, -offset);
}
}
});
}
}
componentDidUpdate (prevProps) {
const { status, ancestorsIds, descendantsIds } = this.props;
const { status, descendantsIds } = this.props;
if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) {
this._scrollStatusIntoView();
}
const isSameStatus = status && (prevProps.status?.get('id') === status.get('id'));
// Only highlight replies after the initial load
if (prevProps.descendantsIds.length) {
if (prevProps.descendantsIds.length && isSameStatus) {
const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds);
if (newRepliesIds.length) {
@@ -598,14 +581,6 @@ class Status extends ImmutablePureComponent {
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
const isIndexable = !status.getIn(['account', 'noindex']);
if (!isLocal) {
remoteHint = (
<RefreshController
statusId={status.get('id')}
/>
);
}
const handlers = {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
@@ -653,6 +628,8 @@ class Status extends ImmutablePureComponent {
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
pictureInPicture={pictureInPicture}
ancestors={this.props.ancestorsIds.length}
multiColumn={multiColumn}
/>
<ActionBar
@@ -665,6 +642,7 @@ class Status extends ImmutablePureComponent {
onDelete={this.handleDeleteClick}
onRevokeQuote={this.handleRevokeQuoteClick}
onQuotePolicyChange={this.handleQuotePolicyChange}
onQuote={this.handleQuote}
onEdit={this.handleEditClick}
onDirect={this.handleDirectClick}
onMention={this.handleMentionClick}
@@ -679,7 +657,12 @@ class Status extends ImmutablePureComponent {
</Hotkeys>
{descendants}
{remoteHint}
<RefreshController
isLocal={isLocal}
statusId={status.get('id')}
statusCreatedAt={status.get('created_at')}
/>
</div>
</ScrollContainer>

View File

@@ -14,7 +14,6 @@ import { IconButton } from 'flavours/glitch/components/icon_button';
import InlineAccount from 'flavours/glitch/components/inline_account';
import MediaAttachments from 'flavours/glitch/components/media_attachments';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import emojify from 'flavours/glitch/features/emoji/emoji';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
@@ -48,13 +47,8 @@ class CompareHistoryModal extends PureComponent {
const { index, versions, language, onClose } = this.props;
const currentVersion = versions.get(index);
const emojiMap = currentVersion.get('emojis').reduce((obj, emoji) => {
obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
return obj;
}, {});
const content = emojify(currentVersion.get('content'), emojiMap);
const spoilerContent = emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap);
const content = currentVersion.get('content');
const spoilerContent = escapeTextContentForBrowser(currentVersion.get('spoiler_text'));
const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />;
const formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
@@ -99,7 +93,7 @@ class CompareHistoryModal extends PureComponent {
<EmojiHTML
as="span"
className='poll__option__text translate'
htmlString={emojify(escapeTextContentForBrowser(option.get('title')), emojiMap)}
htmlString={escapeTextContentForBrowser(option.get('title'))}
lang={language}
/>
</label>

View File

@@ -26,6 +26,7 @@ export const ConfirmationModal: React.FC<
onSecondary?: () => void;
onConfirm: () => void;
closeWhenConfirm?: boolean;
extraContent?: React.ReactNode;
} & BaseConfirmationModalProps
> = ({
title,
@@ -37,6 +38,7 @@ export const ConfirmationModal: React.FC<
secondary,
onSecondary,
closeWhenConfirm = true,
extraContent,
}) => {
const handleClick = useCallback(() => {
if (closeWhenConfirm) {
@@ -57,6 +59,8 @@ export const ConfirmationModal: React.FC<
<div className='safety-action-modal__confirmation'>
<h1>{title}</h1>
{message && <p>{message}</p>}
{extraContent}
</div>
</div>

View File

@@ -0,0 +1,88 @@
import { forwardRef, useCallback, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { submitCompose } from '@/flavours/glitch/actions/compose';
import { changeSetting } from '@/flavours/glitch/actions/settings';
import { CheckBox } from '@/flavours/glitch/components/check_box';
import { useAppDispatch } from '@/flavours/glitch/store';
import { ConfirmationModal } from './confirmation_modal';
import type { BaseConfirmationModalProps } from './confirmation_modal';
import classes from './styles.module.css';
export const PRIVATE_QUOTE_MODAL_ID = 'quote/private_notify';
const messages = defineMessages({
title: {
id: 'confirmations.private_quote_notify.title',
defaultMessage: 'Share with followers and mentioned users?',
},
message: {
id: 'confirmations.private_quote_notify.message',
defaultMessage:
'The person you are quoting and other mentions ' +
"will be notified and will be able to view your post, even if they're not following you.",
},
confirm: {
id: 'confirmations.private_quote_notify.confirm',
defaultMessage: 'Publish post',
},
cancel: {
id: 'confirmations.private_quote_notify.cancel',
defaultMessage: 'Back to editing',
},
});
export const PrivateQuoteNotify = forwardRef<
HTMLDivElement,
BaseConfirmationModalProps
>(
(
{ onClose },
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_ref,
) => {
const intl = useIntl();
const [dismiss, setDismissed] = useState(false);
const handleDismissToggle = useCallback(() => {
setDismissed((prev) => !prev);
}, []);
const dispatch = useAppDispatch();
const handleConfirm = useCallback(() => {
dispatch(submitCompose());
if (dismiss) {
dispatch(
changeSetting(['dismissed_banners', PRIVATE_QUOTE_MODAL_ID], true),
);
}
}, [dismiss, dispatch]);
return (
<ConfirmationModal
title={intl.formatMessage(messages.title)}
message={intl.formatMessage(messages.message)}
confirm={intl.formatMessage(messages.confirm)}
cancel={intl.formatMessage(messages.cancel)}
onConfirm={handleConfirm}
onClose={onClose}
extraContent={
<label className={classes.checkbox_wrapper}>
<CheckBox
value='hide'
checked={dismiss}
onChange={handleDismissToggle}
/>{' '}
<FormattedMessage
id='confirmations.private_quote_notify.do_not_show_again'
defaultMessage="Don't show me this message again"
/>
</label>
}
/>
);
},
);
PrivateQuoteNotify.displayName = 'PrivateQuoteNotify';

Some files were not shown because too many files have changed in this diff Show More