Compare commits

..

94 commits

Author SHA1 Message Date
972e89558c revert 03818781b6
Some checks failed
Check i18n / check-i18n (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Check formatting / 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 / End to End testing (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / Libvips tests (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (push) Has been cancelled
Ruby Testing / Back to original and return test (push) Has been cancelled
revert EN translation edit
2025-06-15 06:34:57 +02:00
b7b57bcc1a fix
Some checks failed
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / End to End testing (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
JavaScript Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
2025-06-15 06:34:25 +02:00
d7f7a2ed7f Fix
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-15 06:34:07 +02:00
de70fca70d Followed tags + dir
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-15 06:33:48 +02:00
8c73a671ab revert 750f5f4885
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
revert EN translation edit
2025-06-15 06:25:35 +02:00
9f711f854a revert 69fb60271d
Some checks failed
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
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
revert Followed Tags + Profile dir
2025-06-15 04:49:58 +02:00
Mario
69fb60271d Followed Tags + Profile dir
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-14 20:56:08 -04:00
Mario
ebcd0950fd Set max trending tags 2025-06-14 20:45:06 -04:00
Mario
c3ff0887d5 En edit
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-14 19:31:47 -04:00
Mario
4eb1c8305c En edit
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-14 19:16:12 -04:00
Mario
03818781b6 EN translation edit
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-14 19:03:23 -04:00
Mario
f677d534dc JP to EN 2025-06-14 18:46:56 -04:00
Mario
51c8f9592e env production sample 2025-06-14 13:11:54 -04:00
Mario
1f8b3fcc92 Max Chars set via env
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-14 13:10:05 -04:00
Mario
bb7024bf3f Set chars via env
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-14 12:12:06 -04:00
db3dc2ab83 revert 16a2f4fb9e
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
revert Top posts test
2025-06-14 00:31:50 +02:00
820fff38d7 revert 5c5a35be24
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
Historical data migration test / test (16-alpine) (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 (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
revert Fix
2025-06-14 00:31:35 +02:00
3f9dc3747b revert 2af5f750ad
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
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 / Back to original and return test (push) Blocked by required conditions
revert fix 2
2025-06-14 00:31:15 +02:00
d7a8574c6e revert 3f97292e58
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
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 / End to End testing (push) Blocked by required conditions
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
revert fix 3
2025-06-14 00:30:33 +02:00
Mario
3f97292e58 fix 3
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 (test) (push) Waiting to run
Ruby Testing / build (production) (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-12 20:09:39 -04:00
Mario
2af5f750ad fix 2
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / End to End testing (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-12 20:07:10 -04:00
Mario
5c5a35be24 Fix
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 (test) (push) Waiting to run
Ruby Testing / build (production) (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-12 20:04:43 -04:00
Mario
16a2f4fb9e Top posts test
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / End to End testing (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-12 20:00:16 -04:00
Mario
725811a2e3 Set new icons
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / build (production) (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-12 19:17:16 -04:00
41a7f3427c Update README.md
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (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 / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Ruby Testing / Back to original and return test (push) Blocked by required conditions
2025-06-13 00:59:08 +02:00
KMY(雪あすか)
620a895184
Merge pull request #1002 from kmycode/upstream-20250416
Upstream 20250416
2025-04-24 07:44:58 +09:00
KMY
94eb912030 Merge commit 'a324edabdf' into upstream-20250416 2025-04-24 07:27:26 +09:00
renovate[bot]
a324edabdf
chore(deps): update dependency ruby to v3.4.3 (#34459)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-16 06:53:05 +00:00
Matt Jankowski
2fc38f524e
Use bundler version 2.6.8 (#34453) 2025-04-16 06:40:25 +00:00
Eugen Rochko
ff0990ec9f
Add REST API for fetching an account's endorsed accounts (#34421) 2025-04-16 05:39:20 +00:00
github-actions[bot]
ba0bd3da4a
New Crowdin Translations (automated) (#34456)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-15 09:19:49 +00:00
renovate[bot]
f115c7b3a2
chore(deps): update yarn to v4.9.1 (#34451)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 09:02:21 +00:00
Matt Jankowski
2eaef09166
Use enum validation instead of ArgumentError rescue for List replies policy check (#34452) 2025-04-14 21:11:10 +00:00
SASAGAWA Hiroto
5991caae87
Disable kerning for Japanese text to preserve monospaced alignment for readability (#34448) 2025-04-14 10:57:21 +00:00
KMY(雪あすか)
5e61c1cf74
Merge pull request #1000 from kmycode/upstream-20250414
Upstream 20250414
2025-04-14 17:43:33 +09:00
KMY
6530844c94 Fix: フルダークで文字が薄く表示される問題 2025-04-14 16:50:41 +09:00
renovate[bot]
abcb9b8a61
chore(deps): update yarn to v4.9.0 (#34410)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 07:31:44 +00:00
Eugen Rochko
f47ad7814a
Fix "Feature on profile" option on profile dropdown menu in web UI (#34422) 2025-04-14 07:29:44 +00:00
Matt Jankowski
aadff24099
Enable validate: true on CustomFilter#action (#34434) 2025-04-14 07:28:11 +00:00
github-actions[bot]
6d94c9f4e7
New Crowdin Translations (automated) (#34437)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-14 07:17:58 +00:00
renovate[bot]
3d2f47498e
fix(deps): update dependency marky to v1.3.0 (#34449)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 07:10:20 +00:00
renovate[bot]
20f4b76389
fix(deps): update dependency ioredis to v5.6.1 (#34442)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 07:07:34 +00:00
renovate[bot]
0f26ab9eee
chore(deps): update dependency csv to v3.3.4 (#34445)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 06:52:43 +00:00
renovate[bot]
e991a3c757
fix(deps): update dependency jsdom to v26.1.0 (#34446)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 06:52:39 +00:00
KMY
ebc6c9d6fb Fix test 2025-04-14 13:40:53 +09:00
KMY
dba5f3b93f Merge remote-tracking branch 'parent/main' into upstream-20250414 2025-04-14 13:23:00 +09:00
Eugen Rochko
a9cfaa6eed
Add dropdown menu to hashtag links in web UI (#34393) 2025-04-11 10:50:46 +00:00
Claire
a296facdea
Fix empty menu section in status dropdown (#34431) 2025-04-11 10:47:05 +00:00
Claire
00cd218741
Add paragraph to tell admins that email announcements cannot be opted out (#34411) 2025-04-11 09:52:23 +00:00
renovate[bot]
eb695e6b17
chore(deps): update dependency dotenv to v3.1.8 (#34417)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-11 09:43:58 +00:00
Claire
5bf6b82625
Fix editing and redrafting polls (#34430) 2025-04-11 09:35:36 +00:00
renovate[bot]
720889cc97
fix(deps): update dependency dotenv to v16.5.0 (#34425)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-11 09:28:44 +00:00
Claire
23edac59ec
Fix dropdown menus not working on mobile (#34428) 2025-04-11 09:28:25 +00:00
github-actions[bot]
7d50942b36
New Crowdin Translations (automated) (#34424)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-11 08:16:00 +00:00
Matt Jankowski
e753776930
Fix Style/HashTransformValues cop (#34416) 2025-04-11 08:09:40 +00:00
Essem
1d7b45093d
Fix notification request screen breaking due to dropdown (#34423) 2025-04-11 04:27:12 +00:00
Echo
d43bfa95aa
Adds featured tab to web (#34405) 2025-04-10 15:40:30 +00:00
Claire
678c8dfeec
Refactor StatusCacheHydrator (#34414) 2025-04-10 14:34:27 +00:00
Eugen Rochko
5d817a758d
Add dropdown to lists of accounts in web UI (#34391) 2025-04-10 14:02:52 +00:00
Matt Jankowski
de19af3650
Extract frontend_translations helper to support module (#34400) 2025-04-10 13:51:17 +00:00
Matt Jankowski
4c2f64907b
Remove deprecated Import model (#34371) 2025-04-10 12:49:24 +00:00
renovate[bot]
e74d682b21
chore(deps): update dependency linzer to v0.6.5 (#34409)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-10 07:02:44 +00:00
github-actions[bot]
a89ddcfd2d
New Crowdin Translations (automated) (#34407)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-10 07:01:39 +00:00
Matt Jankowski
6deadd596d
Remove deprecated single-argument variation of UnfilterNotificationsWorker (#33353) 2025-04-09 13:42:57 +00:00
github-actions[bot]
498372fd06
New Crowdin Translations (automated) (#34403)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-09 07:57:18 +00:00
Eugen Rochko
22d33244ee
Refactor <Dropdown> into TypeScript (#34357)
Co-authored-by: Echo <ChaosExAnima@users.noreply.github.com>
2025-04-08 19:22:19 +00:00
Eugen Rochko
b7c3235349
Change alt text modal to use spring animations in web UI (#34345) 2025-04-08 19:22:05 +00:00
Eugen Rochko
0e5be63fb3
Change unfollow button label from "Mutual" to "Unfollow" in web UI (#34392) 2025-04-08 16:28:14 +00:00
Eugen Rochko
6a39f00745
Refactor <FavouritedStatuses> and <BookmarkedStatuses> into TypeScript (#34356) 2025-04-08 16:06:31 +00:00
Eugen Rochko
bdf9baa2e8
Refactor <FollowedTags> into TypeScript (#34355) 2025-04-08 16:06:23 +00:00
renovate[bot]
887336f2c6
fix(deps): update dependency tesseract.js to v6.0.1 (#34388)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 14:58:42 +00:00
Claire
f66d092b32
Fix usage of incorrect API endpoint for suggestion deletion (#34398) 2025-04-08 14:49:49 +00:00
Claire
36afb4557a
Fix incorrect deprecation warning for DELETE /api/v1/suggestions/:id (#34397) 2025-04-08 14:32:17 +00:00
Renaud Chaput
d81983f181
Fix the delete suggestion button not working and using a deprecated endpoint (#34396) 2025-04-08 10:35:54 +00:00
Claire
ebfd48b0f2
Remove 4.1 support from SECURITY.md (#34386) 2025-04-08 07:18:52 +00:00
github-actions[bot]
f31b533435
New Crowdin Translations (automated) (#34395)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-08 06:46:46 +00:00
renovate[bot]
94ae96b9bc
chore(deps): update dependency haml_lint to v0.62.0 (#34394)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 06:29:21 +00:00
Echo
ff7230df06
Forces radio buttons to be correctly centred (#34389) 2025-04-07 10:56:30 +00:00
Echo
54aefa9014
Fix visual glitches with filtering posts (#34387) 2025-04-07 10:55:04 +00:00
Claire
264ecdcc13
Bump version to v4.3.7 (#34385) 2025-04-07 09:31:00 +00:00
github-actions[bot]
9a05688326
New Crowdin Translations (automated) (#34374)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-07 06:45:26 +00:00
renovate[bot]
0b900339b0
chore(deps): update dependency selenium-webdriver to v4.31.0 (#34379)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 06:42:52 +00:00
renovate[bot]
8451b36a72
chore(deps): update dependency doorkeeper to v5.8.2 (#34372)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 06:40:32 +00:00
renovate[bot]
107a94cf6b
chore(deps): update dependency brakeman to v7.0.2 (#34376)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 06:31:06 +00:00
renovate[bot]
b4317faee2
chore(deps): update dependency linzer to v0.6.4 (#34377)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 06:30:53 +00:00
renovate[bot]
5f87ae101c
chore(deps): update dependency strong_migrations to v2.3.0 (#34369)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-04 08:17:29 +00:00
renovate[bot]
4ed9778c85
chore(deps): update dependency brakeman to v7.0.1 (#34367)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-04 07:36:53 +00:00
renovate[bot]
9b596dbc78
fix(deps): update dependency sass to v1.86.3 (#34368)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-04 07:24:51 +00:00
Matt Jankowski
4d3758308a
Use bundler version 2.6.7 (#34362) 2025-04-04 07:24:32 +00:00
github-actions[bot]
58e3e43e06
New Crowdin Translations (automated) (#34366)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-04 07:24:27 +00:00
renovate[bot]
5859abf2ff
chore(deps): update dependency rubocop to v1.75.2 (#34364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 15:49:14 +00:00
renovate[bot]
d65c3e95ad
chore(deps): update dependency irb to v1.15.2 (#34363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 15:49:10 +00:00
github-actions[bot]
e1d6748422
New Crowdin Translations (automated) (#34360)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-03 08:42:19 +00:00
renovate[bot]
7b9ad2c416
fix(deps): update dependency sass to v1.86.2 (#34358)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 08:28:53 +00:00
270 changed files with 3897 additions and 6345 deletions

View file

@ -110,3 +110,6 @@ FETCH_REPLIES_MAX_SINGLE=500
# Max number of replies Collection pages to fetch - total # Max number of replies Collection pages to fetch - total
FETCH_REPLIES_MAX_PAGES=500 FETCH_REPLIES_MAX_PAGES=500
# Maximum allowed character count
MAX_CHARS=5555

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.75.1. # using RuboCop version 1.75.2.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
@ -62,12 +62,6 @@ Style/FormatStringToken:
Style/GuardClause: Style/GuardClause:
Enabled: false Enabled: false
# This cop supports unsafe autocorrection (--autocorrect-all).
Style/HashTransformValues:
Exclude:
- 'app/serializers/rest/web_push_subscription_serializer.rb'
- 'app/services/import_service.rb'
# Configuration parameters: AllowedMethods. # Configuration parameters: AllowedMethods.
# AllowedMethods: respond_to_missing? # AllowedMethods: respond_to_missing?
Style/OptionalBooleanParameter: Style/OptionalBooleanParameter:

View file

@ -1 +1 @@
3.4.2 3.4.3

View file

@ -2,34 +2,9 @@
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.3.8] - 2025-05-06
### Security
- Update dependencies
- Check scheme on account, profile, and media URLs ([GHSA-x2rc-v5wx-g3m5](https://github.com/mastodon/mastodon/security/advisories/GHSA-x2rc-v5wx-g3m5))
### Added
- Add warning for REDIS_NAMESPACE deprecation at startup (#34581 by @ClearlyClaire)
- Add built-in context for interaction policies (#34574 by @ClearlyClaire)
### Changed
- Change activity distribution error handling to skip retrying for deleted accounts (#33617 by @ClearlyClaire)
### Removed
- Remove double-query for signed query strings (#34610 by @ClearlyClaire)
### Fixed
- Fix incorrect redirect in response to unauthenticated API requests in limited federation mode (#34549 by @ClearlyClaire)
- Fix sign-up e-mail confirmation page reloading on error or redirect (#34548 by @ClearlyClaire)
## [4.3.7] - 2025-04-02 ## [4.3.7] - 2025-04-02
### Added ### Add
- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire) - Add delay to profile updates to debounce them (#34137 by @ClearlyClaire)
- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire) - Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire)

View file

@ -94,7 +94,7 @@ GEM
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.3.2)
aws-partitions (1.1066.0) aws-partitions (1.1087.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)
@ -120,13 +120,13 @@ GEM
rack (>= 0.9.0) rack (>= 0.9.0)
rouge (>= 1.0.0) rouge (>= 1.0.0)
bigdecimal (3.1.9) bigdecimal (3.1.9)
bindata (2.5.0) bindata (2.5.1)
binding_of_caller (1.0.1) binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0) debug_inspector (>= 1.2.0)
blurhash (0.1.8) blurhash (0.1.8)
bootsnap (1.18.4) bootsnap (1.18.4)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.0.0) brakeman (7.0.2)
racc racc
browser (6.2.0) browser (6.2.0)
brpoplpush-redis_script (0.1.3) brpoplpush-redis_script (0.1.3)
@ -170,7 +170,7 @@ GEM
crass (1.0.6) crass (1.0.6)
css_parser (1.21.1) css_parser (1.21.1)
addressable addressable
csv (3.3.3) csv (3.3.4)
database_cleaner-active_record (2.2.0) database_cleaner-active_record (2.2.0)
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
@ -194,14 +194,14 @@ GEM
devise_pam_authenticatable2 (9.2.0) devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0) devise (>= 4.0.0)
rpam2 (~> 4.0) rpam2 (~> 4.0)
diff-lcs (1.6.0) diff-lcs (1.6.1)
discard (1.4.0) discard (1.4.0)
activerecord (>= 4.2, < 9.0) activerecord (>= 4.2, < 9.0)
docile (1.4.1) docile (1.4.1)
domain_name (0.6.20240107) domain_name (0.6.20240107)
doorkeeper (5.8.1) doorkeeper (5.8.2)
railties (>= 5) railties (>= 5)
dotenv (3.1.7) dotenv (3.1.8)
drb (2.2.1) drb (2.2.1)
elasticsearch (7.17.11) elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11) elasticsearch-api (= 7.17.11)
@ -227,7 +227,7 @@ GEM
fabrication (2.31.0) fabrication (2.31.0)
faker (3.5.1) faker (3.5.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.12.2) faraday (2.13.0)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@ -239,7 +239,7 @@ GEM
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)
ffi (1.17.1) ffi (1.17.2)
ffi-compiler (1.3.2) ffi-compiler (1.3.2)
ffi (>= 1.15.5) ffi (>= 1.15.5)
rake rake
@ -266,10 +266,10 @@ GEM
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (4.30.1) google-protobuf (4.30.2)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
googleapis-common-protos-types (1.18.0) googleapis-common-protos-types (1.19.0)
google-protobuf (>= 3.18, < 5.a) google-protobuf (>= 3.18, < 5.a)
haml (6.3.0) haml (6.3.0)
temple (>= 0.8.2) temple (>= 0.8.2)
@ -280,7 +280,7 @@ GEM
activesupport (>= 5.1) activesupport (>= 5.1)
haml (>= 4.0.6) haml (>= 4.0.6)
railties (>= 5.1) railties (>= 5.1)
haml_lint (0.61.1) haml_lint (0.62.0)
haml (>= 5.0) haml (>= 5.0)
parallel (~> 1.10) parallel (~> 1.10)
rainbow rainbow
@ -328,7 +328,7 @@ GEM
activesupport (>= 3.0) activesupport (>= 3.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
io-console (0.8.0) io-console (0.8.0)
irb (1.15.1) irb (1.15.2)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
@ -395,7 +395,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.6.3) linzer (0.6.5)
openssl (~> 3.0, >= 3.0.0) openssl (~> 3.0, >= 3.0.0)
rack (>= 2.2, < 4.0) rack (>= 2.2, < 4.0)
starry (~> 0.2) starry (~> 0.2)
@ -426,7 +426,7 @@ GEM
mime-types (3.6.2) mime-types (3.6.2)
logger logger
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2025.0318) mime-types-data (3.2025.0408)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.8) mini_portile2 (2.8.8)
minitest (5.25.5) minitest (5.25.5)
@ -435,7 +435,7 @@ GEM
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.7) net-imap (0.5.6)
date date
net-protocol net-protocol
net-ldap (0.19.0) net-ldap (0.19.0)
@ -446,7 +446,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.7)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.10) oj (3.16.10)
@ -585,8 +585,8 @@ GEM
ostruct (0.6.1) ostruct (0.6.1)
ox (2.14.22) ox (2.14.22)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
parallel (1.26.3) parallel (1.27.0)
parser (3.3.7.4) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
parslet (2.0.0) parslet (2.0.0)
@ -688,7 +688,7 @@ GEM
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.7.0) rdf-normalize (0.7.0)
rdf (~> 3.3) rdf (~> 3.3)
rdoc (6.12.0) rdoc (6.13.1)
psych (>= 4.0.0) psych (>= 4.0.0)
redcarpet (3.6.1) redcarpet (3.6.1)
redis (4.8.1) redis (4.8.1)
@ -697,7 +697,7 @@ GEM
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.0) reline (0.6.1)
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.7.0) request_store (1.7.0)
rack (>= 1.4) rack (>= 1.4)
@ -740,7 +740,7 @@ GEM
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9) sidekiq (>= 5, < 9)
rspec-support (3.13.2) rspec-support (3.13.2)
rubocop (1.75.1) rubocop (1.75.2)
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)
@ -748,10 +748,10 @@ GEM
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.43.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.43.0) rubocop-ast (1.44.1)
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)
@ -797,7 +797,7 @@ GEM
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.30.1) selenium-webdriver (4.31.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@ -840,7 +840,7 @@ GEM
stoplight (4.1.1) stoplight (4.1.1)
redlock (~> 1.0) redlock (~> 1.0)
stringio (3.1.6) stringio (3.1.6)
strong_migrations (2.2.1) strong_migrations (2.3.0)
activerecord (>= 7) activerecord (>= 7)
swd (2.0.3) swd (2.0.3)
activesupport (>= 3) activesupport (>= 3)
@ -851,7 +851,7 @@ 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.0.1) terrapin (1.1.0)
climate_control climate_control
test-prof (1.4.4) test-prof (1.4.4)
thor (1.3.2) thor (1.3.2)
@ -1085,4 +1085,4 @@ RUBY VERSION
ruby 3.4.1p0 ruby 3.4.1p0
BUNDLED WITH BUNDLED WITH
2.6.6 2.6.8

124
README.md
View file

@ -1,123 +1,27 @@
# ![kmyblue icon](https://raw.githubusercontent.com/kmycode/mastodon/kb_development/app/javascript/icons/favicon-32x32.png) kmyblue NAS is an KMY & Mastodon Fork
[![Ruby Testing](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml) The following are just a few of the most common features. There are many other minor changes to the specifications.
! FOR ENGLISH USER ! We do not provide English documentation for kmyblue; we assume that you will use automatic translation software, such as Google, to translate the site. Emoji reactions
kmyblueは、ActivityPubに接続するSNSの1つである[Mastodon](https://github.com/mastodon/mastodon)のフォークです。創作作家のためのMastodonを目指して開発しました。 Local Public (Does not appear on the federated timeline of remote servers, but does appear on followers' home timelines. This is different from local only)
kmyblueはフォーク名であり、同時に[サーバー名](https://kmy.blue)でもあります。以下は特に記述がない限り、フォークとしてのkmyblueをさします。 Bookmark classification
kmyblueは AGPL ライセンスで公開されているため、どなたでも自由にフォークし、このソースコードを元に自分でサーバーを立てて公開することができます。確かにサーバーkmyblueは創作作家向けの利用規約が設定されていますが、フォークとしてのkmyblueのルールは全くの別物です。いかなるコミュニティにも平等にお使いいただけます。 Set who can search your posts for each post (Searchability)
kmyblueは、閉鎖的なコミュニティ、あまり目立ちたくないコミュニティには特に強力な機能を提供します。kmyblueはプライバシーを考慮したうえで強力な独自機能を提供するため、汎用サーバーとして利用するにもある程度十分な機能が揃っています。
テストコード、Lint どちらも動いています。 Quote posts, modest quotes (references)
### アジェンダ Record posts that meet certain conditions such as domains, accounts, and keywords (Subscriptions/Antennas)
- 利用方法 Send posts to a designated set of followers (Circles) (different from direct messages)
- kmyblueの開発方針
- kmyblueは何でないか
- kmyblueの独自機能
- 英語のサポートについて
## 利用方法 Notification of new posts on lists
### インストール方法 Exclude posts from people you follow when filtering posts
[Wiki](https://github.com/kmycode/mastodon/wiki/Installation)を参照してください。 Hide number of followers and followings
### 開発への参加方法 Automatically delete posts after a specified time has passed
CONTRIBUTING.mdを参照してください。 Expanding moderation functions
### テスト
```
# デバッグ実行(以下のいずれか)
foreman start
DB_USER=postgres DB_PASS=password foreman start
# 一部を除く全てのテストを行う
RAILS_ENV=test bundle exec rspec spec
# ElasticSearch連携テストを行う
RAILS_ENV=test ES_ENABLED=true bundle exec rspec --tag search
RAILS_ENV=test ES_ENABLED=true RUN_SEARCH_SPECS=true bundle exec rspec spec/search
```
## kmyblueの開発方針
### 本家Mastodonへの積極的追従
kmyblueは、追加機能を控えめにする代わりに本家Mastodonに積極的に追従を行います。kmyblueの追加機能そのままに、Mastodonの新機能も利用できるよう調整を行います。
### ゆるやかな内輪での運用
kmyblueは同人向けサーバーとして出発したため、同人作家に需要のある「内輪リを外部にできるだけもらさない」という部分に特化しています。
「ローカル公開」は、投稿を見せたくない人に見つかりにくくする効果があります。「サークル」は、フォロワーの中でも特に見せたい人だけに見せる効果があります。
「検索許可」という独自の検索オプションを利用することで、公開投稿の一部だけを検索されにくくするだけでなく、非収載投稿が誰でも自由に検索できるようになります。
内輪とは自分のサーバーに限ったものではありません。内輪同士で複数のサーバーを運営するとき、お互いが深く繋がれる「フレンドサーバー」というシステムも用意しています。
### 少人数サーバーでの運用
kmyblueは、人の少ないサーバーでの運用を考慮して設計しています。そのため、Fedibirdにあるような、人の多いサーバー向けの機能はあまり作っていません。
サーバーの負荷については一部度外視している部分があります。たとえば絵文字リアクション機能はサーバーへ著しい負荷をかける場合があります。ただしkmyblueでは、絵文字リアクション機能そのものを無効にしたり、負荷の高いストリーミング処理を無効にする管理者オプションも存在します。
もちろん人の多いサーバーでの運用が不便になるような修正は行っていません。人数にかかわらず、そのままお使いいただけます。
### 比較的高い防御力
kmyblueでは、「Fediverseは将来的に荒むのではないか」「Fediverseは将来的にスパムに溢れるのではないか」を念頭に設計している部分があります。投稿だけでなく絵文字リアクションも対象にした防衛策があります。
管理者は「NGワード」「NGルール」機能の利用が可能です。設定を変更することで、一部のモデレーターもこの機能を利用できます。
利用者は、独自拡張されたフィルター機能、絵文字リアクションのブロックなどを利用できます。
ただし防御力の高さは自由を犠牲にします。例えばNGワードが多すぎると、他のサーバーからの投稿が制限され、かつそれに気づきにくくなります。
## kmyblueは何でないか
kmyblueは、企業・政府機関向けに開発されたものではありません。開発者はセキュリティに関する専門知識を有しておらず、高度なセキュリティを求められる機関向けのソフトウェアを制作する能力はありません。また、kmyblueのメンテナは現在1人のみであり、そのメンテナが飽きたら開発がストップするリスクも高いです。Mastodonのような高い信頼性・安全性を保証することはできないので、導入の際はご自身で安全を十分に確認してからお使いになることを強くおすすめします。
個人サーバーであっても、安定性を強く求める方にはおすすめできません。glitch-socがよりよい選択肢になるでしょう。
kmyblueは、Misskeyではありません。Misskeyは「楽しむ」をコンセプトにしていますが、kmyblueはMastodonの思想を受け継ぎ、炎上や喧騒を避けることのできる落ち着いた場所を目指しています。そのため、思想に合わない機能は実装しないか、大幅に弱体化しています。
kmyblueは、Fedibirdではありません。Fedibirdは大規模サーバー向けに設定していると思われる機能があり、例えば購読機能がその代表例です。Fedibirdの購読は擬似的なフォロー体験を与えるものですが、本物のフォローではないため、購読対象の投稿が配送されることを確約したものではありません。小規模サーバーだとかえって不便になる機能を、kmyblueは避けています。
## kmyblueの独自機能
以下に列挙したものはあくまで代表的なものです。これ以外にも、細かい仕様変更などが多数含まれます。
- 絵文字リアクション
- ローカル公開Local Publicリモートサーバーの連合タイムラインには流れませんが、フォロワーのホームタイムラインには流れます。**ローカル限定とは異なります**
- ブックマークの分類
- 自分の投稿を検索できる人を投稿ごとに設定検索許可・Searchability
- 投稿の引用、ひかえめな引用(参照)
- ドメイン・アカウント・キーワードなど特定条件を満たした投稿を記録する機能(購読・アンテナ)
- フォロワーの一部を指名して投稿を送る機能(サークル)(ダイレクトメッセージとは異なります)
- リスト新着投稿の通知
- 投稿のフィルタリングにおいて、自分がフォローしている相手の投稿を除外
- フォロー・フォロワー数を隠す機能
- 指定した時間が経過したあとに投稿を自動削除する機能
- モデレーション機能の拡張
## 英語のサポートについて
kmyblueのメイン開発者である[雪あすか](https://kmy.blue/@askyq)は、英語の読み書きがほとんどできません。そのため、ドキュメントの英語化、海外向け公式アカウントの新設などを行う予定はありません。
要望やバグ報告はIssueに書いて構いませんが、Issue画面内の説明やテンプレートはすべて日本語になっています。投稿が難しければ、Discussionに投稿してください。こちらで必要と判断したものは、改めてIssueとして起票します。
そのほか開発者へ質問があれば、[@askyq@kmy.blue](https://kmy.blue/@askyq)へ英語のまま送ってください。
ただしkmyblueのドキュメント、[@askyq@kmy.blue](https://kmy.blue/@askyq)内のkmyblueフォークに関係する投稿を、許可なく翻訳して公開することは問題ありません。
## 開発者のアカウントについて
kmyblueのメイン開発者である[雪あすか](https://kmy.blue/@askyq)は、用途別にアカウントを分けるようなことはせず、すべての発言をつのアカウントで行っています。そのため、kmyblueの開発だけでなく、成人向け同人作品の話も混ざっています。
このうち、公開範囲「公開」「ローカル公開」「非収載」であるkmyblueフォークの開発に関係する投稿に限り抽出し、翻訳の有無に関係なく公開することを許可します。これはkmyblueフォークの利用者にとって公共性の高いコンテンツであると思われます。これは、日本と欧米では一般的に考えられている児童ポルの基準が異なり、欧米のサーバーの中にはこのアカウントをフォローしづらいものもあるという懸念を考慮したものです。

View file

@ -72,13 +72,6 @@ class Api::BaseController < ApplicationController
end end
end end
# Redefine `require_functional!` to properly output JSON instead of HTML redirects
def require_functional!
return if current_user.functional?
require_user!
end
def render_empty def render_empty
render json: {}, status: 200 render json: {}, status: 200
end end

View file

@ -0,0 +1,66 @@
# frozen_string_literal: true
class Api::V1::Accounts::EndorsementsController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:accounts' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
before_action :require_user!, except: :index
before_action :set_account
before_action :set_endorsed_accounts, only: :index
after_action :insert_pagination_headers, only: :index
def index
cache_if_unauthenticated!
render json: @endorsed_accounts, each_serializer: REST::AccountSerializer
end
def create
AccountPin.find_or_create_by!(account: current_account, target_account: @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
end
def destroy
pin = AccountPin.find_by(account: current_account, target_account: @account)
pin&.destroy!
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
end
private
def set_account
@account = Account.find(params[:account_id])
end
def set_endorsed_accounts
@endorsed_accounts = @account.unavailable? ? [] : paginated_endorsed_accounts
end
def paginated_endorsed_accounts
@account.endorsed_accounts.without_suspended.includes(:account_stat, :user).paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
def relationships_presenter
AccountRelationshipsPresenter.new([@account], current_user.account_id)
end
def next_path
api_v1_account_endorsements_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_account_endorsements_url pagination_params(since_id: pagination_since_id) unless @endorsed_accounts.empty?
end
def pagination_collection
@endorsed_accounts
end
def records_continue?
@endorsed_accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
end

View file

@ -17,6 +17,6 @@ class Api::V1::Accounts::FeaturedTagsController < Api::BaseController
end end
def set_featured_tags def set_featured_tags
@featured_tags = @account.suspended? ? [] : @account.featured_tags @featured_tags = @account.unavailable? ? [] : @account.featured_tags
end end
end end

View file

@ -1,30 +0,0 @@
# frozen_string_literal: true
class Api::V1::Accounts::PinsController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
before_action :require_user!
before_action :set_account
def create
AccountPin.find_or_create_by!(account: current_account, target_account: @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
end
def destroy
pin = AccountPin.find_by(account: current_account, target_account: @account)
pin&.destroy!
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
end
private
def set_account
@account = Account.find(params[:account_id])
end
def relationships_presenter
AccountRelationshipsPresenter.new([@account], current_user.account_id)
end
end

View file

@ -7,10 +7,6 @@ class Api::V1::ListsController < Api::BaseController
before_action :require_user! before_action :require_user!
before_action :set_list, except: [:index, :create] before_action :set_list, except: [:index, :create]
rescue_from ArgumentError do |e|
render json: { error: e.to_s }, status: 422
end
def index def index
@lists = List.where(account: current_account).all @lists = List.where(account: current_account).all
render json: @lists, each_serializer: REST::ListSerializer render json: @lists, each_serializer: REST::ListSerializer

View file

@ -4,7 +4,7 @@ class Api::V1::SuggestionsController < Api::BaseController
include Authorization include Authorization
include DeprecationConcern include DeprecationConcern
deprecate_api '2021-05-16' deprecate_api '2021-05-16', only: [:index]
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index

View file

@ -72,24 +72,10 @@ class ApplicationController < ActionController::Base
def require_functional! def require_functional!
return if current_user.functional? return if current_user.functional?
respond_to do |format| if current_user.confirmed?
format.any do redirect_to edit_user_registration_path
if current_user.confirmed? else
redirect_to edit_user_registration_path redirect_to auth_setup_path
else
redirect_to auth_setup_path
end
end
format.json do
if !current_user.confirmed?
render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
elsif !current_user.approved?
render json: { error: 'Your login is currently pending approval' }, status: 403
elsif !current_user.functional?
render json: { error: 'Your login is currently disabled' }, status: 403
end
end
end end
end end

View file

@ -35,13 +35,6 @@ 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' } },
misskey_license: { 'misskey' => 'https://misskey-hub.net/ns#', '_misskey_license' => 'misskey:_misskey_license' }, misskey_license: { 'misskey' => 'https://misskey-hub.net/ns#', '_misskey_license' => 'misskey:_misskey_license' },
interaction_policies: {
'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
'canQuote' => { '@id' => 'gts:canQuote', '@type' => '@id' },
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
},
}.freeze }.freeze
def full_context def full_context

View file

@ -4,12 +4,9 @@ import axios from 'axios';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
async function checkConfirmation() { async function checkConfirmation() {
const response = await axios.get('/api/v1/emails/check_confirmation', { const response = await axios.get('/api/v1/emails/check_confirmation');
headers: { Accept: 'application/json' },
withCredentials: true,
});
if (response.status === 200 && response.data === true) { if (response.data) {
window.location.href = '/start'; window.location.href = '/start';
} }
} }

View file

@ -1,11 +1,11 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
export const openDropdownMenu = createAction<{ export const openDropdownMenu = createAction<{
id: string; id: number;
keyboard: boolean; keyboard: boolean;
scrollKey: string; scrollKey?: string;
}>('dropdownMenu/open'); }>('dropdownMenu/open');
export const closeDropdownMenu = createAction<{ id: string }>( export const closeDropdownMenu = createAction<{ id: number }>(
'dropdownMenu/close', 'dropdownMenu/close',
); );

View file

@ -94,17 +94,6 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
normalStatus.url = null;
}
normalStatus.url ||= normalStatus.uri;
normalStatus.media_attachments.forEach(item => {
if (item.remote_url && !(item.remote_url.startsWith('http://') || item.remote_url.startsWith('https://')))
item.remote_url = null;
});
} }
if (normalOldStatus) { if (normalOldStatus) {

View file

@ -1,81 +0,0 @@
import api, { getLinks } from '../api';
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
export const fetchFollowedHashtags = () => (dispatch) => {
dispatch(fetchFollowedHashtagsRequest());
api().get('/api/v1/followed_tags').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchFollowedHashtagsFail(err));
});
};
export function fetchFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
};
}
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
followed_tags,
next,
};
}
export function fetchFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
error,
};
}
export function expandFollowedHashtags() {
return (dispatch, getState) => {
const url = getState().getIn(['followed_tags', 'next']);
if (url === null) {
return;
}
dispatch(expandFollowedHashtagsRequest());
api().get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFollowedHashtagsFail(error));
});
};
}
export function expandFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
};
}
export function expandFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
followed_tags,
next,
};
}
export function expandFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
error,
};
}

View file

@ -1,4 +1,4 @@
import { apiRequestPost, apiRequestGet } from 'mastodon/api'; import api, { getLinks, apiRequestPost, apiRequestGet } from 'mastodon/api';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
export const apiGetTag = (tagId: string) => export const apiGetTag = (tagId: string) =>
@ -9,3 +9,15 @@ export const apiFollowTag = (tagId: string) =>
export const apiUnfollowTag = (tagId: string) => export const apiUnfollowTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`); apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
export const apiGetFollowedTags = async (url?: string) => {
const response = await api().request<ApiHashtagJSON[]>({
method: 'GET',
url: url ?? '/api/v1/followed_tags',
});
return {
tags: response.data,
links: getLinks(response),
};
};

View file

@ -1,6 +1,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type React from 'react'; import type React from 'react';
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
@ -14,18 +14,19 @@ import {
muteAccount, muteAccount,
unmuteAccount, unmuteAccount,
} from 'mastodon/actions/accounts'; } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { initMuteModal } from 'mastodon/actions/mutes'; import { initMuteModal } from 'mastodon/actions/mutes';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { FollowersCounter } from 'mastodon/components/counters'; import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name'; import { DisplayName } from 'mastodon/components/display_name';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FollowButton } from 'mastodon/components/follow_button'; import { FollowButton } from 'mastodon/components/follow_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { ShortNumber } from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton'; import { Skeleton } from 'mastodon/components/skeleton';
import { VerifiedBadge } from 'mastodon/components/verified_badge'; import { VerifiedBadge } from 'mastodon/components/verified_badge';
import DropdownMenu from 'mastodon/containers/dropdown_menu_container'; import type { MenuItem } from 'mastodon/models/dropdown_menu';
import { me } from 'mastodon/initial_state';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({ const messages = defineMessages({
@ -48,6 +49,14 @@ const messages = defineMessages({
mute: { id: 'account.mute_short', defaultMessage: 'Mute' }, mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
block: { id: 'account.block_short', defaultMessage: 'Block' }, block: { id: 'account.block_short', defaultMessage: 'Block' },
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
addToLists: {
id: 'account.add_or_remove_from_list',
defaultMessage: 'Add or Remove from lists',
},
openOriginalPage: {
id: 'account.open_original_page',
defaultMessage: 'Open original page',
},
}); });
export const Account: React.FC<{ export const Account: React.FC<{
@ -73,6 +82,7 @@ export const Account: React.FC<{
const account = useAppSelector((state) => state.accounts.get(id)); const account = useAppSelector((state) => state.accounts.get(id));
const relationship = useAppSelector((state) => state.relationships.get(id)); const relationship = useAppSelector((state) => state.relationships.get(id));
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const accountUrl = account?.url;
const handleBlock = useCallback(() => { const handleBlock = useCallback(() => {
if (relationship?.blocking) { if (relationship?.blocking) {
@ -90,13 +100,62 @@ export const Account: React.FC<{
} }
}, [dispatch, id, account, relationship]); }, [dispatch, id, account, relationship]);
const handleMuteNotifications = useCallback(() => { const menu = useMemo(() => {
dispatch(muteAccount(id, true)); let arr: MenuItem[] = [];
}, [dispatch, id]);
const handleUnmuteNotifications = useCallback(() => { if (defaultAction === 'mute') {
dispatch(muteAccount(id, false)); const handleMuteNotifications = () => {
}, [dispatch, id]); dispatch(muteAccount(id, true));
};
const handleUnmuteNotifications = () => {
dispatch(muteAccount(id, false));
};
arr = [
{
text: intl.formatMessage(
relationship?.muting_notifications
? messages.unmute_notifications
: messages.mute_notifications,
),
action: relationship?.muting_notifications
? handleUnmuteNotifications
: handleMuteNotifications,
},
];
} else if (defaultAction !== 'block') {
const handleAddToLists = () => {
dispatch(
openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: id,
},
}),
);
};
arr = [
{
text: intl.formatMessage(messages.addToLists),
action: handleAddToLists,
},
];
if (accountUrl) {
arr.unshift(
{
text: intl.formatMessage(messages.openOriginalPage),
href: accountUrl,
},
null,
);
}
}
return arr;
}, [dispatch, intl, id, accountUrl, relationship, defaultAction]);
if (hidden) { if (hidden) {
return ( return (
@ -107,73 +166,46 @@ export const Account: React.FC<{
); );
} }
let buttons; let button: React.ReactNode, dropdown: React.ReactNode;
if (account && account.id !== me && relationship) { if (menu.length > 0) {
const { requested, blocking, muting } = relationship; dropdown = (
<Dropdown
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)}
/>
);
}
if (requested) { if (defaultAction === 'block') {
buttons = <FollowButton accountId={id} />; button = (
} else if (blocking) { <Button
buttons = ( text={intl.formatMessage(
<Button relationship?.blocking ? messages.unblock : messages.block,
text={intl.formatMessage(messages.unblock)} )}
onClick={handleBlock} onClick={handleBlock}
/> />
); );
} else if (muting) { } else if (defaultAction === 'mute') {
const menu = [ button = (
{ <Button
text: intl.formatMessage( text={intl.formatMessage(
relationship.muting_notifications relationship?.muting ? messages.unmute : messages.mute,
? messages.unmute_notifications )}
: messages.mute_notifications, onClick={handleMute}
), />
action: relationship.muting_notifications );
? handleUnmuteNotifications
: handleMuteNotifications,
},
];
buttons = (
<>
<DropdownMenu
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
<Button
text={intl.formatMessage(messages.unmute)}
onClick={handleMute}
/>
</>
);
} else if (defaultAction === 'mute') {
buttons = (
<Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />
);
} else if (defaultAction === 'block') {
buttons = (
<Button
text={intl.formatMessage(messages.block)}
onClick={handleBlock}
/>
);
} else {
buttons = <FollowButton accountId={id} />;
}
} else { } else {
buttons = <FollowButton accountId={id} />; button = <FollowButton accountId={id} />;
} }
if (hideButtons) { if (hideButtons) {
buttons = null; button = null;
} }
let muteTimeRemaining; let muteTimeRemaining: React.ReactNode;
if (account?.mute_expires_at) { if (account?.mute_expires_at) {
muteTimeRemaining = ( muteTimeRemaining = (
@ -183,7 +215,7 @@ export const Account: React.FC<{
); );
} }
let verification; let verification: React.ReactNode;
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at); const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
@ -233,11 +265,17 @@ export const Account: React.FC<{
{!minimal && children && ( {!minimal && children && (
<div> <div>
<div>{children}</div> <div>{children}</div>
<div className='account__relationship'>{buttons}</div> <div className='account__relationship'>
{dropdown}
{button}
</div>
</div> </div>
)} )}
{!minimal && !children && ( {!minimal && !children && (
<div className='account__relationship'>{buttons}</div> <div className='account__relationship'>
{dropdown}
{button}
</div>
)} )}
</div> </div>

View file

@ -1,345 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent, cloneElement, Children } from 'react';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import { CircularProgress } from 'mastodon/components/circular_progress';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { IconButton } from './icon_button';
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
let id = 0;
class DropdownMenu extends PureComponent {
static propTypes = {
items: PropTypes.array.isRequired,
loading: PropTypes.bool,
scrollable: PropTypes.bool,
onClose: PropTypes.func.isRequired,
style: PropTypes.object,
openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func,
renderHeader: PropTypes.func,
onItemClick: PropTypes.func.isRequired,
};
static defaultProps = {
style: {},
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
e.stopPropagation();
e.preventDefault();
}
};
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('keydown', this.handleKeyDown, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true });
}
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
};
setFocusRef = c => {
this.focusedItem = c;
};
handleKeyDown = e => {
const items = Array.from(this.node.querySelectorAll('a, button'));
const index = items.indexOf(document.activeElement);
let element = null;
switch(e.key) {
case 'ArrowDown':
element = items[index+1] || items[0];
break;
case 'ArrowUp':
element = items[index-1] || items[items.length-1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index-1] || items[items.length-1];
} else {
element = items[index+1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length-1];
break;
case 'Escape':
this.props.onClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
handleItemKeyPress = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
};
handleClick = e => {
const { onItemClick } = this.props;
onItemClick(e);
};
renderItem = (option, i) => {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href = '#', target = '_blank', method, dangerous } = option;
return (
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
{text}
</a>
</li>
);
};
render () {
const { items, scrollable, renderHeader, loading } = this.props;
let renderItem = this.props.renderItem || this.renderItem;
return (
<div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}>
{loading && (
<CircularProgress size={30} strokeWidth={3.5} />
)}
{!loading && renderHeader && (
<div className='dropdown-menu__container__header'>
{renderHeader(items)}
</div>
)}
{!loading && (
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
</ul>
)}
</div>
);
}
}
class Dropdown extends PureComponent {
static propTypes = {
children: PropTypes.node,
icon: PropTypes.string,
iconComponent: PropTypes.func,
items: PropTypes.array.isRequired,
loading: PropTypes.bool,
size: PropTypes.number,
title: PropTypes.string,
disabled: PropTypes.bool,
scrollable: PropTypes.bool,
active: PropTypes.bool,
status: ImmutablePropTypes.map,
isUserTouching: PropTypes.func,
onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
openDropdownId: PropTypes.number,
openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func,
renderHeader: PropTypes.func,
onItemClick: PropTypes.func,
...WithRouterPropTypes
};
static defaultProps = {
title: 'Menu',
};
state = {
id: id++,
};
handleClick = ({ type }) => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
} else {
this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
}
};
handleClose = () => {
if (this.activeElement) {
this.activeElement.focus({ preventScroll: true });
this.activeElement = null;
}
this.props.onClose(this.state.id);
};
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
};
handleKeyPress = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
};
handleItemClick = e => {
const { onItemClick } = this.props;
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
this.handleClose();
if (typeof onItemClick === 'function') {
e.preventDefault();
onItemClick(item, i);
} else if (item && typeof item.action === 'function') {
e.preventDefault();
item.action();
} else if (item && item.to) {
e.preventDefault();
this.props.history.push(item.to);
}
};
setTargetRef = c => {
this.target = c;
};
findTarget = () => {
return this.target?.buttonRef?.current ?? this.target;
};
componentWillUnmount = () => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
}
};
close = () => {
this.handleClose();
};
render () {
const {
icon,
iconComponent,
items,
size,
title,
disabled,
loading,
scrollable,
openDropdownId,
openedViaKeyboard,
children,
renderItem,
renderHeader,
active,
} = this.props;
const open = this.state.id === openDropdownId;
const button = children ? cloneElement(Children.only(children), {
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown,
onKeyPress: this.handleKeyPress,
ref: this.setTargetRef,
}) : (
<IconButton
icon={!open ? icon : 'close'}
iconComponent={iconComponent}
title={title}
active={open || active}
disabled={disabled}
size={size}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
ref={this.setTargetRef}
/>
);
return (
<>
{button}
<Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
{({ props, arrowProps, placement }) => (
<div {...props}>
<div className={`dropdown-animation dropdown-menu ${placement}`}>
<div className={`dropdown-menu__arrow ${placement}`} {...arrowProps} />
<DropdownMenu
items={items}
loading={loading}
scrollable={scrollable}
onClose={this.handleClose}
openedViaKeyboard={openedViaKeyboard}
renderItem={renderItem}
renderHeader={renderHeader}
onItemClick={this.handleItemClick}
/>
</div>
</div>
)}
</Overlay>
</>
);
}
}
export default withRouter(Dropdown);

View file

@ -0,0 +1,551 @@
import {
useState,
useEffect,
useRef,
useCallback,
cloneElement,
Children,
} from 'react';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { Map as ImmutableMap } from 'immutable';
import Overlay from 'react-overlays/Overlay';
import type {
OffsetValue,
UsePopperOptions,
} from 'react-overlays/esm/usePopper';
import { fetchRelationships } from 'mastodon/actions/accounts';
import {
openDropdownMenu,
closeDropdownMenu,
} from 'mastodon/actions/dropdown_menu';
import { openModal, closeModal } from 'mastodon/actions/modal';
import { CircularProgress } from 'mastodon/components/circular_progress';
import { isUserTouching } from 'mastodon/is_mobile';
import type {
MenuItem,
ActionMenuItem,
ExternalLinkMenuItem,
} from 'mastodon/models/dropdown_menu';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import type { IconProp } from './icon';
import { IconButton } from './icon_button';
let id = 0;
const isMenuItem = (item: unknown): item is MenuItem => {
if (item === null) {
return true;
}
return typeof item === 'object' && 'text' in item;
};
const isActionItem = (item: unknown): item is ActionMenuItem => {
if (!item || !isMenuItem(item)) {
return false;
}
return 'action' in item;
};
const isExternalLinkItem = (item: unknown): item is ExternalLinkMenuItem => {
if (!item || !isMenuItem(item)) {
return false;
}
return 'href' in item;
};
type RenderItemFn<Item = MenuItem> = (
item: Item,
index: number,
handlers: {
onClick: (e: React.MouseEvent) => void;
onKeyUp: (e: React.KeyboardEvent) => void;
},
) => React.ReactNode;
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
type RenderHeaderFn<Item = MenuItem> = (items: Item[]) => React.ReactNode;
interface DropdownMenuProps<Item = MenuItem> {
items?: Item[];
loading?: boolean;
scrollable?: boolean;
onClose: () => void;
openedViaKeyboard: boolean;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
onItemClick?: ItemClickFn<Item>;
}
export const DropdownMenu = <Item = MenuItem,>({
items,
loading,
scrollable,
onClose,
openedViaKeyboard,
renderItem,
renderHeader,
onItemClick,
}: DropdownMenuProps<Item>) => {
const nodeRef = useRef<HTMLDivElement>(null);
const focusedItemRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const handleDocumentClick = (e: MouseEvent) => {
if (
e.target instanceof Node &&
nodeRef.current &&
!nodeRef.current.contains(e.target)
) {
onClose();
e.stopPropagation();
e.preventDefault();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!nodeRef.current) {
return;
}
const items = Array.from(nodeRef.current.querySelectorAll('a, button'));
const index = document.activeElement
? items.indexOf(document.activeElement)
: -1;
let element: Element | undefined;
switch (e.key) {
case 'ArrowDown':
element = items[index + 1] ?? items[0];
break;
case 'ArrowUp':
element = items[index - 1] ?? items[items.length - 1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index - 1] ?? items[items.length - 1];
} else {
element = items[index + 1] ?? items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length - 1];
break;
case 'Escape':
onClose();
break;
}
if (element && element instanceof HTMLElement) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
document.addEventListener('click', handleDocumentClick, { capture: true });
document.addEventListener('keydown', handleKeyDown, { capture: true });
if (focusedItemRef.current && openedViaKeyboard) {
focusedItemRef.current.focus({ preventScroll: true });
}
return () => {
document.removeEventListener('click', handleDocumentClick, {
capture: true,
});
document.removeEventListener('keydown', handleKeyDown, { capture: true });
};
}, [onClose, openedViaKeyboard]);
const handleFocusedItemRef = useCallback(
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
focusedItemRef.current = c as HTMLElement;
},
[],
);
const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = items?.[i];
onClose();
if (!item) {
return;
}
if (typeof onItemClick === 'function') {
e.preventDefault();
onItemClick(item, i);
} else if (isActionItem(item)) {
e.preventDefault();
item.action();
}
},
[onClose, onItemClick, items],
);
const handleItemKeyUp = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
handleItemClick(e);
}
},
[handleItemClick],
);
const nativeRenderItem = (option: Item, i: number) => {
if (!isMenuItem(option)) {
return null;
}
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, dangerous } = option;
let element: React.ReactElement;
if (isActionItem(option)) {
element = (
<button
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
</button>
);
} else if (isExternalLinkItem(option)) {
element = (
<a
href={option.href}
target={option.target ?? '_target'}
data-method={option.method}
rel='noopener'
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
</a>
);
} else {
element = (
<Link
to={option.to}
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
</Link>
);
}
return (
<li
className={classNames('dropdown-menu__item', {
'dropdown-menu__item--dangerous': dangerous,
})}
key={`${text}-${i}`}
>
{element}
</li>
);
};
const renderItemMethod = renderItem ?? nativeRenderItem;
return (
<div
className={classNames('dropdown-menu__container', {
'dropdown-menu__container--loading': loading,
})}
ref={nodeRef}
>
{(loading || !items) && <CircularProgress size={30} strokeWidth={3.5} />}
{!loading && renderHeader && items && (
<div className='dropdown-menu__container__header'>
{renderHeader(items)}
</div>
)}
{!loading && items && (
<ul
className={classNames('dropdown-menu__container__list', {
'dropdown-menu__container__list--scrollable': scrollable,
})}
>
{items.map((option, i) =>
renderItemMethod(option, i, {
onClick: handleItemClick,
onKeyUp: handleItemKeyUp,
}),
)}
</ul>
)}
</div>
);
};
interface DropdownProps<Item = MenuItem> {
children?: React.ReactElement;
icon?: string;
iconComponent?: IconProp;
items?: Item[];
loading?: boolean;
title?: string;
disabled?: boolean;
scrollable?: boolean;
active?: boolean;
scrollKey?: string;
status?: ImmutableMap<string, unknown>;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
onOpen?: () => void;
onItemClick?: ItemClickFn<Item>;
}
const offset = [5, 5] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
export const Dropdown = <Item = MenuItem,>({
children,
icon,
iconComponent,
items,
loading,
title = 'Menu',
disabled,
scrollable,
active,
status,
renderItem,
renderHeader,
onOpen,
onItemClick,
scrollKey,
}: DropdownProps<Item>) => {
const dispatch = useAppDispatch();
const openDropdownId = useAppSelector((state) => state.dropdownMenu.openId);
const openedViaKeyboard = useAppSelector(
(state) => state.dropdownMenu.keyboard,
);
const [currentId] = useState(id++);
const open = currentId === openDropdownId;
const activeElement = useRef<HTMLElement | null>(null);
const targetRef = useRef<HTMLButtonElement | null>(null);
const handleClose = useCallback(() => {
if (activeElement.current) {
activeElement.current.focus({ preventScroll: true });
activeElement.current = null;
}
dispatch(
closeModal({
modalType: 'ACTIONS',
ignoreFocus: false,
}),
);
dispatch(closeDropdownMenu({ id: currentId }));
}, [dispatch, currentId]);
const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = items?.[i];
handleClose();
if (!item) {
return;
}
if (typeof onItemClick === 'function') {
e.preventDefault();
onItemClick(item, i);
} else if (isActionItem(item)) {
e.preventDefault();
item.action();
}
},
[handleClose, onItemClick, items],
);
const handleClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const { type } = e;
if (open) {
handleClose();
} else {
onOpen?.();
if (status) {
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
}
if (isUserTouching()) {
dispatch(
openModal({
modalType: 'ACTIONS',
modalProps: {
status,
actions: items,
onClick: handleItemClick,
},
}),
);
} else {
dispatch(
openDropdownMenu({
id: currentId,
keyboard: type !== 'click',
scrollKey,
}),
);
}
}
},
[
dispatch,
currentId,
scrollKey,
onOpen,
handleItemClick,
open,
status,
items,
handleClose,
],
);
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(() => {
return () => {
if (currentId === openDropdownId) {
handleClose();
}
};
}, [currentId, openDropdownId, handleClose]);
let button: React.ReactElement;
if (children) {
button = cloneElement(Children.only(children), {
onClick: handleClick,
onMouseDown: handleMouseDown,
onKeyDown: handleButtonKeyDown,
onKeyPress: handleKeyPress,
ref: targetRef,
});
} else if (icon && iconComponent) {
button = (
<IconButton
icon={!open ? icon : 'close'}
iconComponent={iconComponent}
title={title}
active={open || active}
disabled={disabled}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleButtonKeyDown}
onKeyPress={handleKeyPress}
ref={targetRef}
/>
);
} else {
return null;
}
return (
<>
{button}
<Overlay
show={open}
offset={offset}
placement='bottom'
flip
target={targetRef}
popperConfig={popperConfig}
>
{({ props, arrowProps, placement }) => (
<div {...props}>
<div className={`dropdown-animation dropdown-menu ${placement}`}>
<div
className={`dropdown-menu__arrow ${placement}`}
{...arrowProps}
/>
<DropdownMenu
items={items}
loading={loading}
scrollable={scrollable}
onClose={handleClose}
openedViaKeyboard={openedViaKeyboard}
renderItem={renderItem}
renderHeader={renderHeader}
onItemClick={onItemClick}
/>
</div>
</div>
)}
</Overlay>
</>
);
};

View file

@ -1,32 +0,0 @@
import { connect } from 'react-redux';
import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_menu';
import { fetchHistory } from 'mastodon/actions/history';
import DropdownMenu from 'mastodon/components/dropdown_menu';
/**
*
* @param {import('mastodon/store').RootState} state
* @param {*} props
*/
const mapStateToProps = (state, { statusId }) => ({
openDropdownId: state.dropdownMenu.openId,
openedViaKeyboard: state.dropdownMenu.keyboard,
items: state.getIn(['history', statusId, 'items']),
loading: state.getIn(['history', statusId, 'loading']),
});
const mapDispatchToProps = (dispatch, { statusId }) => ({
onOpen (id, onItemClick, keyboard) {
dispatch(fetchHistory(statusId));
dispatch(openDropdownMenu({ id, keyboard }));
},
onClose (id) {
dispatch(closeDropdownMenu({ id }));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);

View file

@ -1,77 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import DropdownMenu from './containers/dropdown_menu_container';
const mapDispatchToProps = (dispatch, { statusId }) => ({
onItemClick (index) {
dispatch(openModal({
modalType: 'COMPARE_HISTORY',
modalProps: { index, statusId },
}));
},
});
class EditedTimestamp extends PureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
timestamp: PropTypes.string.isRequired,
intl: PropTypes.object.isRequired,
onItemClick: PropTypes.func.isRequired,
};
handleItemClick = (item, i) => {
const { onItemClick } = this.props;
onItemClick(i);
};
renderHeader = items => {
return (
<FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {# time} other {# times}}' values={{ count: items.size - 1 }} />
);
};
renderItem = (item, index, { onClick, onKeyPress }) => {
const formattedDate = <RelativeTimestamp timestamp={item.get('created_at')} short={false} />;
const formattedName = <InlineAccount accountId={item.get('account')} />;
const label = item.get('original') ? (
<FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} />
) : (
<FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} />
);
return (
<li className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at')}>
<button data-index={index} onClick={onClick} onKeyPress={onKeyPress}>{label}</button>
</li>
);
};
render () {
const { timestamp, statusId } = this.props;
return (
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
<button className='dropdown-menu__text-button'>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <FormattedDateWrapper className='animated-number' value={timestamp} month='short' day='2-digit' hour='2-digit' minute='2-digit' /> }} />
</button>
</DropdownMenu>
);
}
}
export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp));

View file

@ -0,0 +1,140 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { fetchHistory } from 'mastodon/actions/history';
import { openModal } from 'mastodon/actions/modal';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
type HistoryItem = ImmutableMap<string, unknown>;
export const EditedTimestamp: React.FC<{
statusId: string;
timestamp: string;
}> = ({ statusId, timestamp }) => {
const dispatch = useAppDispatch();
const items = useAppSelector(
(state) =>
(
state.history.getIn([statusId, 'items']) as
| ImmutableList<unknown>
| undefined
)?.toArray() as HistoryItem[],
);
const loading = useAppSelector(
(state) => state.history.getIn([statusId, 'loading']) as boolean,
);
const handleOpen = useCallback(() => {
dispatch(fetchHistory(statusId));
}, [dispatch, statusId]);
const handleItemClick = useCallback(
(_item: HistoryItem, index: number) => {
dispatch(
openModal({
modalType: 'COMPARE_HISTORY',
modalProps: { index, statusId },
}),
);
},
[dispatch, statusId],
);
const renderHeader = useCallback((items: HistoryItem[]) => {
return (
<FormattedMessage
id='status.edited_x_times'
defaultMessage='Edited {count, plural, one {# time} other {# times}}'
values={{ count: items.length - 1 }}
/>
);
}, []);
const renderItem = useCallback(
(
item: HistoryItem,
index: number,
{
onClick,
onKeyUp,
}: {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
},
) => {
const formattedDate = (
<RelativeTimestamp
timestamp={item.get('created_at') as string}
short={false}
/>
);
const formattedName = (
<InlineAccount accountId={item.get('account') as string} />
);
const label = (item.get('original') as boolean) ? (
<FormattedMessage
id='status.history.created'
defaultMessage='{name} created {date}'
values={{ name: formattedName, date: formattedDate }}
/>
) : (
<FormattedMessage
id='status.history.edited'
defaultMessage='{name} edited {date}'
values={{ name: formattedName, date: formattedDate }}
/>
);
return (
<li
className='dropdown-menu__item edited-timestamp__history__item'
key={item.get('created_at') as string}
>
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}>
{label}
</button>
</li>
);
},
[],
);
return (
<Dropdown<HistoryItem>
items={items}
loading={loading}
renderItem={renderItem}
scrollable
renderHeader={renderHeader}
onOpen={handleOpen}
onItemClick={handleItemClick}
>
<button className='dropdown-menu__text-button'>
<FormattedMessage
id='status.edited'
defaultMessage='Edited {date}'
values={{
date: (
<FormattedDateWrapper
className='animated-number'
value={timestamp}
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
),
}}
/>
</button>
</Dropdown>
);
};

View file

@ -16,8 +16,7 @@ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
mutual: { id: 'account.mutual', defaultMessage: 'Mutual' }, editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
}); });
export const FollowButton: React.FC<{ export const FollowButton: React.FC<{
@ -73,15 +72,9 @@ export const FollowButton: React.FC<{
if (!signedIn) { if (!signedIn) {
label = intl.formatMessage(messages.follow); label = intl.formatMessage(messages.follow);
} else if (accountId === me) { } else if (accountId === me) {
label = intl.formatMessage(messages.edit_profile); label = intl.formatMessage(messages.editProfile);
} else if (!relationship) { } else if (!relationship) {
label = <LoadingIndicator />; label = <LoadingIndicator />;
} else if (
relationship.following &&
isShowItem('relationships') &&
relationship.followed_by
) {
label = intl.formatMessage(messages.mutual);
} else if (relationship.following || relationship.requested) { } else if (relationship.following || relationship.requested) {
label = intl.formatMessage(messages.unfollow); label = intl.formatMessage(messages.unfollow);
} else if (relationship.followed_by && isShowItem('relationships')) { } else if (relationship.followed_by && isShowItem('relationships')) {

View file

@ -102,10 +102,11 @@ export interface HashtagProps {
description?: React.ReactNode; description?: React.ReactNode;
history?: number[]; history?: number[];
name: string; name: string;
people: number; people?: number;
to: string; to: string;
uses?: number; uses?: number;
withGraph?: boolean; withGraph?: boolean;
children?: React.ReactNode;
} }
export const Hashtag: React.FC<HashtagProps> = ({ export const Hashtag: React.FC<HashtagProps> = ({
@ -117,6 +118,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
className, className,
description, description,
withGraph = true, withGraph = true,
children,
}) => ( }) => (
<div className={classNames('trends__item', className)}> <div className={classNames('trends__item', className)}>
<div className='trends__item__name'> <div className='trends__item__name'>
@ -158,5 +160,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
</SilentErrorBoundary> </SilentErrorBoundary>
</div> </div>
)} )}
{children && <div className='trends__item__buttons'>{children}</div>}
</div> </div>
); );

View file

@ -20,6 +20,7 @@ export type StatusLike = Record<{
contentHTML: string; contentHTML: string;
media_attachments: List<unknown>; media_attachments: List<unknown>;
spoiler_text?: string; spoiler_text?: string;
account: Record<{ id: string }>;
}>; }>;
function normalizeHashtag(hashtag: string) { function normalizeHashtag(hashtag: string) {
@ -195,19 +196,36 @@ export function getHashtagBarForStatus(status: StatusLike) {
return { return {
statusContentProps, statusContentProps,
hashtagBar: <HashtagBar hashtags={hashtagsInBar} />, hashtagBar: (
<HashtagBar
hashtags={hashtagsInBar}
accountId={status.getIn(['account', 'id']) as string}
/>
),
}; };
} }
export function getFeaturedHashtagBar(acct: string, tags: string[]) { export function getFeaturedHashtagBar(
return <HashtagBar acct={acct} hashtags={tags} defaultExpanded />; accountId: string,
acct: string,
tags: string[],
) {
return (
<HashtagBar
acct={acct}
hashtags={tags}
accountId={accountId}
defaultExpanded
/>
);
} }
const HashtagBar: React.FC<{ const HashtagBar: React.FC<{
hashtags: string[]; hashtags: string[];
accountId: string;
acct?: string; acct?: string;
defaultExpanded?: boolean; defaultExpanded?: boolean;
}> = ({ hashtags, acct, defaultExpanded }) => { }> = ({ hashtags, accountId, acct, defaultExpanded }) => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
setExpanded(true); setExpanded(true);
@ -228,6 +246,7 @@ const HashtagBar: React.FC<{
<Link <Link
key={hashtag} key={hashtag}
to={acct ? `/@${acct}/tagged/${hashtag}` : `/tags/${hashtag}`} to={acct ? `/@${acct}/tagged/${hashtag}` : `/tags/${hashtag}`}
data-menu-hashtag={accountId}
> >
#<span>{hashtag}</span> #<span>{hashtag}</span>
</Link> </Link>

View file

@ -1,4 +1,4 @@
import { PureComponent, createRef } from 'react'; import { useState, useEffect, useCallback, forwardRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
@ -15,101 +15,110 @@ interface Props {
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>; onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>; onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>; onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
active: boolean; active?: boolean;
expanded?: boolean; expanded?: boolean;
style?: React.CSSProperties; style?: React.CSSProperties;
activeStyle?: React.CSSProperties; activeStyle?: React.CSSProperties;
disabled: boolean; disabled?: boolean;
inverted?: boolean; inverted?: boolean;
animate: boolean; animate?: boolean;
overlay: boolean; overlay?: boolean;
tabIndex: number; tabIndex?: number;
counter?: number; counter?: number;
href?: string; href?: string;
ariaHidden: boolean; ariaHidden?: boolean;
data_id?: string; data_id?: string;
} }
interface States {
activate: boolean;
deactivate: boolean;
}
export class IconButton extends PureComponent<Props, States> {
buttonRef = createRef<HTMLButtonElement>();
static defaultProps = { export const IconButton = forwardRef<HTMLButtonElement, Props>(
active: false, (
disabled: false, {
animate: false,
overlay: false,
tabIndex: 0,
ariaHidden: false,
};
state = {
activate: false,
deactivate: false,
};
UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (!nextProps.animate) return;
if (this.props.active && !nextProps.active) {
this.setState({ activate: false, deactivate: true });
} else if (!this.props.active && nextProps.active) {
this.setState({ activate: true, deactivate: false });
}
}
handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
if (!this.props.disabled && this.props.onClick != null) {
this.props.onClick(e);
}
};
handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (this.props.onKeyPress && !this.props.disabled) {
this.props.onKeyPress(e);
}
};
handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (!this.props.disabled && this.props.onMouseDown) {
this.props.onMouseDown(e);
}
};
handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (!this.props.disabled && this.props.onKeyDown) {
this.props.onKeyDown(e);
}
};
render() {
const style = {
...this.props.style,
...(this.props.active ? this.props.activeStyle : {}),
};
const {
active,
className, className,
disabled,
expanded, expanded,
icon, icon,
iconComponent, iconComponent,
inverted, inverted,
overlay,
tabIndex,
title, title,
counter, counter,
href, href,
ariaHidden, style,
data_id, activeStyle,
} = this.props; onClick,
onKeyDown,
onKeyPress,
onMouseDown,
active = false,
disabled = false,
animate = false,
overlay = false,
tabIndex = 0,
ariaHidden = false,
data_id = undefined,
},
buttonRef,
) => {
const [activate, setActivate] = useState(false);
const [deactivate, setDeactivate] = useState(false);
const { activate, deactivate } = this.state; useEffect(() => {
if (!animate) {
return;
}
if (activate && !active) {
setActivate(false);
setDeactivate(true);
} else if (!activate && active) {
setActivate(true);
setDeactivate(false);
}
}, [setActivate, setDeactivate, animate, active, activate]);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => {
e.preventDefault();
if (!disabled) {
onClick?.(e);
}
},
[disabled, onClick],
);
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onKeyPress?.(e);
}
},
[disabled, onKeyPress],
);
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onMouseDown?.(e);
}
},
[disabled, onMouseDown],
);
const handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onKeyDown?.(e);
}
},
[disabled, onKeyDown],
);
const buttonStyle = {
...style,
...(active ? activeStyle : {}),
};
const classes = classNames(className, 'icon-button', { const classes = classNames(className, 'icon-button', {
active, active,
@ -148,19 +157,20 @@ export class IconButton extends PureComponent<Props, States> {
aria-hidden={ariaHidden} aria-hidden={ariaHidden}
title={title} title={title}
className={classes} className={classes}
onClick={this.handleClick} onClick={handleClick}
onMouseDown={this.handleMouseDown} onMouseDown={handleMouseDown}
onKeyDown={this.handleKeyDown} onKeyDown={handleKeyDown}
// eslint-disable-next-line @typescript-eslint/no-deprecated onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
onKeyPress={this.handleKeyPress} style={buttonStyle}
style={style}
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled} disabled={disabled}
data-id={data_id} data-id={data_id}
ref={this.buttonRef} ref={buttonRef}
> >
{contents} {contents}
</button> </button>
); );
} },
} );
IconButton.displayName = 'IconButton';

View file

@ -1,25 +1,6 @@
import { Switch, Route } from 'react-router-dom';
import AccountNavigation from 'mastodon/features/account/navigation';
import Trends from 'mastodon/features/getting_started/containers/trends_container'; import Trends from 'mastodon/features/getting_started/containers/trends_container';
import { showTrends } from 'mastodon/initial_state'; import { showTrends } from 'mastodon/initial_state';
const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
export const NavigationPortal: React.FC = () => ( export const NavigationPortal: React.FC = () => (
<div className='navigation-panel__portal'> <div className='navigation-panel__portal'>{showTrends && <Trends />}</div>
<Switch>
<Route path='/@:acct' exact component={AccountNavigation} />
<Route
path='/@:acct/tagged/:tagged?'
exact
component={AccountNavigation}
/>
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
<Route path='/@:acct/followers' exact component={AccountNavigation} />
<Route path='/@:acct/following' exact component={AccountNavigation} />
<Route path='/@:acct/media' exact component={AccountNavigation} />
<Route component={DefaultNavigation} />
</Switch>
</div>
); );

View file

@ -0,0 +1,43 @@
import { FormattedMessage } from 'react-intl';
import { useAppSelector } from 'mastodon/store';
import { TimelineHint } from './timeline_hint';
interface RemoteHintProps {
accountId?: string;
}
export const RemoteHint: React.FC<RemoteHintProps> = ({ accountId }) => {
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const domain = account?.acct ? account.acct.split('@')[1] : undefined;
if (
!account ||
!account.url ||
account.acct !== account.username ||
!domain
) {
return null;
}
return (
<TimelineHint
url={account.url}
message={
<FormattedMessage
id='hints.profiles.posts_may_be_missing'
defaultMessage='Some posts from this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_posts'
defaultMessage='See more posts on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};

View file

@ -42,7 +42,7 @@ class ServerBanner extends PureComponent {
return ( return (
<div className='server-banner'> <div className='server-banner'>
<div className='server-banner__introduction'> <div className='server-banner__introduction'>
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank' rel='noopener'>Mastodon</a> }} /> <FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank' rel='noopener'>Mastodon</a> }} />
</div> </div>
<Link to='/about'> <Link to='/about'>

View file

@ -25,9 +25,8 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { enableEmojiReaction , bookmarkCategoryNeeded, simpleTimelineMenu, me, isHideItem, boostMenu, boostModal } from '../initial_state'; import { enableEmojiReaction , bookmarkCategoryNeeded, simpleTimelineMenu, me, isHideItem, boostMenu, boostModal } from '../initial_state';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
@ -349,10 +348,9 @@ class StatusActionBar extends ImmutablePureComponent {
if (writtenByMe && pinnableStatus) { if (writtenByMe && pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
} }
menu.push(null);
if (writtenByMe || withDismiss) { if (writtenByMe || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null); menu.push(null);
@ -498,7 +496,7 @@ class StatusActionBar extends ImmutablePureComponent {
</div> </div>
<div className='status__action-bar__button-wrapper'> <div className='status__action-bar__button-wrapper'>
{reblogMenu.length === 0 ? reblogButton : ( {reblogMenu.length === 0 ? reblogButton : (
<DropdownMenuContainer <Dropdown
className={classNames('status__action-bar__button', { reblogPrivate })} className={classNames('status__action-bar__button', { reblogPrivate })}
scrollKey={scrollKey} scrollKey={scrollKey}
status={status} status={status}
@ -509,9 +507,7 @@ class StatusActionBar extends ImmutablePureComponent {
title={reblogTitle} title={reblogTitle}
active={status.get('reblogged')} active={status.get('reblogged')}
disabled={!publicStatus && !reblogPrivate} disabled={!publicStatus && !reblogPrivate}
> />
{reblogButton}
</DropdownMenuContainer>
)} )}
</div> </div>
<div className='status__action-bar__button-wrapper'> <div className='status__action-bar__button-wrapper'>
@ -522,7 +518,7 @@ class StatusActionBar extends ImmutablePureComponent {
</div> </div>
{emojiPickerDropdown} {emojiPickerDropdown}
<div className='status__action-bar__button-wrapper'> <div className='status__action-bar__button-wrapper'>
<DropdownMenuContainer <Dropdown
scrollKey={scrollKey} scrollKey={scrollKey}
status={status} status={status}
items={menu} items={menu}

View file

@ -115,6 +115,7 @@ class StatusContent extends PureComponent {
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`); link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
link.setAttribute('data-menu-hashtag', this.props.status.getIn(['account', 'id']));
} else { } else {
link.setAttribute('title', link.href); link.setAttribute('title', link.href);
link.classList.add('unhandled-link'); link.classList.add('unhandled-link');

View file

@ -1,50 +0,0 @@
import { connect } from 'react-redux';
import { fetchRelationships } from 'mastodon/actions/accounts';
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
import { openModal, closeModal } from '../actions/modal';
import DropdownMenu from '../components/dropdown_menu';
import { isUserTouching } from '../is_mobile';
/**
* @param {import('mastodon/store').RootState} state
*/
const mapStateToProps = state => ({
openDropdownId: state.dropdownMenu.openId,
openedViaKeyboard: state.dropdownMenu.keyboard,
});
/**
* @param {any} dispatch
* @param {Object} root0
* @param {any} [root0.status]
* @param {any} root0.items
* @param {any} [root0.scrollKey]
*/
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
onOpen(id, onItemClick, keyboard) {
if (status) {
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
}
dispatch(isUserTouching() ? openModal({
modalType: 'ACTIONS',
modalProps: {
status,
actions: items,
onClick: onItemClick,
},
}) : openDropdownMenu({ id, keyboard, scrollKey }));
},
onClose(id) {
dispatch(closeModal({
modalType: 'ACTIONS',
ignoreFocus: false,
}));
dispatch(closeDropdownMenu({ id }));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);

View file

@ -14,13 +14,13 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
import DisabledIcon from '@/material-icons/400-24px/close-fill.svg?react'; import DisabledIcon from '@/material-icons/400-24px/close-fill.svg?react';
import EnabledIcon from '@/material-icons/400-24px/done-fill.svg?react'; import EnabledIcon from '@/material-icons/400-24px/done-fill.svg?react';
import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react'; import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server'; import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
import { Account } from 'mastodon/components/account'; import { Account } from 'mastodon/components/account';
import Column from 'mastodon/components/column'; import Column from 'mastodon/components/column';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { ServerHeroImage } from 'mastodon/components/server_hero_image'; import { ServerHeroImage } from 'mastodon/components/server_hero_image';
import { Skeleton } from 'mastodon/components/skeleton'; import { Skeleton } from 'mastodon/components/skeleton';
import { LinkFooter } from 'mastodon/features/ui/components/link_footer'; import { LinkFooter} from 'mastodon/features/ui/components/link_footer';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.about', defaultMessage: 'About' }, title: { id: 'column.about', defaultMessage: 'About' },
@ -40,10 +40,6 @@ const messages = defineMessages({
enabled: { id: 'about.enabled', defaultMessage: 'Enabled' }, enabled: { id: 'about.enabled', defaultMessage: 'Enabled' },
disabled: { id: 'about.disabled', defaultMessage: 'Disabled' }, disabled: { id: 'about.disabled', defaultMessage: 'Disabled' },
capabilities: { id: 'about.kmyblue_capabilities', defaultMessage: 'Features available in this server' }, capabilities: { id: 'about.kmyblue_capabilities', defaultMessage: 'Features available in this server' },
joinFediverse: {
id: 'about.join_fediverse',
defaultMessage: "Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse."
},
}); });
const severityMessages = { const severityMessages = {
@ -63,13 +59,14 @@ const severityMessages = {
}, },
}; };
const mapStateToProps = (state) => ({ const mapStateToProps = state => ({
server: state.getIn(['server', 'server']), server: state.getIn(['server', 'server']),
extendedDescription: state.getIn(['server', 'extendedDescription']), extendedDescription: state.getIn(['server', 'extendedDescription']),
domainBlocks: state.getIn(['server', 'domainBlocks']), domainBlocks: state.getIn(['server', 'domainBlocks']),
}); });
class Section extends PureComponent { class Section extends PureComponent {
static propTypes = { static propTypes = {
title: PropTypes.string, title: PropTypes.string,
children: PropTypes.node, children: PropTypes.node,
@ -88,64 +85,49 @@ class Section extends PureComponent {
this.setState({ collapsed: !collapsed }, () => onOpen && onOpen()); this.setState({ collapsed: !collapsed }, () => onOpen && onOpen());
}; };
handleKeyDown = (e) => { render () {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.handleClick();
}
};
render() {
const { title, children } = this.props; const { title, children } = this.props;
const { collapsed } = this.state; const { collapsed } = this.state;
return ( return (
<div className={classNames('about__section', { active: !collapsed })}> <div className={classNames('about__section', { active: !collapsed })}>
<div <div className='about__section__title' role='button' tabIndex={0} onClick={this.handleClick}>
className="about__section__title"
role="button"
tabIndex={0}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
aria-expanded={!collapsed}
>
<Icon id={collapsed ? 'chevron-right' : 'chevron-down'} icon={collapsed ? ChevronRightIcon : ExpandMoreIcon} /> {title} <Icon id={collapsed ? 'chevron-right' : 'chevron-down'} icon={collapsed ? ChevronRightIcon : ExpandMoreIcon} /> {title}
</div> </div>
{!collapsed && <div className="about__section__body">{children}</div>} {!collapsed && (
<div className='about__section__body'>{children}</div>
)}
</div> </div>
); );
} }
} }
class CapabilityIcon extends PureComponent { class CapabilityIcon extends PureComponent {
static propTypes = { static propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
state: PropTypes.bool, state: PropTypes.bool,
}; };
render() { render () {
const { intl, state } = this.props; const { intl, state } = this.props;
if (state) { if (state) {
return ( return (
<span className="capability-icon enabled"> <span className='capability-icon enabled'><Icon id='check' icon={EnabledIcon} title={intl.formatMessage(messages.enabled)} />{intl.formatMessage(messages.enabled)}</span>
<Icon id="check" icon={EnabledIcon} title={intl.formatMessage(messages.enabled)} />
{intl.formatMessage(messages.enabled)}
</span>
); );
} else { } else {
return ( return (
<span className="capability-icon disabled"> <span className='capability-icon disabled'><Icon id='times' icon={DisabledIcon} title={intl.formatMessage(messages.disabled)} />{intl.formatMessage(messages.disabled)}</span>
<Icon id="times" icon={DisabledIcon} title={intl.formatMessage(messages.disabled)} />
{intl.formatMessage(messages.disabled)}
</span>
); );
} }
} }
} }
class About extends PureComponent { class About extends PureComponent {
static propTypes = { static propTypes = {
server: ImmutablePropTypes.map, server: ImmutablePropTypes.map,
extendedDescription: ImmutablePropTypes.map, extendedDescription: ImmutablePropTypes.map,
@ -159,7 +141,7 @@ class About extends PureComponent {
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
}; };
componentDidMount() { componentDidMount () {
const { dispatch } = this.props; const { dispatch } = this.props;
dispatch(fetchServer()); dispatch(fetchServer());
dispatch(fetchExtendedDescription()); dispatch(fetchExtendedDescription());
@ -170,11 +152,11 @@ class About extends PureComponent {
dispatch(fetchDomainBlocks()); dispatch(fetchDomainBlocks());
}; };
render() { render () {
const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props; const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
const isLoading = server.get('isLoading'); const isLoading = server.get('isLoading');
const fedibirdCapabilities = server.get('fedibird_capabilities') || []; const fedibirdCapabilities = server.get('fedibird_capabilities') || []; // thinking about isLoading is true
const isPublicUnlistedVisibility = fedibirdCapabilities.includes('kmyblue_visibility_public_unlisted'); const isPublicUnlistedVisibility = fedibirdCapabilities.includes('kmyblue_visibility_public_unlisted');
const isPublicVisibility = !fedibirdCapabilities.includes('kmyblue_no_public_visibility'); const isPublicVisibility = !fedibirdCapabilities.includes('kmyblue_no_public_visibility');
const isEmojiReaction = fedibirdCapabilities.includes('emoji_reaction'); const isEmojiReaction = fedibirdCapabilities.includes('emoji_reaction');
@ -187,88 +169,59 @@ class About extends PureComponent {
return ( return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
<div className="scrollable about"> <div className='scrollable about'>
<div className="about__header"> <div className='about__header'>
<ServerHeroImage <ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
blurhash={server.getIn(['thumbnail', 'blurhash'])} <h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
src={server.getIn(['thumbnail', 'url'])} <p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {domain}' /></p>
srcSet={server
.getIn(['thumbnail', 'versions'])
?.map((value, key) => `${value} ${key.replace('@', '')}`)
.join(', ')}
className="about__header__hero"
/>
<h1>{isLoading ? <Skeleton width="10ch" /> : server.get('domain')}</h1>
<p>
<FormattedMessage id="about.powered_by" defaultMessage="Social media powered by You!" />
</p>
</div> </div>
<div className="about__meta"> <div className='about__meta'>
<div className="about__meta__column"> <div className='about__meta__column'>
<h4> <h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
<FormattedMessage id="server_banner.administered_by" defaultMessage="Administered by:" />
</h4>
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal /> <Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal />
</div> </div>
<hr className="about__meta__divider" /> <hr className='about__meta__divider' />
<div className="about__meta__column"> <div className='about__meta__column'>
<h4> <h4><FormattedMessage id='about.contact' defaultMessage='Contact:' /></h4>
<FormattedMessage id="about.contact" defaultMessage="Contact:" />
</h4>
{isLoading ? ( {isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={emailLink}>{server.getIn(['contact', 'email'])}</a>}
<Skeleton width="10ch" />
) : (
<a className="about__mail" href={emailLink}>
{server.getIn(['contact', 'email'])}
</a>
)}
</div> </div>
</div> </div>
<Section open title={intl.formatMessage(messages.title)}> <Section open title={intl.formatMessage(messages.title)}>
{extendedDescription.get('isLoading') ? ( {extendedDescription.get('isLoading') ? (
<> <>
<Skeleton width="100%" /> <Skeleton width='100%' />
<br /> <br />
<Skeleton width="100%" /> <Skeleton width='100%' />
<br /> <br />
<Skeleton width="100%" /> <Skeleton width='100%' />
<br /> <br />
<Skeleton width="70%" /> <Skeleton width='70%' />
</> </>
) : extendedDescription.get('content')?.length > 0 ? ( ) : (extendedDescription.get('content')?.length > 0 ? (
<div className="prose" dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }} /> <div
className='prose'
dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }}
/>
) : ( ) : (
<p> <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
<FormattedMessage ))}
id="about.not_available"
defaultMessage="This information has not been made available on this server."
/>
</p>
)}
</Section> </Section>
<Section title={intl.formatMessage(messages.rules)}> <Section title={intl.formatMessage(messages.rules)}>
{!isLoading && (server.get('rules', ImmutableList()).isEmpty() ? ( {!isLoading && (server.get('rules', ImmutableList()).isEmpty() ? (
<p> <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
<FormattedMessage
id="about.not_available"
defaultMessage="This information has not been made available on this server."
/>
</p>
) : ( ) : (
<ol className="rules-list"> <ol className='rules-list'>
{server.get('rules').map((rule) => ( {server.get('rules').map(rule => (
<li key={rule.get('id')}> <li key={rule.get('id')}>
<div className="rules-list__text">{rule.get('text')}</div> <div className='rules-list__text'>{rule.get('text')}</div>
{!!rule.get('hint') && rule.get('hint').length > 0 && ( {rule.get('hint').length > 0 && (<div className='rules-list__hint'>{rule.get('hint')}</div>)}
<div className="rules-list__hint">{rule.get('hint')}</div>
)}
</li> </li>
))} ))}
</ol> </ol>
@ -276,38 +229,23 @@ class About extends PureComponent {
</Section> </Section>
<Section title={intl.formatMessage(messages.capabilities)}> <Section title={intl.formatMessage(messages.capabilities)}>
<p> <p><FormattedMessage id='about.kmyblue_capability' defaultMessage='This server is using kmyblue, a fork of Mastodon. On this server, kmyblues unique features are configured as follows.' /></p>
<FormattedMessage
id="about.kmyblue_capability"
defaultMessage="This server is using unique features are configured as follows."
/>
</p>
{!isLoading && ( {!isLoading && (
<ol className="rules-list"> <ol className='rules-list'>
<li> <li>
<span className="rules-list__text"> <span className='rules-list__text'>{intl.formatMessage(messages.emojiReaction)}: <CapabilityIcon state={isEmojiReaction} intl={intl} /></span>
{intl.formatMessage(messages.emojiReaction)}: <CapabilityIcon state={isEmojiReaction} intl={intl} />
</span>
</li> </li>
<li> <li>
<span className="rules-list__text"> <span className='rules-list__text'>{intl.formatMessage(messages.publicVisibility)}: <CapabilityIcon state={isPublicVisibility} intl={intl} /></span>
{intl.formatMessage(messages.publicVisibility)}: <CapabilityIcon state={isPublicVisibility} intl={intl} />
</span>
</li> </li>
<li> <li>
<span className="rules-list__text"> <span className='rules-list__text'>{intl.formatMessage(messages.publicUnlistedVisibility)}: <CapabilityIcon state={isPublicUnlistedVisibility} intl={intl} /></span>
{intl.formatMessage(messages.publicUnlistedVisibility)}: <CapabilityIcon state={isPublicUnlistedVisibility} intl={intl} />
</span>
</li> </li>
<li> <li>
<span className="rules-list__text"> <span className='rules-list__text'>{intl.formatMessage(messages.localTimeline)}: <CapabilityIcon state={isLocalTimeline} intl={intl} /></span>
{intl.formatMessage(messages.localTimeline)}: <CapabilityIcon state={isLocalTimeline} intl={intl} />
</span>
</li> </li>
<li> <li>
<span className="rules-list__text"> <span className='rules-list__text'>{intl.formatMessage(messages.fullTextSearch)}: <CapabilityIcon state={isFullTextSearch} intl={intl} /></span>
{intl.formatMessage(messages.fullTextSearch)}: <CapabilityIcon state={isFullTextSearch} intl={intl} />
</span>
</li> </li>
</ol> </ol>
)} )}
@ -316,75 +254,49 @@ class About extends PureComponent {
<Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}> <Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
{domainBlocks.get('isLoading') ? ( {domainBlocks.get('isLoading') ? (
<> <>
<Skeleton width="100%" /> <Skeleton width='100%' />
<br /> <br />
<Skeleton width="70%" /> <Skeleton width='70%' />
</> </>
) : domainBlocks.get('isAvailable') ? ( ) : (domainBlocks.get('isAvailable') ? (
<> <>
<p> <p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
<FormattedMessage
id="about.domain_blocks.preamble"
defaultMessage="Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server."
/>
</p>
{domainBlocks.get('items').size > 0 && ( {domainBlocks.get('items').size > 0 && (
<div className="about__domain-blocks"> <div className='about__domain-blocks'>
{domainBlocks.get('items').map((block) => ( {domainBlocks.get('items').map(block => (
<div className="about__domain-blocks__domain" key={block.get('domain')}> <div className='about__domain-blocks__domain' key={block.get('domain')}>
<div className="about__domain-blocks__domain__header"> <div className='about__domain-blocks__domain__header'>
<h6> <h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
<span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span> <span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity_ex') || block.get('severity')].title)}</span>
</h6>
<span
className="about__domain-blocks__domain__type"
title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}
>
{intl.formatMessage(
severityMessages[block.get('severity_ex') || block.get('severity')].title
)}
</span>
</div> </div>
<p> <p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
{(block.get('comment') || '').length > 0 ? (
block.get('comment')
) : (
<FormattedMessage id="about.domain_blocks.no_reason_available" defaultMessage="Reason not available" />
)}
</p>
</div> </div>
))} ))}
</div> </div>
)} )}
</> </>
) : ( ) : (
<p> <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
<FormattedMessage ))}
id="about.not_available"
defaultMessage="This information has not been made available on this server."
/>
</p>
)}
</Section> </Section>
<LinkFooter /> <LinkFooter />
<div className="about__footer"> <div className='about__footer'>
<p> <p><FormattedMessage id='about.disclaimer' defaultMessage='Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.' /></p>
<FormattedMessage {...messages.joinFediverse} />
</p>
</div> </div>
</div> </div>
<Helmet> <Helmet>
<title>{intl.formatMessage(messages.title)}</title> <title>{intl.formatMessage(messages.title)}</title>
<meta name="robots" content="all" /> <meta name='robots' content='all' />
</Helmet> </Helmet>
</Column> </Column>
); );
} }
} }
export default connect(mapStateToProps)(injectIntl(About)); export default connect(mapStateToProps)(injectIntl(About));

View file

@ -1,51 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Hashtag } from 'mastodon/components/hashtag';
const messages = defineMessages({
lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },
empty: { id: 'account.featured_tags.last_status_never', defaultMessage: 'No posts' },
});
class FeaturedTags extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record,
featuredTags: ImmutablePropTypes.list,
tagged: PropTypes.string,
intl: PropTypes.object.isRequired,
};
render () {
const { account, featuredTags, intl } = this.props;
if (!account || account.get('suspended') || featuredTags.isEmpty()) {
return null;
}
return (
<div className='getting-started__trends'>
<h4><FormattedMessage id='account.featured_tags.title' defaultMessage="{name}'s featured hashtags" values={{ name: <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /> }} /></h4>
{featuredTags.take(3).map(featuredTag => (
<Hashtag
key={featuredTag.get('name')}
name={featuredTag.get('name')}
to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`}
uses={featuredTag.get('statuses_count') * 1}
withGraph={false}
description={((featuredTag.get('statuses_count') * 1) > 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
/>
))}
</div>
);
}
}
export default injectIntl(FeaturedTags);

View file

@ -1,17 +0,0 @@
import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux';
import { makeGetAccount } from 'mastodon/selectors';
import FeaturedTags from '../components/featured_tags';
const mapStateToProps = () => {
const getAccount = makeGetAccount();
return (state, { accountId }) => ({
account: getAccount(state, accountId),
featuredTags: state.getIn(['user_lists', 'featured_tags', accountId, 'items'], ImmutableList()),
});
};
export default connect(mapStateToProps)(FeaturedTags);

View file

@ -1,52 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { connect } from 'react-redux';
import FeaturedTags from 'mastodon/features/account/containers/featured_tags_container';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
const mapStateToProps = (state, { match: { params: { acct } } }) => {
const accountId = state.getIn(['accounts_map', normalizeForLookup(acct)]);
if (!accountId) {
return {
isLoading: true,
};
}
return {
accountId,
isLoading: false,
};
};
class AccountNavigation extends PureComponent {
static propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
acct: PropTypes.string,
tagged: PropTypes.string,
}).isRequired,
}).isRequired,
accountId: PropTypes.string,
isLoading: PropTypes.bool,
};
render () {
const { accountId, isLoading, match: { params: { tagged } } } = this.props;
if (isLoading) {
return null;
}
return (
<FeaturedTags accountId={accountId} tagged={tagged} />
);
}
}
export default connect(mapStateToProps)(AccountNavigation);

View file

@ -0,0 +1,50 @@
import { FormattedMessage } from 'react-intl';
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
interface EmptyMessageProps {
suspended: boolean;
hidden: boolean;
blockedBy: boolean;
accountId?: string;
}
export const EmptyMessage: React.FC<EmptyMessageProps> = ({
accountId,
suspended,
hidden,
blockedBy,
}) => {
if (!accountId) {
return null;
}
let message: React.ReactNode = null;
if (suspended) {
message = (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
message = <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
message = (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
} else {
message = (
<FormattedMessage
id='empty_column.account_featured'
defaultMessage='This list is empty'
/>
);
}
return <div className='empty-column-indicator'>{message}</div>;
};

View file

@ -0,0 +1,51 @@
import { defineMessages, useIntl } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable';
import { Hashtag } from 'mastodon/components/hashtag';
export type TagMap = ImmutableMap<
'id' | 'name' | 'url' | 'statuses_count' | 'last_status_at' | 'accountId',
string | null
>;
interface FeaturedTagProps {
tag: TagMap;
account: string;
}
const messages = defineMessages({
lastStatusAt: {
id: 'account.featured_tags.last_status_at',
defaultMessage: 'Last post on {date}',
},
empty: {
id: 'account.featured_tags.last_status_never',
defaultMessage: 'No posts',
},
});
export const FeaturedTag: React.FC<FeaturedTagProps> = ({ tag, account }) => {
const intl = useIntl();
const name = tag.get('name') ?? '';
const count = Number.parseInt(tag.get('statuses_count') ?? '');
return (
<Hashtag
key={name}
name={name}
to={`/@${account}/tagged/${name}`}
uses={count}
withGraph={false}
description={
count > 0
? intl.formatMessage(messages.lastStatusAt, {
date: intl.formatDate(tag.get('last_status_at') ?? '', {
month: 'short',
day: '2-digit',
}),
})
: intl.formatMessage(messages.empty)
}
/>
);
};

View file

@ -0,0 +1,156 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RemoteHint } from 'mastodon/components/remote_hint';
import StatusContainer from 'mastodon/containers/status_container';
import { useAccountId } from 'mastodon/hooks/useAccountId';
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { AccountHeader } from '../account_timeline/components/account_header';
import Column from '../ui/components/column';
import { EmptyMessage } from './components/empty_message';
import { FeaturedTag } from './components/featured_tag';
import type { TagMap } from './components/featured_tag';
interface Params {
acct?: string;
id?: string;
}
const AccountFeatured = () => {
const accountId = useAccountId();
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
const forceEmptyState = suspended || blockedBy || hidden;
const { acct = '' } = useParams<Params>();
const dispatch = useAppDispatch();
useEffect(() => {
if (accountId) {
void dispatch(expandAccountFeaturedTimeline(accountId));
dispatch(fetchFeaturedTags(accountId));
}
}, [accountId, dispatch]);
const isLoading = useAppSelector(
(state) =>
!accountId ||
!!(state.timelines as ImmutableMap<string, unknown>).getIn([
`account:${accountId}:pinned`,
'isLoading',
]) ||
!!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']),
);
const featuredTags = useAppSelector(
(state) =>
state.user_lists.getIn(
['featured_tags', accountId, 'items'],
ImmutableList(),
) as ImmutableList<TagMap>,
);
const featuredStatusIds = useAppSelector(
(state) =>
(state.timelines as ImmutableMap<string, unknown>).getIn(
[`account:${accountId}:pinned`, 'items'],
ImmutableList(),
) as ImmutableList<string>,
);
if (isLoading) {
return (
<AccountFeaturedWrapper accountId={accountId}>
<div className='scrollable__append'>
<LoadingIndicator />
</div>
</AccountFeaturedWrapper>
);
}
if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) {
return (
<AccountFeaturedWrapper accountId={accountId}>
<EmptyMessage
blockedBy={blockedBy}
hidden={hidden}
suspended={suspended}
accountId={accountId}
/>
<RemoteHint accountId={accountId} />
</AccountFeaturedWrapper>
);
}
return (
<Column>
<ColumnBackButton />
<div className='scrollable scrollable--flex'>
{accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)}
{!featuredTags.isEmpty() && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.hashtags'
defaultMessage='Hashtags'
/>
</h4>
{featuredTags.map((tag) => (
<FeaturedTag key={tag.get('id')} tag={tag} account={acct} />
))}
</>
)}
{!featuredStatusIds.isEmpty() && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.posts'
defaultMessage='Posts'
/>
</h4>
{featuredStatusIds.map((statusId) => (
<StatusContainer
key={`f-${statusId}`}
// @ts-expect-error inferred props are wrong
id={statusId}
contextType='account'
/>
))}
</>
)}
<RemoteHint accountId={accountId} />
</div>
</Column>
);
};
const AccountFeaturedWrapper = ({
children,
accountId,
}: React.PropsWithChildren<{ accountId?: string }>) => {
return (
<Column>
<ColumnBackButton />
<div className='scrollable scrollable--flex'>
{accountId && <AccountHeader accountId={accountId} />}
{children}
</div>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default AccountFeatured;

View file

@ -2,25 +2,22 @@ import { useEffect, useCallback } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import type { Map as ImmutableMap } from 'immutable'; import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { expandAccountMediaTimeline } from 'mastodon/actions/timelines'; import { expandAccountMediaTimeline } from 'mastodon/actions/timelines';
import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { RemoteHint } from 'mastodon/components/remote_hint';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint'; import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import Column from 'mastodon/features/ui/components/column'; import Column from 'mastodon/features/ui/components/column';
import { useAccountId } from 'mastodon/hooks/useAccountId';
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
import type { MediaAttachment } from 'mastodon/models/media_attachment'; import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import type { RootState } from 'mastodon/store'; import type { RootState } from 'mastodon/store';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -56,53 +53,11 @@ const getAccountGallery = createSelector(
}, },
); );
interface Params {
acct?: string;
id?: string;
}
const RemoteHint: React.FC<{
accountId: string;
}> = ({ accountId }) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
const acct = account?.acct;
const url = account?.url;
const domain = acct ? acct.split('@')[1] : undefined;
if (!url) {
return null;
}
return (
<TimelineHint
url={url}
message={
<FormattedMessage
id='hints.profiles.posts_may_be_missing'
defaultMessage='Some posts from this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_posts'
defaultMessage='See more posts on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};
export const AccountGallery: React.FC<{ export const AccountGallery: React.FC<{
multiColumn: boolean; multiColumn: boolean;
}> = ({ multiColumn }) => { }> = ({ multiColumn }) => {
const { acct, id } = useParams<Params>();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const accountId = useAppSelector( const accountId = useAccountId();
(state) =>
id ??
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
);
const attachments = useAppSelector((state) => const attachments = useAppSelector((state) =>
accountId accountId
? getAccountGallery(state, accountId) ? getAccountGallery(state, accountId)
@ -123,33 +78,15 @@ export const AccountGallery: React.FC<{
const account = useAppSelector((state) => const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined, accountId ? state.accounts.get(accountId) : undefined,
); );
const blockedBy = useAppSelector(
(state) =>
state.relationships.getIn([accountId, 'blocked_by'], false) as boolean,
);
const suspended = useAppSelector(
(state) => state.accounts.getIn([accountId, 'suspended'], false) as boolean,
);
const isAccount = !!account; const isAccount = !!account;
const remote = account?.acct !== account?.username;
const hidden = useAppSelector((state) => const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
accountId ? getAccountHidden(state, accountId) : false,
);
const maxId = attachments.last()?.getIn(['status', 'id']) as const maxId = attachments.last()?.getIn(['status', 'id']) as
| string | string
| undefined; | undefined;
useEffect(() => { useEffect(() => {
if (!accountId) {
dispatch(lookupAccount(acct));
}
}, [dispatch, accountId, acct]);
useEffect(() => {
if (accountId && !isAccount) {
dispatch(fetchAccount(accountId));
}
if (accountId && isAccount) { if (accountId && isAccount) {
void dispatch(expandAccountMediaTimeline(accountId)); void dispatch(expandAccountMediaTimeline(accountId));
} }
@ -233,7 +170,7 @@ export const AccountGallery: React.FC<{
defaultMessage='Profile unavailable' defaultMessage='Profile unavailable'
/> />
); );
} else if (remote && attachments.isEmpty()) { } else if (attachments.isEmpty()) {
emptyMessage = <RemoteHint accountId={accountId} />; emptyMessage = <RemoteHint accountId={accountId} />;
} else { } else {
emptyMessage = ( emptyMessage = (
@ -259,7 +196,7 @@ export const AccountGallery: React.FC<{
) )
} }
alwaysPrepend alwaysPrepend
append={remote && accountId && <RemoteHint accountId={accountId} />} append={accountId && <RemoteHint accountId={accountId} />}
scrollKey='account_gallery' scrollKey='account_gallery'
isLoading={isLoading} isLoading={isLoading}
hasMore={!forceEmptyState && hasMore} hasMore={!forceEmptyState && hasMore}

View file

@ -44,27 +44,21 @@ import {
FollowingCounter, FollowingCounter,
StatusesCounter, StatusesCounter,
} from 'mastodon/components/counters'; } from 'mastodon/components/counters';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FollowButton } from 'mastodon/components/follow_button';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date'; import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import { getFeaturedHashtagBar } from 'mastodon/components/hashtag_bar'; import { getFeaturedHashtagBar } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { ShortNumber } from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { DomainPill } from 'mastodon/features/account/components/domain_pill'; import { DomainPill } from 'mastodon/features/account/components/domain_pill';
import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container'; import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container';
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container'; import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
import { useLinks } from 'mastodon/hooks/useLinks'; import { useLinks } from 'mastodon/hooks/useLinks';
import { useIdentity } from 'mastodon/identity_context'; import { useIdentity } from 'mastodon/identity_context';
import { import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
autoPlayGif,
me,
domain as localDomain,
isShowItem,
} from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
import type { DropdownMenu } from 'mastodon/models/dropdown_menu'; import type { MenuItem } from 'mastodon/models/dropdown_menu';
import type { Relationship } from 'mastodon/models/relationship';
import { import {
PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_USERS,
PERMISSION_MANAGE_FEDERATION, PERMISSION_MANAGE_FEDERATION,
@ -204,24 +198,6 @@ const titleFromAccount = (account: Account) => {
return `${prefix} (@${acct})`; return `${prefix} (@${acct})`;
}; };
const messageForFollowButton = (relationship?: Relationship) => {
if (!relationship) return messages.follow;
if (
relationship.get('following') &&
relationship.get('followed_by') &&
isShowItem('relationships')
) {
return messages.mutual;
} else if (relationship.get('following') || relationship.get('requested')) {
return messages.unfollow;
} else if (relationship.get('followed_by') && isShowItem('relationships')) {
return messages.followBack;
} else {
return messages.follow;
}
};
const dateFormatOptions: Intl.DateTimeFormatOptions = { const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@ -249,20 +225,6 @@ export const AccountHeader: React.FC<{
); );
const handleLinkClick = useLinks(); const handleLinkClick = useLinks();
const handleFollow = useCallback(() => {
if (!account) {
return;
}
if (relationship?.following || relationship?.requested) {
dispatch(
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
);
} else {
dispatch(followAccount(account.id));
}
}, [dispatch, account, relationship]);
const handleBlock = useCallback(() => { const handleBlock = useCallback(() => {
if (!account) { if (!account) {
return; return;
@ -446,23 +408,6 @@ export const AccountHeader: React.FC<{
); );
}, [dispatch, account]); }, [dispatch, account]);
const handleInteractionModal = useCallback(() => {
if (!account) {
return;
}
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'follow',
accountId: account.id,
url: account.uri,
},
}),
);
}, [dispatch, account]);
const handleOpenAvatar = useCallback( const handleOpenAvatar = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (e.button !== 0 || e.ctrlKey || e.metaKey) { if (e.button !== 0 || e.ctrlKey || e.metaKey) {
@ -498,10 +443,6 @@ export const AccountHeader: React.FC<{
}); });
}, [account]); }, [account]);
const handleEditProfile = useCallback(() => {
window.open('/settings/profile', '_blank');
}, []);
const handleMouseEnter = useCallback( const handleMouseEnter = useCallback(
({ currentTarget }: React.MouseEvent) => { ({ currentTarget }: React.MouseEvent) => {
if (autoPlayGif) { if (autoPlayGif) {
@ -537,7 +478,7 @@ export const AccountHeader: React.FC<{
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null; const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
const menu = useMemo(() => { const menu = useMemo(() => {
const arr: DropdownMenu = []; const arr: MenuItem[] = [];
if (!account) { if (!account) {
return arr; return arr;
@ -626,9 +567,7 @@ export const AccountHeader: React.FC<{
arr.push({ arr.push({
text: intl.formatMessage( text: intl.formatMessage(
account.getIn(['relationship', 'endorsed']) relationship.endorsed ? messages.unendorse : messages.endorse,
? messages.unendorse
: messages.endorse,
), ),
action: handleEndorseToggle, action: handleEndorseToggle,
}); });
@ -778,9 +717,12 @@ export const AccountHeader: React.FC<{
return null; return null;
} }
let actionBtn, bellBtn, lockedIcon, shareBtn; let actionBtn: React.ReactNode,
bellBtn: React.ReactNode,
lockedIcon: React.ReactNode,
shareBtn: React.ReactNode;
const info = []; const info: React.ReactNode[] = [];
if (me !== account.id && relationship?.blocking) { if (me !== account.id && relationship?.blocking) {
info.push( info.push(
@ -848,43 +790,17 @@ export const AccountHeader: React.FC<{
); );
} }
if (me !== account.id) { if (relationship?.blocking) {
if (signedIn && !relationship) {
// Wait until the relationship is loaded
actionBtn = (
<Button disabled>
<LoadingIndicator />
</Button>
);
} else if (!relationship?.blocking) {
actionBtn = (
<Button
disabled={relationship?.blocked_by}
className={classNames({
'button--destructive':
relationship?.following || relationship?.requested,
})}
text={intl.formatMessage(messageForFollowButton(relationship))}
onClick={signedIn ? handleFollow : handleInteractionModal}
/>
);
} else {
actionBtn = (
<Button
text={intl.formatMessage(messages.unblock, {
name: account.username,
})}
onClick={handleBlock}
/>
);
}
} else {
actionBtn = ( actionBtn = (
<Button <Button
text={intl.formatMessage(messages.edit_profile)} text={intl.formatMessage(messages.unblock, {
onClick={handleEditProfile} name: account.username,
})}
onClick={handleBlock}
/> />
); );
} else {
actionBtn = <FollowButton accountId={accountId} />;
} }
if (account.moved && !relationship?.following) { if (account.moved && !relationship?.following) {
@ -910,7 +826,11 @@ export const AccountHeader: React.FC<{
const isIndexable = !account.noindex; const isIndexable = !account.noindex;
const featuredTagsArr = const featuredTagsArr =
featuredTags?.map((tag: any) => tag.get('name')).toArray() || []; featuredTags?.map((tag: any) => tag.get('name')).toArray() || [];
const featuredTagsBar = getFeaturedHashtagBar(account.acct, featuredTagsArr); const featuredTagsBar = getFeaturedHashtagBar(
account.id,
account.acct,
featuredTagsArr,
);
const badges = []; const badges = [];
@ -920,7 +840,7 @@ export const AccountHeader: React.FC<{
badges.push(<GroupBadge key='group-badge' />); badges.push(<GroupBadge key='group-badge' />);
} }
account.get('roles', []).forEach((role) => { account.roles.forEach((role) => {
badges.push( badges.push(
<Badge <Badge
key={`role-badge-${role.get('id')}`} key={`role-badge-${role.get('id')}`}
@ -980,13 +900,11 @@ export const AccountHeader: React.FC<{
<div className='account__header__tabs__buttons'> <div className='account__header__tabs__buttons'>
{!hidden && bellBtn} {!hidden && bellBtn}
{!hidden && shareBtn} {!hidden && shareBtn}
<DropdownMenuContainer <Dropdown
disabled={menu.length === 0} disabled={menu.length === 0}
items={menu} items={menu}
icon='ellipsis-v' icon='ellipsis-v'
iconComponent={MoreHorizIcon} iconComponent={MoreHorizIcon}
size={24}
direction='right'
/> />
{!hidden && actionBtn} {!hidden && actionBtn}
</div> </div>
@ -1141,6 +1059,9 @@ export const AccountHeader: React.FC<{
{!(hideTabs || hidden) && ( {!(hideTabs || hidden) && (
<div className='account__section-headline'> <div className='account__section-headline'>
<NavLink exact to={`/@${account.acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' />
</NavLink>
<NavLink exact to={`/@${account.acct}`}> <NavLink exact to={`/@${account.acct}`}>
<FormattedMessage id='account.posts' defaultMessage='Posts' /> <FormattedMessage id='account.posts' defaultMessage='Posts' />
</NavLink> </NavLink>

View file

@ -7,12 +7,10 @@ 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 { TimelineHint } from 'mastodon/components/timeline_hint';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors/accounts'; import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector } from 'mastodon/store';
import { lookupAccount, fetchAccount } from '../../actions/accounts'; import { lookupAccount, fetchAccount } from '../../actions/accounts';
import { fetchFeaturedTags } from '../../actions/featured_tags'; import { fetchFeaturedTags } from '../../actions/featured_tags';
@ -21,6 +19,7 @@ import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator'; import { LoadingIndicator } from '../../components/loading_indicator';
import StatusList from '../../components/status_list'; import StatusList from '../../components/status_list';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
import { RemoteHint } from 'mastodon/components/remote_hint';
import { AccountHeader } from './components/account_header'; import { AccountHeader } from './components/account_header';
import { LimitedAccountHint } from './components/limited_account_hint'; import { LimitedAccountHint } from './components/limited_account_hint';
@ -47,11 +46,8 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
return { return {
accountId, accountId,
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
remoteUrl: state.getIn(['accounts', accountId, 'url']),
isAccount: !!state.getIn(['accounts', accountId]), isAccount: !!state.getIn(['accounts', accountId]),
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList), statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], emptyList),
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false), suspended: state.getIn(['accounts', accountId, 'suspended'], false),
@ -60,24 +56,6 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
}; };
}; };
const RemoteHint = ({ accountId, url }) => {
const acct = useAppSelector(state => state.accounts.get(accountId)?.acct);
const domain = acct ? acct.split('@')[1] : undefined;
return (
<TimelineHint
url={url}
message={<FormattedMessage id='hints.profiles.posts_may_be_missing' defaultMessage='Some posts from this profile may be missing.' />}
label={<FormattedMessage id='hints.profiles.see_more_posts' defaultMessage='See more posts on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
/>
);
};
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
};
class AccountTimeline extends ImmutablePureComponent { class AccountTimeline extends ImmutablePureComponent {
static propTypes = { static propTypes = {
@ -89,7 +67,6 @@ class AccountTimeline extends ImmutablePureComponent {
accountId: PropTypes.string, accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list, statusIds: ImmutablePropTypes.list,
featuredStatusIds: ImmutablePropTypes.list,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
withReplies: PropTypes.bool, withReplies: PropTypes.bool,
@ -97,8 +74,6 @@ class AccountTimeline extends ImmutablePureComponent {
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
suspended: PropTypes.bool, suspended: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
}; };
@ -161,7 +136,7 @@ class AccountTimeline extends ImmutablePureComponent {
}; };
render () { render () {
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props; const { accountId, statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
if (isLoading && statusIds.isEmpty()) { if (isLoading && statusIds.isEmpty()) {
return ( return (
@ -191,8 +166,6 @@ class AccountTimeline extends ImmutablePureComponent {
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />; emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
} }
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton />
@ -200,10 +173,9 @@ class AccountTimeline extends ImmutablePureComponent {
<StatusList <StatusList
prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />} prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
alwaysPrepend alwaysPrepend
append={remoteMessage} append={<RemoteHint accountId={accountId} />}
scrollKey='account_timeline' scrollKey='account_timeline'
statusIds={forceEmptyState ? emptyList : statusIds} statusIds={forceEmptyState ? emptyList : statusIds}
featuredStatusIds={featuredStatusIds}
isLoading={isLoading} isLoading={isLoading}
hasMore={!forceEmptyState && hasMore} hasMore={!forceEmptyState && hasMore}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}

View file

@ -2,7 +2,6 @@ import {
useState, useState,
useCallback, useCallback,
useRef, useRef,
useEffect,
useImperativeHandle, useImperativeHandle,
forwardRef, forwardRef,
} from 'react'; } from 'react';
@ -13,6 +12,7 @@ import classNames from 'classnames';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { useSpring, animated } from '@react-spring/web';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import { length } from 'stringz'; import { length } from 'stringz';
// eslint-disable-next-line import/extensions // eslint-disable-next-line import/extensions
@ -31,7 +31,7 @@ import Audio from 'mastodon/features/audio';
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter'; import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components'; import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
import { Video, getPointerPosition } from 'mastodon/features/video'; import { Video, getPointerPosition } from 'mastodon/features/video';
import { me } from 'mastodon/initial_state'; import { me, reduceMotion } from 'mastodon/initial_state';
import type { MediaAttachment } from 'mastodon/models/media_attachment'; import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { assetHost } from 'mastodon/utils/config'; import { assetHost } from 'mastodon/utils/config';
@ -105,6 +105,17 @@ const Preview: React.FC<{
position: FocalPoint; position: FocalPoint;
onPositionChange: (arg0: FocalPoint) => void; onPositionChange: (arg0: FocalPoint) => void;
}> = ({ mediaId, position, onPositionChange }) => { }> = ({ mediaId, position, onPositionChange }) => {
const draggingRef = useRef<boolean>(false);
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
const [x, y] = position;
const style = useSpring({
to: {
left: `${x * 100}%`,
top: `${y * 100}%`,
},
immediate: reduceMotion || draggingRef.current,
});
const media = useAppSelector((state) => const media = useAppSelector((state) =>
( (
(state.compose as ImmutableMap<string, unknown>).get( (state.compose as ImmutableMap<string, unknown>).get(
@ -117,9 +128,6 @@ const Preview: React.FC<{
); );
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [x, y] = position;
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
const draggingRef = useRef<boolean>(false);
const setRef = useCallback( const setRef = useCallback(
(e: HTMLImageElement | HTMLVideoElement | null) => { (e: HTMLImageElement | HTMLVideoElement | null) => {
@ -134,36 +142,30 @@ const Preview: React.FC<{
return; return;
} }
const handleMouseMove = (e: MouseEvent) => {
const { x, y } = getPointerPosition(nodeRef.current, e);
draggingRef.current = true; // This will disable the animation for quicker feedback, only do this if the mouse actually moves
onPositionChange([x, y]);
};
const handleMouseUp = () => {
setDragging(false);
draggingRef.current = false;
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent); const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent);
setDragging(true); setDragging(true);
draggingRef.current = true;
onPositionChange([x, y]); onPositionChange([x, y]);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
}, },
[setDragging, onPositionChange], [setDragging, onPositionChange],
); );
useEffect(() => {
const handleMouseUp = () => {
setDragging(false);
draggingRef.current = false;
};
const handleMouseMove = (e: MouseEvent) => {
if (draggingRef.current) {
const { x, y } = getPointerPosition(nodeRef.current, e);
onPositionChange([x, y]);
}
};
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, [setDragging, onPositionChange]);
if (!media) { if (!media) {
return null; return null;
} }
@ -179,10 +181,7 @@ const Preview: React.FC<{
role='presentation' role='presentation'
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
/> />
<div <animated.div className='focal-point__reticle' style={style} />
className='focal-point__reticle'
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
/>
</div> </div>
); );
} else if (media.get('type') === 'gifv') { } else if (media.get('type') === 'gifv') {
@ -194,10 +193,7 @@ const Preview: React.FC<{
alt='' alt=''
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
/> />
<div <animated.div className='focal-point__reticle' style={style} />
className='focal-point__reticle'
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
/>
</div> </div>
); );
} else if (media.get('type') === 'video') { } else if (media.get('type') === 'video') {

View file

@ -13,9 +13,9 @@ import { fetchAntennas } from 'mastodon/actions/antennas';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedAntennas } from 'mastodon/selectors/antennas'; import { getOrderedAntennas } from 'mastodon/selectors/antennas';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -96,12 +96,11 @@ const AntennaItem: React.FC<{
</span> </span>
</Link> </Link>
<DropdownMenuContainer <Dropdown
scrollKey='antennas' scrollKey='antennas'
items={menu} items={menu}
icons='ellipsis-h' icon='ellipsis-h'
iconComponent={MoreHorizIcon} iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)} title={intl.formatMessage(messages.more)}
/> />
</div> </div>

View file

@ -14,9 +14,9 @@ import { fetchBookmarkCategories } from 'mastodon/actions/bookmark_categories';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedBookmarkCategories } from 'mastodon/selectors/bookmark_categories'; import { getOrderedBookmarkCategories } from 'mastodon/selectors/bookmark_categories';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -76,12 +76,11 @@ const BookmarkCategoryItem: React.FC<{
<span>{title}</span> <span>{title}</span>
</Link> </Link>
<DropdownMenuContainer <Dropdown
scrollKey='bookmark_categories' scrollKey='bookmark_categories'
items={menu} items={menu}
icons='ellipsis-h' icon='ellipsis-h'
iconComponent={MoreHorizIcon} iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)} title={intl.formatMessage(messages.more)}
/> />
</div> </div>

View file

@ -1,195 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import { deleteBookmarkCategory, expandBookmarkCategoryStatuses, fetchBookmarkCategory, fetchBookmarkCategoryStatuses } from 'mastodon/actions/bookmark_categories';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { getBookmarkCategoryStatusList } from 'mastodon/selectors';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete_bookmark_category.message', defaultMessage: 'Are you sure you want to permanently delete this category?' },
deleteConfirm: { id: 'confirmations.delete_bookmark_category.confirm', defaultMessage: 'Delete' },
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const mapStateToProps = (state, { params }) => ({
bookmarkCategory: state.getIn(['bookmark_categories', params.id]),
statusIds: getBookmarkCategoryStatusList(state, params.id),
isLoading: state.getIn(['status_lists', 'bookmark_category_statuses', params.id, 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmark_category_statuses', params.id, 'next']),
});
class BookmarkCategoryStatuses extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
bookmarkCategory: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
...WithRouterPropTypes,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBookmarkCategory(this.props.params.id));
this.props.dispatch(fetchBookmarkCategoryStatuses(this.props.params.id));
}
UNSAFE_componentWillReceiveProps (nextProps) {
const { dispatch } = this.props;
const { id } = nextProps.params;
if (id !== this.props.params.id) {
dispatch(fetchBookmarkCategory(id));
dispatch(fetchBookmarkCategoryStatuses(id));
}
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS_EX', { id: this.props.params.id }));
this.props.history.push('/');
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
handleEditClick = () => {
this.props.history.push(`/bookmark_categories/${this.props.params.id}/edit`);
};
handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props;
const { id } = this.props.params;
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteBookmarkCategory(id));
if (columnId) {
dispatch(removeColumn(columnId));
} else {
this.props.history.push('/bookmark_categories');
}
},
},
}));
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandBookmarkCategoryStatuses(this.props.params.id));
}, 300, { leading: true });
render () {
const { intl, bookmarkCategory, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
if (typeof bookmarkCategory === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (bookmarkCategory === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='bookmark'
iconComponent={BookmarkIcon}
title={bookmarkCategory.get('title')}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings'>
<section className='column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='bookmark_categories.edit' defaultMessage='Edit category' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='bookmark_categories.delete' defaultMessage='Delete category' />
</button>
</section>
</div>
</ColumnHeader>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmark_ex_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default withRouter(connect(mapStateToProps)(injectIntl(BookmarkCategoryStatuses)));

View file

@ -0,0 +1,121 @@
import { useEffect, useRef, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import {
expandBookmarkCategoryStatuses,
fetchBookmarkCategory,
fetchBookmarkCategoryStatuses,
} from 'mastodon/actions/bookmark_categories';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getSubStatusList } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const BookmarkCategoryStatuses: React.FC<{
columnId: string;
multiColumn: boolean;
}> = ({ columnId, multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const columnRef = useRef<ColumnRef>(null);
const statusIds = useAppSelector((state) =>
getSubStatusList(state, 'bookmark_category', id),
);
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(
['bookmark_category_statuses', id, 'isLoading'],
true,
) as boolean,
);
const hasMore = useAppSelector(
(state) =>
!!state.status_lists.getIn(['bookmark_category_statuses', id, 'next']),
);
const bookmarkCategory = useAppSelector((state) =>
state.bookmark_categories.get(id),
);
useEffect(() => {
dispatch(fetchBookmarkCategory(id));
dispatch(fetchBookmarkCategoryStatuses(id));
}, [dispatch, id]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS_EX', { id }));
}
}, [dispatch, columnId, id]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(() => {
dispatch(expandBookmarkCategoryStatuses(id));
}, [dispatch, id]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.bookmarked_statuses'
defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here."
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={bookmarkCategory?.get('title')}
>
<ColumnHeader
icon='bookmark'
iconComponent={BookmarkIcon}
title={bookmarkCategory?.get('title')}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmark_ex_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{bookmarkCategory?.get('title')}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default BookmarkCategoryStatuses;

View file

@ -1,117 +0,0 @@
// Kmyblue tracking marker: copied bookmark_category_statuses, circle_statuses
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'mastodon/actions/bookmarks';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const mapStateToProps = state => ({
statusIds: getStatusList(state, 'bookmarks'),
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
});
class Bookmarks extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBookmarkedStatuses());
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS', {}));
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandBookmarkedStatuses());
}, 300, { leading: true });
render () {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='bookmarks'
iconComponent={BookmarksIcon}
title={intl.formatMessage(messages.heading)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmarked_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
timelineId='bookmarks'
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Bookmarks));

View file

@ -0,0 +1,116 @@
import { useEffect, useRef, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import {
fetchBookmarkedStatuses,
expandBookmarkedStatuses,
} from 'mastodon/actions/bookmarks';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const Bookmarks: React.FC<{
columnId: string;
multiColumn: boolean;
}> = ({ columnId, multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const columnRef = useRef<ColumnRef>(null);
const statusIds = useAppSelector((state) =>
getStatusList(state, 'bookmarks'),
);
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(['bookmarks', 'isLoading'], true) as boolean,
);
const hasMore = useAppSelector(
(state) => !!state.status_lists.getIn(['bookmarks', 'next']),
);
useEffect(() => {
dispatch(fetchBookmarkedStatuses());
}, [dispatch]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS', {}));
}
}, [dispatch, columnId]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(() => {
dispatch(expandBookmarkedStatuses());
}, [dispatch]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.bookmarked_statuses'
defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here."
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
icon='bookmarks'
iconComponent={BookmarksIcon}
title={intl.formatMessage(messages.heading)}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmarked_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
timelineId='bookmarks'
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Bookmarks;

View file

@ -1,195 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import { deleteCircle, expandCircleStatuses, fetchCircle, fetchCircleStatuses } from 'mastodon/actions/circles';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { getCircleStatusList } from 'mastodon/selectors';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' },
deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' },
heading: { id: 'column.circles', defaultMessage: 'Circles' },
});
const mapStateToProps = (state, { params }) => ({
circle: state.getIn(['circles', params.id]),
statusIds: getCircleStatusList(state, params.id),
isLoading: state.getIn(['status_lists', 'circle_statuses', params.id, 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'circle_statuses', params.id, 'next']),
});
class CircleStatuses extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
circle: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
...WithRouterPropTypes,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchCircle(this.props.params.id));
this.props.dispatch(fetchCircleStatuses(this.props.params.id));
}
UNSAFE_componentWillReceiveProps (nextProps) {
const { dispatch } = this.props;
const { id } = nextProps.params;
if (id !== this.props.params.id) {
dispatch(fetchCircle(id));
dispatch(fetchCircleStatuses(id));
}
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('CIRCLE_STATUSES', { id: this.props.params.id }));
this.props.history.push('/');
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
handleEditClick = () => {
this.props.history.push(`/circles/${this.props.params.id}/edit`);
};
handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props;
const { id } = this.props.params;
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteCircle(id));
if (columnId) {
dispatch(removeColumn(columnId));
} else {
this.props.history.push('/circles');
}
},
},
}));
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandCircleStatuses(this.props.params.id));
}, 300, { leading: true });
render () {
const { intl, circle, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
if (typeof circle === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (circle === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
const emptyMessage = <FormattedMessage id='empty_column.circle_statuses' defaultMessage="You don't have any circle posts yet. When you post one as circle, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='user-circle'
iconComponent={CircleIcon}
title={circle.get('title')}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings'>
<section className='column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='circles.edit' defaultMessage='Edit circle' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='circles.delete' defaultMessage='Delete circle' />
</button>
</section>
</div>
</ColumnHeader>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`circle_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default withRouter(connect(mapStateToProps)(injectIntl(CircleStatuses)));

View file

@ -0,0 +1,118 @@
import { useEffect, useRef, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import {
expandCircleStatuses,
fetchCircle,
fetchCircleStatuses,
} from 'mastodon/actions/circles';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getSubStatusList } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const CircleStatuses: React.FC<{
columnId: string;
multiColumn: boolean;
}> = ({ columnId, multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const columnRef = useRef<ColumnRef>(null);
const statusIds = useAppSelector((state) =>
getSubStatusList(state, 'circle', id),
);
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(
['circle_statuses', id, 'isLoading'],
true,
) as boolean,
);
const hasMore = useAppSelector(
(state) => !!state.status_lists.getIn(['circle_statuses', id, 'next']),
);
const circle = useAppSelector((state) => state.circles.get(id));
useEffect(() => {
dispatch(fetchCircle(id));
dispatch(fetchCircleStatuses(id));
}, [dispatch, id]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('CIRCLE_STATUSES', { id }));
}
}, [dispatch, columnId, id]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(() => {
dispatch(expandCircleStatuses(id));
}, [dispatch, id]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.circle_statuses'
defaultMessage="You don't have any circle posts yet. When you post one as circle, it will show up here."
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={circle?.get('title')}
>
<ColumnHeader
icon='bookmark'
iconComponent={CircleIcon}
title={circle?.get('title')}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmark_ex_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{circle?.get('title')}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default CircleStatuses;

View file

@ -13,9 +13,9 @@ import { fetchCircles } from 'mastodon/actions/circles';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedCircles } from 'mastodon/selectors/circles'; import { getOrderedCircles } from 'mastodon/selectors/circles';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -60,12 +60,11 @@ const CircleItem: React.FC<{
<span>{title}</span> <span>{title}</span>
</Link> </Link>
<DropdownMenuContainer <Dropdown
scrollKey='circles' scrollKey='circles'
items={menu} items={menu}
icons='ellipsis-h' icon='ellipsis-h'
iconComponent={MoreHorizIcon} iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)} title={intl.formatMessage(messages.more)}
/> />
</div> </div>

View file

@ -2,67 +2,94 @@ import { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import { Dropdown } from 'mastodon/components/dropdown_menu';
import { useAppDispatch } from 'mastodon/store';
const messages = defineMessages({ const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' }, pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, preferences: {
reaction_deck: { id: 'navigation_bar.reaction_deck', defaultMessage: 'Reaction deck' }, id: 'navigation_bar.preferences',
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, defaultMessage: 'Preferences',
},
reaction_deck: {
id: 'navigation_bar.reaction_deck',
defaultMessage: 'Reaction deck',
},
follow_requests: {
id: 'navigation_bar.follow_requests',
defaultMessage: 'Follow requests',
},
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
emoji_reactions: { id: 'navigation_bar.emoji_reactions', defaultMessage: 'Stamps' }, emoji_reactions: {
id: 'navigation_bar.emoji_reactions',
defaultMessage: 'Stamps',
},
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' }, followed_tags: {
id: 'navigation_bar.followed_tags',
defaultMessage: 'Followed hashtags',
},
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' }, domain_blocks: {
id: 'navigation_bar.domain_blocks',
defaultMessage: 'Blocked domains',
},
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' }, filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
}); });
export const ActionBar = () => { export const ActionBar: React.FC = () => {
const dispatch = useDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const menu = useMemo(() => { const menu = useMemo(() => {
const handleLogoutClick = () => { const handleLogoutClick = () => {
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' })); dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
}; };
return ([ return [
{ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }, {
{ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }, text: intl.formatMessage(messages.edit_profile),
href: '/settings/profile',
},
{
text: intl.formatMessage(messages.preferences),
href: '/settings/preferences',
},
{ text: intl.formatMessage(messages.pins), to: '/pinned' }, { text: intl.formatMessage(messages.pins), to: '/pinned' },
null, null,
{ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }, {
text: intl.formatMessage(messages.follow_requests),
to: '/follow_requests',
},
{ text: intl.formatMessage(messages.favourites), to: '/favourites' }, { text: intl.formatMessage(messages.favourites), to: '/favourites' },
{ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }, { text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' },
{ text: intl.formatMessage(messages.emoji_reactions), to: '/emoji_reactions' }, {
text: intl.formatMessage(messages.emoji_reactions),
to: '/emoji_reactions',
},
{ text: intl.formatMessage(messages.lists), to: '/lists' }, { text: intl.formatMessage(messages.lists), to: '/lists' },
{ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' }, {
text: intl.formatMessage(messages.followed_tags),
to: '/followed_tags',
},
null, null,
{ text: intl.formatMessage(messages.mutes), to: '/mutes' }, { text: intl.formatMessage(messages.mutes), to: '/mutes' },
{ text: intl.formatMessage(messages.blocks), to: '/blocks' }, { text: intl.formatMessage(messages.blocks), to: '/blocks' },
{ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }, {
text: intl.formatMessage(messages.domain_blocks),
to: '/domain_blocks',
},
{ text: intl.formatMessage(messages.filters), href: '/filters' }, { text: intl.formatMessage(messages.filters), href: '/filters' },
null, null,
{ text: intl.formatMessage(messages.logout), action: handleLogoutClick }, { text: intl.formatMessage(messages.logout), action: handleLogoutClick },
]); ];
}, [intl, dispatch]); }, [intl, dispatch]);
return ( return <Dropdown items={menu} icon='bars' iconComponent={MoreHorizIcon} />;
<DropdownMenuContainer
items={menu}
icon='bars'
iconComponent={MoreHorizIcon}
size={24}
direction='right'
/>
);
}; };

View file

@ -24,7 +24,7 @@ import AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content'; import StatusContent from 'mastodon/components/status_content';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import { Dropdown } from 'mastodon/components/dropdown_menu';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif } from 'mastodon/initial_state';
import { makeGetStatus } from 'mastodon/selectors'; import { makeGetStatus } from 'mastodon/selectors';
@ -205,7 +205,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} /> <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>
<DropdownMenuContainer <Dropdown
scrollKey={scrollKey} scrollKey={scrollKey}
status={lastStatus} status={lastStatus}
items={menu} items={menu}

View file

@ -1,114 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import EmojiReactionIcon from '@/material-icons/400-24px/mood.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { fetchEmojiReactedStatuses, expandEmojiReactedStatuses } from 'mastodon/actions/emoji_reactions';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
const messages = defineMessages({
heading: { id: 'column.emoji_reactions', defaultMessage: 'Stamps' },
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'emoji_reactions', 'items']),
isLoading: state.getIn(['status_lists', 'emoji_reactions', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'emoji_reactions', 'next']),
});
class EmojiReactions extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
componentWillMount () {
this.props.dispatch(fetchEmojiReactedStatuses());
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('EMOJI_REACTIONS', {}));
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandEmojiReactedStatuses());
}, 300, { leading: true });
render () {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.emoji_reacted_statuses' defaultMessage="You don't have any emoji reacted posts yet. When you emoji react one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='smile-o'
iconComponent={EmojiReactionIcon}
title={intl.formatMessage(messages.heading)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
showBackButton
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`emoji_reacted_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(EmojiReactions));

View file

@ -0,0 +1,118 @@
import { useEffect, useRef, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import EmojiReactionIcon from '@/material-icons/400-24px/mood.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import {
fetchEmojiReactedStatuses,
expandEmojiReactedStatuses,
} from 'mastodon/actions/emoji_reactions';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.emoji_reactions', defaultMessage: 'Stamps' },
});
const Favourites: React.FC<{ columnId: string; multiColumn: boolean }> = ({
columnId,
multiColumn,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const columnRef = useRef<ColumnRef>(null);
const statusIds = useAppSelector((state) =>
getStatusList(state, 'emoji_reactions'),
);
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(
['emoji_reactions', 'isLoading'],
true,
) as boolean,
);
const hasMore = useAppSelector(
(state) => !!state.status_lists.getIn(['emoji_reactions', 'next']),
);
useEffect(() => {
dispatch(fetchEmojiReactedStatuses());
}, [dispatch]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('EMOJI_REACTIONS', {}));
}
}, [dispatch, columnId]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(() => {
dispatch(expandEmojiReactedStatuses());
}, [dispatch]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.emoji_reacted_statuses'
defaultMessage="You don't have any emoji reacted posts yet. When you emoji react one, it will show up here."
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
icon='star'
iconComponent={EmojiReactionIcon}
title={intl.formatMessage(messages.heading)}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`emoji_reacted_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Favourites;

View file

@ -25,7 +25,7 @@ export const Card = ({ id, source }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
dispatch(dismissSuggestion(id)); dispatch(dismissSuggestion({ accountId: id }));
}, [id, dispatch]); }, [id, dispatch]);
let label; let label;

View file

@ -0,0 +1,116 @@
import { useEffect, useRef, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import {
fetchFavouritedStatuses,
expandFavouritedStatuses,
} from 'mastodon/actions/favourites';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.favourites', defaultMessage: 'Favorites' },
});
const Favourites: React.FC<{ columnId: string; multiColumn: boolean }> = ({
columnId,
multiColumn,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const columnRef = useRef<ColumnRef>(null);
const statusIds = useAppSelector((state) =>
getStatusList(state, 'favourites'),
);
const isLoading = useAppSelector(
(state) =>
state.status_lists.getIn(['favourites', 'isLoading'], true) as boolean,
);
const hasMore = useAppSelector(
(state) => !!state.status_lists.getIn(['favourites', 'next']),
);
useEffect(() => {
dispatch(fetchFavouritedStatuses());
}, [dispatch]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('FAVOURITES', {}));
}
}, [dispatch, columnId]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(() => {
dispatch(expandFavouritedStatuses());
}, [dispatch]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.favourited_statuses'
defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here."
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
icon='star'
iconComponent={StarIcon}
title={intl.formatMessage(messages.heading)}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`favourited_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
timelineId='favourites'
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Favourites;

View file

@ -1,95 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
import ColumnHeader from 'mastodon/components/column_header';
import { Hashtag } from 'mastodon/components/hashtag';
import ScrollableList from 'mastodon/components/scrollable_list';
import Column from 'mastodon/features/ui/components/column';
const messages = defineMessages({
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
});
const mapStateToProps = state => ({
hashtags: state.getIn(['followed_tags', 'items']),
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
hasMore: !!state.getIn(['followed_tags', 'next']),
});
class FollowedTags extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hashtags: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
multiColumn: PropTypes.bool,
};
componentDidMount() {
this.props.dispatch(fetchFollowedHashtags());
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandFollowedHashtags());
}, 300, { leading: true });
render () {
const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
icon='hashtag'
iconComponent={TagIcon}
title={intl.formatMessage(messages.heading)}
showBackButton
multiColumn={multiColumn}
/>
<ScrollableList
scrollKey='followed_tags'
emptyMessage={emptyMessage}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
bindToDocument={!multiColumn}
>
{hashtags.map((hashtag) => (
<Hashtag
key={hashtag.get('name')}
name={hashtag.get('name')}
to={`/tags/${hashtag.get('name')}`}
withGraph={false}
// Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
/>
))}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(FollowedTags));

View file

@ -0,0 +1,161 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { isFulfilled } from '@reduxjs/toolkit';
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { unfollowHashtag } from 'mastodon/actions/tags_typed';
import { apiGetFollowedTags } from 'mastodon/api/tags';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Hashtag } from 'mastodon/components/hashtag';
import ScrollableList from 'mastodon/components/scrollable_list';
import { useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
});
const FollowedTag: React.FC<{
tag: ApiHashtagJSON;
onUnfollow: (arg0: string) => void;
}> = ({ tag, onUnfollow }) => {
const dispatch = useAppDispatch();
const tagId = tag.name;
const handleClick = useCallback(() => {
void dispatch(unfollowHashtag({ tagId })).then((result) => {
if (isFulfilled(result)) {
onUnfollow(tagId);
}
return '';
});
}, [dispatch, onUnfollow, tagId]);
const people =
parseInt(tag.history[0].accounts) +
parseInt(tag.history[1]?.accounts ?? '');
return (
<Hashtag
name={tag.name}
to={`/tags/${tag.name}`}
withGraph={false}
people={people}
>
<Button onClick={handleClick}>
<FormattedMessage id='account.unfollow' defaultMessage='Unfollow' />
</Button>
</Hashtag>
);
};
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const intl = useIntl();
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
const [loading, setLoading] = useState(false);
const [next, setNext] = useState<string | undefined>();
const hasMore = !!next;
const columnRef = useRef<ColumnRef>(null);
useEffect(() => {
setLoading(true);
void apiGetFollowedTags()
.then(({ tags, links }) => {
const next = links.refs.find((link) => link.rel === 'next');
setTags(tags);
setLoading(false);
setNext(next?.uri);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setTags, setLoading, setNext]);
const handleLoadMore = useCallback(() => {
setLoading(true);
void apiGetFollowedTags(next)
.then(({ tags, links }) => {
const next = links.refs.find((link) => link.rel === 'next');
setLoading(false);
setTags((previousTags) => [...previousTags, ...tags]);
setNext(next?.uri);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setTags, setLoading, setNext, next]);
const handleUnfollow = useCallback(
(tagId: string) => {
setTags((tags) => tags.filter((tag) => tag.name !== tagId));
},
[setTags],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const emptyMessage = (
<FormattedMessage
id='empty_column.followed_tags'
defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.'
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
icon='hashtag'
iconComponent={TagIcon}
title={intl.formatMessage(messages.heading)}
onClick={handleHeaderClick}
multiColumn={multiColumn}
showBackButton
/>
<ScrollableList
scrollKey='followed_tags'
emptyMessage={emptyMessage}
hasMore={hasMore}
isLoading={loading}
showLoading={loading && tags.length === 0}
onLoadMore={handleLoadMore}
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
>
{tags.map((tag) => (
<FollowedTag key={tag.name} tag={tag} onUnfollow={handleUnfollow} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default FollowedTags;

View file

@ -12,8 +12,8 @@ import {
} from 'mastodon/actions/tags_typed'; } from 'mastodon/actions/tags_typed';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { ShortNumber } from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
import { useIdentity } from 'mastodon/identity_context'; import { useIdentity } from 'mastodon/identity_context';
import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions'; import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
@ -153,13 +153,11 @@ export const HashtagHeader: React.FC<{
<div className='hashtag-header__header__buttons'> <div className='hashtag-header__header__buttons'>
{menu.length > 0 && ( {menu.length > 0 && (
<DropdownMenu <Dropdown
disabled={menu.length === 0} disabled={menu.length === 0}
items={menu} items={menu}
icon='ellipsis-v' icon='ellipsis-v'
iconComponent={MoreHorizIcon} iconComponent={MoreHorizIcon}
size={24}
direction='right'
/> />
)} )}

View file

@ -13,9 +13,9 @@ import { fetchLists } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedLists } from 'mastodon/selectors/lists'; import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -72,12 +72,11 @@ const ListItem: React.FC<{
</span> </span>
</Link> </Link>
<DropdownMenuContainer <Dropdown
scrollKey='lists' scrollKey='lists'
items={menu} items={menu}
icons='ellipsis-h' icon='ellipsis-h'
iconComponent={MoreHorizIcon} iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)} title={intl.formatMessage(messages.more)}
/> />
</div> </div>

View file

@ -17,7 +17,7 @@ import { initReport } from 'mastodon/actions/reports';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { CheckBox } from 'mastodon/components/check_box'; import { CheckBox } from 'mastodon/components/check_box';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import { Dropdown } from 'mastodon/components/dropdown_menu';
import { makeGetAccount } from 'mastodon/selectors'; import { makeGetAccount } from 'mastodon/selectors';
import { toCappedNumber } from 'mastodon/utils/numbers'; import { toCappedNumber } from 'mastodon/utils/numbers';
@ -105,11 +105,10 @@ export const NotificationRequest = ({ id, accountId, notificationsCount, checked
<div className='notification-request__actions'> <div className='notification-request__actions'>
<IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} /> <IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<DropdownMenuContainer <Dropdown
items={menu} items={menu}
icons='ellipsis-h' icon='ellipsis-h'
iconComponent={MoreHorizIcon} iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)} title={intl.formatMessage(messages.more)}
/> />
</div> </div>

View file

@ -23,7 +23,7 @@ import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import { Dropdown } from 'mastodon/components/dropdown_menu';
import { NotificationRequest } from './components/notification_request'; import { NotificationRequest } from './components/notification_request';
import { PolicyControls } from './components/policy_controls'; import { PolicyControls } from './components/policy_controls';
@ -126,7 +126,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
<div className='column-header__select-row__checkbox'> <div className='column-header__select-row__checkbox'>
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={handleSelectAll} /> <CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={handleSelectAll} />
</div> </div>
<DropdownMenuContainer <Dropdown
items={menu} items={menu}
icons='ellipsis-h' icons='ellipsis-h'
iconComponent={MoreHorizIcon} iconComponent={MoreHorizIcon}
@ -139,7 +139,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
</span> </span>
<Icon id='down' icon={ArrowDropDownIcon} /> <Icon id='down' icon={ArrowDropDownIcon} />
</button> </button>
</DropdownMenuContainer> </Dropdown>
<div className='column-header__select-row__mode-button'> <div className='column-header__select-row__mode-button'>
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}> <button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
{selectionMode ? ( {selectionMode ? (

View file

@ -27,7 +27,7 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { IconButton } from '../../../components/icon_button'; import { IconButton } from '../../../components/icon_button';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import { Dropdown } from 'mastodon/components/dropdown_menu';
import { enableEmojiReaction , bookmarkCategoryNeeded, me, isHideItem, boostMenu, boostModal } from '../../../initial_state'; import { enableEmojiReaction , bookmarkCategoryNeeded, me, isHideItem, boostMenu, boostModal } from '../../../initial_state';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
@ -416,7 +416,7 @@ class ActionBar extends PureComponent {
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} /></div> <div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} /></div>
) : ( ) : (
<div className='detailed-status__button'> <div className='detailed-status__button'>
<DropdownMenuContainer <Dropdown
className={classNames({ reblogPrivate })} className={classNames({ reblogPrivate })}
icon='retweet' icon='retweet'
iconComponent={reblogIconComponent} iconComponent={reblogIconComponent}
@ -434,7 +434,7 @@ class ActionBar extends PureComponent {
{emojiPickerDropdown} {emojiPickerDropdown}
<div className='detailed-status__action-bar-dropdown'> <div className='detailed-status__action-bar-dropdown'>
<DropdownMenuContainer icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} /> <Dropdown icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
</div> </div>
</div> </div>
); );

View file

@ -14,7 +14,7 @@ import { Link } from 'react-router-dom';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { AnimatedNumber } from 'mastodon/components/animated_number'; import { AnimatedNumber } from 'mastodon/components/animated_number';
import { ContentWarning } from 'mastodon/components/content_warning'; import { ContentWarning } from 'mastodon/components/content_warning';
import EditedTimestamp from 'mastodon/components/edited_timestamp'; import { EditedTimestamp } from 'mastodon/components/edited_timestamp';
import { FilterWarning } from 'mastodon/components/filter_warning'; import { FilterWarning } from 'mastodon/components/filter_warning';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date'; import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import type { StatusLike } from 'mastodon/components/hashtag_bar'; import type { StatusLike } from 'mastodon/components/hashtag_bar';

View file

@ -0,0 +1,157 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { useLocation } from 'react-router-dom';
import Overlay from 'react-overlays/Overlay';
import type {
OffsetValue,
UsePopperOptions,
} from 'react-overlays/esm/usePopper';
import { DropdownMenu } from 'mastodon/components/dropdown_menu';
import { useAppSelector } from 'mastodon/store';
const messages = defineMessages({
browseHashtag: {
id: 'hashtag.browse',
defaultMessage: 'Browse posts in #{hashtag}',
},
browseHashtagFromAccount: {
id: 'hashtag.browse_from_account',
defaultMessage: 'Browse posts from @{name} in #{hashtag}',
},
muteHashtag: { id: 'hashtag.mute', defaultMessage: 'Mute #{hashtag}' },
});
const offset = [5, 5] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
const isHashtagLink = (
element: HTMLAnchorElement | null,
): element is HTMLAnchorElement => {
if (!element) {
return false;
}
return element.matches('[data-menu-hashtag]');
};
interface TargetParams {
hashtag?: string;
accountId?: string;
}
export const HashtagMenuController: React.FC = () => {
const intl = useIntl();
const [open, setOpen] = useState(false);
const [{ accountId, hashtag }, setTargetParams] = useState<TargetParams>({});
const targetRef = useRef<HTMLAnchorElement | null>(null);
const location = useLocation();
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
useEffect(() => {
setOpen(false);
targetRef.current = null;
}, [setOpen, location]);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('a');
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
return;
}
if (!isHashtagLink(target)) {
return;
}
const hashtag = target.text.replace(/^#/, '');
const accountId = target.getAttribute('data-menu-hashtag');
if (!hashtag || !accountId) {
return;
}
e.preventDefault();
e.stopPropagation();
targetRef.current = target;
setOpen(true);
setTargetParams({ hashtag, accountId });
};
document.addEventListener('click', handleClick, { capture: true });
return () => {
document.removeEventListener('click', handleClick);
};
}, [setTargetParams, setOpen]);
const handleClose = useCallback(() => {
setOpen(false);
targetRef.current = null;
}, [setOpen]);
const menu = useMemo(
() => [
{
text: intl.formatMessage(messages.browseHashtag, {
hashtag,
}),
to: `/tags/${hashtag}`,
},
{
text: intl.formatMessage(messages.browseHashtagFromAccount, {
hashtag,
name: account?.username,
}),
to: `/@${account?.acct}/tagged/${hashtag}`,
},
null,
{
text: intl.formatMessage(messages.muteHashtag, {
hashtag,
}),
href: '/filters',
dangerous: true,
},
],
[intl, hashtag, account],
);
if (!open) {
return null;
}
return (
<Overlay
show={open}
offset={offset}
placement='bottom'
flip
target={targetRef}
popperConfig={popperConfig}
>
{({ props, arrowProps, placement }) => (
<div {...props}>
<div className={`dropdown-animation dropdown-menu ${placement}`}>
<div
className={`dropdown-menu__arrow ${placement}`}
{...arrowProps}
/>
<DropdownMenu
items={menu}
onClose={handleClose}
openedViaKeyboard={false}
/>
</div>
</div>
)}
</Overlay>
);
};

View file

@ -1,63 +1,101 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
domain, domain,
version,
source_url, source_url,
statusPageUrl, statusPageUrl,
profile_directory as canProfileDirectory,
termsOfServiceEnabled, termsOfServiceEnabled,
} from 'mastodon/initial_state'; } from 'mastodon/initial_state';
const DividingCircle: React.FC = () => <span aria-hidden={true}>{' · '}</span>; const DividingCircle: React.FC = () => <span aria-hidden>{' · '}</span>;
export const LinkFooter: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { export const LinkFooter: React.FC<{
multiColumn: boolean;
}> = ({ multiColumn }) => {
return ( return (
<footer className='link-footer' role='contentinfo'> <div className='link-footer'>
<p> <p>
<strong>{domain}</strong>:{' '} <strong>{domain}</strong>:{' '}
<Link to='/about' target={multiColumn ? '_blank' : undefined}> <Link to='/about' target={multiColumn ? '_blank' : undefined}>
<FormattedMessage id='footer.about' defaultMessage='About' /> <FormattedMessage id='footer.about' defaultMessage='About' />
</Link> </Link>
{statusPageUrl && ( {statusPageUrl && (
<> <>
<DividingCircle /> <DividingCircle />
<a href={statusPageUrl} target='_blank' rel='noopener noreferrer'> <a href={statusPageUrl} target='_blank' rel='noopener'>
<FormattedMessage id='footer.status' defaultMessage='Status' /> <FormattedMessage id='footer.status' defaultMessage='Status' />
</a> </a>
</> </>
)} )}
{canProfileDirectory && (
<DividingCircle />
<Link to='/privacy-policy' target={multiColumn ? '_blank' : undefined}>
<FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' />
</Link>
{termsOfServiceEnabled && (
<> <>
<DividingCircle /> <DividingCircle />
<Link to='/terms-of-service' target={multiColumn ? '_blank' : undefined}> <Link to='/directory'>
<FormattedMessage id='footer.terms_of_service' defaultMessage='Terms of service' /> <FormattedMessage
id='footer.directory'
defaultMessage='Profiles directory'
/>
</Link> </Link>
</> </>
)} )}
<DividingCircle /> <DividingCircle />
<Link to='/keyboard-shortcuts'> <Link
<FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' /> to='/privacy-policy'
target={multiColumn ? '_blank' : undefined}
rel='privacy-policy'
>
<FormattedMessage
id='footer.privacy_policy'
defaultMessage='Privacy policy'
/>
</Link> </Link>
{termsOfServiceEnabled && (
<DividingCircle /> <>
<a href={source_url} rel='noopener noreferrer' target='_blank'> <DividingCircle />
<FormattedMessage id='footer.source_code' defaultMessage='View source code' /> <Link
</a> to='/terms-of-service'
target={multiColumn ? '_blank' : undefined}
rel='terms-of-service'
>
<FormattedMessage
id='footer.terms_of_service'
defaultMessage='Terms of service'
/>
</Link>
</>
)}
</p> </p>
<p> <p>
<span title="Crafted with love for the fediverse"> <strong>Mastodon</strong>:{' '}
Made with <span aria-label='heart' role='img'></span> <a href='https://joinmastodon.org' target='_blank' rel='noopener'>
</span> <FormattedMessage id='footer.about' defaultMessage='About' />
</a>
<DividingCircle />
<a href='https://joinmastodon.org/apps' target='_blank' rel='noopener'>
<FormattedMessage id='footer.get_app' defaultMessage='Get the app' />
</a>
<DividingCircle />
<Link to='/keyboard-shortcuts'>
<FormattedMessage
id='footer.keyboard_shortcuts'
defaultMessage='Keyboard shortcuts'
/>
</Link>
<DividingCircle />
<a href={source_url} rel='noopener' target='_blank'>
<FormattedMessage
id='footer.source_code'
defaultMessage='View source code'
/>
</a>
<DividingCircle />
<span className='version'>v{version}</span>
</p> </p>
</footer> </div>
); );
}; };

View file

@ -22,7 +22,7 @@ const SignInBanner = () => {
if (sso_redirect) { if (sso_redirect) {
return ( return (
<div className='sign-in-banner'> <div className='sign-in-banner'>
<p><strong><FormattedMessage id='sign_in_banner.mastodon_is' defaultMessage="Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse." /></strong></p> <p><strong><FormattedMessage id='sign_in_banner.mastodon_is' defaultMessage="Mastodon is the best way to keep up with what's happening." /></strong></p>
<p><FormattedMessage id='sign_in_banner.follow_anyone' defaultMessage='Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.' /></p> <p><FormattedMessage id='sign_in_banner.follow_anyone' defaultMessage='Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.' /></p>
<a href={sso_redirect} data-method='post' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sso_redirect' defaultMessage='Login or Register' /></a> <a href={sso_redirect} data-method='post' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sso_redirect' defaultMessage='Login or Register' /></a>
</div> </div>
@ -45,7 +45,7 @@ const SignInBanner = () => {
return ( return (
<div className='sign-in-banner'> <div className='sign-in-banner'>
<p><strong><FormattedMessage id='sign_in_banner.mastodon_is' defaultMessage="Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse." /></strong></p> <p><strong><FormattedMessage id='sign_in_banner.mastodon_is' defaultMessage="Mastodon is the best way to keep up with what's happening." /></strong></p>
<p><FormattedMessage id='sign_in_banner.follow_anyone' defaultMessage='Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.' /></p> <p><FormattedMessage id='sign_in_banner.follow_anyone' defaultMessage='Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.' /></p>
{signupButton} {signupButton}
<a href='/auth/sign_in' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a> <a href='/auth/sign_in' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>

View file

@ -31,6 +31,7 @@ import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding
import BundleColumnError from './components/bundle_column_error'; import BundleColumnError from './components/bundle_column_error';
import Header from './components/header'; import Header from './components/header';
import { UploadArea } from './components/upload_area'; import { UploadArea } from './components/upload_area';
import { HashtagMenuController } from './components/hashtag_menu_controller';
import ColumnsAreaContainer from './containers/columns_area_container'; import ColumnsAreaContainer from './containers/columns_area_container';
import LoadingBarContainer from './containers/loading_bar_container'; import LoadingBarContainer from './containers/loading_bar_container';
import ModalContainer from './containers/modal_container'; import ModalContainer from './containers/modal_container';
@ -91,6 +92,7 @@ import {
BookmarkCategoryEdit, BookmarkCategoryEdit,
ReactionDeck, ReactionDeck,
TermsOfService, TermsOfService,
AccountFeatured,
} from './util/async-components'; } from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context'; import { ColumnsContextProvider } from './util/columns_context';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
@ -272,6 +274,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} /> <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} /> <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} /> <WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} /> <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
<WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} /> <WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} />
@ -658,6 +661,7 @@ class UI extends PureComponent {
{layout !== 'mobile' && <PictureInPicture />} {layout !== 'mobile' && <PictureInPicture />}
<AlertsController /> <AlertsController />
{!disableHoverCards && <HoverCardController />} {!disableHoverCards && <HoverCardController />}
<HashtagMenuController />
<LoadingBarContainer className='loading-bar' /> <LoadingBarContainer className='loading-bar' />
<ModalContainer /> <ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} /> <UploadArea active={draggingOver} onClose={this.closeUploadModal} />

View file

@ -82,6 +82,10 @@ export function AccountGallery () {
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery'); return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
} }
export function AccountFeatured() {
return import(/* webpackChunkName: "features/account_featured" */'../../account_featured');
}
export function Followers () { export function Followers () {
return import(/* webpackChunkName: "features/followers" */'../../followers'); return import(/* webpackChunkName: "features/followers" */'../../followers');
} }

View file

@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { useParams } from 'react-router';
import { fetchAccount, lookupAccount } from 'mastodon/actions/accounts';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
interface Params {
acct?: string;
id?: string;
}
export function useAccountId() {
const { acct, id } = useParams<Params>();
const accountId = useAppSelector(
(state) =>
id ??
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
);
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const isAccount = !!account;
const dispatch = useAppDispatch();
useEffect(() => {
if (!accountId) {
dispatch(lookupAccount(acct));
} else if (!isAccount) {
dispatch(fetchAccount(accountId));
}
}, [dispatch, accountId, acct, isAccount]);
return accountId;
}

View file

@ -0,0 +1,20 @@
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector } from 'mastodon/store';
export function useAccountVisibility(accountId?: string) {
const blockedBy = useAppSelector(
(state) => !!state.relationships.getIn([accountId, 'blocked_by'], false),
);
const suspended = useAppSelector(
(state) => !!state.accounts.getIn([accountId, 'suspended'], false),
);
const hidden = useAppSelector((state) =>
accountId ? Boolean(getAccountHidden(state, accountId)) : false,
);
return {
blockedBy,
suspended,
hidden,
};
}

View file

@ -25,7 +25,6 @@
"account.endorse": "Amostrar en perfil", "account.endorse": "Amostrar en perfil",
"account.featured_tags.last_status_at": "Zaguera publicación lo {date}", "account.featured_tags.last_status_at": "Zaguera publicación lo {date}",
"account.featured_tags.last_status_never": "Sin publicacions", "account.featured_tags.last_status_never": "Sin publicacions",
"account.featured_tags.title": "Etiquetas destacadas de {name}",
"account.follow": "Seguir", "account.follow": "Seguir",
"account.followers": "Seguidores", "account.followers": "Seguidores",
"account.followers.empty": "Encara no sigue dengún a este usuario.", "account.followers.empty": "Encara no sigue dengún a este usuario.",

View file

@ -29,7 +29,6 @@
"account.endorse": "أوصِ به على صفحتك الشخصية", "account.endorse": "أوصِ به على صفحتك الشخصية",
"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.featured_tags.title": "وسوم {name} المميَّزة",
"account.follow": "متابعة", "account.follow": "متابعة",
"account.follow_back": "تابعه بالمثل", "account.follow_back": "تابعه بالمثل",
"account.followers": "مُتابِعون", "account.followers": "مُتابِعون",

View file

@ -27,7 +27,6 @@
"account.enable_notifications": "Avisame cuando @{name} espublice artículos", "account.enable_notifications": "Avisame cuando @{name} espublice artículos",
"account.endorse": "Destacar nel perfil", "account.endorse": "Destacar nel perfil",
"account.featured_tags.last_status_never": "Nun hai nenguna publicación", "account.featured_tags.last_status_never": "Nun hai nenguna publicación",
"account.featured_tags.title": "Etiquetes destacaes de: {name}",
"account.follow": "Siguir", "account.follow": "Siguir",
"account.follow_back": "Siguir tamién", "account.follow_back": "Siguir tamién",
"account.followers": "Siguidores", "account.followers": "Siguidores",

View file

@ -29,7 +29,6 @@
"account.endorse": "Profildə seçilmişlərə əlavə et", "account.endorse": "Profildə seçilmişlərə əlavə et",
"account.featured_tags.last_status_at": "Son paylaşım {date} tarixində olub", "account.featured_tags.last_status_at": "Son paylaşım {date} tarixində olub",
"account.featured_tags.last_status_never": "Paylaşım yoxdur", "account.featured_tags.last_status_never": "Paylaşım yoxdur",
"account.featured_tags.title": "{name} istifadəçisinin seçilmiş heşteqləri",
"account.follow": "İzlə", "account.follow": "İzlə",
"account.follow_back": "Sən də izlə", "account.follow_back": "Sən də izlə",
"account.followers": "İzləyicilər", "account.followers": "İzləyicilər",

View file

@ -29,7 +29,6 @@
"account.endorse": "Паказваць у профілі", "account.endorse": "Паказваць у профілі",
"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.featured_tags.title": "Тэгі, выбраныя {name}",
"account.follow": "Падпісацца", "account.follow": "Падпісацца",
"account.follow_back": "Падпісацца ў адказ", "account.follow_back": "Падпісацца ў адказ",
"account.followers": "Падпісчыкі", "account.followers": "Падпісчыкі",

View file

@ -27,9 +27,11 @@
"account.edit_profile": "Редактиране на профила", "account.edit_profile": "Редактиране на профила",
"account.enable_notifications": "Известяване при публикуване от @{name}", "account.enable_notifications": "Известяване при публикуване от @{name}",
"account.endorse": "Представи в профила", "account.endorse": "Представи в профила",
"account.featured": "Препоръчано",
"account.featured.hashtags": "Хаштагове",
"account.featured.posts": "Публикации",
"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.featured_tags.title": "Главни хаштагове на {name}",
"account.follow": "Последване", "account.follow": "Последване",
"account.follow_back": "Последване взаимно", "account.follow_back": "Последване взаимно",
"account.followers": "Последователи", "account.followers": "Последователи",
@ -65,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} публикация} other {{counter} публикации}}", "account.statuses_counter": "{count, plural, one {{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": "Стоп на следването",
@ -293,6 +296,7 @@
"emoji_button.search_results": "Резултати от търсене", "emoji_button.search_results": "Резултати от търсене",
"emoji_button.symbols": "Символи", "emoji_button.symbols": "Символи",
"emoji_button.travel": "Пътуване и места", "emoji_button.travel": "Пътуване и места",
"empty_column.account_featured": "Списъкът е празен",
"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": "Тук няма публикации!",
@ -377,6 +381,8 @@
"generic.saved": "Запазено", "generic.saved": "Запазено",
"getting_started.heading": "Първи стъпки", "getting_started.heading": "Първи стъпки",
"hashtag.admin_moderation": "Отваряне на модериращия интерфейс за #{name}", "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}",
@ -390,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} публикация} other {{counter} публикации}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} публикация} other {{counter} публикации}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} публикация} other {{counter} публикации}} днес", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} публикация} other {{counter} публикации}} днес",
"hashtag.follow": "Следване на хаштаг", "hashtag.follow": "Следване на хаштаг",
"hashtag.mute": "Заглушаване на #{hashtag}",
"hashtag.unfollow": "Спиране на следване на хаштаг", "hashtag.unfollow": "Спиране на следване на хаштаг",
"hashtags.and_other": "…и {count, plural, other {# още}}", "hashtags.and_other": "…и {count, plural, other {# още}}",
"hints.profiles.followers_may_be_missing": "Последователи за този профил може да липсват.", "hints.profiles.followers_may_be_missing": "Последователи за този профил може да липсват.",
@ -674,7 +681,7 @@
"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": "Може да @споменавате други хора или #хаштагове…",

View file

@ -29,7 +29,6 @@
"account.endorse": "প্রোফাইলে ফিচার করুন", "account.endorse": "প্রোফাইলে ফিচার করুন",
"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.featured_tags.title": "{name} এর ফিচার করা Hashtag সমূহ",
"account.follow": "অনুসরণ", "account.follow": "অনুসরণ",
"account.follow_back": "তাকে অনুসরণ করো", "account.follow_back": "তাকে অনুসরণ করো",
"account.followers": "অনুসরণকারী", "account.followers": "অনুসরণকারী",

View file

@ -28,7 +28,6 @@
"account.endorse": "Lakaat war-wel war ar profil", "account.endorse": "Lakaat war-wel war ar profil",
"account.featured_tags.last_status_at": "Toud diwezhañ : {date}", "account.featured_tags.last_status_at": "Toud diwezhañ : {date}",
"account.featured_tags.last_status_never": "Embannadur ebet", "account.featured_tags.last_status_never": "Embannadur ebet",
"account.featured_tags.title": "Hashtagoù pennañ {name}",
"account.follow": "Heuliañ", "account.follow": "Heuliañ",
"account.follow_back": "Heuliañ d'ho tro", "account.follow_back": "Heuliañ d'ho tro",
"account.followers": "Tud koumanantet", "account.followers": "Tud koumanantet",

View file

@ -27,9 +27,11 @@
"account.edit_profile": "Edita el perfil", "account.edit_profile": "Edita el perfil",
"account.enable_notifications": "Notifica'm els tuts de @{name}", "account.enable_notifications": "Notifica'm els tuts de @{name}",
"account.endorse": "Recomana en el perfil", "account.endorse": "Recomana en el perfil",
"account.featured": "Destacat",
"account.featured.hashtags": "Etiquetes",
"account.featured.posts": "Publicacions",
"account.featured_tags.last_status_at": "Darrer tut el {date}", "account.featured_tags.last_status_at": "Darrer tut el {date}",
"account.featured_tags.last_status_never": "No hi ha tuts", "account.featured_tags.last_status_never": "No hi ha tuts",
"account.featured_tags.title": "etiquetes destacades de {name}",
"account.follow": "Segueix", "account.follow": "Segueix",
"account.follow_back": "Segueix tu també", "account.follow_back": "Segueix tu també",
"account.followers": "Seguidors", "account.followers": "Seguidors",
@ -65,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} publicació} other {{counter} publicacions}}", "account.statuses_counter": "{count, plural, one {{counter} publicació} other {{counter} publicacions}}",
"account.unblock": "Desbloca @{name}", "account.unblock": "Desbloca @{name}",
"account.unblock_domain": "Desbloca el domini {domain}", "account.unblock_domain": "Desbloca el domini {domain}",
"account.unblock_domain_short": "Desbloca",
"account.unblock_short": "Desbloca", "account.unblock_short": "Desbloca",
"account.unendorse": "No recomanis en el perfil", "account.unendorse": "No recomanis en el perfil",
"account.unfollow": "Deixa de seguir", "account.unfollow": "Deixa de seguir",
@ -293,6 +296,7 @@
"emoji_button.search_results": "Resultats de la cerca", "emoji_button.search_results": "Resultats de la cerca",
"emoji_button.symbols": "Símbols", "emoji_button.symbols": "Símbols",
"emoji_button.travel": "Viatges i llocs", "emoji_button.travel": "Viatges i llocs",
"empty_column.account_featured": "Aquesta llista està buida",
"empty_column.account_hides_collections": "Aquest usuari ha decidit no mostrar aquesta informació", "empty_column.account_hides_collections": "Aquest usuari ha decidit no mostrar aquesta informació",
"empty_column.account_suspended": "Compte suspès", "empty_column.account_suspended": "Compte suspès",
"empty_column.account_timeline": "No hi ha tuts aquí!", "empty_column.account_timeline": "No hi ha tuts aquí!",
@ -390,6 +394,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} tut} other {{counter} tuts}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} tut} other {{counter} tuts}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} tut} other {{counter} tuts}} avui", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} tut} other {{counter} tuts}} avui",
"hashtag.follow": "Segueix l'etiqueta", "hashtag.follow": "Segueix l'etiqueta",
"hashtag.mute": "Silencia #{hashtag}",
"hashtag.unfollow": "Deixa de seguir l'etiqueta", "hashtag.unfollow": "Deixa de seguir l'etiqueta",
"hashtags.and_other": "…i {count, plural, other {# més}}", "hashtags.and_other": "…i {count, plural, other {# més}}",
"hints.profiles.followers_may_be_missing": "Es poden haver perdut seguidors d'aquest perfil.", "hints.profiles.followers_may_be_missing": "Es poden haver perdut seguidors d'aquest perfil.",

View file

@ -28,7 +28,6 @@
"account.endorse": "ناساندن لە پرۆفایل", "account.endorse": "ناساندن لە پرۆفایل",
"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.featured_tags.title": "هاشتاگە تایبەتەکانی {name}",
"account.follow": "بەدواداچوون", "account.follow": "بەدواداچوون",
"account.follow_back": "فۆڵۆو بکەنەوە", "account.follow_back": "فۆڵۆو بکەنەوە",
"account.followers": "شوێنکەوتووان", "account.followers": "شوێنکەوتووان",

View file

@ -27,9 +27,11 @@
"account.edit_profile": "Upravit profil", "account.edit_profile": "Upravit profil",
"account.enable_notifications": "Oznamovat mi příspěvky @{name}", "account.enable_notifications": "Oznamovat mi příspěvky @{name}",
"account.endorse": "Zvýraznit na profilu", "account.endorse": "Zvýraznit na profilu",
"account.featured": "Doporučené",
"account.featured.hashtags": "Hashtagy",
"account.featured.posts": "Příspěvky",
"account.featured_tags.last_status_at": "Poslední příspěvek {date}", "account.featured_tags.last_status_at": "Poslední příspěvek {date}",
"account.featured_tags.last_status_never": "Žádné příspěvky", "account.featured_tags.last_status_never": "Žádné příspěvky",
"account.featured_tags.title": "Hlavní hashtagy uživatele {name}",
"account.follow": "Sledovat", "account.follow": "Sledovat",
"account.follow_back": "Také sledovat", "account.follow_back": "Také sledovat",
"account.followers": "Sledující", "account.followers": "Sledující",
@ -65,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}", "account.statuses_counter": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}",
"account.unblock": "Odblokovat @{name}", "account.unblock": "Odblokovat @{name}",
"account.unblock_domain": "Odblokovat doménu {domain}", "account.unblock_domain": "Odblokovat doménu {domain}",
"account.unblock_domain_short": "Odblokovat",
"account.unblock_short": "Odblokovat", "account.unblock_short": "Odblokovat",
"account.unendorse": "Nezvýrazňovat na profilu", "account.unendorse": "Nezvýrazňovat na profilu",
"account.unfollow": "Přestat sledovat", "account.unfollow": "Přestat sledovat",
@ -79,7 +82,7 @@
"admin.dashboard.retention.cohort_size": "Noví uživatelé", "admin.dashboard.retention.cohort_size": "Noví uživatelé",
"admin.impact_report.instance_accounts": "Profily účtů, které by byli odstaněny", "admin.impact_report.instance_accounts": "Profily účtů, které by byli odstaněny",
"admin.impact_report.instance_followers": "Sledující, o které by naši uživatelé přišli", "admin.impact_report.instance_followers": "Sledující, o které by naši uživatelé přišli",
"admin.impact_report.instance_follows": "Sledující, o které by naši uživatelé přišli", "admin.impact_report.instance_follows": "Sledující, o které by jejich uživatelé přišli",
"admin.impact_report.title": "Shrnutí dopadu", "admin.impact_report.title": "Shrnutí dopadu",
"alert.rate_limited.message": "Zkuste to prosím znovu po {retry_time, time, medium}.", "alert.rate_limited.message": "Zkuste to prosím znovu po {retry_time, time, medium}.",
"alert.rate_limited.title": "Spojení omezena", "alert.rate_limited.title": "Spojení omezena",
@ -101,7 +104,7 @@
"annual_report.summary.archetype.replier": "Sociální motýlek", "annual_report.summary.archetype.replier": "Sociální motýlek",
"annual_report.summary.followers.followers": "sledujících", "annual_report.summary.followers.followers": "sledujících",
"annual_report.summary.followers.total": "{count} celkem", "annual_report.summary.followers.total": "{count} celkem",
"annual_report.summary.here_it_is": "Zde je tvůj {year} v přehledu:", "annual_report.summary.here_it_is": "Zde je tvůj rok {year} v přehledu:",
"annual_report.summary.highlighted_post.by_favourites": "nejvíce oblíbený příspěvek", "annual_report.summary.highlighted_post.by_favourites": "nejvíce oblíbený příspěvek",
"annual_report.summary.highlighted_post.by_reblogs": "nejvíce boostovaný příspěvek", "annual_report.summary.highlighted_post.by_reblogs": "nejvíce boostovaný příspěvek",
"annual_report.summary.highlighted_post.by_replies": "příspěvek s nejvíce odpověďmi", "annual_report.summary.highlighted_post.by_replies": "příspěvek s nejvíce odpověďmi",
@ -267,7 +270,7 @@
"domain_pill.activitypub_like_language": "ActivityPub je jako jazyk, kterým Mastodon mluví s jinými sociálními sítěmi.", "domain_pill.activitypub_like_language": "ActivityPub je jako jazyk, kterým Mastodon mluví s jinými sociálními sítěmi.",
"domain_pill.server": "Server", "domain_pill.server": "Server",
"domain_pill.their_handle": "Handle:", "domain_pill.their_handle": "Handle:",
"domain_pill.their_server": "Jejich digitální domov, kde žijí jejich všechny příspěvky.", "domain_pill.their_server": "Jejich digitální domov, kde žijí všechny jejich příspěvky.",
"domain_pill.their_username": "Jejich jedinečný identifikátor na jejich serveru. Je možné, že na jiných serverech jsou uživatelé se stejným uživatelským jménem.", "domain_pill.their_username": "Jejich jedinečný identifikátor na jejich serveru. Je možné, že na jiných serverech jsou uživatelé se stejným uživatelským jménem.",
"domain_pill.username": "Uživatelské jméno", "domain_pill.username": "Uživatelské jméno",
"domain_pill.whats_in_a_handle": "Co obsahuje handle?", "domain_pill.whats_in_a_handle": "Co obsahuje handle?",
@ -293,6 +296,7 @@
"emoji_button.search_results": "Výsledky hledání", "emoji_button.search_results": "Výsledky hledání",
"emoji_button.symbols": "Symboly", "emoji_button.symbols": "Symboly",
"emoji_button.travel": "Cestování a místa", "emoji_button.travel": "Cestování a místa",
"empty_column.account_featured": "Tento seznam je prázdný",
"empty_column.account_hides_collections": "Tento uživatel se rozhodl tuto informaci nezveřejňovat", "empty_column.account_hides_collections": "Tento uživatel se rozhodl tuto informaci nezveřejňovat",
"empty_column.account_suspended": "Účet je pozastaven", "empty_column.account_suspended": "Účet je pozastaven",
"empty_column.account_timeline": "Nejsou tu žádné příspěvky!", "empty_column.account_timeline": "Nejsou tu žádné příspěvky!",
@ -377,6 +381,8 @@
"generic.saved": "Uloženo", "generic.saved": "Uloženo",
"getting_started.heading": "Začínáme", "getting_started.heading": "Začínáme",
"hashtag.admin_moderation": "Otevřít moderátorské rozhraní pro #{name}", "hashtag.admin_moderation": "Otevřít moderátorské rozhraní pro #{name}",
"hashtag.browse": "Procházet příspěvky na #{hashtag}",
"hashtag.browse_from_account": "Procházet příspěvky od @{name} v #{hashtag}",
"hashtag.column_header.tag_mode.all": "a {additional}", "hashtag.column_header.tag_mode.all": "a {additional}",
"hashtag.column_header.tag_mode.any": "nebo {additional}", "hashtag.column_header.tag_mode.any": "nebo {additional}",
"hashtag.column_header.tag_mode.none": "bez {additional}", "hashtag.column_header.tag_mode.none": "bez {additional}",
@ -390,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} other {{counter} příspěvků}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} other {{counter} příspěvků}}",
"hashtag.counter_by_uses_today": "Dnes {count, plural, one {{counter} příspěvek} few {{counter} příspěvky} other {{counter} příspěvků}}", "hashtag.counter_by_uses_today": "Dnes {count, plural, one {{counter} příspěvek} few {{counter} příspěvky} other {{counter} příspěvků}}",
"hashtag.follow": "Sledovat hashtag", "hashtag.follow": "Sledovat hashtag",
"hashtag.mute": "Skrýt #{hashtag}",
"hashtag.unfollow": "Přestat sledovat hashtag", "hashtag.unfollow": "Přestat sledovat hashtag",
"hashtags.and_other": "…a {count, plural, one {# další} few {# další} other {# dalších}}", "hashtags.and_other": "…a {count, plural, one {# další} few {# další} other {# dalších}}",
"hints.profiles.followers_may_be_missing": "Sledující mohou pro tento profil chybět.", "hints.profiles.followers_may_be_missing": "Sledující mohou pro tento profil chybět.",
@ -559,7 +566,7 @@
"notification.admin.sign_up.name_and_others": "{name} a {count, plural, one {# další} few {# další} many {# dalších} other {# dalších}} se zaregistrovali", "notification.admin.sign_up.name_and_others": "{name} a {count, plural, one {# další} few {# další} many {# dalších} other {# dalších}} se zaregistrovali",
"notification.annual_report.message": "Váš #Wrapstodon {year} na Vás čeká! Podívejte se, jak vypadal tento Váš rok na Mastodonu!", "notification.annual_report.message": "Váš #Wrapstodon {year} na Vás čeká! Podívejte se, jak vypadal tento Váš rok na Mastodonu!",
"notification.annual_report.view": "Zobrazit #Wrapstodon", "notification.annual_report.view": "Zobrazit #Wrapstodon",
"notification.favourite": "{name} si oblíbil*a váš příspěvek", "notification.favourite": "{name} si oblíbil váš příspěvek",
"notification.favourite.name_and_others_with_link": "{name} a {count, plural, one {<a># další</a> si oblíbil} few {<a># další</a> si oblíbili} other {<a># dalších</a> si oblíbilo}} Váš příspěvek", "notification.favourite.name_and_others_with_link": "{name} a {count, plural, one {<a># další</a> si oblíbil} few {<a># další</a> si oblíbili} other {<a># dalších</a> si oblíbilo}} Váš příspěvek",
"notification.favourite_pm": "{name} si oblíbil vaši soukromou zmínku", "notification.favourite_pm": "{name} si oblíbil vaši soukromou zmínku",
"notification.favourite_pm.name_and_others_with_link": "{name} a {count, plural, one {<a># další</a> si oblíbil} few {<a># další</a> si oblíbili} other {<a># dalších</a> si oblíbilo}} Vaši soukromou zmínku", "notification.favourite_pm.name_and_others_with_link": "{name} a {count, plural, one {<a># další</a> si oblíbil} few {<a># další</a> si oblíbili} other {<a># dalších</a> si oblíbilo}} Vaši soukromou zmínku",

View file

@ -27,9 +27,11 @@
"account.edit_profile": "Golygu proffil", "account.edit_profile": "Golygu proffil",
"account.enable_notifications": "Rhowch wybod i fi pan fydd @{name} yn postio", "account.enable_notifications": "Rhowch wybod i fi pan fydd @{name} yn postio",
"account.endorse": "Dangos ar fy mhroffil", "account.endorse": "Dangos ar fy mhroffil",
"account.featured": "Dethol",
"account.featured.hashtags": "Hashnodau",
"account.featured.posts": "Postiadau",
"account.featured_tags.last_status_at": "Y postiad olaf ar {date}", "account.featured_tags.last_status_at": "Y postiad olaf ar {date}",
"account.featured_tags.last_status_never": "Dim postiadau", "account.featured_tags.last_status_never": "Dim postiadau",
"account.featured_tags.title": "Prif hashnodau {name}",
"account.follow": "Dilyn", "account.follow": "Dilyn",
"account.follow_back": "Dilyn nôl", "account.follow_back": "Dilyn nôl",
"account.followers": "Dilynwyr", "account.followers": "Dilynwyr",
@ -65,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} postiad} two {{counter} bostiad} few {{counter} phostiad} many {{counter} postiad} other {{counter} postiad}}", "account.statuses_counter": "{count, plural, one {{counter} postiad} two {{counter} bostiad} few {{counter} phostiad} many {{counter} postiad} other {{counter} postiad}}",
"account.unblock": "Dadrwystro @{name}", "account.unblock": "Dadrwystro @{name}",
"account.unblock_domain": "Dadrwystro parth {domain}", "account.unblock_domain": "Dadrwystro parth {domain}",
"account.unblock_domain_short": "Dadrwystro",
"account.unblock_short": "Dadrwystro", "account.unblock_short": "Dadrwystro",
"account.unendorse": "Peidio a'i ddangos ar fy mhroffil", "account.unendorse": "Peidio a'i ddangos ar fy mhroffil",
"account.unfollow": "Dad-ddilyn", "account.unfollow": "Dad-ddilyn",
@ -293,6 +296,7 @@
"emoji_button.search_results": "Canlyniadau chwilio", "emoji_button.search_results": "Canlyniadau chwilio",
"emoji_button.symbols": "Symbolau", "emoji_button.symbols": "Symbolau",
"emoji_button.travel": "Teithio a Llefydd", "emoji_button.travel": "Teithio a Llefydd",
"empty_column.account_featured": "Mae'r rhestr hon yn wag",
"empty_column.account_hides_collections": "Mae'r defnyddiwr wedi dewis i beidio rhannu'r wybodaeth yma", "empty_column.account_hides_collections": "Mae'r defnyddiwr wedi dewis i beidio rhannu'r wybodaeth yma",
"empty_column.account_suspended": "Cyfrif wedi'i atal", "empty_column.account_suspended": "Cyfrif wedi'i atal",
"empty_column.account_timeline": "Dim postiadau yma!", "empty_column.account_timeline": "Dim postiadau yma!",

View file

@ -27,9 +27,11 @@
"account.edit_profile": "Redigér profil", "account.edit_profile": "Redigér profil",
"account.enable_notifications": "Advisér mig, når @{name} poster", "account.enable_notifications": "Advisér mig, når @{name} poster",
"account.endorse": "Fremhæv på profil", "account.endorse": "Fremhæv på profil",
"account.featured": "Fremhævet",
"account.featured.hashtags": "Hashtags",
"account.featured.posts": "Indlæg",
"account.featured_tags.last_status_at": "Seneste indlæg {date}", "account.featured_tags.last_status_at": "Seneste indlæg {date}",
"account.featured_tags.last_status_never": "Ingen indlæg", "account.featured_tags.last_status_never": "Ingen indlæg",
"account.featured_tags.title": "{name}s fremhævede etiketter",
"account.follow": "Følg", "account.follow": "Følg",
"account.follow_back": "Følg tilbage", "account.follow_back": "Følg tilbage",
"account.followers": "Følgere", "account.followers": "Følgere",
@ -65,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}}", "account.statuses_counter": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}}",
"account.unblock": "Fjern blokering af @{name}", "account.unblock": "Fjern blokering af @{name}",
"account.unblock_domain": "Fjern blokering af domænet {domain}", "account.unblock_domain": "Fjern blokering af domænet {domain}",
"account.unblock_domain_short": "Afblokér",
"account.unblock_short": "Fjern blokering", "account.unblock_short": "Fjern blokering",
"account.unendorse": "Fjern visning på din profil", "account.unendorse": "Fjern visning på din profil",
"account.unfollow": "Følg ikke længere", "account.unfollow": "Følg ikke længere",
@ -293,6 +296,7 @@
"emoji_button.search_results": "Søgeresultater", "emoji_button.search_results": "Søgeresultater",
"emoji_button.symbols": "Symboler", "emoji_button.symbols": "Symboler",
"emoji_button.travel": "Rejser og steder", "emoji_button.travel": "Rejser og steder",
"empty_column.account_featured": "Denne liste er tom",
"empty_column.account_hides_collections": "Brugeren har valgt ikke at gøre denne information tilgængelig", "empty_column.account_hides_collections": "Brugeren har valgt ikke at gøre denne information tilgængelig",
"empty_column.account_suspended": "Konto suspenderet", "empty_column.account_suspended": "Konto suspenderet",
"empty_column.account_timeline": "Ingen indlæg her!", "empty_column.account_timeline": "Ingen indlæg her!",
@ -377,6 +381,8 @@
"generic.saved": "Gemt", "generic.saved": "Gemt",
"getting_started.heading": "Startmenu", "getting_started.heading": "Startmenu",
"hashtag.admin_moderation": "Åbn modereringsbrugerflade for #{name}", "hashtag.admin_moderation": "Åbn modereringsbrugerflade for #{name}",
"hashtag.browse": "Gennemse indlæg i #{hashtag}",
"hashtag.browse_from_account": "Gennemse indlæg fra @{name} i #{hashtag}",
"hashtag.column_header.tag_mode.all": "og {additional}", "hashtag.column_header.tag_mode.all": "og {additional}",
"hashtag.column_header.tag_mode.any": "eller {additional}", "hashtag.column_header.tag_mode.any": "eller {additional}",
"hashtag.column_header.tag_mode.none": "uden {additional}", "hashtag.column_header.tag_mode.none": "uden {additional}",
@ -390,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}}",
"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.follow": "Følg etiket", "hashtag.follow": "Følg etiket",
"hashtag.mute": "Tavsgør #{hashtag}",
"hashtag.unfollow": "Stop med at følge etiket", "hashtag.unfollow": "Stop med at følge etiket",
"hashtags.and_other": "…og {count, plural, one {}other {# flere}}", "hashtags.and_other": "…og {count, plural, one {}other {# flere}}",
"hints.profiles.followers_may_be_missing": "Der kan mangle følgere for denne profil.", "hints.profiles.followers_may_be_missing": "Der kan mangle følgere for denne profil.",

