Compare commits

...

138 Commits

Author SHA1 Message Date
william light
f3786e0816
hotkeys: only match just() when no modifiers are held (#35535)
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
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
2025-07-26 05:51:50 +00:00
Claire
e93efe0e13
Fix unnecessary duplication in vite code for finding entrypoints (#35515)
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-25 16:38:21 +00:00
Claire
5a88b7f683
Add experimental basic quote post authoring (#35355)
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
Crowdin / Upload translations / upload-translations (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
2025-07-25 12:35:24 +00:00
Roni Laukkarinen
81da377d8e
Fix Vite build failure on Node.js v20 due to undefined file.parentPath (#35509) 2025-07-25 07:59:49 +00:00
github-actions[bot]
d950298d29
New Crowdin Translations (automated) (#35514)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-25 07:29:48 +00:00
Matt Jankowski
2e35defeec
Update rubocop to version 1.79.0 (#35502) 2025-07-25 07:22:05 +00:00
Matt Jankowski
960f693219
Use field partial in admin account show view (#35503) 2025-07-25 07:21:08 +00:00
Matt Jankowski
e5e977c24f
Fix Style/GuardClause in worker rescues (#35508) 2025-07-25 07:18:46 +00:00
Claire
a863e68d17
Add restrictions on which quote posts can trend (#35507)
Some checks failed
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
Chromatic / Run Chromatic (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
2025-07-24 15:45:12 +00:00
renovate[bot]
847b37552a
chore(deps): update dependency httplog to v1.7.2 (#35506)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-24 15:08:50 +00:00
Echo
dfaca794bf
Force modern emoji experimental to be dev mode only (#35505) 2025-07-24 14:55:00 +00:00
diondiondion
6fc77a545b
fix: Fix TypeError on pages with empty feeds (#35504) 2025-07-24 14:30:56 +00:00
renovate[bot]
c871c7398e
fix(deps): update dependency @vitejs/plugin-react to v4.7.0 (#35421)
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
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
CSS Linting / lint (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-24 09:00:32 +00:00
Mayank
8baed8b90e
remove redundant title text from media modal images in web UI (#35468) 2025-07-24 08:58:22 +00:00
David Roetzel
8a1c43bf3b
Use default for preselected default privacy post setting (#35422) 2025-07-24 08:23:41 +00:00
Matt Jankowski
5c01ccc31f
Set flash options via redirect where possible (#35370) 2025-07-24 08:03:28 +00:00
renovate[bot]
67be8208db
chore(deps): update dependency haml_lint to v0.65.1 (#35497)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-24 07:52:34 +00:00
David Roetzel
7d136feccf
Bump version to v4.4.2 (#35498) 2025-07-24 07:51:56 +00:00
Matt Jankowski
e54e96d61f
Extract params hash for api/v1/push/subscriptions#create (#35475) 2025-07-24 07:49:20 +00:00
github-actions[bot]
469304359a
New Crowdin Translations (automated) (#35495)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-24 07:49:09 +00:00
Matt Jankowski
290e36d7e8
Finish migration of api/web/push_subscriptions controller->request spec (#35482) 2025-07-24 07:46:09 +00:00
Matt Jankowski
4241ce9888
Silence json key duplicate warning from api/web/push_subscriptions (#35481) 2025-07-24 07:33:53 +00:00
Echo
7f9ad7eabf
Enables cross-origin Web Workers (#35483) 2025-07-24 07:14:27 +00:00
Claire
a6794c066d
Fix “Expand this post” link including user @undefined (#35478)
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
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
2025-07-23 23:09:24 +00:00
Echo
7d3ef27a8d
Fix accidentally instantiating Web Worker (#35473)
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
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-23 14:01:45 +00:00
Eugen Rochko
14a781fa24
Add button to load new replies in web UI (#35210) 2025-07-23 13:42:07 +00:00
renovate[bot]
cec26d58c8
chore(deps): update dependency json-ld-preloaded to v3.3.2 (#35470)
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
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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 08:13:04 +00:00
renovate[bot]
593cdae404
fix(deps): update dependency axios to v1.11.0 (#35471)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 08:12:16 +00:00
Matt Jankowski
d2ef9ac04a
Use report_range method in admin/tags to generate reporting period (#35465) 2025-07-23 08:02:07 +00:00
Matt Jankowski
d065ec9298
Combine assignment params in admin/accounts#batch action (#35463) 2025-07-23 07:59:14 +00:00
Matt Jankowski
b19131202f
Extract constants for captcha directives/sources (#35439) 2025-07-23 07:55:54 +00:00
Matt Jankowski
70058ae49d
Add Form::BaseBatch class for "batch form update" objects (#35458) 2025-07-23 07:50:35 +00:00
renovate[bot]
62a23b1985
chore(deps): update dependency rspec-sidekiq to v5.2.0 (#35436)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 07:42:48 +00:00
renovate[bot]
6917cd2f40
chore(deps): update dependency propshaft to v1.2.1 (#35451)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 07:40:24 +00:00
diondiondion
d36236cbcd
fix: Fix glitchy status keyboard navigation (#35455) 2025-07-23 07:39:36 +00:00
Echo
760d00b7f7
Emoji Rendering (#35424)
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
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-22 14:43:15 +00:00
Echo
0af2c4829f
Creates Vite plugin to generate assets file (#35454) 2025-07-22 13:58:04 +00:00
github-actions[bot]
be3dc5b508
New Crowdin Translations (automated) (#35453)
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-22 10:10:37 +00:00
renovate[bot]
ae13063460
chore(deps): update dependency eslint-plugin-jsdoc to v51 (#35026)
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
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
Haml Linting / lint (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 16:31:10 +00:00
renovate[bot]
1ed58aaaf2
fix(deps): update dependency axios to v1.10.0 (#35050)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 16:28:49 +00:00
renovate[bot]
bf17895d19
chore(deps): update dependency chromatic to v13 (#35064)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 16:28:01 +00:00
Matt Jankowski
20b3c43dde
Update playwright-ruby-client to version 1.54.0 (#35448) 2025-07-21 16:24:55 +00:00
diondiondion
ee21f72211
fix: Don't submit post when pressing Enter in CW field (#35445) 2025-07-21 15:57:20 +00:00
diondiondion
4de5cbd6f5
refactor: Replace react-hotkeys with custom hook (#35425) 2025-07-21 14:43:38 +00:00
Matt Jankowski
fab95b8dae
Add coverage for api/v1/invites scenarios (#35389) 2025-07-21 14:17:53 +00:00
renovate[bot]
4d2655490c
chore(deps): update dependency nokogiri to v1.18.9 (#35433)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 12:25:28 +00:00
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
Bundler Audit / security (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
388 changed files with 8467 additions and 2437 deletions

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

@ -23,7 +23,6 @@
matchManagers: ['npm'], matchManagers: ['npm'],
matchPackageNames: [ matchPackageNames: [
'tesseract.js', // Requires code changes 'tesseract.js', // Requires code changes
'react-hotkeys', // Requires code changes
// react-router: Requires manual upgrade // react-router: Requires manual upgrade
'history', 'history',

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:

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.77.0. # using RuboCop version 1.79.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

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 = {
@ -26,6 +28,12 @@ const config: StorybookConfig = {
'oops.png', 'oops.png',
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })), ].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
], ],
viteFinal(config) {
// For an unknown reason, Storybook does not use the root
// from the Vite config so we need to set it manually.
config.root = resolve(__dirname, '../app/javascript');
return config;
},
}; };
export default config; export default config;

View File

@ -2,7 +2,37 @@
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.2] - 2025-07-23
### Security
- Update dependencies
### Fixed
- Fix menu not clickable in Firefox (#35390 and #35414 by @diondiondion)
- Add `lang` attribute to current composer language in alt text modal (#35412 by @diondiondion)
- Fix quote posts styling on notifications page (#35411 by @diondiondion)
- Improve a11y of custom select menus in notifications settings (#35403 by @diondiondion)
- Fix selected item in poll select menus is unreadable in Firefox (#35402 by @diondiondion)
- Update age limit wording (#35387 by @diondiondion)
- Fix support for quote verification in implicit status updates (#35384 by @ClearlyClaire)
- Improve `Dropdown` component accessibility (#35373 by @diondiondion)
- Fix processing some incoming quotes failing because of missing JSON-LD context (#35354 and #35380 by @ClearlyClaire)
- Make bio hashtags open the local page instead of the remote instance (#35349 by @ChaosExAnima)
- Fix styling of external log-in button (#35320 by @ClearlyClaire)
## [4.4.1] - 2025-07-09
### Fixed
- Fix nearly every sub-directory being crawled as part of Vite build (#35323 by @ClearlyClaire)
- Fix assets not building when Redis is unavailable (#35321 by @oneiros)
- Fix replying from media modal or pop-in-player tagging user `@undefined` (#35317 by @ClearlyClaire)
- Fix support for special characters in various environment variables (#35314 by @mjankowski and @ClearlyClaire)
- Fix some database migrations failing for indexes manually removed by admins (#35309 by @mjankowski)
## [4.4.0] - 2025-07-08
### Added ### Added
@ -38,7 +68,7 @@ All notable changes to this project will be documented in this file.
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path. Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron) - Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm) - Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126 and #35127 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\ - **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
Server administrators can now fill in Terms of Service and notify their users of upcoming changes. Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\ - Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\
This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\ This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\
@ -51,7 +81,7 @@ All notable changes to this project will be documented in this file.
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron) - Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron) - Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm) - Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033 and #35218 by @oneiros)\ - **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org). This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\ - Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users. This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
@ -64,7 +94,7 @@ All notable changes to this project will be documented in this file.
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk) - Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\ - Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action. Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033 and #35109 by @oneiros)\ - Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033, #35109 and #35278 by @oneiros)\
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests). For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
- Add experimental Async Refreshes API (#34918 by @oneiros) - Add experimental Async Refreshes API (#34918 by @oneiros)
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\ - Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
@ -218,6 +248,7 @@ All notable changes to this project will be documented in this file.
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire) - Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
- Fix OIDC account creation failing for long display names (#34639 by @defnull) - Fix OIDC account creation failing for long display names (#34639 by @defnull)
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap) - Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
- Fix inaccessible “Clear search” button (#35152 and #35281 by @diondiondion)
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire) - Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
- Fix directory scroll position reset (#34560 by @przucidlo) - Fix directory scroll position reset (#34560 by @przucidlo)
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent) - Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
@ -232,7 +263,7 @@ All notable changes to this project will be documented in this file.
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire) - Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion) - Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
- Fix zoomed images being blurry in Safari (#35052 by @diondiondion) - Fix zoomed images being blurry in Safari (#35052 by @diondiondion)
- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096 and #35150 by @diondiondion) - Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096, #35150 and #35251 by @diondiondion)
- Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion) - Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion)
- Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion) - Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion)
- Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion) - Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)

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'

View File

@ -90,13 +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.16.0) annotaterb (4.17.0)
activerecord (>= 6.0.0) activerecord (>= 6.0.0)
activesupport (>= 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)
@ -109,9 +109,9 @@ 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)
@ -224,16 +224,16 @@ GEM
mail (~> 2.7) mail (~> 2.7)
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erb (5.0.1) 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
@ -241,7 +241,7 @@ GEM
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-httpclient (2.0.2) 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)
@ -266,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)
@ -287,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.64.0) haml_lint (0.65.1)
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)
@ -315,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.2)
rack (>= 2.0) rack (>= 2.0)
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.14.7) i18n (1.14.7)
@ -335,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)
@ -345,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,14 +362,14 @@ GEM
rack (>= 2.2, < 4) rack (>= 2.2, < 4)
rdf (~> 3.3) rdf (~> 3.3)
rexml (~> 3.2) rexml (~> 3.2)
json-ld-preloaded (3.3.1) json-ld-preloaded (3.3.2)
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)
@ -403,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)
@ -433,21 +433,21 @@ 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
mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2025.0514) mime-types-data (3.2025.0715)
mini_mime (1.1.5) mini_mime (1.1.5)
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)
@ -458,7 +458,7 @@ GEM
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.4)
nokogiri (1.18.8) nokogiri (1.18.9)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.11) oj (3.16.11)
@ -515,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)
@ -553,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.25.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)
@ -597,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)
@ -610,7 +610,7 @@ GEM
pg (1.5.9) pg (1.5.9)
pghero (3.7.0) pghero (3.7.0)
activerecord (>= 7.1) activerecord (>= 7.1)
playwright-ruby-client (1.52.0) playwright-ruby-client (1.54.0)
concurrent-ruby (>= 1.1.6) concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0) mime-types (>= 3.0)
pp (0.6.2) pp (0.6.2)
@ -627,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.1)
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
@ -682,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)
@ -702,23 +701,28 @@ 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.14.1) rdoc (6.14.2)
erb 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)
regexp_parser (2.10.0) regexp_parser (2.10.0)
reline (0.6.1) reline (0.6.2)
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.7.0) request_store (1.7.0)
rack (>= 1.4) rack (>= 1.4)
@ -733,11 +737,11 @@ 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.4) rspec-core (3.13.5)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.5) rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
@ -755,13 +759,13 @@ GEM
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-sidekiq (5.1.0) rspec-sidekiq (5.2.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
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.4) rspec-support (3.13.4)
rubocop (1.77.0) rubocop (1.79.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)
@ -769,10 +773,11 @@ 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.1, < 2.0) rubocop-ast (>= 1.46.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
tsort (>= 0.2.0)
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)
@ -815,7 +820,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)
@ -846,7 +851,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)
@ -866,16 +871,17 @@ 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)
openssl (> 2.0) openssl (> 2.0)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
tsort (0.2.0)
tty-color (0.6.0) tty-color (0.6.0)
tty-cursor (0.7.1) tty-cursor (0.7.1)
tty-prompt (0.23.1) tty-prompt (0.23.1)
@ -932,7 +938,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)
@ -1008,7 +1014,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)
@ -1102,4 +1108,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

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

View File

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

View File

@ -16,11 +16,14 @@ module Admin
def batch def batch
authorize :account, :index? authorize :account, :index?
@form = Form::AccountBatch.new(form_account_batch_params) @form = Form::AccountBatch.new(
@form.current_account = current_account form_account_batch_params.merge(
@form.action = action_from_button action: action_from_button,
@form.select_all_matching = params[:select_all_matching] current_account:,
@form.query = filtered_accounts query: filtered_accounts,
select_all_matching: params[:select_all_matching]
)
)
@form.save @form.save
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected') flash[:alert] = I18n.t('admin.accounts.no_account_selected')

View File

@ -19,15 +19,13 @@ module Admin
log_action :resend, @user log_action :resend, @user
flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success') redirect_to admin_accounts_path, notice: t('admin.accounts.resend_confirmation.success')
redirect_to admin_accounts_path
end end
private private
def redirect_confirmed_user def redirect_confirmed_user
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') redirect_to admin_accounts_path, flash: { error: t('admin.accounts.resend_confirmation.already_confirmed') }
redirect_to admin_accounts_path
end end
def user_confirmed? def user_confirmed?

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

@ -14,8 +14,7 @@ module Admin
@admin_settings = Form::AdminSettings.new(settings_params) @admin_settings = Form::AdminSettings.new(settings_params)
if @admin_settings.save if @admin_settings.save
flash[:notice] = I18n.t('generic.changes_saved_msg') redirect_to after_update_redirect_path, notice: t('generic.changes_saved_msg')
redirect_to after_update_redirect_path
else else
render :show render :show
end end

View File

@ -5,6 +5,7 @@ module Admin
before_action :set_tag, except: [:index] before_action :set_tag, except: [:index]
PER_PAGE = 20 PER_PAGE = 20
PERIOD_DAYS = 6.days
def index def index
authorize :tag, :index? authorize :tag, :index?
@ -15,7 +16,7 @@ module Admin
def show def show
authorize @tag, :show? authorize @tag, :show?
@time_period = (6.days.ago.to_date...Time.now.utc.to_date) @time_period = report_range
end end
def update def update
@ -24,7 +25,7 @@ module Admin
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc)) if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg') redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
else else
@time_period = (6.days.ago.to_date...Time.now.utc.to_date) @time_period = report_range
render :show render :show
end end
@ -36,6 +37,10 @@ module Admin
@tag = Tag.find(params[:id]) @tag = Tag.find(params[:id])
end end
def report_range
(PERIOD_DAYS.ago.to_date...Time.now.utc.to_date)
end
def tag_params def tag_params
params params
.expect(tag: [:name, :display_name, :trendable, :usable, :listable]) .expect(tag: [:name, :display_name, :trendable, :usable, :listable])

View File

@ -2,6 +2,7 @@
class Api::V1::Admin::TagsController < Api::BaseController class Api::V1::Admin::TagsController < Api::BaseController
include Authorization include Authorization
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update before_action -> { authorize_if_got_token! :'admin:write' }, only: :update

View File

@ -7,6 +7,7 @@ class Api::V1::InvitesController < Api::BaseController
skip_around_action :set_locale skip_around_action :set_locale
before_action :set_invite before_action :set_invite
before_action :check_valid_usage!
before_action :check_enabled_registrations! before_action :check_enabled_registrations!
# Override `current_user` to avoid reading session cookies # Override `current_user` to avoid reading session cookies
@ -22,9 +23,11 @@ class Api::V1::InvitesController < Api::BaseController
@invite = Invite.find_by!(code: params[:invite_code]) @invite = Invite.find_by!(code: params[:invite_code])
end end
def check_enabled_registrations! def check_valid_usage!
return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use? render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
end
def check_enabled_registrations!
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite) raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
end end
end end

View File

@ -16,16 +16,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def create def create
with_redis_lock("push_subscription:#{current_user.id}") do with_redis_lock("push_subscription:#{current_user.id}") do
destroy_web_push_subscriptions! destroy_web_push_subscriptions!
@push_subscription = Web::PushSubscription.create!(web_push_subscription_params)
@push_subscription = Web::PushSubscription.create!(
endpoint: subscription_params[:endpoint],
key_p256dh: subscription_params[:keys][:p256dh],
key_auth: subscription_params[:keys][:auth],
standard: subscription_params[:standard] || false,
data: data_params,
user_id: current_user.id,
access_token_id: doorkeeper_token.id
)
end end
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
@ -55,6 +46,18 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
not_found if @push_subscription.nil? not_found if @push_subscription.nil?
end end
def web_push_subscription_params
{
access_token_id: doorkeeper_token.id,
data: data_params,
endpoint: subscription_params[:endpoint],
key_auth: subscription_params[:keys][:auth],
key_p256dh: subscription_params[:keys][:p256dh],
standard: subscription_params[:standard] || false,
user_id: current_user.id,
}
end
def subscription_params def subscription_params
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]]) params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
end end

View File

@ -2,6 +2,7 @@
class Api::V1::StatusesController < Api::BaseController class Api::V1::StatusesController < Api::BaseController
include Authorization include Authorization
include AsyncRefreshesConcern
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
@ -9,6 +10,7 @@ class Api::V1::StatusesController < Api::BaseController
before_action :set_statuses, only: [:index] before_action :set_statuses, only: [:index]
before_action :set_status, only: [:show, :context] before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create] before_action :set_thread, only: [:create]
before_action :set_quoted_status, only: [:create]
before_action :check_statuses_limit, only: [:index] before_action :check_statuses_limit, only: [:index]
override_rate_limit_headers :create, family: :statuses override_rate_limit_headers :create, family: :statuses
@ -57,9 +59,17 @@ class Api::V1::StatusesController < Api::BaseController
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants statuses = [@status] + @context.ancestors + @context.descendants
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) refresh_key = "context:#{@status.id}:refresh"
async_refresh = AsyncRefresh.new(refresh_key)
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies? if async_refresh.running?
add_async_refresh_header(async_refresh)
elsif !current_account.nil? && @status.should_fetch_replies?
add_async_refresh_header(AsyncRefresh.create(refresh_key))
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id)
end
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
end end
def create def create
@ -67,6 +77,7 @@ class Api::V1::StatusesController < Api::BaseController
current_user.account, current_user.account,
text: status_params[:status], text: status_params[:status],
thread: @thread, thread: @thread,
quoted_status: @quoted_status,
media_ids: status_params[:media_ids], media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
@ -138,6 +149,16 @@ class Api::V1::StatusesController < Api::BaseController
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
end end
def set_quoted_status
return unless Mastodon::Feature.outgoing_quotes_enabled?
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
authorize(@quoted_status, :quote?) if @quoted_status.present?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
# TODO: distinguish between non-existing and non-quotable posts
render json: { error: I18n.t('statuses.errors.quoted_status_not_found') }, status: 404
end
def check_statuses_limit def check_statuses_limit
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
end end
@ -154,6 +175,7 @@ class Api::V1::StatusesController < Api::BaseController
params.permit( params.permit(
:status, :status,
:in_reply_to_id, :in_reply_to_id,
:quoted_status_id,
:sensitive, :sensitive,
:spoiler_text, :spoiler_text,
:visibility, :visibility,

View File

@ -49,7 +49,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
{ {
policy: 'all', policy: 'all',
alerts: Notification::TYPES.index_with { alerts_enabled }, alerts: Notification::TYPES.index_with { alerts_enabled },
} }.deep_stringify_keys
end end
def alerts_enabled def alerts_enabled

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

@ -19,8 +19,7 @@ class Auth::PasswordsController < Devise::PasswordsController
private private
def redirect_invalid_reset_token def redirect_invalid_reset_token
flash[:error] = I18n.t('auth.invalid_reset_password_token') redirect_to new_password_path(resource_name), flash: { error: t('auth.invalid_reset_password_token') }
redirect_to new_password_path(resource_name)
end end
def reset_password_token_is_valid? def reset_password_token_is_valid?

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, success: true
ip: request.remote_ip, )
user_agent: request.user_agent
) )
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
@ -167,13 +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, success: false
ip: request.remote_ip, )
user_agent: request.user_agent
) )
# Only send a notification email every hour at most # Only send a notification email every hour at most
@ -182,6 +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

@ -5,6 +5,18 @@ module Auth::CaptchaConcern
include Hcaptcha::Adapters::ViewMethods include Hcaptcha::Adapters::ViewMethods
CAPTCHA_DIRECTIVES = %w(
connect_src
frame_src
script_src
style_src
).freeze
CAPTCHA_SOURCES = %w(
https://*.hcaptcha.com
https://hcaptcha.com
).freeze
included do included do
helper_method :render_captcha helper_method :render_captcha
end end
@ -42,20 +54,9 @@ module Auth::CaptchaConcern
end end
def extend_csp_for_captcha! def extend_csp_for_captcha!
policy = request.content_security_policy&.clone return unless captcha_required? && request.content_security_policy.present?
return unless captcha_required? && policy.present? request.content_security_policy = captcha_adjusted_policy
%w(script_src frame_src style_src connect_src).each do |directive|
values = policy.send(directive)
values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
policy.send(directive, *values)
end
request.content_security_policy = policy
end end
def render_captcha def render_captcha
@ -63,4 +64,24 @@ module Auth::CaptchaConcern
hcaptcha_tags hcaptcha_tags
end end
private
def captcha_adjusted_policy
request.content_security_policy.clone.tap do |policy|
populate_captcha_policy(policy)
end
end
def populate_captcha_policy(policy)
CAPTCHA_DIRECTIVES.each do |directive|
values = policy.send(directive)
CAPTCHA_SOURCES.each do |source|
values << source unless values.include?(source) || values.include?('https:')
end
policy.send(directive, *values)
end
end
end end

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

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

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

@ -8,8 +8,7 @@ class Settings::SessionsController < Settings::BaseController
def destroy def destroy
@session.destroy! @session.destroy!
flash[:notice] = I18n.t('sessions.revoke_success') redirect_to edit_user_registration_path, notice: t('sessions.revoke_success')
redirect_to edit_user_registration_path
end end
private private

View File

@ -86,13 +86,11 @@ module Settings
private private
def redirect_invalid_otp def redirect_invalid_otp
flash[:error] = t('webauthn_credentials.otp_required') redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.otp_required') }
redirect_to settings_two_factor_authentication_methods_path
end end
def redirect_invalid_webauthn def redirect_invalid_webauthn
flash[:error] = t('webauthn_credentials.not_enabled') redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.not_enabled') }
redirect_to settings_two_factor_authentication_methods_path
end end
end end
end end

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

@ -49,8 +49,8 @@ module HomeHelper
end end
end end
def custom_field_classes(field) def field_verified_class(verified)
if field.verified? if verified
'verified' 'verified'
else else
'emojify' 'emojify'

View File

@ -1,3 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
import { apiGetContext } from 'mastodon/api/statuses'; import { apiGetContext } from 'mastodon/api/statuses';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
@ -6,13 +8,18 @@ import { importFetchedStatuses } from './importer';
export const fetchContext = createDataLoadingThunk( export const fetchContext = createDataLoadingThunk(
'status/context', 'status/context',
({ statusId }: { statusId: string }) => apiGetContext(statusId), ({ statusId }: { statusId: string }) => apiGetContext(statusId),
(context, { dispatch }) => { ({ context, refresh }, { dispatch }) => {
const statuses = context.ancestors.concat(context.descendants); const statuses = context.ancestors.concat(context.descendants);
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
return { return {
context, context,
refresh,
}; };
}, },
); );
export const completeContextRefresh = createAction<{ statusId: string }>(
'status/context/complete',
);

View File

