Compare commits

..

172 Commits

Author SHA1 Message Date
github-actions[bot]
6bb4113d0a
New Crowdin Translations (automated) (#35428)
Some checks are pending
Bundler Audit / security (push) Waiting to run
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
Haml Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-21 09:01:34 +00:00
Matt Jankowski
3e76f01db4
Use bundler version 2.7.0 (#35430) 2025-07-21 08:07:53 +00:00
diondiondion
cf580d8c90
Don't require JSDoc params & return in TS (#35426)
Some checks failed
Check i18n / check-i18n (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / ImageMagick tests (.ruby-version) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.2) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
2025-07-18 18:34:04 +00:00
github-actions[bot]
dbd0c3cbd9
New Crowdin Translations (automated) (#35420)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-18 07:52:05 +00:00
diondiondion
3771f9e04b
fix: Fix quote posts styling on notifications page (#35411)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Chromatic / Run Chromatic (push) Has been cancelled
CSS Linting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
2025-07-17 17:28:30 +00:00
diondiondion
a842b14c84
refactor: Disable useDrag hook when main menu is not openable (#35414) 2025-07-17 12:39:40 +00:00
diondiondion
138746bdcc
fix: Add lang attribute to current composer language in alt text modal (#35412) 2025-07-17 11:12:54 +00:00
Echo
9e6a9efe10
Replace Ruby Vite plugins (#35195)
Co-authored-by: Renaud Chaput <renchap@gmail.com>
2025-07-17 10:04:04 +00:00
renovate[bot]
19626ad89f
chore(deps): update dependency httplog to v1.7.1 (#35406)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Bundler Audit / security (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 09:32:57 +00:00
github-actions[bot]
7e2d92284c
New Crowdin Translations (automated) (#35408)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-17 09:31:22 +00:00
renovate[bot]
20fb6bd788
chore(deps): update docker.io/ruby docker tag to v3.4.5 (#35397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 09:03:40 +00:00
diondiondion
faffb73cbd
fix: Improve a11y of custom select menus in notifications settings (#35403) 2025-07-17 08:56:00 +00:00
renovate[bot]
02a4e30594
chore(deps): update dependency propshaft to v1.2.0 (#35398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 08:48:10 +00:00
renovate[bot]
f10b522f0c
chore(deps): update dependency ruby to v3.4.5 (#35395)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 08:47:39 +00:00
github-actions[bot]
331599fa2b
New Crowdin Translations (automated) (#35399)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-16 12:24:09 +00:00
diondiondion
558b9c90a6
fix: Fix selected item in poll select menus is unreadable in Firefox (#35402) 2025-07-16 12:03:39 +00:00
Renaud Chaput
7d2dda97b3
Remove the "to triage" label status (#35394)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
2025-07-16 07:17:21 +00:00
diondiondion
74fc4dbacf
refactor: Only remove pointer-events when necessary (#35390)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Crowdin / Upload translations / upload-translations (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
2025-07-15 15:57:31 +00:00
diondiondion
07912a1cb7
Update age limit wording (#35387) 2025-07-15 15:46:44 +00:00
Claire
d36bf3b6fb
Fix support for quote verification in implicit status updates (#35384) 2025-07-15 15:36:12 +00:00
Claire
594976a538
Refactor ActivityPub::Activity::Accept and ActivityPub::Activity::Reject specs (#35382) 2025-07-15 13:18:37 +00:00
Matt Jankowski
0efb889a9c
Extract constant for attribution domains limit in account (#35350) 2025-07-15 13:08:24 +00:00
Claire
c0eabe289b
Always give local quote of remote posts a quote request URI (#35383) 2025-07-15 13:01:03 +00:00
Claire
5bbc3c5ebb
Fix quoteAuthorization type in JSON-LD context (#35380) 2025-07-15 09:32:02 +00:00
github-actions[bot]
d5e2cf5d3c
New Crowdin Translations (automated) (#35379)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
JavaScript Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-15 08:16:56 +00:00
diondiondion
82a6ff091f
fix: Improve Dropdown component accessibility (#35373) 2025-07-15 07:52:34 +00:00
renovate[bot]
4b8e60682d
fix(deps): update dependency react-select to v5.10.2 (#35352)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-15 06:32:38 +00:00
renovate[bot]
6c2db9b1cf
fix(deps): update dependency vite-plugin-static-copy to v3.1.1 (#35367)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-15 06:32:00 +00:00
Matt Jankowski
30344d6abf
Confirm User#login_activities in auth/sessions spec (#35372) 2025-07-15 06:31:00 +00:00
Matt Jankowski
1637297085
Add coverage for CustomFilterStatus model (#35374) 2025-07-15 06:28:40 +00:00
Matt Jankowski
dec1fb71f4
Add coverage for FollowRecommendationMute model (#35376) 2025-07-15 06:27:36 +00:00
Matt Jankowski
7273f6c03c
Move shared params to common method in admin/reports/actions (#35353)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
2025-07-14 09:23:18 +00:00
Matt Jankowski
a3ffd2edf8
Use sequence for unique names on webauthn cred fabricator (#35356) 2025-07-14 09:20:50 +00:00
renovate[bot]
a2c5eace88
chore(deps): update dependency annotaterb to v4.17.0 (#35368)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Bundler Audit / security (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 07:07:21 +00:00
github-actions[bot]
a643d9d498
New Crowdin Translations (automated) (#35358)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-14 06:21:56 +00:00
Claire
3b52dca405
Fix quote attributes missing from Mastodon's context (#35354)
Some checks failed
Check i18n / check-i18n (push) Has been cancelled
Chromatic / Run Chromatic (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / ImageMagick tests (.ruby-version) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.2) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Bundler Audit / security (push) Has been cancelled
2025-07-11 16:35:06 +00:00
Echo
853a0c466e
Make bio hashtags open the local page instead of the remote instance (#35349) 2025-07-11 15:18:34 +00:00
Echo
94bceb8683
Expose enabled features to the frontend (#35348)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
2025-07-11 13:15:22 +00:00
Claire
88b0f3a172
Simplify DatabaseViewRecord.refresh (#35252) 2025-07-11 08:36:05 +00:00
github-actions[bot]
b69b5ba775
New Crowdin Translations (automated) (#35344)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-11 08:14:39 +00:00
Matt Jankowski
c442589593
Use ActiveModel::Attributes in FollowLimitable concern (#35327)
Some checks failed
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Bundler Audit / security (push) Has been cancelled
CSS Linting / lint (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled
2025-07-10 07:40:56 +00:00
renovate[bot]
28633a504a
chore(deps): update dependency json-schema to v5.2.1 (#35337)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 07:38:07 +00:00
Matt Jankowski
ad78701b6f
Mark private methods in AnnualReport::TopStatuses (#35256) 2025-07-10 07:35:40 +00:00
Matt Jankowski
1496488771
Add Status#not_replying_to_account scope for annual report classes (#35257) 2025-07-10 07:35:04 +00:00
renovate[bot]
dd3d958e75
fix(deps): update dependency core-js to v3.44.0 (#35284)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 07:23:54 +00:00
github-actions[bot]
b363a3651d
New Crowdin Translations (automated) (#35335)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-10 07:23:27 +00:00
renovate[bot]
86645fc14c
chore(deps): update dependency rubocop to v1.78.0 (#35289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 07:23:23 +00:00
Matt Jankowski
f9beecb343
Improve Accounts CLI prune spec (#35302) 2025-07-10 07:23:09 +00:00
Matt Jankowski
4ecfbd3920
Add Status.only_polls (and without polls) scope (#35330) 2025-07-10 07:13:22 +00:00
Claire
a315934314
Fix styling of external log-in button (#35320) 2025-07-10 06:56:40 +00:00
Claire
e9170e2de1
Bump version to v4.4.1 (#35329)
Some checks failed
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
Haml Linting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Crowdin / Upload translations / upload-translations (push) Has been cancelled
2025-07-09 16:22:25 +00:00
Claire
5cfc1fabcf
Fix nearly every sub-directory being crawled as part of Vite build (#35323) 2025-07-09 14:34:16 +00:00
David Roetzel
786b12e379
Relax error restriction in initializer (#35321) 2025-07-09 14:22:47 +00:00
Claire
e7c5c25de8
Fix replying from media modal or pop-in-player tagging user @undefined (#35317) 2025-07-09 12:13:51 +00:00
Echo
a1e8813522
Emoji Indexing and Search (#35253) 2025-07-09 09:55:41 +00:00
github-actions[bot]
76c1446416
New Crowdin Translations (automated) (#35310)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-09 09:10:55 +00:00
Claire
8bd2c87399
Fix support for special characters in various environment variables (#35314)
Co-authored-by: Matt Jankowski <matt@jankowski.online>
2025-07-09 08:58:41 +00:00
Matt Jankowski
1e2d77f2c7
Use if_exists: true when removing duplicate indexes (#35309) 2025-07-09 08:45:29 +00:00
Matt Jankowski
fb6c22f5c2
Use touch to record viewing annual report (#35296) 2025-07-09 08:04:00 +00:00
Matt Jankowski
f7259f625f
Prefer on: :update in Tag validation declaration (#35297) 2025-07-09 08:03:39 +00:00
Claire
b628a98d32
Bump version to v4.4.0 (#35291) 2025-07-08 14:26:43 +00:00
Miguel Landaeta
d8fa807998
Bump linzer to 0.7.7 (#35258) 2025-07-08 13:04:16 +00:00
Echo
ef66d8379c
Add option to set emoji preferences behind feature flag (#35282) 2025-07-08 10:51:11 +00:00
David Roetzel
8ee6cee36e
Better error response to malformed headers (#35278) 2025-07-08 09:31:04 +00:00
github-actions[bot]
71b2120e5c
New Crowdin Translations (automated) (#35286)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-08 09:22:38 +00:00
renovate[bot]
b10078633c
chore(deps): update dependency libvips to v8.17.1 (#35283)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 08:35:54 +00:00
diondiondion
b5eebd4d2b
fix: Fix can't skip search field by tabbing (#35281) 2025-07-07 15:10:51 +00:00
Claire
fdefc4d2b4
Add ability to manually trigger i18n uploads (#35279) 2025-07-07 09:22:22 +00:00
github-actions[bot]
f6b2609353
New Crowdin Translations (automated) (#35269)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-07 08:21:51 +00:00
Matt Jankowski
bdffdcb12f
Remove unused scopes in Account model (#35276) 2025-07-07 08:07:01 +00:00
Claire
1ebb87a6a8
Fix incorrect name in scheduler configuration (#35263) 2025-07-04 07:51:01 +00:00
github-actions[bot]
83660ee381
New Crowdin Translations (automated) (#35261)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-04 07:34:54 +00:00
David Roetzel
1fa72d6c44
Raise better exception on FASP error responses (#35262) 2025-07-04 07:25:42 +00:00
Andy Piper
5a7c0d42f7
Add specific language confirming that we test with BrowserStack and Chromatic (#35248)
Signed-off-by: Andy Piper <andypiper@users.noreply.github.com>
2025-07-03 20:51:32 +00:00
Matt Jankowski
e8d2432e6a
Fix intermittent failure of TOS model spec from effective date collision (#35244) 2025-07-03 16:28:47 +00:00
Matt Jankowski
2af17adc34
Use ActiveModel::Attributes in admin/status_batch_action (#35255) 2025-07-03 14:43:36 +00:00
Claire
e97f43399b
Fix error handling for blank actions in account moderation action form (#35246) 2025-07-03 14:42:48 +00:00
github-actions[bot]
c66c5fd73d
New Crowdin Translations (automated) (#35250)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-03 09:47:57 +00:00
diondiondion
3c0767f543
fix: Remove focus highlight when status is clicked in light mode (#35251) 2025-07-03 07:51:12 +00:00
renovate[bot]
70cd1fdc63
fix(deps): update dependency vite-plugin-pwa to v1.0.1 (#35223)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 07:32:51 +00:00
renovate[bot]
39028dde40
chore(deps): update dependency scenic to v1.9.0 (#35226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 07:32:15 +00:00
Matt Jankowski
6e39b5ef04
Use ActiveModel::Attributes for admin/account_action boolean values (#35247) 2025-07-03 07:28:07 +00:00
Matt Jankowski
49db8a9662
Use Account#targeted_reports association where needed (#35249) 2025-07-03 07:28:03 +00:00
Andy Piper
2cfa6cb0e0
Update README with testing tool references. (#35236)
Signed-off-by: Andy Piper <andypiper@users.noreply.github.com>
2025-07-02 12:00:15 +00:00
Matt Jankowski
1ae3510ede
Add coverage for TOS interstitial interruption flow of web app controller concern (#35235) 2025-07-02 09:21:32 +00:00
github-actions[bot]
6f1135d763
New Crowdin Translations (automated) (#35238)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-02 09:17:03 +00:00
Echo
52bc2f64f4
Import Emojibase data (#35229) 2025-07-02 08:58:39 +00:00
renovate[bot]
b1375328e1
chore(deps): update dependency faker to v3.5.2 (#35239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 08:02:33 +00:00
renovate[bot]
9443e2cc4b
chore(deps): update dependency opentelemetry-instrumentation-http to v0.25.1 (#35240)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 08:02:30 +00:00
Renaud Chaput
3a533c6c8d
Bump version to 4.5.0-alpha.1 (#35231) 2025-07-02 08:00:43 +00:00
Matt Jankowski
c047014214
Add coverage for valid_locale_or_nil languages helper method (#34866) 2025-07-02 07:34:42 +00:00
Claire
68b05e994f
Fix error on log-in from old users requiring ToS interstitial when said ToS has been removed (#35233) 2025-07-01 17:43:59 +00:00
Claire
a203a05eb1
Fix missing newline in changelog (#35227) 2025-07-01 12:31:55 +00:00
Claire
68090cd8be
Bump version to v4.4.0-rc.1 (#35196) 2025-07-01 09:21:32 +00:00
github-actions[bot]
dd064aaa36
New Crowdin Translations (automated) (#35224)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-01 09:02:26 +00:00
diondiondion
e6e8974785
fix: Fix column header overlapping mobile menu on old Safari (#35225) 2025-07-01 08:53:43 +00:00
Renaud Chaput
498af63b85
chore: validate the project funding.json association (#35221) 2025-06-30 16:21:52 +00:00
David Roetzel
c357a7f8d6
Add optional bulk mailer settings (#35203) 2025-06-30 14:49:14 +00:00
David Roetzel
bae258925c
Persist follow recommendations from FASP (#35218) 2025-06-30 13:39:36 +00:00
diondiondion
e8a603b18f
fix: Fix popover/dialog backgrounds not blurred on older Webkit browsers (#35220) 2025-06-30 12:16:54 +00:00
renovate[bot]
f00c8e3245
chore(deps): update dependency haml_lint to v0.64.0 (#35215)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 11:25:47 +00:00
Claire
153af19f55
Add specs for PublicFileServer middleware (#35219) 2025-06-30 11:23:11 +00:00
Matt Jankowski
964916c71b
Add coverage for TermsOfService scopes/validations (#35204) 2025-06-30 10:28:14 +00:00
github-actions[bot]
8782e860b6
New Crowdin Translations (automated) (#35208)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-06-30 09:33:15 +00:00
renovate[bot]
641c0c6393
fix(deps): update dependency pg to v8.16.3 (#35213)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 09:33:09 +00:00
renovate[bot]
0383100b0e
fix(deps): update dependency ws to v8.18.3 (#35214)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 09:33:03 +00:00
Jeong Arm
87db28cebc
Fix unexpected "cache-control: no-cache" header in public file server (#35209) 2025-06-30 09:06:18 +00:00
David Roetzel
ac4b735c67
Add FASP account search support (#34033) 2025-06-30 07:42:34 +00:00
github-actions[bot]
6d017dbf10
New Crowdin Translations (automated) (#35202)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-06-27 08:37:39 +00:00
renovate[bot]
0d650780e2
fix(deps): update dependency postcss-preset-env to v10.2.4 (#35194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 14:17:31 +00:00
Eugen Rochko
1804a87193
Change terms of service generator to not be displayed (#35127) 2025-06-26 13:26:41 +00:00
diondiondion
9576434d47
fix: Fix outdated icon in notifications permissions banner (#35193) 2025-06-26 13:25:12 +00:00
diondiondion
b804ed0cba
refactor: Tweak wording of "discard draft?" confirmation dialogs (#35192) 2025-06-26 13:03:24 +00:00
David Roetzel
48451b782d
Move email env var reading to yml files (#35191) 2025-06-26 12:18:30 +00:00
Claire
2e0a00ab46
Fix search operators sometimes getting lost (#35190) 2025-06-26 10:35:49 +00:00
github-actions[bot]
a9f2ec45da
New Crowdin Translations (automated) (#35189)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-06-26 08:40:39 +00:00
diondiondion
c1ef1f62d5
fix: Prevent scrolling behind menus and modals in Safari iOS (#35183) 2025-06-25 19:22:11 +00:00
Claire
dbb20f76a7
Fix crash in development environment with no prebuilt assets and no vite dev server running (#35177) 2025-06-25 14:20:07 +00:00
renovate[bot]
91741214e1
chore(deps): update node.js to 22.17 (#35166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 14:01:46 +00:00
diondiondion
8fa32ca8ba
fix: Fix search icon overlapping text on Trending page (#35175) 2025-06-25 13:26:44 +00:00
Matt Jankowski
8285194451
Move layout setup for OAuth views to controllers (#35176) 2025-06-25 13:26:17 +00:00
Claire
392eaf1010
Ensure consistent ordering of rule translations in admin interface (#35174) 2025-06-25 13:15:59 +00:00
diondiondion
c6dddbb66e
fix: Prevent content scrolling behind main menu (part 1) (#35173) 2025-06-25 12:12:49 +00:00
Echo
c52848b444
Storybook Helpers (#35158) 2025-06-25 11:20:11 +00:00
Claire
0a7418e6d8
Change rule translation interface to display english name and populate empty translations (#35170) 2025-06-25 10:02:19 +00:00
Emelia Smith
72f2f35bfb
Implement Instance Moderation Notes (#31529) 2025-06-25 08:15:44 +00:00
github-actions[bot]
0f9f27972d
New Crowdin Translations (automated) (#35165)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-06-25 08:05:57 +00:00
Matt Jankowski
9f16f41678
Remove patch for unsupported redis version (#35155) 2025-06-25 07:53:38 +00:00
Matt Jankowski
47fda2df2c
Update OAuth inflection to match spec (#35160) 2025-06-25 07:52:30 +00:00
Matt Jankowski
377289c961
Add coverage for doorkeeper model extensions (#35161) 2025-06-25 07:50:20 +00:00
Matt Jankowski
f852da50f6
Add User#email_domain method to extract domain from email address (#35159) 2025-06-25 07:22:19 +00:00
diondiondion
8ba1487f30
fix: Fix inaccessible "Clear search" button (#35152) 2025-06-24 14:36:05 +00:00
diondiondion
644da36336
feat: More obvious loading state when submitting a post (#35153) 2025-06-24 14:08:48 +00:00
diondiondion
fb5b8ae0a5
fix: Improve status focus indicators (#35150) 2025-06-24 09:34:43 +00:00
Matt Jankowski
fd902c04f7
Use config_for for omniauth enabled values (#35015) 2025-06-24 09:32:13 +00:00
Echo
8ee8231a43
Adds Redux and React-Intl to storybook (#35094) 2025-06-24 09:31:27 +00:00
Claire
c4128d89c9
Fix Firefox not updating spellcheck language in textarea (#35148) 2025-06-24 09:08:00 +00:00
Claire
9954acf61d
Fix “Alt text” button submitting form in moderation interface (#35147) 2025-06-24 09:04:26 +00:00
renovate[bot]
0276354775
fix(deps): update dependency @vitejs/plugin-react to v4.6.0 (#35137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 08:05:24 +00:00
github-actions[bot]
dba636da7a
New Crowdin Translations (automated) (#35144)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-06-24 07:58:50 +00:00
renovate[bot]
43e9186e5d
chore(deps): update dependency haml_lint to v0.63.0 (#35146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 07:54:49 +00:00
Matt Jankowski
0338733531
Add model coverage and scopes to RuleTranslation class (#35098) 2025-06-24 07:44:50 +00:00
Eugen Rochko
1be48d0cab
Change terms of service e-mail job to be iterable (#35126) 2025-06-24 07:41:39 +00:00
renovate[bot]
e60014ed9c
fix(deps): update dependency pg to v8.16.2 (#35111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 07:40:46 +00:00
Matt Jankowski
0d7f1584bc
Move remaining _map method specs from account to mappings spec (#35142) 2025-06-24 07:40:24 +00:00
Matt Jankowski
36f01af6c4
Add Status#only_reblogs scope for annual report classes (#35141) 2025-06-24 06:54:55 +00:00
renovate[bot]
16057f550d
fix(deps): update dependency pg-connection-string to v2.9.1 (#35112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 06:51:37 +00:00
renovate[bot]
e79ecabd0a
chore(deps): update dependency strong_migrations to v2.4.0 (#35140)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 06:51:09 +00:00
Matt Jankowski
c023ebc87a
Limit count to pending&trending on admin/trends/tags page (#35120) 2025-06-23 13:30:12 +00:00
Matt Jankowski
ebc6897afb
Extract method to DRY up month/year grouping in AnnualReport::TimeSeries class (#35113) 2025-06-23 12:18:29 +00:00
Matt Jankowski
b08ccaa5b3
Extract Account::Mappings concern from "interactions" (#35119) 2025-06-23 12:02:14 +00:00
diondiondion
b9b1500fc5
fix: Update hashtags when (un)following a hashtag (#35101) 2025-06-23 11:44:59 +00:00
diondiondion
d28a4428b5
refactor: Use new main menu as "Getting started" column in Advanced Web UI (#35117) 2025-06-23 09:59:47 +00:00
diondiondion
6166e61638
fix: Keep user on Compose page when changing screen size, #34937 (#35105) 2025-06-23 09:53:21 +00:00
github-actions[bot]
e5aa8c1ff3
New Crowdin Translations (automated) (#35090)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-06-23 08:43:53 +00:00
Matt Jankowski
8837fd8c54
Update rubocop to version 1.77.0 (#35128) 2025-06-23 07:40:11 +00:00
Claire
ac039d5f13
Fix clicking a status multiple times causing duplicate entries in browser history (#35118) 2025-06-21 09:00:38 +00:00
David Roetzel
adf812efb3
Fix missing terms of services link (#35115) 2025-06-21 08:59:47 +00:00
diondiondion
3f743b1a07
fix: Fix SCSS lint warnings (#35102) 2025-06-21 08:58:12 +00:00
renovate[bot]
204ff46f7e
chore(deps): update dependency rspec-rails to v8.0.1 (#35110)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-20 10:20:24 +00:00
Matt Jankowski
54f9a1b43b
Extract secret size constants in Webhook model (#35104) 2025-06-20 10:05:24 +00:00
Matt Jankowski
e9b1c1edfe
Simplify WebauthnCredential constant limit math (#35107) 2025-06-20 10:04:14 +00:00
David Roetzel
0ec6c26af3
Fix error when RFC9421 signatures are used (#35109) 2025-06-20 09:44:26 +00:00
diondiondion
08597a1819
fix: Prevent click on content warning banner in notification from opening the post (#35096) 2025-06-20 09:41:24 +00:00
diondiondion
102a7635d6
fix: Prevent mobile navbar from overscrolling (#35074) 2025-06-18 11:55:16 +00:00
Matt Jankowski
b1fe35d7d2
Update rubocop to version 1.76.2 (#35070) 2025-06-18 09:54:17 +00:00
renovate[bot]
adf01b021c
chore(deps): update dependency debug to v1.11.0 (#35079)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 09:53:05 +00:00
Matt Jankowski
aac51707d1
Use ENV.fetch for ffmpeg/ffprobe defaults (#35081) 2025-06-18 09:43:25 +00:00
renovate[bot]
aa345c4630
chore(deps): update dependency opentelemetry-instrumentation-http to '~> 0.25.0' (#35088)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 09:42:53 +00:00
renovate[bot]
70c6e09e0f
chore(deps): update dependency annotaterb to v4.16.0 (#35087)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 09:42:17 +00:00
renovate[bot]
1a7fd2f446
chore(deps): update dependency faraday-httpclient to v2.0.2 (#35082)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 09:41:24 +00:00
diondiondion
474464ffff
fix: Tweak focus style & spacing of list/hashtags expand/collapse button (#35075)
Some checks failed
Check i18n / check-i18n (push) Has been cancelled
Chromatic / Run Chromatic (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
CSS Linting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / ImageMagick tests (.ruby-version) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.2) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Bundler Audit / security (push) Has been cancelled
2025-06-17 17:54:23 +00:00
493 changed files with 10082 additions and 4622 deletions

View File

@ -0,0 +1 @@
https://joinmastodon.org/funding.json

View File

@ -1,6 +1,6 @@
name: Bug Report (Web Interface) name: Bug Report (Web Interface)
description: There is a problem using Mastodon's web interface. description: There is a problem using Mastodon's web interface.
labels: ['status/to triage', 'area/web interface'] labels: ['area/web interface']
type: Bug type: Bug
body: body:
- type: markdown - type: markdown

View File

@ -1,7 +1,6 @@
name: Bug Report (server / API) name: Bug Report (server / API)
description: | description: |
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc. There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
labels: ['status/to triage']
type: 'Bug' type: 'Bug'
body: body:
- type: markdown - type: markdown

View File

@ -14,6 +14,7 @@ on:
- config/locales/devise.en.yml - config/locales/devise.en.yml
- config/locales/doorkeeper.en.yml - config/locales/doorkeeper.en.yml
- .github/workflows/crowdin-upload.yml - .github/workflows/crowdin-upload.yml
workflow_dispatch:
jobs: jobs:
upload-translations: upload-translations:

2
.nvmrc
View File

@ -1 +1 @@
22.16 22.17

View File

@ -81,3 +81,6 @@ AUTHORS.md
# Process a few selected JS files # Process a few selected JS files
!lint-staged.config.js !lint-staged.config.js
# Ignore config YAML files that include ERB/ruby code prettier does not understand
/config/email.yml

View File

@ -23,5 +23,6 @@ RSpec/SpecFilePathFormat:
ActivityPub: activitypub ActivityPub: activitypub
DeepL: deepl DeepL: deepl
FetchOEmbedService: fetch_oembed_service FetchOEmbedService: fetch_oembed_service
OAuth: oauth
OEmbedController: oembed_controller OEmbedController: oembed_controller
OStatus: ostatus OStatus: ostatus

View File

@ -1,6 +1,6 @@
# This configuration was generated by # This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.76.1. # using RuboCop version 1.77.0.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
@ -28,7 +28,7 @@ Metrics/PerceivedComplexity:
Max: 27 Max: 27
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowedVars. # Configuration parameters: AllowedVars, DefaultToNil.
Style/FetchEnvVar: Style/FetchEnvVar:
Exclude: Exclude:
- 'config/initializers/paperclip.rb' - 'config/initializers/paperclip.rb'

View File

@ -1 +1 @@
3.4.4 3.4.5

View File

@ -1,3 +1,5 @@
import { resolve } from 'node:path';
import type { StorybookConfig } from '@storybook/react-vite'; import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = { const config: StorybookConfig = {
@ -11,6 +13,27 @@ const config: StorybookConfig = {
name: '@storybook/react-vite', name: '@storybook/react-vite',
options: {}, options: {},
}, },
staticDirs: [
'./static',
// We need to manually specify the assets because of the symlink in public/sw.js
...[
'avatars',
'emoji',
'headers',
'sounds',
'badge.png',
'loading.gif',
'loading.png',
'oops.gif',
'oops.png',
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
],
viteFinal(config) {
// For an unknown reason, Storybook does not use the root
// from the Vite config so we need to set it manually.
config.root = resolve(__dirname, '../app/javascript');
return config;
},
}; };
export default config; export default config;

View File

@ -1,29 +0,0 @@
import type { Preview } from '@storybook/react-vite';
// If you want to run the dark theme during development,
// you can change the below to `/application.scss`
import '../app/javascript/styles/mastodon-light.scss';
const preview: Preview = {
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
parameters: {
layout: 'centered',
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
},
};
export default preview;

146
.storybook/preview.tsx Normal file
View File

@ -0,0 +1,146 @@
import { useEffect, useState } from 'react';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import type { Preview } from '@storybook/react-vite';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { action } from 'storybook/actions';
import type { LocaleData } from '@/mastodon/locales';
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
import { defaultMiddleware } from '@/mastodon/store/store';
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
// If you want to run the dark theme during development,
// you can change the below to `/application.scss`
import '../app/javascript/styles/mastodon-light.scss';
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
query: { as: 'json' },
});
// Initialize MSW
initialize({
onUnhandledRequest: unhandledRequestHandler,
});
const preview: Preview = {
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
globalTypes: {
locale: {
description: 'Locale for the story',
toolbar: {
title: 'Locale',
icon: 'globe',
items: Object.keys(localeFiles).map((path) =>
path.replace('/mastodon/locales/', '').replace('.json', ''),
),
dynamicTitle: true,
},
},
},
initialGlobals: {
locale: 'en',
},
decorators: [
(Story, { parameters }) => {
const { state = {} } = parameters;
let reducer = rootReducer;
if (typeof state === 'object' && state) {
reducer = reducerWithInitialState(state as Record<string, unknown>);
}
const store = configureStore({
reducer,
middleware(getDefaultMiddleware) {
return getDefaultMiddleware(defaultMiddleware);
},
});
return (
<Provider store={store}>
<Story />
</Provider>
);
},
(Story, { globals }) => {
const currentLocale = (globals.locale as string) || 'en';
const [messages, setMessages] = useState<
Record<string, Record<string, string>>
>({});
const currentLocaleData = messages[currentLocale];
useEffect(() => {
async function loadLocaleData() {
const { default: localeFile } = (await import(
`@/mastodon/locales/${currentLocale}.json`
)) as { default: LocaleData['messages'] };
setMessages((prevLocales) => ({
...prevLocales,
[currentLocale]: localeFile,
}));
}
if (!currentLocaleData) {
void loadLocaleData();
}
}, [currentLocale, currentLocaleData]);
return (
<IntlProvider
locale={currentLocale}
messages={currentLocaleData}
textComponent='span'
>
<Story />
</IntlProvider>
);
},
(Story) => (
<MemoryRouter>
<Story />
<Route
path='*'
// eslint-disable-next-line react/jsx-no-bind
render={({ location }) => {
if (location.pathname !== '/') {
action(`route change to ${location.pathname}`)(location);
}
return null;
}}
/>
</MemoryRouter>
),
],
loaders: [mswLoader],
parameters: {
layout: 'centered',
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
state: {},
docs: {},
msw: {
handlers: mockHandlers,
},
},
};
export default preview;

View File

@ -0,0 +1,344 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.10.2'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
*/
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View File

@ -2,7 +2,17 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.4.0] - UNRELEASED ## [4.4.1] - 2025-07-09
### Fixed
- Fix nearly every sub-directory being crawled as part of Vite build (#35323 by @ClearlyClaire)
- Fix assets not building when Redis is unavailable (#35321 by @oneiros)
- Fix replying from media modal or pop-in-player tagging user `@undefined` (#35317 by @ClearlyClaire)
- Fix support for special characters in various environment variables (#35314 by @mjankowski and @ClearlyClaire)
- Fix some database migrations failing for indexes manually removed by admins (#35309 by @mjankowski)
## [4.4.0] - 2025-07-08
### Added ### Added
@ -23,7 +33,7 @@ All notable changes to this project will be documented in this file.
Support for verifying remote quotes according to [FEP-044f](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) and displaying them in the Web UI has been implemented.\ Support for verifying remote quotes according to [FEP-044f](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) and displaying them in the Web UI has been implemented.\
Quoting other people is not implemented yet, and it is currently not possible to mark your own posts as allowing quotes. However, a new “Who can quote” setting has been added to the “Posting defaults” section of the user settings. This setting allows you to set a default that will be used for new posts made on Mastodon 4.5 and newer, when quote posts will be fully implemented.\ Quoting other people is not implemented yet, and it is currently not possible to mark your own posts as allowing quotes. However, a new “Who can quote” setting has been added to the “Posting defaults” section of the user settings. This setting allows you to set a default that will be used for new posts made on Mastodon 4.5 and newer, when quote posts will be fully implemented.\
In the REST API, quote posts are represented by a new `quote` attribute on `Status` and `StatusEdit` entities: https://docs.joinmastodon.org/entities/StatusEdit/#quote https://docs.joinmastodon.org/entities/Status/#quote In the REST API, quote posts are represented by a new `quote` attribute on `Status` and `StatusEdit` entities: https://docs.joinmastodon.org/entities/StatusEdit/#quote https://docs.joinmastodon.org/entities/Status/#quote
- Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, #34820 and #34997 by @ChaosExAnima and @ClearlyClaire)\ - Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, #34820, #34997, #35170, #35174 and #35174 by @ChaosExAnima and @ClearlyClaire)\
Rules are now shown in the users language, if a translation has been set.\ Rules are now shown in the users language, if a translation has been set.\
In the REST API, `Rule` entities now have a new `translations` attribute: https://docs.joinmastodon.org/entities/Rule/#translations In the REST API, `Rule` entities now have a new `translations` attribute: https://docs.joinmastodon.org/entities/Rule/#translations
- Add emoji from Twemoji 15.1.0, including in the emoji picker/completion (#33395, #34321, #34620, and #34677 by @ChaosExAnima, @ClearlyClaire, @TheEssem, and @eramdam) - Add emoji from Twemoji 15.1.0, including in the emoji picker/completion (#33395, #34321, #34620, and #34677 by @ChaosExAnima, @ClearlyClaire, @TheEssem, and @eramdam)
@ -38,8 +48,11 @@ All notable changes to this project will be documented in this file.
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path. Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron) - Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm) - Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527 and #35053 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\ - **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
Server administrators can now fill in Terms of Service, optionally using a provided template. Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\
This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\
When `BULK_SMTP_SERVER` is set, this group of variables is used instead of the regular ones for sending announcement notification emails and Terms of Service notification emails.
- **Add age verification on sign-up** (#34150, #34663, and #34636 by @ClearlyClaire and @Gargron)\ - **Add age verification on sign-up** (#34150, #34663, and #34636 by @ClearlyClaire and @Gargron)\
Server administrators now have a setting to set a minimum age requirement for creating a new server, asking users for their date of birth. The date of birth is checked against the minimum age requirement server-side but not stored.\ Server administrators now have a setting to set a minimum age requirement for creating a new server, asking users for their date of birth. The date of birth is checked against the minimum age requirement server-side but not stored.\
The following REST API changes have been made to accommodate this: The following REST API changes have been made to accommodate this:
@ -48,10 +61,12 @@ All notable changes to this project will be documented in this file.
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron) - Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron) - Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm) - Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, and #34964 by @oneiros)\ - **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org). This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\ - Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users. This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
- Add Server Moderation Notes (#31529 by @ThisIsMissEm)
- Add loading spinner to “Post” button when sending a post (#35153 by @diondiondion)
- Add option to use system scrollbar styling (#32117 by @vmstan) - Add option to use system scrollbar styling (#32117 by @vmstan)
- Add hover cards to follow suggestions (#33749 by @ClearlyClaire) - Add hover cards to follow suggestions (#33749 by @ClearlyClaire)
- Add `t` hotkey for post translations (#33441 by @ClearlyClaire) - Add `t` hotkey for post translations (#33441 by @ClearlyClaire)
@ -59,7 +74,7 @@ All notable changes to this project will be documented in this file.
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk) - Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\ - Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action. Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814 and #35033 by @oneiros)\ - Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033, #35109 and #35278 by @oneiros)\
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests). For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
- Add experimental Async Refreshes API (#34918 by @oneiros) - Add experimental Async Refreshes API (#34918 by @oneiros)
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\ - Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
@ -112,7 +127,7 @@ All notable changes to this project will be documented in this file.
### Changed ### Changed
- Change design of navigation panel in Web UI, change layout on narrow screens (#34910, #34987, #35017, #34986, #35029, #35065, #35067 and #35072 by @ClearlyClaire, @Gargron, and @diondiondion) - Change design of navigation panel in Web UI, change layout on narrow screens (#34910, #34987, #35017, #34986, #35029, #35065, #35067, #35072, #35074, #35075, #35101, #35173, #35183, #35193 and #35225 by @ClearlyClaire, @Gargron, and @diondiondion)
- Change design of lists in web UI (#32881, #33054, and #33036 by @Gargron) - Change design of lists in web UI (#32881, #33054, and #33036 by @Gargron)
- Change design of edit media modal in web UI (#33516, #33702, #33725, #33725, #33771, and #34345 by @Gargron) - Change design of edit media modal in web UI (#33516, #33702, #33725, #33725, #33771, and #34345 by @Gargron)
- Change design of audio player in web UI (#34520, #34740, #34865, #34929, #34933, and #35034 by @ClearlyClaire, @Gargron, and @diondiondion) - Change design of audio player in web UI (#34520, #34740, #34865, #34929, #34933, and #35034 by @ClearlyClaire, @Gargron, and @diondiondion)
@ -126,15 +141,17 @@ All notable changes to this project will be documented in this file.
Moderators will still be able to access them while they are kept, but they won't be accessible to the public in the meantime. Moderators will still be able to access them while they are kept, but they won't be accessible to the public in the meantime.
- Change language names in compose box language picker to be localized (#33402 by @c960657) - Change language names in compose box language picker to be localized (#33402 by @c960657)
- Change onboarding flow in web UI (#32998, #33119, #33471 and #34962 by @ClearlyClaire and @Gargron) - Change onboarding flow in web UI (#32998, #33119, #33471 and #34962 by @ClearlyClaire and @Gargron)
- Change Advanced Web UI to use the new main menu instead of the “Getting started” column (#35117 by @diondiondion)
- Change emoji categories in admin interface to be ordered by name (#33630 by @ShadowJonathan) - Change emoji categories in admin interface to be ordered by name (#33630 by @ShadowJonathan)
- Change design of rich text elements in web UI (#32633 by @Gargron) - Change design of rich text elements in web UI (#32633 by @Gargron)
- Change wording of “single choice” to “pick one” in poll authoring form (#32397 by @ThisIsMissEm) - Change wording of “single choice” to “pick one” in poll authoring form (#32397 by @ThisIsMissEm)
- Change returned favorite and boost counts to use those provided by the remote server, if available (#32620, #34594, #34618, and #34619 by @ClearlyClaire and @sneakers-the-rat) - Change returned favorite and boost counts to use those provided by the remote server, if available (#32620, #34594, #34618, and #34619 by @ClearlyClaire and @sneakers-the-rat)
- Change label of favourite notifications on private mentions (#31659 by @ClearlyClaire) - Change label of favourite notifications on private mentions (#31659 by @ClearlyClaire)
- Change wording of "discard draft?" confirmation dialogs (#35192 by @diondiondion)
- Change `libvips` to be enabled by default in place of ImageMagick (#34741 and #34753 by @ClearlyClaire and @diondiondion) - Change `libvips` to be enabled by default in place of ImageMagick (#34741 and #34753 by @ClearlyClaire and @diondiondion)
- Change avatar and header size limits from 2MB to 8MB when using libvips (#33002 by @Gargron) - Change avatar and header size limits from 2MB to 8MB when using libvips (#33002 by @Gargron)
- Change search to use query params in web UI (#32949 and #33670 by @ClearlyClaire and @Gargron) - Change search to use query params in web UI (#32949 and #33670 by @ClearlyClaire and @Gargron)
- Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, #34732, #35007 and #35035 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap) - Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, #34732, #35007, #35035 and #35177 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap)
- Change account creation API to forbid creation from user tokens (#34828 by @ThisIsMissEm) - Change account creation API to forbid creation from user tokens (#34828 by @ThisIsMissEm)
- Change `/api/v2/instance` to be enabled without authentication when limited federation mode is enabled (#34576 by @ClearlyClaire) - Change `/api/v2/instance` to be enabled without authentication when limited federation mode is enabled (#34576 by @ClearlyClaire)
- Change `DEFAULT_LOCALE` to not override unauthenticated users browser language (#34535 by @ClearlyClaire)\ - Change `DEFAULT_LOCALE` to not override unauthenticated users browser language (#34535 by @ClearlyClaire)\
@ -202,17 +219,23 @@ All notable changes to this project will be documented in this file.
- Fix not being able to scroll dropdown on touch devices in web UI (#34873 by @Gargron) - Fix not being able to scroll dropdown on touch devices in web UI (#34873 by @Gargron)
- Fix inconsistent filtering of silenced accounts for other silenced accounts (#34863 by @ClearlyClaire) - Fix inconsistent filtering of silenced accounts for other silenced accounts (#34863 by @ClearlyClaire)
- Fix update checker listing updates older or equal to current running version (#33906 by @ClearlyClaire) - Fix update checker listing updates older or equal to current running version (#33906 by @ClearlyClaire)
- Fix clicking a status multiple times causing duplicate entries in browser history (#35118 by @ClearlyClaire)
- Fix “Alt text” button submitting form in moderation interface (#35147 by @ClearlyClaire)
- Fix Firefox sometimes not updating spellcheck language in textarea (#35148 by @ClearlyClaire)
- Fix `NoMethodError` in edge case of emoji cache handling (#34749 by @dariusk) - Fix `NoMethodError` in edge case of emoji cache handling (#34749 by @dariusk)
- Fix handling of inlined `featured` collections in ActivityPub actor objects (#34789 and #34811 by @ClearlyClaire) - Fix handling of inlined `featured` collections in ActivityPub actor objects (#34789 and #34811 by @ClearlyClaire)
- Fix long link names in admin sidebar being truncated (#34727 by @diondiondion) - Fix long link names in admin sidebar being truncated (#34727 by @diondiondion)
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire) - Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
- Fix OIDC account creation failing for long display names (#34639 by @defnull) - Fix OIDC account creation failing for long display names (#34639 by @defnull)
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap) - Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
- Fix inaccessible “Clear search” button (#35152 and #35281 by @diondiondion)
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
- Fix directory scroll position reset (#34560 by @przucidlo) - Fix directory scroll position reset (#34560 by @przucidlo)
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent) - Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
- Fix avatar sizing with long account name in some UI elements (#34514 by @gomasy) - Fix avatar sizing with long account name in some UI elements (#34514 by @gomasy)
- Fix empty menu section in status dropdown (#34431 by @ClearlyClaire) - Fix empty menu section in status dropdown (#34431 by @ClearlyClaire)
- Fix the delete suggestion button not working (#34396 and #34398 by @ClearlyClaire and @renchap) - Fix the delete suggestion button not working (#34396 and #34398 by @ClearlyClaire and @renchap)
- Fix popover/dialog backgrounds not being blurred on older Webkit browsers (#35220 by @diondiondion)
- Fix radio buttons not always being correctly centered (#34389 by @ChaosExAnima) - Fix radio buttons not always being correctly centered (#34389 by @ChaosExAnima)
- Fix visual glitches with adding post filters (#34387 by @ChaosExAnima) - Fix visual glitches with adding post filters (#34387 by @ChaosExAnima)
- Fix bugs with upload progress (#34325 by @ChaosExAnima) - Fix bugs with upload progress (#34325 by @ChaosExAnima)
@ -220,7 +243,7 @@ All notable changes to this project will be documented in this file.
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire) - Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion) - Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
- Fix zoomed images being blurry in Safari (#35052 by @diondiondion) - Fix zoomed images being blurry in Safari (#35052 by @diondiondion)
- Fix redundant focus stop within status component in Web UI (#35037 and #35051 by @diondiondion) - Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096, #35150 and #35251 by @diondiondion)
- Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion) - Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion)
- Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion) - Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion)
- Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion) - Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)

View File

@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"] # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
# renovate: datasource=docker depName=docker.io/ruby # renovate: datasource=docker depName=docker.io/ruby
ARG RUBY_VERSION="3.4.4" ARG RUBY_VERSION="3.4.5"
# # 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="20"]
# renovate: datasource=node-version depName=node # renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="22" ARG NODE_MAJOR_VERSION="22"
@ -186,7 +186,7 @@ FROM build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
ARG VIPS_VERSION=8.17.0 ARG VIPS_VERSION=8.17.1
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download ARG VIPS_URL=https://github.com/libvips/libvips/releases/download

View File

@ -62,7 +62,7 @@ gem 'inline_svg'
gem 'irb', '~> 1.8' gem 'irb', '~> 1.8'
gem 'kaminari', '~> 1.2' gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'linzer', '~> 0.7.2' gem 'linzer', '~> 0.7.7'
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar' gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
gem 'mutex_m' gem 'mutex_m'
@ -111,7 +111,7 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-http', '~> 0.24.0', require: false gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false

View File

@ -90,11 +90,13 @@ GEM
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0) aes_key_wrap (1.1.0)
android_key_attestation (0.3.0) android_key_attestation (0.3.0)
annotaterb (4.15.0) annotaterb (4.17.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
ast (2.4.3) ast (2.4.3)
attr_required (1.0.2) attr_required (1.0.2)
aws-eventstream (1.3.2) aws-eventstream (1.4.0)
aws-partitions (1.1103.0) aws-partitions (1.1131.0)
aws-sdk-core (3.215.1) aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
@ -107,19 +109,19 @@ GEM
aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0) aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-blob (0.5.8) azure-blob (0.5.9.1)
rexml rexml
base64 (0.3.0) base64 (0.3.0)
bcp47_spec (0.2.1) bcp47_spec (0.2.1)
bcrypt (3.1.20) bcrypt (3.1.20)
benchmark (0.4.0) benchmark (0.4.1)
better_errors (2.10.1) better_errors (2.10.1)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
rouge (>= 1.0.0) rouge (>= 1.0.0)
bigdecimal (3.1.9) bigdecimal (3.2.2)
bindata (2.5.1) bindata (2.5.1)
binding_of_caller (1.0.1) binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0) debug_inspector (>= 1.2.0)
@ -178,7 +180,7 @@ GEM
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.4.1) date (3.4.1)
debug (1.10.0) debug (1.11.0)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
debug_inspector (1.2.0) debug_inspector (1.2.0)
@ -222,23 +224,24 @@ GEM
mail (~> 2.7) mail (~> 2.7)
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erb (5.0.2)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
excon (1.2.5) excon (1.2.8)
logger logger
fabrication (3.0.0) fabrication (3.0.0)
faker (3.5.1) faker (3.5.2)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.13.1) faraday (2.13.2)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
faraday-follow_redirects (0.3.0) faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-httpclient (2.0.1) faraday-httpclient (2.0.2)
httpclient (>= 2.2) httpclient (>= 2.2)
faraday-net_http (3.4.0) faraday-net_http (3.4.1)
net-http (>= 0.5.0) net-http (>= 0.5.0)
fast_blank (1.0.1) fast_blank (1.0.1)
fastimage (2.4.0) fastimage (2.4.0)
@ -263,14 +266,14 @@ GEM
fog-openstack (1.1.5) fog-openstack (1.1.5)
fog-core (~> 2.1) fog-core (~> 2.1)
fog-json (>= 1.0) fog-json (>= 1.0)
formatador (1.1.0) formatador (1.1.1)
forwardable (1.3.3) forwardable (1.3.3)
fugit (1.11.1) fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11) et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (4.31.0) google-protobuf (4.31.1)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
googleapis-common-protos-types (1.20.0) googleapis-common-protos-types (1.20.0)
@ -284,21 +287,21 @@ GEM
activesupport (>= 5.1) activesupport (>= 5.1)
haml (>= 4.0.6) haml (>= 4.0.6)
railties (>= 5.1) railties (>= 5.1)
haml_lint (0.62.0) haml_lint (0.65.0)
haml (>= 5.0) haml (>= 5.0)
parallel (~> 1.10) parallel (~> 1.10)
rainbow rainbow
rubocop (>= 1.0) rubocop (>= 1.0)
sysexits (~> 1.1) sysexits (~> 1.1)
hashdiff (1.1.2) hashdiff (1.2.0)
hashie (5.0.0) hashie (5.0.0)
hcaptcha (7.1.0) hcaptcha (7.1.0)
json json
highline (3.1.2) highline (3.1.2)
reline reline
hiredis (0.6.3) hiredis (0.6.3)
hiredis-client (0.24.0) hiredis-client (0.25.1)
redis-client (= 0.24.0) redis-client (= 0.25.1)
hkdf (0.3.0) hkdf (0.3.0)
htmlentities (4.3.4) htmlentities (4.3.4)
http (5.3.1) http (5.3.1)
@ -312,7 +315,7 @@ GEM
http_accept_language (2.1.1) http_accept_language (2.1.1)
httpclient (2.9.0) httpclient (2.9.0)
mutex_m mutex_m
httplog (1.7.0) httplog (1.7.1)
rack (>= 2.0) rack (>= 2.0)
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.14.7) i18n (1.14.7)
@ -332,7 +335,7 @@ GEM
inline_svg (1.10.0) inline_svg (1.10.0)
activesupport (>= 3.0) activesupport (>= 3.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
io-console (0.8.0) io-console (0.8.1)
irb (1.15.2) irb (1.15.2)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
@ -342,7 +345,7 @@ GEM
azure-blob (~> 0.5.2) azure-blob (~> 0.5.2)
hashie (~> 5.0) hashie (~> 5.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.12.2) json (2.13.0)
json-canonicalization (1.0.0) json-canonicalization (1.0.0)
json-jwt (1.16.7) json-jwt (1.16.7)
activesupport (>= 4.2) activesupport (>= 4.2)
@ -362,11 +365,11 @@ GEM
json-ld-preloaded (3.3.1) json-ld-preloaded (3.3.1)
json-ld (~> 3.3) json-ld (~> 3.3)
rdf (~> 3.3) rdf (~> 3.3)
json-schema (5.1.1) json-schema (5.2.1)
addressable (~> 2.8) addressable (~> 2.8)
bigdecimal (~> 3.1) bigdecimal (~> 3.1)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
jwt (2.10.1) jwt (2.10.2)
base64 base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@ -400,7 +403,7 @@ GEM
rexml rexml
link_header (0.0.8) link_header (0.0.8)
lint_roller (1.1.0) lint_roller (1.1.0)
linzer (0.7.3) linzer (0.7.7)
cgi (~> 0.4.2) cgi (~> 0.4.2)
forwardable (~> 1.3, >= 1.3.3) forwardable (~> 1.3, >= 1.3.3)
logger (~> 1.7, >= 1.7.0) logger (~> 1.7, >= 1.7.0)
@ -430,7 +433,7 @@ GEM
marcel (1.0.4) marcel (1.0.4)
mario-redis-lock (1.2.1) mario-redis-lock (1.2.1)
redis (>= 3.0.5) redis (>= 3.0.5)
matrix (0.4.2) matrix (0.4.3)
memory_profiler (1.1.0) memory_profiler (1.1.0)
mime-types (3.7.0) mime-types (3.7.0)
logger logger
@ -440,11 +443,11 @@ GEM
mini_portile2 (2.8.9) mini_portile2 (2.8.9)
minitest (5.25.5) minitest (5.25.5)
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.15.0) multi_json (1.17.0)
mutex_m (0.3.0) mutex_m (0.3.0)
net-http (0.6.0) net-http (0.6.0)
uri uri
net-imap (0.5.8) net-imap (0.5.9)
date date
net-protocol net-protocol
net-ldap (0.19.0) net-ldap (0.19.0)
@ -512,7 +515,7 @@ GEM
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7) opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-action_pack (0.12.1) opentelemetry-instrumentation-action_pack (0.12.3)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-rack (~> 0.21) opentelemetry-instrumentation-rack (~> 0.21)
@ -550,7 +553,7 @@ GEM
opentelemetry-instrumentation-faraday (0.27.0) opentelemetry-instrumentation-faraday (0.27.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-http (0.24.0) opentelemetry-instrumentation-http (0.25.1)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-http_client (0.23.0) opentelemetry-instrumentation-http_client (0.23.0)
@ -594,7 +597,7 @@ GEM
opentelemetry-semantic_conventions (1.11.0) opentelemetry-semantic_conventions (1.11.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ostruct (0.6.1) ostruct (0.6.3)
ox (2.14.23) ox (2.14.23)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
parallel (1.27.0) parallel (1.27.0)
@ -624,11 +627,10 @@ GEM
prism (1.4.0) prism (1.4.0)
prometheus_exporter (2.2.0) prometheus_exporter (2.2.0)
webrick webrick
propshaft (1.1.0) propshaft (1.2.0)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
railties (>= 7.0.0)
psych (5.2.6) psych (5.2.6)
date date
stringio stringio
@ -679,7 +681,7 @@ GEM
activesupport (= 8.0.2) activesupport (= 8.0.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2) railties (= 8.0.2)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
@ -699,17 +701,23 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.0) rake (13.3.0)
rdf (3.3.2) rdf (3.3.4)
bcp47_spec (~> 0.2) bcp47_spec (~> 0.2)
bigdecimal (~> 3.1, >= 3.1.5) bigdecimal (~> 3.1, >= 3.1.5)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
logger (~> 1.5)
ostruct (~> 0.6)
readline (~> 0.0)
rdf-normalize (0.7.0) rdf-normalize (0.7.0)
rdf (~> 3.3) rdf (~> 3.3)
rdoc (6.13.1) rdoc (6.14.2)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
readline (0.0.4)
reline
redcarpet (3.6.1) redcarpet (3.6.1)
redis (4.8.1) redis (4.8.1)
redis-client (0.24.0) redis-client (0.25.1)
connection_pool connection_pool
redlock (1.3.2) redlock (1.3.2)
redis (>= 3.0.0, < 6.0) redis (>= 3.0.0, < 6.0)
@ -729,21 +737,21 @@ GEM
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.0.0) rqrcode_core (2.0.0)
rspec (3.13.0) rspec (3.13.1)
rspec-core (~> 3.13.0) rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0) rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0) rspec-mocks (~> 3.13.0)
rspec-core (3.13.3) rspec-core (3.13.5)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.4) rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-github (3.0.0) rspec-github (3.0.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
rspec-mocks (3.13.4) rspec-mocks (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (8.0.0) rspec-rails (8.0.1)
actionpack (>= 7.2) actionpack (>= 7.2)
activesupport (>= 7.2) activesupport (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
@ -756,8 +764,8 @@ GEM
rspec-expectations (~> 3.0) rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9) sidekiq (>= 5, < 9)
rspec-support (3.13.3) rspec-support (3.13.4)
rubocop (1.76.1) rubocop (1.78.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@ -765,10 +773,10 @@ GEM
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.45.0, < 2.0) rubocop-ast (>= 1.45.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.45.1) rubocop-ast (1.46.0)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.4)
rubocop-capybara (2.22.1) rubocop-capybara (2.22.1)
@ -811,7 +819,7 @@ GEM
sanitize (7.0.0) sanitize (7.0.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.16.8) nokogiri (>= 1.16.8)
scenic (1.8.0) scenic (1.9.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
securerandom (0.4.1) securerandom (0.4.1)
@ -842,7 +850,7 @@ GEM
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1) simplecov-html (0.13.2)
simplecov-lcov (0.8.0) simplecov-lcov (0.8.0)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
stackprof (0.2.27) stackprof (0.2.27)
@ -851,8 +859,8 @@ GEM
stoplight (4.1.1) stoplight (4.1.1)
redlock (~> 1.0) redlock (~> 1.0)
stringio (3.1.7) stringio (3.1.7)
strong_migrations (2.3.0) strong_migrations (2.4.0)
activerecord (>= 7) activerecord (>= 7.1)
swd (2.0.3) swd (2.0.3)
activesupport (>= 3) activesupport (>= 3)
attr_required (>= 0.0.5) attr_required (>= 0.0.5)
@ -862,11 +870,11 @@ GEM
temple (0.10.3) temple (0.10.3)
terminal-table (4.0.0) terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4) unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.0) terrapin (1.1.1)
climate_control climate_control
test-prof (1.4.4) test-prof (1.4.4)
thor (1.3.2) thor (1.4.0)
tilt (2.6.0) tilt (2.6.1)
timeout (0.4.3) timeout (0.4.3)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
@ -928,7 +936,7 @@ GEM
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1) webrick (1.9.1)
websocket-driver (0.7.7) websocket-driver (0.8.0)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
@ -936,7 +944,7 @@ GEM
xorcist (1.1.3) xorcist (1.1.3)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.2) zeitwerk (2.7.3)
PLATFORMS PLATFORMS
ruby ruby
@ -1004,7 +1012,7 @@ DEPENDENCIES
letter_opener (~> 1.8) letter_opener (~> 1.8)
letter_opener_web (~> 3.0) letter_opener_web (~> 3.0)
link_header (~> 0.0) link_header (~> 0.0)
linzer (~> 0.7.2) linzer (~> 0.7.7)
lograge (~> 0.12) lograge (~> 0.12)
mail (~> 2.8) mail (~> 2.8)
mario-redis-lock (~> 1.2) mario-redis-lock (~> 1.2)
@ -1027,7 +1035,7 @@ DEPENDENCIES
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
opentelemetry-instrumentation-excon (~> 0.23.0) opentelemetry-instrumentation-excon (~> 0.23.0)
opentelemetry-instrumentation-faraday (~> 0.27.0) opentelemetry-instrumentation-faraday (~> 0.27.0)
opentelemetry-instrumentation-http (~> 0.24.0) opentelemetry-instrumentation-http (~> 0.25.0)
opentelemetry-instrumentation-http_client (~> 0.23.0) opentelemetry-instrumentation-http_client (~> 0.23.0)
opentelemetry-instrumentation-net_http (~> 0.23.0) opentelemetry-instrumentation-net_http (~> 0.23.0)
opentelemetry-instrumentation-pg (~> 0.30.0) opentelemetry-instrumentation-pg (~> 0.30.0)
@ -1098,4 +1106,4 @@ RUBY VERSION
ruby 3.4.1p0 ruby 3.4.1p0
BUNDLED WITH BUNDLED WITH
2.6.9 2.7.0

View File

@ -17,71 +17,71 @@
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a> <img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
</p> </p>
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!) Mastodon is a **free, open-source social network server** based on [ActivityPub](https://www.w3.org/TR/activitypub/) where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
## Navigation ## Navigation
- [Project homepage 🐘](https://joinmastodon.org) - [Project homepage 🐘](https://joinmastodon.org)
- [Support the development via Patreon][patreon] - [Donate to support development 🎁](https://joinmastodon.org/sponsors#donate)
- [View sponsors](https://joinmastodon.org/sponsors) - [View sponsors](https://joinmastodon.org/sponsors)
- [Blog](https://blog.joinmastodon.org) - [Blog 📰](https://blog.joinmastodon.org)
- [Documentation](https://docs.joinmastodon.org) - [Documentation 📚](https://docs.joinmastodon.org)
- [Roadmap](https://joinmastodon.org/roadmap) - [Official container image 🚢](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
- [Browse Mastodon servers](https://joinmastodon.org/communities)
- [Browse Mastodon apps](https://joinmastodon.org/apps)
[patreon]: https://www.patreon.com/mastodon
## Features ## Features
<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" /> <img src="./app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
**No vendor lock-in: Fully interoperable with any conforming platform** - It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/) **Part of the Fediverse. Based on open standards, with no vendor lock-in.** - the network goes beyond just Mastodon; anything that implements ActivityPub is part of a broader social network known as [the Fediverse](https://jointhefediverse.net/). You can follow and interact with users on other servers (including those running different software), and they can follow you back.
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well! **Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI.
**Media attachments like images and short videos** - upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously! **Media attachments** - upload and view images and videos attached to the updates. Videos with no audio track are treated like animated GIFs; normal videos loop continuously.
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/) **Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and many other features, along with a reporting and moderation system.
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices! **OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, and third party apps can use the REST and Streaming APIs. This results in a [rich app ecosystem](https://joinmastodon.org/apps) with a variety of choices!
## Deployment ## Deployment
### Tech stack ### Tech stack
- **Ruby on Rails** powers the REST API and other web pages - [Ruby on Rails](https://github.com/rails/rails) powers the REST API and other web pages.
- **React.js** and **Redux** are used for the dynamic parts of the interface - [PostgreSQL](https://www.postgresql.org/) is the main database.
- **Node.js** powers the streaming API - [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing.
- [Node.js](https://nodejs.org/) powers the streaming API.
- [React.js](https://reactjs.org/) and [Redux](https://redux.js.org/) are used for the dynamic parts of the interface.
- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers. (This project is tested with BrowserStack)
- [Chromatic](https://www.chromatic.com/) provides visual regression testing. (This project is tested with Chromatic)
### Requirements ### Requirements
- **Ruby** 3.2+
- **PostgreSQL** 13+ - **PostgreSQL** 13+
- **Redis** 6.2+ - **Redis** 6.2+
- **Ruby** 3.2+
- **Node.js** 20+ - **Node.js** 20+
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation. This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.
## Contributing ## Contributing
Mastodon is **free, open-source software** licensed under **AGPLv3**. Mastodon is **free, open-source software** licensed under **AGPLv3**. We welcome contributions and help from anyone who wants to improve the project.
You can open issues for bugs you've found or features you think are missing. You You should read the overall [CONTRIBUTING](https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md) guide, which covers our development processes.
can also submit pull requests to this repository or translations via Crowdin. To
get started, look at the [CONTRIBUTING] and [DEVELOPMENT] guides. For changes
accepted into Mastodon, you can request to be paid through our [OpenCollective].
**IRC channel**: #mastodon on [`irc.libera.chat`](https://libera.chat) You should also read and understand the [CODE OF CONDUCT](https://github.com/mastodon/.github/blob/main/CODE_OF_CONDUCT.md) that enables us to maintain a welcoming and inclusive community. Collaboration begins with mutual respect and understanding.
## License You can learn about setting up a development environment in the [DEVELOPMENT](docs/DEVELOPMENT.md) documentation.
If you would like to help with translations 🌐 you can do so on [Crowdin](https://crowdin.com/project/mastodon).
## LICENSE
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md)) Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE): Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
``` ```text
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
This program is free software: you can redistribute it and/or modify it under This program is free software: you can redistribute it and/or modify it under
@ -97,7 +97,3 @@ details.
You should have received a copy of the GNU Affero General Public License along You should have received a copy of the GNU Affero General Public License along
with this program. If not, see https://www.gnu.org/licenses/ with this program. If not, see https://www.gnu.org/licenses/
``` ```
[CONTRIBUTING]: CONTRIBUTING.md
[DEVELOPMENT]: docs/DEVELOPMENT.md
[OpenCollective]: https://opencollective.com/mastodon

View File

@ -14,7 +14,8 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
## Supported Versions ## Supported Versions
| Version | Supported | | Version | Supported |
| ------- | --------- | | ------- | ---------------- |
| 4.4.x | Yes |
| 4.3.x | Yes | | 4.3.x | Yes |
| 4.2.x | Yes | | 4.2.x | Until 2026-01-08 |
| < 4.2 | No | | < 4.2 | No |

View File

@ -14,17 +14,21 @@ module Admin
def create def create
authorize @account, :show? authorize @account, :show?
account_action = Admin::AccountAction.new(resource_params) @account_action = Admin::AccountAction.new(resource_params)
account_action.target_account = @account @account_action.target_account = @account
account_action.current_account = current_account @account_action.current_account = current_account
account_action.save! if @account_action.save
if @account_action.with_report?
if account_action.with_report?
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id]) redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
else else
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id)
end end
else
@warning_presets = AccountWarningPreset.all
render :new
end
end end
private private

View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
class Admin::Instances::ModerationNotesController < Admin::BaseController
before_action :set_instance, only: [:create]
before_action :set_instance_note, only: [:destroy]
def create
authorize :instance_moderation_note, :create?
@instance_moderation_note = current_account.instance_moderation_notes.new(content: resource_params[:content], domain: @instance.domain)
if @instance_moderation_note.save
redirect_to admin_instance_path(@instance.domain, anchor: helpers.dom_id(@instance_moderation_note)), notice: I18n.t('admin.instances.moderation_notes.created_msg')
else
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5)
render 'admin/instances/show'
end
end
def destroy
authorize @instance_moderation_note, :destroy?
@instance_moderation_note.destroy!
redirect_to admin_instance_path(@instance_moderation_note.domain, anchor: 'instance-notes'), notice: I18n.t('admin.instances.moderation_notes.destroyed_msg')
end
private
def resource_params
params
.expect(instance_moderation_note: [:content])
end
def set_instance
domain = params[:instance_id]&.strip
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
end
def set_instance_note
@instance_moderation_note = InstanceModerationNote.find(params[:id])
end
end

View File

@ -14,6 +14,9 @@ module Admin
def show def show
authorize :instance, :show? authorize :instance, :show?
@instance_moderation_note = @instance.moderation_notes.new
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
@time_period = (6.days.ago.to_date...Time.now.utc.to_date) @time_period = (6.days.ago.to_date...Time.now.utc.to_date)
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(LOGS_LIMIT) @action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(LOGS_LIMIT)
end end
@ -52,7 +55,8 @@ module Admin
private private
def set_instance def set_instance
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(params[:id]&.strip)) domain = params[:id]&.strip
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
end end
def set_instances def set_instances

View File

@ -13,27 +13,9 @@ class Admin::Reports::ActionsController < Admin::BaseController
case action_from_button case action_from_button
when 'delete', 'mark_as_sensitive' when 'delete', 'mark_as_sensitive'
status_batch_action = Admin::StatusBatchAction.new( Admin::StatusBatchAction.new(status_batch_action_params).save!
type: action_from_button,
status_ids: @report.status_ids,
current_account: current_account,
report_id: @report.id,
send_email_notification: !@report.spam?,
text: params[:text]
)
status_batch_action.save!
when 'silence', 'suspend' when 'silence', 'suspend'
account_action = Admin::AccountAction.new( Admin::AccountAction.new(account_action_params).save!
type: action_from_button,
report_id: @report.id,
target_account: @report.target_account,
current_account: current_account,
send_email_notification: !@report.spam?,
text: params[:text]
)
account_action.save!
else else
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button) return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
end end
@ -43,6 +25,26 @@ class Admin::Reports::ActionsController < Admin::BaseController
private private
def status_batch_action_params
shared_params
.merge(status_ids: @report.status_ids)
end
def account_action_params
shared_params
.merge(target_account: @report.target_account)
end
def shared_params
{
current_account: current_account,
report_id: @report.id,
send_email_notification: !@report.spam?,
text: params[:text],
type: action_from_button,
}
end
def set_report def set_report
@report = Report.find(params[:report_id]) @report = Report.find(params[:report_id])
end end

View File

@ -17,6 +17,9 @@ module Admin
def edit def edit
authorize @rule, :update? authorize @rule, :update?
missing_languages = RuleTranslation.languages - @rule.translations.pluck(:language)
missing_languages.each { |lang| @rule.translations.build(language: lang) }
end end
def create def create

View File

@ -4,7 +4,7 @@ class Admin::Trends::TagsController < Admin::BaseController
def index def index
authorize :tag, :review? authorize :tag, :review?
@pending_tags_count = Tag.pending_review.async_count @pending_tags_count = pending_tags.async_count
@tags = filtered_tags.page(params[:page]) @tags = filtered_tags.page(params[:page])
@form = Trends::TagBatch.new @form = Trends::TagBatch.new
end end
@ -22,6 +22,10 @@ class Admin::Trends::TagsController < Admin::BaseController
private private
def pending_tags
Trends::TagFilter.new(status: :pending_review).results
end
def filtered_tags def filtered_tags
Trends::TagFilter.new(filter_params).results Trends::TagFilter.new(filter_params).results
end end

View File

@ -32,7 +32,7 @@ class Api::V1::FiltersController < Api::BaseController
ApplicationRecord.transaction do ApplicationRecord.transaction do
@filter.update!(keyword_params) @filter.update!(keyword_params)
@filter.custom_filter.assign_attributes(filter_params) @filter.custom_filter.assign_attributes(filter_params)
raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1 raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.many?
@filter.custom_filter.save! @filter.custom_filter.save!
end end

View File

@ -15,8 +15,9 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo
if params[:date].present? if params[:date].present?
TermsOfService.published.find_by!(effective_date: params[:date]) TermsOfService.published.find_by!(effective_date: params[:date])
else else
TermsOfService.live.first || TermsOfService.published.first! # For the case when none of the published terms have become effective yet TermsOfService.current
end end
end end
not_found if @terms_of_service.nil?
end end
end end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V2::SearchController < Api::BaseController class Api::V2::SearchController < Api::BaseController
include AsyncRefreshesConcern
include Authorization include Authorization
RESULTS_LIMIT = 20 RESULTS_LIMIT = 20
@ -13,6 +14,7 @@ class Api::V2::SearchController < Api::BaseController
before_action :remote_resolve_error, if: :remote_resolve_requested? before_action :remote_resolve_error, if: :remote_resolve_requested?
end end
before_action :require_valid_pagination_options! before_action :require_valid_pagination_options!
before_action :handle_fasp_requests
def index def index
@search = Search.new(search_results) @search = Search.new(search_results)
@ -37,6 +39,21 @@ class Api::V2::SearchController < Api::BaseController
render json: { error: 'Search queries that resolve remote resources are not supported without authentication' }, status: 401 render json: { error: 'Search queries that resolve remote resources are not supported without authentication' }, status: 401
end end
def handle_fasp_requests
return unless Mastodon::Feature.fasp_enabled?
return if params[:q].blank?
# Do not schedule a new retrieval if the request is a follow-up
# to an earlier retrieval
return if request.headers['Mastodon-Async-Refresh-Id'].present?
refresh_key = "fasp:account_search:#{Digest::MD5.base64digest(params[:q])}"
return if AsyncRefresh.new(refresh_key).running?
add_async_refresh_header(AsyncRefresh.create(refresh_key))
@query_fasp = true
end
def remote_resolve_requested? def remote_resolve_requested?
truthy_param?(:resolve) truthy_param?(:resolve)
end end
@ -58,7 +75,8 @@ class Api::V2::SearchController < Api::BaseController
search_params.merge( search_params.merge(
resolve: truthy_param?(:resolve), resolve: truthy_param?(:resolve),
exclude_unreviewed: truthy_param?(:exclude_unreviewed), exclude_unreviewed: truthy_param?(:exclude_unreviewed),
following: truthy_param?(:following) following: truthy_param?(:following),
query_fasp: @query_fasp
) )
end end

View File

@ -98,7 +98,7 @@ class ApplicationController < ActionController::Base
end end
def after_sign_out_path_for(_resource_or_scope) def after_sign_out_path_for(_resource_or_scope)
if ENV['OMNIAUTH_ONLY'] == 'true' && ENV['OIDC_ENABLED'] == 'true' if ENV['OMNIAUTH_ONLY'] == 'true' && Rails.configuration.x.omniauth.oidc_enabled?
'/auth/auth/openid_connect/logout' '/auth/auth/openid_connect/logout'
else else
new_user_session_path new_user_session_path

View File

@ -38,8 +38,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
private private
def record_login_activity def record_login_activity
LoginActivity.create( @user.login_activities.create(
user: @user,
success: true, success: true,
authentication_method: :omniauth, authentication_method: :omniauth,
provider: @provider, provider: @provider,

View File

@ -151,12 +151,11 @@ class Auth::SessionsController < Devise::SessionsController
sign_in(user) sign_in(user)
flash.delete(:notice) flash.delete(:notice)
LoginActivity.create( user.login_activities.create(
user: user, request_details.merge(
success: true,
authentication_method: security_measure, authentication_method: security_measure,
ip: request.remote_ip, success: true
user_agent: request.user_agent )
) )
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
@ -167,13 +166,12 @@ class Auth::SessionsController < Devise::SessionsController
end end
def on_authentication_failure(user, security_measure, failure_reason) def on_authentication_failure(user, security_measure, failure_reason)
LoginActivity.create( user.login_activities.create(
user: user, request_details.merge(
success: false,
authentication_method: security_measure, authentication_method: security_measure,
failure_reason: failure_reason, failure_reason: failure_reason,
ip: request.remote_ip, success: false
user_agent: request.user_agent )
) )
# Only send a notification email every hour at most # Only send a notification email every hour at most
@ -182,6 +180,13 @@ class Auth::SessionsController < Devise::SessionsController
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
end end
def request_details
{
ip: request.remote_ip,
user_agent: request.user_agent,
}
end
def second_factor_attempts_key(user) def second_factor_attempts_key(user)
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}" "2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
end end

View File

@ -64,6 +64,9 @@ module SignatureVerification
return (@signed_request_actor = actor) if signed_request.verified?(actor) return (@signed_request_actor = actor) if signed_request.verified?(actor)
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}" fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
rescue Mastodon::MalformedHeaderError => e
@signature_verification_failure_code = 400
fail_with! e.message
rescue Mastodon::SignatureVerificationError => e rescue Mastodon::SignatureVerificationError => e
fail_with! e.message fail_with! e.message
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
@ -82,7 +85,7 @@ module SignatureVerification
end end
def actor_from_key_id def actor_from_key_id
key_id = signature_key_id key_id = signed_request.key_id
domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id
if domain_not_allowed?(domain) if domain_not_allowed?(domain)

View File

@ -50,6 +50,13 @@ module WebAppControllerConcern
return unless current_user&.require_tos_interstitial? return unless current_user&.require_tos_interstitial?
@terms_of_service = TermsOfService.published.first @terms_of_service = TermsOfService.published.first
# Handle case where terms of service have been removed from the database
if @terms_of_service.nil?
current_user.update(require_tos_interstitial: false)
return
end
render 'terms_of_service_interstitial/show', layout: 'auth' render 'terms_of_service_interstitial/show', layout: 'auth'
end end

View File

@ -1,11 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
skip_before_action :authenticate_resource_owner! skip_before_action :authenticate_resource_owner!
before_action :store_current_location before_action :store_current_location
before_action :authenticate_resource_owner! before_action :authenticate_resource_owner!
layout 'modal'
content_security_policy do |p| content_security_policy do |p|
p.form_action(false) p.form_action(false)
end end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController class OAuth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
skip_before_action :authenticate_resource_owner! skip_before_action :authenticate_resource_owner!
before_action :store_current_location before_action :store_current_location
@ -11,6 +11,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
skip_before_action :require_functional! skip_before_action :require_functional!
layout 'admin'
include Localized include Localized
def destroy def destroy

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Oauth::TokensController < Doorkeeper::TokensController class OAuth::TokensController < Doorkeeper::TokensController
def revoke def revoke
unsubscribe_for_token if token.present? && authorized? && token.accessible? unsubscribe_for_token if token.present? && authorized? && token.accessible?

View File

@ -1,11 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class Oauth::UserinfoController < Api::BaseController class OAuth::UserinfoController < Api::BaseController
before_action -> { doorkeeper_authorize! :profile }, only: [:show] before_action -> { doorkeeper_authorize! :profile }, only: [:show]
before_action :require_user! before_action :require_user!
def show def show
@account = current_account @account = current_account
render json: @account, serializer: OauthUserinfoSerializer render json: @account, serializer: OAuthUserinfoSerializer
end end
end end

View File

@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
skip_before_action :require_functional! skip_before_action :require_functional!
def index def index
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page]) @login_activities = current_user.login_activities.order(id: :desc).page(params[:page])
end end
end end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module WellKnown module WellKnown
class OauthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController class OAuthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController
include CacheConcern include CacheConcern
# Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user` # Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user`
@ -13,8 +13,8 @@ module WellKnown
# new OAuth scopes are added), we don't use expires_in to cache upstream, # new OAuth scopes are added), we don't use expires_in to cache upstream,
# instead just caching in the rails cache: # instead just caching in the rails cache:
render_with_cache( render_with_cache(
json: ::OauthMetadataPresenter.new, json: ::OAuthMetadataPresenter.new,
serializer: ::OauthMetadataSerializer, serializer: ::OAuthMetadataSerializer,
content_type: 'application/json', content_type: 'application/json',
expires_in: 15.minutes expires_in: 15.minutes
) )

View File

@ -66,7 +66,7 @@ module ApplicationHelper
def provider_sign_in_link(provider) def provider_sign_in_link(provider)
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize) label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post
end end
def locale_direction def locale_direction

View File

@ -26,6 +26,12 @@ module ContextHelper
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' }, quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
quotes: {
'quote' => 'https://w3id.org/fep/044f#quote',
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
'_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
},
interaction_policies: { interaction_policies: {
'gts' => 'https://gotosocial.org/ns#', 'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, 'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },

View File

@ -1,12 +1,30 @@
import { createAction } from '@reduxjs/toolkit';
import { import {
apiGetTag, apiGetTag,
apiFollowTag, apiFollowTag,
apiUnfollowTag, apiUnfollowTag,
apiFeatureTag, apiFeatureTag,
apiUnfeatureTag, apiUnfeatureTag,
apiGetFollowedTags,
} from 'mastodon/api/tags'; } from 'mastodon/api/tags';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
export const fetchFollowedHashtags = createDataLoadingThunk(
'tags/fetch-followed',
async ({ next }: { next?: string } = {}) => {
const response = await apiGetFollowedTags(next);
return {
...response,
replace: !next,
};
},
);
export const markFollowedHashtagsStale = createAction(
'tags/mark-followed-stale',
);
export const fetchHashtag = createDataLoadingThunk( export const fetchHashtag = createDataLoadingThunk(
'tags/fetch', 'tags/fetch',
({ tagId }: { tagId: string }) => apiGetTag(tagId), ({ tagId }: { tagId: string }) => apiGetTag(tagId),
@ -15,6 +33,9 @@ export const fetchHashtag = createDataLoadingThunk(
export const followHashtag = createDataLoadingThunk( export const followHashtag = createDataLoadingThunk(
'tags/follow', 'tags/follow',
({ tagId }: { tagId: string }) => apiFollowTag(tagId), ({ tagId }: { tagId: string }) => apiFollowTag(tagId),
(_, { dispatch }) => {
void dispatch(markFollowedHashtagsStale());
},
); );
export const unfollowHashtag = createDataLoadingThunk( export const unfollowHashtag = createDataLoadingThunk(

View File

@ -1,6 +1,6 @@
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import { render, fireEvent, screen } from 'mastodon/test_helpers'; import { render, fireEvent, screen } from '@/testing/rendering';
import { Button } from '../button'; import { Button } from '../button';

View File

@ -0,0 +1,120 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { accountFactoryState, relationshipsFactory } from '@/testing/factories';
import { Account } from './index';
const meta = {
title: 'Components/Account',
component: Account,
argTypes: {
id: {
type: 'string',
description: 'ID of the account to display',
},
size: {
type: 'number',
description: 'Size of the avatar in pixels',
},
hidden: {
type: 'boolean',
description: 'Whether the account is hidden or not',
},
minimal: {
type: 'boolean',
description: 'Whether to display a minimal version of the account',
},
defaultAction: {
type: 'string',
control: 'select',
options: ['block', 'mute'],
description: 'Default action to take on the account',
},
withBio: {
type: 'boolean',
description: 'Whether to display the account bio or not',
},
withMenu: {
type: 'boolean',
description: 'Whether to display the account menu or not',
},
},
args: {
id: '1',
size: 46,
hidden: false,
minimal: false,
defaultAction: 'mute',
withBio: false,
withMenu: true,
},
parameters: {
state: {
accounts: {
'1': accountFactoryState(),
},
},
},
} satisfies Meta<typeof Account>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
id: '1',
},
};
export const Hidden: Story = {
args: {
hidden: true,
},
};
export const Minimal: Story = {
args: {
minimal: true,
},
};
export const WithBio: Story = {
args: {
withBio: true,
},
};
export const NoMenu: Story = {
args: {
withMenu: false,
},
};
export const Blocked: Story = {
args: {
defaultAction: 'block',
},
parameters: {
state: {
relationships: {
'1': relationshipsFactory({
blocking: true,
}),
},
},
},
};
export const Muted: Story = {
args: {},
parameters: {
state: {
relationships: {
'1': relationshipsFactory({
muting: true,
}),
},
},
},
};

View File

@ -1,12 +1,30 @@
import { useCallback } from 'react';
import { useLinks } from 'mastodon/hooks/useLinks'; import { useLinks } from 'mastodon/hooks/useLinks';
export const AccountBio: React.FC<{ interface AccountBioProps {
note: string; note: string;
className: string; className: string;
}> = ({ note, className }) => { dropdownAccountId?: string;
const handleClick = useLinks(); }
if (note.length === 0 || note === '<p></p>') { export const AccountBio: React.FC<AccountBioProps> = ({
note,
className,
dropdownAccountId,
}) => {
const handleClick = useLinks(!!dropdownAccountId);
const handleNodeChange = useCallback(
(node: HTMLDivElement | null) => {
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
return;
}
addDropdownToHashtags(node, dropdownAccountId);
},
[dropdownAccountId],
);
if (note.length === 0) {
return null; return null;
} }
@ -15,6 +33,28 @@ export const AccountBio: React.FC<{
className={`${className} translate`} className={`${className} translate`}
dangerouslySetInnerHTML={{ __html: note }} dangerouslySetInnerHTML={{ __html: note }}
onClickCapture={handleClick} onClickCapture={handleClick}
ref={handleNodeChange}
/> />
); );
}; };
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

@ -33,6 +33,7 @@ export const AltTextBadge: React.FC<{
return ( return (
<> <>
<button <button
type='button'
ref={anchorRef} ref={anchorRef}
className='media-gallery__alt__label' className='media-gallery__alt__label'
onClick={handleClick} onClick={handleClick}

View File

@ -162,6 +162,14 @@ const AutosuggestTextarea = forwardRef(({
} }
}, [suggestions, textareaRef, setSuggestionsHidden]); }, [suggestions, textareaRef, setSuggestionsHidden]);
// Hack to force Firefox to change language in autocorrect
useEffect(() => {
if (lang && textareaRef.current && textareaRef.current === document.activeElement) {
textareaRef.current.blur();
textareaRef.current.focus();
}
}, [lang]);
const renderSuggestion = (suggestion, i) => { const renderSuggestion = (suggestion, i) => {
let inner, key; let inner, key;

View File

@ -11,6 +11,7 @@ const meta = {
compact: false, compact: false,
dangerous: false, dangerous: false,
disabled: false, disabled: false,
loading: false,
onClick: fn(), onClick: fn(),
}, },
argTypes: { argTypes: {
@ -36,19 +37,11 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => { const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button')); const button = await canvas.findByRole('button');
await userEvent.click(button);
await expect(args.onClick).toHaveBeenCalled(); await expect(args.onClick).toHaveBeenCalled();
}; };
const disabledButtonTest: Story['play'] = async ({
args,
canvas,
userEvent,
}) => {
await userEvent.click(canvas.getByRole('button'));
await expect(args.onClick).not.toHaveBeenCalled();
};
export const Primary: Story = { export const Primary: Story = {
args: { args: {
children: 'Primary button', children: 'Primary button',
@ -80,6 +73,18 @@ export const Dangerous: Story = {
play: buttonTest, play: buttonTest,
}; };
const disabledButtonTest: Story['play'] = async ({
args,
canvas,
userEvent,
}) => {
const button = await canvas.findByRole('button');
await userEvent.click(button);
// Disabled controls can't be focused
await expect(button).not.toHaveFocus();
await expect(args.onClick).not.toHaveBeenCalled();
};
export const PrimaryDisabled: Story = { export const PrimaryDisabled: Story = {
args: { args: {
...Primary.args, ...Primary.args,
@ -95,3 +100,24 @@ export const SecondaryDisabled: Story = {
}, },
play: disabledButtonTest, play: disabledButtonTest,
}; };
const loadingButtonTest: Story['play'] = async ({
args,
canvas,
userEvent,
}) => {
const button = await canvas.findByRole('button', {
name: 'Primary button Loading…',
});
await userEvent.click(button);
await expect(button).toHaveFocus();
await expect(args.onClick).not.toHaveBeenCalled();
};
export const Loading: Story = {
args: {
...Primary.args,
loading: true,
},
play: loadingButtonTest,
};

View File

@ -3,12 +3,15 @@ import { useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
interface BaseProps interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> { extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean; block?: boolean;
secondary?: boolean; secondary?: boolean;
compact?: boolean; compact?: boolean;
dangerous?: boolean; dangerous?: boolean;
loading?: boolean;
} }
interface PropsChildren extends PropsWithChildren<BaseProps> { interface PropsChildren extends PropsWithChildren<BaseProps> {
@ -34,6 +37,7 @@ export const Button: React.FC<Props> = ({
secondary, secondary,
compact, compact,
dangerous, dangerous,
loading,
className, className,
title, title,
text, text,
@ -42,13 +46,18 @@ export const Button: React.FC<Props> = ({
}) => { }) => {
const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>( const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(e) => { (e) => {
if (!disabled && onClick) { if (disabled || loading) {
e.stopPropagation();
e.preventDefault();
} else if (onClick) {
onClick(e); onClick(e);
} }
}, },
[disabled, onClick], [disabled, loading, onClick],
); );
const label = text ?? children;
return ( return (
<button <button
className={classNames('button', className, { className={classNames('button', className, {
@ -56,14 +65,27 @@ export const Button: React.FC<Props> = ({
'button--compact': compact, 'button--compact': compact,
'button--block': block, 'button--block': block,
'button--dangerous': dangerous, 'button--dangerous': dangerous,
loading,
})} })}
disabled={disabled} // Disabled buttons can't have focus, so we don't really
// disable the button during loading
disabled={disabled && !loading}
aria-disabled={loading}
// If the loading prop is used, announce label changes
aria-live={loading !== undefined ? 'polite' : undefined}
onClick={handleClick} onClick={handleClick}
title={title} title={title}
type={type} type={type}
{...props} {...props}
> >
{text ?? children} {loading ? (
<>
<span className='button__label-wrapper'>{label}</span>
<LoadingIndicator role='none' />
</>
) : (
label
)}
</button> </button>
); );
}; };

View File

@ -18,7 +18,7 @@ import { useIdentity } from 'mastodon/identity_context';
import { useAppHistory } from './router'; import { useAppHistory } from './router';
const messages = defineMessages({ export const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
moveLeft: { moveLeft: {

View File

@ -5,6 +5,7 @@ import {
useCallback, useCallback,
cloneElement, cloneElement,
Children, Children,
useId,
} from 'react'; } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
@ -16,6 +17,7 @@ import Overlay from 'react-overlays/Overlay';
import type { import type {
OffsetValue, OffsetValue,
UsePopperOptions, UsePopperOptions,
Placement,
} from 'react-overlays/esm/usePopper'; } from 'react-overlays/esm/usePopper';
import { fetchRelationships } from 'mastodon/actions/accounts'; import { fetchRelationships } from 'mastodon/actions/accounts';
@ -295,6 +297,11 @@ interface DropdownProps<Item = MenuItem> {
title?: string; title?: string;
disabled?: boolean; disabled?: boolean;
scrollable?: boolean; scrollable?: boolean;
placement?: Placement;
/**
* Prevent the `ScrollableList` with this scrollKey
* from being scrolled while the dropdown is open
*/
scrollKey?: string; scrollKey?: string;
status?: ImmutableMap<string, unknown>; status?: ImmutableMap<string, unknown>;
forceDropdown?: boolean; forceDropdown?: boolean;
@ -316,6 +323,7 @@ export const Dropdown = <Item = MenuItem,>({
title = 'Menu', title = 'Menu',
disabled, disabled,
scrollable, scrollable,
placement = 'bottom',
status, status,
forceDropdown = false, forceDropdown = false,
renderItem, renderItem,
@ -331,16 +339,15 @@ export const Dropdown = <Item = MenuItem,>({
); );
const [currentId] = useState(id++); const [currentId] = useState(id++);
const open = currentId === openDropdownId; const open = currentId === openDropdownId;
const activeElement = useRef<HTMLElement | null>(null); const buttonRef = useRef<HTMLButtonElement | null>(null);
const targetRef = useRef<HTMLButtonElement | null>(null); const menuId = useId();
const prefetchAccountId = status const prefetchAccountId = status
? status.getIn(['account', 'id']) ? status.getIn(['account', 'id'])
: undefined; : undefined;
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if (activeElement.current) { if (buttonRef.current) {
activeElement.current.focus({ preventScroll: true }); buttonRef.current.focus({ preventScroll: true });
activeElement.current = null;
} }
dispatch( dispatch(
@ -375,7 +382,7 @@ export const Dropdown = <Item = MenuItem,>({
[handleClose, onItemClick, items], [handleClose, onItemClick, items],
); );
const handleClick = useCallback( const toggleDropdown = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => { (e: React.MouseEvent | React.KeyboardEvent) => {
const { type } = e; const { type } = e;
@ -423,38 +430,6 @@ export const Dropdown = <Item = MenuItem,>({
], ],
); );
const handleMouseDown = useCallback(() => {
if (!open && document.activeElement instanceof HTMLElement) {
activeElement.current = document.activeElement;
}
}, [open]);
const handleButtonKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case ' ':
case 'Enter':
handleMouseDown();
break;
}
},
[handleMouseDown],
);
const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case ' ':
case 'Enter':
handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
},
[handleClick],
);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (currentId === openDropdownId) { if (currentId === openDropdownId) {
@ -465,14 +440,16 @@ export const Dropdown = <Item = MenuItem,>({
let button: React.ReactElement; let button: React.ReactElement;
const buttonProps = {
disabled,
onClick: toggleDropdown,
'aria-expanded': open,
'aria-controls': menuId,
ref: buttonRef,
};
if (children) { if (children) {
button = cloneElement(Children.only(children), { button = cloneElement(Children.only(children), buttonProps);
onClick: handleClick,
onMouseDown: handleMouseDown,
onKeyDown: handleButtonKeyDown,
onKeyPress: handleKeyPress,
ref: targetRef,
});
} else if (icon && iconComponent) { } else if (icon && iconComponent) {
button = ( button = (
<IconButton <IconButton
@ -480,12 +457,7 @@ export const Dropdown = <Item = MenuItem,>({
iconComponent={iconComponent} iconComponent={iconComponent}
title={title} title={title}
active={open} active={open}
disabled={disabled} {...buttonProps}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleButtonKeyDown}
onKeyPress={handleKeyPress}
ref={targetRef}
/> />
); );
} else { } else {
@ -499,13 +471,13 @@ export const Dropdown = <Item = MenuItem,>({
<Overlay <Overlay
show={open} show={open}
offset={offset} offset={offset}
placement='bottom' placement={placement}
flip flip
target={targetRef} target={buttonRef}
popperConfig={popperConfig} popperConfig={popperConfig}
> >
{({ props, arrowProps, placement }) => ( {({ props, arrowProps, placement }) => (
<div {...props}> <div {...props} id={menuId}>
<div className={`dropdown-animation dropdown-menu ${placement}`}> <div className={`dropdown-animation dropdown-menu ${placement}`}>
<div <div
className={`dropdown-menu__arrow ${placement}`} className={`dropdown-menu__arrow ${placement}`}

View File

@ -13,14 +13,13 @@ interface Props extends React.SVGProps<SVGSVGElement> {
children?: never; children?: never;
id: string; id: string;
icon: IconProp; icon: IconProp;
title?: string;
} }
export const Icon: React.FC<Props> = ({ export const Icon: React.FC<Props> = ({
id, id,
icon: IconComponent, icon: IconComponent,
className, className,
title: titleProp, 'aria-label': ariaLabel,
...other ...other
}) => { }) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@ -34,18 +33,19 @@ export const Icon: React.FC<Props> = ({
IconComponent = CheckBoxOutlineBlankIcon; IconComponent = CheckBoxOutlineBlankIcon;
} }
const ariaHidden = titleProp ? undefined : true; const ariaHidden = ariaLabel ? undefined : true;
const role = !ariaHidden ? 'img' : undefined; const role = !ariaHidden ? 'img' : undefined;
// Set the title to an empty string to remove the built-in SVG one if any // Set the title to an empty string to remove the built-in SVG one if any
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const title = titleProp || ''; const title = ariaLabel || '';
return ( return (
<IconComponent <IconComponent
className={classNames('icon', `icon-${id}`, className)} className={classNames('icon', `icon-${id}`, className)}
title={title} title={title}
aria-hidden={ariaHidden} aria-hidden={ariaHidden}
aria-label={ariaLabel}
role={role} role={role}
{...other} {...other}
/> />

View File

@ -14,7 +14,6 @@ interface Props {
onClick?: React.MouseEventHandler<HTMLButtonElement>; onClick?: React.MouseEventHandler<HTMLButtonElement>;
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>; onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>; onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
active?: boolean; active?: boolean;
expanded?: boolean; expanded?: boolean;
style?: React.CSSProperties; style?: React.CSSProperties;
@ -45,7 +44,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
activeStyle, activeStyle,
onClick, onClick,
onKeyDown, onKeyDown,
onKeyPress,
onMouseDown, onMouseDown,
active = false, active = false,
disabled = false, disabled = false,
@ -85,16 +83,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
[disabled, onClick], [disabled, onClick],
); );
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onKeyPress?.(e);
}
},
[disabled, onKeyPress],
);
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
useCallback( useCallback(
(e) => { (e) => {
@ -161,7 +149,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
onClick={handleClick} onClick={handleClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
style={buttonStyle} style={buttonStyle}
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled} disabled={disabled}

View File

@ -6,15 +6,34 @@ const messages = defineMessages({
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' }, loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' },
}); });
export const LoadingIndicator: React.FC = () => { interface LoadingIndicatorProps {
/**
* Use role='none' to opt out of the current default role 'progressbar'
* and aria attributes which we should re-visit to check if they're appropriate.
* In Firefox the aria-label is not applied, instead an implied value of `50` is
* used as the label.
*/
role?: string;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
role = 'progressbar',
}) => {
const intl = useIntl(); const intl = useIntl();
const a11yProps =
role === 'progressbar'
? ({
role,
'aria-busy': true,
'aria-live': 'polite',
} as const)
: undefined;
return ( return (
<div <div
className='loading-indicator' className='loading-indicator'
role='progressbar' {...a11yProps}
aria-busy
aria-live='polite'
aria-label={intl.formatMessage(messages.loading)} aria-label={intl.formatMessage(messages.loading)}
> >
<CircularProgress size={50} strokeWidth={6} /> <CircularProgress size={50} strokeWidth={6} />

View File

@ -318,7 +318,7 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
id='check' id='check'
icon={CheckIcon} icon={CheckIcon}
className='poll__voted__mark' className='poll__voted__mark'
title={intl.formatMessage(messages.voted)} aria-label={intl.formatMessage(messages.voted)}
/> />
</span> </span>
)} )}

View File

@ -300,9 +300,13 @@ class Status extends ImmutablePureComponent {
if (newTab) { if (newTab) {
window.open(path, '_blank', 'noopener'); window.open(path, '_blank', 'noopener');
} else {
if (history.location.pathname.replace('/deck/', '/') === path) {
history.replace(path);
} else { } else {
history.push(path); history.push(path);
} }
}
}; };
handleHotkeyOpenProfile = () => { handleHotkeyOpenProfile = () => {

View File

@ -8,6 +8,10 @@ export enum BannerVariant {
Filter = 'filter', Filter = 'filter',
} }
const stopPropagation: MouseEventHandler = (e) => {
e.stopPropagation();
};
export const StatusBanner: React.FC<{ export const StatusBanner: React.FC<{
children: React.ReactNode; children: React.ReactNode;
variant: BannerVariant; variant: BannerVariant;
@ -38,6 +42,7 @@ export const StatusBanner: React.FC<{
: 'content-warning content-warning--filter' : 'content-warning content-warning--filter'
} }
onClick={forwardClick} onClick={forwardClick}
onMouseUp={stopPropagation}
> >
<p id={descriptionId}>{children}</p> <p id={descriptionId}>{children}</p>

View File

@ -58,7 +58,7 @@ export const VisibilityIcon: React.FC<{ visibility: StatusVisibility }> = ({
<Icon <Icon
id={visibilityIcon.icon} id={visibilityIcon.icon}
icon={visibilityIcon.iconComponent} icon={visibilityIcon.iconComponent}
title={visibilityIcon.text} aria-label={visibilityIcon.text}
/> />
); );
}; };

View File

@ -18,6 +18,7 @@ import initialState, { title as siteTitle } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales'; import { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store'; import { store } from 'mastodon/store';
import { isProduction } from 'mastodon/utils/environment'; import { isProduction } from 'mastodon/utils/environment';
import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock';
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`; const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
@ -58,6 +59,7 @@ export default class Mastodon extends PureComponent {
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}> <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Route path='/' component={UI} /> <Route path='/' component={UI} />
</ScrollContext> </ScrollContext>
<BodyScrollLock />
</Router> </Router>
<Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} /> <Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />

View File

@ -14,7 +14,6 @@ import MediaModal from 'mastodon/features/ui/components/media_modal';
import { Video } from 'mastodon/features/video'; import { Video } from 'mastodon/features/video';
import { IntlProvider } from 'mastodon/locales'; import { IntlProvider } from 'mastodon/locales';
import { createPollFromServerJSON } from 'mastodon/models/poll'; import { createPollFromServerJSON } from 'mastodon/models/poll';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
@ -34,9 +33,6 @@ export default class MediaContainer extends PureComponent {
}; };
handleOpenMedia = (media, index, lang) => { handleOpenMedia = (media, index, lang) => {
document.body.classList.add('with-modals--active');
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
this.setState({ media, index, lang }); this.setState({ media, index, lang });
}; };
@ -45,16 +41,10 @@ export default class MediaContainer extends PureComponent {
const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props')); const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
const mediaList = fromJS(media); const mediaList = fromJS(media);
document.body.classList.add('with-modals--active');
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
this.setState({ media: mediaList, lang, options }); this.setState({ media: mediaList, lang, options });
}; };
handleCloseMedia = () => { handleCloseMedia = () => {
document.body.classList.remove('with-modals--active');
document.documentElement.style.marginRight = '0';
this.setState({ this.setState({
media: null, media: null,
index: null, index: null,

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -768,12 +769,11 @@ export const AccountHeader: React.FC<{
<Icon <Icon
id='lock' id='lock'
icon={LockIcon} icon={LockIcon}
title={intl.formatMessage(messages.account_locked)} aria-label={intl.formatMessage(messages.account_locked)}
/> />
); );
} }
const content = { __html: account.note_emojified };
const displayNameHtml = { __html: account.display_name_html }; const displayNameHtml = { __html: account.display_name_html };
const fields = account.fields; const fields = account.fields;
const isLocal = !account.acct.includes('@'); const isLocal = !account.acct.includes('@');
@ -897,12 +897,11 @@ export const AccountHeader: React.FC<{
<AccountNote accountId={accountId} /> <AccountNote accountId={accountId} />
)} )}
{account.note.length > 0 && account.note !== '<p></p>' && ( <AccountBio
<div note={account.note_emojified}
className='account__header__content translate' dropdownAccountId={accountId}
dangerouslySetInnerHTML={content} className='account__header__content'
/> />
)}
<div className='account__header__fields'> <div className='account__header__fields'>
<dl> <dl>

View File

@ -261,7 +261,9 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
); );
const lang = useAppSelector( const lang = useAppSelector(
(state) => (state) =>
(state.compose as ImmutableMap<string, unknown>).get('lang') as string, (state.compose as ImmutableMap<string, unknown>).get(
'language',
) as string,
); );
const focusX = const focusX =
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0; (media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;

View File

@ -12,9 +12,10 @@ import { length } from 'stringz';
import { missingAltTextModal } from 'mastodon/initial_state'; import { missingAltTextModal } from 'mastodon/initial_state';
import AutosuggestInput from '../../../components/autosuggest_input'; import AutosuggestInput from 'mastodon/components/autosuggest_input';
import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea';
import { Button } from '../../../components/button'; import { Button } from 'mastodon/components/button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollButtonContainer from '../containers/poll_button_container'; import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
@ -225,9 +226,8 @@ class ComposeForm extends ImmutablePureComponent {
}; };
render () { render () {
const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props; const { intl, onPaste, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props;
const { highlighted } = this.state; const { highlighted } = this.state;
const disabled = this.props.isSubmitting;
return ( return (
<form className='compose-form' onSubmit={this.handleSubmit}> <form className='compose-form' onSubmit={this.handleSubmit}>
@ -246,7 +246,7 @@ class ComposeForm extends ImmutablePureComponent {
<AutosuggestInput <AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)} placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText} value={this.props.spoilerText}
disabled={disabled} disabled={isSubmitting}
onChange={this.handleChangeSpoilerText} onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
ref={this.setSpoilerText} ref={this.setSpoilerText}
@ -268,7 +268,7 @@ class ComposeForm extends ImmutablePureComponent {
<AutosuggestTextarea <AutosuggestTextarea
ref={this.textareaRef} ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled} disabled={isSubmitting}
value={this.props.text} value={this.props.text}
onChange={this.handleChange} onChange={this.handleChange}
suggestions={this.props.suggestions} suggestions={this.props.suggestions}
@ -305,9 +305,15 @@ class ComposeForm extends ImmutablePureComponent {
<Button <Button
type='submit' type='submit'
compact compact
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
disabled={!this.canSubmit()} disabled={!this.canSubmit()}
/> loading={isSubmitting}
>
{intl.formatMessage(
this.props.isEditing ?
messages.saveChanges :
(this.props.isInReply ? messages.reply : messages.publish)
)}
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -29,6 +29,7 @@ import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
clearSearch: { id: 'search.clear', defaultMessage: 'Clear search' },
placeholderSignedIn: { placeholderSignedIn: {
id: 'search.search_or_paste', id: 'search.search_or_paste',
defaultMessage: 'Search or paste URL', defaultMessage: 'Search or paste URL',
@ -46,8 +47,32 @@ const labelForRecentSearch = (search: RecentSearch) => {
} }
}; };
const unfocus = () => { const ClearButton: React.FC<{
document.querySelector('.ui')?.parentElement?.focus(); onClick: () => void;
hasValue: boolean;
}> = ({ onClick, hasValue }) => {
const intl = useIntl();
return (
<div
className={classNames('search__icon-wrapper', { 'has-value': hasValue })}
>
<Icon id='search' icon={SearchIcon} className='search__icon' />
<button
type='button'
onClick={onClick}
className='search__icon search__icon--clear-button'
tabIndex={hasValue ? undefined : -1}
aria-hidden={!hasValue}
>
<Icon
id='times-circle'
icon={CancelIcon}
aria-label={intl.formatMessage(messages.clearSearch)}
/>
</button>
</div>
);
}; };
interface SearchOption { interface SearchOption {
@ -78,6 +103,11 @@ export const Search: React.FC<{
}, [initialValue]); }, [initialValue]);
const searchOptions: SearchOption[] = []; const searchOptions: SearchOption[] = [];
const unfocus = useCallback(() => {
document.querySelector('.ui')?.parentElement?.focus();
setExpanded(false);
}, []);
if (searchEnabled) { if (searchEnabled) {
searchOptions.push( searchOptions.push(
{ {
@ -253,7 +283,7 @@ export const Search: React.FC<{
history.push({ pathname: '/search', search: queryParams.toString() }); history.push({ pathname: '/search', search: queryParams.toString() });
unfocus(); unfocus();
}, },
[dispatch, history], [dispatch, history, unfocus],
); );
const handleChange = useCallback( const handleChange = useCallback(
@ -373,14 +403,15 @@ export const Search: React.FC<{
setQuickActions(newQuickActions); setQuickActions(newQuickActions);
}, },
[dispatch, history, signedIn, setValue, setQuickActions, submit], [signedIn, dispatch, unfocus, history, submit],
); );
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
setValue(''); setValue('');
setQuickActions([]); setQuickActions([]);
setSelectedOption(-1); setSelectedOption(-1);
}, [setValue, setQuickActions, setSelectedOption]); unfocus();
}, [unfocus]);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@ -431,7 +462,7 @@ export const Search: React.FC<{
break; break;
} }
}, },
[navigableOptions, value, selectedOption, setSelectedOption, submit], [unfocus, navigableOptions, selectedOption, submit, value],
); );
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
@ -451,12 +482,38 @@ export const Search: React.FC<{
}, [setExpanded, setSelectedOption, singleColumn]); }, [setExpanded, setSelectedOption, singleColumn]);
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setExpanded(false);
setSelectedOption(-1); setSelectedOption(-1);
}, [setExpanded, setSelectedOption]); }, [setSelectedOption]);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
// If the search popover is expanded, close it when tabbing or
// clicking outside of it or the search form, while allowing
// tabbing or clicking inside of the popover
if (expanded) {
function closeOnLeave(event: FocusEvent | MouseEvent) {
const form = formRef.current;
const isClickInsideForm =
form &&
(form === event.target || form.contains(event.target as Node));
if (!isClickInsideForm) {
setExpanded(false);
}
}
document.addEventListener('focusin', closeOnLeave);
document.addEventListener('click', closeOnLeave);
return () => {
document.removeEventListener('focusin', closeOnLeave);
document.removeEventListener('click', closeOnLeave);
};
}
return () => null;
}, [expanded]);
return ( return (
<form className={classNames('search', { active: expanded })}> <form ref={formRef} className={classNames('search', { active: expanded })}>
<input <input
ref={searchInputRef} ref={searchInputRef}
className='search__input' className='search__input'
@ -474,21 +531,9 @@ export const Search: React.FC<{
onBlur={handleBlur} onBlur={handleBlur}
/> />
<button type='button' className='search__icon' onClick={handleClear}> <ClearButton hasValue={hasValue} onClick={handleClear} />
<Icon
id='search'
icon={SearchIcon}
className={hasValue ? '' : 'active'}
/>
<Icon
id='times-circle'
icon={CancelIcon}
className={hasValue ? 'active' : ''}
aria-label={intl.formatMessage(messages.placeholder)}
/>
</button>
<div className='search__popout'> <div className='search__popout' tabIndex={-1}>
{!hasValue && ( {!hasValue && (
<> <>
<h4> <h4>

View File

@ -15,39 +15,34 @@ import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react'; import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react'; import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import { mountCompose, unmountCompose } from 'mastodon/actions/compose'; import { mountCompose, unmountCompose } from 'mastodon/actions/compose';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { mascot } from 'mastodon/initial_state'; import { mascot, reduceMotion } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { messages as navbarMessages } from '../ui/components/navigation_bar';
import { Search } from './components/search'; import { Search } from './components/search';
import ComposeFormContainer from './containers/compose_form_container'; import ComposeFormContainer from './containers/compose_form_container';
const messages = defineMessages({ const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, live_feed_public: {
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, id: 'navigation_bar.live_feed_public',
notifications: { defaultMessage: 'Live feed (public)',
id: 'tabs_bar.notifications',
defaultMessage: 'Notifications',
}, },
public: { live_feed_local: {
id: 'navigation_bar.public_timeline', id: 'navigation_bar.live_feed_local',
defaultMessage: 'Federated timeline', defaultMessage: 'Live feed (local)',
},
community: {
id: 'navigation_bar.community_timeline',
defaultMessage: 'Local timeline',
}, },
preferences: { preferences: {
id: 'navigation_bar.preferences', id: 'navigation_bar.preferences',
defaultMessage: 'Preferences', defaultMessage: 'Preferences',
}, },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
}); });
type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>; type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>;
@ -82,19 +77,27 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
[dispatch], [dispatch],
); );
const scrollNavbarIntoView = useCallback(() => {
const navbar = document.querySelector('.navigation-panel');
navbar?.scrollIntoView({
behavior: reduceMotion ? 'auto' : 'smooth',
});
}, []);
if (multiColumn) { if (multiColumn) {
return ( return (
<div <div
className='drawer' className='drawer'
role='region' role='region'
aria-label={intl.formatMessage(messages.compose)} aria-label={intl.formatMessage(navbarMessages.publish)}
> >
<nav className='drawer__header'> <nav className='drawer__header'>
<Link <Link
to='/getting-started' to='/getting-started'
className='drawer__tab' className='drawer__tab'
title={intl.formatMessage(messages.start)} title={intl.formatMessage(navbarMessages.menu)}
aria-label={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(navbarMessages.menu)}
onClick={scrollNavbarIntoView}
> >
<Icon id='bars' icon={MenuIcon} /> <Icon id='bars' icon={MenuIcon} />
</Link> </Link>
@ -102,8 +105,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
<Link <Link
to='/home' to='/home'
className='drawer__tab' className='drawer__tab'
title={intl.formatMessage(messages.home_timeline)} title={intl.formatMessage(navbarMessages.home)}
aria-label={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(navbarMessages.home)}
> >
<Icon id='home' icon={HomeIcon} /> <Icon id='home' icon={HomeIcon} />
</Link> </Link>
@ -112,8 +115,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
<Link <Link
to='/notifications' to='/notifications'
className='drawer__tab' className='drawer__tab'
title={intl.formatMessage(messages.notifications)} title={intl.formatMessage(navbarMessages.notifications)}
aria-label={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(navbarMessages.notifications)}
> >
<Icon id='bell' icon={NotificationsIcon} /> <Icon id='bell' icon={NotificationsIcon} />
</Link> </Link>
@ -122,8 +125,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
<Link <Link
to='/public/local' to='/public/local'
className='drawer__tab' className='drawer__tab'
title={intl.formatMessage(messages.community)} title={intl.formatMessage(messages.live_feed_local)}
aria-label={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.live_feed_local)}
> >
<Icon id='users' icon={PeopleIcon} /> <Icon id='users' icon={PeopleIcon} />
</Link> </Link>
@ -132,8 +135,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
<Link <Link
to='/public' to='/public'
className='drawer__tab' className='drawer__tab'
title={intl.formatMessage(messages.public)} title={intl.formatMessage(messages.live_feed_public)}
aria-label={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.live_feed_public)}
> >
<Icon id='globe' icon={PublicIcon} /> <Icon id='globe' icon={PublicIcon} />
</Link> </Link>
@ -175,12 +178,12 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
return ( return (
<Column <Column
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
label={intl.formatMessage(messages.compose)} label={intl.formatMessage(navbarMessages.publish)}
> >
<ColumnHeader <ColumnHeader
icon='pencil' icon='pencil'
iconComponent={EditIcon} iconComponent={EditIcon}
title={intl.formatMessage(messages.compose)} title={intl.formatMessage(navbarMessages.publish)}
multiColumn={multiColumn} multiColumn={multiColumn}
showBackButton showBackButton
/> />

View File

@ -0,0 +1,110 @@
// Utility codes
export const VARIATION_SELECTOR_CODE = 0xfe0f;
export const KEYCAP_CODE = 0x20e3;
// Gender codes
export const GENDER_FEMALE_CODE = 0x2640;
export const GENDER_MALE_CODE = 0x2642;
// Skin tone codes
export const SKIN_TONE_CODES = [
0x1f3fb, // Light skin tone
0x1f3fc, // Medium-light skin tone
0x1f3fd, // Medium skin tone
0x1f3fe, // Medium-dark skin tone
0x1f3ff, // Dark skin tone
] as const;
export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1
'🐜', // 1F41C
'⚫', // 26AB
'🖤', // 1F5A4
'⬛', // 2B1B
'◼️', // 25FC-FE0F
'◾', // 25FE
'◼️', // 25FC-FE0F
'✒️', // 2712-FE0F
'▪️', // 25AA-FE0F
'💣', // 1F4A3
'🎳', // 1F3B3
'📷', // 1F4F7
'📸', // 1F4F8
'♣️', // 2663-FE0F
'🕶️', // 1F576-FE0F
'✴️', // 2734-FE0F
'🔌', // 1F50C
'💂‍♀️', // 1F482-200D-2640-FE0F
'📽️', // 1F4FD-FE0F
'🍳', // 1F373
'🦍', // 1F98D
'💂', // 1F482
'🔪', // 1F52A
'🕳️', // 1F573-FE0F
'🕹️', // 1F579-FE0F
'🕋', // 1F54B
'🖊️', // 1F58A-FE0F
'🖋️', // 1F58B-FE0F
'💂‍♂️', // 1F482-200D-2642-FE0F
'🎤', // 1F3A4
'🎓', // 1F393
'🎥', // 1F3A5
'🎼', // 1F3BC
'♠️', // 2660-FE0F
'🎩', // 1F3A9
'🦃', // 1F983
'📼', // 1F4FC
'📹', // 1F4F9
'🎮', // 1F3AE
'🐃', // 1F403
'🏴', // 1F3F4
'🐞', // 1F41E
'🕺', // 1F57A
'📱', // 1F4F1
'📲', // 1F4F2
'🚲', // 1F6B2
'🪮', // 1FAA6
'🐦‍⬛', // 1F426-200D-2B1B
];
export const EMOJIS_WITH_LIGHT_BORDER = [
'👽', // 1F47D
'⚾', // 26BE
'🐔', // 1F414
'☁️', // 2601-FE0F
'💨', // 1F4A8
'🕊️', // 1F54A-FE0F
'👀', // 1F440
'🍥', // 1F365
'👻', // 1F47B
'🐐', // 1F410
'❕', // 2755
'❔', // 2754
'⛸️', // 26F8-FE0F
'🌩️', // 1F329-FE0F
'🔊', // 1F50A
'🔇', // 1F507
'📃', // 1F4C3
'🌧️', // 1F327-FE0F
'🐏', // 1F40F
'🍚', // 1F35A
'🍙', // 1F359
'🐓', // 1F413
'🐑', // 1F411
'💀', // 1F480
'☠️', // 2620-FE0F
'🌨️', // 1F328-FE0F
'🔉', // 1F509
'🔈', // 1F508
'💬', // 1F4AC
'💭', // 1F4AD
'🏐', // 1F3D0
'🏳️', // 1F3F3-FE0F
'⚪', // 26AA
'⬜', // 2B1C
'◽', // 25FD
'◻️', // 25FB-FE0F
'▫️', // 25AB-FE0F
'🪽', // 1FAE8
'🪿', // 1FABF
];

View File

@ -0,0 +1,102 @@
import { SUPPORTED_LOCALES } from 'emojibase';
import type { FlatCompactEmoji, Locale } from 'emojibase';
import type { DBSchema } from 'idb';
import { openDB } from 'idb';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type { LocaleOrCustom } from './locale';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
interface EmojiDB extends LocaleTables, DBSchema {
custom: {
key: string;
value: ApiCustomEmojiJSON;
indexes: {
category: string;
};
};
etags: {
key: LocaleOrCustom;
value: string;
};
}
interface LocaleTable {
key: string;
value: FlatCompactEmoji;
indexes: {
group: number;
label: string;
order: number;
tags: string[];
};
}
type LocaleTables = Record<Locale, LocaleTable>;
const SCHEMA_VERSION = 1;
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
customTable.createIndex('category', 'category');
database.createObjectStore('etags');
for (const locale of SUPPORTED_LOCALES) {
const localeTable = database.createObjectStore(locale, {
keyPath: 'hexcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
}
},
});
export async function putEmojiData(emojis: FlatCompactEmoji[], locale: Locale) {
const trx = db.transaction(locale, 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
}
export async function putCustomEmojiData(emojis: ApiCustomEmojiJSON[]) {
const trx = db.transaction('custom', 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
}
export function putLatestEtag(etag: string, localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
return db.put('etags', etag, locale);
}
export function searchEmojiByHexcode(hexcode: string, localeString: string) {
const locale = toSupportedLocale(localeString);
return db.get(locale, hexcode);
}
export function searchEmojiByTag(tag: string, localeString: string) {
const locale = toSupportedLocale(localeString);
const range = IDBKeyRange.only(tag.toLowerCase());
return db.getAllFromIndex(locale, 'tags', range);
}
export function searchCustomEmojiByShortcode(shortcode: string) {
return db.get('custom', shortcode);
}
export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const rowCount = await db.count(locale);
if (!rowCount) {
return null; // No data for this locale, return null even if there is an etag.
}
const etag = await db.get('etags', locale);
return etag ?? null;
}

View File

@ -0,0 +1,38 @@
import initialState from '@/mastodon/initial_state';
import { toSupportedLocale } from './locale';
const serverLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
const worker =
'Worker' in window
? new Worker(new URL('./worker', import.meta.url), {
type: 'module',
})
: null;
export async function initializeEmoji() {
if (worker) {
worker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === 'ready') {
worker.postMessage(serverLocale);
worker.postMessage('custom');
}
});
} else {
const { importCustomEmojiData, importEmojiData } = await import('./loader');
await Promise.all([importCustomEmojiData(), importEmojiData(serverLocale)]);
}
}
export async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
if (worker) {
worker.postMessage(locale);
} else {
const { importEmojiData } = await import('./loader');
await importEmojiData(locale);
}
}

View File

@ -0,0 +1,77 @@
import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { isDevelopment } from '@/mastodon/utils/environment';
import {
putEmojiData,
putCustomEmojiData,
loadLatestEtag,
putLatestEtag,
} from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { LocaleOrCustom } from './locale';
export async function importEmojiData(localeString: string) {
const locale = toSupportedLocale(localeString);
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale);
if (!emojis) {
return;
}
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
await putEmojiData(flattenedEmojis, locale);
}
export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom');
if (!emojis) {
return;
}
await putCustomEmojiData(emojis);
}
async function fetchAndCheckEtag<ResultType extends object[]>(
localeOrCustom: LocaleOrCustom,
): Promise<ResultType | null> {
const locale = toSupportedLocaleOrCustom(localeOrCustom);
let uri: string;
if (locale === 'custom') {
uri = '/api/v1/custom_emojis';
} else {
uri = `/packs${isDevelopment() ? '-dev' : ''}/emoji/${locale}.json`;
}
const oldEtag = await loadLatestEtag(locale);
const response = await fetch(uri, {
headers: {
'Content-Type': 'application/json',
'If-None-Match': oldEtag ?? '', // Send the old ETag to check for modifications
},
});
// If not modified, return null
if (response.status === 304) {
return null;
}
if (!response.ok) {
throw new Error(
`Failed to fetch emoji data for ${localeOrCustom}: ${response.statusText}`,
);
}
const data = (await response.json()) as ResultType;
if (!Array.isArray(data)) {
throw new Error(
`Unexpected data format for ${localeOrCustom}: expected an array`,
);
}
// Store the ETag for future requests
const etag = response.headers.get('ETag');
if (etag) {
await putLatestEtag(etag, localeOrCustom);
}
return data;
}

View File

@ -0,0 +1,29 @@
import { SUPPORTED_LOCALES } from 'emojibase';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
describe('toSupportedLocale', () => {
test('returns the same locale if it is supported', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(toSupportedLocale(locale)).toBe(locale);
}
});
test('returns "en" for unsupported locales', () => {
const unsupportedLocales = ['xx', 'fr-CA'];
for (const locale of unsupportedLocales) {
expect(toSupportedLocale(locale)).toBe('en');
}
});
});
describe('toSupportedLocaleOrCustom', () => {
test('returns custom for "custom" locale', () => {
expect(toSupportedLocaleOrCustom('custom')).toBe('custom');
});
test('returns supported locale for valid locales', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(toSupportedLocaleOrCustom(locale)).toBe(locale);
}
});
});

View File

@ -0,0 +1,23 @@
import type { Locale } from 'emojibase';
import { SUPPORTED_LOCALES } from 'emojibase';
export type LocaleOrCustom = Locale | 'custom';
export function toSupportedLocale(localeBase: string): Locale {
const locale = localeBase.toLowerCase();
if (isSupportedLocale(locale)) {
return locale;
}
return 'en'; // Default to English if unsupported
}
export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom {
if (locale.toLowerCase() === 'custom') {
return 'custom';
}
return toSupportedLocale(locale);
}
function isSupportedLocale(locale: string): locale is Locale {
return SUPPORTED_LOCALES.includes(locale.toLowerCase() as Locale);
}

View File

@ -0,0 +1,101 @@
import { readdir } from 'fs/promises';
import { basename, resolve } from 'path';
import { flattenEmojiData } from 'emojibase';
import unicodeRawEmojis from 'emojibase-data/en/data.json';
import {
twemojiHasBorder,
twemojiToUnicodeInfo,
unicodeToTwemojiHex,
CODES_WITH_DARK_BORDER,
CODES_WITH_LIGHT_BORDER,
emojiToUnicodeHex,
} from './normalize';
const emojiSVGFiles = await readdir(
// This assumes tests are run from project root
resolve(process.cwd(), 'public/emoji'),
{
withFileTypes: true,
},
);
const svgFileNames = emojiSVGFiles
.filter((file) => file.isFile() && file.name.endsWith('.svg'))
.map((file) => basename(file.name, '.svg').toUpperCase());
const svgFileNamesWithoutBorder = svgFileNames.filter(
(fileName) => !fileName.endsWith('_BORDER'),
);
const unicodeEmojis = flattenEmojiData(unicodeRawEmojis);
describe('emojiToUnicodeHex', () => {
test.concurrent.for([
['🎱', '1F3B1'],
['🐜', '1F41C'],
['⚫', '26AB'],
['🖤', '1F5A4'],
['💀', '1F480'],
['💂‍♂️', '1F482-200D-2642-FE0F'],
] as const)(
'emojiToUnicodeHex converts %s to %s',
([emoji, hexcode], { expect }) => {
expect(emojiToUnicodeHex(emoji)).toBe(hexcode);
},
);
});
describe('unicodeToTwemojiHex', () => {
test.concurrent.for(
unicodeEmojis
// Our version of Twemoji only supports up to version 15.1
.filter((emoji) => emoji.version < 16)
.map((emoji) => [emoji.hexcode, emoji.label] as [string, string]),
)('verifying an emoji exists for %s (%s)', ([hexcode], { expect }) => {
const result = unicodeToTwemojiHex(hexcode);
expect(svgFileNamesWithoutBorder).toContain(result);
});
});
describe('twemojiHasBorder', () => {
test.concurrent.for(
svgFileNames
.filter((file) => file.endsWith('_BORDER'))
.map((file) => {
const hexCode = file.replace('_BORDER', '');
return [
hexCode,
CODES_WITH_LIGHT_BORDER.includes(hexCode),
CODES_WITH_DARK_BORDER.includes(hexCode),
] as const;
}),
)('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => {
const result = twemojiHasBorder(hexCode);
expect(result).toHaveProperty('hexCode', hexCode);
expect(result).toHaveProperty('hasLightBorder', isLight);
expect(result).toHaveProperty('hasDarkBorder', isDark);
});
});
describe('twemojiToUnicodeInfo', () => {
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));
test.concurrent.for(svgFileNamesWithoutBorder)(
'verifying SVG file %s maps to Unicode emoji',
(svgFileName, { expect }) => {
assert(!!svgFileName);
const result = twemojiToUnicodeInfo(svgFileName);
const hexcode = typeof result === 'string' ? result : result.unqualified;
if (!hexcode) {
// No hexcode means this is a special case like the Shibuya 109 emoji
expect(result).toHaveProperty('label');
return;
}
assert(!!hexcode);
expect(
unicodeCodeSet.has(hexcode),
`${hexcode} (${svgFileName}) not found`,
).toBeTruthy();
},
);
});

View File

@ -0,0 +1,173 @@
import {
VARIATION_SELECTOR_CODE,
KEYCAP_CODE,
GENDER_FEMALE_CODE,
GENDER_MALE_CODE,
SKIN_TONE_CODES,
EMOJIS_WITH_DARK_BORDER,
EMOJIS_WITH_LIGHT_BORDER,
} from './constants';
// Misc codes that have special handling
const SKIER_CODE = 0x26f7;
const CHRISTMAS_TREE_CODE = 0x1f384;
const MR_CLAUS_CODE = 0x1f385;
const EYE_CODE = 0x1f441;
const LEVITATING_PERSON_CODE = 0x1f574;
const SPEECH_BUBBLE_CODE = 0x1f5e8;
const MS_CLAUS_CODE = 0x1f936;
export function emojiToUnicodeHex(emoji: string): string {
const codes: number[] = [];
for (const char of emoji) {
const code = char.codePointAt(0);
if (code !== undefined) {
codes.push(code);
}
}
return hexNumbersToString(codes);
}
export function unicodeToTwemojiHex(unicodeHex: string): string {
const codes = hexStringToNumbers(unicodeHex);
const normalizedCodes: number[] = [];
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
if (!code) {
continue;
}
// Some emoji have their variation selector removed
if (code === VARIATION_SELECTOR_CODE) {
// Key emoji
if (i === 1 && codes.at(-1) === KEYCAP_CODE) {
continue;
}
// Eye in speech bubble
if (codes.at(0) === EYE_CODE && codes.at(-2) === SPEECH_BUBBLE_CODE) {
continue;
}
}
// This removes zero padding to correctly match the SVG filenames
normalizedCodes.push(code);
}
return hexNumbersToString(normalizedCodes, 0);
}
interface TwemojiBorderInfo {
hexCode: string;
hasLightBorder: boolean;
hasDarkBorder: boolean;
}
export const CODES_WITH_DARK_BORDER =
EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
export const CODES_WITH_LIGHT_BORDER =
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo {
const normalizedHex = twemojiHex.toUpperCase();
let hasLightBorder = false;
let hasDarkBorder = false;
if (CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
hasLightBorder = true;
}
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
hasDarkBorder = true;
}
return {
hexCode: normalizedHex,
hasLightBorder,
hasDarkBorder,
};
}
interface TwemojiSpecificEmoji {
unqualified?: string;
gender?: number;
skin?: number;
label?: string;
}
// Normalize man/woman to male/female
const GENDER_CODES_MAP: Record<number, number> = {
[GENDER_FEMALE_CODE]: GENDER_FEMALE_CODE,
[GENDER_MALE_CODE]: GENDER_MALE_CODE,
// These are man/woman markers, but are used for gender sometimes.
[0x1f468]: GENDER_MALE_CODE,
[0x1f469]: GENDER_FEMALE_CODE,
};
const TWEMOJI_SPECIAL_CASES: Record<string, string | TwemojiSpecificEmoji> = {
'1F441-200D-1F5E8': '1F441-FE0F-200D-1F5E8-FE0F', // Eye in speech bubble
// An emoji that was never ported to the Unicode standard.
// See: https://emojipedia.org/shibuya
E50A: { label: 'Shibuya 109' },
};
export function twemojiToUnicodeInfo(
twemojiHex: string,
): TwemojiSpecificEmoji | string {
const specialCase = TWEMOJI_SPECIAL_CASES[twemojiHex.toUpperCase()];
if (specialCase) {
return specialCase;
}
const codes = hexStringToNumbers(twemojiHex);
let gender: undefined | number;
let skin: undefined | number;
for (const code of codes) {
if (!gender && code in GENDER_CODES_MAP) {
gender = GENDER_CODES_MAP[code];
} else if (!skin && code in SKIN_TONE_CODES) {
skin = code;
}
// Exit if we have both skin and gender
if (skin && gender) {
break;
}
}
let mappedCodes: unknown[] = codes;
if (codes.at(-1) === CHRISTMAS_TREE_CODE && codes.length >= 3 && gender) {
// Twemoji uses the christmas tree with a ZWJ for Mr. and Mrs. Claus,
// but in Unicode that only works for Mx. Claus.
const START_CODE =
gender === GENDER_FEMALE_CODE ? MS_CLAUS_CODE : MR_CLAUS_CODE;
mappedCodes = [START_CODE, skin];
} else if (codes.at(-1) === KEYCAP_CODE && codes.length === 2) {
// For key emoji, insert the variation selector
mappedCodes = [codes[0], VARIATION_SELECTOR_CODE, KEYCAP_CODE];
} else if (
(codes.at(0) === SKIER_CODE || codes.at(0) === LEVITATING_PERSON_CODE) &&
codes.length > 1
) {
// Twemoji offers more gender and skin options for the skier and levitating person emoji.
return {
unqualified: hexNumbersToString([codes.at(0)]),
skin,
gender,
};
}
return hexNumbersToString(mappedCodes);
}
function hexStringToNumbers(hexString: string): number[] {
return hexString
.split('-')
.map((code) => Number.parseInt(code, 16))
.filter((code) => !Number.isNaN(code));
}
function hexNumbersToString(codes: unknown[], padding = 4): string {
return codes
.filter(
(code): code is number =>
typeof code === 'number' && code > 0 && !Number.isNaN(code),
)
.map((code) => code.toString(16).padStart(padding, '0').toUpperCase())
.join('-');
}

View File

@ -0,0 +1,13 @@
import { importEmojiData, importCustomEmojiData } 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;
if (locale !== 'custom') {
void importEmojiData(locale);
} else {
void importCustomEmojiData();
}
}

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useCallback, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
@ -7,8 +7,10 @@ import { Helmet } from 'react-helmet';
import { isFulfilled } from '@reduxjs/toolkit'; import { isFulfilled } from '@reduxjs/toolkit';
import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { unfollowHashtag } from 'mastodon/actions/tags_typed'; import {
import { apiGetFollowedTags } from 'mastodon/api/tags'; fetchFollowedHashtags,
unfollowHashtag,
} from 'mastodon/actions/tags_typed';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
@ -16,7 +18,7 @@ import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { Hashtag } from 'mastodon/components/hashtag'; import { Hashtag } from 'mastodon/components/hashtag';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' }, heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
@ -59,55 +61,32 @@ const FollowedTag: React.FC<{
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const intl = useIntl(); const intl = useIntl();
const [tags, setTags] = useState<ApiHashtagJSON[]>([]); const dispatch = useAppDispatch();
const [loading, setLoading] = useState(false); const { tags, loading, next, stale } = useAppSelector(
const [next, setNext] = useState<string | undefined>(); (state) => state.followedTags,
);
const hasMore = !!next; const hasMore = !!next;
const columnRef = useRef<ColumnRef>(null);
useEffect(() => { useEffect(() => {
setLoading(true); if (stale) {
void dispatch(fetchFollowedHashtags());
void apiGetFollowedTags() }
.then(({ tags, links }) => { }, [dispatch, stale]);
const next = links.refs.find((link) => link.rel === 'next');
setTags(tags);
setLoading(false);
setNext(next?.uri);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setTags, setLoading, setNext]);
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
setLoading(true); if (next) {
void dispatch(fetchFollowedHashtags({ next }));
void apiGetFollowedTags(next) }
.then(({ tags, links }) => { }, [dispatch, next]);
const next = links.refs.find((link) => link.rel === 'next');
setLoading(false);
setTags((previousTags) => [...previousTags, ...tags]);
setNext(next?.uri);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setTags, setLoading, setNext, next]);
const handleUnfollow = useCallback( const handleUnfollow = useCallback(
(tagId: string) => { (tagId: string) => {
setTags((tags) => tags.filter((tag) => tag.name !== tagId)); void dispatch(unfollowHashtag({ tagId }));
}, },
[setTags], [dispatch],
); );
const columnRef = useRef<ColumnRef>(null);
const handleHeaderClick = useCallback(() => { const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop(); columnRef.current?.scrollTop();
}, []); }, []);

View File

@ -1,179 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
import StarIcon from '@/material-icons/400-24px/star.svg?react';
import { fetchFollowRequests } from 'mastodon/actions/accounts';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
import { me, showTrends } from '../../initial_state';
import { NavigationBar } from '../compose/components/navigation_bar';
import { ColumnLink } from '../ui/components/column_link';
import ColumnSubheading from '../ui/components/column_subheading';
import { Trends } from 'mastodon/features/navigation_panel/components/trends';
const messages = defineMessages({
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
explore: { id: 'navigation_bar.explore', defaultMessage: 'Explore' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' },
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
discover: { id: 'navigation_bar.discover', defaultMessage: 'Discover' },
personal: { id: 'navigation_bar.personal', defaultMessage: 'Personal' },
security: { id: 'navigation_bar.security', defaultMessage: 'Security' },
menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
});
const mapStateToProps = state => ({
myAccount: state.getIn(['accounts', me]),
unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
});
const mapDispatchToProps = dispatch => ({
fetchFollowRequests: () => dispatch(fetchFollowRequests()),
});
const badgeDisplay = (number, limit) => {
if (number === 0) {
return undefined;
} else if (limit && number >= limit) {
return `${limit}+`;
} else {
return number;
}
};
class GettingStarted extends ImmutablePureComponent {
static propTypes = {
identity: identityContextPropShape,
intl: PropTypes.object.isRequired,
myAccount: ImmutablePropTypes.record,
multiColumn: PropTypes.bool,
fetchFollowRequests: PropTypes.func.isRequired,
unreadFollowRequests: PropTypes.number,
unreadNotifications: PropTypes.number,
};
componentDidMount () {
const { fetchFollowRequests } = this.props;
const { signedIn } = this.props.identity;
if (!signedIn) {
return;
}
fetchFollowRequests();
}
render () {
const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props;
const { signedIn, permissions } = this.props.identity;
const navItems = [];
navItems.push(
<ColumnSubheading key='header-discover' text={intl.formatMessage(messages.discover)} />,
);
if (showTrends) {
navItems.push(
<ColumnLink key='explore' icon='explore' iconComponent={ExploreIcon} text={intl.formatMessage(messages.explore)} to='/explore' />,
);
}
navItems.push(
<ColumnLink key='community_timeline' icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.community_timeline)} to='/public/local' />,
<ColumnLink key='public_timeline' icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.public_timeline)} to='/public' />,
);
if (signedIn) {
navItems.push(
<ColumnSubheading key='header-personal' text={intl.formatMessage(messages.personal)} />,
<ColumnLink key='home' icon='home' iconComponent={HomeIcon} text={intl.formatMessage(messages.home_timeline)} to='/home' />,
<ColumnLink key='direct' icon='at' iconComponent={AlternateEmailIcon} text={intl.formatMessage(messages.direct)} to='/conversations' />,
<ColumnLink key='bookmark' icon='bookmarks' iconComponent={BookmarksIcon} text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
<ColumnLink key='favourites' icon='star' iconComponent={StarIcon} text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='lists' icon='list-ul' iconComponent={ListAltIcon} text={intl.formatMessage(messages.lists)} to='/lists' />,
);
if (myAccount.get('locked') || unreadFollowRequests > 0) {
navItems.push(<ColumnLink key='follow_requests' icon='user-plus' iconComponent={PersonAddIcon} text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
}
navItems.push(
<ColumnSubheading key='header-settings' text={intl.formatMessage(messages.settings_subheading)} />,
<ColumnLink key='preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />,
);
if (canManageReports(permissions)) {
navItems.push(<ColumnLink key='moderation' href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />);
}
if (canViewAdminDashboard(permissions)) {
navItems.push(<ColumnLink key='administration' href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />);
}
}
return (
<Column>
{(signedIn && !multiColumn) ? <NavigationBar /> : <ColumnHeader title={intl.formatMessage(messages.menu)} icon='bars' iconComponent={MenuIcon} multiColumn={multiColumn} />}
<div className='getting-started scrollable scrollable--flex'>
<div className='getting-started__wrapper'>
{navItems}
</div>
{!multiColumn && <div className='flex-spacer' />}
<LinkFooter multiColumn />
</div>
{(multiColumn && showTrends) && <Trends />}
<Helmet>
<title>{intl.formatMessage(messages.menu)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default withIdentity(connect(mapStateToProps, mapDispatchToProps)(injectIntl(GettingStarted)));

View File

@ -0,0 +1,32 @@
import { useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Column } from 'mastodon/components/column';
import { NavigationPanel } from '../navigation_panel';
import { LinkFooter } from '../ui/components/link_footer';
const GettingStarted: React.FC = () => {
const intl = useIntl();
return (
<Column>
<NavigationPanel multiColumn />
<LinkFooter multiColumn />
<Helmet>
<title>
{intl.formatMessage({
id: 'getting_started.heading',
defaultMessage: 'Getting started',
})}
</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default GettingStarted;

View File

@ -1,11 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { apiGetFollowedTags } from 'mastodon/api/tags'; import { fetchFollowedHashtags } from 'mastodon/actions/tags_typed';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import { ColumnLink } from 'mastodon/features/ui/components/column_link'; import { ColumnLink } from 'mastodon/features/ui/components/column_link';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollapsiblePanel } from './collapsible_panel'; import { CollapsiblePanel } from './collapsible_panel';
@ -24,25 +24,20 @@ const messages = defineMessages({
}, },
}); });
const TAG_LIMIT = 4;
export const FollowedTagsPanel: React.FC = () => { export const FollowedTagsPanel: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const [tags, setTags] = useState<ApiHashtagJSON[]>([]); const dispatch = useAppDispatch();
const [loading, setLoading] = useState(false); const { tags, stale, loading } = useAppSelector(
(state) => state.followedTags,
);
useEffect(() => { useEffect(() => {
setLoading(true); if (stale) {
void dispatch(fetchFollowedHashtags());
void apiGetFollowedTags(undefined, 4) }
.then(({ tags }) => { }, [dispatch, stale]);
setTags(tags);
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setLoading, setTags]);
return ( return (
<CollapsiblePanel <CollapsiblePanel
@ -54,14 +49,14 @@ export const FollowedTagsPanel: React.FC = () => {
expandTitle={intl.formatMessage(messages.expand)} expandTitle={intl.formatMessage(messages.expand)}
loading={loading} loading={loading}
> >
{tags.map((tag) => ( {tags.slice(0, TAG_LIMIT).map((tag) => (
<ColumnLink <ColumnLink
transparent
icon='hashtag' icon='hashtag'
key={tag.name} key={tag.name}
iconComponent={TagIcon} iconComponent={TagIcon}
text={`#${tag.name}`} text={`#${tag.name}`}
to={`/tags/${tag.name}`} to={`/tags/${tag.name}`}
transparent
/> />
))} ))}
</CollapsiblePanel> </CollapsiblePanel>

View File

@ -50,16 +50,22 @@ export const MoreLink: React.FC = () => {
const menu = useMemo(() => { const menu = useMemo(() => {
const arr: MenuItem[] = [ const arr: MenuItem[] = [
{ text: intl.formatMessage(messages.filters), href: '/filters' },
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
{ {
text: intl.formatMessage(messages.domainBlocks), href: '/filters',
to: '/domain_blocks', text: intl.formatMessage(messages.filters),
},
{
to: '/mutes',
text: intl.formatMessage(messages.mutes),
},
{
to: '/blocks',
text: intl.formatMessage(messages.blocks),
},
{
to: '/domain_blocks',
text: intl.formatMessage(messages.domainBlocks),
}, },
];
arr.push(
null, null,
{ {
href: '/settings/privacy', href: '/settings/privacy',
@ -77,7 +83,7 @@ export const MoreLink: React.FC = () => {
href: '/settings/export', href: '/settings/export',
text: intl.formatMessage(messages.importExport), text: intl.formatMessage(messages.importExport),
}, },
); ];
if (canManageReports(permissions)) { if (canManageReports(permissions)) {
arr.push(null, { arr.push(null, {
@ -106,7 +112,7 @@ export const MoreLink: React.FC = () => {
}, [intl, dispatch, permissions]); }, [intl, dispatch, permissions]);
return ( return (
<Dropdown items={menu}> <Dropdown items={menu} placement='bottom-start'>
<button className='column-link column-link--transparent'> <button className='column-link column-link--transparent'>
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' /> <Icon id='' icon={MoreHorizIcon} className='column-link__icon' />

View File

@ -185,113 +185,13 @@ const isFirehoseActive = (
const MENU_WIDTH = 284; const MENU_WIDTH = 284;
export const NavigationPanel: React.FC = () => { export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
multiColumn = false,
}) => {
const intl = useIntl(); const intl = useIntl();
const { signedIn, disabledAccountId } = useIdentity(); const { signedIn, disabledAccountId } = useIdentity();
const open = useAppSelector((state) => state.navigation.open);
const dispatch = useAppDispatch();
const openable = useBreakpoint('openable');
const showSearch = useBreakpoint('full');
const location = useLocation(); const location = useLocation();
const overlayRef = useRef<HTMLDivElement | null>(null); const showSearch = useBreakpoint('full') && !multiColumn;
useEffect(() => {
dispatch(closeNavigation());
}, [dispatch, location]);
useEffect(() => {
const handleDocumentClick = (e: MouseEvent) => {
if (overlayRef.current && e.target === overlayRef.current) {
dispatch(closeNavigation());
}
};
const handleDocumentKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
dispatch(closeNavigation());
}
};
document.addEventListener('click', handleDocumentClick);
document.addEventListener('keyup', handleDocumentKeyUp);
return () => {
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('keyup', handleDocumentKeyUp);
};
}, [dispatch]);
const isLtrDir = getComputedStyle(document.body).direction !== 'rtl';
const OPEN_MENU_OFFSET = isLtrDir ? MENU_WIDTH : -MENU_WIDTH;
const [{ x }, spring] = useSpring(
() => ({
x: open ? 0 : OPEN_MENU_OFFSET,
onRest: {
x({ value }: { value: number }) {
if (value === 0) {
dispatch(openNavigation());
} else if (isLtrDir ? value > 0 : value < 0) {
dispatch(closeNavigation());
}
},
},
}),
[open],
);
const bind = useDrag(
({
last,
offset: [xOffset],
velocity: [xVelocity],
direction: [xDirection],
cancel,
}) => {
const logicalXDirection = isLtrDir ? xDirection : -xDirection;
const logicalXOffset = isLtrDir ? xOffset : -xOffset;
const hasReachedDragThreshold = logicalXOffset < -70;
if (hasReachedDragThreshold) {
cancel();
}
if (last) {
const isAboveOpenThreshold = logicalXOffset > MENU_WIDTH / 2;
const isQuickFlick = xVelocity > 0.5 && logicalXDirection > 0;
if (isAboveOpenThreshold || isQuickFlick) {
void spring.start({ x: OPEN_MENU_OFFSET });
} else {
void spring.start({ x: 0 });
}
} else {
void spring.start({ x: xOffset, immediate: true });
}
},
{
from: () => [x.get(), 0],
filterTaps: true,
bounds: isLtrDir ? { left: 0 } : { right: 0 },
rubberband: true,
},
);
const previouslyFocusedElementRef = useRef<HTMLElement | null>();
useEffect(() => {
if (open) {
const firstLink = document.querySelector<HTMLAnchorElement>(
'.navigation-panel__menu .column-link',
);
previouslyFocusedElementRef.current =
document.activeElement as HTMLElement;
firstLink?.focus();
} else {
previouslyFocusedElementRef.current?.focus();
}
}, [open]);
let banner: React.ReactNode; let banner: React.ReactNode;
@ -309,21 +209,7 @@ export const NavigationPanel: React.FC = () => {
); );
} }
const showOverlay = openable && open;
return ( return (
<div
className={classNames(
'columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational',
{ 'columns-area__panels__pane--overlay': showOverlay },
)}
ref={overlayRef}
>
<animated.div
className='columns-area__panels__pane__inner'
{...bind()}
style={openable ? { x } : undefined}
>
<div className='navigation-panel'> <div className='navigation-panel'>
<div className='navigation-panel__logo'> <div className='navigation-panel__logo'>
<Link to='/' className='column-link column-link--logo'> <Link to='/' className='column-link column-link--logo'>
@ -333,13 +219,14 @@ export const NavigationPanel: React.FC = () => {
{showSearch && <Search singleColumn />} {showSearch && <Search singleColumn />}
<ProfileCard /> {!multiColumn && <ProfileCard />}
{banner && <div className='navigation-panel__banner'>{banner}</div>} {banner && <div className='navigation-panel__banner'>{banner}</div>}
<div className='navigation-panel__menu'> <div className='navigation-panel__menu'>
{signedIn && ( {signedIn && (
<> <>
{!multiColumn && (
<ColumnLink <ColumnLink
to='/publish' to='/publish'
icon='plus' icon='plus'
@ -348,6 +235,7 @@ export const NavigationPanel: React.FC = () => {
text={intl.formatMessage(messages.compose)} text={intl.formatMessage(messages.compose)}
className='button navigation-panel__compose-button' className='button navigation-panel__compose-button'
/> />
)}
<ColumnLink <ColumnLink
transparent transparent
to='/home' to='/home'
@ -444,11 +332,7 @@ export const NavigationPanel: React.FC = () => {
<div className='navigation-panel__sign-in-banner'> <div className='navigation-panel__sign-in-banner'>
<hr /> <hr />
{disabledAccountId ? ( {disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner />}
<DisabledAccountBanner />
) : (
<SignInBanner />
)}
</div> </div>
)} )}
</div> </div>
@ -457,6 +341,131 @@ export const NavigationPanel: React.FC = () => {
<Trends /> <Trends />
</div> </div>
);
};
export const CollapsibleNavigationPanel: React.FC = () => {
const open = useAppSelector((state) => state.navigation.open);
const dispatch = useAppDispatch();
const openable = useBreakpoint('openable');
const location = useLocation();
const overlayRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
dispatch(closeNavigation());
}, [dispatch, location]);
useEffect(() => {
const handleDocumentClick = (e: MouseEvent) => {
if (overlayRef.current && e.target === overlayRef.current) {
dispatch(closeNavigation());
}
};
const handleDocumentKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
dispatch(closeNavigation());
}
};
document.addEventListener('click', handleDocumentClick);
document.addEventListener('keyup', handleDocumentKeyUp);
return () => {
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('keyup', handleDocumentKeyUp);
};
}, [dispatch]);
const isLtrDir = getComputedStyle(document.body).direction !== 'rtl';
const OPEN_MENU_OFFSET = isLtrDir ? MENU_WIDTH : -MENU_WIDTH;
const [{ x }, spring] = useSpring(
() => ({
x: open ? 0 : OPEN_MENU_OFFSET,
onRest: {
x({ value }: { value: number }) {
if (value === 0) {
dispatch(openNavigation());
} else if (isLtrDir ? value > 0 : value < 0) {
dispatch(closeNavigation());
}
},
},
}),
[open],
);
const bind = useDrag(
({
last,
offset: [xOffset],
velocity: [xVelocity],
direction: [xDirection],
cancel,
}) => {
const logicalXDirection = isLtrDir ? xDirection : -xDirection;
const logicalXOffset = isLtrDir ? xOffset : -xOffset;
const hasReachedDragThreshold = logicalXOffset < -70;
if (hasReachedDragThreshold) {
cancel();
}
if (last) {
const isAboveOpenThreshold = logicalXOffset > MENU_WIDTH / 2;
const isQuickFlick = xVelocity > 0.5 && logicalXDirection > 0;
if (isAboveOpenThreshold || isQuickFlick) {
void spring.start({ x: OPEN_MENU_OFFSET });
} else {
void spring.start({ x: 0 });
}
} else {
void spring.start({ x: xOffset, immediate: true });
}
},
{
from: () => [x.get(), 0],
filterTaps: true,
bounds: isLtrDir ? { left: 0 } : { right: 0 },
rubberband: true,
enabled: openable,
},
);
const previouslyFocusedElementRef = useRef<HTMLElement | null>();
useEffect(() => {
if (open) {
const firstLink = document.querySelector<HTMLAnchorElement>(
'.navigation-panel__menu .column-link',
);
previouslyFocusedElementRef.current =
document.activeElement as HTMLElement;
firstLink?.focus();
} else {
previouslyFocusedElementRef.current?.focus();
}
}, [open]);
const showOverlay = openable && open;
return (
<div
className={classNames(
'columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational',
{ 'columns-area__panels__pane--overlay': showOverlay },
)}
ref={overlayRef}
>
<animated.div
className='columns-area__panels__pane__inner'
{...bind()}
style={openable ? { x } : undefined}
>
<NavigationPanel />
</animated.div> </animated.div>
</div> </div>
); );

View File

@ -1,53 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import SettingsIcon from '@/material-icons/400-20px/settings.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { requestBrowserPermission } from 'mastodon/actions/notifications';
import { changeSetting } from 'mastodon/actions/settings';
import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
class NotificationsPermissionBanner extends PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleClick = () => {
this.props.dispatch(requestBrowserPermission());
};
handleClose = () => {
this.props.dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true));
};
render () {
const { intl } = this.props;
return (
<div className='notifications-permission-banner'>
<div className='notifications-permission-banner__close'>
<IconButton icon='times' iconComponent={CloseIcon} onClick={this.handleClose} title={intl.formatMessage(messages.close)} />
</div>
<h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
<p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' icon={SettingsIcon} /> }} /></p>
<Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
</div>
);
}
}
export default connect()(injectIntl(NotificationsPermissionBanner));

View File

@ -0,0 +1,74 @@
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useAppDispatch } from '@/mastodon/store';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
import { requestBrowserPermission } from 'mastodon/actions/notifications';
import { changeSetting } from 'mastodon/actions/settings';
import { Button } from 'mastodon/components/button';
import { messages as columnHeaderMessages } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
const NotificationsPermissionBanner: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
dispatch(requestBrowserPermission());
}, [dispatch]);
const handleClose = useCallback(() => {
dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true));
}, [dispatch]);
return (
<div className='notifications-permission-banner'>
<div className='notifications-permission-banner__close'>
<IconButton
icon='times'
iconComponent={CloseIcon}
onClick={handleClose}
title={intl.formatMessage(messages.close)}
/>
</div>
<h2>
<FormattedMessage
id='notifications_permission_banner.title'
defaultMessage='Never miss a thing'
/>
</h2>
<p>
<FormattedMessage
id='notifications_permission_banner.how_to_control'
defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled."
values={{
icon: (
<Icon
id='sliders'
icon={UnfoldMoreIcon}
aria-label={intl.formatMessage(columnHeaderMessages.show)}
/>
),
}}
/>
</p>
<Button onClick={handleClick}>
<FormattedMessage
id='notifications_permission_banner.enable'
defaultMessage='Enable desktop notifications'
/>
</Button>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default NotificationsPermissionBanner;

View File

@ -122,98 +122,93 @@ export const PolicyControls: React.FC = () => {
value={notificationPolicy.for_not_following} value={notificationPolicy.for_not_following}
onChange={handleFilterNotFollowing} onChange={handleFilterNotFollowing}
options={options} options={options}
> label={
<strong>
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_not_following_title' id='notifications.policy.filter_not_following_title'
defaultMessage="People you don't follow" defaultMessage="People you don't follow"
/> />
</strong> }
<span className='hint'> hint={
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_not_following_hint' id='notifications.policy.filter_not_following_hint'
defaultMessage='Until you manually approve them' defaultMessage='Until you manually approve them'
/> />
</span> }
</SelectWithLabel> />
<SelectWithLabel <SelectWithLabel
value={notificationPolicy.for_not_followers} value={notificationPolicy.for_not_followers}
onChange={handleFilterNotFollowers} onChange={handleFilterNotFollowers}
options={options} options={options}
> label={
<strong>
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_not_followers_title' id='notifications.policy.filter_not_followers_title'
defaultMessage='People not following you' defaultMessage='People not following you'
/> />
</strong> }
<span className='hint'> hint={
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_not_followers_hint' id='notifications.policy.filter_not_followers_hint'
defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}' defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}'
values={{ days: 3 }} values={{ days: 3 }}
/> />
</span> }
</SelectWithLabel> />
<SelectWithLabel <SelectWithLabel
value={notificationPolicy.for_new_accounts} value={notificationPolicy.for_new_accounts}
onChange={handleFilterNewAccounts} onChange={handleFilterNewAccounts}
options={options} options={options}
> label={
<strong>
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_new_accounts_title' id='notifications.policy.filter_new_accounts_title'
defaultMessage='New accounts' defaultMessage='New accounts'
/> />
</strong> }
<span className='hint'> hint={
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_new_accounts.hint' id='notifications.policy.filter_new_accounts.hint'
defaultMessage='Created within the past {days, plural, one {one day} other {# days}}' defaultMessage='Created within the past {days, plural, one {one day} other {# days}}'
values={{ days: 30 }} values={{ days: 30 }}
/> />
</span> }
</SelectWithLabel> />
<SelectWithLabel <SelectWithLabel
value={notificationPolicy.for_private_mentions} value={notificationPolicy.for_private_mentions}
onChange={handleFilterPrivateMentions} onChange={handleFilterPrivateMentions}
options={options} options={options}
> label={
<strong>
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_private_mentions_title' id='notifications.policy.filter_private_mentions_title'
defaultMessage='Unsolicited private mentions' defaultMessage='Unsolicited private mentions'
/> />
</strong> }
<span className='hint'> hint={
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_private_mentions_hint' id='notifications.policy.filter_private_mentions_hint'
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender" defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
/> />
</span> }
</SelectWithLabel> />
<SelectWithLabel <SelectWithLabel
value={notificationPolicy.for_limited_accounts} value={notificationPolicy.for_limited_accounts}
onChange={handleFilterLimitedAccounts} onChange={handleFilterLimitedAccounts}
options={options} options={options}
> label={
<strong>
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_limited_accounts_title' id='notifications.policy.filter_limited_accounts_title'
defaultMessage='Moderated accounts' defaultMessage='Moderated accounts'
/> />
</strong> }
<span className='hint'> hint={
<FormattedMessage <FormattedMessage
id='notifications.policy.filter_limited_accounts_hint' id='notifications.policy.filter_limited_accounts_hint'
defaultMessage='Limited by server moderators' defaultMessage='Limited by server moderators'
/> />
</span> }
</SelectWithLabel> />
</div> </div>
</section> </section>
); );

View File

@ -1,5 +1,5 @@
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import { useCallback, useState, useRef } from 'react'; import { useCallback, useState, useRef, useId } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
@ -16,6 +16,8 @@ interface DropdownProps {
options: SelectItem[]; options: SelectItem[];
disabled?: boolean; disabled?: boolean;
onChange: (value: string) => void; onChange: (value: string) => void;
'aria-labelledby': string;
'aria-describedby'?: string;
placement?: Placement; placement?: Placement;
} }
@ -24,51 +26,33 @@ const Dropdown: React.FC<DropdownProps> = ({
options, options,
disabled, disabled,
onChange, onChange,
'aria-labelledby': ariaLabelledBy,
'aria-describedby': ariaDescribedBy,
placement: initialPlacement = 'bottom-end', placement: initialPlacement = 'bottom-end',
}) => { }) => {
const activeElementRef = useRef<Element | null>(null); const containerRef = useRef<HTMLDivElement>(null);
const containerRef = useRef(null); const buttonRef = useRef<HTMLButtonElement>(null);
const [isOpen, setOpen] = useState<boolean>(false); const [isOpen, setOpen] = useState<boolean>(false);
const [placement, setPlacement] = useState<Placement>(initialPlacement); const [placement, setPlacement] = useState<Placement>(initialPlacement);
const uniqueId = useId();
const handleToggle = useCallback(() => { const menuId = `${uniqueId}-menu`;
if ( const buttonLabelId = `${uniqueId}-button`;
isOpen &&
activeElementRef.current &&
activeElementRef.current instanceof HTMLElement
) {
activeElementRef.current.focus({ preventScroll: true });
}
setOpen(!isOpen);
}, [isOpen, setOpen]);
const handleMouseDown = useCallback(() => {
if (!isOpen) activeElementRef.current = document.activeElement;
}, [isOpen]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case ' ':
case 'Enter':
if (!isOpen) activeElementRef.current = document.activeElement;
break;
}
},
[isOpen],
);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if ( if (isOpen && buttonRef.current) {
isOpen && buttonRef.current.focus({ preventScroll: true });
activeElementRef.current && }
activeElementRef.current instanceof HTMLElement
)
activeElementRef.current.focus({ preventScroll: true });
setOpen(false); setOpen(false);
}, [isOpen]); }, [isOpen]);
const handleToggle = useCallback(() => {
if (isOpen) {
handleClose();
} else {
setOpen(true);
}
}, [isOpen, handleClose]);
const handleOverlayEnter = useCallback( const handleOverlayEnter = useCallback(
(state: Partial<PopperState>) => { (state: Partial<PopperState>) => {
if (state.placement) setPlacement(state.placement); if (state.placement) setPlacement(state.placement);
@ -82,13 +66,18 @@ const Dropdown: React.FC<DropdownProps> = ({
<div ref={containerRef}> <div ref={containerRef}>
<button <button
type='button' type='button'
ref={buttonRef}
onClick={handleToggle} onClick={handleToggle}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
disabled={disabled} disabled={disabled}
aria-expanded={isOpen}
aria-controls={menuId}
aria-labelledby={`${ariaLabelledBy} ${buttonLabelId}`}
aria-describedby={ariaDescribedBy}
className={classNames('dropdown-button', { active: isOpen })} className={classNames('dropdown-button', { active: isOpen })}
> >
<span className='dropdown-button__label'>{valueOption?.text}</span> <span id={buttonLabelId} className='dropdown-button__label'>
{valueOption?.text}
</span>
<Icon id='down' icon={ArrowDropDownIcon} /> <Icon id='down' icon={ArrowDropDownIcon} />
</button> </button>
@ -101,7 +90,7 @@ const Dropdown: React.FC<DropdownProps> = ({
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }} popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
> >
{({ props, placement }) => ( {({ props, placement }) => (
<div {...props}> <div {...props} id={menuId}>
<div <div
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`} className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
> >
@ -123,6 +112,8 @@ const Dropdown: React.FC<DropdownProps> = ({
interface Props { interface Props {
value: string; value: string;
options: SelectItem[]; options: SelectItem[];
label: string | React.ReactElement;
hint: string | React.ReactElement;
disabled?: boolean; disabled?: boolean;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
@ -130,13 +121,26 @@ interface Props {
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
value, value,
options, options,
label,
hint,
disabled, disabled,
children,
onChange, onChange,
}) => { }) => {
const uniqueId = useId();
const labelId = `${uniqueId}-label`;
const descId = `${uniqueId}-desc`;
return ( return (
// This label is only used for its click-forwarding behaviour,
// accessible names are assigned manually
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label className='app-form__toggle'> <label className='app-form__toggle'>
<div className='app-form__toggle__label'>{children}</div> <div className='app-form__toggle__label'>
<strong id={labelId}>{label}</strong>
<span className='hint' id={descId}>
{hint}
</span>
</div>
<div className='app-form__toggle__toggle'> <div className='app-form__toggle__toggle'>
<div> <div>
@ -144,6 +148,8 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
aria-labelledby={labelId}
aria-describedby={descId}
options={options} options={options}
/> />
</div> </div>

View File

@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@ -21,6 +21,9 @@ import { openModal } from 'mastodon/actions/modal';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { useIdentity } from 'mastodon/identity_context'; import { useIdentity } from 'mastodon/identity_context';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import type { Status } from 'mastodon/models/status';
import { makeGetStatus } from 'mastodon/selectors';
import type { RootState } from 'mastodon/store';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({ const messages = defineMessages({
@ -47,6 +50,11 @@ const messages = defineMessages({
open: { id: 'status.open', defaultMessage: 'Expand this status' }, open: { id: 'status.open', defaultMessage: 'Expand this status' },
}); });
type GetStatusSelector = (
state: RootState,
props: { id?: string | null; contextType?: string },
) => Status | null;
export const Footer: React.FC<{ export const Footer: React.FC<{
statusId: string; statusId: string;
withOpenButton?: boolean; withOpenButton?: boolean;
@ -56,7 +64,8 @@ export const Footer: React.FC<{
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const status = useAppSelector((state) => state.statuses.get(statusId)); const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector;
const status = useAppSelector((state) => getStatus(state, { id: statusId }));
const accountId = status?.get('account') as string | undefined; const accountId = status?.get('account') as string | undefined;
const account = useAppSelector((state) => const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined, accountId ? state.accounts.get(accountId) : undefined,

View File

@ -1,4 +1,4 @@
import { render, fireEvent, screen } from 'mastodon/test_helpers'; import { render, fireEvent, screen } from '@/testing/rendering';
import Column from '../column'; import Column from '../column';

View File

@ -0,0 +1,30 @@
import { useLayoutEffect } from 'react';
import { createAppSelector, useAppSelector } from 'mastodon/store';
const getShouldLockBodyScroll = createAppSelector(
[
(state) => state.navigation.open,
(state) => state.modal.get('stack').size > 0,
],
(isMobileMenuOpen: boolean, isModalOpen: boolean) =>
isMobileMenuOpen || isModalOpen,
);
/**
* This component locks scrolling on the body when
* `getShouldLockBodyScroll` returns true.
*/
export const BodyScrollLock: React.FC = () => {
const shouldLockBodyScroll = useAppSelector(getShouldLockBodyScroll);
useLayoutEffect(() => {
document.documentElement.classList.toggle(
'has-modal',
shouldLockBodyScroll,
);
}, [shouldLockBodyScroll]);
return null;
};

View File

@ -16,7 +16,6 @@ export const ColumnLink: React.FC<{
method?: string; method?: string;
badge?: React.ReactNode; badge?: React.ReactNode;
transparent?: boolean; transparent?: boolean;
optional?: boolean;
className?: string; className?: string;
id?: string; id?: string;
}> = ({ }> = ({
@ -30,13 +29,11 @@ export const ColumnLink: React.FC<{
method, method,
badge, badge,
transparent, transparent,
optional,
...other ...other
}) => { }) => {
const match = useRouteMatch(to ?? ''); const match = useRouteMatch(to ?? '');
const className = classNames('column-link', { const className = classNames('column-link', {
'column-link--transparent': transparent, 'column-link--transparent': transparent,
'column-link--optional': optional,
}); });
const badgeElement = const badgeElement =
typeof badge !== 'undefined' ? ( typeof badge !== 'undefined' ? (

View File

@ -23,9 +23,9 @@ import { useColumnsContext } from '../util/columns_context';
import BundleColumnError from './bundle_column_error'; import BundleColumnError from './bundle_column_error';
import { ColumnLoading } from './column_loading'; import { ColumnLoading } from './column_loading';
import { ComposePanel } from './compose_panel'; import { ComposePanel, RedirectToMobileComposeIfNeeded } from './compose_panel';
import DrawerLoading from './drawer_loading'; import DrawerLoading from './drawer_loading';
import { NavigationPanel } from 'mastodon/features/navigation_panel'; import { CollapsibleNavigationPanel } from 'mastodon/features/navigation_panel';
const componentMap = { const componentMap = {
'COMPOSE': Compose, 'COMPOSE': Compose,
@ -124,6 +124,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
<div className='columns-area__panels__pane columns-area__panels__pane--compositional'> <div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
<div className='columns-area__panels__pane__inner'> <div className='columns-area__panels__pane__inner'>
{renderComposePanel && <ComposePanel />} {renderComposePanel && <ComposePanel />}
<RedirectToMobileComposeIfNeeded />
</div> </div>
</div> </div>
@ -132,7 +133,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
<div className='columns-area columns-area--mobile'>{children}</div> <div className='columns-area columns-area--mobile'>{children}</div>
</div> </div>
<NavigationPanel /> <CollapsibleNavigationPanel />
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect, useLayoutEffect } from 'react';
import { useLayout } from '@/mastodon/hooks/useLayout'; import { useLayout } from '@/mastodon/hooks/useLayout';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@ -7,6 +7,7 @@ import {
mountCompose, mountCompose,
unmountCompose, unmountCompose,
} from 'mastodon/actions/compose'; } from 'mastodon/actions/compose';
import { useAppHistory } from 'mastodon/components/router';
import ServerBanner from 'mastodon/components/server_banner'; import ServerBanner from 'mastodon/components/server_banner';
import { Search } from 'mastodon/features/compose/components/search'; import { Search } from 'mastodon/features/compose/components/search';
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container'; import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
@ -54,3 +55,25 @@ export const ComposePanel: React.FC = () => {
</div> </div>
); );
}; };
/**
* Redirect the user to the standalone compose page when the
* sidebar composer is hidden due to a change in viewport size
* while a post is being written.
*/
export const RedirectToMobileComposeIfNeeded: React.FC = () => {
const history = useAppHistory();
const shouldRedirect = useAppSelector((state) =>
state.compose.get('should_redirect_to_compose_page'),
);
useLayoutEffect(() => {
if (shouldRedirect) {
history.push('/publish');
}
}, [history, shouldRedirect]);
return null;
};

View File

@ -13,6 +13,7 @@ export const ConfirmationModal: React.FC<
title: React.ReactNode; title: React.ReactNode;
message: React.ReactNode; message: React.ReactNode;
confirm: React.ReactNode; confirm: React.ReactNode;
cancel?: React.ReactNode;
secondary?: React.ReactNode; secondary?: React.ReactNode;
onSecondary?: () => void; onSecondary?: () => void;
onConfirm: () => void; onConfirm: () => void;
@ -22,6 +23,7 @@ export const ConfirmationModal: React.FC<
title, title,
message, message,
confirm, confirm,
cancel,
onClose, onClose,
onConfirm, onConfirm,
secondary, secondary,
@ -57,10 +59,12 @@ export const ConfirmationModal: React.FC<
<div className='safety-action-modal__bottom'> <div className='safety-action-modal__bottom'>
<div className='safety-action-modal__actions'> <div className='safety-action-modal__actions'>
<button onClick={handleCancel} className='link-button'> <button onClick={handleCancel} className='link-button'>
{cancel ?? (
<FormattedMessage <FormattedMessage
id='confirmation_modal.cancel' id='confirmation_modal.cancel'
defaultMessage='Cancel' defaultMessage='Cancel'
/> />
)}
</button> </button>
{secondary && ( {secondary && (

View File

@ -0,0 +1,104 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { replyCompose } from 'mastodon/actions/compose';
import { editStatus } from 'mastodon/actions/statuses';
import type { Status } from 'mastodon/models/status';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const editMessages = defineMessages({
title: {
id: 'confirmations.discard_draft.edit.title',
defaultMessage: 'Discard changes to your post?',
},
message: {
id: 'confirmations.discard_draft.edit.message',
defaultMessage:
'Continuing will discard any changes you have made to the post you are currently editing.',
},
cancel: {
id: 'confirmations.discard_draft.edit.cancel',
defaultMessage: 'Resume editing',
},
});
const postMessages = defineMessages({
title: {
id: 'confirmations.discard_draft.post.title',
defaultMessage: 'Discard your draft post?',
},
message: {
id: 'confirmations.discard_draft.post.message',
defaultMessage:
'Continuing will discard the post you are currently composing.',
},
cancel: {
id: 'confirmations.discard_draft.post.cancel',
defaultMessage: 'Resume draft',
},
});
const messages = defineMessages({
confirm: {
id: 'confirmations.discard_draft.confirm',
defaultMessage: 'Discard and continue',
},
});
const DiscardDraftConfirmationModal: React.FC<
{
onConfirm: () => void;
} & BaseConfirmationModalProps
> = ({ onConfirm, onClose }) => {
const intl = useIntl();
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
const contextualMessages = isEditing ? editMessages : postMessages;
return (
<ConfirmationModal
title={intl.formatMessage(contextualMessages.title)}
message={intl.formatMessage(contextualMessages.message)}
cancel={intl.formatMessage(contextualMessages.cancel)}
confirm={intl.formatMessage(messages.confirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};
export const ConfirmReplyModal: React.FC<
{
status: Status;
} & BaseConfirmationModalProps
> = ({ status, onClose }) => {
const dispatch = useAppDispatch();
const onConfirm = useCallback(() => {
dispatch(replyCompose(status));
}, [dispatch, status]);
return (
<DiscardDraftConfirmationModal onConfirm={onConfirm} onClose={onClose} />
);
};
export const ConfirmEditStatusModal: React.FC<
{
statusId: string;
} & BaseConfirmationModalProps
> = ({ statusId, onClose }) => {
const dispatch = useAppDispatch();
const onConfirm = useCallback(() => {
dispatch(editStatus(statusId));
}, [dispatch, statusId]);
return (
<DiscardDraftConfirmationModal onConfirm={onConfirm} onClose={onClose} />
);
};

View File

@ -1,45 +0,0 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { editStatus } from 'mastodon/actions/statuses';
import { useAppDispatch } from 'mastodon/store';
import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
editTitle: {
id: 'confirmations.edit.title',
defaultMessage: 'Overwrite post?',
},
editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
editMessage: {
id: 'confirmations.edit.message',
defaultMessage:
'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?',
},
});
export const ConfirmEditStatusModal: React.FC<
{
statusId: string;
} & BaseConfirmationModalProps
> = ({ statusId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const onConfirm = useCallback(() => {
dispatch(editStatus(statusId));
}, [dispatch, statusId]);
return (
<ConfirmationModal
title={intl.formatMessage(messages.editTitle)}
message={intl.formatMessage(messages.editMessage)}
confirm={intl.formatMessage(messages.editConfirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};

View File

@ -1,8 +1,10 @@
export { ConfirmationModal } from './confirmation_modal'; export { ConfirmationModal } from './confirmation_modal';
export { ConfirmDeleteStatusModal } from './delete_status'; export { ConfirmDeleteStatusModal } from './delete_status';
export { ConfirmDeleteListModal } from './delete_list'; export { ConfirmDeleteListModal } from './delete_list';
export { ConfirmReplyModal } from './reply'; export {
export { ConfirmEditStatusModal } from './edit_status'; ConfirmReplyModal,
ConfirmEditStatusModal,
} from './discard_draft_confirmation';
export { ConfirmUnfollowModal } from './unfollow'; export { ConfirmUnfollowModal } from './unfollow';
export { ConfirmClearNotificationsModal } from './clear_notifications'; export { ConfirmClearNotificationsModal } from './clear_notifications';
export { ConfirmLogOutModal } from './log_out'; export { ConfirmLogOutModal } from './log_out';

View File

@ -1,46 +0,0 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { replyCompose } from 'mastodon/actions/compose';
import type { Status } from 'mastodon/models/status';
import { useAppDispatch } from 'mastodon/store';
import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
replyTitle: {
id: 'confirmations.reply.title',
defaultMessage: 'Overwrite post?',
},
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: {
id: 'confirmations.reply.message',
defaultMessage:
'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?',
},
});
export const ConfirmReplyModal: React.FC<
{
status: Status;
} & BaseConfirmationModalProps
> = ({ status, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const onConfirm = useCallback(() => {
dispatch(replyCompose(status));
}, [dispatch, status]);
return (
<ConfirmationModal
title={intl.formatMessage(messages.replyTitle)}
message={intl.formatMessage(messages.replyMessage)}
confirm={intl.formatMessage(messages.replyConfirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};

View File

@ -20,7 +20,6 @@ import {
IgnoreNotificationsModal, IgnoreNotificationsModal,
AnnualReportModal, AnnualReportModal,
} from 'mastodon/features/ui/util/async-components'; } from 'mastodon/features/ui/util/async-components';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
@ -90,20 +89,6 @@ export default class ModalRoot extends PureComponent {
backgroundColor: null, backgroundColor: null,
}; };
getSnapshotBeforeUpdate () {
return { visible: !!this.props.type };
}
componentDidUpdate (prevProps, prevState, { visible }) {
if (visible) {
document.body.classList.add('with-modals--active');
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
} else {
document.body.classList.remove('with-modals--active');
document.documentElement.style.marginRight = '0';
}
}
setBackgroundColor = color => { setBackgroundColor = color => {
this.setState({ backgroundColor: color }); this.setState({ backgroundColor: color });
}; };

View File

@ -22,7 +22,7 @@ import { registrationsOpen, sso_redirect } from 'mastodon/initial_state';
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications'; import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({ export const messages = defineMessages({
home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
search: { id: 'tabs_bar.search', defaultMessage: 'Search' }, search: { id: 'tabs_bar.search', defaultMessage: 'Search' },
publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' }, publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },

View File

@ -142,13 +142,8 @@ class SwitchingColumnsArea extends PureComponent {
}; };
UNSAFE_componentWillMount () { UNSAFE_componentWillMount () {
if (this.props.singleColumn) { document.body.classList.toggle('layout-single-column', this.props.singleColumn);
document.body.classList.toggle('layout-single-column', true); document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn);
document.body.classList.toggle('layout-multiple-columns', false);
} else {
document.body.classList.toggle('layout-single-column', false);
document.body.classList.toggle('layout-multiple-columns', true);
}
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
@ -200,8 +195,8 @@ class SwitchingColumnsArea extends PureComponent {
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null} {singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null} {singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */} {/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
{!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null} {!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null}
{pathName === '/getting-started' ? <Redirect from='/getting-started' to={singleColumn ? '/home' : '/deck/getting-started'} exact /> : null}
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />

View File

@ -8,13 +8,14 @@ import { openURL } from 'mastodon/actions/search';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
const isMentionClick = (element: HTMLAnchorElement) => const isMentionClick = (element: HTMLAnchorElement) =>
element.classList.contains('mention'); element.classList.contains('mention') &&
!element.classList.contains('hashtag');
const isHashtagClick = (element: HTMLAnchorElement) => const isHashtagClick = (element: HTMLAnchorElement) =>
element.textContent?.[0] === '#' || element.textContent?.[0] === '#' ||
element.previousSibling?.textContent?.endsWith('#'); element.previousSibling?.textContent?.endsWith('#');
export const useLinks = () => { export const useLinks = (skipHashtags?: boolean) => {
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -61,12 +62,12 @@ export const useLinks = () => {
if (isMentionClick(target)) { if (isMentionClick(target)) {
e.preventDefault(); e.preventDefault();
void handleMentionClick(target); void handleMentionClick(target);
} else if (isHashtagClick(target)) { } else if (isHashtagClick(target) && !skipHashtags) {
e.preventDefault(); e.preventDefault();
handleHashtagClick(target); handleHashtagClick(target);
} }
}, },
[handleMentionClick, handleHashtagClick], [skipHashtags, handleMentionClick, handleHashtagClick],
); );
return handleClick; return handleClick;

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