View file

@ -27,9 +27,11 @@
"account.edit_profile": "Profil bearbeiten", "account.edit_profile": "Profil bearbeiten",
"account.enable_notifications": "Benachrichtige mich wenn @{name} etwas postet", "account.enable_notifications": "Benachrichtige mich wenn @{name} etwas postet",
"account.endorse": "Im Profil empfehlen", "account.endorse": "Im Profil empfehlen",
"account.featured": "Empfohlen",
"account.featured.hashtags": "Hashtags",
"account.featured.posts": "Beiträge",
"account.featured_tags.last_status_at": "Letzter Beitrag am {date}", "account.featured_tags.last_status_at": "Letzter Beitrag am {date}",
"account.featured_tags.last_status_never": "Keine Beiträge", "account.featured_tags.last_status_never": "Keine Beiträge",
"account.featured_tags.title": "Von {name} vorgestellte Hashtags",
"account.follow": "Folgen", "account.follow": "Folgen",
"account.follow_back": "Ebenfalls folgen", "account.follow_back": "Ebenfalls folgen",
"account.followers": "Follower", "account.followers": "Follower",
@ -65,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}}", "account.statuses_counter": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}}",
"account.unblock": "{name} nicht mehr blockieren", "account.unblock": "{name} nicht mehr blockieren",
"account.unblock_domain": "Blockierung von {domain} aufheben", "account.unblock_domain": "Blockierung von {domain} aufheben",
"account.unblock_domain_short": "Entsperren",
"account.unblock_short": "Blockierung aufheben", "account.unblock_short": "Blockierung aufheben",
"account.unendorse": "Im Profil nicht mehr empfehlen", "account.unendorse": "Im Profil nicht mehr empfehlen",
"account.unfollow": "Entfolgen", "account.unfollow": "Entfolgen",
@ -293,6 +296,7 @@
"emoji_button.search_results": "Suchergebnisse", "emoji_button.search_results": "Suchergebnisse",
"emoji_button.symbols": "Symbole", "emoji_button.symbols": "Symbole",
"emoji_button.travel": "Reisen & Orte", "emoji_button.travel": "Reisen & Orte",
"empty_column.account_featured": "Diese Liste ist leer",
"empty_column.account_hides_collections": "Das Konto hat sich dazu entschieden, diese Information nicht zu veröffentlichen", "empty_column.account_hides_collections": "Das Konto hat sich dazu entschieden, diese Information nicht zu veröffentlichen",
"empty_column.account_suspended": "Konto gesperrt", "empty_column.account_suspended": "Konto gesperrt",
"empty_column.account_timeline": "Keine Beiträge vorhanden!", "empty_column.account_timeline": "Keine Beiträge vorhanden!",
@ -377,6 +381,8 @@
"generic.saved": "Gespeichert", "generic.saved": "Gespeichert",
"getting_started.heading": "Auf gehts!", "getting_started.heading": "Auf gehts!",
"hashtag.admin_moderation": "#{name} moderieren", "hashtag.admin_moderation": "#{name} moderieren",
"hashtag.browse": "Beiträge mit #{hashtag} suchen",
"hashtag.browse_from_account": "Beiträge von @{name} mit #{hashtag} suchen",
"hashtag.column_header.tag_mode.all": "und {additional}", "hashtag.column_header.tag_mode.all": "und {additional}",
"hashtag.column_header.tag_mode.any": "oder {additional}", "hashtag.column_header.tag_mode.any": "oder {additional}",
"hashtag.column_header.tag_mode.none": "ohne {additional}", "hashtag.column_header.tag_mode.none": "ohne {additional}",
@ -390,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}} heute", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}} heute",
"hashtag.follow": "Hashtag folgen", "hashtag.follow": "Hashtag folgen",
"hashtag.mute": "#{hashtag} stummschalten",
"hashtag.unfollow": "Hashtag entfolgen", "hashtag.unfollow": "Hashtag entfolgen",
"hashtags.and_other": "… und {count, plural, one{# weiterer} other {# weitere}}", "hashtags.and_other": "… und {count, plural, one{# weiterer} other {# weitere}}",
"hints.profiles.followers_may_be_missing": "Möglicherweise werden für dieses Profil nicht alle Follower angezeigt.", "hints.profiles.followers_may_be_missing": "Möglicherweise werden für dieses Profil nicht alle Follower angezeigt.",

