Compare commits

...

132 Commits

Author SHA1 Message Date
diondiondion
15a7507a09 Use radio buttons for emoji style preference (#39126) 2026-05-21 16:55:28 +00:00
Claire
cdf721a273 Fix remote statuses with large media descriptions being rejected (#39135) 2026-05-21 15:46:10 +00:00
Echo
cafe7ea35c Use display name component for empty message (#39131) 2026-05-21 13:59:02 +00:00
David Roetzel
e18ca373eb Revert "Add partial accounts to collections endpoint (#38919)" (#39128) 2026-05-21 13:45:09 +00:00
diondiondion
e54f927149 Accessibility: Add skip link & landmark regions to settings (#39129) 2026-05-21 13:41:14 +00:00
Echo
6735902c1a Fixes collection notification urls (#39127) 2026-05-21 13:17:35 +00:00
renovate[bot]
dc3ffac4a2 Update dependency lint-staged to v17 (#38917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 12:28:52 +00:00
David Roetzel
fe885d5788 Remove superfluous comment (#39123) 2026-05-21 12:10:31 +00:00
Echo
adfe7242f7 Updates vagrant to Node v24 (#39124) 2026-05-21 12:09:08 +00:00
David Roetzel
dfcfef38af Update to ruby 4.0.5 (#39099) 2026-05-21 11:45:36 +00:00
Matt Jankowski
fbc116ef90 Drop support for EOL node version 20 (#38926) 2026-05-21 07:41:52 +00:00
github-actions[bot]
6b5e18fb1d New Crowdin Translations (automated) (#39095)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-21 07:37:39 +00:00
diondiondion
d39f7bc72f Accessibility: Add visible focus outlines to emoji/language search fields (#39120) 2026-05-21 07:24:55 +00:00
diondiondion
e68c1c824a Accessibility: Add visible focus outlines to main search & composer fields (#39111) 2026-05-21 06:41:34 +00:00
Eugen Rochko
076c8ec51e Refactor server reducer into TypeScript (#39089) 2026-05-20 14:06:38 +00:00
Claire
f5b57e8ba7 Bump version to v4.5.10 (#39104) 2026-05-20 13:20:08 +00:00
Claire
0786c1e57a Merge commit from fork 2026-05-20 14:38:24 +02:00
Claire
ec2a99341c Merge commit from fork
* Refactor `PrivateAddressCheck`

Also ensures IPv4-mapped IPv6 addresses get properly checked no matter the version of `ipaddr`.

* Add some missing IPv6 ranges from `PrivateAddressCheck`
2026-05-20 14:34:32 +02:00
diondiondion
6e7e8de343 Allow adding an account to a collection directly from the profile page (#39080) 2026-05-20 11:29:41 +00:00
diondiondion
a444a0b572 Accessibility: Add landmark elements to login/sign-up pages (#39098) 2026-05-20 11:28:37 +00:00
Claire
6f8558a6b9 Fix Request error when issuing a request which host is an IP address (#39030) 2026-05-20 09:04:50 +00:00
David Roetzel
22203f8aeb Improve collection item verification (#39096) 2026-05-20 07:55:17 +00:00
Claire
f28715d370 Fix custom emoji selection (#39088) 2026-05-20 07:23:29 +00:00
Claire
fcf012c602 Update browserslists target (#39076) 2026-05-19 16:21:18 +00:00
Echo
dee1dc41aa Include boosts to restore pinned ordering (#39084) 2026-05-19 15:15:36 +00:00
diondiondion
655de32990 Ensure quote posts have no collection previews (#39082) 2026-05-19 14:14:25 +00:00
Echo
99db6a1910 Trigger initial field flow recalculation (#39079) 2026-05-19 12:58:46 +00:00
Echo
d5a7b383fa Autosuggest emojis rendering fix (#39077) 2026-05-19 12:22:52 +00:00
Echo
34c91555ae Refactor emoji search (#39008) 2026-05-19 10:47:45 +00:00
renovate[bot]
bd77f2e86d Update dependency typescript to v6.0.3 (#39060)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 08:07:01 +00:00
renovate[bot]
de7282d1cd Update opentelemetry-ruby (non-major) (#39021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 08:06:10 +00:00
renovate[bot]
7f5b16a6ad Update dependency @vitejs/plugin-react to v6.0.2 (#39041)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 07:52:11 +00:00
renovate[bot]
e3f81c7368 Update dependency @vitejs/plugin-legacy to v8.0.2 (#39039)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 07:52:07 +00:00
renovate[bot]
b36c121a53 Update github/codeql-action digest to 9e0d7b8 (#38981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 07:18:08 +00:00
Tan, Kian-ting
b3992e62ed fix nan-tw not listed in SUPPORTED_LOCALES (#37721)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2026-05-19 07:16:50 +00:00
Matt Jankowski
1232b55211 Use mime type symbol to set content_type for custom css response (#37845) 2026-05-19 07:16:24 +00:00
Matt Jankowski
5f33cf0b0a Extract api/v1/statuses#context to standalone controller (#38348) 2026-05-19 07:15:35 +00:00
Matt Jankowski
c3afdb760c Remove references to deleted lint config files (#39033) 2026-05-19 07:11:43 +00:00
renovate[bot]
40f5533990 Update peter-evans/create-pull-request digest to 5f6978f (#38982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 07:07:26 +00:00
github-actions[bot]
eec97e387a New Crowdin Translations (automated) (#39075)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-19 07:00:03 +00:00
renovate[bot]
eea90c205a Update DefinitelyTyped types (non-major) (#39059)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 06:53:52 +00:00
renovate[bot]
7592813e15 Update dependency postcss-preset-env to v11.3.0 (#39028)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 06:51:14 +00:00
renovate[bot]
f0204f3e62 Update dependency vite to v8.0.13 (#38985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 06:50:28 +00:00
renovate[bot]
01434ad4b6 Update dependency ox to v2.14.26 (#38974)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 06:44:57 +00:00
diondiondion
c26003af21 Allow users to change how accounts are sorted when viewing a collection (#39073) 2026-05-18 16:48:40 +00:00
Pia B.
07a05e1edf Add batch remove for collections in reports (#39020) 2026-05-18 14:53:40 +00:00
Matt Jankowski
2402730083 Remove unused bin/retry script (#39071) 2026-05-18 14:43:55 +00:00
diondiondion
28ae61f34d Unify compact button size between variants (#39070) 2026-05-18 14:33:23 +00:00
diondiondion
dcb6dbbc86 Update content & placement of "sensitive content" warning on collection page (#39069) 2026-05-18 13:08:41 +00:00
Claire
99b72f60ad Nudge users to turn on discoverable when viewing the empty list of collections they are in (#39029)
Co-authored-by: diondiondion <mail@diondiondion.com>
2026-05-18 12:11:14 +00:00
renovate[bot]
19914e9ef6 Update dependency axios to v1.16.1 (#39031)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 12:10:48 +00:00
renovate[bot]
a05d2d7ee2 Update formatjs monorepo (#39013)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 11:32:04 +00:00
renovate[bot]
19b19ad8c2 Update dependency ws to v8.20.1 (#39018)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 10:31:45 +00:00
renovate[bot]
8f47470853 Update dependency aws-sdk-s3 to v1.222.0 (#39036)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 10:20:46 +00:00
Matt Jankowski
75024a1778 Use ruby version 4.0.4 (#39016) 2026-05-18 10:08:22 +00:00
David Roetzel
db304735bf Temporary tweak to account background refresh (#39062) 2026-05-18 10:04:28 +00:00
Shlee
bb94f91f86 Fix accounts header banner grayscale (#39042) 2026-05-18 08:46:49 +00:00
Echo
cdf48e806d Fixes bio spacing when there aren't paragraph tags (#39055) 2026-05-18 08:45:43 +00:00
renovate[bot]
b946b8679d Update dependency strong_migrations to v2.8.0 (#39040)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 07:26:23 +00:00
github-actions[bot]
3294b5777f New Crowdin Translations (automated) (#39037)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-18 07:26:12 +00:00
renovate[bot]
f095346f8f Update dependency sidekiq to v8.1.5 (#39032)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 07:19:53 +00:00
renovate[bot]
d70c807a76 Update dependency aws-sdk-core to v3.247.0 (#39035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 07:19:28 +00:00
Nicholas La Roux
630ad9fd49 Add libheif dependencies to setup-ruby action to unbreak media_attachment_spec.rb on latest pre-release yet rolled out runner image (#39052) 2026-05-18 06:23:20 +00:00
David Roetzel
8bbde181db Use the same condition for stale refresh (#39026) 2026-05-13 14:57:25 +00:00
Matt Jankowski
13fbf00a97 Update codecov-action to v6 (#39019) 2026-05-13 14:48:51 +00:00
Matt Jankowski
0ef5dca3c8 Remove flatware config block, re-extract simplecov config to standalone file (#39017) 2026-05-13 14:46:46 +00:00
github-actions[bot]
771fdcbb9f New Crowdin Translations (automated) (#39024)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-13 09:08:11 +00:00
Echo
758db36ec7 Refactor account header banners (#38921) 2026-05-13 08:38:17 +00:00
Pia B.
bbb3392dbe add collections icon to admin report interface (#39009) 2026-05-12 17:46:24 +00:00
Michael Stanclift
cb5c5432b3 Narrow scope of Docker build cache in Github workflows (#39014) 2026-05-12 16:16:55 +00:00
Pia B.
7c05f56fe8 Add batch actions to collections and possibility to report multiple collections (#38991) 2026-05-12 16:13:01 +00:00
Michael Stanclift
d2f640272f Prepare Dockerfile for Node 26 (#38943) 2026-05-12 15:22:38 +00:00
renovate[bot]
2f1bbe051c Update dependency sidekiq to v8.1.4 (#38953)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 13:20:47 +00:00
Matt Jankowski
a547dfff37 Move flatware setup into rails_helper (#38944) 2026-05-12 13:12:49 +00:00
github-actions[bot]
735a00d741 New Crowdin Translations (automated) (#39000)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-12 07:54:35 +00:00
Pia B.
a6a8a37ae1 add raketasks to generate collections for testing (#38986) 2026-05-11 15:17:00 +00:00
Echo
82ce9367c3 Fixes line spacing in bio (#38988) 2026-05-11 11:13:37 +00:00
github-actions[bot]
e081d5936c New Crowdin Translations (automated) (#38952)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-11 07:02:39 +00:00
renovate[bot]
c3a1e04692 Update dorny/paths-filter digest to d1c1ffe (#38886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 06:47:22 +00:00
renovate[bot]
817a0a6764 Update dependency aws-sdk-s3 to v1.221.0 (#38928)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 06:46:53 +00:00
renovate[bot]
c45287c72d Update dependency vite to v8.0.11 (#38931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 06:46:36 +00:00
renovate[bot]
2a890822e3 Update dependency hiredis-client to v0.29.0 (#38958)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 06:22:19 +00:00
diondiondion
2e7df27180 Make search field and tabs sticky on search results page (#38968) 2026-05-11 06:21:51 +00:00
renovate[bot]
8e03c9c1fc Update dependency devise to v5.0.4 [SECURITY] (#38969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 06:21:03 +00:00
David Roetzel
b1f7c9641f Fix updating collection items when position is unknown (#38962) 2026-05-11 06:08:44 +00:00
diondiondion
e7ed8bb682 Indent collection preview cards when displayed in notifications (#38957) 2026-05-10 08:31:12 +00:00
diondiondion
dcc26c1b24 Stylelint: Allow leading underscore in CSS variable names (#38966) 2026-05-08 14:24:02 +00:00
Echo
8d6406f561 Remove legacy emojify function (#38965) 2026-05-08 14:05:31 +00:00
diondiondion
2543425e04 New profile: Fix link colors in bio and display name overflow (#38964) 2026-05-08 13:30:28 +00:00
diondiondion
cf7a092053 Improve layout and spacing of number fields (#38963) 2026-05-08 13:30:20 +00:00
diondiondion
86e4ecfa20 Add language to collection payload (#38961) 2026-05-08 09:52:09 +00:00
diondiondion
658ad9f57e Fix crash when rendering remote post with collection card (#38959) 2026-05-08 09:22:28 +00:00
diondiondion
b71333921b Fix text overflow issues in list item component (#38954) 2026-05-08 08:34:55 +00:00
Shlee
9ff094b62e Fix #38946 (#38951) 2026-05-08 06:44:28 +00:00
Claire
b2aa476abb Redirect with interstitial when trying to view a remote collection while logged out (#38941) 2026-05-07 16:04:26 +00:00
Echo
496d41cdce Fix fields not having links (#38945) 2026-05-07 15:54:19 +00:00
diondiondion
674e2685be Fix "New collection" link appearing on other accounts' profiles (#38942) 2026-05-07 14:49:31 +00:00
Claire
fcd56d6732 Fix type of interactingObject, interactionTarget and add missing QuoteAuthorization (#38940) 2026-05-07 14:47:19 +00:00
diondiondion
53d0499254 Add label to "Why do you want to join" field during sign-up (#38936) 2026-05-07 11:34:54 +00:00
Trivikram Kamat
27e90864ac Globally install corepack (#34406) 2026-05-07 10:43:15 +00:00
diondiondion
9c8e1855a5 Fix collection sharing/link copying using the local/relative collection URL (#38935) 2026-05-07 10:27:24 +00:00
David Roetzel
11803e3d04 Relax uniqueness constraint to allow nil (#38934) 2026-05-07 10:08:50 +00:00
Echo
c47922602f React Strict Mode (#38895) 2026-05-07 10:03:08 +00:00
diondiondion
60a437e045 Show "Follow" button next to accounts in a collection when logged out (#38933) 2026-05-07 09:51:12 +00:00
Echo
f24f98ce40 Profile: Remove old classes (#38920) 2026-05-07 09:41:03 +00:00
diondiondion
2fed2edd5e Hide "Follows you" badge when viewing your own list of followers (#38932) 2026-05-07 09:34:20 +00:00
github-actions[bot]
92c9fda9e6 New Crowdin Translations (automated) (#38930)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-07 09:26:43 +00:00
Echo
d0c8eb2f1b Swap from React Helmet to Unhead (#38896) 2026-05-07 09:09:27 +00:00
Claire
90c812ed16 Add explicit dependency to ipaddr (#38925) 2026-05-07 09:04:04 +00:00
Pia B.
1a2038775c Add ability to search email blocks by domain (#38923) 2026-05-06 15:54:12 +00:00
Matt Jankowski
65b7ddb3e8 Add failing service case to remote account refresh worker spec (#38922) 2026-05-06 15:10:12 +00:00
David Roetzel
f6f45c43a9 Add partial accounts to collections endpoint (#38919) 2026-05-06 15:08:26 +00:00
renovate[bot]
0f753038c4 Update github/codeql-action digest to e46ed2c (#38887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 13:17:26 +00:00
renovate[bot]
fee4c262d2 Update formatjs monorepo (#38913)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 13:15:54 +00:00
renovate[bot]
a2d04ee7b4 Update dependency vite-plugin-pwa to v1.3.0 (#38910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 13:15:36 +00:00
nicole mikołajczyk
194b889873 Expose mastodon-async-refresh response header through CORS (#38914) 2026-05-06 10:41:25 +00:00
renovate[bot]
1e3b089eb6 Update docker/build-push-action digest to 10e90e3 (#38815)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 09:53:28 +00:00
David Roetzel
07ce066d68 Move PartialAccountSerializer to the top-level (#38916) 2026-05-06 09:35:07 +00:00
renovate[bot]
b653660a5c Update dependency axios to v1.16.0 (#38880)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 09:14:07 +00:00
github-actions[bot]
b04f7e7411 New Crowdin Translations (automated) (#38915)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-06 08:54:35 +00:00
renovate[bot]
9ef8df569e Update dependency rubyzip to v3.3.0 (#38881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 08:49:03 +00:00
Matt Jankowski
d243ba36ce Add admin area spec for email subscriptions management (#38912) 2026-05-06 08:47:12 +00:00
Matt Jankowski
aee0025ca3 Add system spec for managing everyone user role 2FA (#38911) 2026-05-06 08:36:42 +00:00
Claire
cb2e770584 Remove duplicate index index_email_subscriptions_on_account_id (#38907) 2026-05-05 15:15:55 +00:00
Claire
5e3e11bbfa Fix role management interface not offering to require 2FA for all users (#38906) 2026-05-05 14:46:52 +00:00
Claire
3bc27b9b64 Resolve unknown tagged collections in remote posts (#38900) 2026-05-05 14:46:47 +00:00
github-actions[bot]
127de5bb3d New Crowdin Translations (automated) (#38905)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-05 10:24:49 +00:00
renovate[bot]
0aae54dfd9 Update dependency linzer to v0.7.9 (#38874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-05 08:17:59 +00:00
Kenta Ishizaki
066456ecdf Fix typo in typed_functions.ts comment (#38590) 2026-05-05 04:54:34 +00:00
renovate[bot]
e715531dd3 Update devDependencies (non-major) (#38901)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: ChaosExAnima <ChaosExAnima@users.noreply.github.com>
2026-05-04 21:21:52 +00:00
renovate[bot]
ba83509ff4 Update dependency FFmpeg/FFmpeg to v8.1.1 (#38888)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 16:24:22 +00:00
diondiondion
a23b3c7c25 Update block dialog copy to include quotes & collections (#38897) 2026-05-04 15:22:24 +00:00
480 changed files with 9478 additions and 7078 deletions

View File

@@ -1,6 +1,6 @@
defaults
> 0.2%
firefox >= 78
> 0.2% and not ios < 15.6
firefox >= 91
ios >= 15.6
not dead
not OperaMini all

View File

@@ -59,7 +59,7 @@ body:
Any additional technical details you may have, like logs or error traces
value: |
If this is happening on your own Mastodon server, please fill out those:
- Ruby version: (from `ruby --version`, eg. v4.0.3)
- Ruby version: (from `ruby --version`, eg. v4.0.5)
- Node.js version: (from `node --version`, eg. v22.16.0)
validations:
required: false

View File

@@ -61,7 +61,7 @@ body:
value: |
Please at least include those informations:
- Operating system: (eg. Ubuntu 24.04.2)
- Ruby version: (from `ruby --version`, eg. v4.0.3)
- Ruby version: (from `ruby --version`, eg. v4.0.5)
- Node.js version: (from `node --version`, eg. v22.16.0)
validations:
required: false

View File

@@ -16,7 +16,7 @@ runs:
# The following is needed because we can not use `cache: true` for `setup-node`, as it does not support Corepack yet and mess up with the cache location if ran after Node is installed
- name: Enable corepack
shell: bash
run: corepack enable
run: npm i -g corepack
- name: Get yarn cache directory path
id: yarn-cache-dir-path

View File

@@ -14,10 +14,16 @@ runs:
shell: bash
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }}
sudo apt-get install --no-install-recommends -y \
libicu-dev \
libidn11-dev \
libvips42 \
libheif-plugin-aomdec \
libheif-plugin-libde265 \
${{ inputs.additional-system-dependencies }}
- name: Set up Ruby
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
with:
ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true

View File

@@ -76,7 +76,7 @@ jobs:
- name: Build and push by digest
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: ${{ inputs.file_to_build }}
@@ -87,8 +87,8 @@ jobs:
platforms: ${{ matrix.platform }}
provenance: false
push: ${{ inputs.push_to_images != '' }}
cache-from: ${{ inputs.cache && 'type=gha' || '' }}
cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }}
cache-from: ${{ inputs.cache && format('type=gha,scope=build-{0}-{1}', hashFiles(inputs.file_to_build), env.PLATFORM_PAIR) || '' }}
cache-to: ${{ inputs.cache && format('type=gha,mode=max,scope=build-{0}-{1}', hashFiles(inputs.file_to_build), env.PLATFORM_PAIR) || '' }}
outputs: type=image,"name=${{ env.IMAGE_NAMES }}",push-by-digest=true,name-canonical=true,push=${{ inputs.push_to_images != '' }}
- name: Export digest

View File

@@ -31,7 +31,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Ruby
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
with:
bundler-cache: true

View File

@@ -20,7 +20,7 @@ jobs:
with:
fetch-depth: 0
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
id: filter
with:
filters: |

View File

@@ -35,7 +35,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -48,7 +48,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -61,6 +61,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
category: '/language:${{matrix.language}}'

View File

@@ -52,7 +52,7 @@ jobs:
# Create or update the pull request
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
with:
commit-message: 'New Crowdin translations'
title: 'New Crowdin Translations (automated)'

View File

@@ -13,7 +13,6 @@ on:
- '**/*.css'
- '**/*.scss'
- '.github/workflows/lint-css.yml'
- '.github/stylelint-matcher.json'
pull_request:
paths:
@@ -24,7 +23,6 @@ on:
- '**/*.css'
- '**/*.scss'
- '.github/workflows/lint-css.yml'
- '.github/stylelint-matcher.json'
jobs:
lint:

View File

@@ -6,7 +6,6 @@ on:
- 'main'
- 'stable-*'
paths:
- '.github/workflows/haml-lint-problem-matcher.json'
- '.github/workflows/lint-haml.yml'
- '.haml-lint*.yml'
- '.rubocop*.yml'
@@ -16,7 +15,6 @@ on:
pull_request:
paths:
- '.github/workflows/haml-lint-problem-matcher.json'
- '.github/workflows/lint-haml.yml'
- '.haml-lint*.yml'
- '.rubocop*.yml'
@@ -36,7 +34,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Ruby
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
with:
bundler-cache: true

View File

@@ -10,7 +10,6 @@ on:
- '.rubocop*.yml'
- '.ruby-version'
- 'bin/rubocop'
- 'config/brakeman.ignore'
- '**/*.rb'
- '**/*.rake'
- '.github/workflows/lint-ruby.yml'
@@ -21,7 +20,6 @@ on:
- '.rubocop*.yml'
- '.ruby-version'
- 'bin/rubocop'
- 'config/brakeman.ignore'
- '**/*.rb'
- '**/*.rake'
- '.github/workflows/lint-ruby.yml'
@@ -38,7 +36,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Ruby
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
with:
bundler-cache: true

View File

@@ -163,11 +163,11 @@ jobs:
rspec-persistence-main
rspec-persistence
- run: bin/flatware rspec -r ./spec/flatware_helper.rb
- run: bin/flatware rspec
- name: Upload coverage reports to Codecov
if: matrix.ruby-version == '.ruby-version'
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
with:
files: coverage/lcov/*.lcov
env:

View File

@@ -1 +1 @@
4.0.3
4.0.5

26
.simplecov Normal file
View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
SimpleCov.start 'rails' do
# During parallel runs, ensure unique names for post-run merge
command_name "job-#{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
if ENV['CI']
require 'simplecov-lcov'
formatter SimpleCov::Formatter::LcovFormatter
formatter.config.report_with_single_file = true
else
formatter SimpleCov::Formatter::HTMLFormatter
end
enable_coverage :branch
add_filter 'lib/linter'
add_group 'Libraries', 'lib'
add_group 'Policies', 'app/policies'
add_group 'Presenters', 'app/presenters'
add_group 'Search', 'app/chewy'
add_group 'Serializers', 'app/serializers'
add_group 'Services', 'app/services'
add_group 'Validators', 'app/validators'
end

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## [4.5.10] - 2026-05-20
### Security
- Fix SSRF protection bypass ([GHSA-crr4-7rm4-8gpw](https://github.com/mastodon/mastodon/security/advisories/GHSA-crr4-7rm4-8gpw), [GHSA-xx55-4rrg-8xg6](https://github.com/mastodon/mastodon/security/advisories/GHSA-xx55-4rrg-8xg6))
- Fix Linked-Data Signature bypass through JSON-LD graph restructuring features ([GHSA-53m7-2wrh-q839](https://github.com/mastodon/mastodon/security/advisories/GHSA-53m7-2wrh-q839), [GHSA-chgx-jx3p-rf73](https://github.com/mastodon/mastodon/security/advisories/GHSA-chgx-jx3p-rf73))
- Updated dependencies
### Fixed
- Fix type of `interactingObject`, `interactionTarget` and add missing `QuoteAuthorization` (#38940 by @ClearlyClaire)
### Removed
- Remove unused devise strategies (#38795 by @ClearlyClaire)
## [4.5.9] - 2026-04-15
### Security

View File

@@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="4.0.x"]
# renovate: datasource=docker depName=docker.io/ruby
ARG RUBY_VERSION="4.0.3"
ARG RUBY_VERSION="4.0.5"
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"]
# renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="24"
@@ -234,7 +234,7 @@ FROM media-build AS ffmpeg
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
# renovate: datasource=github-tags depName=FFmpeg/FFmpeg extractVersion=^n(?<version>\d+\.\d+(\.\d+)?)$
ARG FFMPEG_VERSION=8.1
ARG FFMPEG_VERSION=8.1.1
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
ARG FFMPEG_URL=https://github.com/FFmpeg/FFmpeg/archive/refs/tags
@@ -340,10 +340,13 @@ COPY --from=node /usr/local/bin /usr/local/bin
COPY --from=node /usr/local/lib /usr/local/lib
RUN \
# Configure Corepack
rm /usr/local/bin/yarn*; \
corepack enable; \
corepack prepare --activate;
# Mount local Corepack and Yarn caches from Docker buildx caches
--mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \
--mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \
# Remove pre-installed Yarn binaries (only present on Node <26)
rm -f /usr/local/bin/yarn*; \
# Install Corepack
npm i -g corepack;
# hadolint ignore=DL3008
RUN \

View File

@@ -58,6 +58,7 @@ gem 'httplog', '~> 1.8.0', require: false
gem 'i18n'
gem 'idn-ruby', require: 'idn'
gem 'inline_svg'
gem 'ipaddr', '~> 1.2'
gem 'irb', '~> 1.8'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
@@ -101,10 +102,10 @@ gem 'rdf-normalize', '~> 0.5'
gem 'prometheus_exporter', '~> 2.2', require: false
gem 'opentelemetry-api', '~> 1.9.0'
gem 'opentelemetry-api', '~> 1.10.0'
group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.33.0', require: false
gem 'opentelemetry-exporter-otlp', '~> 0.34.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.12.0', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.25.0', require: false

View File

@@ -99,8 +99,8 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1242.0)
aws-sdk-core (3.246.0)
aws-partitions (1.1249.0)
aws-sdk-core (3.247.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -108,11 +108,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.124.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (1.125.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.220.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-s3 (1.222.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -132,7 +132,7 @@ GEM
binding_of_caller (2.0.0)
debug_inspector (>= 1.2.0)
blurhash (0.1.8)
bootsnap (1.24.1)
bootsnap (1.24.4)
msgpack (~> 1.2)
brakeman (8.0.4)
racc
@@ -190,7 +190,7 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
debug_inspector (1.2.0)
devise (5.0.3)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
@@ -305,8 +305,8 @@ GEM
json
highline (3.1.2)
reline
hiredis-client (0.28.0)
redis-client (= 0.28.0)
hiredis-client (0.29.0)
redis-client (= 0.29.0)
hkdf (0.3.0)
htmlentities (4.4.2)
http (5.3.1)
@@ -343,6 +343,7 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.8.2)
ipaddr (1.2.9)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
@@ -353,7 +354,7 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
json (2.19.4)
json (2.19.5)
json-canonicalization (1.0.0)
json-jwt (1.17.0)
activesupport (>= 4.2)
@@ -411,15 +412,13 @@ GEM
rexml
link_header (0.0.8)
lint_roller (1.1.0)
linzer (0.7.8)
linzer (0.7.9)
cgi (>= 0.4.2, < 0.6.0)
forwardable (~> 1.3, >= 1.3.3)
logger (~> 1.7, >= 1.7.0)
net-http (>= 0.6, < 0.10)
openssl (>= 3, < 5)
rack (>= 2.2, < 4.0)
starry (~> 0.2)
stringio (~> 3.1, >= 3.1.2)
uri (~> 1.0, >= 1.0.2)
llhttp-ffi (0.5.1)
ffi-compiler (~> 1.0)
@@ -450,7 +449,7 @@ GEM
mime-types-data (3.2026.0414)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (6.0.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
@@ -508,11 +507,11 @@ GEM
openssl (4.0.1)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.9.0)
opentelemetry-api (1.10.0)
logger
opentelemetry-common (0.24.0)
opentelemetry-common (0.25.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.33.0)
opentelemetry-exporter-otlp (0.34.0)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
@@ -575,19 +574,19 @@ GEM
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-sidekiq (0.29.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-registry (0.5.0)
opentelemetry-registry (0.6.0)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.11.0)
opentelemetry-sdk (1.12.0)
logger
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions
opentelemetry-semantic_conventions (1.37.0)
opentelemetry-semantic_conventions (1.37.1)
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
ostruct (0.6.3)
ox (2.14.25)
ox (2.14.26)
bigdecimal (>= 3.0)
parallel (1.28.0)
parser (3.3.11.1)
@@ -710,7 +709,7 @@ GEM
redcarpet (3.6.1)
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.28.0)
redis-client (0.29.0)
connection_pool
regexp_parser (2.12.0)
reline (0.6.3)
@@ -803,7 +802,7 @@ GEM
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
rubyzip (3.2.2)
rubyzip (3.3.0)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
safety_net_attestation (0.5.0)
@@ -817,12 +816,12 @@ GEM
securerandom (0.4.1)
shoulda-matchers (7.0.1)
activesupport (>= 7.1)
sidekiq (8.1.3)
sidekiq (8.1.5)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
logger (>= 1.7.0)
rack (>= 3.2.0)
redis-client (>= 0.26.0)
redis-client (>= 0.29.0)
sidekiq-bulk (0.2.0)
sidekiq
sidekiq-scheduler (6.0.2)
@@ -852,7 +851,7 @@ GEM
concurrent-ruby
zeitwerk
stringio (3.2.0)
strong_migrations (2.7.0)
strong_migrations (2.8.0)
activerecord (>= 7.2)
swd (2.0.3)
activesupport (>= 3)
@@ -994,6 +993,7 @@ DEPENDENCIES
i18n-tasks (~> 1.0)
idn-ruby
inline_svg
ipaddr (~> 1.2)
irb (~> 1.8)
jd-paperclip-azure (~> 3.0)
json
@@ -1020,8 +1020,8 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 2.0)
omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.8.0)
opentelemetry-api (~> 1.9.0)
opentelemetry-exporter-otlp (~> 0.33.0)
opentelemetry-api (~> 1.10.0)
opentelemetry-exporter-otlp (~> 0.34.0)
opentelemetry-instrumentation-active_job (~> 0.12.0)
opentelemetry-instrumentation-active_model_serializers (~> 0.25.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.25.0)
@@ -1097,7 +1097,7 @@ DEPENDENCIES
xorcist (~> 1.1)
RUBY VERSION
ruby 4.0.3
ruby 4.0.5
BUNDLED WITH
4.0.11

View File

@@ -59,7 +59,7 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub]
- **Ruby** 3.3+
- **PostgreSQL** 14+
- **Redis** 7.0+
- **Node.js** 20+
- **Node.js** 22+
- **FFmpeg** 5.1+
This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.

4
Vagrantfile vendored
View File

@@ -12,7 +12,7 @@ sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
# Add repo for NodeJS
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=20
NODE_MAJOR=24
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt-get update
@@ -115,7 +115,7 @@ gem install bundler foreman
bundle install
# Install node modules
sudo corepack enable
sudo npm i -g corepack
corepack prepare
yarn install

View File

@@ -4,13 +4,48 @@ module Admin
class CollectionsController < BaseController
before_action :set_account
before_action :set_collection, only: :show
before_action :set_collections, except: :show
PER_PAGE = 20
def index
authorize [:admin, :collection], :index?
@collection_batch_action = Admin::CollectionBatchAction.new
end
def show
authorize @collection, :show?
end
def batch
authorize [:admin, :collection], :index?
@collection_batch_action = Admin::CollectionBatchAction.new(admin_collection_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button))
@collection_batch_action.save!
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.collections.no_collection_selected')
ensure
redirect_to after_create_redirect_path
end
private
def after_create_redirect_path
report_id = @collections_batch_action&.report_id || params[:report_id]
if report_id.present?
admin_report_path(report_id)
else
admin_account_collections_path(params[:account_id], params[:page])
end
end
def admin_collection_batch_action_params
params
.expect(admin_collection_batch_action: [collection_ids: []])
end
def set_account
@account = Account.find(params[:account_id])
end
@@ -18,5 +53,17 @@ module Admin
def set_collection
@collection = @account.collections.includes(accepted_collection_items: :account).find(params[:id])
end
def set_collections
@collections = @account.collections.includes(accepted_collection_items: :account).page(params[:page]).per(PER_PAGE)
end
def action_from_button
if params[:report]
'report'
elsif params[:remove_from_report]
'remove_from_report'
end
end
end
end

View File

@@ -5,7 +5,7 @@ module Admin
def index
authorize :email_domain_block, :index?
@email_domain_blocks = EmailDomainBlock.parents.includes(:children).order(id: :desc).page(params[:page])
@email_domain_blocks = filter_by_domain.page(params[:page])
@form = Form::EmailDomainBlockBatch.new
end
@@ -57,6 +57,12 @@ module Admin
private
def filter_by_domain
scope = EmailDomainBlock.parents.includes(:children).order(id: :desc)
scope.merge!(EmailDomainBlock.matches_domain(params[:domain])) if params[:domain].present?
scope
end
def set_resolved_records
@resolved_records = DomainResource.new(@email_domain_block.domain).mx
end

View File

@@ -6,7 +6,7 @@ class Admin::EmailSubscriptionsController < Admin::BaseController
@enabled = Setting.email_subscriptions
@roles = UserRole.where('permissions & ? != 0', UserRole::FLAGS[:manage_email_subscriptions] | UserRole::FLAGS[:administrator])
@accounts = Account.local.joins(:email_subscriptions).where.associated(:email_subscriptions).includes(:user)
@accounts = Account.local.where.associated(:email_subscriptions).includes(:user)
end
def disable

View File

@@ -16,6 +16,8 @@ module Admin
@report_notes = @report.notes.chronological.includes(:account)
@action_logs = @report.history.includes(:target)
@form = Admin::StatusBatchAction.new
@collection_form = Admin::CollectionBatchAction.new
@collections = @report.collections
@statuses = @report.statuses.with_includes
end

View File

@@ -0,0 +1,67 @@
# frozen_string_literal: true
class Api::V1::Statuses::ContextsController < Api::BaseController
include Authorization
include AsyncRefreshesConcern
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_status
# This API was originally unlimited, pagination cannot be introduced without
# breaking backwards-compatibility. Arbitrarily high number to cover most
# conversations as quasi-unlimited, it would be too much work to render more
# than this anyway
CONTEXT_LIMIT = 4_096
# This remains expensive and we don't want to show everything to logged-out users
ANCESTORS_LIMIT = 40
DESCENDANTS_LIMIT = 60
DESCENDANTS_DEPTH_LIMIT = 20
def show
cache_if_unauthenticated!
ancestors_limit = CONTEXT_LIMIT
descendants_limit = CONTEXT_LIMIT
descendants_depth_limit = nil
if current_account.nil?
ancestors_limit = ANCESTORS_LIMIT
descendants_limit = DESCENDANTS_LIMIT
descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT
end
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account)
descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit)
loaded_ancestors = preload_collection(ancestors_results, Status)
loaded_descendants = preload_collection(descendants_results, Status)
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
refresh_key = "context:#{@status.id}:refresh"
async_refresh = AsyncRefresh.new(refresh_key)
if async_refresh.running?
add_async_refresh_header(async_refresh)
elsif !current_account.nil? && @status.should_fetch_replies?
add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true))
WorkerBatch.new.within do |batch|
batch.connect(refresh_key, threshold: 1.0)
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
end
end
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
end
private
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end
end

View File

@@ -2,14 +2,13 @@
class Api::V1::StatusesController < Api::BaseController
include Authorization
include AsyncRefreshesConcern
include Api::InteractionPoliciesConcern
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
before_action :require_user!, except: [:index, :show, :context]
before_action :require_user!, except: [:index, :show]
before_action :set_statuses, only: [:index]
before_action :set_status, only: [:show, :context]
before_action :set_status, only: [:show]
before_action :set_thread, only: [:create]
before_action :set_quoted_status, only: [:create]
before_action :check_statuses_limit, only: [:index]
@@ -17,17 +16,6 @@ class Api::V1::StatusesController < Api::BaseController
override_rate_limit_headers :create, family: :statuses
override_rate_limit_headers :update, family: :statuses
# This API was originally unlimited, pagination cannot be introduced without
# breaking backwards-compatibility. Arbitrarily high number to cover most
# conversations as quasi-unlimited, it would be too much work to render more
# than this anyway
CONTEXT_LIMIT = 4_096
# This remains expensive and we don't want to show everything to logged-out users
ANCESTORS_LIMIT = 40
DESCENDANTS_LIMIT = 60
DESCENDANTS_DEPTH_LIMIT = 20
def index
@statuses = preload_collection(@statuses, Status)
render json: @statuses, each_serializer: REST::StatusSerializer
@@ -39,44 +27,6 @@ class Api::V1::StatusesController < Api::BaseController
render json: @status, serializer: REST::StatusSerializer
end
def context
cache_if_unauthenticated!
ancestors_limit = CONTEXT_LIMIT
descendants_limit = CONTEXT_LIMIT
descendants_depth_limit = nil
if current_account.nil?
ancestors_limit = ANCESTORS_LIMIT
descendants_limit = DESCENDANTS_LIMIT
descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT
end
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account)
descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit)
loaded_ancestors = preload_collection(ancestors_results, Status)
loaded_descendants = preload_collection(descendants_results, Status)
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
refresh_key = "context:#{@status.id}:refresh"
async_refresh = AsyncRefresh.new(refresh_key)
if async_refresh.running?
add_async_refresh_header(async_refresh)
elsif !current_account.nil? && @status.should_fetch_replies?
add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true))
WorkerBatch.new.within do |batch|
batch.connect(refresh_key, threshold: 1.0)
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
end
end
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
end
def create
@status = PostStatusService.new.call(
current_user.account,

View File

@@ -3,7 +3,7 @@
class CustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController
def show
expires_in 1.month, public: true
render content_type: 'text/css'
render content_type: :css
end
private

View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
class Redirect::CollectionsController < Redirect::BaseController
private
def set_resource
@resource = Collection.find(params[:id])
not_found if @resource.local? || @resource&.account&.suspended?
end
end

View File

@@ -48,9 +48,9 @@ module ContextHelper
},
quote_authorizations: {
'gts' => 'https://gotosocial.org/ns#',
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
'interactingObject' => { '@id' => 'gts:interactingObject' },
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
'QuoteAuthorization' => 'https://w3id.org/fep/044f#QuoteAuthorization',
'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' },
'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' },
},
}.freeze

View File

@@ -3,6 +3,8 @@
module JsonLdHelper
include ContextHelper
UNSUPPORTED_JSONLD_KEYWORDS = %w(@graph @included @reverse).freeze
def equals_or_includes?(haystack, needle)
haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
end
@@ -110,6 +112,16 @@ module JsonLdHelper
compacted
end
def unsupported_jsonld_features?(json)
if json.is_a?(Hash)
json.any? { |key, value| UNSUPPORTED_JSONLD_KEYWORDS.include?(key) || unsupported_jsonld_features?(value) }
elsif json.is_a?(Array)
json.any? { |value| unsupported_jsonld_features?(value) }
else
false
end
end
# Patches a JSON-LD document to avoid compatibility issues on redistribution
#
# Since compacting a JSON-LD document against Mastodon's built-in vocabulary

View File

@@ -223,7 +223,14 @@ module LanguagesHelper
'zh-YUE': ['Cantonese', '廣東話'].freeze,
}.freeze
SUPPORTED_LOCALES = {}.merge(ISO_639_1).merge(ISO_639_1_REGIONAL).merge(ISO_639_3).freeze
# Since nan is not translated but nan-TW is translated,
# to enable the ISO-639-3 language-code with the regional variant but no
# official name, we use a specific hash for nan-TW
ISO_639_3_REGIONAL = {
'nan-TW': ['Hokkien (Taiwan)', '臺語 (Hô-ló話)'].freeze,
}.freeze
SUPPORTED_LOCALES = {}.merge(ISO_639_1).merge(ISO_639_1_REGIONAL).merge(ISO_639_3).merge(ISO_639_3_REGIONAL).freeze
# For ISO-639-1 and ISO-639-3 language codes, we have their official
# names, but for some translations, we need the names of the
@@ -233,7 +240,6 @@ module LanguagesHelper
'es-AR': 'Español (Argentina)',
'es-MX': 'Español (México)',
'fr-CA': 'Français (Canadien)',
'nan-TW': '臺語 (Hô-ló話)',
'pt-BR': 'Português (Brasil)',
'pt-PT': 'Português (Portugal)',
'sr-Latn': 'Srpski (latinica)',

View File

@@ -69,8 +69,9 @@ on('change', '#batch_checkbox_all', ({ target }) => {
'.batch-table__select-all',
);
document
.querySelectorAll<HTMLInputElement>(batchCheckboxClassName)
target
.closest('.batch-table')
?.querySelectorAll<HTMLInputElement>(batchCheckboxClassName)
.forEach((content) => {
content.checked = target.checked;
});
@@ -112,17 +113,20 @@ on('click', '.batch-table__select-all button', () => {
}
});
on('change', batchCheckboxClassName, () => {
const checkAllElement = document.querySelector<HTMLInputElement>(
on('change', batchCheckboxClassName, (event) => {
const targetTable = (event.target as HTMLElement).closest('.batch-table');
if (!targetTable) return;
const checkAllElement = targetTable.querySelector<HTMLInputElement>(
'input#batch_checkbox_all',
);
const selectAllMatchingElement = document.querySelector(
const selectAllMatchingElement = targetTable.querySelector(
'.batch-table__select-all',
);
if (checkAllElement) {
const allCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>(batchCheckboxClassName),
targetTable.querySelectorAll<HTMLInputElement>(batchCheckboxClassName),
);
checkAllElement.checked = allCheckboxes.every((content) => content.checked);
checkAllElement.indeterminate =

View File

@@ -13,14 +13,17 @@ import axios from 'axios';
import { on } from 'delegated-events';
import { throttle } from 'lodash';
import { determineEmojiMode } from '@/mastodon/features/emoji/mode';
import { updateHtmlWithEmoji } from '@/mastodon/features/emoji/render';
import loadKeyboardExtensions from '@/mastodon/load_keyboard_extensions';
import { loadLocale, getLocale } from '@/mastodon/locales';
import { loadPolyfills } from '@/mastodon/polyfills';
import ready from '@/mastodon/ready';
import { assetHost } from '@/mastodon/utils/config';
import { getNestedProperty } from '@/mastodon/utils/objects';
import { isDarkMode } from '@/mastodon/utils/theme';
import { formatTime } from '@/mastodon/utils/time';
import emojify from '../mastodon/features/emoji/emoji';
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
import { loadLocale, getLocale } from '../mastodon/locales';
import { loadPolyfills } from '../mastodon/polyfills';
import ready from '../mastodon/ready';
import 'cocoon-js-vanilla';
const messages = defineMessages({
@@ -38,7 +41,7 @@ const messages = defineMessages({
},
});
function loaded() {
async function loaded() {
const { messages: localeData } = getLocale();
const locale = document.documentElement.lang;
@@ -75,9 +78,30 @@ function loaded() {
return messageFormat.format(values) as string;
};
document.querySelectorAll('.emojify').forEach((content) => {
content.innerHTML = emojify(content.innerHTML);
});
let emojiStyle = 'auto';
const initialStateText =
document.getElementById('initial-state')?.textContent;
if (initialStateText) {
const stateEmojiStyle = getNestedProperty(
JSON.parse(initialStateText) as unknown,
'meta',
'emoji_style',
);
if (typeof stateEmojiStyle === 'string') {
emojiStyle = stateEmojiStyle;
}
}
const emojiMode = determineEmojiMode(emojiStyle);
const darkTheme = isDarkMode();
for (const element of document.querySelectorAll('.emojify')) {
await updateHtmlWithEmoji({
assetHost,
element,
locale,
mode: emojiMode,
darkTheme,
});
}
document
.querySelectorAll<HTMLTimeElement>('time.formatted')

View File

@@ -6,9 +6,8 @@ import { throttle } from 'lodash';
import api from 'mastodon/api';
import { browserHistory } from 'mastodon/components/router';
import { countableText } from 'mastodon/features/compose/util/counter';
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
import { tagHistory } from 'mastodon/settings';
import { fetchCustomEmojiData } from '@/mastodon/features/emoji/picker';
import { emojiMartSearch } from '@/mastodon/features/emoji/picker';
import { showAlert, showAlertForError } from './alerts';
import { useEmoji } from './emojis';
@@ -100,7 +99,7 @@ export const ensureComposeIsVisible = (getState) => {
export function setComposeToStatus(status, text, spoiler_text) {
return (dispatch, getState) => {
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
const maxOptions = getState().server.server.item?.configuration.polls.max_options;
dispatch({
type: COMPOSE_SET_STATUS,
@@ -592,8 +591,9 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => {
}, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
const custom = await fetchCustomEmojiData();
const results = emojiSearch(token.replace(':', ''), { maxResults: 5, custom });
// Right now we are hard-coding the locale to English since the picker search only supports English.
// Once we replace the legacy picker we can remove this and use the actual locale of the user.
const results = await emojiMartSearch(token, 'en', 5);
dispatch(readyComposeSuggestionsEmojis(token, results));
};
@@ -671,7 +671,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
let completion, startPosition;
if (suggestion.type === 'emoji') {
completion = suggestion.native || suggestion.colons;
completion = suggestion.native || `:${suggestion.id}:`;
startPosition = position - 1;
dispatch(useEmoji(suggestion));

View File

@@ -1,139 +0,0 @@
import api from '../api';
import { importFetchedAccount } from './importer';
export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST';
export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS';
export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL';
export const SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST = 'SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST';
export const SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS = 'SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS';
export const SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL = 'SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL';
export const EXTENDED_DESCRIPTION_REQUEST = 'EXTENDED_DESCRIPTION_REQUEST';
export const EXTENDED_DESCRIPTION_SUCCESS = 'EXTENDED_DESCRIPTION_SUCCESS';
export const EXTENDED_DESCRIPTION_FAIL = 'EXTENDED_DESCRIPTION_FAIL';
export const SERVER_DOMAIN_BLOCKS_FETCH_REQUEST = 'SERVER_DOMAIN_BLOCKS_FETCH_REQUEST';
export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS';
export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL';
export const fetchServer = () => (dispatch, getState) => {
if (getState().getIn(['server', 'server', 'isLoading'])) {
return;
}
dispatch(fetchServerRequest());
api()
.get('/api/v2/instance').then(({ data }) => {
// Only import the account if it doesn't already exist,
// because the API is cached even for logged in users.
const account = data.contact.account;
if (account) {
const existingAccount = getState().getIn(['accounts', account.id]);
if (!existingAccount) {
dispatch(importFetchedAccount(account));
}
}
dispatch(fetchServerSuccess(data));
}).catch(err => dispatch(fetchServerFail(err)));
};
const fetchServerRequest = () => ({
type: SERVER_FETCH_REQUEST,
});
const fetchServerSuccess = server => ({
type: SERVER_FETCH_SUCCESS,
server,
});
const fetchServerFail = error => ({
type: SERVER_FETCH_FAIL,
error,
});
export const fetchServerTranslationLanguages = () => (dispatch) => {
dispatch(fetchServerTranslationLanguagesRequest());
api()
.get('/api/v1/instance/translation_languages').then(({ data }) => {
dispatch(fetchServerTranslationLanguagesSuccess(data));
}).catch(err => dispatch(fetchServerTranslationLanguagesFail(err)));
};
const fetchServerTranslationLanguagesRequest = () => ({
type: SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST,
});
const fetchServerTranslationLanguagesSuccess = translationLanguages => ({
type: SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS,
translationLanguages,
});
const fetchServerTranslationLanguagesFail = error => ({
type: SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL,
error,
});
export const fetchExtendedDescription = () => (dispatch, getState) => {
if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) {
return;
}
dispatch(fetchExtendedDescriptionRequest());
api()
.get('/api/v1/instance/extended_description')
.then(({ data }) => dispatch(fetchExtendedDescriptionSuccess(data)))
.catch(err => dispatch(fetchExtendedDescriptionFail(err)));
};
const fetchExtendedDescriptionRequest = () => ({
type: EXTENDED_DESCRIPTION_REQUEST,
});
const fetchExtendedDescriptionSuccess = description => ({
type: EXTENDED_DESCRIPTION_SUCCESS,
description,
});
const fetchExtendedDescriptionFail = error => ({
type: EXTENDED_DESCRIPTION_FAIL,
error,
});
export const fetchDomainBlocks = () => (dispatch, getState) => {
if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) {
return;
}
dispatch(fetchDomainBlocksRequest());
api()
.get('/api/v1/instance/domain_blocks')
.then(({ data }) => dispatch(fetchDomainBlocksSuccess(true, data)))
.catch(err => {
if (err.response.status === 404) {
dispatch(fetchDomainBlocksSuccess(false, []));
} else {
dispatch(fetchDomainBlocksFail(err));
}
});
};
const fetchDomainBlocksRequest = () => ({
type: SERVER_DOMAIN_BLOCKS_FETCH_REQUEST,
});
const fetchDomainBlocksSuccess = (isAvailable, blocks) => ({
type: SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS,
isAvailable,
blocks,
});
const fetchDomainBlocksFail = error => ({
type: SERVER_DOMAIN_BLOCKS_FETCH_FAIL,
error,
});

View File

@@ -0,0 +1,34 @@
import {
apiGetInstance,
apiGetExtendedDescription,
apiGetDomainBlocks,
apiGetTranslationLanguages,
} from 'mastodon/api/instance';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { importFetchedAccount } from './importer';
export const fetchServer = createDataLoadingThunk(
'server/fetch',
() => apiGetInstance(),
(instance, { dispatch }) => {
if (instance.contact.account) {
dispatch(importFetchedAccount(instance.contact.account));
}
},
);
export const fetchExtendedDescription = createDataLoadingThunk(
'server/extended_description',
() => apiGetExtendedDescription(),
);
export const fetchServerTranslationLanguages = createDataLoadingThunk(
'server/translation_languages',
() => apiGetTranslationLanguages(),
);
export const fetchDomainBlocks = createDataLoadingThunk(
'server/domain_blocks',
() => apiGetDomainBlocks(),
);

View File

@@ -111,7 +111,7 @@ export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) {
export function redraft(status, raw_text, quoted_status_id = null) {
return (dispatch, getState) => {
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
const maxOptions = getState().server.server.item?.configuration.polls.max_options;
dispatch({
type: REDRAFT,

View File

@@ -2,6 +2,10 @@ import { apiRequestGet } from 'mastodon/api';
import type {
ApiTermsOfServiceJSON,
ApiPrivacyPolicyJSON,
ApiInstanceJSON,
ApiExtendedDescriptionJSON,
ApiTranslationLanguagesJSON,
ApiDomainBlockJSON,
} from 'mastodon/api_types/instance';
export const apiGetTermsOfService = (version?: string) =>
@@ -13,3 +17,17 @@ export const apiGetTermsOfService = (version?: string) =>
export const apiGetPrivacyPolicy = () =>
apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy');
export const apiGetInstance = () =>
apiRequestGet<ApiInstanceJSON>('v2/instance');
export const apiGetExtendedDescription = () =>
apiRequestGet<ApiExtendedDescriptionJSON>('v1/instance/extended_description');
export const apiGetTranslationLanguages = () =>
apiRequestGet<ApiTranslationLanguagesJSON>(
'v1/instance/translation_languages',
);
export const apiGetDomainBlocks = () =>
apiRequestGet<ApiDomainBlockJSON[]>('v1/instance/domain_blocks');

View File

@@ -1,3 +1,5 @@
import type { ApiAccountJSON } from './accounts';
export interface ApiTermsOfServiceJSON {
effective_date: string;
effective: boolean;
@@ -9,3 +11,136 @@ export interface ApiPrivacyPolicyJSON {
updated_at: string;
content: string;
}
interface ApiBaseRuleJSON {
text: string;
hint: string;
}
export interface ApiRuleJSON {
id: string;
text: string;
hint: string;
translations?: Record<string, ApiBaseRuleJSON>;
}
export interface ApiExtendedDescriptionJSON {
updated_at: string;
content: string;
}
export interface ApiDomainBlockJSON {
domain: string;
digest: string;
severity: string;
comment: string;
}
export type ApiTranslationLanguagesJSON = Record<string, string[]>;
export interface ApiInstanceJSON {
domain: string;
title: string;
version: string;
source_url: string;
description: string;
languages: string[];
usage: {
users: {
active_month: number;
};
};
thumbnail: {
url: string;
blurhash?: string;
description: string;
versions?: Record<string, string>;
};
contact: {
email: string | null;
account: ApiAccountJSON | null;
};
api_versions: {
mastodon: number;
};
registrations: {
enabled: boolean;
approval_required: boolean;
reason_required: boolean | null;
message: string | null;
min_age: string | null;
url: string | null;
};
rules: ApiRuleJSON[];
configuration: {
urls: {
streaming: string;
status: string | null;
about: string;
privacy_policy: string | null;
terms_of_service: string | null;
};
vapid: {
public_key: string;
};
accounts: {
max_display_name_length: number;
max_note_length: number;
max_avatar_description_length: number;
max_header_description_length: number;
max_featured_tags: number;
max_pinned_statuses: number;
max_profile_fields: number;
profile_field_name_limit: number;
profile_field_value_limit: number;
};
statuses: {
max_characters: number;
max_media_attachments: number;
characters_reserved_per_url: number;
};
media_attachments: {
description_limit: number;
image_matrix_limit: number;
image_size_limit: number;
supported_mime_types: string[];
video_frame_rate_limit: number;
video_matrix_limit: number;
video_size_limit: number;
};
polls: {
max_options: number;
max_characters_per_option: number;
min_expiration: number;
max_expiration: number;
};
translation: {
enabled: boolean;
};
timeline_access: {
live_feeds: {
local: string;
remote: string;
};
hashtag_feeds: {
local: string;
remote: string;
};
trending_link_feeds: {
local: string;
remote: string;
};
};
limited_federation: boolean;
};
}

View File

@@ -1,35 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<AutosuggestEmoji /> > renders emoji with custom url 1`] = `
<div
className="autosuggest-emoji"
>
<img
alt="foobar"
className="emojione"
src="http://example.com/emoji.png"
/>
<div
className="autosuggest-emoji__name"
>
:foobar:
</div>
</div>
`;
exports[`<AutosuggestEmoji /> > renders native emoji 1`] = `
<div
className="autosuggest-emoji"
>
<img
alt="💙"
className="emojione"
src="/emoji/1f499.svg"
/>
<div
className="autosuggest-emoji__name"
>
:foobar:
</div>
</div>
`;

View File

@@ -1,29 +0,0 @@
import renderer from 'react-test-renderer';
import AutosuggestEmoji from '../autosuggest_emoji';
describe('<AutosuggestEmoji />', () => {
it('renders native emoji', () => {
const emoji = {
native: '💙',
colons: ':foobar:',
};
const component = renderer.create(<AutosuggestEmoji emoji={emoji} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders emoji with custom url', () => {
const emoji = {
custom: true,
imageUrl: 'http://example.com/emoji.png',
native: 'foobar',
colons: ':foobar:',
};
const component = renderer.create(<AutosuggestEmoji emoji={emoji} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,140 @@
import { useCallback } from 'react';
import type { FC, ReactElement, ReactNode } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import {
authorizeFollowRequest,
rejectFollowRequest,
} from '@/mastodon/actions/accounts';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import type { Account } from '@/mastodon/models/account';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { AvatarOverlay } from '../avatar_overlay';
import { Button } from '../button';
import { DisplayName } from '../display_name';
import { Icon } from '../icon';
import classes from './styles.module.scss';
export const AccountBanners: FC<{ account: Account }> = ({ account }) => {
const { suspended, hidden } = useAccountVisibility(account.id);
const relationship = useRelationship(account.id);
if (hidden) {
return null;
}
let banner: ReactNode = null;
if (account.memorial) {
banner = (
<MessageText>
<FormattedMessage
id='account.in_memoriam'
defaultMessage='In Memoriam.'
/>
</MessageText>
);
}
if (account.moved) {
banner = <MovedNote account={account} targetAccountId={account.moved} />;
}
if (!suspended && relationship?.requested_by) {
banner = <FollowRequestNote account={account} />;
}
if (!banner) {
return null;
}
return <div className={classes.bannerWrapper}>{banner}</div>;
};
const FollowRequestNote: FC<{ account: Account }> = ({ account }) => {
const accountId = account.id;
const dispatch = useAppDispatch();
const handleAuthorize = useCallback(() => {
dispatch(authorizeFollowRequest(accountId));
}, [accountId, dispatch]);
const handleReject = useCallback(() => {
dispatch(rejectFollowRequest(accountId));
}, [accountId, dispatch]);
return (
<>
<MessageText>
<FormattedMessage
id='account.requested_follow'
defaultMessage='{name} has requested to follow you'
values={{ name: <DisplayName account={account} variant='simple' /> }}
/>
</MessageText>
<div className={classes.bannerActions}>
<Button secondary onClick={handleAuthorize}>
<Icon id='check' icon={CheckIcon} />
<FormattedMessage
id='follow_request.authorize'
defaultMessage='Authorize'
/>
</Button>
<Button secondary onClick={handleReject}>
<Icon id='times' icon={CloseIcon} />
<FormattedMessage
id='follow_request.reject'
defaultMessage='Reject'
/>
</Button>
</div>
</>
);
};
const MovedNote: React.FC<{
account: Account;
targetAccountId: string;
}> = ({ account: from, targetAccountId }) => {
const to = useAppSelector((state) => state.accounts.get(targetAccountId));
return (
<>
<MessageText>
<FormattedMessage
id='account.moved_to'
defaultMessage='{name} has indicated that their new account is now:'
values={{
name: <DisplayName account={from} variant='simple' />,
}}
/>
</MessageText>
<div className={classes.bannerActions}>
<Link to={`/@${to?.acct}`} className={classes.bannerActionsDisplayName}>
<AvatarOverlay account={to} friend={from} />
<DisplayName account={to} />
</Link>
<Link to={`/@${to?.acct}`} className='button'>
<FormattedMessage
id='account.go_to_profile'
defaultMessage='Go to profile'
/>
</Link>
</div>
</>
);
};
const MessageText: React.FC<{ children: ReactElement }> = ({ children }) => (
<div className={classes.bannerText}>{children}</div>
);

View File

@@ -3,8 +3,6 @@ import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { followAccount } from '@/mastodon/actions/accounts';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { getAccountHidden } from '@/mastodon/selectors/accounts';
@@ -18,6 +16,7 @@ import { FollowButton } from '../follow_button';
import { IconButton } from '../icon_button';
import { AccountMenu } from './menu';
import classes from './styles.module.scss';
const messages = defineMessages({
enableNotifications: {
@@ -49,7 +48,7 @@ export const AccountButtons: FC<AccountButtonsProps> = ({
const me = useAppSelector((state) => state.meta.get('me') as string);
return (
<div className={classNames('account__header__buttons', className)}>
<div className={className}>
{!hidden && (
<AccountButtonsOther accountId={accountId} noShare={noShare} />
)}
@@ -94,7 +93,7 @@ const AccountButtonsOther: FC<
{!isMovedAndUnfollowedAccount && (
<FollowButton
accountId={accountId}
className='account__header__follow-button'
className={classes.followButton}
labelLength='long'
/>
)}

View File

@@ -108,11 +108,9 @@ const FieldCard: FC<{
}> = ({ htmlHandlers, field }) => {
const intl = useIntl();
const {
name,
name_emojified,
nameHasEmojis,
value_emojified,
value_plain,
valueHasEmojis,
verified_at,
} = field;
@@ -138,8 +136,7 @@ const FieldCard: FC<{
)}
label={
<FieldHTML
text={name}
textEmojified={name_emojified}
text={name_emojified}
textHasCustomEmoji={nameHasEmojis}
className='translate'
isOverflowing={isLabelOverflowing}
@@ -149,8 +146,7 @@ const FieldCard: FC<{
}
value={
<FieldHTML
text={value_plain}
textEmojified={value_emojified}
text={value_emojified}
textHasCustomEmoji={valueHasEmojis}
isOverflowing={isValueOverflowing}
onOverflowClick={handleOverflowClick}
@@ -175,7 +171,6 @@ const FieldCard: FC<{
type FieldHTMLProps = {
text: string;
textEmojified: string;
textHasCustomEmoji: boolean;
isOverflowing?: boolean;
onOverflowClick?: () => void;
@@ -183,9 +178,7 @@ type FieldHTMLProps = {
const FieldHTML: FC<FieldHTMLProps> = ({
className,
extraEmojis,
text,
textEmojified,
textHasCustomEmoji,
isOverflowing,
onOverflowClick,
@@ -198,7 +191,7 @@ const FieldHTML: FC<FieldHTMLProps> = ({
const html = (
<EmojiHTML
as='span'
htmlString={textEmojified}
htmlString={text}
className={className}
onElement={handleElement}
data-contents
@@ -324,9 +317,10 @@ function useColumnWrap() {
if (element) {
listRef.current = element;
observer.observe(element);
handleRecalculate();
}
},
[observer],
[handleRecalculate, observer],
);
return { wrapperRef: wrapperRefCallback };

View File

@@ -1,10 +1,10 @@
import { useCallback } from 'react';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { Helmet } from '@unhead/react/helmet';
import { openModal } from '@/mastodon/actions/modal';
import FollowRequestNoteContainer from '@/mastodon/features/account/containers/follow_request_note_container';
import { useLayout } from '@/mastodon/hooks/useLayout';
import { useVisibility } from '@/mastodon/hooks/useVisibility';
import {
@@ -21,10 +21,9 @@ import { Avatar } from '../avatar';
import { AnimateEmojiProvider } from '../emoji/context';
import { FamiliarFollowers } from '../familiar_followers';
import { AccountBanners } from './banners';
import { AccountButtons } from './buttons';
import { AccountHeaderFields } from './fields';
import { MemorialNote } from './memorial_note';
import { MovedNote } from './moved_note';
import { AccountName } from './name';
import { AccountNote } from './note';
import { AccountNumberFields } from './number_fields';
@@ -50,9 +49,6 @@ export const AccountHeader: React.FC<{
}> = ({ accountId, hideTabs }) => {
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
const handleOpenAvatar = useCallback(
@@ -96,22 +92,13 @@ export const AccountHeader: React.FC<{
const isMe = me && account.id === me;
return (
<div className='account-timeline__header'>
{!hidden && account.memorial && <MemorialNote />}
{!hidden && account.moved && (
<MovedNote accountId={account.id} targetAccountId={account.moved} />
)}
<div>
<AccountBanners account={account} />
<AnimateEmojiProvider
className={classNames('account__header', {
inactive: !!account.moved,
})}
className={classNames(!!account.moved && classes.moved)}
>
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
<FollowRequestNoteContainer account={account} />
)}
<div className={classNames('account__header__image', classes.header)}>
<div className={classes.header}>
{!suspendedOrHidden && (
<img
src={autoPlayGif ? account.header : account.header_static}
@@ -121,21 +108,16 @@ export const AccountHeader: React.FC<{
)}
</div>
<div className={classNames('account__header__bar', classes.barWrapper)}>
<div
className={classNames(
'account__header__tabs',
classes.avatarWrapper,
)}
>
<div className={classes.barWrapper}>
<div className={classes.avatarWrapper}>
<a
className='avatar'
href={account.avatar}
rel='noopener'
target='_blank'
onClick={handleOpenAvatar}
>
<Avatar
className={classes.avatar}
account={suspendedOrHidden ? undefined : account}
alt={account.avatar_description}
size={80}
@@ -143,12 +125,7 @@ export const AccountHeader: React.FC<{
</a>
</div>
<div
className={classNames(
'account__header__tabs__name',
classes.displayNameWrapper,
)}
>
<div className={classes.displayNameWrapper}>
<AccountName accountId={accountId} />
<AccountButtons
accountId={accountId}
@@ -168,23 +145,16 @@ export const AccountHeader: React.FC<{
)}
{!suspendedOrHidden && (
<div className='account__header__extra'>
<div className='account__header__bio'>
{me && account.id !== me && (
<AccountNote accountId={accountId} />
)}
<div className={classes.bioButtonsWrapper}>
{me && account.id !== me && <AccountNote accountId={accountId} />}
<AccountBio
showDropdown
accountId={accountId}
className={classNames(
'account__header__content',
classes.bio,
)}
/>
<AccountBio
showDropdown
accountId={accountId}
className={classes.bio}
/>
<AccountHeaderFields accountId={accountId} />
</div>
<AccountHeaderFields accountId={accountId} />
{!me && account.email_subscriptions && (
<AccountSubscriptionForm accountId={accountId} />

View File

@@ -1,12 +0,0 @@
import { FormattedMessage } from 'react-intl';
export const MemorialNote: React.FC = () => (
<div className='account-memorial-banner'>
<div className='account-memorial-banner__message'>
<FormattedMessage
id='account.in_memoriam'
defaultMessage='In Memoriam.'
/>
</div>
</div>
);

View File

@@ -21,6 +21,10 @@ import {
import { openModal } from '@/mastodon/actions/modal';
import { initMuteModal } from '@/mastodon/actions/mutes';
import { initReport } from '@/mastodon/actions/reports';
import {
canAccountBeAdded,
canAccountBeAddedByFollowers,
} from '@/mastodon/features/collections/utils';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useIdentity } from '@/mastodon/identity_context';
import type { Account } from '@/mastodon/models/account';
@@ -214,6 +218,10 @@ const redesignMessages = defineMessages({
id: 'account.menu.add_to_list',
defaultMessage: 'Add to list…',
},
addToCollection: {
id: 'account.menu.add_to_collection',
defaultMessage: 'Add to collection…',
},
openOriginalPage: {
id: 'account.menu.open_original_page',
defaultMessage: 'View on {domain}',
@@ -294,35 +302,57 @@ function getMenuItems({
return items;
}
// List and featuring options
// Add to list
if (relationship?.following) {
items.push(
{
text: intl.formatMessage(redesignMessages.addToList),
action: () => {
dispatch(
openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: account.id,
},
}),
);
},
items.push({
text: intl.formatMessage(redesignMessages.addToList),
action: () => {
dispatch(
openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: account.id,
},
}),
);
},
{
text: intl.formatMessage(
relationship.endorsed ? messages.unendorse : messages.endorse,
),
action: () => {
if (relationship.endorsed) {
dispatch(unpinAccount(account.id));
} else {
dispatch(pinAccount(account.id));
}
},
});
}
// Add to collection
if (
canAccountBeAdded(account) ||
(canAccountBeAddedByFollowers(account) && relationship?.following)
) {
items.push({
text: intl.formatMessage(redesignMessages.addToCollection),
action: () => {
dispatch(
openModal({
modalType: 'COLLECTION_ADDER',
modalProps: {
accountId: account.id,
},
}),
);
},
);
});
}
// Feature on profile
if (relationship?.following) {
items.push({
text: intl.formatMessage(
relationship.endorsed ? messages.unendorse : messages.endorse,
),
action: () => {
if (relationship.endorsed) {
dispatch(unpinAccount(account.id));
} else {
dispatch(pinAccount(account.id));
}
},
});
}
items.push(

View File

@@ -1,46 +0,0 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useAppSelector } from '@/mastodon/store';
import { AvatarOverlay } from '../avatar_overlay';
import { DisplayName } from '../display_name';
export const MovedNote: React.FC<{
accountId: string;
targetAccountId: string;
}> = ({ accountId, targetAccountId }) => {
const from = useAppSelector((state) => state.accounts.get(accountId));
const to = useAppSelector((state) => state.accounts.get(targetAccountId));
return (
<div className='moved-account-banner'>
<div className='moved-account-banner__message'>
<FormattedMessage
id='account.moved_to'
defaultMessage='{name} has indicated that their new account is now:'
values={{
name: <DisplayName account={from} variant='simple' />,
}}
/>
</div>
<div className='moved-account-banner__action'>
<Link to={`/@${to?.acct}`} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'>
<AvatarOverlay account={to} friend={from} />
</div>
<DisplayName account={to} />
</Link>
<Link to={`/@${to?.acct}`} className='button'>
<FormattedMessage
id='account.go_to_profile'
defaultMessage='Go to profile'
/>
</Link>
</div>
</div>
);
};

View File

@@ -11,6 +11,8 @@ import { FormattedDateWrapper } from '../formatted_date';
import { NumberFields, NumberFieldsItem } from '../number_fields';
import { ShortNumber } from '../short_number';
import classes from './styles.module.scss';
export const AccountNumberFields: FC<{ accountId: string }> = ({
accountId,
}) => {
@@ -33,7 +35,7 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
}
return (
<NumberFields>
<NumberFields className={classes.numberFields}>
<NumberFieldsItem
label={
<FormattedMessage id='account.followers' defaultMessage='Followers' />

View File

@@ -1,30 +1,85 @@
.moved {
opacity: 0.5;
}
// Account header
.header {
height: 120px;
overflow: hidden;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border-primary);
img {
object-fit: cover;
display: block;
width: 100%;
height: 100%;
margin: 0;
}
@container (width >= 500px) {
height: 160px;
}
.moved & {
filter: grayscale(100%);
}
}
// Wraps everything except the header image.
.barWrapper {
border-bottom: none;
padding-inline: 16px;
}
// Avatar
.avatarWrapper {
margin-top: -64px;
padding-top: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
overflow: hidden;
margin-inline-start: -2px; // aligns the pfp with content below
}
.avatar {
background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--avatar-border-radius);
.moved & {
filter: grayscale(100%);
}
}
.displayNameWrapper {
display: flex;
align-items: start;
gap: 16px;
margin-top: 16px;
margin-bottom: 16px;
h1 {
font-size: 17px;
line-height: 22px;
color: var(--color-text-primary);
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:global(.emojione) {
width: 22px;
height: 22px;
}
}
.nameWrapper {
flex-grow: 1;
min-width: 0;
overflow-wrap: break-word;
}
.name {
@@ -112,21 +167,6 @@
}
}
$button-breakpoint: 420px;
$button-fallback-breakpoint: $button-breakpoint + 55px;
.buttonsDesktop {
@container (width < #{$button-breakpoint}) {
display: none;
}
@supports (not (container-type: inline-size)) {
@media (max-width: #{$button-fallback-breakpoint}) {
display: none;
}
}
}
.handleCopy {
border: 1px solid var(--color-border-primary);
border-radius: 8px;
@@ -147,6 +187,45 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
}
}
$button-breakpoint: 420px;
$button-fallback-breakpoint: $button-breakpoint + 55px;
.buttonsDesktop,
.buttonsMobile {
display: flex;
align-items: center;
gap: 8px;
:global(.button) {
flex-shrink: 1;
white-space: nowrap;
min-width: 80px;
}
:global(.icon-button) {
border: 1px solid var(--color-border-primary);
border-radius: 4px;
box-sizing: content-box;
padding: 5px;
&:global(.copied) {
border-color: var(--color-text-success);
}
}
}
.buttonsDesktop {
@container (width < #{$button-breakpoint}) {
display: none;
}
@supports (not (container-type: inline-size)) {
@media (max-width: #{$button-fallback-breakpoint}) {
display: none;
}
}
}
.buttonsMobile {
position: sticky;
bottom: var(--mobile-bottom-nav-height);
@@ -186,8 +265,32 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
}
}
.numberFields {
@container (width >= #{$button-breakpoint}) {
--number-fields-gap: 40px;
}
}
.bio {
font-size: 15px;
color: var(--color-text-primary);
unicode-bidi: plaintext;
p {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
:any-link {
color: var(--color-text-status-links);
&:hover {
text-decoration: none;
}
}
}
.familiarFollowers {
@@ -355,13 +458,24 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
border-bottom: 1px solid var(--color-border-primary);
}
// Banners
.bannerWrapper,
.bannerBase {
box-sizing: border-box;
padding: 16px;
border-radius: 12px;
background: var(--color-bg-secondary);
display: flex;
flex-direction: column;
}
.bannerWrapper {
background: var(--color-bg-tertiary);
padding: 16px;
align-items: center;
}
.bannerBase {
border-radius: 12px;
background: var(--color-bg-secondary);
gap: 12px;
justify-content: center;
align-items: flex-start;
@@ -377,6 +491,13 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
}
}
.bannerText {
color: var(--color-text-secondary);
font-size: 14px;
font-weight: 500;
text-align: center;
}
.bannerTextAndActions {
display: flex;
flex-direction: column;
@@ -391,6 +512,19 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
}
}
.bannerActions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
width: 100%;
margin-top: 16px;
button {
width: 100%;
}
}
.bannerDisclaimer {
a {
color: inherit;
@@ -420,3 +554,39 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
background: var(--color-bg-primary);
}
}
.bannerActionsDisplayName {
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
line-height: 22px;
overflow: hidden;
text-decoration: none;
&:hover strong {
text-decoration: underline;
}
strong,
span {
display: block;
text-overflow: ellipsis;
overflow: hidden;
}
strong {
color: var(--color-text-primary);
}
}
// Buttons
.followButton {
flex-grow: 1;
}
.bioButtonsWrapper {
margin-top: 16px;
}

View File

@@ -104,8 +104,6 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
.then(() => {
setSubmitting(false);
setSubmitted(true);
return '';
})
.catch((err: unknown) => {
setSubmitting(false);
@@ -133,12 +131,11 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
className={classNames(classes.bannerBase, classes.bannerBaseCentered)}
>
<div className={classes.bannerTextAndActions}>
<h2>
<FormattedMessage
id='email_subscriptions.submitted.title'
defaultMessage='One more step'
/>
</h2>
<FormattedMessage
id='email_subscriptions.submitted.title'
defaultMessage='One more step'
tagName='h2'
/>
<FormattedMessage
id='email_subscriptions.submitted.lead'
defaultMessage='Check your inbox for an email to finish signing up for email updates.'
@@ -151,15 +148,14 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
return (
<form onSubmit={handleSubmit} className={classes.bannerBase} noValidate>
<div className={classes.bannerTextAndActions}>
<h2>
<FormattedMessage
id='email_subscriptions.form.title'
defaultMessage='Sign up for email updates from {name}'
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</h2>
<FormattedMessage
id='email_subscriptions.form.title'
defaultMessage='Sign up for email updates from {name}'
tagName='h2'
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</div>
<div className={classes.bannerInputButton}>

View File

@@ -90,7 +90,7 @@ export const AccountListItem: React.FC<Props> = ({
<ListItemLink
to={`/@${account.acct}`}
data-hover-card-account={accountId}
subtitle={handle}
subtitle={<span className={classes.handle}>{handle}</span>}
>
<DisplayNameSimple
account={account}

View File

@@ -1,7 +1,6 @@
.wrapper {
display: flex;
flex-direction: column;
align-items: start;
gap: 12px;
padding: 16px;
@@ -24,6 +23,13 @@
vertical-align: -4px;
}
.handle {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.button {
align-self: start;
}

View File

@@ -1,43 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { assetHost } from 'mastodon/utils/config';
import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light';
export default class AutosuggestEmoji extends PureComponent {
static propTypes = {
emoji: PropTypes.object.isRequired,
};
render () {
const { emoji } = this.props;
let url;
if (emoji.custom) {
url = emoji.imageUrl;
} else {
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
if (!mapping) {
return null;
}
url = `${assetHost}/emoji/${mapping.filename}.svg`;
}
return (
<div className='autosuggest-emoji'>
<img
className='emojione'
src={url}
alt={emoji.native || emoji.colons}
/>
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
</div>
);
}
}

View File

@@ -0,0 +1,20 @@
import type { FC } from 'react';
import { Emoji } from './emoji';
interface LegacyEmoji {
id: string;
custom?: boolean;
native?: string;
imageUrl?: string;
}
export const AutosuggestEmoji: FC<{ emoji: LegacyEmoji }> = ({ emoji }) => {
const colons = `:${emoji.id}:`;
return (
<div className='autosuggest-emoji'>
<Emoji code={emoji.native ?? colons} />
<div className='autosuggest-emoji__name'>{colons}</div>
</div>
);
};

View File

@@ -9,8 +9,9 @@ import Overlay from 'react-overlays/Overlay';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestEmoji } from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag';
import { LocalCustomEmojiProvider } from './emoji/context';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;
@@ -219,15 +220,17 @@ export default class AutosuggestInput extends ImmutablePureComponent {
spellCheck={spellCheck}
/>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
{suggestions.map(this.renderSuggestion)}
<LocalCustomEmojiProvider>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>
</div>
)}
</Overlay>
)}
</Overlay>
</LocalCustomEmojiProvider>
</div>
);
}

View File

@@ -10,8 +10,9 @@ import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestEmoji } from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag';
import { LocalCustomEmojiProvider } from './emoji/context';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
@@ -218,15 +219,17 @@ const AutosuggestTextarea = forwardRef(({
lang={lang}
/>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
{suggestions.map(renderSuggestion)}
<LocalCustomEmojiProvider>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
{suggestions.map(renderSuggestion)}
</div>
</div>
</div>
)}
</Overlay>
)}
</Overlay>
</LocalCustomEmojiProvider>
</div>
);
});

View File

@@ -74,6 +74,24 @@ export const Compact: Story = {
play: buttonTest,
};
export const CompactSecondary: Story = {
args: {
compact: true,
secondary: true,
children: 'Compact secondary button',
},
play: buttonTest,
};
export const CompactPlain: Story = {
args: {
compact: true,
plain: true,
children: 'Compact plain button',
},
play: buttonTest,
};
export const Dangerous: Story = {
args: {
dangerous: true,

View File

@@ -90,7 +90,7 @@ export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({
);
};
export const DropdownMenu = <Item = MenuItem>({
export const DropdownMenu = <Item = MenuItem,>({
items,
loading,
scrollable,

View File

@@ -1,4 +1,9 @@
import type { MouseEventHandler, PropsWithChildren } from 'react';
import type {
FC,
MouseEventHandler,
PropsWithChildren,
ReactNode,
} from 'react';
import {
createContext,
useCallback,
@@ -8,6 +13,7 @@ import {
} from 'react';
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis';
import { autoPlayGif } from '@/mastodon/initial_state';
import { polymorphicForwardRef } from '@/types/polymorphic';
import type {
@@ -103,3 +109,10 @@ export const CustomEmojiProvider = ({
</CustomEmojiContext.Provider>
);
};
export const LocalCustomEmojiProvider: FC<{ children: ReactNode }> = ({
children,
}) => {
const emojis = useCustomEmojis();
return <CustomEmojiProvider emojis={emojis}>{children}</CustomEmojiProvider>;
};

View File

@@ -35,6 +35,10 @@
color: var(--color-text-secondary);
text-wrap: pretty;
}
a {
color: var(--color-text-status-links);
}
}
[data-color-scheme='dark'] .defaultImage {

View File

@@ -3,7 +3,7 @@ import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Helmet } from '@unhead/react/helmet';
import StackTrace from 'stacktrace-js';

View File

@@ -48,10 +48,14 @@
.status {
// If there's no content, we need to compensate for the parent's
// flex gap to avoid extra spacing below the field.
// flex gap to avoid extra spacing below or next to the field.
&:empty {
margin-top: calc(-1 * var(--form-field-label-gap));
}
[data-input-placement^='inline'] &:empty {
margin-inline-start: calc(-1 * var(--form-field-label-gap));
}
}
.inputWrapper {

View File

@@ -24,7 +24,7 @@ export interface FieldStatus {
message?: string;
}
interface FieldWrapperProps {
export interface FieldWrapperProps {
label: ReactNode;
hint?: ReactNode;
required?: boolean;

View File

@@ -4,11 +4,17 @@ import { forwardRef } from 'react';
import classNames from 'classnames';
import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import type {
CommonFieldWrapperProps,
FieldWrapperProps,
} from './form_field_wrapper';
import classes from './select.module.scss';
interface Props
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
extends
ComponentPropsWithoutRef<'select'>,
CommonFieldWrapperProps,
Pick<FieldWrapperProps, 'inputPlacement'> {}
/**
* A simple form field for single-item selections.
@@ -19,13 +25,28 @@ interface Props
*/
export const SelectField = forwardRef<HTMLSelectElement, Props>(
({ id, label, hint, required, status, children, ...otherProps }, ref) => (
(
{
id,
label,
hint,
required,
status,
inputPlacement,
children,
wrapperClassName,
...otherProps
},
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
inputPlacement={inputPlacement}
className={wrapperClassName}
>
{(inputProps) => (
<Select {...otherProps} {...inputProps} ref={ref}>

View File

@@ -154,17 +154,19 @@ export const HoverCardAccount = forwardRef<
{(isMutual || isFollower) && (
<>
&middot;
{isMutual ? (
<FormattedMessage
id='account.mutual'
defaultMessage='You follow each other'
/>
) : (
<FormattedMessage
id='account.follows_you'
defaultMessage='Follows you'
/>
)}
<span>
{isMutual ? (
<FormattedMessage
id='account.mutual'
defaultMessage='You follow each other'
/>
) : (
<FormattedMessage
id='account.follows_you'
defaultMessage='Follows you'
/>
)}
</span>
</>
)}
</div>

View File

@@ -30,7 +30,7 @@ export const ListItemWrapper: React.FC<WrapperProps> = ({
return (
<div {...otherProps} className={classNames(classes.wrapper, className)}>
{icon}
<div>{children}</div>
<div className={classes.main}>{children}</div>
{sideContent && (
<span className={classes.sideContent}>{sideContent}</span>
)}
@@ -67,9 +67,14 @@ interface LinkProps
extends React.ComponentPropsWithoutRef<typeof Link>, ContentProps {}
export const ListItemLink = polymorphicForwardRef<'h3', LinkProps>(
({ as, subtitle, children, className, ...otherProps }, ref) => {
({ as, subtitle, subtitleId, children, className, ...otherProps }, ref) => {
return (
<ListItemContent ref={ref} as={as} subtitle={subtitle}>
<ListItemContent
ref={ref}
as={as}
subtitle={subtitle}
subtitleId={subtitleId}
>
<Link className={classNames(className, 'focusable')} {...otherProps}>
{children}
</Link>
@@ -82,9 +87,14 @@ interface ButtonProps
extends React.ComponentPropsWithoutRef<'button'>, ContentProps {}
export const ListItemButton = polymorphicForwardRef<'h3', ButtonProps>(
({ as, subtitle, children, className, ...otherProps }, ref) => {
({ as, subtitle, subtitleId, children, className, ...otherProps }, ref) => {
return (
<ListItemContent as={as} ref={ref} subtitle={subtitle}>
<ListItemContent
as={as}
ref={ref}
subtitle={subtitle}
subtitleId={subtitleId}
>
<button
type='button'
className={classNames(className, 'focusable')}

View File

@@ -14,6 +14,11 @@
color: var(--color-text-primary);
}
.main {
min-width: 0;
overflow-wrap: break-word;
}
.title {
font-weight: 500;

View File

@@ -1,9 +1,11 @@
.list {
--_item-gap: var(--number-fields-gap, 24px);
display: flex;
flex-wrap: wrap;
margin: 0;
padding: 0;
gap: 4px 24px;
gap: 4px var(--_item-gap);
font-size: 13px;
color: var(--color-text-secondary);
}

View File

@@ -22,7 +22,7 @@ const messages = defineMessages({
});
const mapStateToProps = state => ({
server: state.getIn(['server', 'server']),
server: state.server.server,
});
class ServerBanner extends PureComponent {
@@ -40,7 +40,7 @@ class ServerBanner extends PureComponent {
render () {
const { server, intl } = this.props;
const isLoading = server.get('isLoading');
const isLoading = server.isLoading;
return (
<div className='server-banner'>
@@ -50,8 +50,8 @@ class ServerBanner extends PureComponent {
<NavLink to='/about'>
<ServerHeroImage
blurhash={server.getIn(['thumbnail', 'blurhash'])}
src={server.getIn(['thumbnail', 'url'])}
blurhash={server.item?.thumbnail.blurhash}
src={server.item?.thumbnail.url}
alt={intl.formatMessage(messages.aboutThisServer)}
className='server-banner__hero'
/>
@@ -66,14 +66,14 @@ class ServerBanner extends PureComponent {
<br />
<Skeleton width='70%' />
</>
) : server.get('description')}
) : server.item?.description}
</div>
<div className='server-banner__meta'>
<div className='server-banner__meta__column'>
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal />
<Account id={server.item?.contact.account?.id} size={36} minimal />
</div>
<div className='server-banner__meta__column'>
@@ -87,7 +87,7 @@ class ServerBanner extends PureComponent {
</>
) : (
<>
<strong className='server-banner__number'><ShortNumber value={server.getIn(['usage', 'users', 'active_month'])} /></strong>
<strong className='server-banner__number'><ShortNumber value={server.item?.usage.users.active_month} /></strong>
<br />
<span className='server-banner__number-label' title={intl.formatMessage(messages.aboutActiveUsers)}><FormattedMessage id='server_banner.active_users' defaultMessage='active users' /></span>
</>

View File

@@ -32,6 +32,7 @@ import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';
import { StatusThreadLabel } from './status_thread_label';
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
import { compareUrls } from '../utils/compare_urls';
const domParser = new DOMParser();
@@ -555,7 +556,7 @@ class Status extends ImmutablePureComponent {
).find((item) => compareUrls(item.get('url'), cardUrl));
if (taggedCollection) {
media = <CollectionPreviewCard collection={taggedCollection} />;
media = <CollectionPreviewCard collection={taggedCollection.toJS()} />;
} else {
media = (
<Card
@@ -565,7 +566,7 @@ class Status extends ImmutablePureComponent {
/>
);
}
} else if (status.get('tagged_collections').size) {
} else if (status.get('tagged_collections').size && !status.get('quote')) {
const firstLinkedCollection = status.get('tagged_collections').first();
if (firstLinkedCollection) {
media = (

View File

@@ -4,7 +4,6 @@ import type { ComponentProps, FC } from 'react';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
import { getCollectionPath } from '@/mastodon/features/collections/utils';
import type { OnElementHandler } from '@/mastodon/utils/html';
@@ -15,7 +14,7 @@ export interface HandledLinkProps {
prevText?: string;
hashtagAccountId?: string;
mention?: Pick<ApiMentionJSON, 'id' | 'acct'>;
collection?: Pick<ApiCollectionJSON, 'id'>;
collectionId?: string;
}
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
@@ -24,7 +23,7 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
prevText,
hashtagAccountId,
mention,
collection,
collectionId,
className,
children,
...props
@@ -61,11 +60,11 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
{children}
</Link>
);
} else if (collection) {
} else if (collectionId) {
return (
<Link
className={classNames(className)}
to={getCollectionPath(collection.id)}
to={getCollectionPath(collectionId)}
>
{children}
</Link>
@@ -98,15 +97,18 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
export const useElementHandledLink = ({
hashtagAccountId,
hrefToCollectionId: hrefToCollection,
hrefToMention,
}: {
hashtagAccountId?: string;
hrefToCollectionId?: (href: string) => string | undefined;
hrefToMention?: (href: string) => ApiMentionJSON | undefined;
} = {}) => {
const onElement = useCallback<OnElementHandler>(
(element, { key, ...props }, children) => {
if (element instanceof HTMLAnchorElement) {
const mention = hrefToMention?.(element.href);
const collectionId = hrefToCollection?.(element.href);
return (
<HandledLink
{...props}
@@ -116,6 +118,7 @@ export const useElementHandledLink = ({
prevText={element.previousSibling?.textContent ?? undefined}
hashtagAccountId={hashtagAccountId}
mention={mention}
collectionId={collectionId}
>
{children}
</HandledLink>
@@ -123,7 +126,7 @@ export const useElementHandledLink = ({
}
return undefined;
},
[hashtagAccountId, hrefToMention],
[hashtagAccountId, hrefToCollection, hrefToMention],
);
return { onElement };
};

View File

@@ -69,7 +69,7 @@ class TranslateButton extends PureComponent {
}
const mapStateToProps = state => ({
languages: state.getIn(['server', 'translationLanguages', 'items']),
languages: state.server.translationLanguages.items,
});
class StatusContent extends PureComponent {
@@ -170,7 +170,7 @@ class StatusContent extends PureComponent {
text={element.innerText}
hashtagAccountId={this.props.status.getIn(['account', 'id'])}
mention={mention?.toJSON()}
collection={taggedCollection?.toJSON()}
collectionId={taggedCollection?.get('id')}
key={key}
>
{children}

View File

@@ -1,6 +1,6 @@
import { PureComponent } from 'react';
import { Helmet } from 'react-helmet';
import { Helmet } from '@unhead/react/helmet';
import { Route } from 'react-router-dom';
import { Provider as ReduxProvider } from 'react-redux';

View File

@@ -5,7 +5,6 @@ import type { IntlShape } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import type { List as ImmutableList } from 'immutable';
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
import { Select } from '@/mastodon/components/form_fields';
@@ -123,14 +122,13 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
};
const selectRules = (state: RootState) => {
const rules = state.server.getIn([
'server',
'rules',
]) as ImmutableList<Rule> | null;
const rules = state.server.server.item?.rules;
if (!rules) {
return [];
}
return rules.toJS() as Rule[];
return rules;
};
const rulesSelector = createSelector(

View File

@@ -3,7 +3,7 @@ import { PureComponent } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Helmet } from '@unhead/react/helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
@@ -41,10 +41,10 @@ const severityMessages = {
};
const mapStateToProps = state => ({
server: state.getIn(['server', 'server']),
server: state.server.server,
locale: state.getIn(['meta', 'locale']),
extendedDescription: state.getIn(['server', 'extendedDescription']),
domainBlocks: state.getIn(['server', 'domainBlocks']),
extendedDescription: state.server.extendedDescription,
domainBlocks: state.server.domainBlocks,
});
class About extends PureComponent {
@@ -76,7 +76,7 @@ class About extends PureComponent {
render () {
const { multiColumn, intl, server, extendedDescription, domainBlocks, locale } = this.props;
const isLoading = server.get('isLoading');
const isLoading = server.isLoading;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
@@ -84,13 +84,13 @@ class About extends PureComponent {
<div className='about__header'>
<ServerHeroImage
withAltBadge
alt={server.getIn(['thumbnail', 'description']) ?? ''}
blurhash={server.getIn(['thumbnail', 'blurhash'])}
src={server.getIn(['thumbnail', 'url'])}
srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')}
alt={server.item?.thumbnail.description ?? ''}
blurhash={server.item?.thumbnail.blurhash}
src={server.item?.thumbnail.url}
srcSet={Object.keys(server.item?.thumbnail.versions ?? {}).map((key) => `${server.item?.thumbnail.versions && server.item.thumbnail.versions[key]} ${key.replace('@', '')}`).join(', ')}
className='about__header__hero'
/>
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
<h1>{isLoading ? <Skeleton width='10ch' /> : server.domain}</h1>
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
</div>
@@ -98,7 +98,7 @@ class About extends PureComponent {
<div className='about__meta__column'>
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal />
<Account id={server.item?.contact?.account?.id} size={36} minimal />
</div>
<hr className='about__meta__divider' />
@@ -106,12 +106,12 @@ class About extends PureComponent {
<div className='about__meta__column'>
<h4><FormattedMessage id='about.contact' defaultMessage='Contact:' /></h4>
{isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={`mailto:${server.getIn(['contact', 'email'])}`}>{server.getIn(['contact', 'email'])}</a>}
{isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={`mailto:${server.item?.contact?.email}`}>{server.item?.contact?.email}</a>}
</div>
</div>
<Section open title={intl.formatMessage(messages.title)}>
{extendedDescription.get('isLoading') ? (
{extendedDescription.isLoading ? (
<>
<Skeleton width='100%' />
<br />
@@ -121,10 +121,10 @@ class About extends PureComponent {
<br />
<Skeleton width='70%' />
</>
) : (extendedDescription.get('content')?.length > 0 ? (
) : (extendedDescription.item?.content?.length > 0 ? (
<div
className='prose'
dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }}
dangerouslySetInnerHTML={{ __html: extendedDescription.item?.content }}
/>
) : (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
@@ -134,26 +134,26 @@ class About extends PureComponent {
<RulesSection />
<Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
{domainBlocks.get('isLoading') ? (
{domainBlocks.isLoading ? (
<>
<Skeleton width='100%' />
<br />
<Skeleton width='70%' />
</>
) : (domainBlocks.get('isAvailable') ? (
) : (domainBlocks.isAvailable ? (
<>
<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.items.length > 0 && (
<div className='about__domain-blocks'>
{domainBlocks.get('items').map(block => (
<div className='about__domain-blocks__domain' key={block.get('domain')}>
{domainBlocks.items.map(block => (
<div className='about__domain-blocks__domain' key={block.domain}>
<div className='about__domain-blocks__domain__header'>
<h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span>
<h6><span title={`SHA-256: ${block.digest}`}>{block.domain}</span></h6>
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.severity].explanation)}>{intl.formatMessage(severityMessages[block.severity].title)}</span>
</div>
<p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
<p>{(block.comment ?? '').length > 0 ? block.comment : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
</div>
))}
</div>

View File

@@ -1,131 +0,0 @@
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
import { useState, useRef, useCallback, useId } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import Textarea from 'react-textarea-autosize';
import { submitAccountNote } from '@/mastodon/actions/account_notes';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
const messages = defineMessages({
placeholder: {
id: 'account_note.placeholder',
defaultMessage: 'Click to add a note',
},
});
const AccountNoteUI: React.FC<{
initialValue: string | undefined;
onSubmit: (newNote: string) => void;
wasSaved: boolean;
}> = ({ initialValue, onSubmit, wasSaved }) => {
const intl = useIntl();
const uniqueId = useId();
const [value, setValue] = useState(initialValue ?? '');
const isLoading = initialValue === undefined;
const canSubmitOnBlurRef = useRef(true);
const handleChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
(e) => {
setValue(e.target.value);
},
[],
);
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setValue(initialValue ?? '');
canSubmitOnBlurRef.current = false;
e.currentTarget.blur();
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onSubmit(value);
canSubmitOnBlurRef.current = false;
e.currentTarget.blur();
}
},
[initialValue, onSubmit, value],
);
const handleBlur = useCallback(() => {
if (initialValue !== value && canSubmitOnBlurRef.current) {
onSubmit(value);
}
canSubmitOnBlurRef.current = true;
}, [initialValue, onSubmit, value]);
return (
<div className='account__header__account-note'>
<label htmlFor={`account-note-${uniqueId}`}>
<FormattedMessage
id='account.account_note_header'
defaultMessage='Personal note'
/>{' '}
<span
aria-live='polite'
role='status'
className='inline-alert'
style={{ opacity: wasSaved ? 1 : 0 }}
>
{wasSaved && (
<FormattedMessage id='generic.saved' defaultMessage='Saved' />
)}
</span>
</label>
{isLoading ? (
<div className='account__header__account-note__loading-indicator-wrapper'>
<LoadingIndicator />
</div>
) : (
<Textarea
id={`account-note-${uniqueId}`}
className='account__header__account-note__content'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
)}
</div>
);
};
export const AccountNote: React.FC<{
accountId: string;
}> = ({ accountId }) => {
const dispatch = useAppDispatch();
const initialValue = useAppSelector((state) =>
state.relationships.get(accountId)?.get('note'),
);
const [wasSaved, setWasSaved] = useState(false);
const handleSubmit = useCallback(
(note: string) => {
setWasSaved(true);
void dispatch(submitAccountNote({ accountId, note }));
setTimeout(() => {
setWasSaved(false);
}, 2000);
},
[dispatch, accountId],
);
return (
<AccountNoteUI
key={`${accountId}-${initialValue}`}
initialValue={initialValue}
wasSaved={wasSaved}
onSubmit={handleSubmit}
/>
);
};

View File

@@ -1,210 +0,0 @@
import type { ReactNode } from 'react';
import { useState, useRef, useCallback, useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BadgeIcon from '@/material-icons/400-24px/badge.svg?react';
import GlobeIcon from '@/material-icons/400-24px/globe.svg?react';
import { Icon } from 'mastodon/components/icon';
export const DomainPill: React.FC<{
domain: string;
username: string;
isSelf: boolean;
children?: ReactNode;
className?: string;
}> = ({ domain, username, isSelf, children, className }) => {
const accessibilityId = useId();
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const triggerRef = useRef(null);
const handleClick = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
const handleExpandClick = useCallback(() => {
setExpanded(!expanded);
}, [expanded, setExpanded]);
return (
<>
<button
className={classNames('account__domain-pill', className, {
active: open,
})}
ref={triggerRef}
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
type='button'
>
{children ?? domain}
</button>
<Overlay
show={open}
rootClose
onHide={handleClick}
offset={[5, 5]}
target={triggerRef}
>
{({ props }) => (
<div
{...props}
role='region'
id={accessibilityId}
className='account__domain-pill__popout dropdown-animation'
>
<div className='account__domain-pill__popout__header'>
<div className='account__domain-pill__popout__header__icon'>
<Icon id='' icon={BadgeIcon} />
</div>
<h3>
<FormattedMessage
id='domain_pill.whats_in_a_handle'
defaultMessage="What's in a handle?"
/>
</h3>
</div>
<div className='account__domain-pill__popout__handle'>
<div className='account__domain-pill__popout__handle__label'>
{isSelf ? (
<FormattedMessage
id='domain_pill.your_handle'
defaultMessage='Your handle:'
/>
) : (
<FormattedMessage
id='domain_pill.their_handle'
defaultMessage='Their handle:'
/>
)}
</div>
<div className='account__domain-pill__popout__handle__handle'>
@{username}@{domain}
</div>
</div>
<div className='account__domain-pill__popout__parts'>
<div>
<div className='account__domain-pill__popout__parts__icon'>
<Icon id='' icon={AlternateEmailIcon} />
</div>
<div>
<h6>
<FormattedMessage
id='domain_pill.username'
defaultMessage='Username'
/>
</h6>
<p>
{isSelf ? (
<FormattedMessage
id='domain_pill.your_username'
defaultMessage='Your unique identifier on this server. Its possible to find users with the same username on different servers.'
/>
) : (
<FormattedMessage
id='domain_pill.their_username'
defaultMessage='Their unique identifier on their server. Its possible to find users with the same username on different servers.'
/>
)}
</p>
</div>
</div>
<div>
<div className='account__domain-pill__popout__parts__icon'>
<Icon id='' icon={GlobeIcon} />
</div>
<div>
<h6>
<FormattedMessage
id='domain_pill.server'
defaultMessage='Server'
/>
</h6>
<p>
{isSelf ? (
<FormattedMessage
id='domain_pill.your_server'
defaultMessage='Your digital home, where all of your posts live. Dont like this one? Transfer servers at any time and bring your followers, too.'
/>
) : (
<FormattedMessage
id='domain_pill.their_server'
defaultMessage='Their digital home, where all of their posts live.'
/>
)}
</p>
</div>
</div>
</div>
<p>
{isSelf ? (
<FormattedMessage
id='domain_pill.who_you_are'
defaultMessage='Because your handle says who you are and where you are, people can interact with you across the social web of <button>ActivityPub-powered platforms</button>.'
values={{
button: (x) => (
<button
onClick={handleExpandClick}
className='link-button'
type='button'
>
{x}
</button>
),
}}
/>
) : (
<FormattedMessage
id='domain_pill.who_they_are'
defaultMessage='Since handles say who someone is and where they are, you can interact with people across the social web of <button>ActivityPub-powered platforms</button>.'
values={{
button: (x) => (
<button
onClick={handleExpandClick}
className='link-button'
type='button'
>
{x}
</button>
),
}}
/>
)}
</p>
{expanded && (
<>
<p>
<FormattedMessage
id='domain_pill.activitypub_like_language'
defaultMessage='ActivityPub is like the language Mastodon speaks with other social networks.'
/>
</p>
<p>
<FormattedMessage
id='domain_pill.activitypub_lets_connect'
defaultMessage='It lets you connect and interact with people not just on Mastodon, but across different social apps too.'
/>
</p>
</>
)}
</div>
)}
</Overlay>
</>
);
};

View File

@@ -1,41 +0,0 @@
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Icon } from 'mastodon/components/icon';
import { DisplayName } from '@/mastodon/components/display_name';
export default class FollowRequestNote extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
};
render () {
const { account, onAuthorize, onReject } = this.props;
return (
<div className='follow-request-banner'>
<div className='follow-request-banner__message'>
<FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <DisplayName account={account} variant='simple' /> }} />
</div>
<div className='follow-request-banner__action'>
<button type='button' className='button button-secondary button--confirmation' onClick={onAuthorize}>
<Icon id='check' icon={CheckIcon} />
<FormattedMessage id='follow_request.authorize' defaultMessage='Authorize' />
</button>
<button type='button' className='button button-secondary button--destructive' onClick={onReject}>
<Icon id='times' icon={CloseIcon} />
<FormattedMessage id='follow_request.reject' defaultMessage='Reject' />
</button>
</div>
</div>
);
}
}

View File

@@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { authorizeFollowRequest, rejectFollowRequest } from 'mastodon/actions/accounts';
import FollowRequestNote from '../components/follow_request_note';
const mapDispatchToProps = (dispatch, { account }) => ({
onAuthorize () {
dispatch(authorizeFollowRequest(account.get('id')));
},
onReject () {
dispatch(rejectFollowRequest(account.get('id')));
},
});
export default connect(null, mapDispatchToProps)(FollowRequestNote);

View File

@@ -2,9 +2,10 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import { Column } from '@/mastodon/components/column';
import { ColumnHeader } from '@/mastodon/components/column_header';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';

View File

@@ -37,10 +37,7 @@ const selectTags = createAppSelector(
[
(state) => state.profileEdit,
(state) =>
state.server.getIn(
['server', 'accounts', 'max_featured_tags'],
10,
) as number,
state.server.server.item?.configuration.accounts.max_featured_tags ?? 0,
],
(profileEdit, maxTags) => ({
tags: profileEdit.profile?.featuredTags ?? [],

View File

@@ -133,12 +133,7 @@ export const AccountEdit: FC = () => {
const maxFieldCount = useAppSelector(
(state) =>
(state.server.getIn([
'server',
'configuration',
'accounts',
'max_profile_fields',
]) as number | undefined) ?? 4,
state.server.server.item?.configuration.accounts.max_profile_fields ?? 4,
);
const handleOpenModal = useCallback(

View File

@@ -36,13 +36,7 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
);
const [newBio, setNewBio] = useState(bio ?? '');
const maxLength = useAppSelector(
(state) =>
state.server.getIn([
'server',
'configuration',
'accounts',
'max_note_length',
]) as number | undefined,
(state) => state.server.server.item?.configuration.accounts.max_note_length,
);
const dispatch = useAppDispatch();

View File

@@ -9,8 +9,6 @@ import type { FC, FocusEventHandler } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable';
import { closeModal } from '@/mastodon/actions/modal';
import { Button } from '@/mastodon/components/button';
import type { FieldStatus } from '@/mastodon/components/form_fields';
@@ -97,15 +95,10 @@ const messages = defineMessages({
// We have two different values- the hard limit set by the server,
// and the soft limit for mobile display.
const selectFieldLimits = createAppSelector(
[
(state) =>
state.server.getIn(['server', 'configuration', 'accounts']) as
| ImmutableMap<string, number>
| undefined,
],
[(state) => state.server.server.item?.configuration.accounts],
(accounts) => ({
nameLimit: accounts?.get('profile_field_name_limit'),
valueLimit: accounts?.get('profile_field_value_limit'),
nameLimit: accounts?.profile_field_name_limit,
valueLimit: accounts?.profile_field_value_limit,
}),
);

View File

@@ -84,15 +84,8 @@ export const ImageAltTextField: FC<{
}> = ({ imageSrc, altText, onChange, hideTip }) => {
const altLimit = useAppSelector(
(state) =>
state.server.getIn(
[
'server',
'configuration',
'accounts',
'max_header_description_length',
],
150,
) as number,
state.server.server.item?.configuration.accounts
.max_header_description_length ?? 0,
);
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(

View File

@@ -33,12 +33,7 @@ export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
);
const maxLength = useAppSelector(
(state) =>
state.server.getIn([
'server',
'configuration',
'accounts',
'max_display_name_length',
]) as number | undefined,
state.server.server.item?.configuration.accounts.max_display_name_length,
);
const [newName, setNewName] = useState(displayName ?? '');

View File

@@ -2,14 +2,15 @@ import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { openModal } from '@/mastodon/actions/modal';
import { Button } from '@/mastodon/components/button';
import { DisplayName } from '@/mastodon/components/display_name';
import { EmptyState } from '@/mastodon/components/empty_state';
import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint';
import { areCollectionsEnabled } from '@/mastodon/features/collections/utils';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { useAppDispatch } from '@/mastodon/store';
@@ -28,8 +29,8 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
blockedBy,
withoutAddCollectionButton,
}) => {
const { acct } = useParams<{ acct?: string }>();
const me = useCurrentAccountId();
const account = useAccount(accountId);
const dispatch = useAppDispatch();
@@ -116,12 +117,12 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
/>
);
} else {
if (acct) {
if (account) {
title = (
<FormattedMessage
id='empty_column.account_featured.other'
defaultMessage='{acct} has not featured anything yet.'
values={{ acct }}
values={{ acct: <DisplayName variant='simple' account={account} /> }}
/>
);
} else {

View File

@@ -24,6 +24,7 @@ import Column from '@/mastodon/features/ui/components/column';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { me } from '@/mastodon/initial_state';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
@@ -173,12 +174,14 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
defaultMessage='Collections'
/>
</h2>
<SubheadingLink to='/collections/new' icon={AddIcon}>
<FormattedMessage
id='account.featured.new_collection'
defaultMessage='New collection'
/>
</SubheadingLink>
{accountId === me && (
<SubheadingLink to='/collections/new' icon={AddIcon}>
<FormattedMessage
id='account.featured.new_collection'
defaultMessage='New collection'
/>
</SubheadingLink>
)}
</Subheading>
{hasCollections ? (
<ItemList>

View File

@@ -25,6 +25,7 @@ export function usePinnedStatusIds({
tagged,
pinned: true,
replies: true,
boosts: true,
});
const dispatch = useAppDispatch();

View File

@@ -31,7 +31,7 @@ const selectServerName = createAppSelector(
[
(state) => state.accounts,
(_, accountId: string) => accountId,
(state) => state.server.getIn(['server', 'domain']) as string | undefined,
(state) => state.server.server.item?.domain,
],
(accounts, accountId, serverDomain) => {
const acct = accounts.getIn([accountId, 'acct']) as string | undefined;

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Helmet } from '@unhead/react/helmet';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import {

View File

@@ -0,0 +1,12 @@
.wrapper {
--list-item-gap: 16px;
--list-item-padding-block: 12px;
&:not(:last-child) {
border-bottom: 1px solid var(--color-border-primary);
}
&:has(input:disabled) {
color: var(--color-text-secondary);
}
}

View File

@@ -0,0 +1,72 @@
import { useId } from 'react';
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
import { Toggle } from '@/mastodon/components/form_fields';
import {
ListItemContent,
ListItemWrapper,
} from '@/mastodon/components/list_item';
import {
AvatarGrid,
CollectionInfo,
} from 'mastodon/features/collections/components/collection_lockup';
import classes from './collection_toggle.module.scss';
export interface CollectionToggleProps {
collection: ApiCollectionJSON;
checked: boolean;
disabled?: boolean;
loading?: boolean;
subtitle?: React.ReactNode;
onChange: React.ChangeEventHandler<HTMLInputElement>;
}
export const CollectionToggle: React.FC<CollectionToggleProps> = ({
collection,
checked,
disabled,
subtitle,
onChange,
}) => {
const uniqueId = useId();
const toggleId = `${uniqueId}-toggle`;
const infoId = `${uniqueId}-info`;
return (
<ListItemWrapper
className={classes.wrapper}
icon={
<AvatarGrid
accountIds={collection.items.map((item) => item.account_id)}
sensitive={collection.sensitive}
/>
}
sideContent={
<Toggle
id={toggleId}
checked={checked}
disabled={disabled}
onChange={onChange}
aria-describedby={infoId}
/>
}
>
<ListItemContent
as='label'
htmlFor={toggleId}
subtitle={
subtitle ?? (
<CollectionInfo
collection={collection}
withTimestamp={false}
withAuthorHandle={false}
/>
)
}
>
{collection.name}
</ListItemContent>
</ListItemWrapper>
);
};

View File

@@ -0,0 +1,143 @@
import { useCallback, useId, useState } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import type { Account } from '@/mastodon/models/account';
import {
addCollectionItem,
removeCollectionItem,
} from '@/mastodon/reducers/slices/collections';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { MAX_COLLECTION_ACCOUNT_COUNT } from '../collections/editor/accounts';
import { useCollectionsCreatedBy } from '../collections/overview/created_by_you';
import { CollectionToggle } from './collection_toggle';
const messages = defineMessages({
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const ListItem: React.FC<{
collection: ApiCollectionJSON;
account: Account;
}> = ({ collection, account }) => {
const dispatch = useAppDispatch();
const [isUpdating, setIsUpdating] = useState(false);
const accountItemInCollection = collection.items.find(
(item) => item.account_id === account.id,
);
const isAccountInCollection = !!accountItemInCollection;
const addOrRemove = useCallback(
async (shouldAdd: boolean) => {
setIsUpdating(true);
if (shouldAdd) {
await dispatch(
addCollectionItem({
collectionId: collection.id,
accountId: account.id,
}),
);
} else if (accountItemInCollection) {
await dispatch(
removeCollectionItem({
collectionId: collection.id,
itemId: accountItemInCollection.id,
}),
);
}
setIsUpdating(false);
},
[account.id, collection.id, accountItemInCollection, dispatch],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
void addOrRemove(e.target.checked);
},
[addOrRemove],
);
const hasMaxItemCount =
!isAccountInCollection &&
collection.item_count >= MAX_COLLECTION_ACCOUNT_COUNT;
return (
<CollectionToggle
key={collection.id}
collection={collection}
disabled={isUpdating || hasMaxItemCount}
subtitle={
hasMaxItemCount ? (
<FormattedMessage
id='collections.search_accounts_max_reached'
defaultMessage='You have added the maximum number of accounts'
/>
) : null
}
checked={isAccountInCollection}
onChange={handleChange}
/>
);
};
export const CollectionAdder: React.FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const intl = useIntl();
const titleId = useId();
const account = useAppSelector((state) => state.accounts.get(accountId));
const currentAccountId = useCurrentAccountId();
const { collections, status } = useCollectionsCreatedBy(currentAccountId);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<span className='dialog-modal__header__title' id={titleId}>
<FormattedMessage
id='collections.add_to_collection'
defaultMessage='Add {name} to collections'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div
className='lists-scrollable'
role='group'
aria-labelledby={titleId}
>
{status === 'loading' || !account ? (
<LoadingIndicator />
) : (
collections.map((item) => (
<ListItem key={item.id} collection={item} account={account} />
))
)}
</div>
</div>
</div>
);
};

View File

@@ -57,10 +57,45 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
className,
}) => {
const { id, name } = collection;
return (
<ListItemWrapper
className={classNames(classes.wrapper, className)}
icon={
<AvatarGrid
accountIds={collection.items.map((item) => item.account_id)}
sensitive={collection.sensitive}
/>
}
sideContent={sideContent}
>
<ListItemLink
as='h3'
to={getCollectionPath(id)}
subtitle={
<CollectionInfo
collection={collection}
withAuthorHandle={withAuthorHandle}
withTimestamp={withTimestamp}
/>
}
>
{name}
</ListItemLink>
</ListItemWrapper>
);
};
export const CollectionInfo: React.FC<
Pick<
CollectionLockupProps,
'collection' | 'withAuthorHandle' | 'withTimestamp'
>
> = ({ collection, withAuthorHandle, withTimestamp }) => {
const authorAccount = useAccount(collection.account_id);
const authorHandle = useAccountHandle(authorAccount, domain);
const collectionInfo = (
return (
<ul>
{collection.sensitive && (
<li className='sr-only'>
@@ -98,25 +133,4 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
)}
</ul>
);
return (
<ListItemWrapper
className={classNames(classes.wrapper, className)}
icon={
<AvatarGrid
accountIds={collection.items.map((item) => item.account_id)}
sensitive={collection.sensitive}
/>
}
sideContent={sideContent}
>
<ListItemLink
as='h3'
to={getCollectionPath(id)}
subtitle={collectionInfo}
>
{name}
</ListItemLink>
</ListItemWrapper>
);
};

View File

@@ -131,7 +131,7 @@ export const CollectionMenu: React.FC<{
{
text: intl.formatMessage(messages.copyLink),
action: () => {
void navigator.clipboard.writeText(getCollectionPath(id));
void navigator.clipboard.writeText(collection.url);
dispatch(showAlert({ message: messages.copyLinkConfirmation }));
},
},
@@ -196,13 +196,14 @@ export const CollectionMenu: React.FC<{
id,
openShareModal,
isOwnCollection,
collection.url,
dispatch,
openDeleteConfirmation,
context,
currentAccountInCollection,
openReportModal,
openBlockModal,
openRevokeConfirmation,
openBlockModal,
]);
return (

View File

@@ -24,11 +24,13 @@ import classes from './share_modal.module.scss';
const messages = defineMessages({
shareTextOwn: {
id: 'collection.share_template_own',
defaultMessage: 'Check out my new collection: {link}',
defaultMessage: 'Check out my new collection:',
description: 'Collection links are appended after a new line',
},
shareTextOther: {
id: 'collection.share_template_other',
defaultMessage: 'Check out this cool collection: {link}',
defaultMessage: 'Check out this cool collection:',
description: 'Collection links are appended after a new line',
},
});
@@ -42,7 +44,7 @@ export const CollectionShareModal: React.FC<{
const isNew = !!location.state?.newCollection;
const isOwnCollection = collection.account_id === me;
const collectionLink = `${window.location.origin}/collections/${collection.id}`;
const collectionLink = collection.url;
const handleShareOnDevice = useCallback(() => {
void navigator.share({
@@ -51,13 +53,10 @@ export const CollectionShareModal: React.FC<{
}, [collectionLink]);
const handleShareViaPost = useCallback(() => {
const shareMessage = isOwnCollection
? intl.formatMessage(messages.shareTextOwn, {
link: collectionLink,
})
: intl.formatMessage(messages.shareTextOther, {
link: collectionLink,
});
let shareMessage = isOwnCollection
? intl.formatMessage(messages.shareTextOwn)
: intl.formatMessage(messages.shareTextOther);
shareMessage += `\n\n${collectionLink}`;
onClose();
dispatch(changeCompose(shareMessage));

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