Compare commits

...

178 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
Eugen Rochko
ee88da4511 Add admin UI for managing email subscriptions (#38741)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2026-05-04 13:56:04 +00:00
Juan José Martos
46ccfa6e8d Updating rollup and flatted dependencies [SECURITY] (#38497) 2026-05-04 13:09:09 +00:00
renovate[bot]
5922d0181e Update formatjs monorepo (#38804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 12:29:58 +00:00
Claire
030104a30c Change how invalid-but-not-expired invites are shown in moderation interface (#38736) 2026-05-04 11:38:11 +00:00
Jeong Arm
ff99131776 Fix unblocking domain from blocked domains column does not update the list (#38882) 2026-05-04 11:37:56 +00:00
Echo
a7001f52ab Wraps content in Callout component (#38893) 2026-05-04 10:18:43 +00:00
Echo
708fe31908 Keep trying to load emojis if data isn't available yet (#38892) 2026-05-04 09:50:24 +00:00
Gomasy
00c2089e81 Fix emoji picker not rendering when no custom emojis (#38885) 2026-05-04 09:09:17 +00:00
Matt Jankowski
696aaa616b Update rubocop-capybara to version 2.23.0 (#38868) 2026-05-04 07:52:29 +00:00
github-actions[bot]
e4b8bbe6e8 New Crowdin Translations (automated) (#38875)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-04 07:49:54 +00:00
renovate[bot]
aa6baf15aa Update dependency jsdom to v29.1.1 (#38816)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 07:28:56 +00:00
renovate[bot]
ea52f76314 Update opentelemetry-ruby (non-major) (#38834)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 07:28:52 +00:00
renovate[bot]
b44aa94853 Update dependency playwright-ruby-client to v1.59.1 (#38848)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 07:28:46 +00:00
github-actions[bot]
c6facd27ed New Crowdin Translations (automated) (#38871)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-04-30 16:44:20 +00:00
Echo
c79bd31234 Change handle explainer to refer to the Fediverse (#38872) 2026-04-30 16:32:48 +00:00
Claire
322ada898f Update dependency minimatch (#38869) 2026-04-30 16:21:38 +00:00
Echo
d63ca75422 Fixes minor profile visual glitches (#38870) 2026-04-30 16:17:14 +00:00
Matt Jankowski
59f3d8a993 Handle IPv6 scenario in custom Request::Socket (#38866) 2026-04-30 16:03:55 +00:00
Echo
c270634565 Profile editing: Control follower/following list visibility (#38845) 2026-04-30 14:16:30 +00:00
Matt Jankowski
b1703467f1 Use bundler version 4.0.11 (#38867) 2026-04-30 14:16:15 +00:00
diondiondion
b076808fd2 Add "Featuring you" tab to Collections page (#38865) 2026-04-30 14:10:25 +00:00
Echo
945ac23910 Remove and move profile code (#38863) 2026-04-30 13:58:22 +00:00
Matt Jankowski
3021cd8002 Update puma to version 8.0.1 (#38738) 2026-04-30 13:28:15 +00:00
renovate[bot]
a8c261ae7c Update dependency aws-sdk-s3 to v1.220.0 (#38788)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 13:22:03 +00:00
renovate[bot]
d4e7af910c Update dependency vite to v8.0.10 (#38748)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 12:59:56 +00:00
github-actions[bot]
20e3265f3b New Crowdin Translations (automated) (#38864)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-04-30 12:32:25 +00:00
renovate[bot]
88b21e587c Update dependency irb to v1.18.0 (#38773)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 09:53:20 +00:00
renovate[bot]
c18db97254 Update dependency stoplight to v5.8.2 (#38752)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 09:52:13 +00:00
renovate[bot]
d4e60dae9a Update codecov/codecov-action digest to 75cd116 (#38747)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 09:25:22 +00:00
renovate[bot]
8456616793 Update actions/cache digest to 27d5ce7 (#38746)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 09:24:55 +00:00
renovate[bot]
9c5ef8f3f6 Update dependency aws-sdk-core to v3.246.0 (#38742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 09:23:40 +00:00
renovate[bot]
5288abfb03 Update dependency axios to v1.15.2 (#38613)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 09:22:31 +00:00
renovate[bot]
6dbad32d65 Update actions/setup-node digest to 48b55a0 (#38483)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 09:21:23 +00:00
Claire
1df259f8c9 Fix translation string of some fallback notifications (#38860) 2026-04-30 09:18:32 +00:00
github-actions[bot]
deb72a4c91 New Crowdin Translations (automated) (#38859)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-04-30 08:21:13 +00:00
Echo
a47ed31047 Fixes custom emoji not appearing in autocomplete (#38854)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 19:44:42 +00:00
Claire
5b395774c0 Add fallback attributes to notifications for new and infrequent notifications (#38832) 2026-04-29 15:53:29 +00:00
diondiondion
afeb63d287 Improve collection page loading states (#38847) 2026-04-29 15:49:52 +00:00
David Roetzel
725d8983fa Fix client-side collection routes (#38850) 2026-04-29 14:52:32 +00:00
diondiondion
b761310823 Fix stale collections list after deleting a collection (#38852) 2026-04-29 14:42:23 +00:00
github-actions[bot]
578836f9ae New Crowdin Translations (automated) (#38842)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-04-29 13:47:29 +00:00
diondiondion
41a3679d83 Mark pending accounts in collection editor (#38843) 2026-04-29 10:35:09 +00:00
diondiondion
614eda43ff Add date & correct icon to "You are in this collection" callout (#38844) 2026-04-29 10:35:07 +00:00
diondiondion
b193913f46 Mark pending accounts on the collection detail page (#38830) 2026-04-28 16:31:18 +00:00
Nicholas La Roux
eb5bfa4541 Upgrade development Ruby from 4.0.2 to 4.0.3 (#38820) 2026-04-28 15:39:41 +00:00
diondiondion
31f89617d8 Fix error when collection is null in collection notification (#38831) 2026-04-28 15:25:17 +00:00
615 changed files with 13022 additions and 8907 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

@@ -9,21 +9,21 @@ runs:
using: 'composite'
steps:
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: '.nvmrc'
# 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
shell: bash
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}

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

@@ -43,7 +43,7 @@ jobs:
onlyProduction: 'true'
- name: Cache assets from compilation
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
public/assets
@@ -151,7 +151,7 @@ jobs:
bin/flatware fan bin/rails db:test:prepare
- name: Cache RSpec persistence file
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
tmp/rspec/examples.txt
@@ -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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
with:
files: coverage/lcov/*.lcov
env:
@@ -247,7 +247,7 @@ jobs:
- name: Cache Playwright Chromium browser
id: playwright-cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('yarn.lock') }}

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 \

13
Gemfile
View File

@@ -4,7 +4,7 @@ source 'https://rubygems.org'
ruby '>= 3.3.0', '< 4.1.0'
gem 'propshaft'
gem 'puma', '~> 7.0'
gem 'puma'
gem 'rails', '~> 8.1.0'
gem 'thor', '~> 1.2'
@@ -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,11 +102,11 @@ 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-instrumentation-active_job', '~> 0.11.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
gem 'opentelemetry-instrumentation-excon', '~> 0.29.0', require: false
@@ -115,7 +116,7 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-net_http', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.36.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.31.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.41.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.42.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.29.0', require: false
gem 'opentelemetry-sdk', '~> 1.4', require: false
@@ -134,7 +135,7 @@ group :test do
# Browser integration testing
gem 'capybara', '~> 3.39'
gem 'capybara-playwright-driver'
gem 'playwright-ruby-client', '1.59.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package
gem 'playwright-ruby-client', '1.59.1', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package
# Used to reset the database between system tests
gem 'database_cleaner-active_record'

View File

@@ -99,8 +99,8 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1238.0)
aws-sdk-core (3.244.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.123.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.219.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.23.0)
bootsnap (1.24.4)
msgpack (~> 1.2)
brakeman (8.0.4)
racc
@@ -178,7 +178,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
css_parser (2.0.0)
css_parser (2.1.0)
addressable
csv (3.3.5)
database_cleaner-active_record (2.2.2)
@@ -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)
@@ -226,7 +226,7 @@ GEM
elasticsearch-dsl (0.1.10)
email_validator (2.2.4)
activemodel
erb (6.0.3)
erb (6.0.4)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@@ -264,7 +264,7 @@ GEM
excon (~> 1.0)
formatador (>= 0.2, < 2.0)
mime-types
fog-json (1.2.0)
fog-json (1.3.0)
fog-core
multi_json (~> 1.10)
fog-openstack (1.1.5)
@@ -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)
@@ -314,7 +314,7 @@ GEM
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.5.0)
http-cookie (1.1.4)
http-cookie (1.1.6)
domain_name (~> 0.5)
http-form_data (2.3.0)
http_accept_language (2.1.1)
@@ -343,7 +343,8 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.8.2)
irb (1.17.0)
ipaddr (1.2.9)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.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)
@@ -458,7 +457,7 @@ GEM
mutex_m (0.3.0)
net-http (0.6.0)
uri
net-imap (0.6.3)
net-imap (0.6.4)
date
net-protocol
net-ldap (0.20.0)
@@ -471,7 +470,7 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.2)
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
omniauth (2.1.4)
@@ -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)
@@ -524,21 +523,21 @@ GEM
opentelemetry-helpers-sql-processor (0.5.0)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.7.0)
opentelemetry-instrumentation-action_mailer (0.8.0)
opentelemetry-instrumentation-active_support (~> 0.10)
opentelemetry-instrumentation-action_pack (0.17.0)
opentelemetry-instrumentation-action_pack (0.18.0)
opentelemetry-instrumentation-rack (~> 0.29)
opentelemetry-instrumentation-action_view (0.12.0)
opentelemetry-instrumentation-action_view (0.13.0)
opentelemetry-instrumentation-active_support (~> 0.10)
opentelemetry-instrumentation-active_job (0.11.0)
opentelemetry-instrumentation-active_job (0.12.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-active_model_serializers (0.25.0)
opentelemetry-instrumentation-active_support (>= 0.7.0)
opentelemetry-instrumentation-active_record (0.12.0)
opentelemetry-instrumentation-active_record (0.13.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-active_storage (0.4.0)
opentelemetry-instrumentation-active_storage (0.5.0)
opentelemetry-instrumentation-active_support (~> 0.10)
opentelemetry-instrumentation-active_support (0.11.0)
opentelemetry-instrumentation-active_support (0.12.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-base (0.26.0)
opentelemetry-api (~> 1.7)
@@ -546,7 +545,7 @@ GEM
opentelemetry-registry (~> 0.1)
opentelemetry-instrumentation-concurrent_ruby (0.25.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-excon (0.29.0)
opentelemetry-instrumentation-excon (0.29.1)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-faraday (0.33.0)
opentelemetry-instrumentation-base (~> 0.25)
@@ -562,7 +561,7 @@ GEM
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-rack (0.31.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-rails (0.41.0)
opentelemetry-instrumentation-rails (0.42.0)
opentelemetry-instrumentation-action_mailer (~> 0.7)
opentelemetry-instrumentation-action_pack (~> 0.17)
opentelemetry-instrumentation-action_view (~> 0.12)
@@ -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)
@@ -599,7 +598,7 @@ GEM
pg (1.6.3)
pghero (3.8.0)
activerecord (>= 7.2)
playwright-ruby-client (1.59.0)
playwright-ruby-client (1.59.1)
base64
concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0)
@@ -625,7 +624,7 @@ GEM
date
stringio
public_suffix (7.0.5)
puma (7.2.0)
puma (8.0.1)
nio4r (~> 2.0)
pundit (2.5.2)
activesupport (>= 3.0.0)
@@ -691,7 +690,7 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.4.1)
rake (13.4.2)
rdf (3.3.4)
bcp47_spec (~> 0.2)
bigdecimal (~> 3.1, >= 3.1.5)
@@ -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)
@@ -770,9 +769,9 @@ GEM
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-capybara (2.22.1)
rubocop-capybara (2.23.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop (~> 1.81)
rubocop-i18n (3.3.0)
lint_roller (~> 1.1)
rubocop (>= 1.72.1)
@@ -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)
@@ -848,11 +847,11 @@ GEM
stackprof (0.2.28)
starry (0.2.0)
base64
stoplight (5.8.0)
stoplight (5.8.2)
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,9 +1020,9 @@ 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-instrumentation-active_job (~> 0.11.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)
opentelemetry-instrumentation-excon (~> 0.29.0)
@@ -1032,7 +1032,7 @@ DEPENDENCIES
opentelemetry-instrumentation-net_http (~> 0.29.0)
opentelemetry-instrumentation-pg (~> 0.36.0)
opentelemetry-instrumentation-rack (~> 0.31.0)
opentelemetry-instrumentation-rails (~> 0.41.0)
opentelemetry-instrumentation-rails (~> 0.42.0)
opentelemetry-instrumentation-redis (~> 0.29.0)
opentelemetry-instrumentation-sidekiq (~> 0.29.0)
opentelemetry-sdk (~> 1.4)
@@ -1040,12 +1040,12 @@ DEPENDENCIES
parslet
pg (~> 1.5)
pghero
playwright-ruby-client (= 1.59.0)
playwright-ruby-client (= 1.59.1)
premailer-rails
prometheus_exporter (~> 2.2)
propshaft
public_suffix (~> 7.0)
puma (~> 7.0)
puma
pundit (~> 2.3)
rack-attack (~> 6.6)
rack-cors
@@ -1097,7 +1097,7 @@ DEPENDENCIES
xorcist (~> 1.1)
RUBY VERSION
ruby 4.0.2
ruby 4.0.5
BUNDLED WITH
4.0.10
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

@@ -0,0 +1,9 @@
# frozen_string_literal: true
class Admin::EmailSubscriptions::AdditionalFooterTextsController < Admin::SettingsController
private
def after_update_redirect_path
admin_email_subscriptions_path
end
end

View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true
class Admin::EmailSubscriptions::SetupsController < Admin::BaseController
before_action :require_enabled!
def show
authorize :email_subscription, :enable?
@form = Form::EmailSubscriptionsConfirmation.new
end
def create
authorize :email_subscription, :enable?
@form = Form::EmailSubscriptionsConfirmation.new(resource_params)
if @form.valid?
Setting.email_subscriptions = true
redirect_to admin_email_subscriptions_path
else
render :show
end
end
private
def require_enabled!
raise ActionController::RoutingError, 'Feature disabled' unless Rails.application.config.x.email_subscriptions
end
def resource_params
params.expect(form_email_subscriptions_confirmation: [:agreement_email_volume, :agreement_privacy_and_terms])
end
end

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
class Admin::EmailSubscriptionsController < Admin::BaseController
def index
authorize :email_subscription, :index?
@enabled = Setting.email_subscriptions
@roles = UserRole.where('permissions & ? != 0', UserRole::FLAGS[:manage_email_subscriptions] | UserRole::FLAGS[:administrator])
@accounts = Account.local.where.associated(:email_subscriptions).includes(:user)
end
def disable
authorize :email_subscription, :disable?
Setting.email_subscriptions = false
redirect_to admin_email_subscriptions_path, notice: I18n.t('admin.email_subscriptions.disabled_msg')
end
def purge
authorize :email_subscription, :purge?
Admin::EmailSubscriptionsPurgeWorker.perform_async
redirect_to admin_email_subscriptions_path, notice: I18n.t('admin.email_subscriptions.purged_msg')
end
end

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

@@ -19,7 +19,7 @@ class Api::V1::Accounts::EmailSubscriptionsController < Api::BaseController
end
def require_feature_enabled!
head 404 unless Mastodon::Feature.email_subscriptions_enabled?
head 404 unless Rails.application.config.x.email_subscriptions && Setting.email_subscriptions
end
def require_account_permissions!

View File

@@ -16,7 +16,7 @@ class Api::V1::NotificationsController < Api::BaseController
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
end
render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships
render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships, supported_notification_types: params[:supported_types]
end
def unread_count
@@ -29,7 +29,7 @@ class Api::V1::NotificationsController < Api::BaseController
def show
@notification = current_account.notifications.without_suspended.find(params[:id])
render json: @notification, serializer: REST::NotificationSerializer
render json: @notification, serializer: REST::NotificationSerializer, supported_notification_types: params[:supported_types]
end
def clear
@@ -89,6 +89,8 @@ class Api::V1::NotificationsController < Api::BaseController
end
def pagination_params(core_params)
params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered).permit(:limit, :account_id, :include_filtered, types: [], exclude_types: []).merge(core_params)
params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered, :supported_types)
.permit(:limit, :account_id, :include_filtered, types: [], exclude_types: [], supported_types: [])
.merge(core_params)
end
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

@@ -33,7 +33,7 @@ class Api::V2::NotificationsController < Api::BaseController
'app.notification_grouping.expand_accounts_param' => expand_accounts_param
)
render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param
render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param, supported_notification_types: params[:supported_types]
end
end
@@ -48,7 +48,7 @@ class Api::V2::NotificationsController < Api::BaseController
def show
@notification = current_account.notifications.without_suspended.by_group_key(params[:group_key]).take!
presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification]))
render json: presenter, serializer: REST::DedupNotificationGroupSerializer
render json: presenter, serializer: REST::DedupNotificationGroupSerializer, supported_notification_types: params[:supported_types]
end
def clear
@@ -138,7 +138,9 @@ class Api::V2::NotificationsController < Api::BaseController
end
def pagination_params(core_params)
params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types).permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: []).merge(core_params)
params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types, :supported_types)
.permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: [], supported_types: [])
.merge(core_params)
end
def expand_accounts_param

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

@@ -127,6 +127,10 @@ module ApplicationHelper
)
end
def emptyphaunt
inline_svg_tag 'elephant_ui.svg'
end
def check_icon
inline_svg_tag 'check.svg'
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

@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M13.2502 8.00029C13.2502 5.10089 10.8996 2.75044 8.00024 2.75029C5.10075 2.75029 2.75024 5.10079 2.75024 8.00029C2.7504 10.8997 5.10084 13.2503 8.00024 13.2503C10.8995 13.2501 13.2501 10.8996 13.2502 8.00029ZM7.41663 4.50029C7.41663 4.17812 7.67808 3.91667 8.00024 3.91667C8.32228 3.91683 8.58301 4.17822 8.58301 4.50029V7.63884L10.5945 8.64458C10.8825 8.78865 10.999 9.13922 10.8551 9.42729C10.711 9.71541 10.3605 9.83276 10.0724 9.68877L7.73877 8.52153C7.54143 8.4227 7.41673 8.221 7.41663 8.00029V4.50029ZM14.4166 8.00029C14.4165 11.5439 11.5438 14.4165 8.00024 14.4167C4.45651 14.4167 1.58316 11.544 1.58301 8.00029C1.58301 4.45646 4.45642 1.58305 8.00024 1.58305C11.5439 1.58321 14.4166 4.45656 14.4166 8.00029Z" fill="black"/>
<svg width="16" height="16" viewBox="0 0 16 16" fill="black">
<path d="M13.2502 8.00029C13.2502 5.10089 10.8996 2.75044 8.00024 2.75029C5.10075 2.75029 2.75024 5.10079 2.75024 8.00029C2.7504 10.8997 5.10084 13.2503 8.00024 13.2503C10.8995 13.2501 13.2501 10.8996 13.2502 8.00029ZM7.41663 4.50029C7.41663 4.17812 7.67808 3.91667 8.00024 3.91667C8.32228 3.91683 8.58301 4.17822 8.58301 4.50029V7.63884L10.5945 8.64458C10.8825 8.78865 10.999 9.13922 10.8551 9.42729C10.711 9.71541 10.3605 9.83276 10.0724 9.68877L7.73877 8.52153C7.54143 8.4227 7.41673 8.221 7.41663 8.00029V4.50029ZM14.4166 8.00029C14.4165 11.5439 11.5438 14.4165 8.00024 14.4167C4.45651 14.4167 1.58316 11.544 1.58301 8.00029C1.58301 4.45646 4.45642 1.58305 8.00024 1.58305C11.5439 1.58321 14.4166 4.45656 14.4166 8.00029Z" />
</svg>

Before

Width:  |  Height:  |  Size: 810 B

After

Width:  |  Height:  |  Size: 799 B

View File

@@ -6,8 +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 { emojiMartSearch } from '@/mastodon/features/emoji/picker';
import { showAlert, showAlertForError } from './alerts';
import { useEmoji } from './emojis';
@@ -99,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,
@@ -563,7 +563,7 @@ export function clearComposeSuggestions() {
};
}
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => {
if (fetchComposeSuggestionsAccountsController) {
fetchComposeSuggestionsAccountsController.abort();
}
@@ -590,12 +590,14 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
});
}, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
// 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));
};
const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
const fetchComposeSuggestionsTags = throttle((dispatch, token) => {
if (fetchComposeSuggestionsTagsController) {
fetchComposeSuggestionsTagsController.abort();
}
@@ -629,14 +631,14 @@ export function fetchComposeSuggestions(token) {
return (dispatch, getState) => {
switch (token[0]) {
case ':':
fetchComposeSuggestionsEmojis(dispatch, getState, token);
void fetchComposeSuggestionsEmojis(dispatch, token);
break;
case '#':
case '':
fetchComposeSuggestionsTags(dispatch, getState, token);
fetchComposeSuggestionsTags(dispatch, token);
break;
default:
fetchComposeSuggestionsAccounts(dispatch, getState, token);
fetchComposeSuggestionsAccounts(dispatch, token);
break;
}
};
@@ -669,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

@@ -34,11 +34,16 @@ export const apiGetCollection = (collectionId: string) =>
`v1_alpha/collections/${collectionId}`,
);
export const apiGetAccountCollections = (accountId: string) =>
export const apiGetCollectionsCreatedByAccount = (accountId: string) =>
apiRequestGet<ApiCollectionsJSON>(
`v1_alpha/accounts/${accountId}/collections`,
);
export const apiGetCollectionsFeaturingAccount = (accountId: string) =>
apiRequestGet<ApiCollectionsJSON>(
`v1_alpha/accounts/${accountId}/in_collections`,
);
export const apiAddCollectionItem = (collectionId: string, accountId: string) =>
apiRequestPost<WrappedCollectionAccountItem>(
`v1_alpha/collections/${collectionId}/items`,

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

@@ -4,6 +4,10 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchRelationships } from '@/mastodon/actions/accounts';
import { useAccount } from '@/mastodon/hooks/useAccount';
import type { AccountRole } from '@/mastodon/models/account';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import {
AdminBadge,
AutomatedBadge,
@@ -11,10 +15,7 @@ import {
BlockedBadge,
GroupBadge,
MutedBadge,
} from '@/mastodon/components/badge';
import { useAccount } from '@/mastodon/hooks/useAccount';
import type { AccountRole } from '@/mastodon/models/account';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
} from '../badge';
import classes from './styles.module.scss';

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,12 +3,7 @@ import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { followAccount } from '@/mastodon/actions/accounts';
import { CopyIconButton } from '@/mastodon/components/copy_icon_button';
import { FollowButton } from '@/mastodon/components/follow_button';
import { IconButton } from '@/mastodon/components/icon_button';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { getAccountHidden } from '@/mastodon/selectors/accounts';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -16,7 +11,12 @@ import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { CopyIconButton } from '../copy_icon_button';
import { FollowButton } from '../follow_button';
import { IconButton } from '../icon_button';
import { AccountMenu } from './menu';
import classes from './styles.module.scss';
const messages = defineMessages({
enableNotifications: {
@@ -48,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} />
)}
@@ -93,7 +93,7 @@ const AccountButtonsOther: FC<
{!isMovedAndUnfollowedAccount && (
<FollowButton
accountId={accountId}
className='account__header__follow-button'
className={classes.followButton}
labelLength='long'
/>
)}

View File

@@ -7,21 +7,21 @@ import classNames from 'classnames';
import IconVerified from '@/images/icons/icon_verified.svg?react';
import { openModal } from '@/mastodon/actions/modal';
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
import type { EmojiHTMLProps } from '@/mastodon/components/emoji/html';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { Icon } from '@/mastodon/components/icon';
import { IconButton } from '@/mastodon/components/icon_button';
import { MiniCard } from '@/mastodon/components/mini_card';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import { useFieldHtml } from '@/mastodon/features/account_timeline/hooks/useFieldHtml';
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useResizeObserver } from '@/mastodon/hooks/useObserver';
import type { AccountFieldShape } from '@/mastodon/models/account';
import { useAppDispatch } from '@/mastodon/store';
import MoreIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { cleanExtraEmojis } from '../../emoji/normalize';
import type { AccountField } from '../common';
import { useFieldHtml } from '../hooks/useFieldHtml';
import { CustomEmojiProvider } from '../emoji/context';
import type { EmojiHTMLProps } from '../emoji/html';
import { EmojiHTML } from '../emoji/html';
import { Icon } from '../icon';
import { IconButton } from '../icon_button';
import { MiniCard } from '../mini_card';
import { useElementHandledLink } from '../status/handled_link';
import classes from './styles.module.scss';
@@ -37,6 +37,12 @@ const dateFormatOptions: Intl.DateTimeFormatOptions = {
minute: '2-digit',
};
export interface AccountField extends AccountFieldShape {
nameHasEmojis: boolean;
value_plain: string;
valueHasEmojis: boolean;
}
export const AccountHeaderFields: FC<{ accountId: string }> = ({
accountId,
}) => {
@@ -102,11 +108,9 @@ const FieldCard: FC<{
}> = ({ htmlHandlers, field }) => {
const intl = useIntl();
const {
name,
name_emojified,
nameHasEmojis,
value_emojified,
value_plain,
valueHasEmojis,
verified_at,
} = field;
@@ -132,8 +136,7 @@ const FieldCard: FC<{
)}
label={
<FieldHTML
text={name}
textEmojified={name_emojified}
text={name_emojified}
textHasCustomEmoji={nameHasEmojis}
className='translate'
isOverflowing={isLabelOverflowing}
@@ -143,8 +146,7 @@ const FieldCard: FC<{
}
value={
<FieldHTML
text={value_plain}
textEmojified={value_emojified}
text={value_emojified}
textHasCustomEmoji={valueHasEmojis}
isOverflowing={isValueOverflowing}
onOverflowClick={handleOverflowClick}
@@ -169,7 +171,6 @@ const FieldCard: FC<{
type FieldHTMLProps = {
text: string;
textEmojified: string;
textHasCustomEmoji: boolean;
isOverflowing?: boolean;
onOverflowClick?: () => void;
@@ -177,9 +178,7 @@ type FieldHTMLProps = {
const FieldHTML: FC<FieldHTMLProps> = ({
className,
extraEmojis,
text,
textEmojified,
textHasCustomEmoji,
isOverflowing,
onOverflowClick,
@@ -192,7 +191,7 @@ const FieldHTML: FC<FieldHTMLProps> = ({
const html = (
<EmojiHTML
as='span'
htmlString={textEmojified}
htmlString={text}
className={className}
onElement={handleElement}
data-contents
@@ -318,9 +317,10 @@ function useColumnWrap() {
if (element) {
listRef.current = element;
observer.observe(element);
handleRecalculate();
}
},
[observer],
[handleRecalculate, observer],
);
return { wrapperRef: wrapperRefCallback };

View File

@@ -1,13 +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 { AccountBio } from '@/mastodon/components/account_bio';
import { Avatar } from '@/mastodon/components/avatar';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import FollowRequestNoteContainer from '@/mastodon/features/account/containers/follow_request_note_container';
import { useLayout } from '@/mastodon/hooks/useLayout';
import { useVisibility } from '@/mastodon/hooks/useVisibility';
import {
@@ -19,17 +16,19 @@ import type { Account } from '@/mastodon/models/account';
import { getAccountHidden } from '@/mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import { FamiliarFollowers } from '../../../components/familiar_followers';
import { AccountBio } from '../account_bio';
import { Avatar } from '../avatar';
import { AnimateEmojiProvider } from '../emoji/context';
import { FamiliarFollowers } from '../familiar_followers';
import { AccountName } from './account_name';
import { AccountSubscriptionForm } from './account_subscription_form';
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';
import classes from './styles.module.scss';
import { AccountSubscriptionForm } from './subscription_form';
import { AccountTabs } from './tabs';
const titleFromAccount = (account: Account) => {
@@ -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

@@ -21,7 +21,10 @@ import {
import { openModal } from '@/mastodon/actions/modal';
import { initMuteModal } from '@/mastodon/actions/mutes';
import { initReport } from '@/mastodon/actions/reports';
import { Dropdown } from '@/mastodon/components/dropdown_menu';
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';
@@ -40,6 +43,8 @@ import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react'
import ReportIcon from '@/material-icons/400-24px/report.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { Dropdown } from '../dropdown_menu';
import classes from './styles.module.scss';
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
@@ -213,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}',
@@ -293,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

@@ -8,10 +8,6 @@ import classNames from 'classnames';
import Overlay from 'react-overlays/esm/Overlay';
import { showAlert } from '@/mastodon/actions/alerts';
import { FollowsYouBadge } from '@/mastodon/components/badge';
import { Button } from '@/mastodon/components/button';
import { DisplayName } from '@/mastodon/components/display_name';
import { Icon } from '@/mastodon/components/icon';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -20,6 +16,11 @@ import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
import HelpIcon from '@/material-icons/400-24px/help.svg?react';
import DomainIcon from '@/material-icons/400-24px/language.svg?react';
import { FollowsYouBadge } from '../badge';
import { Button } from '../button';
import { DisplayName } from '../display_name';
import { Icon } from '../icon';
import { AccountBadges } from './badges';
import classes from './styles.module.scss';
@@ -111,7 +112,6 @@ const AccountNameHelp: FC<{
aria-controls={accessibilityId}
>
{handle}
<Icon
id='help'
icon={HelpIcon}
@@ -178,7 +178,7 @@ const AccountNameHelp: FC<{
</ol>
<FormattedMessage
id='account.name.help.footer'
defaultMessage='Just like you can send emails to people using different email providers, you can interact with people on other Mastodon servers, and with anyone on other Mastodon-compatible social apps.'
defaultMessage='Just like you can send emails to people using different email providers, you can interact with people on other Mastodon servers, and with anyone on other Fediverse apps.'
tagName='p'
/>

View File

@@ -5,11 +5,12 @@ import { defineMessages, useIntl } from 'react-intl';
import { fetchRelationships } from '@/mastodon/actions/accounts';
import { openModal } from '@/mastodon/actions/modal';
import { Callout } from '@/mastodon/components/callout';
import { IconButton } from '@/mastodon/components/icon_button';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import EditIcon from '@/material-icons/400-24px/edit_square.svg?react';
import { Callout } from '../callout';
import { IconButton } from '../icon_button';
import classes from './styles.module.scss';
const messages = defineMessages({

View File

@@ -4,15 +4,15 @@ import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { openModal } from '@/mastodon/actions/modal';
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
import {
NumberFields,
NumberFieldsItem,
} from '@/mastodon/components/number_fields';
import { ShortNumber } from '@/mastodon/components/short_number';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAppDispatch } from '@/mastodon/store';
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,
}) => {
@@ -35,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 {
@@ -46,13 +101,14 @@
gap: 2px;
padding: 0;
margin-top: 4px;
align-items: center;
appearance: none;
background: none;
border: none;
color: var(--color-text-secondary);
font-size: 13px;
transition: color 0.2s ease-in-out;
word-break: break-all;
text-align: left;
> svg {
width: 16px;
@@ -111,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;
@@ -146,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);
@@ -185,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 {
@@ -271,10 +375,7 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
&.fieldItem {
border-color: var(--color-border-success-soft);
}
dt {
padding-right: 24px;
padding-right: 32px; // 8px padding + 16px for the icon + 8px gap
}
}
@@ -357,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;
@@ -379,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;
@@ -393,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;
@@ -422,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

@@ -8,16 +8,17 @@ import { Link } from 'react-router-dom';
import { AxiosError } from 'axios';
import { apiSubscribeByEmail } from 'mastodon/api/accounts';
import { apiSubscribeByEmail } from '@/mastodon/api/accounts';
import type {
ValidationErrorResponse,
ValidationError,
} from 'mastodon/api_types/errors';
import { Button } from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
import type { FieldStatus } from 'mastodon/components/form_fields';
import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
import { useAppSelector } from 'mastodon/store';
} from '@/mastodon/api_types/errors';
import { useAppSelector } from '@/mastodon/store';
import { Button } from '../button';
import { DisplayName } from '../display_name';
import type { FieldStatus } from '../form_fields';
import { TextInputField } from '../form_fields/text_input_field';
import classes from './styles.module.scss';
@@ -103,8 +104,6 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
.then(() => {
setSubmitting(false);
setSubmitted(true);
return '';
})
.catch((err: unknown) => {
setSubmitting(false);
@@ -132,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.'
@@ -150,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

@@ -4,10 +4,11 @@ import { FormattedMessage } from 'react-intl';
import type { NavLinkProps } from 'react-router-dom';
import { TabLink, TabList } from '@/mastodon/components/tab_list';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { TabLink, TabList } from '../tab_list';
import classes from './styles.module.scss';
const isActive: Required<NavLinkProps>['isActive'] = (match, location) =>

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

@@ -17,6 +17,11 @@
margin-top: -2px;
}
.content,
.body {
min-width: 0;
}
.content {
display: flex;
gap: 8px;
@@ -32,6 +37,8 @@
.body {
flex-grow: 1;
overflow-wrap: break-word;
hyphens: auto;
a {
color: inherit;
@@ -87,7 +94,7 @@
}
.variantSubtle {
border: 1px solid var(--color-bg-brand-softest);
border: 1px solid var(--color-border-brand-soft);
background-color: var(--color-bg-primary);
.icon {

View File

@@ -9,12 +9,14 @@ import { Button } from './button';
export const Domain: React.FC<{
domain: string;
}> = ({ domain }) => {
onUnblock?: (domain: string) => void;
}> = ({ domain, onUnblock }) => {
const dispatch = useAppDispatch();
const handleDomainUnblock = useCallback(() => {
dispatch(unblockDomain(domain));
}, [dispatch, domain]);
onUnblock?.(domain);
}, [dispatch, domain, onUnblock]);
return (
<div className='domain'>

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

@@ -2,6 +2,7 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 600px;
padding: 24px;
gap: 16px;
@@ -34,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

@@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import ElephantImage from '@/images/elephant_ui.svg?react';
import classes from './empty_state.module.scss';
@@ -19,6 +21,7 @@ export const EmptyState: React.FC<{
title?: React.ReactNode;
message?: React.ReactNode;
children?: React.ReactNode;
className?: string;
}> = ({
image = 'default',
title = (
@@ -26,11 +29,12 @@ export const EmptyState: React.FC<{
),
message,
children,
className,
}) => {
const imageToRender = typeof image === 'string' ? images[image] : image;
return (
<div className={classes.wrapper}>
<div className={classNames(classes.wrapper, className)}>
{(title || message || imageToRender) && (
<div className={classes.content}>
{imageToRender}

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

@@ -2,10 +2,11 @@ import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { revealAccount } from 'mastodon/actions/accounts_typed';
import { Button } from 'mastodon/components/button';
import { domain } from 'mastodon/initial_state';
import { useAppDispatch } from 'mastodon/store';
import { revealAccount } from '@/mastodon/actions/accounts_typed';
import { domain } from '@/mastodon/initial_state';
import { useAppDispatch } from '@/mastodon/store';
import { Button } from './button';
export const LimitedAccountHint: React.FC<{ accountId: string }> = ({
accountId,

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

@@ -8,7 +8,7 @@ import { debounce } from 'lodash';
import { TIMELINE_GAP, TIMELINE_PINNED_VIEW_ALL, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
import { PinnedShowAllButton } from '@/mastodon/features/account_timeline/v2/pinned_statuses';
import { PinnedShowAllButton } from '@/mastodon/features/account_timeline/components/pinned_statuses';
import { StatusQuoteManager } from '../components/status_quoted';

View File

@@ -27,6 +27,12 @@
text-decoration: none;
}
&:focus {
// Override silly global border radius on focused links
border-radius: 0;
outline-offset: 2px;
}
&:not(:global(.active)):is(:hover, :focus) {
color: var(--color-text-brand-soft);
}

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 ?? [],

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