View file

@ -29,7 +29,6 @@
"account.endorse": "Προβολή στο προφίλ", "account.endorse": "Προβολή στο προφίλ",
"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.featured_tags.title": "προβεβλημένες ετικέτες του/της {name}",
"account.follow": "Ακολούθησε", "account.follow": "Ακολούθησε",
"account.follow_back": "Ακολούθησε και εσύ", "account.follow_back": "Ακολούθησε και εσύ",
"account.followers": "Ακόλουθοι", "account.followers": "Ακόλουθοι",

View file

@ -1,7 +1,7 @@
{ {
"about.blocks": "Moderated servers", "about.blocks": "Moderated servers",
"about.contact": "Contact:", "about.contact": "Contact:",
"about.disclaimer": "Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse.", "about.disclaimer": "Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.",
"about.domain_blocks.no_reason_available": "Reason not available", "about.domain_blocks.no_reason_available": "Reason not available",
"about.domain_blocks.preamble": "Mastodon generally allows you to view content from and interact with users from any other server in the Fediverse. These are the exceptions that have been made on this particular server.", "about.domain_blocks.preamble": "Mastodon generally allows you to view content from and interact with users from any other server in the Fediverse. These are the exceptions that have been made on this particular server.",
"about.domain_blocks.silenced.explanation": "You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.", "about.domain_blocks.silenced.explanation": "You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.",
@ -29,7 +29,6 @@
"account.endorse": "Feature on profile", "account.endorse": "Feature on profile",
"account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_at": "Last post on {date}",
"account.featured_tags.last_status_never": "No posts", "account.featured_tags.last_status_never": "No posts",
"account.featured_tags.title": "{name}'s featured hashtags",
"account.follow": "Follow", "account.follow": "Follow",
"account.follow_back": "Follow back", "account.follow_back": "Follow back",
"account.followers": "Followers", "account.followers": "Followers",
@ -802,11 +801,11 @@
"server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)", "server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)",
"server_banner.active_users": "active users", "server_banner.active_users": "active users",
"server_banner.administered_by": "Administered by:", "server_banner.administered_by": "Administered by:",
"server_banner.is_one_of_many": "{domain} is one of the many independent servers you can use to participate in the fediverse.", "server_banner.is_one_of_many": "{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.",
"server_banner.server_stats": "Server stats:", "server_banner.server_stats": "Server stats:",
"sign_in_banner.create_account": "Create account", "sign_in_banner.create_account": "Create account",
"sign_in_banner.follow_anyone": "Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.", "sign_in_banner.follow_anyone": "Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.",
"sign_in_banner.mastodon_is": "Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse.", "sign_in_banner.mastodon_is": "Mastodon is the best way to keep up with what's happening.",
"sign_in_banner.sign_in": "Sign in", "sign_in_banner.sign_in": "Sign in",
"sign_in_banner.sso_redirect": "Login or Register", "sign_in_banner.sso_redirect": "Login or Register",
"status.admin_account": "Open moderation interface for @{name}", "status.admin_account": "Open moderation interface for @{name}",

View file

@ -2,7 +2,7 @@
"about.blocks": "Moderated servers", "about.blocks": "Moderated servers",
"about.contact": "Contact:", "about.contact": "Contact:",
"about.disabled": "Disabled", "about.disabled": "Disabled",
"about.disclaimer": "Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse.", "about.disclaimer": "Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.",
"about.domain_blocks.no_reason_available": "Reason not available", "about.domain_blocks.no_reason_available": "Reason not available",
"about.domain_blocks.noop.explanation": "This server is limited partically.", "about.domain_blocks.noop.explanation": "This server is limited partically.",
"about.domain_blocks.noop.title": "Soft limited", "about.domain_blocks.noop.title": "Soft limited",
@ -14,9 +14,9 @@
"about.enabled": "Enabled", "about.enabled": "Enabled",
"about.full_text_search": "Full text search", "about.full_text_search": "Full text search",
"about.kmyblue_capabilities": "Features available in this server", "about.kmyblue_capabilities": "Features available in this server",
"about.kmyblue_capability": "Server unique features are configured as follows.", "about.kmyblue_capability": "This server is using kmyblue, a fork of Mastodon. On this server, kmyblues unique features are configured as follows.",
"about.not_available": "This information has not been made available on this server.", "about.not_available": "This information has not been made available on this server.",
"about.powered_by": "Social media powered by You!", "about.powered_by": "Decentralized social media powered by {domain}",
"about.public_visibility": "Public visibility", "about.public_visibility": "Public visibility",
"about.rules": "Server rules", "about.rules": "Server rules",
"account.account_note_header": "Personal note", "account.account_note_header": "Personal note",
@ -38,9 +38,11 @@
"account.edit_profile": "Edit profile", "account.edit_profile": "Edit profile",
"account.enable_notifications": "Notify me when @{name} posts", "account.enable_notifications": "Notify me when @{name} posts",
"account.endorse": "Feature on profile", "account.endorse": "Feature on profile",
"account.featured": "Featured",
"account.featured.hashtags": "Hashtags",
"account.featured.posts": "Posts",
"account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_at": "Last post on {date}",
"account.featured_tags.last_status_never": "No posts", "account.featured_tags.last_status_never": "No posts",
"account.featured_tags.title": "{name}'s featured hashtags",
"account.follow": "Follow", "account.follow": "Follow",
"account.follow_back": "Follow back", "account.follow_back": "Follow back",
"account.followers": "Followers", "account.followers": "Followers",
@ -433,6 +435,7 @@
"emoji_button.search_results": "Search results", "emoji_button.search_results": "Search results",
"emoji_button.symbols": "Symbols", "emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places", "emoji_button.travel": "Travel & Places",
"empty_column.account_featured": "This list is empty",
"empty_column.account_hides_collections": "This user has chosen to not make this information available", "empty_column.account_hides_collections": "This user has chosen to not make this information available",
"empty_column.account_suspended": "Account suspended", "empty_column.account_suspended": "Account suspended",
"empty_column.account_timeline": "No posts here!", "empty_column.account_timeline": "No posts here!",
@ -523,6 +526,8 @@
"generic.saved": "Saved", "generic.saved": "Saved",
"getting_started.heading": "Getting started", "getting_started.heading": "Getting started",
"hashtag.admin_moderation": "Open moderation interface for #{name}", "hashtag.admin_moderation": "Open moderation interface for #{name}",
"hashtag.browse": "Browse posts in #{hashtag}",
"hashtag.browse_from_account": "Browse posts from @{name} in #{hashtag}",
"hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}", "hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}", "hashtag.column_header.tag_mode.none": "without {additional}",
@ -536,6 +541,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} post} other {{counter} posts}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} post} other {{counter} posts}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} post} other {{counter} posts}} today", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} post} other {{counter} posts}} today",
"hashtag.follow": "Follow hashtag", "hashtag.follow": "Follow hashtag",
"hashtag.mute": "Mute #{hashtag}",
"hashtag.unfollow": "Unfollow hashtag", "hashtag.unfollow": "Unfollow hashtag",
"hashtags.and_other": "…and {count, plural, other {# more}}", "hashtags.and_other": "…and {count, plural, other {# more}}",
"hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.", "hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.",
@ -1000,11 +1006,11 @@
"server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)", "server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)",
"server_banner.active_users": "active users", "server_banner.active_users": "active users",
"server_banner.administered_by": "Administered by:", "server_banner.administered_by": "Administered by:",
"server_banner.is_one_of_many": "{domain} is one of the many independent servers you can use to participate in the fediverse.", "server_banner.is_one_of_many": "{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.",
"server_banner.server_stats": "Server stats:", "server_banner.server_stats": "Server stats:",
"sign_in_banner.create_account": "Create account", "sign_in_banner.create_account": "Create account",
"sign_in_banner.follow_anyone": "Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.", "sign_in_banner.follow_anyone": "Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.",
"sign_in_banner.mastodon_is": "Join the Fediverse, become part of a community, and break free from Big Tech™'s stranglehold on public discourse.", "sign_in_banner.mastodon_is": "Mastodon is the best way to keep up with what's happening.",
"sign_in_banner.sign_in": "Login", "sign_in_banner.sign_in": "Login",
"sign_in_banner.sso_redirect": "Login or Register", "sign_in_banner.sso_redirect": "Login or Register",
"status.admin_account": "Open moderation interface for @{name}", "status.admin_account": "Open moderation interface for @{name}",