@ -20,6 +20,50 @@ export const getLinks = (response: AxiosResponse) => {
return LinkHeader.parse(value); return LinkHeader.parse(value);
}; };
export interface AsyncRefreshHeader {
id: string;
retry: number;
}
const isAsyncRefreshHeader = (obj: object): obj is AsyncRefreshHeader =>
'id' in obj && 'retry' in obj;
export const getAsyncRefreshHeader = (
response: AxiosResponse,
): AsyncRefreshHeader | null => {
const value = response.headers['mastodon-async-refresh'] as
| string
| undefined;
if (!value) {
return null;
}
const asyncRefreshHeader: Record<string, unknown> = {};
value.split(/,\s*/).forEach((pair) => {
const [key, val] = pair.split('=', 2);
let typedValue: string | number;
if (key && ['id', 'retry'].includes(key) && val) {
if (val.startsWith('"')) {
typedValue = val.slice(1, -1);
} else {
typedValue = parseInt(val);
}
asyncRefreshHeader[key] = typedValue;
}
});
if (isAsyncRefreshHeader(asyncRefreshHeader)) {
return asyncRefreshHeader;
}
return null;
};
const csrfHeader: RawAxiosRequestHeaders = {}; const csrfHeader: RawAxiosRequestHeaders = {};
const setCSRFHeader = () => { const setCSRFHeader = () => {
@ -83,7 +127,7 @@ export default function api(withAuthorization = true) {
return instance; return instance;
} }
type ApiUrl = `v${1 | 2}/${string}`; type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
type RequestParamsOrData = Record<string, unknown>; type RequestParamsOrData = Record<string, unknown>;
export async function apiRequest<ApiResponse = unknown>( export async function apiRequest<ApiResponse = unknown>(

View File

@ -0,0 +1,5 @@
import { apiRequestGet } from 'mastodon/api';
import type { ApiAsyncRefreshJSON } from 'mastodon/api_types/async_refreshes';
export const apiGetAsyncRefresh = (id: string) =>
apiRequestGet<ApiAsyncRefreshJSON>(`v1_alpha/async_refreshes/${id}`);

View File

@ -1,5 +1,14 @@
import { apiRequestGet } from 'mastodon/api'; import api, { getAsyncRefreshHeader } from 'mastodon/api';
import type { ApiContextJSON } from 'mastodon/api_types/statuses'; import type { ApiContextJSON } from 'mastodon/api_types/statuses';
export const apiGetContext = (statusId: string) => export const apiGetContext = async (statusId: string) => {
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`); const response = await api().request<ApiContextJSON>({
method: 'GET',
url: `/api/v1/statuses/${statusId}/context`,
});
return {
context: response.data,
refresh: getAsyncRefreshHeader(response),
};
};

View File

@ -0,0 +1,7 @@
export interface ApiAsyncRefreshJSON {
async_refresh: {
id: string;
status: 'running' | 'finished';
result_count: number;
};
}

View File

@ -1,20 +1,76 @@
import { useCallback } from 'react';
import { useLinks } from 'mastodon/hooks/useLinks'; import { useLinks } from 'mastodon/hooks/useLinks';
export const AccountBio: React.FC<{ import { EmojiHTML } from '../features/emoji/emoji_html';
note: string; import { useAppSelector } from '../store';
className: string; import { isModernEmojiEnabled } from '../utils/environment';
}> = ({ note, className }) => {
const handleClick = useLinks();
if (note.length === 0 || note === '<p></p>') { interface AccountBioProps {
className: string;
accountId: string;
showDropdown?: boolean;
}
export const AccountBio: React.FC<AccountBioProps> = ({
className,
accountId,
showDropdown = false,
}) => {
const handleClick = useLinks(showDropdown);
const handleNodeChange = useCallback(
(node: HTMLDivElement | null) => {
if (!showDropdown || !node || node.childNodes.length === 0) {
return;
}
addDropdownToHashtags(node, accountId);
},
[showDropdown, accountId],
);
const note = useAppSelector((state) => {
const account = state.accounts.get(accountId);
if (!account) {
return '';
}
return isModernEmojiEnabled() ? account.note : account.note_emojified;
});
const extraEmojis = useAppSelector((state) => {
const account = state.accounts.get(accountId);
return account?.emojis;
});
if (note.length === 0) {
return null; return null;
} }
return ( return (
<div <div
className={`${className} translate`} className={`${className} translate`}
dangerouslySetInnerHTML={{ __html: note }}
onClickCapture={handleClick} onClickCapture={handleClick}
/> ref={handleNodeChange}
>
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
</div>
); );
}; };
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

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

@ -37,7 +37,6 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
role='button' role='button'
tabIndex={0} tabIndex={0}
aria-label={alt} aria-label={alt}
title={alt}
lang={lang} lang={lang}
onClick={handleClick} onClick={handleClick}
/> />
@ -49,7 +48,6 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
role='button' role='button'
tabIndex={0} tabIndex={0}
aria-label={alt} aria-label={alt}
title={alt}
lang={lang} lang={lang}
width={width} width={width}
height={height} height={height}

View File

@ -0,0 +1,171 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect } from 'storybook/test';
import type { HandlerMap } from '.';
import { Hotkeys } from '.';
const meta = {
title: 'Components/Hotkeys',
component: Hotkeys,
args: {
global: undefined,
focusable: undefined,
handlers: {},
},
tags: ['test'],
} satisfies Meta<typeof Hotkeys>;
export default meta;
type Story = StoryObj<typeof meta>;
const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => {
async function confirmHotkey(name: string, shouldFind = true) {
// 'status' is the role of the 'output' element
const output = await canvas.findByRole('status');
if (shouldFind) {
await expect(output).toHaveTextContent(name);
} else {
await expect(output).not.toHaveTextContent(name);
}
}
const button = await canvas.findByRole('button');
await userEvent.click(button);
await userEvent.keyboard('n');
await confirmHotkey('new');
await userEvent.keyboard('/');
await confirmHotkey('search');
await userEvent.keyboard('o');
await confirmHotkey('open');
await userEvent.keyboard('{Alt>}N{/Alt}');
await confirmHotkey('forceNew');
await userEvent.keyboard('gh');
await confirmHotkey('goToHome');
await userEvent.keyboard('gn');
await confirmHotkey('goToNotifications');
await userEvent.keyboard('gf');
await confirmHotkey('goToFavourites');
/**
* Ensure that hotkeys are not triggered when certain
* interactive elements are focused:
*/
await userEvent.keyboard('{enter}');
await confirmHotkey('open', false);
const input = await canvas.findByRole('textbox');
await userEvent.click(input);
await userEvent.keyboard('n');
await confirmHotkey('new', false);
await userEvent.keyboard('{backspace}');
await confirmHotkey('None', false);
/**
* Reset playground:
*/
await userEvent.click(button);
await userEvent.keyboard('{backspace}');
};
export const Default = {
render: function Render() {
const [matchedHotkey, setMatchedHotkey] = useState<keyof HandlerMap | null>(
null,
);
const handlers = {
back: () => {
setMatchedHotkey(null);
},
new: () => {
setMatchedHotkey('new');
},
forceNew: () => {
setMatchedHotkey('forceNew');
},
search: () => {
setMatchedHotkey('search');
},
open: () => {
setMatchedHotkey('open');
},
goToHome: () => {
setMatchedHotkey('goToHome');
},
goToNotifications: () => {
setMatchedHotkey('goToNotifications');
},
goToFavourites: () => {
setMatchedHotkey('goToFavourites');
},
};
return (
<Hotkeys handlers={handlers}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: '1em',
border: '1px dashed #ccc',
fontSize: 14,
color: '#222',
}}
>
<h1
style={{
fontSize: 22,
marginBottom: '0.3em',
}}
>
Hotkey playground
</h1>
<p>
Last pressed hotkey: <output>{matchedHotkey ?? 'None'}</output>
</p>
<p>
Click within the dashed border and press the &quot;<kbd>n</kbd>
&quot; or &quot;<kbd>/</kbd>&quot; key. Press &quot;
<kbd>Backspace</kbd>&quot; to clear the displayed hotkey.
</p>
<p>
Try typing a sequence, like &quot;<kbd>g</kbd>&quot; shortly
followed by &quot;<kbd>h</kbd>&quot;, &quot;<kbd>n</kbd>&quot;, or
&quot;<kbd>f</kbd>&quot;
</p>
<p>
Note that this playground doesn&apos;t support all hotkeys we use in
the app.
</p>
<p>
When a <button>Button</button> is focused, &quot;
<kbd>Enter</kbd>
&quot; should not trigger &quot;open&quot;, but &quot;<kbd>o</kbd>
&quot; should.
</p>
<p>
When an input element is focused, hotkeys should not interfere with
regular typing:
</p>
<input type='text' />
</div>
</Hotkeys>
);
},
play: hotkeyTest,
};

View File

@ -0,0 +1,286 @@
import { useEffect, useRef } from 'react';
import { normalizeKey, isKeyboardEvent } from './utils';
/**
* In case of multiple hotkeys matching the pressed key(s),
* the hotkey with a higher priority is selected. All others
* are ignored.
*/
const hotkeyPriority = {
singleKey: 0,
combo: 1,
sequence: 2,
} as const;
/**
* This type of function receives a keyboard event and an array of
* previously pressed keys (within the last second), and returns
* `isMatch` (whether the pressed keys match a hotkey) and `priority`
* (a weighting used to resolve conflicts when two hotkeys match the
* pressed keys)
*/
type KeyMatcher = (
event: KeyboardEvent,
bufferedKeys?: string[],
) => {
/**
* Whether the event.key matches the hotkey
*/
isMatch: boolean;
/**
* If there are multiple matching hotkeys, the
* first one with the highest priority will be handled
*/
priority: (typeof hotkeyPriority)[keyof typeof hotkeyPriority];
};
/**
* Matches a single key
*/
function just(keyName: string): KeyMatcher {
return (event) => ({
isMatch:
normalizeKey(event.key) === keyName &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey,
priority: hotkeyPriority.singleKey,
});
}
/**
* Matches any single key out of those provided
*/
function any(...keys: string[]): KeyMatcher {
return (event) => ({
isMatch: keys.some((keyName) => just(keyName)(event).isMatch),
priority: hotkeyPriority.singleKey,
});
}
/**
* Matches a single key combined with the option/alt modifier
*/
function optionPlus(key: string): KeyMatcher {
return (event) => ({
// Matching against event.code here as alt combos are often
// mapped to other characters
isMatch: event.altKey && event.code === `Key${key.toUpperCase()}`,
priority: hotkeyPriority.combo,
});
}
/**
* Matches when all provided keys are pressed in sequence.
*/
function sequence(...sequence: string[]): KeyMatcher {
return (event, bufferedKeys) => {
const lastKeyInSequence = sequence.at(-1);
const startOfSequence = sequence.slice(0, -1);
const relevantBufferedKeys = bufferedKeys?.slice(-startOfSequence.length);
const bufferMatchesStartOfSequence =
!!relevantBufferedKeys &&
startOfSequence.join('') === relevantBufferedKeys.join('');
return {
isMatch:
bufferMatchesStartOfSequence &&
normalizeKey(event.key) === lastKeyInSequence,
priority: hotkeyPriority.sequence,
};
};
}
/**
* This is a map of all global hotkeys we support.
* To trigger a hotkey, a handler with a matching name must be
* provided to the `useHotkeys` hook or `Hotkeys` component.
*/
const hotkeyMatcherMap = {
help: just('?'),
search: any('s', '/'),
back: just('backspace'),
new: just('n'),
forceNew: optionPlus('n'),
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
reply: just('r'),
favourite: just('f'),
boost: just('b'),
mention: just('m'),
open: any('enter', 'o'),
openProfile: just('p'),
moveDown: any('down', 'j'),
moveUp: any('up', 'k'),
toggleHidden: just('x'),
toggleSensitive: just('h'),
toggleComposeSpoilers: optionPlus('x'),
openMedia: just('e'),
onTranslate: just('t'),
goToHome: sequence('g', 'h'),
goToNotifications: sequence('g', 'n'),
goToLocal: sequence('g', 'l'),
goToFederated: sequence('g', 't'),
goToDirect: sequence('g', 'd'),
goToStart: sequence('g', 's'),
goToFavourites: sequence('g', 'f'),
goToPinned: sequence('g', 'p'),
goToProfile: sequence('g', 'u'),
goToBlocked: sequence('g', 'b'),
goToMuted: sequence('g', 'm'),
goToRequests: sequence('g', 'r'),
cheat: sequence(
'up',
'up',
'down',
'down',
'left',
'right',
'left',
'right',
'b',
'a',
'enter',
),
} as const;
type HotkeyName = keyof typeof hotkeyMatcherMap;
export type HandlerMap = Partial<
Record<HotkeyName, (event: KeyboardEvent) => void>
>;
export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const ref = useRef<T>(null);
const bufferedKeys = useRef<string[]>([]);
const sequenceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
/**
* Store the latest handlers object in a ref so we don't need to
* add it as a dependency to the main event listener effect
*/
const handlersRef = useRef(handlers);
useEffect(() => {
handlersRef.current = handlers;
}, [handlers]);
useEffect(() => {
const element = ref.current ?? document;
function listener(event: Event) {
// Ignore key presses from input, textarea, or select elements
const tagName = (event.target as HTMLElement).tagName.toLowerCase();
const shouldHandleEvent =
isKeyboardEvent(event) &&
!event.defaultPrevented &&
!['input', 'textarea', 'select'].includes(tagName) &&
!(
['a', 'button'].includes(tagName) &&
normalizeKey(event.key) === 'enter'
);
if (shouldHandleEvent) {
const matchCandidates: {
handler: (event: KeyboardEvent) => void;
priority: number;
}[] = [];
(Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach(
(handlerName) => {
const handler = handlersRef.current[handlerName];
if (handler) {
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
const { isMatch, priority } = hotkeyMatcher(
event,
bufferedKeys.current,
);
if (isMatch) {
matchCandidates.push({ handler, priority });
}
}
},
);
// Sort all matches by priority
matchCandidates.sort((a, b) => b.priority - a.priority);
const bestMatchingHandler = matchCandidates.at(0)?.handler;
if (bestMatchingHandler) {
bestMatchingHandler(event);
event.stopPropagation();
event.preventDefault();
}
// Add last keypress to buffer
bufferedKeys.current.push(normalizeKey(event.key));
// Reset the timeout
if (sequenceTimer.current) {
clearTimeout(sequenceTimer.current);
}
sequenceTimer.current = setTimeout(() => {
bufferedKeys.current = [];
}, 1000);
}
}
element.addEventListener('keydown', listener);
return () => {
element.removeEventListener('keydown', listener);
if (sequenceTimer.current) {
clearTimeout(sequenceTimer.current);
}
};
}, []);
return ref;
}
/**
* The Hotkeys component allows us to globally register keyboard combinations
* under a name and assign actions to them, either globally or scoped to a portion
* of the app.
*
* ### How to use
*
* To add a new hotkey, add its key combination to the `hotkeyMatcherMap` object
* and give it a name.
*
* Use the `<Hotkeys>` component or the `useHotkeys` hook in the part of of the app
* where you want to handle the action, and pass in a handlers object.
*
* ```tsx
* <Hotkeys handlers={{open: openStatus}} />
* ```
*
* Now this function will be called when the 'open' hotkey is pressed by the user.
*/
export const Hotkeys: React.FC<{
/**
* An object containing functions to be run when a hotkey is pressed.
* The key must be the name of a registered hotkey, e.g. "help" or "search"
*/
handlers: HandlerMap;
/**
* When enabled, hotkeys will be matched against the document root
* rather than only inside of this component's DOM node.
*/
global?: boolean;
/**
* Allow the rendered `div` to be focused
*/
focusable?: boolean;
children: React.ReactNode;
}> = ({ handlers, global, focusable = true, children }) => {
const ref = useHotkeys<HTMLDivElement>(handlers);
return (
<div ref={global ? undefined : ref} tabIndex={focusable ? -1 : undefined}>
{children}
</div>
);
};

View File

@ -0,0 +1,29 @@
export function isKeyboardEvent(event: Event): event is KeyboardEvent {
return 'key' in event;
}
export function normalizeKey(key: string): string {
const lowerKey = key.toLowerCase();
switch (lowerKey) {
case ' ':
case 'spacebar': // for older browsers
return 'space';
case 'arrowup':
return 'up';
case 'arrowdown':
return 'down';
case 'arrowleft':
return 'left';
case 'arrowright':
return 'right';
case 'esc':
case 'escape':
return 'escape';
default:
return lowerKey;
}
}

View File

@ -102,7 +102,7 @@ export const HoverCardAccount = forwardRef<
<> <>
<div className='hover-card__text-row'> <div className='hover-card__text-row'>
<AccountBio <AccountBio
note={account.note_emojified} accountId={account.id}
className='hover-card__bio' className='hover-card__bio'
/> />
<AccountFields fields={account.fields} limit={2} /> <AccountFields fields={account.fields} limit={2} />

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

@ -8,10 +8,9 @@ import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import { Hotkeys } from 'mastodon/components/hotkeys';
import { ContentWarning } from 'mastodon/components/content_warning'; import { ContentWarning } from 'mastodon/components/content_warning';
import { FilterWarning } from 'mastodon/components/filter_warning'; import { FilterWarning } from 'mastodon/components/filter_warning';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
@ -35,7 +34,6 @@ import StatusActionBar from './status_action_bar';
import StatusContent from './status_content'; import StatusContent from './status_content';
import { StatusThreadLabel } from './status_thread_label'; import { StatusThreadLabel } from './status_thread_label';
import { VisibilityIcon } from './visibility_icon'; import { VisibilityIcon } from './visibility_icon';
const domParser = new DOMParser(); const domParser = new DOMParser();
export const textForScreenReader = (intl, status, rebloggedByText = false) => { export const textForScreenReader = (intl, status, rebloggedByText = false) => {
@ -325,11 +323,11 @@ class Status extends ImmutablePureComponent {
}; };
handleHotkeyMoveUp = e => { handleHotkeyMoveUp = e => {
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured')); this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
}; };
handleHotkeyMoveDown = e => { handleHotkeyMoveDown = e => {
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured')); this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
}; };
handleHotkeyToggleHidden = () => { handleHotkeyToggleHidden = () => {
@ -437,13 +435,13 @@ class Status extends ImmutablePureComponent {
if (hidden) { if (hidden) {
return ( return (
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}> <Hotkeys handlers={handlers} focusable={!unfocusable}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}> <div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span> <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)} {status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
{expanded && <span>{status.get('content')}</span>} {expanded && <span>{status.get('content')}</span>}
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -543,7 +541,7 @@ class Status extends ImmutablePureComponent {
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
return ( return (
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}> <Hotkeys handlers={handlers} focusable={!unfocusable}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}> <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{!skipPrepend && prepend} {!skipPrepend && prepend}
@ -604,7 +602,7 @@ class Status extends ImmutablePureComponent {
} }
</div> </div>
</div> </div>
</HotKeys> </Hotkeys>
); );
} }

View File

@ -14,6 +14,8 @@ import { Icon } from 'mastodon/components/icon';
import { Poll } from 'mastodon/components/poll'; import { Poll } from 'mastodon/components/poll';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { isModernEmojiEnabled } from '../utils/environment';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
@ -23,6 +25,9 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
* @returns {string} * @returns {string}
*/ */
export function getStatusContent(status) { export function getStatusContent(status) {
if (isModernEmojiEnabled()) {
return status.getIn(['translation', 'content']) || status.get('content');
}
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
} }
@ -228,7 +233,7 @@ class StatusContent extends PureComponent {
const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
const content = { __html: statusContent ?? getStatusContent(status) }; const content = statusContent ?? getStatusContent(status);
const language = status.getIn(['translation', 'language']) || status.get('language'); const language = status.getIn(['translation', 'language']) || status.get('language');
const classNames = classnames('status__content', { const classNames = classnames('status__content', {
'status__content--with-action': this.props.onClick && this.props.history, 'status__content--with-action': this.props.onClick && this.props.history,
@ -253,7 +258,12 @@ class StatusContent extends PureComponent {
return ( return (
<> <>
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} /> <EmojiHTML
className='status__content__text status__content__text--visible translate'
lang={language}
htmlString={content}
extraEmojis={status.get('emojis')}
/>
{poll} {poll}
{translateButton} {translateButton}
@ -265,7 +275,12 @@ class StatusContent extends PureComponent {
} else { } else {
return ( return (
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} /> <EmojiHTML
className='status__content__text status__content__text--visible translate'
lang={language}
htmlString={content}
extraEmojis={status.get('emojis')}
/>
{poll} {poll}
{translateButton} {translateButton}

View File

@ -40,6 +40,14 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: true, trackScroll: true,
}; };
componentDidMount() {
this.columnHeaderHeight = this.node?.node
? parseFloat(
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
) || 0
: 0;
}
getFeaturedStatusCount = () => { getFeaturedStatusCount = () => {
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0; return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
}; };
@ -53,34 +61,68 @@ export default class StatusList extends ImmutablePureComponent {
}; };
handleMoveUp = (id, featured) => { handleMoveUp = (id, featured) => {
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1; const index = this.getCurrentStatusIndex(id, featured);
this._selectChild(elementIndex, true); this._selectChild(id, index, -1);
}; };
handleMoveDown = (id, featured) => { handleMoveDown = (id, featured) => {
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1; const index = this.getCurrentStatusIndex(id, featured);
this._selectChild(elementIndex, false); this._selectChild(id, index, 1);
}; };
_selectChild = (id, index, direction) => {
const listContainer = this.node?.node;
let listItem = listContainer?.querySelector(
// :nth-child uses 1-based indexing
`.item-list > :nth-child(${index + 1 + direction})`
);
if (!listItem) {
return;
}
// If selected container element is empty, we skip it
if (listItem.matches(':empty')) {
this._selectChild(id, index + direction, direction);
return;
}
// Check if the list item is a post
let targetElement = listItem.querySelector('.focusable');
// Otherwise, check if the item contains follow suggestions or
// is a 'load more' button.
if (
!targetElement && (
listItem.querySelector('.inline-follow-suggestions') ||
listItem.matches('.load-more')
)
) {
targetElement = listItem;
}
if (targetElement) {
const elementRect = targetElement.getBoundingClientRect();
const isFullyVisible =
elementRect.top >= this.columnHeaderHeight &&
elementRect.bottom <= window.innerHeight;
if (!isFullyVisible) {
targetElement.scrollIntoView({
block: direction === 1 ? 'start' : 'center',
});
}
targetElement.focus();
}
}
handleLoadOlder = debounce(() => { handleLoadOlder = debounce(() => {
const { statusIds, lastId, onLoadMore } = this.props; const { statusIds, lastId, onLoadMore } = this.props;
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined)); onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
}, 300, { leading: true }); }, 300, { leading: true });
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}
setRef = c => { setRef = c => {
this.node = c; this.node = c;
}; };

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';
@ -773,7 +774,6 @@ export const AccountHeader: React.FC<{
); );
} }
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,10 @@ export const AccountHeader: React.FC<{
<AccountNote accountId={accountId} /> <AccountNote accountId={accountId} />
)} )}
{account.note.length > 0 && account.note !== '<p></p>' && ( <AccountBio
<div accountId={accountId}
className='account__header__content translate' className='account__header__content'
dangerouslySetInnerHTML={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

@ -92,10 +92,29 @@ class ComposeForm extends ImmutablePureComponent {
this.props.onChange(e.target.value); this.props.onChange(e.target.value);
}; };
handleKeyDown = (e) => { blurOnEscape = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { if (['esc', 'escape'].includes(e.key.toLowerCase())) {
this.handleSubmit(); e.target.blur();
} }
}
handleKeyDownPost = (e) => {
if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
this.blurOnEscape(e);
};
handleKeyDownSpoiler = (e) => {
if (e.key.toLowerCase() === 'enter') {
if (e.ctrlKey || e.metaKey) {
this.handleSubmit();
} else {
e.preventDefault();
this.textareaRef.current?.focus();
}
}
this.blurOnEscape(e);
}; };
getFulltextForCharacterCounting = () => { getFulltextForCharacterCounting = () => {
@ -248,7 +267,7 @@ class ComposeForm extends ImmutablePureComponent {
value={this.props.spoilerText} value={this.props.spoilerText}
disabled={isSubmitting} disabled={isSubmitting}
onChange={this.handleChangeSpoilerText} onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDownSpoiler}
ref={this.setSpoilerText} ref={this.setSpoilerText}
suggestions={this.props.suggestions} suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
@ -273,7 +292,7 @@ class ComposeForm extends ImmutablePureComponent {
onChange={this.handleChange} onChange={this.handleChange}
suggestions={this.props.suggestions} suggestions={this.props.suggestions}
onFocus={this.handleFocus} onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDownPost}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected} onSuggestionSelected={this.onSuggestionSelected}

View File

@ -47,10 +47,6 @@ const labelForRecentSearch = (search: RecentSearch) => {
} }
}; };
const unfocus = () => {
document.querySelector('.ui')?.parentElement?.focus();
};
const ClearButton: React.FC<{ const ClearButton: React.FC<{
onClick: () => void; onClick: () => void;
hasValue: boolean; hasValue: boolean;
@ -107,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(
{ {
@ -282,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(
@ -402,7 +403,7 @@ 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(() => {
@ -410,7 +411,7 @@ export const Search: React.FC<{
setQuickActions([]); setQuickActions([]);
setSelectedOption(-1); setSelectedOption(-1);
unfocus(); unfocus();
}, [setValue, setQuickActions, setSelectedOption]); }, [unfocus]);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@ -461,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(() => {
@ -481,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'
@ -506,7 +533,7 @@ export const Search: React.FC<{
<ClearButton hasValue={hasValue} onClick={handleClear} /> <ClearButton hasValue={hasValue} onClick={handleClear} />
<div className='search__popout'> <div className='search__popout' tabIndex={-1}>
{!hasValue && ( {!hasValue && (
<> <>
<h4> <h4>

View File

@ -10,15 +10,13 @@ import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { HotKeys } from 'react-hotkeys';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import { replyCompose } from 'mastodon/actions/compose'; import { replyCompose } from 'mastodon/actions/compose';
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations'; import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses'; import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
import { Hotkeys } from 'mastodon/components/hotkeys';
import AttachmentList from 'mastodon/components/attachment_list'; import AttachmentList from 'mastodon/components/attachment_list';
import AvatarComposite from 'mastodon/components/avatar_composite'; import AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
@ -169,7 +167,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
}; };
return ( return (
<HotKeys handlers={handlers}> <Hotkeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}> <div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={handleClick} role='presentation'> <div className='conversation__avatar' onClick={handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} /> <AvatarComposite accounts={accounts} size={48} />
@ -219,7 +217,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
</div> </div>
</div> </div>
</div> </div>
</HotKeys> </Hotkeys>
); );
}; };

View File

@ -0,0 +1,120 @@
// 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;
// Emoji rendering modes. A mode is what we are using to render emojis, a style is what the user has selected.
export const EMOJI_MODE_NATIVE = 'native';
export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags';
export const EMOJI_MODE_TWEMOJI = 'twemoji';
export const EMOJI_TYPE_UNICODE = 'unicode';
export const EMOJI_TYPE_CUSTOM = 'custom';
export const EMOJI_STATE_MISSING = 'missing';
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,155 @@
import { SUPPORTED_LOCALES } from 'emojibase';
import type { Locale } from 'emojibase';
import type { DBSchema, IDBPDatabase } from 'idb';
import { openDB } from 'idb';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type {
CustomEmojiData,
UnicodeEmojiData,
LocaleOrCustom,
} from './types';
interface EmojiDB extends LocaleTables, DBSchema {
custom: {
key: string;
value: CustomEmojiData;
indexes: {
category: string;
};
};
etags: {
key: LocaleOrCustom;
value: string;
};
}
interface LocaleTable {
key: string;
value: UnicodeEmojiData;
indexes: {
group: number;
label: string;
order: number;
tags: string[];
};
}
type LocaleTables = Record<Locale, LocaleTable>;
const SCHEMA_VERSION = 1;
let db: IDBPDatabase<EmojiDB> | null = null;
async function loadDB() {
if (db) {
return db;
}
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 });
}
},
});
return db;
}
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
const db = await loadDB();
const trx = db.transaction(locale, 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
}
export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
const db = await loadDB();
const trx = db.transaction('custom', 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
}
export async function putLatestEtag(etag: string, localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
return db.put('etags', etag, locale);
}
export async function searchEmojiByHexcode(
hexcode: string,
localeString: string,
) {
const locale = toSupportedLocale(localeString);
const db = await loadDB();
return db.get(locale, hexcode);
}
export async function searchEmojisByHexcodes(
hexcodes: string[],
localeString: string,
) {
const locale = toSupportedLocale(localeString);
const db = await loadDB();
return db.getAll(
locale,
IDBKeyRange.bound(hexcodes[0], hexcodes[hexcodes.length - 1]),
);
}
export async function searchEmojiByTag(tag: string, localeString: string) {
const locale = toSupportedLocale(localeString);
const range = IDBKeyRange.only(tag.toLowerCase());
const db = await loadDB();
return db.getAllFromIndex(locale, 'tags', range);
}
export async function searchCustomEmojiByShortcode(shortcode: string) {
const db = await loadDB();
return db.get('custom', shortcode);
}
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
const db = await loadDB();
return db.getAll(
'custom',
IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 1]),
);
}
export async function findMissingLocales(localeStrings: string[]) {
const locales = new Set(localeStrings.map(toSupportedLocale));
const missingLocales: Locale[] = [];
const db = await loadDB();
for (const locale of locales) {
const rowCount = await db.count(locale);
if (!rowCount) {
missingLocales.push(locale);
}
}
return missingLocales;
}
export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
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,81 @@
import type { HTMLAttributes } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { List as ImmutableList } from 'immutable';
import { isList } from 'immutable';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { useEmojiAppState } from './hooks';
import { emojifyElement } from './render';
import type { ExtraCustomEmojiMap } from './types';
type EmojiHTMLProps = Omit<
HTMLAttributes<HTMLDivElement>,
'dangerouslySetInnerHTML'
> & {
htmlString: string;
extraEmojis?: ExtraCustomEmojiMap | ImmutableList<CustomEmoji>;
};
export const EmojiHTML: React.FC<EmojiHTMLProps> = ({
htmlString,
extraEmojis,
...props
}) => {
if (isModernEmojiEnabled()) {
return (
<ModernEmojiHTML
htmlString={htmlString}
extraEmojis={extraEmojis}
{...props}
/>
);
}
return <div dangerouslySetInnerHTML={{ __html: htmlString }} {...props} />;
};
const ModernEmojiHTML: React.FC<EmojiHTMLProps> = ({
extraEmojis: rawEmojis,
htmlString: text,
...props
}) => {
const appState = useEmojiAppState();
const [innerHTML, setInnerHTML] = useState('');
const extraEmojis: ExtraCustomEmojiMap = useMemo(() => {
if (!rawEmojis) {
return {};
}
if (isList(rawEmojis)) {
return (
rawEmojis.toJS() as ApiCustomEmojiJSON[]
).reduce<ExtraCustomEmojiMap>(
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
{},
);
}
return rawEmojis;
}, [rawEmojis]);
useEffect(() => {
if (!text) {
return;
}
const cb = async () => {
const div = document.createElement('div');
div.innerHTML = text;
const ele = await emojifyElement(div, appState, extraEmojis);
setInnerHTML(ele.innerHTML);
};
void cb();
}, [text, appState, extraEmojis]);
if (!innerHTML) {
return null;
}
return <div {...props} dangerouslySetInnerHTML={{ __html: innerHTML }} />;
};

View File

@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
import { useEmojiAppState } from './hooks';
import { emojifyText } from './render';
interface EmojiTextProps {
text: string;
}
export const EmojiText: React.FC<EmojiTextProps> = ({ text }) => {
const appState = useEmojiAppState();
const [rendered, setRendered] = useState<(string | HTMLImageElement)[]>([]);
useEffect(() => {
const cb = async () => {
const rendered = await emojifyText(text, appState);
setRendered(rendered ?? []);
};
void cb();
}, [text, appState]);
if (rendered.length === 0) {
return null;
}
return (
<>
{rendered.map((fragment, index) => {
if (typeof fragment === 'string') {
return <span key={index}>{fragment}</span>;
}
return (
<img
key={index}
draggable='false'
src={fragment.src}
alt={fragment.alt}
title={fragment.title}
className={fragment.className}
/>
);
})}
</>
);
};

View File

@ -0,0 +1,16 @@
import { useAppSelector } from '@/mastodon/store';
import { toSupportedLocale } from './locale';
import { determineEmojiMode } from './mode';
import type { EmojiAppState } from './types';
export function useEmojiAppState(): EmojiAppState {
const locale = useAppSelector((state) =>
toSupportedLocale(state.meta.get('locale') as string),
);
const mode = useAppSelector((state) =>
determineEmojiMode(state.meta.get('emoji_style') as string),
);
return { currentLocale: locale, locales: [locale], mode };
}

View File

@ -0,0 +1,55 @@
import initialState from '@/mastodon/initial_state';
import { loadWorker } from '@/mastodon/utils/workers';
import { toSupportedLocale } from './locale';
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
let worker: Worker | null = null;
export async function initializeEmoji() {
if (!worker && 'Worker' in window) {
try {
worker = loadWorker(new URL('./worker', import.meta.url), {
type: 'module',
});
} catch (err) {
console.warn('Error creating web worker:', err);
}
}
if (worker) {
// Assign worker to const to make TS happy inside the event listener.
const thisWorker = worker;
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === 'ready') {
thisWorker.postMessage('custom');
void loadEmojiLocale(userLocale);
// Load English locale as well, because people are still used to
// using it from before we supported other locales.
if (userLocale !== 'en') {
void loadEmojiLocale('en');
}
}
});
} else {
const { importCustomEmojiData } = await import('./loader');
await importCustomEmojiData();
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
}
}
}
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,78 @@
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 './types';
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);
// Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(location.origin);
if (locale === 'custom') {
url.pathname = '/api/v1/custom_emojis';
} else {
url.pathname = `/packs${isDevelopment() ? '-dev' : ''}/emoji/${locale}.json`;
}
const oldEtag = await loadLatestEtag(locale);
const response = await fetch(url, {
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';
import type { LocaleOrCustom } from './types';
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,119 @@
// Credit to Nolan Lawson for the original implementation.
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
import { isDevelopment } from '@/mastodon/utils/environment';
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import type { EmojiMode } from './types';
type Feature = Uint8ClampedArray;
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js
const FONT_FAMILY =
'"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' +
'"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif';
function getTextFeature(text: string, color: string) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d', {
// Improves the performance of `getImageData()`
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently
willReadFrequently: true,
});
if (!ctx) {
throw new Error('Canvas context not available');
}
ctx.textBaseline = 'top';
ctx.font = `100px ${FONT_FAMILY}`;
ctx.fillStyle = color;
ctx.scale(0.01, 0.01);
ctx.fillText(text, 0, 0);
return ctx.getImageData(0, 0, 1, 1).data satisfies Feature;
}
function compareFeatures(feature1: Feature, feature2: Feature) {
const feature1Str = [...feature1].join(',');
const feature2Str = [...feature2].join(',');
// This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes.
// Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is
// 0,0,0,61 - there is a transparency here.
return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,');
}
function testEmojiSupport(text: string) {
// Render white and black and then compare them to each other and ensure they're the same
// color, and neither one is black. This shows that the emoji was rendered in color.
const feature1 = getTextFeature(text, '#000');
const feature2 = getTextFeature(text, '#fff');
return compareFeatures(feature1, feature2);
}
const EMOJI_VERSION_TEST_EMOJI = '🫨'; // shaking head, from v15
const EMOJI_FLAG_TEST_EMOJI = '🇨🇭';
export function determineEmojiMode(style: string): EmojiMode {
if (style === EMOJI_MODE_NATIVE) {
// If flags are not supported, we replace them with Twemoji.
if (shouldReplaceFlags()) {
return EMOJI_MODE_NATIVE_WITH_FLAGS;
}
return EMOJI_MODE_NATIVE;
}
if (style === EMOJI_MODE_TWEMOJI) {
return EMOJI_MODE_TWEMOJI;
}
// Auto style so determine based on browser capabilities.
if (shouldUseTwemoji()) {
return EMOJI_MODE_TWEMOJI;
} else if (shouldReplaceFlags()) {
return EMOJI_MODE_NATIVE_WITH_FLAGS;
}
return EMOJI_MODE_NATIVE;
}
export function shouldUseTwemoji(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
// Test a known color emoji to see if 15.1 is supported.
return !testEmojiSupport(EMOJI_VERSION_TEST_EMOJI);
} catch (err: unknown) {
// If an error occurs, fall back to Twemoji to be safe.
if (isDevelopment()) {
console.warn(
'Emoji rendering test failed, defaulting to Twemoji. Error:',
err,
);
}
return true;
}
}
// Based on https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L19
export function shouldReplaceFlags(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
// Test a known flag emoji to see if it is rendered in color.
return !testEmojiSupport(EMOJI_FLAG_TEST_EMOJI);
} catch (err: unknown) {
// If an error occurs, assume flags should be replaced.
if (isDevelopment()) {
console.warn(
'Flag emoji rendering test failed, defaulting to replacement. Error:',
err,
);
}
return true;
}
}

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'));
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.toUpperCase()),
CODES_WITH_DARK_BORDER.includes(hexCode.toUpperCase()),
] 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,168 @@
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';
import type { TwemojiBorderInfo } from './types';
// 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).toLowerCase();
}
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: twemojiHex,
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,163 @@
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import { emojifyElement, tokenizeText } from './render';
import type { CustomEmojiData, UnicodeEmojiData } from './types';
vitest.mock('./database', () => ({
searchCustomEmojisByShortcodes: vitest.fn(
() =>
[
{
shortcode: 'custom',
static_url: 'emoji/static',
url: 'emoji/custom',
category: 'test',
visible_in_picker: true,
},
] satisfies CustomEmojiData[],
),
searchEmojisByHexcodes: vitest.fn(
() =>
[
{
hexcode: '1F60A',
group: 0,
label: 'smiling face with smiling eyes',
order: 0,
tags: ['smile', 'happy'],
unicode: '😊',
},
{
hexcode: '1F1EA-1F1FA',
group: 0,
label: 'flag-eu',
order: 0,
tags: ['flag', 'european union'],
unicode: '🇪🇺',
},
] satisfies UnicodeEmojiData[],
),
findMissingLocales: vitest.fn(() => []),
}));
describe('emojifyElement', () => {
const testElement = document.createElement('div');
testElement.innerHTML = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>';
const expectedSmileImage =
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
const expectedFlagImage =
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
const expectedCustomEmojiImage =
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/static" data-original="emoji/custom" data-static="emoji/static">';
function cloneTestElement() {
return testElement.cloneNode(true) as HTMLElement;
}
test('emojifies custom emoji in native mode', async () => {
const emojifiedElement = await emojifyElement(cloneTestElement(), {
locales: ['en'],
mode: EMOJI_MODE_NATIVE,
currentLocale: 'en',
});
expect(emojifiedElement.innerHTML).toBe(
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
);
});
test('emojifies flag emoji in native-with-flags mode', async () => {
const emojifiedElement = await emojifyElement(cloneTestElement(), {
locales: ['en'],
mode: EMOJI_MODE_NATIVE_WITH_FLAGS,
currentLocale: 'en',
});
expect(emojifiedElement.innerHTML).toBe(
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
);
});
test('emojifies everything in twemoji mode', async () => {
const emojifiedElement = await emojifyElement(cloneTestElement(), {
locales: ['en'],
mode: EMOJI_MODE_TWEMOJI,
currentLocale: 'en',
});
expect(emojifiedElement.innerHTML).toBe(
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
);
});
});
describe('tokenizeText', () => {
test('returns empty array for string with only whitespace', () => {
expect(tokenizeText(' \n')).toEqual([]);
});
test('returns an array of text to be a single token', () => {
expect(tokenizeText('Hello')).toEqual(['Hello']);
});
test('returns tokens for text with emoji', () => {
expect(tokenizeText('Hello 😊 🇿🇼!!')).toEqual([
'Hello ',
{
type: 'unicode',
code: '😊',
},
' ',
{
type: 'unicode',
code: '🇿🇼',
},
'!!',
]);
});
test('returns tokens for text with custom emoji', () => {
expect(tokenizeText('Hello :smile:!!')).toEqual([
'Hello ',
{
type: 'custom',
code: 'smile',
},
'!!',
]);
});
test('handles custom emoji with underscores and numbers', () => {
expect(tokenizeText('Hello :smile_123:!!')).toEqual([
'Hello ',
{
type: 'custom',
code: 'smile_123',
},
'!!',
]);
});
test('returns tokens for text with mixed emoji', () => {
expect(tokenizeText('Hello 😊 :smile:!!')).toEqual([
'Hello ',
{
type: 'unicode',
code: '😊',
},
' ',
{
type: 'custom',
code: 'smile',
},
'!!',
]);
});
test('does not capture custom emoji with invalid characters', () => {
expect(tokenizeText('Hello :smile-123:!!')).toEqual([
'Hello :smile-123:!!',
]);
});
});

View File

@ -0,0 +1,331 @@
import type { Locale } from 'emojibase';
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
import { autoPlayGif } from '@/mastodon/initial_state';
import { assetHost } from '@/mastodon/utils/config';
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM,
EMOJI_STATE_MISSING,
} from './constants';
import {
findMissingLocales,
searchCustomEmojisByShortcodes,
searchEmojisByHexcodes,
} from './database';
import { loadEmojiLocale } from './index';
import {
emojiToUnicodeHex,
twemojiHasBorder,
unicodeToTwemojiHex,
} from './normalize';
import type {
CustomEmojiToken,
EmojiAppState,
EmojiLoadedState,
EmojiMode,
EmojiState,
EmojiStateMap,
EmojiToken,
ExtraCustomEmojiMap,
LocaleOrCustom,
UnicodeEmojiToken,
} from './types';
import { stringHasUnicodeFlags } from './utils';
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
[EMOJI_TYPE_CUSTOM, new Map()],
]);
// Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
export async function emojifyElement<Element extends HTMLElement>(
element: Element,
appState: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap = {},
): Promise<Element> {
const queue: (HTMLElement | Text)[] = [element];
while (queue.length > 0) {
const current = queue.shift();
if (
!current ||
current instanceof HTMLScriptElement ||
current instanceof HTMLStyleElement
) {
continue;
}
if (
current.textContent &&
(current instanceof Text || !current.hasChildNodes())
) {
const renderedContent = await emojifyText(
current.textContent,
appState,
extraEmojis,
);
if (renderedContent) {
if (!(current instanceof Text)) {
current.textContent = null; // Clear the text content if it's not a Text node.
}
current.replaceWith(renderedToHTMLFragment(renderedContent));
}
continue;
}
for (const child of current.childNodes) {
if (child instanceof HTMLElement || child instanceof Text) {
queue.push(child);
}
}
}
return element;
}
export async function emojifyText(
text: string,
appState: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap = {},
) {
// Exit if no text to convert.
if (!text.trim()) {
return null;
}
const tokens = tokenizeText(text);
// If only one token and it's a string, exit early.
if (tokens.length === 1 && typeof tokens[0] === 'string') {
return null;
}
// Get all emoji from the state map, loading any missing ones.
await ensureLocalesAreLoaded(appState.locales);
await loadMissingEmojiIntoCache(tokens, appState.locales);
const renderedFragments: (string | HTMLImageElement)[] = [];
for (const token of tokens) {
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
let state: EmojiState | undefined;
if (token.type === EMOJI_TYPE_CUSTOM) {
const extraEmojiData = extraEmojis[token.code];
if (extraEmojiData) {
state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData };
} else {
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
}
} else {
state = emojiForLocale(
emojiToUnicodeHex(token.code),
appState.currentLocale,
);
}
// If the state is valid, create an image element. Otherwise, just append as text.
if (state && typeof state !== 'string') {
const image = stateToImage(state);
renderedFragments.push(image);
continue;
}
}
const text = typeof token === 'string' ? token : token.code;
renderedFragments.push(text);
}
return renderedFragments;
}
// Private functions
async function ensureLocalesAreLoaded(locales: Locale[]) {
const missingLocales = await findMissingLocales(locales);
for (const locale of missingLocales) {
await loadEmojiLocale(locale);
}
}
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
const TOKENIZE_REGEX = new RegExp(
`(${EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`,
'g',
);
type TokenizedText = (string | EmojiToken)[];
export function tokenizeText(text: string): TokenizedText {
if (!text.trim()) {
return [];
}
const tokens = [];
let lastIndex = 0;
for (const match of text.matchAll(TOKENIZE_REGEX)) {
if (match.index > lastIndex) {
tokens.push(text.slice(lastIndex, match.index));
}
const code = match[0];
if (code.startsWith(':') && code.endsWith(':')) {
// Custom emoji
tokens.push({
type: EMOJI_TYPE_CUSTOM,
code: code.slice(1, -1), // Remove the colons
} satisfies CustomEmojiToken);
} else {
// Unicode emoji
tokens.push({
type: EMOJI_TYPE_UNICODE,
code: code,
} satisfies UnicodeEmojiToken);
}
lastIndex = match.index + code.length;
}
if (lastIndex < text.length) {
tokens.push(text.slice(lastIndex));
}
return tokens;
}
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
return localeCacheMap.get(locale) ?? (new Map() as EmojiStateMap);
}
function emojiForLocale(
code: string,
locale: LocaleOrCustom,
): EmojiState | undefined {
const cache = cacheForLocale(locale);
return cache.get(code);
}
async function loadMissingEmojiIntoCache(
tokens: TokenizedText,
locales: Locale[],
) {
const missingUnicodeEmoji = new Set<string>();
const missingCustomEmoji = new Set<string>();
// Iterate over tokens and check if they are in the cache already.
for (const token of tokens) {
if (typeof token === 'string') {
continue; // Skip plain strings.
}
// If this is a custom emoji, check it separately.
if (token.type === EMOJI_TYPE_CUSTOM) {
const code = token.code;
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
if (!emojiState) {
missingCustomEmoji.add(code);
}
// Otherwise this is a unicode emoji, so check it against all locales.
} else {
const code = emojiToUnicodeHex(token.code);
if (missingUnicodeEmoji.has(code)) {
continue; // Already marked as missing.
}
for (const locale of locales) {
const emojiState = emojiForLocale(code, locale);
if (!emojiState) {
// If it's missing in one locale, we consider it missing for all.
missingUnicodeEmoji.add(code);
}
}
}
}
if (missingUnicodeEmoji.size > 0) {
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
for (const locale of locales) {
const emojis = await searchEmojisByHexcodes(missingEmojis, locale);
const cache = cacheForLocale(locale);
for (const emoji of emojis) {
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
}
const notFoundEmojis = missingEmojis.filter((code) =>
emojis.every((emoji) => emoji.hexcode !== code),
);
for (const code of notFoundEmojis) {
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
}
localeCacheMap.set(locale, cache);
}
}
if (missingCustomEmoji.size > 0) {
const missingEmojis = Array.from(missingCustomEmoji).toSorted();
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
for (const emoji of emojis) {
cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji });
}
const notFoundEmojis = missingEmojis.filter((code) =>
emojis.every((emoji) => emoji.shortcode !== code),
);
for (const code of notFoundEmojis) {
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
}
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
}
}
function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
if (token.type === EMOJI_TYPE_UNICODE) {
// If the mode is native or native with flags for non-flag emoji
// we can just append the text node directly.
if (
mode === EMOJI_MODE_NATIVE ||
(mode === EMOJI_MODE_NATIVE_WITH_FLAGS &&
!stringHasUnicodeFlags(token.code))
) {
return false;
}
}
return true;
}
function stateToImage(state: EmojiLoadedState) {
const image = document.createElement('img');
image.draggable = false;
image.classList.add('emojione');
if (state.type === EMOJI_TYPE_UNICODE) {
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
if (emojiInfo.hasLightBorder) {
image.dataset.lightCode = `${emojiInfo.hexCode}_BORDER`;
} else if (emojiInfo.hasDarkBorder) {
image.dataset.darkCode = `${emojiInfo.hexCode}_BORDER`;
}
image.alt = state.data.unicode;
image.title = state.data.label;
image.src = `${assetHost}/emoji/${emojiInfo.hexCode}.svg`;
} else {
// Custom emoji
const shortCode = `:${state.data.shortcode}:`;
image.classList.add('custom-emoji');
image.alt = shortCode;
image.title = shortCode;
image.src = autoPlayGif ? state.data.url : state.data.static_url;
image.dataset.original = state.data.url;
image.dataset.static = state.data.static_url;
}
return image;
}
function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
const fragment = document.createDocumentFragment();
for (const fragmentItem of renderedArray) {
if (typeof fragmentItem === 'string') {
fragment.appendChild(document.createTextNode(fragmentItem));
} else if (fragmentItem instanceof HTMLImageElement) {
fragment.appendChild(fragmentItem);
}
}
return fragment;
}

View File

@ -0,0 +1,64 @@
import type { FlatCompactEmoji, Locale } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
EMOJI_STATE_MISSING,
EMOJI_TYPE_CUSTOM,
EMOJI_TYPE_UNICODE,
} from './constants';
export type EmojiMode =
| typeof EMOJI_MODE_NATIVE
| typeof EMOJI_MODE_NATIVE_WITH_FLAGS
| typeof EMOJI_MODE_TWEMOJI;
export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM;
export interface EmojiAppState {
locales: Locale[];
currentLocale: Locale;
mode: EmojiMode;
}
export interface UnicodeEmojiToken {
type: typeof EMOJI_TYPE_UNICODE;
code: string;
}
export interface CustomEmojiToken {
type: typeof EMOJI_TYPE_CUSTOM;
code: string;
}
export type EmojiToken = UnicodeEmojiToken | CustomEmojiToken;
export type CustomEmojiData = ApiCustomEmojiJSON;
export type UnicodeEmojiData = FlatCompactEmoji;
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
export type EmojiStateMissing = typeof EMOJI_STATE_MISSING;
export interface EmojiStateUnicode {
type: typeof EMOJI_TYPE_UNICODE;
data: UnicodeEmojiData;
}
export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM;
data: CustomEmojiData;
}
export type EmojiState =
| EmojiStateMissing
| EmojiStateUnicode
| EmojiStateCustom;
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiStateMap = Map<string, EmojiState>;
export type ExtraCustomEmojiMap = Record<string, ApiCustomEmojiJSON>;
export interface TwemojiBorderInfo {
hexCode: string;
hasLightBorder: boolean;
hasDarkBorder: boolean;
}

View File

@ -0,0 +1,47 @@
import { stringHasUnicodeEmoji, stringHasUnicodeFlags } from './utils';
describe('stringHasEmoji', () => {
test.concurrent.for([
['only text', false],
['text with emoji 😀', true],
['multiple emojis 😀😃😄', true],
['emoji with skin tone 👍🏽', true],
['emoji with ZWJ 👩‍❤️‍👨', true],
['emoji with variation selector ✊️', true],
['emoji with keycap 1⃣', true],
['emoji with flags 🇺🇸', true],
['emoji with regional indicators 🇦🇺', true],
['emoji with gender 👩‍⚕️', true],
['emoji with family 👨‍👩‍👧‍👦', true],
['emoji with zero width joiner 👩‍🔬', true],
['emoji with non-BMP codepoint 🧑‍🚀', true],
['emoji with combining marks 👨‍👩‍👧‍👦', true],
['emoji with enclosing keycap #️⃣', true],
['emoji with no visible glyph \u200D', false],
] as const)(
'stringHasEmoji has emojis in "%s": %o',
([text, expected], { expect }) => {
expect(stringHasUnicodeEmoji(text)).toBe(expected);
},
);
});
describe('stringHasFlags', () => {
test.concurrent.for([
['EU 🇪🇺', true],
['Germany 🇩🇪', true],
['Canada 🇨🇦', true],
['São Tomé & Príncipe 🇸🇹', true],
['Scotland 🏴󠁧󠁢󠁳󠁣󠁴󠁿', true],
['black flag 🏴', false],
['arrr 🏴‍☠️', false],
['rainbow flag 🏳️‍🌈', false],
['non-flag 🔥', false],
['only text', false],
] as const)(
'stringHasFlags has flag in "%s": %o',
([text, expected], { expect }) => {
expect(stringHasUnicodeFlags(text)).toBe(expected);
},
);
});

View File

@ -0,0 +1,13 @@
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
export function stringHasUnicodeEmoji(text: string): boolean {
return EMOJI_REGEX.test(text);
}
// From https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L49-L50
const EMOJIS_FLAGS_REGEX =
/[\u{1F1E6}-\u{1F1FF}|\u{E0062}-\u{E0063}|\u{E0065}|\u{E0067}|\u{E006C}|\u{E006E}|\u{E0073}-\u{E0074}|\u{E0077}|\u{E007F}]+/u;
export function stringHasUnicodeFlags(text: string): boolean {
return EMOJIS_FLAGS_REGEX.test(text);
}

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

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

@ -431,6 +431,7 @@ export const CollapsibleNavigationPanel: React.FC = () => {
filterTaps: true, filterTaps: true,
bounds: isLtrDir ? { left: 0 } : { right: 0 }, bounds: isLtrDir ? { left: 0 } : { right: 0 },
rubberband: true, rubberband: true,
enabled: openable,
}, },
); );

View File

@ -8,7 +8,6 @@ import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react'; import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
@ -20,6 +19,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import { Account } from 'mastodon/components/account'; import { Account } from 'mastodon/components/account';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { Hotkeys } from 'mastodon/components/hotkeys';
import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import { StatusQuoteManager } from 'mastodon/components/status_quoted';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -137,7 +137,7 @@ class Notification extends ImmutablePureComponent {
const { intl, unread } = this.props; const { intl, unread } = this.props;
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-follow focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}> <div className={classNames('notification notification-follow focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<Icon id='user-plus' icon={PersonAddIcon} /> <Icon id='user-plus' icon={PersonAddIcon} />
@ -149,7 +149,7 @@ class Notification extends ImmutablePureComponent {
<Account id={account.get('id')} hidden={this.props.hidden} /> <Account id={account.get('id')} hidden={this.props.hidden} />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -157,7 +157,7 @@ class Notification extends ImmutablePureComponent {
const { intl, unread } = this.props; const { intl, unread } = this.props;
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}> <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<Icon id='user' icon={PersonIcon} /> <Icon id='user' icon={PersonIcon} />
@ -169,7 +169,7 @@ class Notification extends ImmutablePureComponent {
<FollowRequestContainer id={account.get('id')} hidden={this.props.hidden} /> <FollowRequestContainer id={account.get('id')} hidden={this.props.hidden} />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -195,7 +195,7 @@ class Notification extends ImmutablePureComponent {
const { intl, unread } = this.props; const { intl, unread } = this.props;
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-favourite focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> <div className={classNames('notification notification-favourite focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<Icon id='star' icon={StarIcon} className='star-icon' /> <Icon id='star' icon={StarIcon} className='star-icon' />
@ -217,7 +217,7 @@ class Notification extends ImmutablePureComponent {
cacheMediaWidth={this.props.cacheMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth}
/> />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -225,7 +225,7 @@ class Notification extends ImmutablePureComponent {
const { intl, unread } = this.props; const { intl, unread } = this.props;
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-reblog focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> <div className={classNames('notification notification-reblog focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<Icon id='retweet' icon={RepeatIcon} /> <Icon id='retweet' icon={RepeatIcon} />
@ -247,7 +247,7 @@ class Notification extends ImmutablePureComponent {
cacheMediaWidth={this.props.cacheMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth}
/> />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -259,7 +259,7 @@ class Notification extends ImmutablePureComponent {
} }
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-status focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> <div className={classNames('notification notification-status focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<Icon id='home' icon={HomeIcon} /> <Icon id='home' icon={HomeIcon} />
@ -282,7 +282,7 @@ class Notification extends ImmutablePureComponent {
cacheMediaWidth={this.props.cacheMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth}
/> />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -294,7 +294,7 @@ class Notification extends ImmutablePureComponent {
} }
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-update focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.update, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> <div className={classNames('notification notification-update focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.update, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<Icon id='pencil' icon={EditIcon} /> <Icon id='pencil' icon={EditIcon} />
@ -317,7 +317,7 @@ class Notification extends ImmutablePureComponent {
cacheMediaWidth={this.props.cacheMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth}
/> />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -331,7 +331,7 @@ class Notification extends ImmutablePureComponent {
} }
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-poll focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}> <div className={classNames('notification notification-poll focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<Icon id='tasks' icon={InsertChartIcon} /> <Icon id='tasks' icon={InsertChartIcon} />
@ -358,7 +358,7 @@ class Notification extends ImmutablePureComponent {
cacheMediaWidth={this.props.cacheMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth}
/> />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -371,7 +371,7 @@ class Notification extends ImmutablePureComponent {
} }
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-severed-relationships focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.relationshipsSevered, { name: notification.getIn(['event', 'target_name']) }), notification.get('created_at'))}> <div className={classNames('notification notification-severed-relationships focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.relationshipsSevered, { name: notification.getIn(['event', 'target_name']) }), notification.get('created_at'))}>
<RelationshipsSeveranceEvent <RelationshipsSeveranceEvent
type={event.get('type')} type={event.get('type')}
@ -381,7 +381,7 @@ class Notification extends ImmutablePureComponent {
hidden={hidden} hidden={hidden}
/> />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -394,7 +394,7 @@ class Notification extends ImmutablePureComponent {
} }
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-moderation-warning focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.moderationWarning), notification.get('created_at'))}> <div className={classNames('notification notification-moderation-warning focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.moderationWarning), notification.get('created_at'))}>
<ModerationWarning <ModerationWarning
action={warning.get('action')} action={warning.get('action')}
@ -402,7 +402,7 @@ class Notification extends ImmutablePureComponent {
hidden={hidden} hidden={hidden}
/> />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -410,7 +410,7 @@ class Notification extends ImmutablePureComponent {
const { intl, unread } = this.props; const { intl, unread } = this.props;
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-admin-sign-up focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminSignUp, { name: account.get('acct') }), notification.get('created_at'))}> <div className={classNames('notification notification-admin-sign-up focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminSignUp, { name: account.get('acct') }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<Icon id='user-plus' icon={PersonAddIcon} /> <Icon id='user-plus' icon={PersonAddIcon} />
@ -422,7 +422,7 @@ class Notification extends ImmutablePureComponent {
<Account id={account.get('id')} hidden={this.props.hidden} /> <Account id={account.get('id')} hidden={this.props.hidden} />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }
@ -438,7 +438,7 @@ class Notification extends ImmutablePureComponent {
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>; const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
return ( return (
<HotKeys handlers={this.getHandlers()}> <Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: account.get('acct'), target: notification.getIn(['report', 'target_account', 'acct']) }), notification.get('created_at'))}> <div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: account.get('acct'), target: notification.getIn(['report', 'target_account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<Icon id='flag' icon={FlagIcon} /> <Icon id='flag' icon={FlagIcon} />
@ -450,7 +450,7 @@ class Notification extends ImmutablePureComponent {
<Report account={account} report={notification.get('report')} hidden={this.props.hidden} /> <Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }

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,9 +1,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { HotKeys } from 'react-hotkeys';
import { navigateToProfile } from 'mastodon/actions/accounts'; import { navigateToProfile } from 'mastodon/actions/accounts';
import { mentionComposeById } from 'mastodon/actions/compose'; import { mentionComposeById } from 'mastodon/actions/compose';
import { Hotkeys } from 'mastodon/components/hotkeys';
import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group'; import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -156,5 +155,5 @@ export const NotificationGroup: React.FC<{
return null; return null;
} }
return <HotKeys handlers={handlers}>{content}</HotKeys>; return <Hotkeys handlers={handlers}>{content}</Hotkeys>;
}; };

View File

@ -3,12 +3,11 @@ import type { JSX } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { HotKeys } from 'react-hotkeys';
import { replyComposeById } from 'mastodon/actions/compose'; import { replyComposeById } from 'mastodon/actions/compose';
import { navigateToStatus } from 'mastodon/actions/statuses'; import { navigateToStatus } from 'mastodon/actions/statuses';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { AvatarGroup } from 'mastodon/components/avatar_group'; import { AvatarGroup } from 'mastodon/components/avatar_group';
import { Hotkeys } from 'mastodon/components/hotkeys';
import type { IconProp } from 'mastodon/components/icon'; import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
@ -91,7 +90,7 @@ export const NotificationGroupWithStatus: React.FC<{
); );
return ( return (
<HotKeys handlers={handlers}> <Hotkeys handlers={handlers}>
<div <div
role='button' role='button'
className={classNames( className={classNames(
@ -149,6 +148,6 @@ export const NotificationGroupWithStatus: React.FC<{
)} )}
</div> </div>
</div> </div>
</HotKeys> </Hotkeys>
); );
}; };

View File

@ -2,14 +2,13 @@ import { useMemo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { HotKeys } from 'react-hotkeys';
import { replyComposeById } from 'mastodon/actions/compose'; import { replyComposeById } from 'mastodon/actions/compose';
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions'; import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
import { import {
navigateToStatus, navigateToStatus,
toggleStatusSpoilers, toggleStatusSpoilers,
} from 'mastodon/actions/statuses'; } from 'mastodon/actions/statuses';
import { Hotkeys } from 'mastodon/components/hotkeys';
import type { IconProp } from 'mastodon/components/icon'; import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import { StatusQuoteManager } from 'mastodon/components/status_quoted';
@ -83,7 +82,7 @@ export const NotificationWithStatus: React.FC<{
if (!statusId || isFiltered) return null; if (!statusId || isFiltered) return null;
return ( return (
<HotKeys handlers={handlers}> <Hotkeys handlers={handlers}>
<div <div
role='button' role='button'
className={classNames( className={classNames(
@ -111,6 +110,6 @@ export const NotificationWithStatus: React.FC<{
unfocusable unfocusable
/> />
</div> </div>
</HotKeys> </Hotkeys>
); );
}; };

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,10 @@ 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 { Account } from 'mastodon/models/account';
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 +51,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,11 +65,9 @@ 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 accountId = status?.get('account') as string | undefined; const status = useAppSelector((state) => getStatus(state, { id: statusId }));
const account = useAppSelector((state) => const account = status?.get('account') as Account | undefined;
accountId ? state.accounts.get(accountId) : undefined,
);
const askReplyConfirmation = useAppSelector( const askReplyConfirmation = useAppSelector(
(state) => (state.compose.get('text') as string).trim().length !== 0, (state) => (state.compose.get('text') as string).trim().length !== 0,
); );

View File

@ -0,0 +1,111 @@
import { useEffect, useState, useCallback } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import {
fetchContext,
completeContextRefresh,
} from 'mastodon/actions/statuses';
import type { AsyncRefreshHeader } from 'mastodon/api';
import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
loading: {
id: 'status.context.loading',
defaultMessage: 'Checking for more replies',
},
});
export const RefreshController: React.FC<{
statusId: string;
withBorder?: boolean;
}> = ({ statusId, withBorder }) => {
const refresh = useAppSelector(
(state) => state.contexts.refreshing[statusId],
);
const dispatch = useAppDispatch();
const intl = useIntl();
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
const scheduleRefresh = (refresh: AsyncRefreshHeader) => {
timeoutId = setTimeout(() => {
void apiGetAsyncRefresh(refresh.id).then((result) => {
if (result.async_refresh.status === 'finished') {
dispatch(completeContextRefresh({ statusId }));
if (result.async_refresh.result_count > 0) {
setReady(true);
}
} else {
scheduleRefresh(refresh);
}
return '';
});
}, refresh.retry * 1000);
};
if (refresh) {
scheduleRefresh(refresh);
}
return () => {
clearTimeout(timeoutId);
};
}, [dispatch, setReady, statusId, refresh]);
const handleClick = useCallback(() => {
setLoading(true);
setReady(false);
dispatch(fetchContext({ statusId }))
.then(() => {
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
}, [dispatch, setReady, statusId]);
if (ready && !loading) {
return (
<button
className={classNames('load-more load-gap', {
'timeline-hint--with-descendants': withBorder,
})}
onClick={handleClick}
>
<FormattedMessage
id='status.context.load_new_replies'
defaultMessage='New replies available'
/>
</button>
);
}
if (!refresh && !loading) {
return null;
}
return (
<div
className={classNames('load-more load-gap', {
'timeline-hint--with-descendants': withBorder,
})}
aria-busy
aria-live='polite'
aria-label={intl.formatMessage(messages.loading)}
>
<LoadingIndicator />
</div>
);
};

View File

@ -10,10 +10,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { HotKeys } from 'react-hotkeys';
import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { Hotkeys } from 'mastodon/components/hotkeys';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { TimelineHint } from 'mastodon/components/timeline_hint'; import { TimelineHint } from 'mastodon/components/timeline_hint';
@ -69,7 +68,7 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from
import ActionBar from './components/action_bar'; import ActionBar from './components/action_bar';
import { DetailedStatus } from './components/detailed_status'; import { DetailedStatus } from './components/detailed_status';
import { RefreshController } from './components/refresh_controller';
const messages = defineMessages({ const messages = defineMessages({
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' }, revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
@ -549,7 +548,7 @@ class Status extends ImmutablePureComponent {
render () { render () {
let ancestors, descendants, remoteHint; let ancestors, descendants, remoteHint;
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props; const { isLoading, status, ancestorsIds, descendantsIds, refresh, intl, domain, multiColumn, pictureInPicture } = this.props;
const { fullscreen } = this.state; const { fullscreen } = this.state;
if (isLoading) { if (isLoading) {
@ -579,11 +578,9 @@ class Status extends ImmutablePureComponent {
if (!isLocal) { if (!isLocal) {
remoteHint = ( remoteHint = (
<TimelineHint <RefreshController
className={classNames(!!descendants && 'timeline-hint--with-descendants')} statusId={status.get('id')}
url={status.get('url')} withBorder={!!descendants}
message={<FormattedMessage id='hints.threads.replies_may_be_missing' defaultMessage='Replies from other servers may be missing.' />}
label={<FormattedMessage id='hints.threads.see_more' defaultMessage='See more replies on {domain}' values={{ domain: <strong>{status.getIn(['account', 'acct']).split('@')[1]}</strong> }} />}
/> />
); );
} }
@ -616,7 +613,7 @@ class Status extends ImmutablePureComponent {
<div className={classNames('scrollable', { fullscreen })} ref={this.setContainerRef}> <div className={classNames('scrollable', { fullscreen })} ref={this.setContainerRef}>
{ancestors} {ancestors}
<HotKeys handlers={handlers}> <Hotkeys handlers={handlers}>
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader(intl, status, false)} ref={this.setStatusRef}> <div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader(intl, status, false)} ref={this.setStatusRef}>
<DetailedStatus <DetailedStatus
key={`details-${status.get('id')}`} key={`details-${status.get('id')}`}
@ -654,7 +651,7 @@ class Status extends ImmutablePureComponent {
onEmbed={this.handleEmbed} onEmbed={this.handleEmbed}
/> />
</div> </div>
</HotKeys> </Hotkeys>
{descendants} {descendants}
{remoteHint} {remoteHint}

View File

@ -306,10 +306,8 @@ export const ZoomableImage: React.FC<ZoomableImageProps> = ({
<animated.img <animated.img
style={{ transform }} style={{ transform }}
role='presentation'
ref={imageRef} ref={imageRef}
alt={alt} alt={alt}
title={alt}
lang={lang} lang={lang}
src={src} src={src}
width={width} width={width}

View File

@ -9,13 +9,13 @@ import { Redirect, Route, withRouter } from 'react-router-dom';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { HotKeys } from 'react-hotkeys';
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
import { fetchNotifications } from 'mastodon/actions/notification_groups'; import { fetchNotifications } from 'mastodon/actions/notification_groups';
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
import { AlertsController } from 'mastodon/components/alerts_controller'; import { AlertsController } from 'mastodon/components/alerts_controller';
import { Hotkeys } from 'mastodon/components/hotkeys';
import { HoverCardController } from 'mastodon/components/hover_card_controller'; import { HoverCardController } from 'mastodon/components/hover_card_controller';
import { PictureInPicture } from 'mastodon/features/picture_in_picture'; import { PictureInPicture } from 'mastodon/features/picture_in_picture';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
@ -98,40 +98,6 @@ const mapStateToProps = state => ({
username: state.getIn(['accounts', me, 'username']), username: state.getIn(['accounts', me, 'username']),
}); });
const keyMap = {
help: '?',
new: 'n',
search: ['s', '/'],
forceNew: 'option+n',
toggleComposeSpoilers: 'option+x',
focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
reply: 'r',
favourite: 'f',
boost: 'b',
mention: 'm',
open: ['enter', 'o'],
openProfile: 'p',
moveDown: ['down', 'j'],
moveUp: ['up', 'k'],
back: 'backspace',
goToHome: 'g h',
goToNotifications: 'g n',
goToLocal: 'g l',
goToFederated: 'g t',
goToDirect: 'g d',
goToStart: 'g s',
goToFavourites: 'g f',
goToPinned: 'g p',
goToProfile: 'g u',
goToBlocked: 'g b',
goToMuted: 'g m',
goToRequests: 'g r',
toggleHidden: 'x',
toggleSensitive: 'h',
openMedia: 'e',
onTranslate: 't',
};
class SwitchingColumnsArea extends PureComponent { class SwitchingColumnsArea extends PureComponent {
static propTypes = { static propTypes = {
identity: identityContextPropShape, identity: identityContextPropShape,
@ -400,6 +366,10 @@ class UI extends PureComponent {
} }
}; };
handleDonate = () => {
location.href = 'https://joinmastodon.org/sponsors#donate'
}
componentDidMount () { componentDidMount () {
const { signedIn } = this.props.identity; const { signedIn } = this.props.identity;
@ -426,10 +396,6 @@ class UI extends PureComponent {
setTimeout(() => this.props.dispatch(fetchServer()), 3000); setTimeout(() => this.props.dispatch(fetchServer()), 3000);
} }
this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
};
} }
componentWillUnmount () { componentWillUnmount () {
@ -509,10 +475,6 @@ class UI extends PureComponent {
} }
}; };
setHotkeysRef = c => {
this.hotkeys = c;
};
handleHotkeyToggleHelp = () => { handleHotkeyToggleHelp = () => {
if (this.props.location.pathname === '/keyboard-shortcuts') { if (this.props.location.pathname === '/keyboard-shortcuts') {
this.props.history.goBack(); this.props.history.goBack();
@ -593,10 +555,11 @@ class UI extends PureComponent {
goToBlocked: this.handleHotkeyGoToBlocked, goToBlocked: this.handleHotkeyGoToBlocked,
goToMuted: this.handleHotkeyGoToMuted, goToMuted: this.handleHotkeyGoToMuted,
goToRequests: this.handleHotkeyGoToRequests, goToRequests: this.handleHotkeyGoToRequests,
cheat: this.handleDonate,
}; };
return ( return (
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused> <Hotkeys global handlers={handlers}>
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}> <div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
<SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}> <SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}>
{children} {children}
@ -611,7 +574,7 @@ class UI extends PureComponent {
<ModalContainer /> <ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} /> <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
</div> </div>
</HotKeys> </Hotkeys>
); );
} }

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;

View File

@ -1,6 +1,5 @@
// @ts-check // @ts-check
/** /**
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage * @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
*/ */
@ -46,6 +45,7 @@
* @property {string} sso_redirect * @property {string} sso_redirect
* @property {string} status_page_url * @property {string} status_page_url
* @property {boolean} terms_of_service_enabled * @property {boolean} terms_of_service_enabled
* @property {string?} emoji_style
*/ */
/** /**
@ -64,6 +64,7 @@
* @property {boolean=} critical_updates_pending * @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta * @property {InitialStateMeta} meta
* @property {Role?} role * @property {Role?} role
* @property {string[]} features
*/ */
const element = document.getElementById('initial-state'); const element = document.getElementById('initial-state');
@ -95,6 +96,7 @@ export const disableHoverCards = getMeta('disable_hover_cards');
export const disabledAccountId = getMeta('disabled_account_id'); export const disabledAccountId = getMeta('disabled_account_id');
export const displayMedia = getMeta('display_media'); export const displayMedia = getMeta('display_media');
export const domain = getMeta('domain'); export const domain = getMeta('domain');
export const emojiStyle = getMeta('emoji_style') || 'auto';
export const expandSpoilers = getMeta('expand_spoilers'); export const expandSpoilers = getMeta('expand_spoilers');
export const forceSingleColumn = !getMeta('advanced_layout'); export const forceSingleColumn = !getMeta('advanced_layout');
export const limitedFederationMode = getMeta('limited_federation_mode'); export const limitedFederationMode = getMeta('limited_federation_mode');

View File

@ -1,6 +1,7 @@
{ {
"about.blocks": "خوادم تحت الإشراف", "about.blocks": "خوادم تحت الإشراف",
"about.contact": "للاتصال:", "about.contact": "للاتصال:",
"about.default_locale": "افتراضيالافتراضية",
"about.disclaimer": "ماستدون برنامج حر ومفتوح المصدر وعلامة تجارية لـ Mastodon GmbH.", "about.disclaimer": "ماستدون برنامج حر ومفتوح المصدر وعلامة تجارية لـ Mastodon GmbH.",
"about.domain_blocks.no_reason_available": "السبب غير متوفر", "about.domain_blocks.no_reason_available": "السبب غير متوفر",
"about.domain_blocks.preamble": "يتيح مَستُدون عمومًا لمستخدميه مطالعة المحتوى من المستخدمين من الخواديم الأخرى في الفدرالية والتفاعل معهم. وهذه هي الاستثناءات التي وضعت على هذا الخادوم.", "about.domain_blocks.preamble": "يتيح مَستُدون عمومًا لمستخدميه مطالعة المحتوى من المستخدمين من الخواديم الأخرى في الفدرالية والتفاعل معهم. وهذه هي الاستثناءات التي وضعت على هذا الخادوم.",
@ -8,6 +9,7 @@
"about.domain_blocks.silenced.title": "محدود", "about.domain_blocks.silenced.title": "محدود",
"about.domain_blocks.suspended.explanation": "لن يتم معالجة أي بيانات من هذا الخادم أو تخزينها أو تبادلها، مما يجعل أي تفاعل أو اتصال مع المستخدمين من هذا الخادم مستحيلا.", "about.domain_blocks.suspended.explanation": "لن يتم معالجة أي بيانات من هذا الخادم أو تخزينها أو تبادلها، مما يجعل أي تفاعل أو اتصال مع المستخدمين من هذا الخادم مستحيلا.",
"about.domain_blocks.suspended.title": "مُعلّق", "about.domain_blocks.suspended.title": "مُعلّق",
"about.language_label": "اللغة",
"about.not_available": "لم يتم توفير هذه المعلومات على هذا الخادم.", "about.not_available": "لم يتم توفير هذه المعلومات على هذا الخادم.",
"about.powered_by": "شبكة اجتماعية لامركزية مدعومة من {mastodon}", "about.powered_by": "شبكة اجتماعية لامركزية مدعومة من {mastodon}",
"about.rules": "قواعد الخادم", "about.rules": "قواعد الخادم",
@ -19,13 +21,21 @@
"account.block_domain": "حظر اسم النِّطاق {domain}", "account.block_domain": "حظر اسم النِّطاق {domain}",
"account.block_short": "حظر", "account.block_short": "حظر",
"account.blocked": "محظور", "account.blocked": "محظور",
"account.blocking": "محظور",
"account.cancel_follow_request": "إلغاء طلب المتابعة", "account.cancel_follow_request": "إلغاء طلب المتابعة",
"account.copy": "نسخ الرابط إلى الملف الشخصي", "account.copy": "نسخ الرابط إلى الملف الشخصي",
"account.direct": "إشارة خاصة لـ @{name}", "account.direct": "إشارة خاصة لـ @{name}",
"account.disable_notifications": "توقف عن إشعاري عندما ينشر @{name}", "account.disable_notifications": "توقف عن إشعاري عندما ينشر @{name}",
"account.domain_blocking": "نطاق محظور",
"account.edit_profile": "تعديل الملف الشخصي", "account.edit_profile": "تعديل الملف الشخصي",
"account.enable_notifications": "أشعرني عندما ينشر @{name}", "account.enable_notifications": "أشعرني عندما ينشر @{name}",
"account.endorse": "أوصِ به على صفحتك الشخصية", "account.endorse": "أوصِ به على صفحتك الشخصية",
"account.familiar_followers_many": "يتبعه {name1}، {name2} و{othersCount, plural, one {شخص آخر تعرفه} other {# أشخاص آخرون تعرفهم}}",
"account.familiar_followers_one": "يتبعه {name1}",
"account.familiar_followers_two": "يتبعه {name1} و {name2}",
"account.featured": "معروض",
"account.featured.accounts": "ملفات شخصية",
"account.featured.hashtags": "هاشتاقات",
"account.featured_tags.last_status_at": "آخر منشور في {date}", "account.featured_tags.last_status_at": "آخر منشور في {date}",
"account.featured_tags.last_status_never": "لا توجد رسائل", "account.featured_tags.last_status_never": "لا توجد رسائل",
"account.follow": "متابعة", "account.follow": "متابعة",
@ -33,9 +43,11 @@
"account.followers": "مُتابِعون", "account.followers": "مُتابِعون",
"account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم إلى حد الآن.", "account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم إلى حد الآن.",
"account.followers_counter": "{count, plural, zero{لا مُتابع} one {مُتابعٌ واحِد} two {مُتابعانِ اِثنان} few {{counter} مُتابِعين} many {{counter} مُتابِعًا} other {{counter} مُتابع}}", "account.followers_counter": "{count, plural, zero{لا مُتابع} one {مُتابعٌ واحِد} two {مُتابعانِ اِثنان} few {{counter} مُتابِعين} many {{counter} مُتابِعًا} other {{counter} مُتابع}}",
"account.followers_you_know_counter": "{counter} شخص تعرفه",
"account.following": "الاشتراكات", "account.following": "الاشتراكات",
"account.following_counter": "{count, plural, zero{لا يُتابِع أحدًا} one {يُتابِعُ واحد} two{يُتابِعُ اِثنان} few{يُتابِعُ {counter}} many{يُتابِعُ {counter}} other {يُتابِعُ {counter}}}", "account.following_counter": "{count, plural, zero{لا يُتابِع أحدًا} one {يُتابِعُ واحد} two{يُتابِعُ اِثنان} few{يُتابِعُ {counter}} many{يُتابِعُ {counter}} other {يُتابِعُ {counter}}}",
"account.follows.empty": "لا يُتابع هذا المُستخدمُ أيَّ أحدٍ حتى الآن.", "account.follows.empty": "لا يُتابع هذا المُستخدمُ أيَّ أحدٍ حتى الآن.",
"account.follows_you": "يتابعك",
"account.go_to_profile": "اذهب إلى الملف الشخصي", "account.go_to_profile": "اذهب إلى الملف الشخصي",
"account.hide_reblogs": "إخفاء المعاد نشرها مِن @{name}", "account.hide_reblogs": "إخفاء المعاد نشرها مِن @{name}",
"account.in_memoriam": "في الذكرى.", "account.in_memoriam": "في الذكرى.",
@ -50,17 +62,23 @@
"account.mute_notifications_short": "كتم الإشعارات", "account.mute_notifications_short": "كتم الإشعارات",
"account.mute_short": "اكتم", "account.mute_short": "اكتم",
"account.muted": "مَكتوم", "account.muted": "مَكتوم",
"account.muting": "مكتوم",
"account.mutual": "أنتم تتابعون بعضكم البعض",
"account.no_bio": "لم يتم تقديم وصف.", "account.no_bio": "لم يتم تقديم وصف.",
"account.open_original_page": "افتح الصفحة الأصلية", "account.open_original_page": "افتح الصفحة الأصلية",
"account.posts": "منشورات", "account.posts": "منشورات",
"account.posts_with_replies": "المنشورات والرُدود", "account.posts_with_replies": "المنشورات والرُدود",
"account.remove_from_followers": "إزالة {name} من المتابعين",
"account.report": "الإبلاغ عن @{name}", "account.report": "الإبلاغ عن @{name}",
"account.requested": "في انتظار القبول. اضغط لإلغاء طلب المُتابعة", "account.requested": "في انتظار القبول. اضغط لإلغاء طلب المُتابعة",
"account.requested_follow": "لقد طلب {name} متابعتك", "account.requested_follow": "لقد طلب {name} متابعتك",
"account.requests_to_follow_you": "طلبات المتابعة",
"account.share": "شارِك الملف التعريفي لـ @{name}", "account.share": "شارِك الملف التعريفي لـ @{name}",
"account.show_reblogs": "اعرض إعادات نشر @{name}", "account.show_reblogs": "اعرض إعادات نشر @{name}",
"account.statuses_counter": "{count, plural, zero {}one {{counter} مشور} two {{counter} منشور} few {{counter} منشور} many {{counter} منشور} other {{counter} منشور}}",
"account.unblock": "إلغاء الحَظر عن @{name}", "account.unblock": "إلغاء الحَظر عن @{name}",
"account.unblock_domain": "إلغاء الحَظر عن النِّطاق {domain}", "account.unblock_domain": "إلغاء الحَظر عن النِّطاق {domain}",
"account.unblock_domain_short": "رفع الحظر",
"account.unblock_short": "ألغ الحجب", "account.unblock_short": "ألغ الحجب",
"account.unendorse": "لا تُرَوِّج لهُ في الملف الشخصي", "account.unendorse": "لا تُرَوِّج لهُ في الملف الشخصي",
"account.unfollow": "إلغاء المُتابعة", "account.unfollow": "إلغاء المُتابعة",
@ -82,9 +100,33 @@
"alert.unexpected.message": "لقد طرأ خطأ غير متوقّع.", "alert.unexpected.message": "لقد طرأ خطأ غير متوقّع.",
"alert.unexpected.title": "المعذرة!", "alert.unexpected.title": "المعذرة!",
"alt_text_badge.title": "نص بديل", "alt_text_badge.title": "نص بديل",
"alt_text_modal.add_alt_text": "أضف نصًا بديلًا",
"alt_text_modal.add_text_from_image": "أضف النص من الصورة",
"alt_text_modal.cancel": "إلغاء", "alt_text_modal.cancel": "إلغاء",
"alt_text_modal.change_thumbnail": "غيّر الصورة المصغرة",
"alt_text_modal.describe_for_people_with_hearing_impairments": "قم بوصفها للأشخاص ذوي الإعاقة السمعية…",
"alt_text_modal.describe_for_people_with_visual_impairments": "قم بوصفها للأشخاص ذوي الإعاقة البصرية…",
"alt_text_modal.done": "تمّ",
"announcement.announcement": "إعلان", "announcement.announcement": "إعلان",
"annual_report.summary.archetype.booster": "The cool-hunter", "annual_report.summary.archetype.booster": "The cool-hunter",
"annual_report.summary.archetype.lurker": "المتصفح الصامت",
"annual_report.summary.archetype.oracle": "حكيم",
"annual_report.summary.archetype.pollster": "مستطلع للرأي",
"annual_report.summary.archetype.replier": "الفراشة الاجتماعية",
"annual_report.summary.followers.followers": "المُتابِعُون",
"annual_report.summary.followers.total": "{count} في المجمل",
"annual_report.summary.here_it_is": "هذا ملخص الخص بك لسنة {year}:",
"annual_report.summary.highlighted_post.by_favourites": "المنشور ذو أعلى عدد تفضيلات",
"annual_report.summary.highlighted_post.by_reblogs": "أكثر منشور مُعاد نشره",
"annual_report.summary.highlighted_post.by_replies": "المنشور بأعلى عدد تعليقات",
"annual_report.summary.highlighted_post.possessive": "من قبل {name}",
"annual_report.summary.most_used_app.most_used_app": "التطبيق الأكثر استخداماً",
"annual_report.summary.most_used_hashtag.most_used_hashtag": "الهاشتاق الأكثر استخداماً",
"annual_report.summary.most_used_hashtag.none": "لا شيء",
"annual_report.summary.new_posts.new_posts": "المنشورات الجديدة",
"annual_report.summary.percentile.text": "<topLabel>هذا يجعلك من بين أكثر </topLabel><percentage></percentage><bottomLabel>مستخدمي {domain} نشاطاً </bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "سيبقى هذا الأمر بيننا.",
"annual_report.summary.thanks": "شكرا لكونك جزءاً من ماستدون!",
"attachments_list.unprocessed": "(غير معالَج)", "attachments_list.unprocessed": "(غير معالَج)",
"audio.hide": "إخفاء المقطع الصوتي", "audio.hide": "إخفاء المقطع الصوتي",
"block_modal.remote_users_caveat": "سوف نطلب من الخادم {domain} أن يحترم قرارك، لكن الالتزام غير مضمون لأن بعض الخواديم قد تتعامل مع نصوص الكتل بشكل مختلف. قد تظل المنشورات العامة مرئية للمستخدمين غير المسجلين الدخول.", "block_modal.remote_users_caveat": "سوف نطلب من الخادم {domain} أن يحترم قرارك، لكن الالتزام غير مضمون لأن بعض الخواديم قد تتعامل مع نصوص الكتل بشكل مختلف. قد تظل المنشورات العامة مرئية للمستخدمين غير المسجلين الدخول.",
@ -108,6 +150,7 @@
"bundle_column_error.routing.body": "تعذر العثور على الصفحة المطلوبة. هل أنت متأكد من أنّ الرابط التشعبي URL في شريط العناوين صحيح؟", "bundle_column_error.routing.body": "تعذر العثور على الصفحة المطلوبة. هل أنت متأكد من أنّ الرابط التشعبي URL في شريط العناوين صحيح؟",
"bundle_column_error.routing.title": "404", "bundle_column_error.routing.title": "404",
"bundle_modal_error.close": "إغلاق", "bundle_modal_error.close": "إغلاق",
"bundle_modal_error.message": "حدث خطأ أثناء تحميل هذه الشاشة.",
"bundle_modal_error.retry": "إعادة المُحاولة", "bundle_modal_error.retry": "إعادة المُحاولة",
"closed_registrations.other_server_instructions": "بما أن ماستدون لامركزي، يمكنك إنشاء حساب على خادم آخر للاستمرار في التفاعل مع هذا الخادم.", "closed_registrations.other_server_instructions": "بما أن ماستدون لامركزي، يمكنك إنشاء حساب على خادم آخر للاستمرار في التفاعل مع هذا الخادم.",
"closed_registrations_modal.description": "لا يمكن إنشاء حساب على {domain} حاليا، ولكن على فكرة لست بحاجة إلى حساب على {domain} بذاته لاستخدام ماستدون.", "closed_registrations_modal.description": "لا يمكن إنشاء حساب على {domain} حاليا، ولكن على فكرة لست بحاجة إلى حساب على {domain} بذاته لاستخدام ماستدون.",
@ -176,15 +219,32 @@
"confirmations.delete_list.confirm": "حذف", "confirmations.delete_list.confirm": "حذف",
"confirmations.delete_list.message": "هل أنتَ مُتأكدٌ أنكَ تُريدُ حَذفَ هذِهِ القائمة بشكلٍ دائم؟", "confirmations.delete_list.message": "هل أنتَ مُتأكدٌ أنكَ تُريدُ حَذفَ هذِهِ القائمة بشكلٍ دائم؟",
"confirmations.delete_list.title": "أتريد حذف القائمة؟", "confirmations.delete_list.title": "أتريد حذف القائمة؟",
"confirmations.discard_draft.confirm": "تجاهل ومتابعة",
"confirmations.discard_draft.edit.cancel": "استئناف التعديل",
"confirmations.discard_draft.edit.message": "سيتم تجاهل أي تغييرات قمت بها على هذا المنشور.",
"confirmations.discard_draft.edit.title": "تجاهل التغييرات على منشورك؟",
"confirmations.discard_draft.post.cancel": "استئناف المسودة",
"confirmations.discard_draft.post.message": "عبر الاستمرار سيتم تجاهل المنشور الذي تقوم بكتابته الآن.",
"confirmations.discard_draft.post.title": "تجاهل مسودة منشورك؟",
"confirmations.discard_edit_media.confirm": "تجاهل", "confirmations.discard_edit_media.confirm": "تجاهل",
"confirmations.discard_edit_media.message": "لديك تغييرات غير محفوظة لوصف الوسائط أو معاينتها، أتريد تجاهلها على أي حال؟", "confirmations.discard_edit_media.message": "لديك تغييرات غير محفوظة لوصف الوسائط أو معاينتها، أتريد تجاهلها على أي حال؟",
"confirmations.follow_to_list.confirm": "متابعة وأضفه للقائمة",
"confirmations.follow_to_list.message": "يجب أن تتابع {name} لإضافتهم إلى قائمة.",
"confirmations.follow_to_list.title": "متابعة المستخدم؟",
"confirmations.logout.confirm": "خروج", "confirmations.logout.confirm": "خروج",
"confirmations.logout.message": "متأكد من أنك تريد الخروج؟", "confirmations.logout.message": "متأكد من أنك تريد الخروج؟",
"confirmations.logout.title": "أتريد المغادرة؟", "confirmations.logout.title": "أتريد المغادرة؟",
"confirmations.missing_alt_text.confirm": "أضف نصًا بديلًا",
"confirmations.missing_alt_text.message": "يحتوي منشورك على وسائط دون نص بديل. إضافة أوصاف تساعد على جعل المحتوى متاحاً للمزيد من الأشخاص.",
"confirmations.missing_alt_text.secondary": "انشر على أي حال",
"confirmations.missing_alt_text.title": "أضف نصًا بديلًا؟",
"confirmations.mute.confirm": "أكتم", "confirmations.mute.confirm": "أكتم",
"confirmations.redraft.confirm": "إزالة وإعادة الصياغة", "confirmations.redraft.confirm": "إزالة وإعادة الصياغة",
"confirmations.redraft.message": "هل أنت متأكد من أنك تريد حذف هذا المنشور و إعادة صياغته؟ سوف تفقد جميع الإعجابات و الترقيات أما الردود المتصلة به فستُصبِح يتيمة.", "confirmations.redraft.message": "هل أنت متأكد من أنك تريد حذف هذا المنشور و إعادة صياغته؟ سوف تفقد جميع الإعجابات و الترقيات أما الردود المتصلة به فستُصبِح يتيمة.",
"confirmations.redraft.title": "أتريد حذف وإعادة صياغة المنشور؟", "confirmations.redraft.title": "أتريد حذف وإعادة صياغة المنشور؟",
"confirmations.remove_from_followers.confirm": "إزالة المتابع",
"confirmations.remove_from_followers.message": "سيتوقف {name} عن متابعتك. هل بالتأكيد تريد المتابعة؟",
"confirmations.remove_from_followers.title": "إزالة المتابع؟",
"confirmations.unfollow.confirm": "إلغاء المتابعة", "confirmations.unfollow.confirm": "إلغاء المتابعة",
"confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟", "confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
"confirmations.unfollow.title": "إلغاء متابعة المستخدم؟", "confirmations.unfollow.title": "إلغاء متابعة المستخدم؟",
@ -206,12 +266,15 @@
"disabled_account_banner.text": "حسابك {disabledAccount} معطل حاليا.", "disabled_account_banner.text": "حسابك {disabledAccount} معطل حاليا.",
"dismissable_banner.community_timeline": "هذه هي أحدث المنشورات العامة من أشخاص تُستضاف حساباتهم على {domain}.", "dismissable_banner.community_timeline": "هذه هي أحدث المنشورات العامة من أشخاص تُستضاف حساباتهم على {domain}.",
"dismissable_banner.dismiss": "رفض", "dismissable_banner.dismiss": "رفض",
"dismissable_banner.public_timeline": "هذه أحدث المنشورات العامة على الشبكة الفيدرالية التي يتابعها مستخدمي نطاق {domain}.",
"domain_block_modal.block": "حظر الخادم", "domain_block_modal.block": "حظر الخادم",
"domain_block_modal.block_account_instead": "أحجب @{name} بدلاً من ذلك", "domain_block_modal.block_account_instead": "أحجب @{name} بدلاً من ذلك",
"domain_block_modal.they_can_interact_with_old_posts": "يمكن للأشخاص من هذا الخادم التفاعل مع منشوراتك القديمة.", "domain_block_modal.they_can_interact_with_old_posts": "يمكن للأشخاص من هذا الخادم التفاعل مع منشوراتك القديمة.",
"domain_block_modal.they_cant_follow": "لا أحد من هذا الخادم يمكنه متابعتك.", "domain_block_modal.they_cant_follow": "لا أحد من هذا الخادم يمكنه متابعتك.",
"domain_block_modal.they_wont_know": "لن يَعرف أنه قد تم حظره.", "domain_block_modal.they_wont_know": "لن يَعرف أنه قد تم حظره.",
"domain_block_modal.title": "أتريد حظر النطاق؟", "domain_block_modal.title": "أتريد حظر النطاق؟",
"domain_block_modal.you_will_lose_num_followers": "ستخسر {followersCount, plural, zero {}one {{followersCountDisplay} متابع} two {{followersCountDisplay} متابع} few {{followersCountDisplay} متابعين} many {{followersCountDisplay} متابعين} other {{followersCountDisplay} متابعين}} و {followingCount, plural, zero {}one {{followingCountDisplay} شخص تتابعه} two {{followingCountDisplay} شخص تتابعهما} few {{followingCountDisplay} أشخاص تتابعهم} many {{followingCountDisplay} أشخاص تتابعهم} other {{followingCountDisplay} أشخاص تتابعهم}}.",
"domain_block_modal.you_will_lose_relationships": "ستفقد جميع المتابعين والأشخاص الذين تتابعهم من هذا الخادم.",
"domain_block_modal.you_wont_see_posts": "لن ترى منشورات أو إشعارات من المستخدمين على هذا الخادم.", "domain_block_modal.you_wont_see_posts": "لن ترى منشورات أو إشعارات من المستخدمين على هذا الخادم.",
"domain_pill.activitypub_lets_connect": "يتيح لك التواصل والتفاعل مع الناس ليس فقط على ماستدون، ولكن عبر تطبيقات اجتماعية مختلفة أيضا.", "domain_pill.activitypub_lets_connect": "يتيح لك التواصل والتفاعل مع الناس ليس فقط على ماستدون، ولكن عبر تطبيقات اجتماعية مختلفة أيضا.",
"domain_pill.activitypub_like_language": "إنّ ActivityPub مثل لغة ماستدون التي يتحدث بها مع شبكات اجتماعية أخرى.", "domain_pill.activitypub_like_language": "إنّ ActivityPub مثل لغة ماستدون التي يتحدث بها مع شبكات اجتماعية أخرى.",
@ -243,6 +306,9 @@
"emoji_button.search_results": "نتائج البحث", "emoji_button.search_results": "نتائج البحث",
"emoji_button.symbols": "رموز", "emoji_button.symbols": "رموز",
"emoji_button.travel": "الأماكن والسفر", "emoji_button.travel": "الأماكن والسفر",
"empty_column.account_featured.me": "لم تعرض أي شيء حتى الآن. هل تعلم أنه يمكنك عرض الهاشتاقات التي تستخدمها، وحتى حسابات أصدقاءك على ملفك الشخصي؟",
"empty_column.account_featured.other": "{acct} لم يعرض أي شيء حتى الآن. هل تعلم أنه يمكنك عرض الهاشتاقات التي تستخدمها، وحتى حسابات أصدقاءك على ملفك الشخصي؟",
"empty_column.account_featured_other.unknown": "هذا الحساب لم يعرض أي شيء حتى الآن.",
"empty_column.account_hides_collections": "اختار هذا المستخدم عدم إتاحة هذه المعلومات للعامة", "empty_column.account_hides_collections": "اختار هذا المستخدم عدم إتاحة هذه المعلومات للعامة",
"empty_column.account_suspended": "حساب معلق", "empty_column.account_suspended": "حساب معلق",
"empty_column.account_timeline": "لا توجد منشورات هنا!", "empty_column.account_timeline": "لا توجد منشورات هنا!",
@ -271,9 +337,15 @@
"errors.unexpected_crash.copy_stacktrace": "انسخ تتبع الارتباطات إلى الحافظة", "errors.unexpected_crash.copy_stacktrace": "انسخ تتبع الارتباطات إلى الحافظة",
"errors.unexpected_crash.report_issue": "الإبلاغ عن خلل", "errors.unexpected_crash.report_issue": "الإبلاغ عن خلل",
"explore.suggested_follows": "أشخاص", "explore.suggested_follows": "أشخاص",
"explore.title": "رائج",
"explore.trending_links": "المُستجدّات", "explore.trending_links": "المُستجدّات",
"explore.trending_statuses": "المنشورات", "explore.trending_statuses": "المنشورات",
"explore.trending_tags": "وُسُوم", "explore.trending_tags": "وُسُوم",
"featured_carousel.header": "{count, plural, zero {}one {منشور معروض} two {منشور معروضَين} few {منشورات معروضة} many {منشورات معروضة} other {منشورات معروضة}}",
"featured_carousel.next": "التالي",
"featured_carousel.post": "منشور",
"featured_carousel.previous": "السابق",
"featured_carousel.slide": "{index} من {total}",
"filter_modal.added.context_mismatch_explanation": "فئة عامل التصفية هذه لا تنطبق على السياق الذي وصلت فيه إلى هذه المشاركة. إذا كنت ترغب في تصفية المنشور في هذا السياق أيضا، فسيتعين عليك تعديل عامل التصفية.", "filter_modal.added.context_mismatch_explanation": "فئة عامل التصفية هذه لا تنطبق على السياق الذي وصلت فيه إلى هذه المشاركة. إذا كنت ترغب في تصفية المنشور في هذا السياق أيضا، فسيتعين عليك تعديل عامل التصفية.",
"filter_modal.added.context_mismatch_title": "عدم تطابق السياق!", "filter_modal.added.context_mismatch_title": "عدم تطابق السياق!",
"filter_modal.added.expired_explanation": "انتهت صلاحية فئة عامل التصفية هذه، سوف تحتاج إلى تغيير تاريخ انتهاء الصلاحية لتطبيقها.", "filter_modal.added.expired_explanation": "انتهت صلاحية فئة عامل التصفية هذه، سوف تحتاج إلى تغيير تاريخ انتهاء الصلاحية لتطبيقها.",
@ -290,6 +362,8 @@
"filter_modal.select_filter.subtitle": "استخدم فئة موجودة أو قم بإنشاء فئة جديدة", "filter_modal.select_filter.subtitle": "استخدم فئة موجودة أو قم بإنشاء فئة جديدة",
"filter_modal.select_filter.title": "تصفية هذا المنشور", "filter_modal.select_filter.title": "تصفية هذا المنشور",
"filter_modal.title.status": "تصفية منشور", "filter_modal.title.status": "تصفية منشور",
"filter_warning.matches_filter": "يطابق عامل التصفية “<span>{title}</span>”",
"filtered_notifications_banner.pending_requests": "من {count, plural, zero {}=0 {لا أحد} one {شخص واحد قد تعرفه} two {# شخص قد تعرفهما} few {# أشخاص قد تعرفهم} many {# أشخاص قد تعرفهم} other {# أشخاص قد تعرفهم}}",
"filtered_notifications_banner.title": "الإشعارات المصفاة", "filtered_notifications_banner.title": "الإشعارات المصفاة",
"firehose.all": "الكل", "firehose.all": "الكل",
"firehose.local": "هذا الخادم", "firehose.local": "هذا الخادم",
@ -323,6 +397,9 @@
"footer.terms_of_service": "شروط الخدمة", "footer.terms_of_service": "شروط الخدمة",
"generic.saved": "تم الحفظ", "generic.saved": "تم الحفظ",
"getting_started.heading": "استعدّ للبدء", "getting_started.heading": "استعدّ للبدء",
"hashtag.admin_moderation": "افتح الواجهة الإشراف لـ #{name}",
"hashtag.browse": "تصفح المنشورات التي تحتوي #{hashtag}",
"hashtag.browse_from_account": "تصفح المنشورات من @{name} التي تحتوي على #{hashtag}",
"hashtag.column_header.tag_mode.all": "و {additional}", "hashtag.column_header.tag_mode.all": "و {additional}",
"hashtag.column_header.tag_mode.any": "أو {additional}", "hashtag.column_header.tag_mode.any": "أو {additional}",
"hashtag.column_header.tag_mode.none": "بدون {additional}", "hashtag.column_header.tag_mode.none": "بدون {additional}",
@ -335,13 +412,19 @@
"hashtag.counter_by_accounts": "{count, plural, zero {لَا مُشارك} one {مُشارَك واحد} two {مُشارِكان إثنان} few {{counter} مشاركين} many {{counter} مُشاركًا} other {{counter} مُشارِك}}", "hashtag.counter_by_accounts": "{count, plural, zero {لَا مُشارك} one {مُشارَك واحد} two {مُشارِكان إثنان} few {{counter} مشاركين} many {{counter} مُشاركًا} other {{counter} مُشارِك}}",
"hashtag.counter_by_uses": "{count, plural, zero {لَا منشورات} one {منشور واحد} two {منشوران إثنان} few {{counter} منشورات} many {{counter} منشورًا} other {{counter} منشور}}", "hashtag.counter_by_uses": "{count, plural, zero {لَا منشورات} one {منشور واحد} two {منشوران إثنان} few {{counter} منشورات} many {{counter} منشورًا} other {{counter} منشور}}",
"hashtag.counter_by_uses_today": "{count, plural, zero {لَا منشورات} one {منشور واحد} two {منشوران إثنان} few {{counter} منشورات} many {{counter} منشورًا} other {{counter} منشور}}", "hashtag.counter_by_uses_today": "{count, plural, zero {لَا منشورات} one {منشور واحد} two {منشوران إثنان} few {{counter} منشورات} many {{counter} منشورًا} other {{counter} منشور}}",
"hashtag.feature": "اعرضه على صفحتك الشخصية",
"hashtag.follow": "اتبع الوسم", "hashtag.follow": "اتبع الوسم",
"hashtag.mute": "اكتم #{hashtag}",
"hashtag.unfeature": "أزله من العرض على الملف الشخصي",
"hashtag.unfollow": "ألغِ متابعة الوسم", "hashtag.unfollow": "ألغِ متابعة الوسم",
"hashtags.and_other": "…و {count, plural, zero {} one {# واحد آخر} two {# اثنان آخران} few {# آخرون} many {# آخَرًا}other {# آخرون}}", "hashtags.and_other": "…و {count, plural, zero {} one {# واحد آخر} two {# اثنان آخران} few {# آخرون} many {# آخَرًا}other {# آخرون}}",
"hints.profiles.followers_may_be_missing": "قد يكون الأشخاص الذي يتبعهم هذا الملف الشخصي ناقصين.",
"hints.profiles.follows_may_be_missing": "قد يكون المتابعين لهذا الملف الشخصي ناقصين.",
"hints.profiles.posts_may_be_missing": "قد تكون بعض المنشورات من هذا الملف الشخصي ناقصة.",
"hints.profiles.see_more_followers": "عرض المزيد من المتابعين على {domain}", "hints.profiles.see_more_followers": "عرض المزيد من المتابعين على {domain}",
"hints.profiles.see_more_follows": "اطلع على المزيد من المتابعين على {domain}",
"hints.profiles.see_more_posts": "عرض المزيد من المنشورات من {domain}", "hints.profiles.see_more_posts": "عرض المزيد من المنشورات من {domain}",
"hints.threads.replies_may_be_missing": "قد تكون الردود الواردة من الخوادم الأخرى غائبة.", "home.column_settings.show_quotes": "إظهار الاقتباسات",
"hints.threads.see_more": "اطلع على المزيد من الردود على {domain}",
"home.column_settings.show_reblogs": "اعرض المعاد نشرها", "home.column_settings.show_reblogs": "اعرض المعاد نشرها",
"home.column_settings.show_replies": "اعرض الردود", "home.column_settings.show_replies": "اعرض الردود",
"home.hide_announcements": "إخفاء الإعلانات", "home.hide_announcements": "إخفاء الإعلانات",
@ -351,9 +434,23 @@
"home.show_announcements": "إظهار الإعلانات", "home.show_announcements": "إظهار الإعلانات",
"ignore_notifications_modal.disclaimer": "لا يمكن لـ Mastodon إبلاغ المستخدمين بأنك قد تجاهلت إشعاراتهم. تجاهل الإشعارات لن يمنع إرسال الرسائل نفسها.", "ignore_notifications_modal.disclaimer": "لا يمكن لـ Mastodon إبلاغ المستخدمين بأنك قد تجاهلت إشعاراتهم. تجاهل الإشعارات لن يمنع إرسال الرسائل نفسها.",
"ignore_notifications_modal.filter_instead": "تصفيتها بدلا من ذلك", "ignore_notifications_modal.filter_instead": "تصفيتها بدلا من ذلك",
"ignore_notifications_modal.filter_to_act_users": "ستبقى قادراً على قبول المستخدمين أو رفضهم أو الإبلاغ عنهم",
"ignore_notifications_modal.filter_to_avoid_confusion": "التصفية تساعد على تجنب أي ارتباك",
"ignore_notifications_modal.filter_to_review_separately": "يمكنك مراجعة الإشعارات المصفاة بشكل منفصل",
"ignore_notifications_modal.ignore": "تجاهل الإشعارات", "ignore_notifications_modal.ignore": "تجاهل الإشعارات",
"ignore_notifications_modal.limited_accounts_title": "تجاهل الإشعارات من الحسابات التي هي تحت الإشراف؟", "ignore_notifications_modal.limited_accounts_title": "تجاهل الإشعارات من الحسابات التي هي تحت الإشراف؟",
"ignore_notifications_modal.new_accounts_title": "تجاهل الإشعارات الصادرة من الحسابات الجديدة؟", "ignore_notifications_modal.new_accounts_title": "تجاهل الإشعارات الصادرة من الحسابات الجديدة؟",
"ignore_notifications_modal.not_followers_title": "تجاهل الإشعارات من أشخاص لا يتابعونك؟",
"ignore_notifications_modal.not_following_title": "تجاهل الإشعارات من أشخاص لا تتابعهم؟",
"ignore_notifications_modal.private_mentions_title": "تجاهل الإشعارات للرسائل التي لم تطلبها؟",
"info_button.label": "المساعدة",
"info_button.what_is_alt_text": "<h1> ماهو النص البديل؟</h1><p> يوفر النص البديل أوصافا للصور للأشخاص الذين يعانون من إعاقات بصرية أو اتصالات شبكة ضعيفة أو أولئك الذين يبحثون عن سياق إضافي.</p><p> يمكنك تحسين إمكانية الوصول والفهم للجميع من خلال كتابة نص بديل واضح وموجز وموضوعي. </p><ul><li> حدد العناصر المهمة</li><li>لخص النص في الصور</li><li>استخدام بنية الجمل العادية</li><li>تجنب المعلومات الزائدة</li><li> ركز على الاتجاهات والنتائج الرئيسية في العناصر المرئية المعقدة (مثل الرسوم البيانية أو الخرائط)</li></ul>",
"interaction_modal.action.favourite": "للمتابعة، تحتاج إلى تفضيل المنشور من حسابك.",
"interaction_modal.action.follow": "للمتابعة، تحتاج إلى متابعة المنشور من حسابك.",
"interaction_modal.action.reblog": "للمتابعة، تحتاج إلى إعادة نشر المنشور من حسابك.",
"interaction_modal.action.reply": "للمتابعة، تحتاج إلى الرد من حسابك.",
"interaction_modal.action.vote": "للمتابعة، تحتاج إلى التصويت من حسابك.",
"interaction_modal.go": "اذهب",
"interaction_modal.no_account_yet": "لا تملك حساباً بعد؟", "interaction_modal.no_account_yet": "لا تملك حساباً بعد؟",
"interaction_modal.on_another_server": "على خادم مختلف", "interaction_modal.on_another_server": "على خادم مختلف",
"interaction_modal.on_this_server": "على هذا الخادم", "interaction_modal.on_this_server": "على هذا الخادم",
@ -361,6 +458,8 @@
"interaction_modal.title.follow": "اتبع {name}", "interaction_modal.title.follow": "اتبع {name}",
"interaction_modal.title.reblog": "إعادة نشر منشور {name}", "interaction_modal.title.reblog": "إعادة نشر منشور {name}",
"interaction_modal.title.reply": "الرد على منشور {name}", "interaction_modal.title.reply": "الرد على منشور {name}",
"interaction_modal.title.vote": "صوّت في استطلاع {name}",
"interaction_modal.username_prompt": "مثلاً {example}",
"intervals.full.days": "{number, plural, one {# يوم} other {# أيام}}", "intervals.full.days": "{number, plural, one {# يوم} other {# أيام}}",
"intervals.full.hours": "{number, plural, one {# ساعة} other {# ساعات}}", "intervals.full.hours": "{number, plural, one {# ساعة} other {# ساعات}}",
"intervals.full.minutes": "{number, plural, one {دقيقة واحدة}two {دقيقتان} other {# دقائق}}", "intervals.full.minutes": "{number, plural, one {دقيقة واحدة}two {دقيقتان} other {# دقائق}}",
@ -396,30 +495,44 @@
"keyboard_shortcuts.toggle_hidden": "لعرض أو إخفاء النص مِن وراء التحذير", "keyboard_shortcuts.toggle_hidden": "لعرض أو إخفاء النص مِن وراء التحذير",
"keyboard_shortcuts.toggle_sensitivity": "لعرض/إخفاء الوسائط", "keyboard_shortcuts.toggle_sensitivity": "لعرض/إخفاء الوسائط",
"keyboard_shortcuts.toot": "للشروع في تحرير منشور جديد", "keyboard_shortcuts.toot": "للشروع في تحرير منشور جديد",
"keyboard_shortcuts.translate": "لترجمة منشور",
"keyboard_shortcuts.unfocus": "لإلغاء التركيز على حقل النص أو نافذة البحث", "keyboard_shortcuts.unfocus": "لإلغاء التركيز على حقل النص أو نافذة البحث",
"keyboard_shortcuts.up": "للانتقال إلى أعلى القائمة", "keyboard_shortcuts.up": "للانتقال إلى أعلى القائمة",
"lightbox.close": "إغلاق", "lightbox.close": "إغلاق",
"lightbox.next": "التالي", "lightbox.next": "التالي",
"lightbox.previous": "العودة", "lightbox.previous": "العودة",
"lightbox.zoom_in": "التكبير إلى الحجم الفعلي",
"lightbox.zoom_out": "التكبير ليناسب الحجم",
"limited_account_hint.action": "إظهار الملف التعريفي على أي حال", "limited_account_hint.action": "إظهار الملف التعريفي على أي حال",
"limited_account_hint.title": "تم إخفاء هذا الملف الشخصي من قبل مشرفي {domain}.", "limited_account_hint.title": "تم إخفاء هذا الملف الشخصي من قبل مشرفي {domain}.",
"link_preview.author": "مِن {name}", "link_preview.author": "مِن {name}",
"link_preview.more_from_author": "المزيد من {name}", "link_preview.more_from_author": "المزيد من {name}",
"link_preview.shares": "{count, plural, zero {{counter} منشور}one {{counter} منشور} two {{counter} منشور} few {{counter} منشور} many {{counter} منشور} other {{counter} منشور}}",
"lists.add_member": "إضافة", "lists.add_member": "إضافة",
"lists.add_to_list": "إضافة إلى القائمة", "lists.add_to_list": "إضافة إلى القائمة",
"lists.add_to_lists": "إضافة {name} إلى القوائم", "lists.add_to_lists": "إضافة {name} إلى القوائم",
"lists.create": "إنشاء", "lists.create": "إنشاء",
"lists.create_a_list_to_organize": "أنشئ قائمة جديدة لتنظم الصفحة الرئيسة خاصتك",
"lists.create_list": "إنشاء قائمة", "lists.create_list": "إنشاء قائمة",
"lists.delete": "احذف القائمة", "lists.delete": "احذف القائمة",
"lists.done": "تمّ", "lists.done": "تمّ",
"lists.edit": "عدّل القائمة", "lists.edit": "عدّل القائمة",
"lists.exclusive": "إخفاء الأعضاء في الصفحة الرئيسية", "lists.exclusive": "إخفاء الأعضاء في الصفحة الرئيسية",
"lists.exclusive_hint": "إذا يوجد شخص في هذه القائمة، فقم بإخفائه في صفحتك الرئيسة لتجنب رؤية منشوراته مرتين.",
"lists.find_users_to_add": "ابحث عن مستخدمين للإضافة",
"lists.list_members_count": "{count, plural, zero {}one {# عضو} two {# عضو} few {# عضو} many {# عضو} other {# عضو}}",
"lists.list_name": "اسم القائمة",
"lists.new_list_name": "اسم القائمة الجديدة",
"lists.no_lists_yet": "لا توجد قوائم بعد.",
"lists.no_members_yet": "لا أعضاء حتى الآن.",
"lists.no_results_found": "لم يتمّ العثور على أي نتيجة.",
"lists.remove_member": "إزالة", "lists.remove_member": "إزالة",
"lists.replies_policy.followed": "أي مستخدم متابَع", "lists.replies_policy.followed": "أي مستخدم متابَع",
"lists.replies_policy.list": "أعضاء القائمة", "lists.replies_policy.list": "أعضاء القائمة",
"lists.replies_policy.none": "لا أحد", "lists.replies_policy.none": "لا أحد",
"lists.save": "حفظ", "lists.save": "حفظ",
"lists.search": "بحث", "lists.search": "بحث",
"lists.show_replies_to": "تضمين الردود من أعضاء القائمة إلى",
"load_pending": "{count, plural, one {# عنصر جديد} other {# عناصر جديدة}}", "load_pending": "{count, plural, one {# عنصر جديد} other {# عناصر جديدة}}",
"loading_indicator.label": "جاري التحميل…", "loading_indicator.label": "جاري التحميل…",
"media_gallery.hide": "إخفاء", "media_gallery.hide": "إخفاء",
@ -434,8 +547,10 @@
"mute_modal.you_wont_see_mentions": "لن تر المنشورات التي يُشار فيها إليه.", "mute_modal.you_wont_see_mentions": "لن تر المنشورات التي يُشار فيها إليه.",
"mute_modal.you_wont_see_posts": "سيكون بإمكانه رؤية منشوراتك، لكنك لن ترى منشوراته.", "mute_modal.you_wont_see_posts": "سيكون بإمكانه رؤية منشوراتك، لكنك لن ترى منشوراته.",
"navigation_bar.about": "عن", "navigation_bar.about": "عن",
"navigation_bar.account_settings": "كلمة المرور والأمان",
"navigation_bar.administration": "الإدارة", "navigation_bar.administration": "الإدارة",
"navigation_bar.advanced_interface": "افتحه في واجهة الويب المتقدمة", "navigation_bar.advanced_interface": "افتحه في واجهة الويب المتقدمة",
"navigation_bar.automated_deletion": "الحذف الآلي للمنشورات",
"navigation_bar.blocks": "الحسابات المحجوبة", "navigation_bar.blocks": "الحسابات المحجوبة",
"navigation_bar.bookmarks": "الفواصل المرجعية", "navigation_bar.bookmarks": "الفواصل المرجعية",
"navigation_bar.direct": "الإشارات الخاصة", "navigation_bar.direct": "الإشارات الخاصة",
@ -445,19 +560,41 @@
"navigation_bar.follow_requests": "طلبات المتابعة", "navigation_bar.follow_requests": "طلبات المتابعة",
"navigation_bar.followed_tags": "الوسوم المتابَعة", "navigation_bar.followed_tags": "الوسوم المتابَعة",
"navigation_bar.follows_and_followers": "المتابِعون والمتابَعون", "navigation_bar.follows_and_followers": "المتابِعون والمتابَعون",
"navigation_bar.import_export": "الاستيراد والتصدير",
"navigation_bar.lists": "القوائم", "navigation_bar.lists": "القوائم",
"navigation_bar.live_feed_local": "البث الحي للمنشورات المحلية",
"navigation_bar.live_feed_public": "البث الحي للمنشورات العالمية",
"navigation_bar.logout": "خروج", "navigation_bar.logout": "خروج",
"navigation_bar.moderation": "الإشراف", "navigation_bar.moderation": "الإشراف",
"navigation_bar.more": "المزيد",
"navigation_bar.mutes": "الحسابات المكتومة", "navigation_bar.mutes": "الحسابات المكتومة",
"navigation_bar.opened_in_classic_interface": "تُفتَح المنشورات والحسابات وغيرها من الصفحات الخاصة بشكل مبدئي على واجهة الويب التقليدية.", "navigation_bar.opened_in_classic_interface": "تُفتَح المنشورات والحسابات وغيرها من الصفحات الخاصة بشكل مبدئي على واجهة الويب التقليدية.",
"navigation_bar.preferences": "التفضيلات", "navigation_bar.preferences": "التفضيلات",
"navigation_bar.privacy_and_reach": "الخصوصية و الوصول",
"navigation_bar.search": "البحث", "navigation_bar.search": "البحث",
"navigation_bar.search_trends": "البحث / الرائج",
"navigation_panel.collapse_followed_tags": "طي قائمة الهاشتاقات المتابعة",
"navigation_panel.collapse_lists": "طي قائمة القائمة",
"navigation_panel.expand_followed_tags": "توسيع قائمة الهاشتاقات المتابعة",
"navigation_panel.expand_lists": "توسيع قائمة القائمة",
"not_signed_in_indicator.not_signed_in": "تحتاج إلى تسجيل الدخول للوصول إلى هذا المصدر.", "not_signed_in_indicator.not_signed_in": "تحتاج إلى تسجيل الدخول للوصول إلى هذا المصدر.",
"notification.admin.report": "{name} أبلغ عن {target}", "notification.admin.report": "{name} أبلغ عن {target}",
"notification.admin.report_account": "{name} أبلغ عن {count, plural, zero {}one {منشور} two {منشورين} few {# منشورات} many {# منشورات} other {# منشورات}} من قبل {target} بسبب {category}",
"notification.admin.report_account_other": "{name} أبلغ عن {count, plural, zero {}one {منشور} two {منشورين} few {# منشورات} many {# منشورات} other {# منشورات}} من قبل {target}",
"notification.admin.report_statuses": "{name} أبلغ عن {target} بسبب {category}",
"notification.admin.report_statuses_other": "{name} أبلغ عن {target}",
"notification.admin.sign_up": "أنشأ {name} حسابًا", "notification.admin.sign_up": "أنشأ {name} حسابًا",
"notification.admin.sign_up.name_and_others": "{name} و{count, plural, zero {}one {شخص آخر قاما} two {# آخرون قاموا} few {# آخرون قاموا} many {# آخرون قاموا} other {# آخرون قاموا}} بالتسجيل",
"notification.annual_report.message": "إن #Wrapstodon الخاص بك لسنة {year} ينتظرك! تعرّف إلى النقاط البارزة واللحظات التي لا تنسى على ماستدون!",
"notification.annual_report.view": "عرض #Wrapstodon",
"notification.favourite": "أضاف {name} منشورك إلى مفضلته", "notification.favourite": "أضاف {name} منشورك إلى مفضلته",
"notification.favourite.name_and_others_with_link": "{name} و<a>{count, plural, zero {}one {شخص آخر} two {شخصان آخرين} few {# أشخاص آخرون} many {# أشخاص آخرون} other {# أشخاص آخرون}}</a> قاموا بتفضيل منشورك",
"notification.favourite_pm": "قام {name} بتفضيل إشارتك الخاصة",
"notification.favourite_pm.name_and_others_with_link": "{name} و<a>{count, plural, zero {}one {شخص آخر} two {شخصان آخرَين} few {# أشخاص آخرون} many {# أشخاص آخرون} other {# أشخاص آخرون}}</a> قاموا بتفضيل إشارتك الخاصة",
"notification.follow": "يتابعك {name}", "notification.follow": "يتابعك {name}",
"notification.follow.name_and_others": "{name} و<a>{count, plural, zero {}one {شخص آخر} two {شخصان آخرين} few {# أشخاص آخرون} many {# أشخاص آخرون} other {# أشخاص آخرون}}</a> قاموا بمتابعتك",
"notification.follow_request": "لقد طلب {name} متابعتك", "notification.follow_request": "لقد طلب {name} متابعتك",
"notification.follow_request.name_and_others": "{name} و{count, plural, zero {}one {شخص آخر} two {شخصان آخرين} few {# أشخاص آخرون} many {# أشخاص آخرون} other {# أشخاص آخرون}} أرسلوا طلب متابعة لك",
"notification.label.mention": "إشارة", "notification.label.mention": "إشارة",
"notification.label.private_mention": "إشارة خاصة", "notification.label.private_mention": "إشارة خاصة",
"notification.label.private_reply": "رد خاص", "notification.label.private_reply": "رد خاص",
@ -476,6 +613,7 @@
"notification.own_poll": "انتهى استطلاعك للرأي", "notification.own_poll": "انتهى استطلاعك للرأي",
"notification.poll": "لقد انتهى استطلاع رأي صوتت فيه", "notification.poll": "لقد انتهى استطلاع رأي صوتت فيه",
"notification.reblog": "قام {name} بمشاركة منشورك", "notification.reblog": "قام {name} بمشاركة منشورك",
"notification.reblog.name_and_others_with_link": "{name} و<a>{count, plural, zero {}one {شخص آخر} two {شخصان آخرين} few {# أشخاص آخرون} many {# أشخاص آخرون} other {# أشخاص آخرون}}</a> قاموا بإعادة نشر منشورك",
"notification.relationships_severance_event": "فقدت الاتصالات مع {name}", "notification.relationships_severance_event": "فقدت الاتصالات مع {name}",
"notification.relationships_severance_event.account_suspension": "قام مشرف من {from} بتعليق {target}، مما يعني أنك لم يعد بإمكانك تلقي التحديثات منهم أو التفاعل معهم.", "notification.relationships_severance_event.account_suspension": "قام مشرف من {from} بتعليق {target}، مما يعني أنك لم يعد بإمكانك تلقي التحديثات منهم أو التفاعل معهم.",
"notification.relationships_severance_event.domain_block": "قام مشرف من {from} بحظر {target}، بما في ذلك {followersCount} من متابعينك و {followingCount, plural, one {# حساب} other {# حسابات}} تتابعها.", "notification.relationships_severance_event.domain_block": "قام مشرف من {from} بحظر {target}، بما في ذلك {followersCount} من متابعينك و {followingCount, plural, one {# حساب} other {# حسابات}} تتابعها.",
@ -484,12 +622,20 @@
"notification.status": "{name} نشر للتو", "notification.status": "{name} نشر للتو",
"notification.update": "عدّلَ {name} منشورًا", "notification.update": "عدّلَ {name} منشورًا",
"notification_requests.accept": "موافقة", "notification_requests.accept": "موافقة",
"notification_requests.accept_multiple": "قبول {count, plural, zero {}one {طلب واحد…} two {# طلب…} few {# طلبات…} many {# طلبات…} other {# طلبات…}}",
"notification_requests.confirm_accept_multiple.button": "قبول {count, plural, zero {}one {الطلب} two {2 طلب} few {الطلبات} many {الطلبات} other {الطلبات}}",
"notification_requests.confirm_accept_multiple.message": "أنت على وشك قبول {count, plural, zero {}one {طلب إشعار واحد} two {# طلبات إشعار} few {# طلبات إشعار} many {# طلبات إشعار} other {# طلبات إشعار}}. هل أنت متأكد من أنك تريد المتابعة؟",
"notification_requests.confirm_accept_multiple.title": "قبول طلبات الإشعار؟", "notification_requests.confirm_accept_multiple.title": "قبول طلبات الإشعار؟",
"notification_requests.confirm_dismiss_multiple.button": "رفض {count, plural, zero {}one {الطلب} two {2 طلب} few {الطلبات} many {الطلبات} other {الطلبات}}",
"notification_requests.confirm_dismiss_multiple.message": "أنت على وشك رفض {count, plural, zero {}one {طلب إشعار واحد} two {# طلبات إشعار} few {# طلبات إشعار} many {# طلبات إشعار} other {# طلبات إشعار}}. لن تتمكن من الوصول بسهولة {count, plural, zero {}one {إليه} two {إليهما} few {إليهم} many {إليهم} other {إليهم}} مرة أخرى. هل أنت متأكد من أنك تريد المتابعة؟",
"notification_requests.confirm_dismiss_multiple.title": "تجاهل طلبات الإشعار؟", "notification_requests.confirm_dismiss_multiple.title": "تجاهل طلبات الإشعار؟",
"notification_requests.dismiss": "تخطي", "notification_requests.dismiss": "تخطي",
"notification_requests.dismiss_multiple": "رفض {count, plural, zero {}one {# طلب…} two {# طلب…} few {# طلبات…} many {# طلبات…} other {# طلبات…}}",
"notification_requests.edit_selection": "تعديل", "notification_requests.edit_selection": "تعديل",
"notification_requests.exit_selection": "تمّ", "notification_requests.exit_selection": "تمّ",
"notification_requests.explainer_for_limited_account": "تم تصفية الإشعارات من هذا الحساب لأن الحساب تم تقييده من قبل مشرف.", "notification_requests.explainer_for_limited_account": "تم تصفية الإشعارات من هذا الحساب لأن الحساب تم تقييده من قبل مشرف.",
"notification_requests.explainer_for_limited_remote_account": "تم تصفية الإشعارات من هذا الحساب لأنه أو لأن خادمه مقيد من قبل مشرف.",
"notification_requests.maximize": "تكبير",
"notification_requests.minimize_banner": "تصغير شريط الإشعارات المُصفاة", "notification_requests.minimize_banner": "تصغير شريط الإشعارات المُصفاة",
"notification_requests.notifications_from": "إشعارات من {name}", "notification_requests.notifications_from": "إشعارات من {name}",
"notification_requests.title": "الإشعارات المصفاة", "notification_requests.title": "الإشعارات المصفاة",
@ -505,6 +651,7 @@
"notifications.column_settings.filter_bar.category": "شريط التصفية السريعة", "notifications.column_settings.filter_bar.category": "شريط التصفية السريعة",
"notifications.column_settings.follow": "متابعُون جُدُد:", "notifications.column_settings.follow": "متابعُون جُدُد:",
"notifications.column_settings.follow_request": "الطلبات الجديدة لِمتابَعتك:", "notifications.column_settings.follow_request": "الطلبات الجديدة لِمتابَعتك:",
"notifications.column_settings.group": "قم بتجميعهم",
"notifications.column_settings.mention": "الإشارات:", "notifications.column_settings.mention": "الإشارات:",
"notifications.column_settings.poll": "نتائج استطلاع الرأي:", "notifications.column_settings.poll": "نتائج استطلاع الرأي:",
"notifications.column_settings.push": "الإشعارات", "notifications.column_settings.push": "الإشعارات",
@ -531,7 +678,9 @@
"notifications.policy.accept": "قبول", "notifications.policy.accept": "قبول",
"notifications.policy.accept_hint": "إظهار في الإشعارات", "notifications.policy.accept_hint": "إظهار في الإشعارات",
"notifications.policy.drop": "تجاهل", "notifications.policy.drop": "تجاهل",
"notifications.policy.drop_hint": "التخلص منها بشكل دائم",
"notifications.policy.filter": "تصفية", "notifications.policy.filter": "تصفية",
"notifications.policy.filter_hint": "إرسال إلى صندوق الإشعارات المصفاة",
"notifications.policy.filter_limited_accounts_hint": "المحدودة من قبل مشرفي الخادم", "notifications.policy.filter_limited_accounts_hint": "المحدودة من قبل مشرفي الخادم",
"notifications.policy.filter_limited_accounts_title": "حسابات تحت الإشراف", "notifications.policy.filter_limited_accounts_title": "حسابات تحت الإشراف",
"notifications.policy.filter_new_accounts.hint": "تم إنشاؤها منذ {days, plural, zero {}one {يوم واحد} two {يومان} few {# أيام} many {# أيام} other {# أيام}}", "notifications.policy.filter_new_accounts.hint": "تم إنشاؤها منذ {days, plural, zero {}one {يوم واحد} two {يومان} few {# أيام} many {# أيام} other {# أيام}}",
@ -546,7 +695,11 @@
"notifications_permission_banner.enable": "تفعيل إشعارات سطح المكتب", "notifications_permission_banner.enable": "تفعيل إشعارات سطح المكتب",
"notifications_permission_banner.how_to_control": "لتلقي الإشعارات عندما لا يكون ماستدون مفتوح، قم بتفعيل إشعارات سطح المكتب، يمكنك التحكم بدقة في أنواع التفاعلات التي تولد إشعارات سطح المكتب من خلال زر الـ{icon} أعلاه بمجرد تفعيلها.", "notifications_permission_banner.how_to_control": "لتلقي الإشعارات عندما لا يكون ماستدون مفتوح، قم بتفعيل إشعارات سطح المكتب، يمكنك التحكم بدقة في أنواع التفاعلات التي تولد إشعارات سطح المكتب من خلال زر الـ{icon} أعلاه بمجرد تفعيلها.",
"notifications_permission_banner.title": "لا تفوت شيئاً أبداً", "notifications_permission_banner.title": "لا تفوت شيئاً أبداً",
"onboarding.follows.back": "عودة",
"onboarding.follows.done": "تمّ",
"onboarding.follows.empty": "نأسف، لا يمكن عرض نتائج في الوقت الحالي. جرب البحث أو انتقل لصفحة الاستكشاف لإيجاد أشخاص للمتابعة، أو حاول مرة أخرى.", "onboarding.follows.empty": "نأسف، لا يمكن عرض نتائج في الوقت الحالي. جرب البحث أو انتقل لصفحة الاستكشاف لإيجاد أشخاص للمتابعة، أو حاول مرة أخرى.",
"onboarding.follows.search": "بحث",
"onboarding.follows.title": "للبدء قم بمتابعة أشخاص",
"onboarding.profile.discoverable": "اجعل ملفي الشخصي قابلاً للاكتشاف", "onboarding.profile.discoverable": "اجعل ملفي الشخصي قابلاً للاكتشاف",
"onboarding.profile.discoverable_hint": "عندما تختار تفعيل إمكانية الاكتشاف على ماستدون، قد تظهر منشوراتك في نتائج البحث والمواضيع الرائجة، وقد يتم اقتراح ملفك الشخصي لأشخاص ذوي اهتمامات مماثلة معك.", "onboarding.profile.discoverable_hint": "عندما تختار تفعيل إمكانية الاكتشاف على ماستدون، قد تظهر منشوراتك في نتائج البحث والمواضيع الرائجة، وقد يتم اقتراح ملفك الشخصي لأشخاص ذوي اهتمامات مماثلة معك.",
"onboarding.profile.display_name": "الاسم العلني", "onboarding.profile.display_name": "الاسم العلني",
@ -572,6 +725,7 @@
"poll_button.remove_poll": "إزالة استطلاع الرأي", "poll_button.remove_poll": "إزالة استطلاع الرأي",
"privacy.change": "اضبط خصوصية المنشور", "privacy.change": "اضبط خصوصية المنشور",
"privacy.direct.long": "كل من ذُكر في المنشور", "privacy.direct.long": "كل من ذُكر في المنشور",
"privacy.direct.short": "إشارة خاصة",
"privacy.private.long": "متابعيك فقط", "privacy.private.long": "متابعيك فقط",
"privacy.private.short": "للمتابِعين", "privacy.private.short": "للمتابِعين",
"privacy.public.long": "أي شخص على أو خارج ماستدون", "privacy.public.long": "أي شخص على أو خارج ماستدون",
@ -583,6 +737,8 @@
"privacy_policy.title": "سياسة الخصوصية", "privacy_policy.title": "سياسة الخصوصية",
"recommended": "موصى به", "recommended": "موصى به",
"refresh": "أنعِش", "refresh": "أنعِش",
"regeneration_indicator.please_stand_by": "الرجاء الانتظار.",
"regeneration_indicator.preparing_your_home_feed": "جارٍ إعداد صفحتك الرئيسة…",
"relative_time.days": "{number}ي", "relative_time.days": "{number}ي",
"relative_time.full.days": "منذ {number, plural, zero {} one {# يوم} two {# يومين} few {# أيام} many {# أيام} other {# يوم}}", "relative_time.full.days": "منذ {number, plural, zero {} one {# يوم} two {# يومين} few {# أيام} many {# أيام} other {# يوم}}",
"relative_time.full.hours": "منذ {number, plural, zero {} one {ساعة واحدة} two {ساعتَيْن} few {# ساعات} many {# ساعة} other {# ساعة}}", "relative_time.full.hours": "منذ {number, plural, zero {} one {ساعة واحدة} two {ساعتَيْن} few {# ساعات} many {# ساعة} other {# ساعة}}",
@ -647,6 +803,7 @@
"report_notification.categories.violation": "القاعدة المنتهَكة", "report_notification.categories.violation": "القاعدة المنتهَكة",
"report_notification.categories.violation_sentence": "انتهاك لقاعدة", "report_notification.categories.violation_sentence": "انتهاك لقاعدة",
"report_notification.open": "فتح التقرير", "report_notification.open": "فتح التقرير",
"search.clear": "مسح البحث",
"search.no_recent_searches": "ما من عمليات بحث تمت مؤخرًا", "search.no_recent_searches": "ما من عمليات بحث تمت مؤخرًا",
"search.placeholder": "ابحث", "search.placeholder": "ابحث",
"search.quick_action.account_search": "الملفات التعريفية المطابقة لـ {x}", "search.quick_action.account_search": "الملفات التعريفية المطابقة لـ {x}",
@ -666,14 +823,19 @@
"search_results.accounts": "الصفحات التعريفية", "search_results.accounts": "الصفحات التعريفية",
"search_results.all": "الكل", "search_results.all": "الكل",
"search_results.hashtags": "الوُسوم", "search_results.hashtags": "الوُسوم",
"search_results.no_results": "لا توجد نتائج.",
"search_results.no_search_yet": "حاول البحث عن المنشورات، ملفات الشخصية أو الهاشتاقات.",
"search_results.see_all": "رؤية الكل", "search_results.see_all": "رؤية الكل",
"search_results.statuses": "المنشورات", "search_results.statuses": "المنشورات",
"search_results.title": "البحث عن \"{q}\"",
"server_banner.about_active_users": "الأشخاص الذين يستخدمون هذا الخادم خلال الأيام الثلاثين الأخيرة (المستخدمون النشطون شهريًا)", "server_banner.about_active_users": "الأشخاص الذين يستخدمون هذا الخادم خلال الأيام الثلاثين الأخيرة (المستخدمون النشطون شهريًا)",
"server_banner.active_users": "مستخدم نشط", "server_banner.active_users": "مستخدم نشط",
"server_banner.administered_by": "يُديره:", "server_banner.administered_by": "يُديره:",
"server_banner.is_one_of_many": "{domain} هو واحد من بين العديد من خوادم ماستدون المستقلة التي يمكنك استخدامها للمشاركة في الفديفرس.", "server_banner.is_one_of_many": "{domain} هو واحد من بين العديد من خوادم ماستدون المستقلة التي يمكنك استخدامها للمشاركة في الفديفرس.",
"server_banner.server_stats": "إحصائيات الخادم:", "server_banner.server_stats": "إحصائيات الخادم:",
"sign_in_banner.create_account": "أنشئ حسابًا", "sign_in_banner.create_account": "أنشئ حسابًا",
"sign_in_banner.follow_anyone": "تابع أي شخص من عالم الفدرالية وشاهد منشوراته بالترتيب الزمني. دون خوارزميات أو إعلانات أو عنواين مضللة.",
"sign_in_banner.mastodon_is": "ماستودون هو أفضل وسيلة لمواكبة الأحداث.",
"sign_in_banner.sign_in": "تسجيل الدخول", "sign_in_banner.sign_in": "تسجيل الدخول",
"sign_in_banner.sso_redirect": "تسجيل الدخول أو إنشاء حساب", "sign_in_banner.sso_redirect": "تسجيل الدخول أو إنشاء حساب",
"status.admin_account": "افتح الواجهة الإدارية لـ @{name}", "status.admin_account": "افتح الواجهة الإدارية لـ @{name}",
@ -708,6 +870,13 @@
"status.mute_conversation": "كتم المحادثة", "status.mute_conversation": "كتم المحادثة",
"status.open": "وسّع هذا المنشور", "status.open": "وسّع هذا المنشور",
"status.pin": "دبّسه على الصفحة التعريفية", "status.pin": "دبّسه على الصفحة التعريفية",
"status.quote_error.filtered": "مُخفي بسبب إحدى إعدادات التصفية خاصتك",
"status.quote_error.not_found": "لا يمكن عرض هذا المنشور.",
"status.quote_error.pending_approval": "هذا المنشور ينتظر موافقة صاحب المنشور الأصلي.",
"status.quote_error.rejected": "لا يمكن عرض هذا المنشور لأن صاحب المنشور الأصلي لا يسمح له بأن يكون مقتبس.",
"status.quote_error.removed": "تمت إزالة المنشور من قبل صاحبه.",
"status.quote_error.unauthorized": "لا يمكن عرض هذا المنشور لأنك لست مخولاً برؤيته.",
"status.quote_post_author": "منشور من {name}",
"status.read_more": "اقرأ المزيد", "status.read_more": "اقرأ المزيد",
"status.reblog": "إعادة النشر", "status.reblog": "إعادة النشر",
"status.reblog_private": "إعادة النشر إلى الجمهور الأصلي", "status.reblog_private": "إعادة النشر إلى الجمهور الأصلي",
@ -716,6 +885,7 @@
"status.reblogs.empty": "لم يقم أي أحد بمشاركة هذا المنشور بعد. عندما يقوم أحدهم بذلك سوف يظهر هنا.", "status.reblogs.empty": "لم يقم أي أحد بمشاركة هذا المنشور بعد. عندما يقوم أحدهم بذلك سوف يظهر هنا.",
"status.redraft": "إزالة وإعادة الصياغة", "status.redraft": "إزالة وإعادة الصياغة",
"status.remove_bookmark": "احذفه مِن الفواصل المرجعية", "status.remove_bookmark": "احذفه مِن الفواصل المرجعية",
"status.remove_favourite": "إزالة من التفضيلات",
"status.replied_in_thread": "رد في خيط", "status.replied_in_thread": "رد في خيط",
"status.replied_to": "رَدًا على {name}", "status.replied_to": "رَدًا على {name}",
"status.reply": "ردّ", "status.reply": "ردّ",
@ -736,8 +906,13 @@
"subscribed_languages.save": "حفظ التغييرات", "subscribed_languages.save": "حفظ التغييرات",
"subscribed_languages.target": "تغيير اللغات المشتركة لـ {target}", "subscribed_languages.target": "تغيير اللغات المشتركة لـ {target}",
"tabs_bar.home": "الرئيسية", "tabs_bar.home": "الرئيسية",
"tabs_bar.menu": "القائمة",
"tabs_bar.notifications": "الإشعارات", "tabs_bar.notifications": "الإشعارات",
"tabs_bar.publish": "منشور جديد",
"tabs_bar.search": "ابحث",
"terms_of_service.effective_as_of": "مطبق اعتباراً من {date}",
"terms_of_service.title": "شروط الخدمة", "terms_of_service.title": "شروط الخدمة",
"terms_of_service.upcoming_changes_on": "تغييرات قادمة في تاريخ {date}",
"time_remaining.days": "{number, plural, one {# يوم} other {# أيام}} متبقية", "time_remaining.days": "{number, plural, one {# يوم} other {# أيام}} متبقية",
"time_remaining.hours": "{number, plural, one {# ساعة} other {# ساعات}} متبقية", "time_remaining.hours": "{number, plural, one {# ساعة} other {# ساعات}} متبقية",
"time_remaining.minutes": "{number, plural, one {# دقيقة} other {# دقائق}} متبقية", "time_remaining.minutes": "{number, plural, one {# دقيقة} other {# دقائق}} متبقية",
@ -753,6 +928,11 @@
"upload_button.label": "إضافة وسائط", "upload_button.label": "إضافة وسائط",
"upload_error.limit": "لقد تم بلوغ الحد الأقصى المسموح به لإرسال الملفات.", "upload_error.limit": "لقد تم بلوغ الحد الأقصى المسموح به لإرسال الملفات.",
"upload_error.poll": "لا يمكن إدراج ملفات في استطلاعات الرأي.", "upload_error.poll": "لا يمكن إدراج ملفات في استطلاعات الرأي.",
"upload_form.drag_and_drop.instructions": "لحمل مرفق، اضغط على space أو Enter. وفي أثناء السحب، استخدم مفاتيح الأسهم لتنقل المرفق في أية اتجاه. اضغط على Space أو Enter مجدداً لتنقل المرفق إلى موضعه الجديد، أو اضغط Escape للإلغاء.",
"upload_form.drag_and_drop.on_drag_cancel": "تم إلغاء السحب. تم إسقاط مرفقات الوسائط {item}.",
"upload_form.drag_and_drop.on_drag_end": "تم إضافة المرفق {item}.",
"upload_form.drag_and_drop.on_drag_over": "تم نقل مرفق الوسائط {item}.",
"upload_form.drag_and_drop.on_drag_start": "تم إضافة المرفق {item}.",
"upload_form.edit": "تعديل", "upload_form.edit": "تعديل",
"upload_progress.label": "يرفع...", "upload_progress.label": "يرفع...",
"upload_progress.processing": "تتم المعالجة…", "upload_progress.processing": "تتم المعالجة…",
@ -763,6 +943,12 @@
"video.expand": "توسيع الفيديو", "video.expand": "توسيع الفيديو",
"video.fullscreen": "ملء الشاشة", "video.fullscreen": "ملء الشاشة",
"video.hide": "إخفاء الفيديو", "video.hide": "إخفاء الفيديو",
"video.mute": "كتم",
"video.pause": "إيقاف مؤقت", "video.pause": "إيقاف مؤقت",
"video.play": "تشغيل" "video.play": "تشغيل",
"video.skip_backward": "تخطى إلى الوراء",
"video.skip_forward": "تخطي للأمام",
"video.unmute": "إلغاء الكتم",
"video.volume_down": "خفض الصوت",
"video.volume_up": "رفع الصوت"
} }

View File

@ -266,7 +266,6 @@
"hashtag.counter_by_accounts": "{count, plural, one {{counter} participante} other {{counter} participantes}}", "hashtag.counter_by_accounts": "{count, plural, one {{counter} participante} other {{counter} participantes}}",
"hashtag.follow": "Siguir a la etiqueta", "hashtag.follow": "Siguir a la etiqueta",
"hashtag.unfollow": "Dexar de siguir a la etiqueta", "hashtag.unfollow": "Dexar de siguir a la etiqueta",
"hints.threads.replies_may_be_missing": "Ye posible que falten les rempuestes d'otros sirvidores.",
"home.column_settings.show_reblogs": "Amosar los artículos compartíos", "home.column_settings.show_reblogs": "Amosar los artículos compartíos",
"home.column_settings.show_replies": "Amosar les rempuestes", "home.column_settings.show_replies": "Amosar les rempuestes",
"home.pending_critical_update.body": "¡Anueva'l sirvidor de Mastodon namás que puedas!", "home.pending_critical_update.body": "¡Anueva'l sirvidor de Mastodon namás que puedas!",

View File

@ -1,6 +1,7 @@
{ {
"about.blocks": "Мадэраваныя серверы", "about.blocks": "Мадэраваныя серверы",
"about.contact": "Кантакт:", "about.contact": "Кантакт:",
"about.default_locale": "Прадвызначаная",
"about.disclaimer": "Mastodon - свабоднае праграмнае забеспячэнне, з адкрытым зыходным кодам, і гандлёвай маркай Mastodon gGmbH.", "about.disclaimer": "Mastodon - свабоднае праграмнае забеспячэнне, з адкрытым зыходным кодам, і гандлёвай маркай Mastodon gGmbH.",
"about.domain_blocks.no_reason_available": "Прычына недаступная", "about.domain_blocks.no_reason_available": "Прычына недаступная",
"about.domain_blocks.preamble": "Mastodon, у асноўным, дазваляе вам праглядаць кантэнт і ўзаемадзейнічаць з карыстальнікамі з іншых сервераў у федэсвету. Гэтыя выключэнні былі зроблены дакладна на гэтым серверы.", "about.domain_blocks.preamble": "Mastodon, у асноўным, дазваляе вам праглядаць кантэнт і ўзаемадзейнічаць з карыстальнікамі з іншых сервераў у федэсвету. Гэтыя выключэнні былі зроблены дакладна на гэтым серверы.",
@ -8,6 +9,7 @@
"about.domain_blocks.silenced.title": "Абмежаваны", "about.domain_blocks.silenced.title": "Абмежаваны",
"about.domain_blocks.suspended.explanation": "Ніякая інфармацыя з гэтага сервера не будзе апрацавана, захавана або абменена, узаемадзеянне або камунікацыя з карыстальнікамі гэтага сервера немагчымы.", "about.domain_blocks.suspended.explanation": "Ніякая інфармацыя з гэтага сервера не будзе апрацавана, захавана або абменена, узаемадзеянне або камунікацыя з карыстальнікамі гэтага сервера немагчымы.",
"about.domain_blocks.suspended.title": "Прыпынены", "about.domain_blocks.suspended.title": "Прыпынены",
"about.language_label": "Мова",
"about.not_available": "Дадзеная інфармацыя не дасяжная на гэтым серверы.", "about.not_available": "Дадзеная інфармацыя не дасяжная на гэтым серверы.",
"about.powered_by": "Дэцэнтралізаваная сацыяльная сетка, створаная {mastodon}", "about.powered_by": "Дэцэнтралізаваная сацыяльная сетка, створаная {mastodon}",
"about.rules": "Правілы сервера", "about.rules": "Правілы сервера",
@ -19,13 +21,21 @@
"account.block_domain": "Заблакіраваць дамен {domain}", "account.block_domain": "Заблакіраваць дамен {domain}",
"account.block_short": "Заблакіраваць", "account.block_short": "Заблакіраваць",
"account.blocked": "Заблакіраваны", "account.blocked": "Заблакіраваны",
"account.blocking": "Блакіраванне",
"account.cancel_follow_request": "Скасаваць запыт на падпіску", "account.cancel_follow_request": "Скасаваць запыт на падпіску",
"account.copy": "Скапіраваць спасылку на профіль", "account.copy": "Скапіраваць спасылку на профіль",
"account.direct": "Згадаць асабіста @{name}", "account.direct": "Згадаць асабіста @{name}",
"account.disable_notifications": "Не паведамляць мне пра публікацыі @{name}", "account.disable_notifications": "Не паведамляць мне пра публікацыі @{name}",
"account.domain_blocking": "Блакіраванне дамена",
"account.edit_profile": "Рэдагаваць профіль", "account.edit_profile": "Рэдагаваць профіль",
"account.enable_notifications": "Апавяшчаць мяне пра допісы @{name}", "account.enable_notifications": "Апавяшчаць мяне пра допісы @{name}",
"account.endorse": "Паказваць у профілі", "account.endorse": "Паказваць у профілі",
"account.familiar_followers_many": "Мае сярод падпісчыкаў {name1}, {name2}, і {othersCount, plural, one {яшчэ # чалавека, знаёмага вам} few {яшчэ # чалавекі, знаёмыя вам} many {яшчэ # чалавек, знаёмых вам} other {яшчэ # чалавекі, знаёмыя вам}}",
"account.familiar_followers_one": "Мае сярод падпісчыкаў {name1}",
"account.familiar_followers_two": "Мае сярод падпісчыкаў {name1} і {name2}",
"account.featured": "Рэкамендаванае",
"account.featured.accounts": "Профілі",
"account.featured.hashtags": "Хэштэгі",
"account.featured_tags.last_status_at": "Апошні допіс ад {date}", "account.featured_tags.last_status_at": "Апошні допіс ад {date}",
"account.featured_tags.last_status_never": "Няма допісаў", "account.featured_tags.last_status_never": "Няма допісаў",
"account.follow": "Падпісацца", "account.follow": "Падпісацца",
@ -33,9 +43,11 @@
"account.followers": "Падпісчыкі", "account.followers": "Падпісчыкі",
"account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.", "account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.",
"account.followers_counter": "{count, plural, one {{counter} падпісчык} few {{counter} падпісчыкі} many {{counter} падпісчыкаў} other {{counter} падпісчыка}}", "account.followers_counter": "{count, plural, one {{counter} падпісчык} few {{counter} падпісчыкі} many {{counter} падпісчыкаў} other {{counter} падпісчыка}}",
"account.followers_you_know_counter": "{count, one {{counter}, знаёмы вам} other {{counter}, знаёмых вам}}",
"account.following": "Падпіскі", "account.following": "Падпіскі",
"account.following_counter": "{count, plural, one {{counter} падпіска} few {{counter} падпіскі} many {{counter} падпісак} other {{counter} падпіскі}}", "account.following_counter": "{count, plural, one {{counter} падпіска} few {{counter} падпіскі} many {{counter} падпісак} other {{counter} падпіскі}}",
"account.follows.empty": "Карыстальнік ні на каго не падпісаны.", "account.follows.empty": "Карыстальнік ні на каго не падпісаны.",
"account.follows_you": "Падпісаны на вас",
"account.go_to_profile": "Перайсці да профілю", "account.go_to_profile": "Перайсці да профілю",
"account.hide_reblogs": "Схаваць пашырэнні ад @{name}", "account.hide_reblogs": "Схаваць пашырэнні ад @{name}",
"account.in_memoriam": "У памяць.", "account.in_memoriam": "У памяць.",
@ -50,18 +62,22 @@
"account.mute_notifications_short": "Не апавяшчаць", "account.mute_notifications_short": "Не апавяшчаць",
"account.mute_short": "Ігнараваць", "account.mute_short": "Ігнараваць",
"account.muted": "Ігнаруецца", "account.muted": "Ігнаруецца",
"account.mutual": "Вы падпісаны адно на аднаго",
"account.no_bio": "Апісанне адсутнічае.", "account.no_bio": "Апісанне адсутнічае.",
"account.open_original_page": "Адкрыць арыгінальную старонку", "account.open_original_page": "Адкрыць арыгінальную старонку",
"account.posts": "Допісы", "account.posts": "Допісы",
"account.posts_with_replies": "Допісы і адказы", "account.posts_with_replies": "Допісы і адказы",
"account.remove_from_followers": "Выдаліць карыстальніка {name} з падпісчыкаў",
"account.report": "Паскардзіцца на @{name}", "account.report": "Паскардзіцца на @{name}",
"account.requested": "Чакаецца ўхваленне. Націсніце, каб скасаваць запыт на падпіску", "account.requested": "Чакаецца ўхваленне. Націсніце, каб скасаваць запыт на падпіску",
"account.requested_follow": "{name} адправіў запыт на падпіску", "account.requested_follow": "{name} адправіў запыт на падпіску",
"account.requests_to_follow_you": "Хоча падпісацца на вас",
"account.share": "Абагуліць профіль @{name}", "account.share": "Абагуліць профіль @{name}",
"account.show_reblogs": "Паказаць падштурхоўванні ад @{name}", "account.show_reblogs": "Паказаць падштурхоўванні ад @{name}",
"account.statuses_counter": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}", "account.statuses_counter": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
"account.unblock": "Разблакіраваць @{name}", "account.unblock": "Разблакіраваць @{name}",
"account.unblock_domain": "Разблакіраваць дамен {domain}", "account.unblock_domain": "Разблакіраваць дамен {domain}",
"account.unblock_domain_short": "Разблакіраваць",
"account.unblock_short": "Разблакіраваць", "account.unblock_short": "Разблакіраваць",
"account.unendorse": "Не паказваць у профілі", "account.unendorse": "Не паказваць у профілі",
"account.unfollow": "Адпісацца", "account.unfollow": "Адпісацца",
@ -83,26 +99,30 @@
"alert.unexpected.message": "Узнікла нечаканая памылка.", "alert.unexpected.message": "Узнікла нечаканая памылка.",
"alert.unexpected.title": "Вой!", "alert.unexpected.title": "Вой!",
"alt_text_badge.title": "Альтэрнатыўны тэкст", "alt_text_badge.title": "Альтэрнатыўны тэкст",
"alt_text_modal.add_alt_text": "Дадаць альтэрнатыўны тэкст",
"alt_text_modal.add_text_from_image": "Дадаць тэкст з відарыса",
"alt_text_modal.cancel": "Скасаваць",
"alt_text_modal.change_thumbnail": "Змяніць мініяцюру",
"alt_text_modal.done": "Гатова", "alt_text_modal.done": "Гатова",
"announcement.announcement": "Аб'ява", "announcement.announcement": "Аб'ява",
"annual_report.summary.archetype.booster": "Трэнда-сьледнік", "annual_report.summary.archetype.booster": "Паляўнічы на трэнды",
"annual_report.summary.archetype.lurker": "Назіральнік", "annual_report.summary.archetype.lurker": "Назіральнік",
"annual_report.summary.archetype.oracle": "Аракул", "annual_report.summary.archetype.oracle": "Аракул",
"annual_report.summary.archetype.pollster": "Апытвальнік", "annual_report.summary.archetype.pollster": "Апытвальнік",
"annual_report.summary.archetype.replier": "Душа кампанійі", "annual_report.summary.archetype.replier": "Душа кампаніі",
"annual_report.summary.followers.followers": "падпісанты", "annual_report.summary.followers.followers": "падпісчыкі",
"annual_report.summary.followers.total": "Усяго {count}", "annual_report.summary.followers.total": "Агулам {count}",
"annual_report.summary.here_it_is": "Вось вашыя вынікі {year} году:", "annual_report.summary.here_it_is": "Вось вашы вынікі {year} за год:",
"annual_report.summary.highlighted_post.by_favourites": "самы ўпадабаны допіс", "annual_report.summary.highlighted_post.by_favourites": "самы ўпадабаны допіс",
"annual_report.summary.highlighted_post.by_reblogs": "самы пашыраны допіс", "annual_report.summary.highlighted_post.by_reblogs": "самы пашыраны допіс",
"annual_report.summary.highlighted_post.by_replies": "самы каментаваны допіс", "annual_report.summary.highlighted_post.by_replies": "самы каментаваны допіс",
"annual_report.summary.highlighted_post.possessive": "{name}", "annual_report.summary.highlighted_post.possessive": "{name}",
"annual_report.summary.most_used_app.most_used_app": "самая выкарыстоўваная аплікацыя", "annual_report.summary.most_used_app.most_used_app": "праграма, якой карысталіся часцей",
"annual_report.summary.most_used_hashtag.most_used_hashtag": "самы выкарыстоўваны гэштаґ", "annual_report.summary.most_used_hashtag.most_used_hashtag": "хэштэг, якім карысталіся часцей",
"annual_report.summary.most_used_hashtag.none": "Няма", "annual_report.summary.most_used_hashtag.none": "Няма",
"annual_report.summary.new_posts.new_posts": "новыя допісы", "annual_report.summary.new_posts.new_posts": "новыя допісы",
"annual_report.summary.percentile.text": "<topLabel>Мэта месьціць вас у топ</topLabel><percentage></percentage><bottomLabel> карыстальнікаў {domain}.</bottomLabel>", "annual_report.summary.percentile.text": "<topLabel>З-за гэтага, вы знаходзіцеся ў топе</topLabel><percentage></percentage><bottomLabel> карыстальнікаў {domain}.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "Мы ня скажам аб гэтым Сіняпальцаму.", "annual_report.summary.percentile.we_wont_tell_bernie": "КДБ пра гэта не даведаецца.",
"annual_report.summary.thanks": "Дзякуй за ўдзел у Mastodon!", "annual_report.summary.thanks": "Дзякуй за ўдзел у Mastodon!",
"attachments_list.unprocessed": "(неапрацаваны)", "attachments_list.unprocessed": "(неапрацаваны)",
"audio.hide": "Схаваць аўдыя", "audio.hide": "Схаваць аўдыя",
@ -127,7 +147,7 @@
"bundle_column_error.routing.body": "Запытаная старонка не знойдзена. Вы ўпэўнены, што URL у адрасным радку правільны?", "bundle_column_error.routing.body": "Запытаная старонка не знойдзена. Вы ўпэўнены, што URL у адрасным радку правільны?",
"bundle_column_error.routing.title": "404", "bundle_column_error.routing.title": "404",
"bundle_modal_error.close": "Закрыць", "bundle_modal_error.close": "Закрыць",
"bundle_modal_error.message": "Падчас загрузкі гэтага экрана штосьці пайшло ня так.", "bundle_modal_error.message": "Падчас загрузкі гэтага экрана штосьці пайшло не так.",
"bundle_modal_error.retry": "Паспрабуйце зноў", "bundle_modal_error.retry": "Паспрабуйце зноў",
"closed_registrations.other_server_instructions": "Паколькі Mastodon дэцэнтралізаваны, вы можаце стварыць уліковы запіс на іншым серверы і працягваць узаемадзейнічаць з ім.", "closed_registrations.other_server_instructions": "Паколькі Mastodon дэцэнтралізаваны, вы можаце стварыць уліковы запіс на іншым серверы і працягваць узаемадзейнічаць з ім.",
"closed_registrations_modal.description": "Стварэнне ўліковага запісу на {domain} цяпер немагчыма. Заўважце, што няма неабходнасці мець уліковы запіс менавіта на {domain}, каб выкарыстоўваць Mastodon.", "closed_registrations_modal.description": "Стварэнне ўліковага запісу на {domain} цяпер немагчыма. Заўважце, што няма неабходнасці мець уліковы запіс менавіта на {domain}, каб выкарыстоўваць Mastodon.",
@ -147,7 +167,7 @@
"column.firehose": "Стужкі", "column.firehose": "Стужкі",
"column.follow_requests": "Запыты на падпіску", "column.follow_requests": "Запыты на падпіску",
"column.home": "Галоўная", "column.home": "Галоўная",
"column.list_members": "Кіраванне ўдзельнікамі спісу", "column.list_members": "Кіраванне ўдзельнікамі спіса",
"column.lists": "Спісы", "column.lists": "Спісы",
"column.mutes": "Ігнараваныя карыстальнікі", "column.mutes": "Ігнараваныя карыстальнікі",
"column.notifications": "Апавяшчэнні", "column.notifications": "Апавяшчэнні",
@ -196,19 +216,24 @@
"confirmations.delete_list.confirm": "Выдаліць", "confirmations.delete_list.confirm": "Выдаліць",
"confirmations.delete_list.message": "Вы ўпэўненыя, што хочаце беззваротна выдаліць гэты чарнавік?", "confirmations.delete_list.message": "Вы ўпэўненыя, што хочаце беззваротна выдаліць гэты чарнавік?",
"confirmations.delete_list.title": "Выдаліць спіс?", "confirmations.delete_list.title": "Выдаліць спіс?",
"confirmations.discard_draft.edit.cancel": "Працягнуць рэдагаванне",
"confirmations.discard_edit_media.confirm": "Адмяніць", "confirmations.discard_edit_media.confirm": "Адмяніць",
"confirmations.discard_edit_media.message": "У вас ёсць незахаваныя змены ў апісанні або прэв'ю, усе роўна скасаваць іх?", "confirmations.discard_edit_media.message": "У вас ёсць незахаваныя змены ў апісанні або прэв'ю, усе роўна скасаваць іх?",
"confirmations.follow_to_list.confirm": "Падпісацца й дадаць у сьпіс", "confirmations.follow_to_list.confirm": "Падпісацца і дадаць у спіс",
"confirmations.follow_to_list.message": "Вы мусіце быць падпісаныя на {name} каб дадаць яго ў сьпіс.", "confirmations.follow_to_list.message": "Вам трэба падпісацца на карыстальніка {name}, каб дадаць яго ў спіс.",
"confirmations.follow_to_list.title": "Падпісацца на карыстальніка?", "confirmations.follow_to_list.title": "Падпісацца на карыстальніка?",
"confirmations.logout.confirm": "Выйсці", "confirmations.logout.confirm": "Выйсці",
"confirmations.logout.message": "Вы ўпэўненыя, што хочаце выйсці?", "confirmations.logout.message": "Вы ўпэўненыя, што хочаце выйсці?",
"confirmations.logout.title": "Выйсці?", "confirmations.logout.title": "Выйсці?",
"confirmations.missing_alt_text.title": "Дадаць апісаньне?", "confirmations.missing_alt_text.confirm": "Дадаць альтэрнатыўны тэкст",
"confirmations.missing_alt_text.secondary": "Усё адно апублікаваць",
"confirmations.missing_alt_text.title": "Дадаць альтэрнатыўны тэкст?",
"confirmations.mute.confirm": "Ігнараваць", "confirmations.mute.confirm": "Ігнараваць",
"confirmations.redraft.confirm": "Выдаліць і перапісаць", "confirmations.redraft.confirm": "Выдаліць і перапісаць",
"confirmations.redraft.message": "Вы ўпэўнены, што хочаце выдаліць допіс і перапісаць яго? Упадабанні і пашырэнні згубяцца, а адказы да арыгінальнага допісу асірацеюць.", "confirmations.redraft.message": "Вы ўпэўнены, што хочаце выдаліць допіс і перапісаць яго? Упадабанні і пашырэнні згубяцца, а адказы да арыгінальнага допісу асірацеюць.",
"confirmations.redraft.title": "Выдаліць і перапісаць допіс?", "confirmations.redraft.title": "Выдаліць і перапісаць допіс?",
"confirmations.remove_from_followers.confirm": "Выдаліць падпісчыка",
"confirmations.remove_from_followers.title": "Выдаліць падпісчыка?",
"confirmations.unfollow.confirm": "Адпісацца", "confirmations.unfollow.confirm": "Адпісацца",
"confirmations.unfollow.message": "Вы ўпэўненыя, што хочаце адпісацца ад {name}?", "confirmations.unfollow.message": "Вы ўпэўненыя, што хочаце адпісацца ад {name}?",
"confirmations.unfollow.title": "Адпісацца ад карыстальніка?", "confirmations.unfollow.title": "Адпісацца ад карыстальніка?",
@ -221,7 +246,7 @@
"conversation.with": "З {names}", "conversation.with": "З {names}",
"copy_icon_button.copied": "Скапіявана ў буфер абмену", "copy_icon_button.copied": "Скапіявана ў буфер абмену",
"copypaste.copied": "Скапіравана", "copypaste.copied": "Скапіравана",
"copypaste.copy_to_clipboard": "Капіраваць у буфер абмену", "copypaste.copy_to_clipboard": "Скапіяваць у буфер абмену",
"directory.federated": "З вядомага федэсвету", "directory.federated": "З вядомага федэсвету",
"directory.local": "Толькі з {domain}", "directory.local": "Толькі з {domain}",
"directory.new_arrivals": "Новыя карыстальнікі", "directory.new_arrivals": "Новыя карыстальнікі",
@ -230,7 +255,7 @@
"disabled_account_banner.text": "Ваш уліковы запіс {disabledAccount} часова адключаны.", "disabled_account_banner.text": "Ваш уліковы запіс {disabledAccount} часова адключаны.",
"dismissable_banner.community_timeline": "Гэта самыя апошнія допісы ад людзей, уліковыя запісы якіх размяшчаюцца на {domain}.", "dismissable_banner.community_timeline": "Гэта самыя апошнія допісы ад людзей, уліковыя запісы якіх размяшчаюцца на {domain}.",
"dismissable_banner.dismiss": "Адхіліць", "dismissable_banner.dismiss": "Адхіліць",
"dismissable_banner.public_timeline": "Гэта самыя новыя публічныя допісы ад карыстальнікаў фэдывёрсу на якіх падпісаныя карыстальнікі {domain}.", "dismissable_banner.public_timeline": "Вось апошнія публічныя допісы ад карыстальнікаў fediverse на якіх падпісаны карыстальнікі {domain}.",
"domain_block_modal.block": "Заблакіраваць сервер", "domain_block_modal.block": "Заблакіраваць сервер",
"domain_block_modal.block_account_instead": "Заблакіраваць @{name} замест гэтага", "domain_block_modal.block_account_instead": "Заблакіраваць @{name} замест гэтага",
"domain_block_modal.they_can_interact_with_old_posts": "Людзі з гэтага сервера змогуць узаемадзейнічаць з вашымі старымі допісамі.", "domain_block_modal.they_can_interact_with_old_posts": "Людзі з гэтага сервера змогуць узаемадзейнічаць з вашымі старымі допісамі.",
@ -275,15 +300,15 @@
"empty_column.account_timeline": "Тут няма допісаў!", "empty_column.account_timeline": "Тут няма допісаў!",
"empty_column.account_unavailable": "Профіль недаступны", "empty_column.account_unavailable": "Профіль недаступны",
"empty_column.blocks": "Вы яшчэ нікога не заблакіравалі.", "empty_column.blocks": "Вы яшчэ нікога не заблакіравалі.",
"empty_column.bookmarked_statuses": "У вас яшчэ няма паведамленняў з закладкамі. Калі вы дадасце закладку, яна з'явіцца тут.", "empty_column.bookmarked_statuses": "У вашых закладках яшчэ няма допісаў. Калі вы дадасце закладку, яна з’явіцца тут.",
"empty_column.community": "Мясцовая стужка пустая. Напішыце што-небудзь публічна, каб зрушыць з месца!", "empty_column.community": "Мясцовая стужка пустая. Напішыце нешта публічнае, каб разварушыць справу!",
"empty_column.direct": "Пакуль у вас няма асабістых згадак. Калі вы дашляце або атрымаеце штось, яно з'явіцца тут.", "empty_column.direct": "Пакуль у вас няма асабістых згадванняў. Калі вы дашляце або атрымаеце штосьці, яно з’явіцца тут.",
"empty_column.domain_blocks": "Заблакіраваных даменаў пакуль няма.", "empty_column.domain_blocks": "Заблакіраваных даменаў пакуль няма.",
"empty_column.explore_statuses": "Зараз не ў трэндзе. Праверце пазней", "empty_column.explore_statuses": "Зараз не ў трэндзе. Праверце пазней",
"empty_column.favourited_statuses": "Вы яшчэ не ўпадабалі ніводны допіс. Калі гэта адбудзецца, вы ўбачыце яго тут.", "empty_column.favourited_statuses": "Вы яшчэ не ўпадабалі ніводны допіс. Калі гэта адбудзецца, вы ўбачыце яго тут.",
"empty_column.favourites": "Ніхто яшчэ не ўпадабаў гэты допіс. Калі гэта адбудзецца, вы ўбачыце гэтых людзей тут.", "empty_column.favourites": "Ніхто яшчэ не ўпадабаў гэты допіс. Калі гэта адбудзецца, вы ўбачыце гэтых людзей тут.",
"empty_column.follow_requests": "У вас яшчэ няма запытаў на падпіскуі. Калі вы атрымаеце запыт, ён з'явяцца тут.", "empty_column.follow_requests": "У вас яшчэ няма запытаў на падпіску. Калі вы атрымаеце запыт, ён з’явіцца тут.",
"empty_column.followed_tags": "Вы пакуль не падпісаны ні на адзін хэштэг. Калі падпішацеся, яны з'явяцца тут.", "empty_column.followed_tags": "Вы пакуль не падпісаны ні на адзін хэштэг. Калі падпішацеся, яны зявяцца тут.",
"empty_column.hashtag": "Па гэтаму хэштэгу пакуль што нічога няма.", "empty_column.hashtag": "Па гэтаму хэштэгу пакуль што нічога няма.",
"empty_column.home": "Галоўная стужка пустая! Падпішыцеся на іншых людзей, каб запоўніць яе. {suggestions}", "empty_column.home": "Галоўная стужка пустая! Падпішыцеся на іншых людзей, каб запоўніць яе. {suggestions}",
"empty_column.list": "У гэтым спісе пакуль што нічога няма. Калі члены лісту апублікуюць новыя запісы, яны з'явяцца тут.", "empty_column.list": "У гэтым спісе пакуль што нічога няма. Калі члены лісту апублікуюць новыя запісы, яны з'явяцца тут.",
@ -294,13 +319,18 @@
"error.unexpected_crash.explanation": "Гэта старонка не можа быць адлюстравана карэктна з-за памылкі ў нашым кодзе, або праблемы з сумяшчальнасцю браўзера.", "error.unexpected_crash.explanation": "Гэта старонка не можа быць адлюстравана карэктна з-за памылкі ў нашым кодзе, або праблемы з сумяшчальнасцю браўзера.",
"error.unexpected_crash.explanation_addons": "Гэтая старонка не можа быць адлюстравана карэктна. Верагодна, гэтая памылка выклікана дадатковым кампанентам браўзера або інструментамі аўтаматычнага перакладу", "error.unexpected_crash.explanation_addons": "Гэтая старонка не можа быць адлюстравана карэктна. Верагодна, гэтая памылка выклікана дадатковым кампанентам браўзера або інструментамі аўтаматычнага перакладу",
"error.unexpected_crash.next_steps": "Паспрабуйце абнавіць старонку. Калі гэта не дапаможа, вы можаце паспрабаваць іншы браўзер, альбо выкарыстаць усталяваную праграму.", "error.unexpected_crash.next_steps": "Паспрабуйце абнавіць старонку. Калі гэта не дапаможа, вы можаце паспрабаваць іншы браўзер, альбо выкарыстаць усталяваную праграму.",
"error.unexpected_crash.next_steps_addons": "Паспрабуйце выключыць іх і аднавіць старонку. Калі гэта не дапаможа, вы можаце карыстацца Мастадонт праз другі браўзер ці аплікацыю.", "error.unexpected_crash.next_steps_addons": "Паспрабуйце выключыць іх і абнавіць старонку. Калі гэта не дапамагае, вы ўсё яшчэ можаце карыстацца Mastodon праз іншы браўзер ці натыўную праграму.",
"errors.unexpected_crash.copy_stacktrace": "Дадаць дыягнастычны стэк у буфер абмену", "errors.unexpected_crash.copy_stacktrace": "Дадаць дыягнастычны стэк у буфер абмену",
"errors.unexpected_crash.report_issue": "Паведаміць аб праблеме", "errors.unexpected_crash.report_issue": "Паведаміць аб праблеме",
"explore.suggested_follows": "Людзі", "explore.suggested_follows": "Людзі",
"explore.title": "Трэндавае",
"explore.trending_links": "Навіны", "explore.trending_links": "Навіны",
"explore.trending_statuses": "Допісы", "explore.trending_statuses": "Допісы",
"explore.trending_tags": "Хэштэгі", "explore.trending_tags": "Хэштэгі",
"featured_carousel.next": "Далей",
"featured_carousel.post": "Допіс",
"featured_carousel.previous": "Назад",
"featured_carousel.slide": "{index} з {total}",
"filter_modal.added.context_mismatch_explanation": "Гэтая катэгорыя фільтра не прымяняецца да кантэксту, у якім вы адкрылі гэты пост. Калі вы хочаце, каб паведамленне таксама было адфільтравана ў гэтым кантэксце, вам трэба будзе адрэдагаваць фільтр", "filter_modal.added.context_mismatch_explanation": "Гэтая катэгорыя фільтра не прымяняецца да кантэксту, у якім вы адкрылі гэты пост. Калі вы хочаце, каб паведамленне таксама было адфільтравана ў гэтым кантэксце, вам трэба будзе адрэдагаваць фільтр",
"filter_modal.added.context_mismatch_title": "Неадпаведны кантэкст!", "filter_modal.added.context_mismatch_title": "Неадпаведны кантэкст!",
"filter_modal.added.expired_explanation": "Тэрмін дзеяння гэтай катэгорыі фільтраў скончыўся, вам трэба будзе змяніць дату заканчэння тэрміну дзеяння, каб яна прымянялася", "filter_modal.added.expired_explanation": "Тэрмін дзеяння гэтай катэгорыі фільтраў скончыўся, вам трэба будзе змяніць дату заканчэння тэрміну дзеяння, каб яна прымянялася",
@ -349,10 +379,10 @@
"footer.privacy_policy": "Палітыка прыватнасці", "footer.privacy_policy": "Палітыка прыватнасці",
"footer.source_code": "Прагледзець зыходны код", "footer.source_code": "Прагледзець зыходны код",
"footer.status": "Статус", "footer.status": "Статус",
"footer.terms_of_service": "Умовы абслугоўваньня", "footer.terms_of_service": "Умовы выкарыстання",
"generic.saved": "Захавана", "generic.saved": "Захавана",
"getting_started.heading": "Пачатак працы", "getting_started.heading": "Пачатак працы",
"hashtag.admin_moderation": "Адкрыць інтэрфэйс мадаратара для #{name}", "hashtag.admin_moderation": "Адкрыць інтэрфейс мадэратара для #{name}",
"hashtag.column_header.tag_mode.all": "і {additional}", "hashtag.column_header.tag_mode.all": "і {additional}",
"hashtag.column_header.tag_mode.any": "або {additional}", "hashtag.column_header.tag_mode.any": "або {additional}",
"hashtag.column_header.tag_mode.none": "без {additional}", "hashtag.column_header.tag_mode.none": "без {additional}",
@ -374,35 +404,37 @@
"hints.profiles.see_more_followers": "Глядзець больш падпісаных на {domain}", "hints.profiles.see_more_followers": "Глядзець больш падпісаных на {domain}",
"hints.profiles.see_more_follows": "Глядзець больш падпісак на {domain}", "hints.profiles.see_more_follows": "Глядзець больш падпісак на {domain}",
"hints.profiles.see_more_posts": "Глядзець больш допісаў на {domain}", "hints.profiles.see_more_posts": "Глядзець больш допісаў на {domain}",
"hints.threads.replies_may_be_missing": "Адказы зь іншых сэрвэраў могуць адсутнічаць.", "home.column_settings.show_quotes": "Паказаць цытаты",
"hints.threads.see_more": "Глядзіце больш адказаў на {domain}",
"home.column_settings.show_reblogs": "Паказваць пашырэнні", "home.column_settings.show_reblogs": "Паказваць пашырэнні",
"home.column_settings.show_replies": "Паказваць адказы", "home.column_settings.show_replies": "Паказваць адказы",
"home.hide_announcements": "Схаваць аб'явы", "home.hide_announcements": "Схаваць аб'явы",
"home.pending_critical_update.body": "Калі ласка, абнавіце свой сервер Mastodon як мага хутчэй!", "home.pending_critical_update.body": "Абнавіце свой сервер Mastodon як мага хутчэй!",
"home.pending_critical_update.link": "Прагледзець абнаўленні", "home.pending_critical_update.link": "Прагледзець абнаўленні",
"home.pending_critical_update.title": "Даступна крытычнае абнаўленне бяспекі!", "home.pending_critical_update.title": "Даступна крытычнае абнаўленне бяспекі!",
"home.show_announcements": "Паказаць аб'явы", "home.show_announcements": "Паказаць аб'явы",
"ignore_notifications_modal.disclaimer": "Mastodon ня можа йнфармаваць карыстальнікаў аб тым, што вы прайігнаравалі йх паведамленьні. Ігнараваньне паведамленьняў не спыніць іх адпраўку.", "ignore_notifications_modal.disclaimer": "Mastodon не можа паведамляць карыстальнікам, што вы праігнаравалі апавяшчэнні ад іх. Ігнараванне апавяшчэнняў не спыніць адпраўку саміх паведамленняў.",
"ignore_notifications_modal.filter_instead": "Замест гэтага адфільтраваць", "ignore_notifications_modal.filter_instead": "Замест гэтага адфільтраваць",
"ignore_notifications_modal.filter_to_act_users": "Вы па-ранейшаму зможаце прымаць, адхіляць ці скардзіцца на карыстальнікаў", "ignore_notifications_modal.filter_to_act_users": "Вы па-ранейшаму зможаце прымаць, адхіляць ці скардзіцца на карыстальнікаў",
"ignore_notifications_modal.filter_to_avoid_confusion": "Фільтраваньне дапамагае пазьбегнуць патэнцыйнай блытаніны", "ignore_notifications_modal.filter_to_avoid_confusion": "Выкарыстанне фільтраў дапамагае пазбягаць патэнцыйнай блытаніны",
"ignore_notifications_modal.filter_to_review_separately": "Вы можаце прагледзець адфільтраваныя паведамленьні асобна", "ignore_notifications_modal.filter_to_review_separately": "Вы можаце прагледзець адфільтраваныя апавяшчэнні асобна",
"ignore_notifications_modal.ignore": "Ігнараваць паведамленьні", "ignore_notifications_modal.ignore": "Ігнараваць апавяшчэнні",
"ignore_notifications_modal.limited_accounts_title": "Ігнараваць паведамленьні ад абмежаваных уліковых запісаў?", "ignore_notifications_modal.limited_accounts_title": "Ігнараваць апавяшчэнні ад уліковых запісаў пад мадэрацыяй?",
"ignore_notifications_modal.new_accounts_title": "Ігнараваць паведамленьні ад новых уліковых запісаў?", "ignore_notifications_modal.new_accounts_title": "Ігнараваць апавяшчэнні ад новых уліковых запісаў?",
"ignore_notifications_modal.not_followers_title": "Ігнараваць паведамленьні ад людзей, якія ня падпісаныя на вас?", "ignore_notifications_modal.not_followers_title": "Ігнараваць апавяшчэнні ад людзей, якія не падпісаныя на вас?",
"ignore_notifications_modal.not_following_title": "Ігнараваць апавяшчэнні ад людзей на якіх вы не падпісаны?", "ignore_notifications_modal.not_following_title": "Ігнараваць апавяшчэнні ад людзей на якіх вы не падпісаны?",
"ignore_notifications_modal.private_mentions_title": "Ігнараваць паведамленьні аб непажаданых прыватных згадках?", "ignore_notifications_modal.private_mentions_title": "Ігнараваць апавяшчэнні пра непажаданыя асабістыя згадванні?",
"info_button.label": "Даведка", "info_button.label": "Даведка",
"interaction_modal.action.favourite": "Каб працягнуць, вы мусіце ўпадабаць з вашага ўліковага запісу.", "interaction_modal.action.favourite": "Каб працягнуць, вы мусіце ўпадабаць нешта са свайго ўліковага запісу.",
"interaction_modal.action.follow": "Каб працягнуць, вы мусіце падпісацца з вашага ўліковага запісу.", "interaction_modal.action.follow": "Каб працягнуць, вы мусіце падпісацца на некага са свайго ўліковага запісу.",
"interaction_modal.go": "Перайсці",
"interaction_modal.no_account_yet": "Не маеце ўліковага запісу?",
"interaction_modal.on_another_server": "На іншым серверы", "interaction_modal.on_another_server": "На іншым серверы",
"interaction_modal.on_this_server": "На гэтым серверы", "interaction_modal.on_this_server": "На гэтым серверы",
"interaction_modal.title.favourite": "Упадабаць допіс {name}", "interaction_modal.title.favourite": "Упадабаць допіс {name}",
"interaction_modal.title.follow": "Падпісацца на {name}", "interaction_modal.title.follow": "Падпісацца на {name}",
"interaction_modal.title.reblog": "Пашырыць допіс ад {name}", "interaction_modal.title.reblog": "Пашырыць допіс ад {name}",
"interaction_modal.title.reply": "Адказаць на допіс {name}", "interaction_modal.title.reply": "Адказаць на допіс {name}",
"interaction_modal.username_prompt": "Напр., {example}",
"intervals.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}}", "intervals.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}}",
"intervals.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}}", "intervals.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}}",
"intervals.full.minutes": "{number, plural, one {# хвіліна} few {# хвіліны} many {# хвілін} other {# хвіліны}}", "intervals.full.minutes": "{number, plural, one {# хвіліна} few {# хвіліны} many {# хвілін} other {# хвіліны}}",
@ -438,21 +470,32 @@
"keyboard_shortcuts.toggle_hidden": "Паказаць/схаваць тэкст за папярэджаннем пра кантэнт", "keyboard_shortcuts.toggle_hidden": "Паказаць/схаваць тэкст за папярэджаннем пра кантэнт",
"keyboard_shortcuts.toggle_sensitivity": "Паказаць/схаваць медыя", "keyboard_shortcuts.toggle_sensitivity": "Паказаць/схаваць медыя",
"keyboard_shortcuts.toot": "Стварыць новы допіс", "keyboard_shortcuts.toot": "Стварыць новы допіс",
"keyboard_shortcuts.translate": "каб перакласці допіс",
"keyboard_shortcuts.unfocus": "Расфакусаваць тэкставую вобласць/пошукавы радок", "keyboard_shortcuts.unfocus": "Расфакусаваць тэкставую вобласць/пошукавы радок",
"keyboard_shortcuts.up": "Перамясціцца ўверх па спісе", "keyboard_shortcuts.up": "Перамясціцца ўверх па спісе",
"lightbox.close": "Закрыць", "lightbox.close": "Закрыць",
"lightbox.next": "Далей", "lightbox.next": "Далей",
"lightbox.previous": "Назад", "lightbox.previous": "Назад",
"lightbox.zoom_in": "Маштабаваць да фактычнага памеру",
"lightbox.zoom_out": "Дапасаваць усё змесціва пад памеры экрана",
"limited_account_hint.action": "Усе роўна паказваць профіль", "limited_account_hint.action": "Усе роўна паказваць профіль",
"limited_account_hint.title": "Гэты профіль быў схаваны мадэратарамі", "limited_account_hint.title": "Гэты профіль быў схаваны мадэратарамі",
"link_preview.author": "Ад {name}", "link_preview.author": "Ад {name}",
"link_preview.more_from_author": "Больш ад {name}", "link_preview.more_from_author": "Больш ад {name}",
"link_preview.shares": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}", "link_preview.shares": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
"lists.add_member": "Дадаць", "lists.add_member": "Дадаць",
"lists.add_to_list": "Дадаць у спіс",
"lists.add_to_lists": "Дадаць {name} у спісы",
"lists.create": "Стварыць", "lists.create": "Стварыць",
"lists.create_list": "Стварыць спіс", "lists.create_list": "Стварыць спіс",
"lists.delete": "Выдаліць спіс", "lists.delete": "Выдаліць спіс",
"lists.done": "Гатова",
"lists.edit": "Рэдагаваць спіс", "lists.edit": "Рэдагаваць спіс",
"lists.list_name": "Назва спіса",
"lists.new_list_name": "Назва новага спіса",
"lists.no_lists_yet": "Пакуль няма спісаў.",
"lists.no_members_yet": "Пакуль няма ўдзельнікаў.",
"lists.no_results_found": "Нічога не знойдзена.",
"lists.remove_member": "Выдаліць", "lists.remove_member": "Выдаліць",
"lists.replies_policy.followed": "Любы карыстальнік, на якога вы падпісаліся", "lists.replies_policy.followed": "Любы карыстальнік, на якога вы падпісаліся",
"lists.replies_policy.list": "Удзельнікі гэтага спісу", "lists.replies_policy.list": "Удзельнікі гэтага спісу",
@ -460,7 +503,7 @@
"lists.save": "Захаваць", "lists.save": "Захаваць",
"lists.search": "Пошук", "lists.search": "Пошук",
"load_pending": "{count, plural, one {# новы элемент} few {# новыя элементы} many {# новых элементаў} other {# новых элементаў}}", "load_pending": "{count, plural, one {# новы элемент} few {# новыя элементы} many {# новых элементаў} other {# новых элементаў}}",
"loading_indicator.label": "Загрузка…", "loading_indicator.label": "Ідзе загрузка…",
"media_gallery.hide": "Схаваць", "media_gallery.hide": "Схаваць",
"moved_to_account_banner.text": "Ваш уліковы запіс {disabledAccount} зараз адключаны таму што вы перанесены на {movedToAccount}.", "moved_to_account_banner.text": "Ваш уліковы запіс {disabledAccount} зараз адключаны таму што вы перанесены на {movedToAccount}.",
"mute_modal.hide_from_notifications": "Схаваць з апавяшчэнняў", "mute_modal.hide_from_notifications": "Схаваць з апавяшчэнняў",
@ -473,7 +516,10 @@
"mute_modal.you_wont_see_mentions": "Вы не ўбачыце паведамленняў са згадваннем карыстальніка.", "mute_modal.you_wont_see_mentions": "Вы не ўбачыце паведамленняў са згадваннем карыстальніка.",
"mute_modal.you_wont_see_posts": "Карыстальнік па-ранейшаму будзе бачыць вашыя паведамленні, але вы не будзеце паведамленні карыстальніка.", "mute_modal.you_wont_see_posts": "Карыстальнік па-ранейшаму будзе бачыць вашыя паведамленні, але вы не будзеце паведамленні карыстальніка.",
"navigation_bar.about": "Пра нас", "navigation_bar.about": "Пра нас",
"navigation_bar.account_settings": "Пароль і бяспека",
"navigation_bar.administration": "Адміністрацыя",
"navigation_bar.advanced_interface": "Адкрыць у пашыраным вэб-інтэрфейсе", "navigation_bar.advanced_interface": "Адкрыць у пашыраным вэб-інтэрфейсе",
"navigation_bar.automated_deletion": "Аўтаматычнае выдаленне допісаў",
"navigation_bar.blocks": "Заблакіраваныя карыстальнікі", "navigation_bar.blocks": "Заблакіраваныя карыстальнікі",
"navigation_bar.bookmarks": "Закладкі", "navigation_bar.bookmarks": "Закладкі",
"navigation_bar.direct": "Асабістыя згадванні", "navigation_bar.direct": "Асабістыя згадванні",
@ -483,13 +529,21 @@
"navigation_bar.follow_requests": "Запыты на падпіску", "navigation_bar.follow_requests": "Запыты на падпіску",
"navigation_bar.followed_tags": "Падпіскі", "navigation_bar.followed_tags": "Падпіскі",
"navigation_bar.follows_and_followers": "Падпіскі і падпісчыкі", "navigation_bar.follows_and_followers": "Падпіскі і падпісчыкі",
"navigation_bar.import_export": "Імпарт і экспарт",
"navigation_bar.lists": "Спісы", "navigation_bar.lists": "Спісы",
"navigation_bar.live_feed_local": "Жывая стужка (лакальная)",
"navigation_bar.live_feed_public": "Жывая стужка (публічная)",
"navigation_bar.logout": "Выйсці", "navigation_bar.logout": "Выйсці",
"navigation_bar.moderation": "Мадэрацыя", "navigation_bar.moderation": "Мадэрацыя",
"navigation_bar.more": "Больш",
"navigation_bar.mutes": "Ігнараваныя карыстальнікі", "navigation_bar.mutes": "Ігнараваныя карыстальнікі",
"navigation_bar.opened_in_classic_interface": "Допісы, уліковыя запісы і іншыя спецыфічныя старонкі па змоўчанні адчыняюцца ў класічным вэб-інтэрфейсе.", "navigation_bar.opened_in_classic_interface": "Допісы, уліковыя запісы і іншыя спецыфічныя старонкі па змоўчанні адчыняюцца ў класічным вэб-інтэрфейсе.",
"navigation_bar.preferences": "Налады", "navigation_bar.preferences": "Налады",
"navigation_bar.privacy_and_reach": "Прыватнасць і пошук",
"navigation_bar.search": "Пошук", "navigation_bar.search": "Пошук",
"navigation_bar.search_trends": "Пошук / Трэндавае",
"navigation_panel.collapse_followed_tags": "Згарнуць меню падпісак на хэштэгі",
"navigation_panel.collapse_lists": "Згарнуць меню спісаў",
"not_signed_in_indicator.not_signed_in": "Вам трэба ўвайсці каб атрымаць доступ да гэтага рэсурсу.", "not_signed_in_indicator.not_signed_in": "Вам трэба ўвайсці каб атрымаць доступ да гэтага рэсурсу.",
"notification.admin.report": "{name} паскардзіўся на {target}", "notification.admin.report": "{name} паскардзіўся на {target}",
"notification.admin.report_account": "{name} паскардзіўся на {count, plural, one {# допіс} many {# допісаў} other {# допіса}} ад {target} з прычыны {category}", "notification.admin.report_account": "{name} паскардзіўся на {count, plural, one {# допіс} many {# допісаў} other {# допіса}} ад {target} з прычыны {category}",
@ -497,7 +551,10 @@
"notification.admin.report_statuses": "{name} паскардзіўся на {target} з прычыны {category}", "notification.admin.report_statuses": "{name} паскардзіўся на {target} з прычыны {category}",
"notification.admin.report_statuses_other": "{name} паскардзіўся на {target}", "notification.admin.report_statuses_other": "{name} паскардзіўся на {target}",
"notification.admin.sign_up": "{name} зарэгістраваўся", "notification.admin.sign_up": "{name} зарэгістраваўся",
"notification.annual_report.view": "Перайсці да #Wrapstodon",
"notification.favourite": "Ваш допіс упадабаны {name}", "notification.favourite": "Ваш допіс упадабаны {name}",
"notification.favourite_pm": "Ваша асабістае згадванне ўпадабана {name}",
"notification.favourite_pm.name_and_others_with_link": "{name} і <a>{count, plural, one {# іншы} few {# іншыя} many {# іншых} other {# іншых}}</a> ўпадабалі ваша асабістае згадванне",
"notification.follow": "{name} падпісаўся на вас", "notification.follow": "{name} падпісаўся на вас",
"notification.follow_request": "{name} адправіў запыт на падпіску", "notification.follow_request": "{name} адправіў запыт на падпіску",
"notification.follow_request.name_and_others": "{name} і {count, plural, one {# іншы} many {# іншых} other {# іншых}} запыталіся падпісацца на вас", "notification.follow_request.name_and_others": "{name} і {count, plural, one {# іншы} many {# іншых} other {# іншых}} запыталіся падпісацца на вас",
@ -508,7 +565,7 @@
"notification.mention": "Згадванне", "notification.mention": "Згадванне",
"notification.mentioned_you": "{name} згадаў вас", "notification.mentioned_you": "{name} згадаў вас",
"notification.moderation-warning.learn_more": "Даведацца больш", "notification.moderation-warning.learn_more": "Даведацца больш",
"notification.moderation_warning": "Вы атрымалі папярэджанне аб мадэрацыі", "notification.moderation_warning": "Вы атрымалі папярэджанне ад мадэратараў",
"notification.moderation_warning.action_delete_statuses": "Некаторыя вашыя допісы былі выдаленыя.", "notification.moderation_warning.action_delete_statuses": "Некаторыя вашыя допісы былі выдаленыя.",
"notification.moderation_warning.action_disable": "Ваш уліковы запіс быў адключаны.", "notification.moderation_warning.action_disable": "Ваш уліковы запіс быў адключаны.",
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Некаторыя з вашых допісаў былі пазначаныя як далікатныя.", "notification.moderation_warning.action_mark_statuses_as_sensitive": "Некаторыя з вашых допісаў былі пазначаныя як далікатныя.",
@ -539,7 +596,7 @@
"notification_requests.title": "Адфільтраваныя апавяшчэнні", "notification_requests.title": "Адфільтраваныя апавяшчэнні",
"notification_requests.view": "Прагляд апавяшчэнняў", "notification_requests.view": "Прагляд апавяшчэнняў",
"notifications.clear": "Ачысціць апавяшчэнні", "notifications.clear": "Ачысціць апавяшчэнні",
"notifications.clear_confirmation": "Вы ўпэўнены, што жадаеце назаўсёды сцерці ўсё паведамленні?", "notifications.clear_confirmation": "Вы ўпэўнены, што хочаце назаўсёды сцерці ўсе свае паведамленні?",
"notifications.clear_title": "Ачысціць апавяшчэнні?", "notifications.clear_title": "Ачысціць апавяшчэнні?",
"notifications.column_settings.admin.report": "Новыя скаргі:", "notifications.column_settings.admin.report": "Новыя скаргі:",
"notifications.column_settings.admin.sign_up": "Новыя ўваходы:", "notifications.column_settings.admin.sign_up": "Новыя ўваходы:",
@ -549,7 +606,7 @@
"notifications.column_settings.filter_bar.category": "Панэль хуткай фільтрацыі", "notifications.column_settings.filter_bar.category": "Панэль хуткай фільтрацыі",
"notifications.column_settings.follow": "Новыя падпісчыкі:", "notifications.column_settings.follow": "Новыя падпісчыкі:",
"notifications.column_settings.follow_request": "Новыя запыты на падпіску:", "notifications.column_settings.follow_request": "Новыя запыты на падпіску:",
"notifications.column_settings.group": "Аб'яднаць апавяшчэнні ад падпісчыкаў", "notifications.column_settings.group": "Абяднаць апавяшчэнні ад падпісчыкаў",
"notifications.column_settings.mention": "Згадванні:", "notifications.column_settings.mention": "Згадванні:",
"notifications.column_settings.poll": "Вынікі апытання:", "notifications.column_settings.poll": "Вынікі апытання:",
"notifications.column_settings.push": "Push-апавяшчэнні", "notifications.column_settings.push": "Push-апавяшчэнні",
@ -571,13 +628,13 @@
"notifications.group": "{count} Апавяшчэнняў", "notifications.group": "{count} Апавяшчэнняў",
"notifications.mark_as_read": "Пазначыць усе апавяшчэнні як прачытаныя", "notifications.mark_as_read": "Пазначыць усе апавяшчэнні як прачытаныя",
"notifications.permission_denied": "Апавяшчэнні на працоўным стале недаступныя з-за папярэдне адхіленага запыта праў браўзера", "notifications.permission_denied": "Апавяшчэнні на працоўным стале недаступныя з-за папярэдне адхіленага запыта праў браўзера",
"notifications.permission_denied_alert": "Апавяшчэнні на працоўным стале не могуць быць уключаныя, з-за таго што запыт браўзера быў адхілены", "notifications.permission_denied_alert": "З-за таго, што запыт браўзера быў раней адхілены, немагчыма ўключыць апавяшчэнні на працоўным стале",
"notifications.permission_required": "Апавяшчэнні на працоўным стале недаступныя, з-за таго што неабходны дазвол не быў дадзены.", "notifications.permission_required": "Апавяшчэнні на працоўным стале недаступныя, з-за таго што неабходны дазвол не быў дадзены.",
"notifications.policy.accept": "Прыняць", "notifications.policy.accept": "Прыняць",
"notifications.policy.accept_hint": "Паказваць у апавяшчэннях", "notifications.policy.accept_hint": "Паказваць у апавяшчэннях",
"notifications.policy.drop": "Iгнараваць", "notifications.policy.drop": "Iгнараваць",
"notifications.policy.filter": "Фільтраваць", "notifications.policy.filter": "Фільтраваць",
"notifications.policy.filter_limited_accounts_title": "Абмежаваныя ўліковыя запісы", "notifications.policy.filter_limited_accounts_title": "Уліковыя запісы пад мадэрацыяй",
"notifications.policy.filter_new_accounts.hint": "Створаныя на працягу {days, plural, one {апошняга # дня} few {апошніх # дзён} many {апошніх # дзён} other {апошняй # дня}}", "notifications.policy.filter_new_accounts.hint": "Створаныя на працягу {days, plural, one {апошняга # дня} few {апошніх # дзён} many {апошніх # дзён} other {апошняй # дня}}",
"notifications.policy.filter_new_accounts_title": "Новыя ўліковыя запісы", "notifications.policy.filter_new_accounts_title": "Новыя ўліковыя запісы",
"notifications.policy.filter_not_followers_hint": "Уключаючы людзей, якія падпісаны на вас менш, чым {days, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}}", "notifications.policy.filter_not_followers_hint": "Уключаючы людзей, якія падпісаны на вас менш, чым {days, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}}",
@ -594,13 +651,13 @@
"onboarding.follows.done": "Гатова", "onboarding.follows.done": "Гатова",
"onboarding.follows.empty": "На жаль, зараз немагчыма паказаць вынікі. Вы можаце паспрабаваць выкарыстоўваць пошук і праглядзець старонку агляду, каб знайсці людзей, на якіх можна падпісацца, або паўтарыце спробу пазней.", "onboarding.follows.empty": "На жаль, зараз немагчыма паказаць вынікі. Вы можаце паспрабаваць выкарыстоўваць пошук і праглядзець старонку агляду, каб знайсці людзей, на якіх можна падпісацца, або паўтарыце спробу пазней.",
"onboarding.follows.search": "Пошук", "onboarding.follows.search": "Пошук",
"onboarding.follows.title": "Падпішыцеся каб пачаць", "onboarding.follows.title": "Падпішыцеся на некага, каб пачаць",
"onboarding.profile.discoverable": "Зрабіць мой профіль бачным", "onboarding.profile.discoverable": "Зрабіць мой профіль бачным",
"onboarding.profile.discoverable_hint": "Калі вы звяртаецеся да адкрытасці на Mastodon, вашы паведамленні могуць з'яўляцца ў выніках пошуку і тэндэнцый, а ваш профіль можа быць прапанаваны людзям з такімі ж інтарэсамі.", "onboarding.profile.discoverable_hint": "Калі вы звяртаецеся да адкрытасці на Mastodon, вашы паведамленні могуць з'яўляцца ў выніках пошуку і тэндэнцый, а ваш профіль можа быць прапанаваны людзям з такімі ж інтарэсамі.",
"onboarding.profile.display_name": "Бачнае імя", "onboarding.profile.display_name": "Бачнае імя",
"onboarding.profile.display_name_hint": "Ваша поўнае імя або ваш псеўданім…", "onboarding.profile.display_name_hint": "Ваша поўнае імя або ваш псеўданім…",
"onboarding.profile.note": "Біяграфія", "onboarding.profile.note": "Біяграфія",
"onboarding.profile.note_hint": "Вы можаце @згадаць іншых людзей або выкарыстоўваць #хэштэгі…", "onboarding.profile.note_hint": "Вы можаце @згадваць іншых людзей або выкарыстоўваць #хэштэгі…",
"onboarding.profile.save_and_continue": "Захаваць і працягнуць", "onboarding.profile.save_and_continue": "Захаваць і працягнуць",
"onboarding.profile.title": "Налады профілю", "onboarding.profile.title": "Налады профілю",
"onboarding.profile.upload_avatar": "Загрузіць фота профілю", "onboarding.profile.upload_avatar": "Загрузіць фота профілю",
@ -620,6 +677,7 @@
"poll_button.remove_poll": "Выдаліць апытанне", "poll_button.remove_poll": "Выдаліць апытанне",
"privacy.change": "Змяніць прыватнасць допісу", "privacy.change": "Змяніць прыватнасць допісу",
"privacy.direct.long": "Усе згаданыя ў допісе", "privacy.direct.long": "Усе згаданыя ў допісе",
"privacy.direct.short": "Асабістае згадванне",
"privacy.private.long": "Толькі вашыя падпісчыкі", "privacy.private.long": "Толькі вашыя падпісчыкі",
"privacy.private.short": "Падпісчыкі", "privacy.private.short": "Падпісчыкі",
"privacy.public.long": "Усе, хто ёсць і каго няма ў Mastodon", "privacy.public.long": "Усе, хто ёсць і каго няма ў Mastodon",
@ -629,10 +687,10 @@
"privacy.unlisted.short": "Ціхі публічны", "privacy.unlisted.short": "Ціхі публічны",
"privacy_policy.last_updated": "Адноўлена {date}", "privacy_policy.last_updated": "Адноўлена {date}",
"privacy_policy.title": "Палітыка канфідэнцыйнасці", "privacy_policy.title": "Палітыка канфідэнцыйнасці",
"recommended": "Рэкамендуем", "recommended": "Рэкамендаванае",
"refresh": "Абнавiць", "refresh": "Абнавiць",
"regeneration_indicator.please_stand_by": "Калі ласка, пачакайце.", "regeneration_indicator.please_stand_by": "Пачакайце.",
"regeneration_indicator.preparing_your_home_feed": "Рыхтуем вашую стужку…", "regeneration_indicator.preparing_your_home_feed": "Рыхтуем вашу галоўную стужку…",
"relative_time.days": "{number} д", "relative_time.days": "{number} д",
"relative_time.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}} таму", "relative_time.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}} таму",
"relative_time.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}} таму", "relative_time.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}} таму",
@ -649,7 +707,7 @@
"reply_indicator.poll": "Апытанне", "reply_indicator.poll": "Апытанне",
"report.block": "Заблакіраваць", "report.block": "Заблакіраваць",
"report.block_explanation": "Вы перастанеце бачыць допісы гэтага карыстальніка. Ён не зможа сачыць за вамі і бачыць вашы допісы. Ён зможа зразумець, што яго заблакіравалі.", "report.block_explanation": "Вы перастанеце бачыць допісы гэтага карыстальніка. Ён не зможа сачыць за вамі і бачыць вашы допісы. Ён зможа зразумець, што яго заблакіравалі.",
"report.categories.legal": "Права", "report.categories.legal": "Звязанае з правам",
"report.categories.other": "Іншае", "report.categories.other": "Іншае",
"report.categories.spam": "Спам", "report.categories.spam": "Спам",
"report.categories.violation": "Змест парушае адно ці некалькі правілаў сервера", "report.categories.violation": "Змест парушае адно ці некалькі правілаў сервера",
@ -716,8 +774,8 @@
"search_results.accounts": "Профілі", "search_results.accounts": "Профілі",
"search_results.all": "Усё", "search_results.all": "Усё",
"search_results.hashtags": "Хэштэгі", "search_results.hashtags": "Хэштэгі",
"search_results.no_results": "Анічога ня знойдзена.", "search_results.no_results": "Няма вынікаў.",
"search_results.no_search_yet": "Паспрабуйце пашукаць допісы, профілі або гэштаґі.", "search_results.no_search_yet": "Паспрабуйце пашукаць допісы, профілі або хэштэгі.",
"search_results.see_all": "Праглядзець усе", "search_results.see_all": "Праглядзець усе",
"search_results.statuses": "Допісы", "search_results.statuses": "Допісы",
"search_results.title": "Шукаць \"{q}\"", "search_results.title": "Шукаць \"{q}\"",
@ -738,7 +796,7 @@
"status.bookmark": "Дадаць закладку", "status.bookmark": "Дадаць закладку",
"status.cancel_reblog_private": "Прыбраць", "status.cancel_reblog_private": "Прыбраць",
"status.cannot_reblog": "Гэты пост нельга пашырыць", "status.cannot_reblog": "Гэты пост нельга пашырыць",
"status.continued_thread": "Працяг тэмы", "status.continued_thread": "Працяг ланцужка",
"status.copy": "Скапіраваць спасылку на допіс", "status.copy": "Скапіраваць спасылку на допіс",
"status.delete": "Выдаліць", "status.delete": "Выдаліць",
"status.detailed_status": "Дэтальны агляд размовы", "status.detailed_status": "Дэтальны агляд размовы",
@ -763,6 +821,7 @@
"status.mute_conversation": "Ігнараваць размову", "status.mute_conversation": "Ігнараваць размову",
"status.open": "Разгарнуць гэты допіс", "status.open": "Разгарнуць гэты допіс",
"status.pin": "Замацаваць у профілі", "status.pin": "Замацаваць у профілі",
"status.quote_post_author": "Допіс карыстальніка @{name}",
"status.read_more": "Чытаць болей", "status.read_more": "Чытаць болей",
"status.reblog": "Пашырыць", "status.reblog": "Пашырыць",
"status.reblog_private": "Пашырыць з першапачатковай бачнасцю", "status.reblog_private": "Пашырыць з першапачатковай бачнасцю",
@ -771,7 +830,7 @@
"status.reblogs.empty": "Гэты допіс яшчэ ніхто не пашырыў. Калі гэта адбудзецца, гэтых людзей будзе бачна тут.", "status.reblogs.empty": "Гэты допіс яшчэ ніхто не пашырыў. Калі гэта адбудзецца, гэтых людзей будзе бачна тут.",
"status.redraft": "Выдаліць і паправіць", "status.redraft": "Выдаліць і паправіць",
"status.remove_bookmark": "Выдаліць закладку", "status.remove_bookmark": "Выдаліць закладку",
"status.replied_in_thread": "Адказаў у тэме", "status.replied_in_thread": "Адказаў у ланцужку",
"status.replied_to": "Адказаў {name}", "status.replied_to": "Адказаў {name}",
"status.reply": "Адказаць", "status.reply": "Адказаць",
"status.replyAll": "Адказаць у ланцугу", "status.replyAll": "Адказаць у ланцугу",
@ -791,8 +850,11 @@
"subscribed_languages.save": "Захаваць змены", "subscribed_languages.save": "Захаваць змены",
"subscribed_languages.target": "Змяніць мовы падпіскі для {target}", "subscribed_languages.target": "Змяніць мовы падпіскі для {target}",
"tabs_bar.home": "Галоўная", "tabs_bar.home": "Галоўная",
"tabs_bar.menu": "Меню",
"tabs_bar.notifications": "Апавяшчэнні", "tabs_bar.notifications": "Апавяшчэнні",
"terms_of_service.title": "Умовы абслугоўваньня", "tabs_bar.publish": "Новы допіс",
"tabs_bar.search": "Пошук",
"terms_of_service.title": "Умовы выкарыстання",
"time_remaining.days": "{number, plural, one {застаўся # дзень} few {засталося # дні} many {засталося # дзён} other {засталося # дня}}", "time_remaining.days": "{number, plural, one {застаўся # дзень} few {засталося # дні} many {засталося # дзён} other {засталося # дня}}",
"time_remaining.hours": "{number, plural, one {засталася # гадзіна} few {засталося # гадзіны} many {засталося # гадзін} other {засталося # гадзіны}}", "time_remaining.hours": "{number, plural, one {засталася # гадзіна} few {засталося # гадзіны} many {засталося # гадзін} other {засталося # гадзіны}}",
"time_remaining.minutes": "{number, plural, one {засталася # хвіліна} few {засталося # хвіліны} many {засталося # хвілін} other {засталося # хвіліны}}", "time_remaining.minutes": "{number, plural, one {засталася # хвіліна} few {засталося # хвіліны} many {засталося # хвілін} other {засталося # хвіліны}}",
@ -818,6 +880,12 @@
"video.expand": "Разгарнуць відэа", "video.expand": "Разгарнуць відэа",
"video.fullscreen": "Увесь экран", "video.fullscreen": "Увесь экран",
"video.hide": "Схаваць відэа", "video.hide": "Схаваць відэа",
"video.mute": "Выключыць гук",
"video.pause": "Паўза", "video.pause": "Паўза",
"video.play": "Прайграць" "video.play": "Прайграць",
"video.skip_backward": "Праматаць назад",
"video.skip_forward": "Праматаць уперад",
"video.unmute": "Уключыць гук",
"video.volume_down": "Паменшыць гучнасць",
"video.volume_up": "Павялічыць гучнасць"
} }

View File

@ -8,6 +8,7 @@
"about.domain_blocks.silenced.title": "Ограничено", "about.domain_blocks.silenced.title": "Ограничено",
"about.domain_blocks.suspended.explanation": "Никакви данни от този сървър няма да се обработват, съхраняват или обменят, правещи невъзможно всяко взаимодействие или комуникация с потребители от тези сървъри.", "about.domain_blocks.suspended.explanation": "Никакви данни от този сървър няма да се обработват, съхраняват или обменят, правещи невъзможно всяко взаимодействие или комуникация с потребители от тези сървъри.",
"about.domain_blocks.suspended.title": "Спряно", "about.domain_blocks.suspended.title": "Спряно",
"about.language_label": "Език",
"about.not_available": "Тази информация не е публична на този сървър.", "about.not_available": "Тази информация не е публична на този сървър.",
"about.powered_by": "Децентрализирана социална мрежа, захранвана от {mastodon}", "about.powered_by": "Децентрализирана социална мрежа, захранвана от {mastodon}",
"about.rules": "Правила на сървъра", "about.rules": "Правила на сървъра",
@ -28,6 +29,7 @@
"account.edit_profile": "Редактиране на профила", "account.edit_profile": "Редактиране на профила",
"account.enable_notifications": "Известяване при публикуване от @{name}", "account.enable_notifications": "Известяване при публикуване от @{name}",
"account.endorse": "Представи в профила", "account.endorse": "Представи в профила",
"account.familiar_followers_many": "Последвано от {name1}, {name2}, и {othersCount, plural, one {един друг, когото познавате} other {# други, които познавате}}",
"account.familiar_followers_one": "Последвано от {name1}", "account.familiar_followers_one": "Последвано от {name1}",
"account.familiar_followers_two": "Последвано от {name1} и {name2}", "account.familiar_followers_two": "Последвано от {name1} и {name2}",
"account.featured": "Препоръчано", "account.featured": "Препоръчано",
@ -40,6 +42,7 @@
"account.followers": "Последователи", "account.followers": "Последователи",
"account.followers.empty": "Още никой не следва потребителя.", "account.followers.empty": "Още никой не следва потребителя.",
"account.followers_counter": "{count, plural, one {{counter} последовател} other {{counter} последователи}}", "account.followers_counter": "{count, plural, one {{counter} последовател} other {{counter} последователи}}",
"account.followers_you_know_counter": "{counter} познавате",
"account.following": "Последвано", "account.following": "Последвано",
"account.following_counter": "{count, plural, one {{counter} последван} other {{counter} последвани}}", "account.following_counter": "{count, plural, one {{counter} последван} other {{counter} последвани}}",
"account.follows.empty": "Потребителят още никого не следва.", "account.follows.empty": "Потребителят още никого не следва.",
@ -185,7 +188,7 @@
"community.column_settings.remote_only": "Само отдалечено", "community.column_settings.remote_only": "Само отдалечено",
"compose.language.change": "Смяна на езика", "compose.language.change": "Смяна на езика",
"compose.language.search": "Търсене на езици...", "compose.language.search": "Търсене на езици...",
"compose.published.body": "Публикувана публикация.", "compose.published.body": "Публикувано.",
"compose.published.open": "Отваряне", "compose.published.open": "Отваряне",
"compose.saved.body": "Запазена публикация.", "compose.saved.body": "Запазена публикация.",
"compose_form.direct_message_warning_learn_more": "Още информация", "compose_form.direct_message_warning_learn_more": "Още информация",
@ -215,6 +218,13 @@
"confirmations.delete_list.confirm": "Изтриване", "confirmations.delete_list.confirm": "Изтриване",
"confirmations.delete_list.message": "Наистина ли искате да изтриете завинаги списъка?", "confirmations.delete_list.message": "Наистина ли искате да изтриете завинаги списъка?",
"confirmations.delete_list.title": "Изтривате ли списъка?", "confirmations.delete_list.title": "Изтривате ли списъка?",
"confirmations.discard_draft.confirm": "Изхвърляне и продължаване",
"confirmations.discard_draft.edit.cancel": "Възобновяване на редактиране",
"confirmations.discard_draft.edit.message": "Продължаването ще изхвърли всякакви промени, които сте правили в публикацията, която сега редактирате.",
"confirmations.discard_draft.edit.title": "Изхвърляте ли промените в публикацията си?",
"confirmations.discard_draft.post.cancel": "Възстановка на чернова",
"confirmations.discard_draft.post.message": "Продължаването ще изхвърли публикацията, която сега съставяте.",
"confirmations.discard_draft.post.title": "Изхвърляте ли черновата си публикация?",
"confirmations.discard_edit_media.confirm": "Отхвърляне", "confirmations.discard_edit_media.confirm": "Отхвърляне",
"confirmations.discard_edit_media.message": "Не сте запазили промени на описанието или огледа на мултимедията, отхвърляте ли ги?", "confirmations.discard_edit_media.message": "Не сте запазили промени на описанието или огледа на мултимедията, отхвърляте ли ги?",
"confirmations.follow_to_list.confirm": "Последване и добавяне в списък", "confirmations.follow_to_list.confirm": "Последване и добавяне в списък",
@ -324,9 +334,15 @@
"errors.unexpected_crash.copy_stacktrace": "Копиране на трасето на стека в буферната памет", "errors.unexpected_crash.copy_stacktrace": "Копиране на трасето на стека в буферната памет",
"errors.unexpected_crash.report_issue": "Сигнал за проблем", "errors.unexpected_crash.report_issue": "Сигнал за проблем",
"explore.suggested_follows": "Хора", "explore.suggested_follows": "Хора",
"explore.title": "Вървежно",
"explore.trending_links": "Новини", "explore.trending_links": "Новини",
"explore.trending_statuses": "Публикации", "explore.trending_statuses": "Публикации",
"explore.trending_tags": "Хаштагове", "explore.trending_tags": "Хаштагове",
"featured_carousel.header": "{count, plural, one {закачена публикация} other {закачени публикации}}",
"featured_carousel.next": "Напред",
"featured_carousel.post": "Публикация",
"featured_carousel.previous": "Назад",
"featured_carousel.slide": "{index} от {total}",
"filter_modal.added.context_mismatch_explanation": "Тази категория филтър не е приложима към контекста, в който достъпвате тази публикация. Ако желаете да филтрирате публикациите в този контекст, трябва да изберете друг филтър.", "filter_modal.added.context_mismatch_explanation": "Тази категория филтър не е приложима към контекста, в който достъпвате тази публикация. Ако желаете да филтрирате публикациите в този контекст, трябва да изберете друг филтър.",
"filter_modal.added.context_mismatch_title": "Несъвпадащ контекст!", "filter_modal.added.context_mismatch_title": "Несъвпадащ контекст!",
"filter_modal.added.expired_explanation": "Валидността на тази категория филтър е изтекла. Сменете срока на валидност, за да я приложите.", "filter_modal.added.expired_explanation": "Валидността на тази категория филтър е изтекла. Сменете срока на валидност, за да я приложите.",
@ -403,8 +419,7 @@
"hints.profiles.see_more_followers": "Преглед на още последователи на {domain}", "hints.profiles.see_more_followers": "Преглед на още последователи на {domain}",
"hints.profiles.see_more_follows": "Преглед на още последвания на {domain}", "hints.profiles.see_more_follows": "Преглед на още последвания на {domain}",
"hints.profiles.see_more_posts": "Преглед на още публикации на {domain}", "hints.profiles.see_more_posts": "Преглед на още публикации на {domain}",
"hints.threads.replies_may_be_missing": "Отговори от други сървъри може да липсват.", "home.column_settings.show_quotes": "Показване на цитираното",
"hints.threads.see_more": "Преглед на още отговори на {domain}",
"home.column_settings.show_reblogs": "Показване на подсилванията", "home.column_settings.show_reblogs": "Показване на подсилванията",
"home.column_settings.show_replies": "Показване на отговорите", "home.column_settings.show_replies": "Показване на отговорите",
"home.hide_announcements": "Скриване на оповестяванията", "home.hide_announcements": "Скриване на оповестяванията",
@ -527,8 +542,10 @@
"mute_modal.you_wont_see_mentions": "Няма да виждате споменаващите ги публикации.", "mute_modal.you_wont_see_mentions": "Няма да виждате споменаващите ги публикации.",
"mute_modal.you_wont_see_posts": "Още могат да виждат публикациите ви, но вие техните не.", "mute_modal.you_wont_see_posts": "Още могат да виждат публикациите ви, но вие техните не.",
"navigation_bar.about": "Относно", "navigation_bar.about": "Относно",
"navigation_bar.account_settings": "Парола и сигурност",
"navigation_bar.administration": "Администрация", "navigation_bar.administration": "Администрация",
"navigation_bar.advanced_interface": "Отваряне в разширен уебинтерфейс", "navigation_bar.advanced_interface": "Отваряне в разширен уебинтерфейс",
"navigation_bar.automated_deletion": "Автоматично изтриване на публикации",
"navigation_bar.blocks": "Блокирани потребители", "navigation_bar.blocks": "Блокирани потребители",
"navigation_bar.bookmarks": "Отметки", "navigation_bar.bookmarks": "Отметки",
"navigation_bar.direct": "Частни споменавания", "navigation_bar.direct": "Частни споменавания",
@ -538,13 +555,17 @@
"navigation_bar.follow_requests": "Заявки за последване", "navigation_bar.follow_requests": "Заявки за последване",
"navigation_bar.followed_tags": "Последвани хаштагове", "navigation_bar.followed_tags": "Последвани хаштагове",
"navigation_bar.follows_and_followers": "Последвания и последователи", "navigation_bar.follows_and_followers": "Последвания и последователи",
"navigation_bar.import_export": "Внасяне и изнасяне",
"navigation_bar.lists": "Списъци", "navigation_bar.lists": "Списъци",
"navigation_bar.logout": "Излизане", "navigation_bar.logout": "Излизане",
"navigation_bar.moderation": "Модериране", "navigation_bar.moderation": "Модериране",
"navigation_bar.more": "Още",
"navigation_bar.mutes": "Заглушени потребители", "navigation_bar.mutes": "Заглушени потребители",
"navigation_bar.opened_in_classic_interface": "Публикации, акаунти и други особени страници се отварят по подразбиране в класическия мрежови интерфейс.", "navigation_bar.opened_in_classic_interface": "Публикации, акаунти и други особени страници се отварят по подразбиране в класическия мрежови интерфейс.",
"navigation_bar.preferences": "Предпочитания", "navigation_bar.preferences": "Предпочитания",
"navigation_bar.privacy_and_reach": "Поверителност и обхват",
"navigation_bar.search": "Търсене", "navigation_bar.search": "Търсене",
"navigation_bar.search_trends": "Търсене / Вървежно",
"not_signed_in_indicator.not_signed_in": "Трябва ви вход за достъп до ресурса.", "not_signed_in_indicator.not_signed_in": "Трябва ви вход за достъп до ресурса.",
"notification.admin.report": "{name} докладва {target}", "notification.admin.report": "{name} докладва {target}",
"notification.admin.report_account": "{name} докладва {count, plural, one {публикация} other {# публикации}} от {target} за {category}", "notification.admin.report_account": "{name} докладва {count, plural, one {публикация} other {# публикации}} от {target} за {category}",
@ -771,6 +792,7 @@
"report_notification.categories.violation": "Нарушение на правилото", "report_notification.categories.violation": "Нарушение на правилото",
"report_notification.categories.violation_sentence": "нарушение на правило", "report_notification.categories.violation_sentence": "нарушение на правило",
"report_notification.open": "Отваряне на доклада", "report_notification.open": "Отваряне на доклада",
"search.clear": "Изчистване на търсенето",
"search.no_recent_searches": "Няма скорошни търсения", "search.no_recent_searches": "Няма скорошни търсения",
"search.placeholder": "Търсене", "search.placeholder": "Търсене",
"search.quick_action.account_search": "Съвпадение на профили {x}", "search.quick_action.account_search": "Съвпадение на профили {x}",
@ -837,6 +859,13 @@
"status.mute_conversation": "Заглушаване на разговора", "status.mute_conversation": "Заглушаване на разговора",
"status.open": "Разширяване на публикацията", "status.open": "Разширяване на публикацията",
"status.pin": "Закачане в профила", "status.pin": "Закачане в профила",
"status.quote_error.filtered": "Скрито поради един от филтрите ви",
"status.quote_error.not_found": "Публикацията не може да се показва.",
"status.quote_error.pending_approval": "Публикацията чака одобрение от първоначалния автор.",
"status.quote_error.rejected": "Публикацията не може да се показва като първоначалния автор не позволява цитирането ѝ.",
"status.quote_error.removed": "Публикацията е премахната от автора ѝ.",
"status.quote_error.unauthorized": "Публикацията не може да се показва, тъй като не сте упълномощени да я гледате.",
"status.quote_post_author": "Публикация от {name}",
"status.read_more": "Още за четене", "status.read_more": "Още за четене",
"status.reblog": "Подсилване", "status.reblog": "Подсилване",
"status.reblog_private": "Подсилване с оригиналната видимост", "status.reblog_private": "Подсилване с оригиналната видимост",
@ -866,7 +895,10 @@
"subscribed_languages.save": "Запазване на промените", "subscribed_languages.save": "Запазване на промените",
"subscribed_languages.target": "Промяна на абонираните езици за {target}", "subscribed_languages.target": "Промяна на абонираните езици за {target}",
"tabs_bar.home": "Начало", "tabs_bar.home": "Начало",
"tabs_bar.menu": "Меню",
"tabs_bar.notifications": "Известия", "tabs_bar.notifications": "Известия",
"tabs_bar.publish": "Нова публикация",
"tabs_bar.search": "Търсене",
"terms_of_service.effective_as_of": "В сила от {date}", "terms_of_service.effective_as_of": "В сила от {date}",
"terms_of_service.title": "Условия на услугата", "terms_of_service.title": "Условия на услугата",
"terms_of_service.upcoming_changes_on": "Предстоящи промени на {date}", "terms_of_service.upcoming_changes_on": "Предстоящи промени на {date}",

View File

@ -558,6 +558,8 @@
"status.bookmark": "Ouzhpennañ d'ar sinedoù", "status.bookmark": "Ouzhpennañ d'ar sinedoù",
"status.cancel_reblog_private": "Nac'hañ ar skignadenn", "status.cancel_reblog_private": "Nac'hañ ar skignadenn",
"status.cannot_reblog": "Ar c'hannad-se na c'hall ket bezañ skignet", "status.cannot_reblog": "Ar c'hannad-se na c'hall ket bezañ skignet",
"status.context.load_new_replies": "Respontoù nevez zo",
"status.context.loading": "O kerc'hat muioc'h a respontoù",
"status.copy": "Eilañ liamm ar c'hannad", "status.copy": "Eilañ liamm ar c'hannad",
"status.delete": "Dilemel", "status.delete": "Dilemel",
"status.detailed_status": "Gwel kaozeadenn munudek", "status.detailed_status": "Gwel kaozeadenn munudek",

View File

@ -219,6 +219,13 @@
"confirmations.delete_list.confirm": "Elimina", "confirmations.delete_list.confirm": "Elimina",
"confirmations.delete_list.message": "Segur que vols suprimir permanentment aquesta llista?", "confirmations.delete_list.message": "Segur que vols suprimir permanentment aquesta llista?",
"confirmations.delete_list.title": "Eliminar la llista?", "confirmations.delete_list.title": "Eliminar la llista?",
"confirmations.discard_draft.confirm": "Descarta i continua",
"confirmations.discard_draft.edit.cancel": "Continua l'edició",
"confirmations.discard_draft.edit.message": "Continuar descartarà tots els canvis que hàgiu fet a la publicació que editeu.",
"confirmations.discard_draft.edit.title": "Descartar els canvis a la publicació?",
"confirmations.discard_draft.post.cancel": "Reprendre l'esborrany",
"confirmations.discard_draft.post.message": "Continuar descartarà la publicació que escriviu.",
"confirmations.discard_draft.post.title": "Descartar l'esborrany?",
"confirmations.discard_edit_media.confirm": "Descarta", "confirmations.discard_edit_media.confirm": "Descarta",
"confirmations.discard_edit_media.message": "Tens canvis no desats en la descripció del contingut o en la previsualització, els vols descartar?", "confirmations.discard_edit_media.message": "Tens canvis no desats en la descripció del contingut o en la previsualització, els vols descartar?",
"confirmations.follow_to_list.confirm": "Seguir i afegir a una llista", "confirmations.follow_to_list.confirm": "Seguir i afegir a una llista",
@ -417,8 +424,6 @@
"hints.profiles.see_more_followers": "Vegeu més seguidors a {domain}", "hints.profiles.see_more_followers": "Vegeu més seguidors a {domain}",
"hints.profiles.see_more_follows": "Vegeu més seguiments a {domain}", "hints.profiles.see_more_follows": "Vegeu més seguiments a {domain}",
"hints.profiles.see_more_posts": "Vegeu més publicacions a {domain}", "hints.profiles.see_more_posts": "Vegeu més publicacions a {domain}",
"hints.threads.replies_may_be_missing": "Es poden haver perdut respostes d'altres servidors.",
"hints.threads.see_more": "Vegeu més respostes a {domain}",
"home.column_settings.show_quotes": "Mostrar les cites", "home.column_settings.show_quotes": "Mostrar les cites",
"home.column_settings.show_reblogs": "Mostra els impulsos", "home.column_settings.show_reblogs": "Mostra els impulsos",
"home.column_settings.show_replies": "Mostra les respostes", "home.column_settings.show_replies": "Mostra les respostes",
@ -556,6 +561,8 @@
"navigation_bar.follows_and_followers": "Seguint i seguidors", "navigation_bar.follows_and_followers": "Seguint i seguidors",
"navigation_bar.import_export": "Importació i exportació", "navigation_bar.import_export": "Importació i exportació",
"navigation_bar.lists": "Llistes", "navigation_bar.lists": "Llistes",
"navigation_bar.live_feed_local": "Canal en directe (local)",
"navigation_bar.live_feed_public": "Canal en directe (públic)",
"navigation_bar.logout": "Tanca la sessió", "navigation_bar.logout": "Tanca la sessió",
"navigation_bar.moderation": "Moderació", "navigation_bar.moderation": "Moderació",
"navigation_bar.more": "Més", "navigation_bar.more": "Més",
@ -564,7 +571,10 @@
"navigation_bar.preferences": "Preferències", "navigation_bar.preferences": "Preferències",
"navigation_bar.privacy_and_reach": "Privacitat i abast", "navigation_bar.privacy_and_reach": "Privacitat i abast",
"navigation_bar.search": "Cerca", "navigation_bar.search": "Cerca",
"navigation_bar.search_trends": "Cerca / En tendència",
"navigation_panel.collapse_followed_tags": "Comprimeix el menú d'etiquetes seguides",
"navigation_panel.collapse_lists": "Tanca el menú", "navigation_panel.collapse_lists": "Tanca el menú",
"navigation_panel.expand_followed_tags": "Expandeix el menú d'etiquetes seguides",
"navigation_panel.expand_lists": "Expandeix el menú", "navigation_panel.expand_lists": "Expandeix el menú",
"not_signed_in_indicator.not_signed_in": "Cal que iniciïs la sessió per a accedir a aquest recurs.", "not_signed_in_indicator.not_signed_in": "Cal que iniciïs la sessió per a accedir a aquest recurs.",
"notification.admin.report": "{name} ha reportat {target}", "notification.admin.report": "{name} ha reportat {target}",
@ -792,6 +802,7 @@
"report_notification.categories.violation": "Violació de norma", "report_notification.categories.violation": "Violació de norma",
"report_notification.categories.violation_sentence": "violació de normes", "report_notification.categories.violation_sentence": "violació de normes",
"report_notification.open": "Obre l'informe", "report_notification.open": "Obre l'informe",
"search.clear": "Esborra la cerca",
"search.no_recent_searches": "No hi ha cerques recents", "search.no_recent_searches": "No hi ha cerques recents",
"search.placeholder": "Cerca", "search.placeholder": "Cerca",
"search.quick_action.account_search": "Perfils coincidint amb {x}", "search.quick_action.account_search": "Perfils coincidint amb {x}",
@ -833,6 +844,8 @@
"status.bookmark": "Marca", "status.bookmark": "Marca",
"status.cancel_reblog_private": "Desfés l'impuls", "status.cancel_reblog_private": "Desfés l'impuls",
"status.cannot_reblog": "No es pot impulsar aquest tut", "status.cannot_reblog": "No es pot impulsar aquest tut",
"status.context.load_new_replies": "Hi ha respostes noves",
"status.context.loading": "Comprovació de més respostes",
"status.continued_thread": "Continuació del fil", "status.continued_thread": "Continuació del fil",
"status.copy": "Copia l'enllaç al tut", "status.copy": "Copia l'enllaç al tut",
"status.delete": "Elimina", "status.delete": "Elimina",

View File

@ -424,8 +424,6 @@
"hints.profiles.see_more_followers": "Zobrazit více sledujících na {domain}", "hints.profiles.see_more_followers": "Zobrazit více sledujících na {domain}",
"hints.profiles.see_more_follows": "Zobrazit další sledování na {domain}", "hints.profiles.see_more_follows": "Zobrazit další sledování na {domain}",
"hints.profiles.see_more_posts": "Zobrazit další příspěvky na {domain}", "hints.profiles.see_more_posts": "Zobrazit další příspěvky na {domain}",
"hints.threads.replies_may_be_missing": "Odpovědi z jiných serverů mohou chybět.",
"hints.threads.see_more": "Zobrazit další odpovědi na {domain}",
"home.column_settings.show_quotes": "Zobrazit citace", "home.column_settings.show_quotes": "Zobrazit citace",
"home.column_settings.show_reblogs": "Zobrazit boosty", "home.column_settings.show_reblogs": "Zobrazit boosty",
"home.column_settings.show_replies": "Zobrazit odpovědi", "home.column_settings.show_replies": "Zobrazit odpovědi",
@ -847,6 +845,8 @@
"status.bookmark": "Přidat do záložek", "status.bookmark": "Přidat do záložek",
"status.cancel_reblog_private": "Zrušit boostnutí", "status.cancel_reblog_private": "Zrušit boostnutí",
"status.cannot_reblog": "Tento příspěvek nemůže být boostnutý", "status.cannot_reblog": "Tento příspěvek nemůže být boostnutý",
"status.context.load_new_replies": "K dispozici jsou nové odpovědi",
"status.context.loading": "Hledání dalších odpovědí",
"status.continued_thread": "Pokračuje ve vlákně", "status.continued_thread": "Pokračuje ve vlákně",
"status.copy": "Zkopírovat odkaz na příspěvek", "status.copy": "Zkopírovat odkaz na příspěvek",
"status.delete": "Smazat", "status.delete": "Smazat",

View File

@ -424,8 +424,6 @@
"hints.profiles.see_more_followers": "Gweld mwy o ddilynwyr ar {domain}", "hints.profiles.see_more_followers": "Gweld mwy o ddilynwyr ar {domain}",
"hints.profiles.see_more_follows": "Gweld mwy o 'yn dilyn' ar {domain}", "hints.profiles.see_more_follows": "Gweld mwy o 'yn dilyn' ar {domain}",
"hints.profiles.see_more_posts": "Gweld mwy o bostiadau ar {domain}", "hints.profiles.see_more_posts": "Gweld mwy o bostiadau ar {domain}",
"hints.threads.replies_may_be_missing": "Mae'n bosibl y bydd ymatebion gan weinyddion eraill ar goll.",
"hints.threads.see_more": "Gweld mwy o ymatebion ar {domain}",
"home.column_settings.show_quotes": "Dangos dyfyniadau", "home.column_settings.show_quotes": "Dangos dyfyniadau",
"home.column_settings.show_reblogs": "Dangos hybiau", "home.column_settings.show_reblogs": "Dangos hybiau",
"home.column_settings.show_replies": "Dangos ymatebion", "home.column_settings.show_replies": "Dangos ymatebion",

View File

@ -30,7 +30,7 @@
"account.edit_profile": "Redigér profil", "account.edit_profile": "Redigér profil",
"account.enable_notifications": "Giv mig besked, når @{name} laver indlæg", "account.enable_notifications": "Giv mig besked, når @{name} laver indlæg",
"account.endorse": "Fremhæv på profil", "account.endorse": "Fremhæv på profil",
"account.familiar_followers_many": "Følges af {name1}, {name2} og {othersCount, plural, one {# mere, man kender} other {# mere, man kender}}", "account.familiar_followers_many": "Følges af {name1}, {name2} og {othersCount, plural, one {# mere, man kender} other {# mere, du kender}}",
"account.familiar_followers_one": "Følges af {name1}", "account.familiar_followers_one": "Følges af {name1}",
"account.familiar_followers_two": "Følges af {name1} og {name2}", "account.familiar_followers_two": "Følges af {name1} og {name2}",
"account.featured": "Fremhævet", "account.featured": "Fremhævet",
@ -62,7 +62,7 @@
"account.mute_notifications_short": "Sluk for notifikationer", "account.mute_notifications_short": "Sluk for notifikationer",
"account.mute_short": "Skjul", "account.mute_short": "Skjul",
"account.muted": "Skjult", "account.muted": "Skjult",
"account.muting": "Tavsgørelse", "account.muting": "Skjuler",
"account.mutual": "I følger hinanden", "account.mutual": "I følger hinanden",
"account.no_bio": "Ingen beskrivelse til rådighed.", "account.no_bio": "Ingen beskrivelse til rådighed.",
"account.open_original_page": "Åbn oprindelig side", "account.open_original_page": "Åbn oprindelig side",
@ -83,7 +83,7 @@
"account.unendorse": "Vis ikke på profil", "account.unendorse": "Vis ikke på profil",
"account.unfollow": "Følg ikke længere", "account.unfollow": "Følg ikke længere",
"account.unmute": "Vis @{name} igen", "account.unmute": "Vis @{name} igen",
"account.unmute_notifications_short": "Tænd for notifikationer", "account.unmute_notifications_short": "Vis notifikationer igen",
"account.unmute_short": "Vis igen", "account.unmute_short": "Vis igen",
"account_note.placeholder": "Klik for at tilføje notat", "account_note.placeholder": "Klik for at tilføje notat",
"admin.dashboard.daily_retention": "Brugerfastholdelsesrate pr. dag efter tilmelding", "admin.dashboard.daily_retention": "Brugerfastholdelsesrate pr. dag efter tilmelding",
@ -130,7 +130,7 @@
"attachments_list.unprocessed": "(ubehandlet)", "attachments_list.unprocessed": "(ubehandlet)",
"audio.hide": "Skjul lyd", "audio.hide": "Skjul lyd",
"block_modal.remote_users_caveat": "Serveren {domain} vil blive bedt om at respektere din beslutning. Overholdelse er dog ikke garanteret, da nogle servere kan håndtere blokke forskelligt. Offentlige indlæg kan stadig være synlige for ikke-indloggede brugere.", "block_modal.remote_users_caveat": "Serveren {domain} vil blive bedt om at respektere din beslutning. Overholdelse er dog ikke garanteret, da nogle servere kan håndtere blokke forskelligt. Offentlige indlæg kan stadig være synlige for ikke-indloggede brugere.",
"block_modal.show_less": "Vis færre", "block_modal.show_less": "Vis mindre",
"block_modal.show_more": "Vis flere", "block_modal.show_more": "Vis flere",
"block_modal.they_cant_mention": "Vedkommende kan ikke omtale eller følge dig.", "block_modal.they_cant_mention": "Vedkommende kan ikke omtale eller følge dig.",
"block_modal.they_cant_see_posts": "Vedkommende kan ikke se dine indlæg, og du vil ikke se vedkommendes.", "block_modal.they_cant_see_posts": "Vedkommende kan ikke se dine indlæg, og du vil ikke se vedkommendes.",
@ -208,9 +208,9 @@
"compose_form.publish": "Publicér", "compose_form.publish": "Publicér",
"compose_form.reply": "Svar", "compose_form.reply": "Svar",
"compose_form.save_changes": "Opdatér", "compose_form.save_changes": "Opdatér",
"compose_form.spoiler.marked": "Fjern emnefelt", "compose_form.spoiler.marked": "Fjern indholdsadvarsel",
"compose_form.spoiler.unmarked": "Tilføj emnefelt", "compose_form.spoiler.unmarked": "Tilføj indholdsadvarsel",
"compose_form.spoiler_placeholder": "Emnefelt (valgfrit)", "compose_form.spoiler_placeholder": "Indholdsadvarsel (valgfri)",
"confirmation_modal.cancel": "Afbryd", "confirmation_modal.cancel": "Afbryd",
"confirmations.block.confirm": "Blokér", "confirmations.block.confirm": "Blokér",
"confirmations.delete.confirm": "Slet", "confirmations.delete.confirm": "Slet",
@ -239,9 +239,9 @@
"confirmations.missing_alt_text.secondary": "Læg op alligevel", "confirmations.missing_alt_text.secondary": "Læg op alligevel",
"confirmations.missing_alt_text.title": "Tilføj alt-tekst?", "confirmations.missing_alt_text.title": "Tilføj alt-tekst?",
"confirmations.mute.confirm": "Skjul", "confirmations.mute.confirm": "Skjul",
"confirmations.redraft.confirm": "Slet og omformulér", "confirmations.redraft.confirm": "Slet og omskriv",
"confirmations.redraft.message": "Sikker på, at dette indlæg skal slettes og omskrives? Favoritter og fremhævelser går tabt, og svar til det oprindelige indlæg mister tilknytningen.", "confirmations.redraft.message": "Sikker på, at dette indlæg skal slettes og omskrives? Favoritter og fremhævelser går tabt, og svar til det oprindelige indlæg mister tilknytningen.",
"confirmations.redraft.title": "Slet og omformulér indlæg?", "confirmations.redraft.title": "Slet og omskriv indlæg?",
"confirmations.remove_from_followers.confirm": "Fjern følger", "confirmations.remove_from_followers.confirm": "Fjern følger",
"confirmations.remove_from_followers.message": "{name} vil ikke længere følge dig. Er du sikker på, at du vil fortsætte?", "confirmations.remove_from_followers.message": "{name} vil ikke længere følge dig. Er du sikker på, at du vil fortsætte?",
"confirmations.remove_from_followers.title": "Fjern følger?", "confirmations.remove_from_followers.title": "Fjern følger?",
@ -279,14 +279,14 @@
"domain_pill.activitypub_lets_connect": "Det muliggører at forbinde og interagere med folk, ikke kun på Mastodon, men også på tværs af forskellige sociale apps.", "domain_pill.activitypub_lets_connect": "Det muliggører at forbinde og interagere med folk, ikke kun på Mastodon, men også på tværs af forskellige sociale apps.",
"domain_pill.activitypub_like_language": "ActivityPub er \"sproget\", som Mastodon taler med andre sociale netværk.", "domain_pill.activitypub_like_language": "ActivityPub er \"sproget\", som Mastodon taler med andre sociale netværk.",
"domain_pill.server": "Server", "domain_pill.server": "Server",
"domain_pill.their_handle": "Deres greb:", "domain_pill.their_handle": "Deres handle:",
"domain_pill.their_server": "Det digitale hjem, hvor alle indlæggene findes.", "domain_pill.their_server": "Det digitale hjem, hvor alle indlæggene findes.",
"domain_pill.their_username": "Entydig identifikator på denne server. Det er muligt at finde brugere med samme brugernavn på forskellige servere.", "domain_pill.their_username": "Entydig identifikator på denne server. Det er muligt at finde brugere med samme brugernavn på forskellige servere.",
"domain_pill.username": "Brugernavn", "domain_pill.username": "Brugernavn",
"domain_pill.whats_in_a_handle": "Hvad er der i et greb?", "domain_pill.whats_in_a_handle": "Hvad indeholder et handle?",
"domain_pill.who_they_are": "Da et handle fortæller, hvem nogen er, og hvor de er, kan du interagere med folk på tværs af det sociale net af <button>ActivityPub-drevne platforme</button>.", "domain_pill.who_they_are": "Da et handle fortæller, hvem nogen er, og hvor de er, kan du interagere med folk på tværs af det sociale net af <button>ActivityPub-drevne platforme</button>.",
"domain_pill.who_you_are": "Fordi dit handle fortæller, hvem du er, og hvor du er, kan du interagere med folk på tværs af det sociale net af <button>ActivityPub-drevne platforme</button>.", "domain_pill.who_you_are": "Fordi dit handle fortæller, hvem du er, og hvor du er, kan du interagere med folk på tværs af det sociale net af <button>ActivityPub-drevne platforme</button>.",
"domain_pill.your_handle": "Dit greb:", "domain_pill.your_handle": "Dit handle:",
"domain_pill.your_server": "Dit digitale hjem, hvor alle dine indlæg lever. Synes ikke om den her server? Du kan til enhver tid rykke over på en anden server og beholde dine følgere.", "domain_pill.your_server": "Dit digitale hjem, hvor alle dine indlæg lever. Synes ikke om den her server? Du kan til enhver tid rykke over på en anden server og beholde dine følgere.",
"domain_pill.your_username": "Din entydige identifikator på denne server. Det er muligt at finde brugere med samme brugernavn på forskellige servere.", "domain_pill.your_username": "Din entydige identifikator på denne server. Det er muligt at finde brugere med samme brugernavn på forskellige servere.",
"embed.instructions": "Indlejr dette indlæg på din hjemmeside ved at kopiere nedenstående kode.", "embed.instructions": "Indlejr dette indlæg på din hjemmeside ved at kopiere nedenstående kode.",
@ -329,7 +329,7 @@
"empty_column.mutes": "Du har endnu ikke skjult nogle brugere.", "empty_column.mutes": "Du har endnu ikke skjult nogle brugere.",
"empty_column.notification_requests": "Alt er klar! Der er intet her. Når der modtages nye notifikationer, fremgår de her jævnfør dine indstillinger.", "empty_column.notification_requests": "Alt er klar! Der er intet her. Når der modtages nye notifikationer, fremgår de her jævnfør dine indstillinger.",
"empty_column.notifications": "Du har endnu ingen notifikationer. Når andre interagerer med dig, vil det fremgå her.", "empty_column.notifications": "Du har endnu ingen notifikationer. Når andre interagerer med dig, vil det fremgå her.",
"empty_column.public": "Der er intet her! Skriv noget offentligt eller følg manuelt brugere fra andre servere for at se indhold", "empty_column.public": "Der er ikke noget her! Skriv noget offentligt, eller følg manuelt brugere fra andre servere for at se indhold",
"error.unexpected_crash.explanation": "Grundet en fejl i vores kode, eller en netlæser-kompatibilitetsfejl, kunne siden ikke vises korrekt.", "error.unexpected_crash.explanation": "Grundet en fejl i vores kode, eller en netlæser-kompatibilitetsfejl, kunne siden ikke vises korrekt.",
"error.unexpected_crash.explanation_addons": "Denne side kunne ikke vises korrekt. Fejlen skyldes sandsynligvis en browsertilføjelse eller automatiske oversættelsesværktøjer.", "error.unexpected_crash.explanation_addons": "Denne side kunne ikke vises korrekt. Fejlen skyldes sandsynligvis en browsertilføjelse eller automatiske oversættelsesværktøjer.",
"error.unexpected_crash.next_steps": "Prøv at opfriske siden. Hjælper dette ikke, kan Mastodon muligvis stadig bruges via en anden netlæser eller app.", "error.unexpected_crash.next_steps": "Prøv at opfriske siden. Hjælper dette ikke, kan Mastodon muligvis stadig bruges via en anden netlæser eller app.",
@ -350,7 +350,7 @@
"filter_modal.added.context_mismatch_title": "Kontekstmisforhold!", "filter_modal.added.context_mismatch_title": "Kontekstmisforhold!",
"filter_modal.added.expired_explanation": "Denne filterkategori er udløbet. Ændr dens udløbsdato, for at anvende den.", "filter_modal.added.expired_explanation": "Denne filterkategori er udløbet. Ændr dens udløbsdato, for at anvende den.",
"filter_modal.added.expired_title": "Udløbet filter!", "filter_modal.added.expired_title": "Udløbet filter!",
"filter_modal.added.review_and_configure": "Gå til {settings_link} for at gennemse og yderligere opsætte denne filterkategori.", "filter_modal.added.review_and_configure": "Gå til {settings_link} for at gennemgå og konfigurere denne filterkategori yderligere.",
"filter_modal.added.review_and_configure_title": "Filterindstillinger", "filter_modal.added.review_and_configure_title": "Filterindstillinger",
"filter_modal.added.settings_link": "indstillingsside", "filter_modal.added.settings_link": "indstillingsside",
"filter_modal.added.short_explanation": "Dette indlæg er nu føjet til følgende filterkategori: {title}.", "filter_modal.added.short_explanation": "Dette indlæg er nu føjet til følgende filterkategori: {title}.",
@ -363,7 +363,7 @@
"filter_modal.select_filter.title": "Filtrér dette indlæg", "filter_modal.select_filter.title": "Filtrér dette indlæg",
"filter_modal.title.status": "Filtrér et indlæg", "filter_modal.title.status": "Filtrér et indlæg",
"filter_warning.matches_filter": "Matcher filteret “<span>{title}</span>”", "filter_warning.matches_filter": "Matcher filteret “<span>{title}</span>”",
"filtered_notifications_banner.pending_requests": "Fra {count, plural, =0 {ingen} one {én person} other {# personer}}, man måske kender", "filtered_notifications_banner.pending_requests": "Fra {count, plural, =0 {ingen} one {én person} other {# personer}}, du måske kender",
"filtered_notifications_banner.title": "Filtrerede notifikationer", "filtered_notifications_banner.title": "Filtrerede notifikationer",
"firehose.all": "Alle", "firehose.all": "Alle",
"firehose.local": "Denne server", "firehose.local": "Denne server",
@ -386,7 +386,7 @@
"follow_suggestions.similar_to_recently_followed_longer": "Minder om profiler, du har fulgt for nylig", "follow_suggestions.similar_to_recently_followed_longer": "Minder om profiler, du har fulgt for nylig",
"follow_suggestions.view_all": "Vis alle", "follow_suggestions.view_all": "Vis alle",
"follow_suggestions.who_to_follow": "Hvem, som skal følges", "follow_suggestions.who_to_follow": "Hvem, som skal følges",
"followed_tags": "Hashtag, som følges", "followed_tags": "Hashtags, som følges",
"footer.about": "Om", "footer.about": "Om",
"footer.directory": "Profiloversigt", "footer.directory": "Profiloversigt",
"footer.get_app": "Hent appen", "footer.get_app": "Hent appen",
@ -414,7 +414,7 @@
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}} i dag", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}} i dag",
"hashtag.feature": "Fremhæv på profil", "hashtag.feature": "Fremhæv på profil",
"hashtag.follow": "Følg hashtag", "hashtag.follow": "Følg hashtag",
"hashtag.mute": "Tavsgør #{hashtag}", "hashtag.mute": "Skjul #{hashtag}",
"hashtag.unfeature": "Fremhæv ikke på profil", "hashtag.unfeature": "Fremhæv ikke på profil",
"hashtag.unfollow": "Følg ikke længere hashtag", "hashtag.unfollow": "Følg ikke længere hashtag",
"hashtags.and_other": "…og {count, plural, one {}other {# flere}}", "hashtags.and_other": "…og {count, plural, one {}other {# flere}}",
@ -424,8 +424,6 @@
"hints.profiles.see_more_followers": "Se flere følgere på {domain}", "hints.profiles.see_more_followers": "Se flere følgere på {domain}",
"hints.profiles.see_more_follows": "Se flere fulgte på {domain}", "hints.profiles.see_more_follows": "Se flere fulgte på {domain}",
"hints.profiles.see_more_posts": "Se flere indlæg på {domain}", "hints.profiles.see_more_posts": "Se flere indlæg på {domain}",
"hints.threads.replies_may_be_missing": "Der kan mangle svar fra andre servere.",
"hints.threads.see_more": "Se flere svar på {domain}",
"home.column_settings.show_quotes": "Vis citater", "home.column_settings.show_quotes": "Vis citater",
"home.column_settings.show_reblogs": "Vis fremhævelser", "home.column_settings.show_reblogs": "Vis fremhævelser",
"home.column_settings.show_replies": "Vis svar", "home.column_settings.show_replies": "Vis svar",
@ -434,7 +432,7 @@
"home.pending_critical_update.link": "Se opdateringer", "home.pending_critical_update.link": "Se opdateringer",
"home.pending_critical_update.title": "Kritisk sikkerhedsopdatering tilgængelig!", "home.pending_critical_update.title": "Kritisk sikkerhedsopdatering tilgængelig!",
"home.show_announcements": "Vis bekendtgørelser", "home.show_announcements": "Vis bekendtgørelser",
"ignore_notifications_modal.disclaimer": "Mastodon kan ikke informere brugere om, at man har ignoreret deres notifikationer. Ignorerer man notifikationer, forhindrer det ikke selve beskedafsendelsen.", "ignore_notifications_modal.disclaimer": "Mastodon kan ikke informere brugere om, at du har ignoreret deres notifikationer. At ignorere notifikationer forhindrer ikke selve beskederne i at blive sendt.",
"ignore_notifications_modal.filter_instead": "Filtrér i stedet", "ignore_notifications_modal.filter_instead": "Filtrér i stedet",
"ignore_notifications_modal.filter_to_act_users": "Du vil stadig kunne acceptere, afvise eller anmelde brugere", "ignore_notifications_modal.filter_to_act_users": "Du vil stadig kunne acceptere, afvise eller anmelde brugere",
"ignore_notifications_modal.filter_to_avoid_confusion": "Filtrering medvirker til at undgå potentiel forvirring", "ignore_notifications_modal.filter_to_avoid_confusion": "Filtrering medvirker til at undgå potentiel forvirring",
@ -492,9 +490,9 @@
"keyboard_shortcuts.reply": "Besvar indlægget", "keyboard_shortcuts.reply": "Besvar indlægget",
"keyboard_shortcuts.requests": "Åbn liste over følgeanmodninger", "keyboard_shortcuts.requests": "Åbn liste over følgeanmodninger",
"keyboard_shortcuts.search": "Fokusér søgebjælke", "keyboard_shortcuts.search": "Fokusér søgebjælke",
"keyboard_shortcuts.spoilers": "Vis/skjul emnefelt", "keyboard_shortcuts.spoilers": "Vis/skjul indholdsadvarsel-felt",
"keyboard_shortcuts.start": "Åbn \"komme i gang\"-kolonne", "keyboard_shortcuts.start": "Åbn \"komme i gang\"-kolonne",
"keyboard_shortcuts.toggle_hidden": "Vis/skjul tekst bag emnefelt", "keyboard_shortcuts.toggle_hidden": "Vis/skjul tekst bag indholdsadvarsel",
"keyboard_shortcuts.toggle_sensitivity": "Vis/skjul medier", "keyboard_shortcuts.toggle_sensitivity": "Vis/skjul medier",
"keyboard_shortcuts.toot": "Påbegynd nyt indlæg", "keyboard_shortcuts.toot": "Påbegynd nyt indlæg",
"keyboard_shortcuts.translate": "for at oversætte et indlæg", "keyboard_shortcuts.translate": "for at oversætte et indlæg",
@ -543,10 +541,10 @@
"mute_modal.hide_options": "Skjul valgmuligheder", "mute_modal.hide_options": "Skjul valgmuligheder",
"mute_modal.indefinite": "Indtil jeg vælger at se dem igen", "mute_modal.indefinite": "Indtil jeg vælger at se dem igen",
"mute_modal.show_options": "Vis valgmuligheder", "mute_modal.show_options": "Vis valgmuligheder",
"mute_modal.they_can_mention_and_follow": "De kan omtale og følge dig, men du vil ikke se dem.", "mute_modal.they_can_mention_and_follow": "Vedkommende kan nævne og følge dig, men vil ikke blive vist.",
"mute_modal.they_wont_know": "De vil ikke vide, at de er blevet skjult.", "mute_modal.they_wont_know": "De vil ikke vide, at de er blevet skjult.",
"mute_modal.title": "Skjul bruger?", "mute_modal.title": "Skjul bruger?",
"mute_modal.you_wont_see_mentions": "Du vil ikke se indlæg som omtaler dem.", "mute_modal.you_wont_see_mentions": "Indlæg, som nævner vedkommende, vises ikke.",
"mute_modal.you_wont_see_posts": "De kan stadig se dine indlæg, men du vil ikke se deres.", "mute_modal.you_wont_see_posts": "De kan stadig se dine indlæg, men du vil ikke se deres.",
"navigation_bar.about": "Om", "navigation_bar.about": "Om",
"navigation_bar.account_settings": "Adgangskode og sikkerhed", "navigation_bar.account_settings": "Adgangskode og sikkerhed",
@ -560,7 +558,7 @@
"navigation_bar.favourites": "Favoritter", "navigation_bar.favourites": "Favoritter",
"navigation_bar.filters": "Skjulte ord", "navigation_bar.filters": "Skjulte ord",
"navigation_bar.follow_requests": "Følgeanmodninger", "navigation_bar.follow_requests": "Følgeanmodninger",
"navigation_bar.followed_tags": "Hashtag, som følges", "navigation_bar.followed_tags": "Hashtags, som følges",
"navigation_bar.follows_and_followers": "Følges og følgere", "navigation_bar.follows_and_followers": "Følges og følgere",
"navigation_bar.import_export": "Import og eksport", "navigation_bar.import_export": "Import og eksport",
"navigation_bar.lists": "Lister", "navigation_bar.lists": "Lister",
@ -572,7 +570,7 @@
"navigation_bar.mutes": "Skjulte brugere", "navigation_bar.mutes": "Skjulte brugere",
"navigation_bar.opened_in_classic_interface": "Indlæg, konti og visse andre sider åbnes som standard i den klassiske webgrænseflade.", "navigation_bar.opened_in_classic_interface": "Indlæg, konti og visse andre sider åbnes som standard i den klassiske webgrænseflade.",
"navigation_bar.preferences": "Præferencer", "navigation_bar.preferences": "Præferencer",
"navigation_bar.privacy_and_reach": "Fortrolighed og udbredelse", "navigation_bar.privacy_and_reach": "Fortrolighed og rækkevidde",
"navigation_bar.search": "Søg", "navigation_bar.search": "Søg",
"navigation_bar.search_trends": "Søg/Trender", "navigation_bar.search_trends": "Søg/Trender",
"navigation_panel.collapse_followed_tags": "Sammenfold menuen Fulgte hashtags", "navigation_panel.collapse_followed_tags": "Sammenfold menuen Fulgte hashtags",
@ -686,11 +684,11 @@
"notifications.policy.filter_limited_accounts_hint": "Begrænset af servermoderatorer", "notifications.policy.filter_limited_accounts_hint": "Begrænset af servermoderatorer",
"notifications.policy.filter_limited_accounts_title": "Modererede konti", "notifications.policy.filter_limited_accounts_title": "Modererede konti",
"notifications.policy.filter_new_accounts.hint": "Oprettet indenfor {days, plural, one {den seneste dag} other {de seneste # dage}}", "notifications.policy.filter_new_accounts.hint": "Oprettet indenfor {days, plural, one {den seneste dag} other {de seneste # dage}}",
"notifications.policy.filter_new_accounts_title": "Ny konti", "notifications.policy.filter_new_accounts_title": "Nye konti",
"notifications.policy.filter_not_followers_hint": "Inklusiv personer, som har fulgt dig {days, plural, one {mindre end én dag} other {færre end # dage}}", "notifications.policy.filter_not_followers_hint": "Inklusiv personer, som har fulgt dig {days, plural, one {mindre end én dag} other {mindre end # dage}}",
"notifications.policy.filter_not_followers_title": "Folk, som ikke følger dig", "notifications.policy.filter_not_followers_title": "Personer, som ikke følger dig",
"notifications.policy.filter_not_following_hint": "Indtil du manuelt godkender dem", "notifications.policy.filter_not_following_hint": "Indtil du manuelt godkender dem",
"notifications.policy.filter_not_following_title": "Folk, du ikke følger", "notifications.policy.filter_not_following_title": "Personer, du ikke følger",
"notifications.policy.filter_private_mentions_hint": "Filtreret, medmindre det er i svar på egen omtale, eller hvis afsenderen følges", "notifications.policy.filter_private_mentions_hint": "Filtreret, medmindre det er i svar på egen omtale, eller hvis afsenderen følges",
"notifications.policy.filter_private_mentions_title": "Uopfordrede private omtaler", "notifications.policy.filter_private_mentions_title": "Uopfordrede private omtaler",
"notifications.policy.title": "Håndtér notifikationer fra…", "notifications.policy.title": "Håndtér notifikationer fra…",
@ -830,7 +828,7 @@
"search_results.see_all": "Vis alle", "search_results.see_all": "Vis alle",
"search_results.statuses": "Indlæg", "search_results.statuses": "Indlæg",
"search_results.title": "Søg efter \"{q}\"", "search_results.title": "Søg efter \"{q}\"",
"server_banner.about_active_users": "Folk, som brugte denne server de seneste 30 dage (månedlige aktive brugere)", "server_banner.about_active_users": "Personer, som brugte denne server de seneste 30 dage (månedlige aktive brugere)",
"server_banner.active_users": "aktive brugere", "server_banner.active_users": "aktive brugere",
"server_banner.administered_by": "Håndteres af:", "server_banner.administered_by": "Håndteres af:",
"server_banner.is_one_of_many": "{domain} er en af de mange uafhængige Mastodon-servere, du kan bruge for at deltage i fediverset.", "server_banner.is_one_of_many": "{domain} er en af de mange uafhængige Mastodon-servere, du kan bruge for at deltage i fediverset.",
@ -847,6 +845,8 @@
"status.bookmark": "Bogmærk", "status.bookmark": "Bogmærk",
"status.cancel_reblog_private": "Fjern fremhævelse", "status.cancel_reblog_private": "Fjern fremhævelse",
"status.cannot_reblog": "Dette indlæg kan ikke fremhæves", "status.cannot_reblog": "Dette indlæg kan ikke fremhæves",
"status.context.load_new_replies": "Nye svar tilgængelige",
"status.context.loading": "Tjekker for flere svar",
"status.continued_thread": "Fortsat tråd", "status.continued_thread": "Fortsat tråd",
"status.copy": "Kopiér link til indlæg", "status.copy": "Kopiér link til indlæg",
"status.delete": "Slet", "status.delete": "Slet",
@ -885,10 +885,10 @@
"status.reblogged_by": "{name} fremhævede", "status.reblogged_by": "{name} fremhævede",
"status.reblogs": "{count, plural, one {# fremhævelse} other {# fremhævelser}}", "status.reblogs": "{count, plural, one {# fremhævelse} other {# fremhævelser}}",
"status.reblogs.empty": "Ingen har endnu fremhævet dette indlæg. Når nogen gør, vil det fremgå hér.", "status.reblogs.empty": "Ingen har endnu fremhævet dette indlæg. Når nogen gør, vil det fremgå hér.",
"status.redraft": "Slet og omformulér", "status.redraft": "Slet og omskriv",
"status.remove_bookmark": "Fjern bogmærke", "status.remove_bookmark": "Fjern bogmærke",
"status.remove_favourite": "Fjern fra favoritter", "status.remove_favourite": "Fjern fra favoritter",
"status.replied_in_thread": "Svaret i tråd", "status.replied_in_thread": "Svarede i tråd",
"status.replied_to": "Svarede {name}", "status.replied_to": "Svarede {name}",
"status.reply": "Besvar", "status.reply": "Besvar",
"status.replyAll": "Svar alle", "status.replyAll": "Svar alle",
@ -902,7 +902,7 @@
"status.translate": "Oversæt", "status.translate": "Oversæt",
"status.translated_from_with": "Oversat fra {lang} ved brug af {provider}", "status.translated_from_with": "Oversat fra {lang} ved brug af {provider}",
"status.uncached_media_warning": "Ingen forhåndsvisning", "status.uncached_media_warning": "Ingen forhåndsvisning",
"status.unmute_conversation": "Genaktivér samtale", "status.unmute_conversation": "Vis samtale",
"status.unpin": "Frigør fra profil", "status.unpin": "Frigør fra profil",
"subscribed_languages.lead": "Efter ændringen vises kun indlæg på de valgte sprog på din hjem- og listetidslinje. Vælger du ingen, vil du modtage indlæg på alle sprog.", "subscribed_languages.lead": "Efter ændringen vises kun indlæg på de valgte sprog på din hjem- og listetidslinje. Vælger du ingen, vil du modtage indlæg på alle sprog.",
"subscribed_languages.save": "Gem ændringer", "subscribed_languages.save": "Gem ændringer",

View File

@ -6,7 +6,7 @@
"about.domain_blocks.no_reason_available": "Grund unbekannt", "about.domain_blocks.no_reason_available": "Grund unbekannt",
"about.domain_blocks.preamble": "Mastodon erlaubt es dir grundsätzlich, alle Inhalte von allen Nutzer*innen auf allen Servern im Fediverse zu sehen und mit ihnen zu interagieren. Für diesen Server gibt es aber ein paar Ausnahmen.", "about.domain_blocks.preamble": "Mastodon erlaubt es dir grundsätzlich, alle Inhalte von allen Nutzer*innen auf allen Servern im Fediverse zu sehen und mit ihnen zu interagieren. Für diesen Server gibt es aber ein paar Ausnahmen.",
"about.domain_blocks.silenced.explanation": "Standardmäßig werden von diesem Server keine Inhalte oder Profile angezeigt. Du kannst die Profile und Inhalte aber dennoch sehen, wenn du explizit nach diesen suchst oder diesen folgst.", "about.domain_blocks.silenced.explanation": "Standardmäßig werden von diesem Server keine Inhalte oder Profile angezeigt. Du kannst die Profile und Inhalte aber dennoch sehen, wenn du explizit nach diesen suchst oder diesen folgst.",
"about.domain_blocks.silenced.title": "Stummgeschaltet", "about.domain_blocks.silenced.title": "Ausgeblendet",
"about.domain_blocks.suspended.explanation": "Es werden keine Daten von diesem Server verarbeitet, gespeichert oder ausgetauscht, sodass eine Interaktion oder Kommunikation mit Nutzer*innen dieses Servers nicht möglich ist.", "about.domain_blocks.suspended.explanation": "Es werden keine Daten von diesem Server verarbeitet, gespeichert oder ausgetauscht, sodass eine Interaktion oder Kommunikation mit Nutzer*innen dieses Servers nicht möglich ist.",
"about.domain_blocks.suspended.title": "Gesperrt", "about.domain_blocks.suspended.title": "Gesperrt",
"about.language_label": "Sprache", "about.language_label": "Sprache",
@ -43,7 +43,7 @@
"account.followers": "Follower", "account.followers": "Follower",
"account.followers.empty": "Diesem Profil folgt noch niemand.", "account.followers.empty": "Diesem Profil folgt noch niemand.",
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}", "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}",
"account.followers_you_know_counter": "{counter} bekannt", "account.followers_you_know_counter": "{counter} Follower kennst Du",
"account.following": "Folge ich", "account.following": "Folge ich",
"account.following_counter": "{count, plural, one {{counter} Folge ich} other {{counter} Folge ich}}", "account.following_counter": "{count, plural, one {{counter} Folge ich} other {{counter} Folge ich}}",
"account.follows.empty": "Dieses Profil folgt noch niemandem.", "account.follows.empty": "Dieses Profil folgt noch niemandem.",
@ -63,7 +63,7 @@
"account.mute_short": "Stummschalten", "account.mute_short": "Stummschalten",
"account.muted": "Stummgeschaltet", "account.muted": "Stummgeschaltet",
"account.muting": "Stummgeschaltet", "account.muting": "Stummgeschaltet",
"account.mutual": "Ihr folgt einander", "account.mutual": "Ihr folgt euch",
"account.no_bio": "Keine Beschreibung verfügbar.", "account.no_bio": "Keine Beschreibung verfügbar.",
"account.open_original_page": "Ursprüngliche Seite öffnen", "account.open_original_page": "Ursprüngliche Seite öffnen",
"account.posts": "Beiträge", "account.posts": "Beiträge",
@ -225,7 +225,7 @@
"confirmations.discard_draft.edit.title": "Änderungen an diesem Beitrag verwerfen?", "confirmations.discard_draft.edit.title": "Änderungen an diesem Beitrag verwerfen?",
"confirmations.discard_draft.post.cancel": "Entwurf fortsetzen", "confirmations.discard_draft.post.cancel": "Entwurf fortsetzen",
"confirmations.discard_draft.post.message": "Beim Fortfahren wird der gerade verfasste Beitrag verworfen.", "confirmations.discard_draft.post.message": "Beim Fortfahren wird der gerade verfasste Beitrag verworfen.",
"confirmations.discard_draft.post.title": "Beitragsentwurf verwerfen?", "confirmations.discard_draft.post.title": "Entwurf verwerfen?",
"confirmations.discard_edit_media.confirm": "Verwerfen", "confirmations.discard_edit_media.confirm": "Verwerfen",
"confirmations.discard_edit_media.message": "Du hast Änderungen an der Medienbeschreibung oder -vorschau vorgenommen, die noch nicht gespeichert sind. Trotzdem verwerfen?", "confirmations.discard_edit_media.message": "Du hast Änderungen an der Medienbeschreibung oder -vorschau vorgenommen, die noch nicht gespeichert sind. Trotzdem verwerfen?",
"confirmations.follow_to_list.confirm": "Folgen und zur Liste hinzufügen", "confirmations.follow_to_list.confirm": "Folgen und zur Liste hinzufügen",
@ -424,8 +424,6 @@
"hints.profiles.see_more_followers": "Weitere Follower auf {domain} ansehen", "hints.profiles.see_more_followers": "Weitere Follower auf {domain} ansehen",
"hints.profiles.see_more_follows": "Weitere gefolgte Profile auf {domain} ansehen", "hints.profiles.see_more_follows": "Weitere gefolgte Profile auf {domain} ansehen",
"hints.profiles.see_more_posts": "Weitere Beiträge auf {domain} ansehen", "hints.profiles.see_more_posts": "Weitere Beiträge auf {domain} ansehen",
"hints.threads.replies_may_be_missing": "Möglicherweise werden nicht alle Antworten von anderen Servern angezeigt.",
"hints.threads.see_more": "Weitere Antworten auf {domain} ansehen",
"home.column_settings.show_quotes": "Zitierte Beiträge anzeigen", "home.column_settings.show_quotes": "Zitierte Beiträge anzeigen",
"home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen", "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
"home.column_settings.show_replies": "Antworten anzeigen", "home.column_settings.show_replies": "Antworten anzeigen",
@ -847,6 +845,8 @@
"status.bookmark": "Lesezeichen setzen", "status.bookmark": "Lesezeichen setzen",
"status.cancel_reblog_private": "Beitrag nicht mehr teilen", "status.cancel_reblog_private": "Beitrag nicht mehr teilen",
"status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden", "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
"status.context.load_new_replies": "Neue Antworten verfügbar",
"status.context.loading": "Weitere Antworten werden abgerufen",
"status.continued_thread": "Fortgeführter Thread", "status.continued_thread": "Fortgeführter Thread",
"status.copy": "Link zum Beitrag kopieren", "status.copy": "Link zum Beitrag kopieren",
"status.delete": "Beitrag löschen", "status.delete": "Beitrag löschen",

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