View file

@ -27,9 +27,10 @@
"account.edit_profile": "Redakti la profilon", "account.edit_profile": "Redakti la profilon",
"account.enable_notifications": "Sciigu min kiam @{name} afiŝos", "account.enable_notifications": "Sciigu min kiam @{name} afiŝos",
"account.endorse": "Prezenti ĉe via profilo", "account.endorse": "Prezenti ĉe via profilo",
"account.featured.hashtags": "Kradvortoj",
"account.featured.posts": "Afiŝoj",
"account.featured_tags.last_status_at": "Lasta afîŝo je {date}", "account.featured_tags.last_status_at": "Lasta afîŝo je {date}",
"account.featured_tags.last_status_never": "Neniu afiŝo", "account.featured_tags.last_status_never": "Neniu afiŝo",
"account.featured_tags.title": "Rekomendataj kradvortoj de {name}",
"account.follow": "Sekvi", "account.follow": "Sekvi",
"account.follow_back": "Sekvu reen", "account.follow_back": "Sekvu reen",
"account.followers": "Sekvantoj", "account.followers": "Sekvantoj",
@ -65,6 +66,7 @@
"account.statuses_counter": "{count, plural,one {{counter} afiŝo} other {{counter} afiŝoj}}", "account.statuses_counter": "{count, plural,one {{counter} afiŝo} other {{counter} afiŝoj}}",
"account.unblock": "Malbloki @{name}", "account.unblock": "Malbloki @{name}",
"account.unblock_domain": "Malbloki la domajnon {domain}", "account.unblock_domain": "Malbloki la domajnon {domain}",
"account.unblock_domain_short": "Malbloki",
"account.unblock_short": "Malbloki", "account.unblock_short": "Malbloki",
"account.unendorse": "Ne plu rekomendi ĉe la profilo", "account.unendorse": "Ne plu rekomendi ĉe la profilo",
"account.unfollow": "Ĉesi sekvi", "account.unfollow": "Ĉesi sekvi",
@ -293,6 +295,7 @@
"emoji_button.search_results": "Serĉaj rezultoj", "emoji_button.search_results": "Serĉaj rezultoj",
"emoji_button.symbols": "Simboloj", "emoji_button.symbols": "Simboloj",
"emoji_button.travel": "Vojaĝoj kaj lokoj", "emoji_button.travel": "Vojaĝoj kaj lokoj",
"empty_column.account_featured": "Ĉi tiu listo estas malplena",
"empty_column.account_hides_collections": "Ĉi tiu uzanto elektis ne disponebligi ĉi tiu informon", "empty_column.account_hides_collections": "Ĉi tiu uzanto elektis ne disponebligi ĉi tiu informon",
"empty_column.account_suspended": "Konto suspendita", "empty_column.account_suspended": "Konto suspendita",
"empty_column.account_timeline": "Neniuj afiŝoj ĉi tie!", "empty_column.account_timeline": "Neniuj afiŝoj ĉi tie!",
@ -905,6 +908,8 @@
"video.expand": "Pligrandigi la videon", "video.expand": "Pligrandigi la videon",
"video.fullscreen": "Igi plenekrana", "video.fullscreen": "Igi plenekrana",
"video.hide": "Kaŝu la filmeton", "video.hide": "Kaŝu la filmeton",
"video.mute": "Silentigi",
"video.pause": "Paŭzigi", "video.pause": "Paŭzigi",
"video.play": "Ekigi" "video.play": "Ekigi",
"video.unmute": "Ne plu silentigi"
} }

View file

@ -27,9 +27,11 @@
"account.edit_profile": "Editar perfil", "account.edit_profile": "Editar perfil",
"account.enable_notifications": "Notificarme cuando @{name} envíe mensajes", "account.enable_notifications": "Notificarme cuando @{name} envíe mensajes",
"account.endorse": "Destacar en el perfil", "account.endorse": "Destacar en el perfil",
"account.featured": "Destacados",
"account.featured.hashtags": "Etiquetas",
"account.featured.posts": "Mensajes",
"account.featured_tags.last_status_at": "Último mensaje: {date}", "account.featured_tags.last_status_at": "Último mensaje: {date}",
"account.featured_tags.last_status_never": "Sin mensajes", "account.featured_tags.last_status_never": "Sin mensajes",
"account.featured_tags.title": "Etiquetas destacadas de {name}",
"account.follow": "Seguir", "account.follow": "Seguir",
"account.follow_back": "Seguir", "account.follow_back": "Seguir",
"account.followers": "Seguidores", "account.followers": "Seguidores",
@ -65,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}}", "account.statuses_counter": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}}",
"account.unblock": "Desbloquear a @{name}", "account.unblock": "Desbloquear a @{name}",
"account.unblock_domain": "Desbloquear dominio {domain}", "account.unblock_domain": "Desbloquear dominio {domain}",
"account.unblock_domain_short": "Desbloquear",
"account.unblock_short": "Desbloquear", "account.unblock_short": "Desbloquear",
"account.unendorse": "No destacar en el perfil", "account.unendorse": "No destacar en el perfil",
"account.unfollow": "Dejar de seguir", "account.unfollow": "Dejar de seguir",
@ -293,6 +296,7 @@
"emoji_button.search_results": "Resultados de búsqueda", "emoji_button.search_results": "Resultados de búsqueda",
"emoji_button.symbols": "Símbolos", "emoji_button.symbols": "Símbolos",
"emoji_button.travel": "Viajes y lugares", "emoji_button.travel": "Viajes y lugares",
"empty_column.account_featured": "Esta lista está vacía",
"empty_column.account_hides_collections": "Este usuario eligió no publicar esta información", "empty_column.account_hides_collections": "Este usuario eligió no publicar esta información",
"empty_column.account_suspended": "Cuenta suspendida", "empty_column.account_suspended": "Cuenta suspendida",
"empty_column.account_timeline": "¡No hay mensajes acá!", "empty_column.account_timeline": "¡No hay mensajes acá!",
@ -377,6 +381,8 @@
"generic.saved": "Guardado", "generic.saved": "Guardado",
"getting_started.heading": "Inicio de Mastodon", "getting_started.heading": "Inicio de Mastodon",
"hashtag.admin_moderation": "Abrir interface de moderación para #{name}", "hashtag.admin_moderation": "Abrir interface de moderación para #{name}",
"hashtag.browse": "Ver publicaciones con #{hashtag}",
"hashtag.browse_from_account": "Ver publicaciones de @{name} con #{hashtag}",
"hashtag.column_header.tag_mode.all": "y {additional}", "hashtag.column_header.tag_mode.all": "y {additional}",
"hashtag.column_header.tag_mode.any": "o {additional}", "hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "sin {additional}", "hashtag.column_header.tag_mode.none": "sin {additional}",
@ -390,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}} hoy", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}} hoy",
"hashtag.follow": "Seguir etiqueta", "hashtag.follow": "Seguir etiqueta",
"hashtag.mute": "Silenciar #{hashtag}",
"hashtag.unfollow": "Dejar de seguir etiqueta", "hashtag.unfollow": "Dejar de seguir etiqueta",
"hashtags.and_other": "…y {count, plural, other {# más}}", "hashtags.and_other": "…y {count, plural, other {# más}}",
"hints.profiles.followers_may_be_missing": "Es posible que falten seguidores de este perfil.", "hints.profiles.followers_may_be_missing": "Es posible que falten seguidores de este perfil.",

View file

@ -27,9 +27,11 @@
"account.edit_profile": "Editar perfil", "account.edit_profile": "Editar perfil",
"account.enable_notifications": "Notificarme cuando @{name} publique algo", "account.enable_notifications": "Notificarme cuando @{name} publique algo",
"account.endorse": "Destacar en mi perfil", "account.endorse": "Destacar en mi perfil",
"account.featured": "Destacado",
"account.featured.hashtags": "Etiquetas",
"account.featured.posts": "Publicaciones",
"account.featured_tags.last_status_at": "Última publicación el {date}", "account.featured_tags.last_status_at": "Última publicación el {date}",
"account.featured_tags.last_status_never": "Sin publicaciones", "account.featured_tags.last_status_never": "Sin publicaciones",
"account.featured_tags.title": "Etiquetas destacadas de {name}",
"account.follow": "Seguir", "account.follow": "Seguir",
"account.follow_back": "Seguir también", "account.follow_back": "Seguir también",
"account.followers": "Seguidores", "account.followers": "Seguidores",
@ -65,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}", "account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"account.unblock": "Desbloquear a @{name}", "account.unblock": "Desbloquear a @{name}",
"account.unblock_domain": "Mostrar a {domain}", "account.unblock_domain": "Mostrar a {domain}",
"account.unblock_domain_short": "Desbloquear",
"account.unblock_short": "Desbloquear", "account.unblock_short": "Desbloquear",
"account.unendorse": "No mostrar en el perfil", "account.unendorse": "No mostrar en el perfil",
"account.unfollow": "Dejar de seguir", "account.unfollow": "Dejar de seguir",
@ -293,6 +296,7 @@
"emoji_button.search_results": "Resultados de búsqueda", "emoji_button.search_results": "Resultados de búsqueda",
"emoji_button.symbols": "Símbolos", "emoji_button.symbols": "Símbolos",
"emoji_button.travel": "Viajes y lugares", "emoji_button.travel": "Viajes y lugares",
"empty_column.account_featured": "Esta lista está vacía",
"empty_column.account_hides_collections": "Este usuario ha elegido no hacer disponible esta información", "empty_column.account_hides_collections": "Este usuario ha elegido no hacer disponible esta información",
"empty_column.account_suspended": "Cuenta suspendida", "empty_column.account_suspended": "Cuenta suspendida",
"empty_column.account_timeline": "¡No hay publicaciones aquí!", "empty_column.account_timeline": "¡No hay publicaciones aquí!",
@ -377,6 +381,8 @@
"generic.saved": "Guardado", "generic.saved": "Guardado",
"getting_started.heading": "Primeros pasos", "getting_started.heading": "Primeros pasos",
"hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}", "hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}",
"hashtag.browse": "Explorar publicaciones en #{hashtag}",
"hashtag.browse_from_account": "Explorar publicaciones desde @{name} en #{hashtag}",
"hashtag.column_header.tag_mode.all": "y {additional}", "hashtag.column_header.tag_mode.all": "y {additional}",
"hashtag.column_header.tag_mode.any": "o {additional}", "hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "sin {additional}", "hashtag.column_header.tag_mode.none": "sin {additional}",
@ -390,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}} hoy", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}} hoy",
"hashtag.follow": "Seguir etiqueta", "hashtag.follow": "Seguir etiqueta",
"hashtag.mute": "Silenciar #{hashtag}",
"hashtag.unfollow": "Dejar de seguir etiqueta", "hashtag.unfollow": "Dejar de seguir etiqueta",
"hashtags.and_other": "…y {count, plural, other {# más}}", "hashtags.and_other": "…y {count, plural, other {# más}}",
"hints.profiles.followers_may_be_missing": "Puede que no se muestren todos los seguidores de este perfil.", "hints.profiles.followers_may_be_missing": "Puede que no se muestren todos los seguidores de este perfil.",
@ -909,8 +916,8 @@
"video.pause": "Pausar", "video.pause": "Pausar",
"video.play": "Reproducir", "video.play": "Reproducir",
"video.skip_backward": "Saltar atrás", "video.skip_backward": "Saltar atrás",
"video.skip_forward": "Adelantar", "video.skip_forward": "Saltar adelante",
"video.unmute": "Dejar de silenciar", "video.unmute": "Dejar de silenciar",
"video.volume_down": "Bajar volumen", "video.volume_down": "Bajar el volumen",
"video.volume_up": "Subir volumen" "video.volume_up": "Subir el volumen"
} }

View file

@ -27,9 +27,11 @@
"account.edit_profile": "Editar perfil", "account.edit_profile": "Editar perfil",
"account.enable_notifications": "Notificarme cuando @{name} publique algo", "account.enable_notifications": "Notificarme cuando @{name} publique algo",
"account.endorse": "Destacar en el perfil", "account.endorse": "Destacar en el perfil",
"account.featured": "Destacado",
"account.featured.hashtags": "Etiquetas",
"account.featured.posts": "Publicaciones",
"account.featured_tags.last_status_at": "Última publicación el {date}", "account.featured_tags.last_status_at": "Última publicación el {date}",
"account.featured_tags.last_status_never": "Sin publicaciones", "account.featured_tags.last_status_never": "Sin publicaciones",
"account.featured_tags.title": "Etiquetas destacadas de {name}",
"account.follow": "Seguir", "account.follow": "Seguir",
"account.follow_back": "Seguir también", "account.follow_back": "Seguir también",
"account.followers": "Seguidores", "account.followers": "Seguidores",
@ -65,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}", "account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"account.unblock": "Desbloquear a @{name}", "account.unblock": "Desbloquear a @{name}",
"account.unblock_domain": "Desbloquear dominio {domain}", "account.unblock_domain": "Desbloquear dominio {domain}",
"account.unblock_domain_short": "Desbloquear",
"account.unblock_short": "Desbloquear", "account.unblock_short": "Desbloquear",
"account.unendorse": "No mostrar en el perfil", "account.unendorse": "No mostrar en el perfil",
"account.unfollow": "Dejar de seguir", "account.unfollow": "Dejar de seguir",
@ -293,6 +296,7 @@
"emoji_button.search_results": "Resultados de búsqueda", "emoji_button.search_results": "Resultados de búsqueda",
"emoji_button.symbols": "Símbolos", "emoji_button.symbols": "Símbolos",
"emoji_button.travel": "Viajes y lugares", "emoji_button.travel": "Viajes y lugares",
"empty_column.account_featured": "Esta lista está vacía",
"empty_column.account_hides_collections": "Este usuario ha decidido no mostrar esta información", "empty_column.account_hides_collections": "Este usuario ha decidido no mostrar esta información",
"empty_column.account_suspended": "Cuenta suspendida", "empty_column.account_suspended": "Cuenta suspendida",
"empty_column.account_timeline": "¡No hay publicaciones aquí!", "empty_column.account_timeline": "¡No hay publicaciones aquí!",
@ -377,6 +381,8 @@
"generic.saved": "Guardado", "generic.saved": "Guardado",
"getting_started.heading": "Primeros pasos", "getting_started.heading": "Primeros pasos",
"hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}", "hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}",
"hashtag.browse": "Explorar publicaciones en #{hashtag}",
"hashtag.browse_from_account": "Explorar publicaciones desde @{name} en #{hashtag}",
"hashtag.column_header.tag_mode.all": "y {additional}", "hashtag.column_header.tag_mode.all": "y {additional}",
"hashtag.column_header.tag_mode.any": "o {additional}", "hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "sin {additional}", "hashtag.column_header.tag_mode.none": "sin {additional}",
@ -390,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}} hoy", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}} hoy",
"hashtag.follow": "Seguir etiqueta", "hashtag.follow": "Seguir etiqueta",
"hashtag.mute": "Silenciar #{hashtag}",
"hashtag.unfollow": "Dejar de seguir etiqueta", "hashtag.unfollow": "Dejar de seguir etiqueta",
"hashtags.and_other": "…y {count, plural, other {# más}}", "hashtags.and_other": "…y {count, plural, other {# más}}",
"hints.profiles.followers_may_be_missing": "Puede que no se muestren todos los seguidores de este perfil.", "hints.profiles.followers_may_be_missing": "Puede que no se muestren todos los seguidores de este perfil.",

View file

@ -29,7 +29,6 @@
"account.endorse": "Too profiilil esile", "account.endorse": "Too profiilil esile",
"account.featured_tags.last_status_at": "Viimane postitus {date}", "account.featured_tags.last_status_at": "Viimane postitus {date}",
"account.featured_tags.last_status_never": "Postitusi pole", "account.featured_tags.last_status_never": "Postitusi pole",
"account.featured_tags.title": "{name} esiletõstetud sildid",
"account.follow": "Jälgi", "account.follow": "Jälgi",
"account.follow_back": "Jälgi vastu", "account.follow_back": "Jälgi vastu",
"account.followers": "Jälgijad", "account.followers": "Jälgijad",

View file

@ -29,7 +29,6 @@
"account.endorse": "Nabarmendu profilean", "account.endorse": "Nabarmendu profilean",
"account.featured_tags.last_status_at": "Azken bidalketa {date} datan", "account.featured_tags.last_status_at": "Azken bidalketa {date} datan",
"account.featured_tags.last_status_never": "Bidalketarik ez", "account.featured_tags.last_status_never": "Bidalketarik ez",
"account.featured_tags.title": "{name} erabiltzailearen nabarmendutako traolak",
"account.follow": "Jarraitu", "account.follow": "Jarraitu",
"account.follow_back": "Jarraitu bueltan", "account.follow_back": "Jarraitu bueltan",
"account.followers": "Jarraitzaileak", "account.followers": "Jarraitzaileak",

View file

@ -29,7 +29,6 @@
"account.endorse": "معرّفی در نمایه", "account.endorse": "معرّفی در نمایه",
"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.featured_tags.title": "برچسب‌های برگزیدهٔ {name}",
"account.follow": "پی‌گرفتن", "account.follow": "پی‌گرفتن",
"account.follow_back": "دنبال کردن متقابل", "account.follow_back": "دنبال کردن متقابل",
"account.followers": "پی‌گیرندگان", "account.followers": "پی‌گیرندگان",
@ -65,6 +64,7 @@
"account.statuses_counter": "{count, plural, one {{counter} فرسته} other {{counter} فرسته}}", "account.statuses_counter": "{count, plural, one {{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": "پی‌نگرفتن",

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