diff --git a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml index e4595810745529..529a42204b1c57 100644 --- a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml +++ b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml @@ -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.4) + - Ruby version: (from `ruby --version`, eg. v4.0.5) - Node.js version: (from `node --version`, eg. v22.16.0) validations: required: false diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml index aaf6dc752affd8..f26448f9ed8cd0 100644 --- a/.github/actions/setup-ruby/action.yml +++ b/.github/actions/setup-ruby/action.yml @@ -23,7 +23,7 @@ runs: ${{ inputs.additional-system-dependencies }} - name: Set up Ruby - uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: ruby-version: ${{ inputs.ruby-version }} bundler-cache: true diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index 0e51abfef27e78..aba608506357e7 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5217a686e1287b..f095e6b0bf474c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -54,7 +54,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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4 + uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # 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 @@ -67,6 +67,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index 93580260f478a8..ac9a22d63e3c1f 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index c23b356309eff3..0341ac18e3eb52 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -39,7 +39,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 8912d992b709a6..6ec3879682eec1 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -171,7 +171,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6 with: files: coverage/lcov/*.lcov env: diff --git a/.nvmrc b/.nvmrc index a2e33f6e2c077c..7858245567393b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.15 +24.16 diff --git a/.rubocop/rails.yml b/.rubocop/rails.yml index bbd172e65606ca..d98c6fc4865c33 100644 --- a/.rubocop/rails.yml +++ b/.rubocop/rails.yml @@ -24,3 +24,6 @@ Rails/RakeEnvironment: Rails/SkipsModelValidations: Enabled: false + +Rails/StrongParametersExpect: + Enabled: false diff --git a/.rubocop/style.yml b/.rubocop/style.yml index f59340d452e871..1d1c9f987975e4 100644 --- a/.rubocop/style.yml +++ b/.rubocop/style.yml @@ -33,6 +33,9 @@ Style/NumericLiterals: AllowedPatterns: - \d{4}_\d{2}_\d{2}_\d{6} +Style/OneClassPerFile: + Enabled: false + Style/PercentLiteralDelimiters: PreferredDelimiters: '%i': () diff --git a/.ruby-version b/.ruby-version index c5106e6d139660..7636e75650d437 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.4 +4.0.5 diff --git a/.simplecov b/.simplecov index 79b376c9ae6b76..1f84c7154753e7 100644 --- a/.simplecov +++ b/.simplecov @@ -1,6 +1,6 @@ # frozen_string_literal: true -SimpleCov.start 'rails' do +SimpleCov.configure do # During parallel runs, ensure unique names for post-run merge command_name "job-#{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER'] diff --git a/.yarnrc.yml b/.yarnrc.yml index 74a014733cf52f..fdca7836c882f0 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -4,3 +4,5 @@ approvedGitRepositories: enableScripts: true nodeLinker: node-modules + +npmMinimalAgeGate: 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 15b25a4c38e94a..a0240292833507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,157 @@ All notable changes to this project will be documented in this file. +## [4.6.0] - UNRELEASED + +### Added + +- **Add collections** (#37992, #37005, #37049, #37020, #37053, #37110, #37117, #37122, #37154, #37157, #37176, #37192, #37222, #37225, #37254, #37277, #37298, #37322, #37434, #37468, #37514, #37512, #37549, #37556, #37560, #37580, #37591, #37552, #37618, #37643, #37658, #37731, #37678, #37741, #37762, #37790, #37805, #37823, #37837, #37842, #37850, #37848, #37812, #37950, #37898, #37916, #37920, #37927, #37928, #37961, #37967, #37974, #37989, #37986, #38004, #38026, #38027, #38030, #38038, #38065, #38081, #38082, #38096, #38106, #38113, #38124, #38133, #38144, #38153, #38166, #38167, #38169, #38170, #38177, #38193, #38213, #38251, #38255, #38256, #38282, #38298, #38292, #38307, #38306, #38316, #38115, #38329, #38334, #38337, #38351, #38368, #38370, #38356, #38383, #38386, #38385, #38394, #38393, #38399, #38402, #38409, #38414, #38413, #38424, #38425, #38450, #38508, #38528, #38534, #38536, #38540, #38543, #38491, #38586, #38611, #38588, #38612, #38628, #38626, #38630, #38633, #38629, #38638, #38645, #38644, #38636, #38660, #38657, #38688, #38690, #38672, #38698, #38697, #38708, #38712, #38713, #38709, #38719, #38728, #38730, #38732, #38739, #38749, #38751, #38750, #38767, #38769, #38783, #38785, #38959, #38786, #38794, #38776, #38817, #38792, #38822, #38827, #38831, #38830, #38844, #38843, #38852, #38850, #38847, #38865, #38897, #38900, #38919, #38933, #38934, #38935, #38942, #38941, #38954, #38961, #38957, #38962, #38991, #39009, #39062, #39029, #39069, #39020, #39073, #39082, #39096, #39080, #39182, #39143, #39127, #37929, #38029, #39194, #39198, #39210, #39211, #39202, #39214, #39215, #39220, #39234, #39260, and #39251 by @ChaosExAnima, @ClearlyClaire, @Gargron, @arte7, @diondiondion, @mjankowski, @oneiros, and @shleeable) + - Create collections with up to 25 accounts each, then share them with others. You can read more about this feature [on our blog](https://blog.joinmastodon.org/2026/04/designing-collections/). This is based on FEP-7aa9 (Featured Collections) to be interoperable with the wider Fediverse. All the new API methods [are documented here](https://docs.joinmastodon.org/client/collections/). +- **Add email subscriptions** (#38163, #38507, #38502, #38487, #38527, #38582, #38741, #38907, and #39162 by @ClearlyClaire and @Gargron) + - Admins can allow specific roles to enable email subscriptions on their profile, allowing anonymous visitors to subscribe to their posts via email. +- **Add new overview landing page setting** (#39074, #39170, #39163, and #39138 by @Gargron, @diondiondion, and @zunda) + - Admins can choose a new frontpage for anonymous visitors, which combines the about page and most recent posts from local profiles. +- **Add ability to require 2FA for specific roles** (including Everybody) (#37701, #37846, and #38906 by @ClearlyClaire and @mjankowski) +- Add export for custom filters (#39085 by @arte7) +- Add ability to search email blocks by domain in admin UI (#38923 by @arte7) +- Add new endpoints for profile editing in REST API (#37912, #37934, #37932, #38221, and #38339 by @ClearlyClaire) + - Add `GET /api/v1/profile` and `PATCH /api/v1/profile` to replace the existing `update_credentials` endpoint. See [the documentation](https://www.notion.so/joinmastodon/Mastodon-v4-6-0-beta1-changelog-3656208ac91b8088a745d15a9e81f727) for more information. +- Add `missing_attribution` boolean to preview cards in REST API (#38043 by @ClearlyClaire) + - Documentation: https://docs.joinmastodon.org/entities/PreviewCard/#missing_attribution +- Add `exclude_direct` flag to `/api/v1/accounts/:id/statuses` to exclude direct messages (#37763 by @ClearlyClaire) +- Add `max_note_length` and `max_display_name_length` attributes to `configuration.accounts` in `Instance` entity (#37991 by @ClearlyClaire) +- Add profile field limits to instance entity in REST API (#37535 by @mkljczk) + - This adds attributes `configuration.accounts.max_profile_fields`, `configuration.accounts.profile_field_name_limit` and `configuration.accounts.profile_field_value_limit` to the [`Instance` entity](https://docs.joinmastodon.org/entities/Instance). +- Add `unresolved` flag to `/api/v1/admin/reports` to query both resolved and unresolved reports (#38323 by @mkljczk) +- Add fallback attributes to notifications for new and infrequent notifications in REST API (#38832 and #38860 by @ClearlyClaire) + - This adds a [`supported_types`](https://docs.joinmastodon.org/methods/notifications/#query-parameters-1) parameter to `GET /api/v1/notifications`, `GET /api/v1/notifications/:id`, `GET /api/v2/notifications`, and `GET /api/v2/notifications/:group_key` along with a new `fallback` attribute for notifications and notification groups. +- Add support for posts in vertical languages in web UI (#37204, #38205, and #38797 by @shimon1024) +- Add `PageUp` and `PageDown` hotkeys for list navigation (#39252 by @diondiondion) +- Add `g`+`e` keyboard shortcut to access the trending page in web UI (#38014 by @antoinecellerier) +- Add `Cmd`/`Ctrl`+`Enter` for form submissions in more text areas in web UI (#37821 by @diondiondion) +- Add support for quoting by dragging a link into the compose form in web UI (#36859 and #36896 by @ClearlyClaire and @tribela) +- Add `text-autospace` to posts to improve rendering of mixed script posts in web UI (#37694 by @ahxxm) +- Add `nan-TW` to supported locales (#37650, #34923, #37822, and #37721 by @ClearlyClaire and @Yoxem) +- Add support for `hosts` resolver in request socket DNS lookup (#38699, #38866, and #39030 by @ClearlyClaire and @mjankowski) +- Add support for FEP-2c59 (Webfinger Backlink) (#38239, #38538, and #38639 by @ClearlyClaire and @shleeable) +- Add support for FEP-3b86 (Activity Intents) (#38120 and #38130 by @ClearlyClaire and @Gargron) +- Add support for alt text for profile pictures and headers (#37634, #37641, and #38000 by @ClearlyClaire and @Doxterpepper) +- Add support for multiple keypairs for remote accounts (#38279, #38407, #38419, #38511, #38516, #38515, #38555 and #39235 by @ClearlyClaire) +- Add support for the “require approval” feature for email domain blocks to `tootctl email_domain_blocks` (#34579 and #38107 by @ClearlyClaire and @e-nomem) +- Add `--keep-interacted` flag to `tootctl media remove` to preserve cached media on cleanup (#36200 by @northerner) +- Add systemd service file for prometheus exporter (#35130 by @ThisIsMissEm) + +### Changed + +- **Change design of profiles in web UI** (#37472, #37490, #37479, #37513, #37527, #37550, #37538, #37632, #37627, #37593, #37638, #37626, #37645, #37653, #37683, #37707, #37682, #37742, #37747, #37760, #37761, #37831, #37766, #37811, #37813, #37825, #37854, #37851, #37876, #37885, #37892, #37890, #37907, #37922, #37952, #37958, #37996, #37990, #37994, #38005, #38012, #38040, #38052, #38066, #38083, #38147, #38148, #38152, #38168, #38156, #38175, #38191, #38189, #38235, #38283, #38310, #38309, #38315, #38314, #38365, #38366, #38363, #38346, #38382, #38384, #38400, #38404, #38417, #38426, #38440, #38442, #38443, #38445, #38446, #38451, #38456, #38509, #38510, #38512, #38513, #38517, #38529, #38531, #38535, #38532, #38544, #38549, #38575, #38579, #38580, #38581, #38585, #38584, #38604, #38605, #38606, #38607, #38622, #38616, #38625, #38632, #38640, #38663, #38667, #38646, #38691, #38692, #38766, #38791, #38687, #38826, #38828, #38863, #38845, #38870, #38872, #38932, #38945, #38963, #38964, #39055, #39042, #38893, #39079, #39084, #39160, #39070, and #39217 by @ChaosExAnima, @ClearlyClaire, @Coro365, @diondiondion, and @shleeable) + - The profile screen has been entirely redesigned, has new features, and allows you to update your own profile directly without going into the preferences panel. You can read more about it [on our blog](https://blog.joinmastodon.org/2026/03/a-redesign-for-profiles/). +- **Change how #Wrapstodon reports are generated and displayed** (#37033, #37045, #37093, #37055, #37096, #37047, #37103, #37104, #37106, #37109, #37121, #37138, #37134, #37177, #37182, #37169, #37186, #37187, #37188, #37189, #37190, #37193, #37198, #37201, #37203, #37205, #37206, #37207, #37209, #37202, #37216, #37219, #37224, #37226, #37229, #37249, #37251, #37256, #37261, #37269, #37270, #37273, and #37289 by @ChaosExAnima, @ClearlyClaire, @channyeintun, and @diondiondion) + - This finishes up work started in 2024 by completely revamping how Wrapstodon reports are generated and displayed, reducing the amount of data collected and generating reports when active users ask for them. + - Instead of requiring manual generation from a server administrator, this is now offered between the 10th of December and the end of each year if enabled in the server settings. + - The design of the Wrapstodon report has also been fully reworked to be more delightful and easier to share! + - The relevant API endpoints are documented at https://docs.joinmastodon.org/methods/annual_reports/ +- Change pending user notification email to link directly to the pending account (#39206 by @vmstan) +- Changed emoji processing in web UI to make it less resource intensive and more robust (#39077, #39008, #39088, #38892, #38885, #38965, #38854, #38825, #38784, #38541, #37442, #37300, #37306, #37271, #37255, #37284, #37272, #37178, #37084, #37080, #37418, #39167, and #39126 by @ChaosExAnima, @ClearlyClaire, @diondiondion, and @gomasy) +- Change composer textarea to have a limited height to prevent column scrolling (#39268 by @diondiondion) +- Change mentions of “Mastodon gGmbH” to “Mastodon GmbH” (#39261 by @renchap) +- Change the limited profile message to be less misleading (#39231 by @mortie) +- Change images/videos in posts in web UI to not have unlimited height (#36966, #37035, #37136, and #37032 by @diondiondion) +- Change search field and tabs to stick to the top on the search results page in web UI (#38968 by @diondiondion) +- Change “anyone can quote” label to “quotes allowed” in web UI (#37427 by @vmstan) +- Change navigation by `j`/`k` hotkeys to anchor navigated item to top of viewport in web UI (#38036 by @diondiondion) +- Change hotkeys to focus columns to not reset scroll, add hotkey `0` to scroll to top in web UI (#37052 by @diondiondion) +- Change media modal swipe animation in web UI (#36916, #37034, #37323, and #37464 by @ChaosExAnima and @heathdutton) +- Change “Hide”/“Show all” eye icon in thread view in web UI (#22301 by @tribela) +- Change order of onboarding steps (follow people, then fill out profile) in web UI (#38121 by @Gargron) +- Change “Why do you want to join” field on the sign-up page to have a label (#38936 by @diondiondion) +- Change date of birth field on the sign-up page to use locale-specific fields order (#36039 and #36895 by @mjankowski) +- Change how invalid-but-not-expired invites are shown in admin UI (#38736 by @ClearlyClaire) +- Change wording and ordering of media display settings (#38731 by @mjankowski) +- Change wording of server account recommendation setting description (#36771 by @mjankowski) +- Change wording and ordering of account migration warnings (#20387 by @jsoref) +- Change wording of “Automatic post deletion” settings (#37286 by @mjankowski) +- Change wording of language filter settings to clarify they do not impact home/lists (#38490 by @mjankowski) +- Change invitations to only bypass sign-up approval setting when the issuer of the invitation has the `invite_bypass_approval` permission (#38278 by @ClearlyClaire) + - This splits the “Invite Users” permission into a new “Invite Users without review” permission. + - Existing roles will be updated to have the new permission if they have the old one, but default permissions will not include the new `invite_bypass_approval` permission. +- Change followers synchronization mechanism on followers-only posts to be skipped for accounts with 25k followers or more (#37302 by @ClearlyClaire) +- Change “dark”, “light” and “high contrast” themes to be separate “Color scheme” and “Contrast” settings handled by a single theme (#37095, #37120, #37288, #37459, #37470, #37477, #37519, #37520, #37523, #37524, #37526, #37612, #37824, #37807, #37810, #37819, #37906, and #38261 by @ClearlyClaire, @diondiondion, and @mjankowski) + - Existing settings should be migrated automatically from user settings, and using browser defaults otherwise. + - This also allows third-party theme authors to make use of the same browser defaults and user settings. Learn more about this in [our new Theming docs](https://docs.joinmastodon.org/dev/frontend/theming/). +- Change default theme to use CSS theme tokens (#36861, #36936, #37019, #37054, #37056, #37081, #37105, #37268, #37841, #37843, #38387, #38459, and #38621 by @diondiondion) + - A [guide to using the new tokens](https://docs.joinmastodon.org/dev/frontend/design-tokens/) can be found in our docs. +- Change location blocks in default `nginx.conf` (#19644 and #37866 by @BedrockDigger and @Izorkin) +- Change `proxy_read_timeout` to 120 seconds in default `nginx.conf` (#30599 by @shleeable) +- Change JSON-LD collection handling (#34595 and #37806 by @ClearlyClaire and @sneakers-the-rat) + +### Removed + +- Remove support for EOL Node version 20 (#38926 by @mjankowski) +- Remove support for Ruby 3.2 (#37476 by @mjankowski) +- Remove support for `ENABLE_SIDEKIQ_UNIQUE_JOBS_UI` (#38340 by @ClearlyClaire) +- Remove support for ImageMagick (#37488 by @mjankowski) + +### Fixed + +- Fix accessibility issues in web UI (#37250, #38006, #38033, #38188, #38230, #38252, #38257, #38285, #38293, #38362, #38387, #38459, #38796, #38801, #39098, #39111, #39120, #39129, #39133, #39134, #39144, #39145, #39149, #39164, #39165, #39169, and #39181 by @ChaosExAnima and @diondiondion) +- Fix processing some link previews where text is language-tagged (#39190 by @zunda) +- Fix error when “New trends” email is sent at the same time trends are recomputed (#39122 by @arte7) +- Fix hover card opening even when not preceded by mouse movement in web UI (#39166 by @diondiondion) +- Fix [ominous](https://mastodon.social/@mcc/116404362104299129) "Moments remaining" timestamp in web UI (#38488 and #38689 by @ChaosExAnima and @MitarashiDango) +- Fix filters not being applied to search results in web UI (#36346 by @ClearlyClaire) +- Fix error when visiting non-public hashtag timelines (#36961 by @diondiondion) +- Fix duplicate favourite/boost counters in some languages (#36844 by @ChaosExAnima) +- Fix unblocking domain from blocked domains column not updating the list in web UI (#38882 by @tribela) +- Fix profile dropdown menu sometimes ending with a separator in web UI (#38481 by @mkljczk) +- Fix short numbers rounding up instead of truncating in web UI (#38114 by @serranodfm) +- Fix directory showing load more button when no more profiles exist in web UI (#37465 by @heathdutton) +- Fix focus restoration after closing some modals in web UI (#37424 by @MegaManSec) +- Fix video modals being pushed down on mobile in web UI (#37421 by @ChaosExAnima) +- Fix outer page margins when viewport width equals content width in web UI (#36733 by @diondiondion) +- Fix announcement margin when in advanced web UI (#36714 by @ChaosExAnima) +- Fix navigation overflow issue in advanced web UI (#39178 by @diondiondion) +- Fix stale merging stale account from cached instance API response in web UI (#37666 by @ChaosExAnima) +- Fix HTML `lang` attribute being stripped out of remote posts (#39114 by @artemist) +- Fix remote posts with large media descriptions being rejected (#39135 by @ClearlyClaire) +- Fix some occurrence of PostgreSQL log pollution when processing new hashtags (#35792 by @oelison) +- Fix replica database not being used when `REPLICA_DB_HOST` is used but neither `REPLICA_DB_NAME` nor `REPLICA_DATABASE_URL` (#37240 by @smiba) +- Fix remote media attachment thumbnails not being stored in the `cache/` directory (#36911 by @shugo) +- Fix race condition when processing posts twice with the same idempotency key (#37879 by @ClearlyClaire) +- Fix various missing translation strings (#37671, #37838, #37078, #37371, and #37827 by @ClearlyClaire, @mjankowski, and @valtlai) +- Fix last post time for remote accounts not being accurately tracked (#37619 by @ClearlyClaire) +- Fix filtering of mentions from filtered-on-their-origin-server accounts (#37583 by @ClearlyClaire) +- Fix irrelevant remote accounts being passed through to local fan-out worker (#37589 by @ClearlyClaire) +- Fix required field markers being displayed on fields that cannot be empty anyway in settings (#37291 by @diondiondion) +- Fix thumbnails for links from The Guardian (and possibly other CDNs that check URL hashes) not showing up (#36139 by @phocks) +- Fix `mastodon-async-refresh` response header not being exposed through CORS (#38914 by @mkljczk) +- Fix FASP availability being incorrectly updated (#38818 by @oneiros) +- Fix use of deprecated `vsync` FFmpeg option, using `fps_mode` instead (FFmpeg >= 5.1 now required) (#38198 by @mjankowski) +- Fix unnecessary downcasing of some words in admin UI (#37364 by @ClearlyClaire) +- Fix delivery worker counting unsalvageable HTTP errors as successes (#37235 by @shleeable) +- Fix streaming heartbeat comment not being its own event (#37389 by @ClearlyClaire) +- Fix posts with edited out media attachments being returned in `GET /api/v1/accounts/:id/statuses?only_media=true` (#37363 by @ClearlyClaire) +- Fix wrong media attachment URLs being returned from `DELETE /api/v1/statuses/:id` (#35880 by @dbarabashh) +- Fix hashtag matching by replacing negative look-behind with positive look-behind (#37684 and #38212 by @ClearlyClaire) +- Fix discovery of ActivityPub representation from HTML tags in presence of a non-ActivityPub alternate Link header (#37439 by @shleeable) +- Fix Webfinger endpoint not handling new ActivityPub ID scheme (#38391 by @ClearlyClaire) +- Fix error when admin-selected theme does not exist by falling back to `default` theme (#38703 by @shleeable) +- Fix wrong endonyms for Divehi and Latvian in languages list (#36254 and #36876 by @cuu508 and @shimon1024) +- Fix `Accept` headers when fetching ActivityPub resources not including JSON-LD profile (#30354 by @TheOneric) +- Fix wrong hover indicators on unclickable items in admin UI (#38782 by @diondiondion) +- Fix streaming server using deprecated `url.parse` instead of WHATWG URL API (#36973 by @Exagone313) + +## [4.5.11] - 2026-06-03 + +### Security + +- Fix allowed attribution domains spoofing ([GHSA-rwcw-vq68-g34p](https://github.com/mastodon/mastodon/security/advisories/GHSA-rwcw-vq68-g34p)) +- Fix uncaught exception in message sanitization causing Denial of Service ([GHSA-qrgq-9fx2-vf2r](https://github.com/mastodon/mastodon/security/advisories/GHSA-qrgq-9fx2-vf2r)) +- Update dependencies + +### Fixed + +- Fix remote statuses with large media descriptions being rejected (#39135 by @ClearlyClaire) + ## [4.5.10] - 2026-05-20 ### Security diff --git a/Dockerfile b/Dockerfile index feb5b34b803900..6f44c4c3e10b11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.4" +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" diff --git a/Gemfile b/Gemfile index d4c1008a4fb49f..61261e8f9b3535 100644 --- a/Gemfile +++ b/Gemfile @@ -45,7 +45,7 @@ gem 'omniauth-saml', '~> 2.0' gem 'color_diff', '~> 0.1' gem 'csv', '~> 3.2' -gem 'discard', '~> 1.2' +gem 'discard', '~> 2.0' gem 'doorkeeper', '~> 5.6' gem 'faraday-httpclient' gem 'fast_blank', '~> 1.0' @@ -135,7 +135,7 @@ group :test do # Browser integration testing gem 'capybara', '~> 3.39' gem 'capybara-playwright-driver' - 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 + gem 'playwright-ruby-client', '1.60.0', 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' @@ -223,11 +223,11 @@ gem 'concurrent-ruby', require: false gem 'connection_pool', require: false gem 'xorcist', '~> 1.1' -gem 'net-http', '~> 0.6.0' +gem 'net-http', '~> 0.9.0' gem 'rubyzip', '~> 3.0' gem 'hcaptcha', '~> 7.1' gem 'mail', '~> 2.8' -gem 'vite_rails', '~> 3.0.19' +gem 'vite_rails' diff --git a/Gemfile.lock b/Gemfile.lock index 70872eff96e1ad..a8f4d4fc88fd1b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -99,8 +99,8 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1249.0) - aws-sdk-core (3.247.0) + aws-partitions (1.1255.0) + aws-sdk-core (3.250.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.125.0) - aws-sdk-core (~> 3, >= 3.247.0) + aws-sdk-kms (1.128.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.222.0) - aws-sdk-core (~> 3, >= 3.247.0) + aws-sdk-s3 (1.224.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -205,8 +205,8 @@ GEM devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.6.2) - discard (1.4.0) - activerecord (>= 4.2, < 9.0) + discard (2.0.0) + activerecord (>= 7.0, < 9.0) docile (1.4.1) domain_name (0.6.20240107) doorkeeper (5.9.0) @@ -235,7 +235,7 @@ GEM fabrication (3.0.0) faker (3.8.0) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.2) faraday-net_http (>= 2.0, < 3.5) json logger @@ -243,7 +243,7 @@ GEM faraday (>= 1, < 3) faraday-httpclient (2.0.2) httpclient (>= 2.2) - faraday-net_http (3.4.2) + faraday-net_http (3.4.3) net-http (~> 0.5) fast_blank (1.0.1) fastimage (2.4.1) @@ -354,7 +354,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.19.5) + json (2.19.7) json-canonicalization (1.0.0) json-jwt (1.17.0) activesupport (>= 4.2) @@ -378,7 +378,7 @@ GEM addressable (~> 2.8) bigdecimal (>= 3.1, < 5) jsonapi-renderer (0.2.2) - jwt (2.10.2) + jwt (2.10.3) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -455,8 +455,8 @@ GEM msgpack (1.8.0) multi_json (1.20.1) mutex_m (0.3.0) - net-http (0.6.0) - uri + net-http (0.9.1) + uri (>= 0.11.1) net-imap (0.6.4) date net-protocol @@ -539,7 +539,7 @@ GEM opentelemetry-instrumentation-active_support (~> 0.10) opentelemetry-instrumentation-active_support (0.12.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-base (0.26.0) + opentelemetry-instrumentation-base (0.26.1) opentelemetry-api (~> 1.7) opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) @@ -559,7 +559,7 @@ GEM opentelemetry-helpers-sql opentelemetry-helpers-sql-processor opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-rack (0.31.0) + opentelemetry-instrumentation-rack (0.31.1) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-rails (0.42.0) opentelemetry-instrumentation-action_mailer (~> 0.7) @@ -588,7 +588,7 @@ GEM ostruct (0.6.3) ox (2.14.26) bigdecimal (>= 3.0) - parallel (1.28.0) + parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) racc @@ -598,7 +598,7 @@ GEM pg (1.6.3) pghero (3.8.0) activerecord (>= 7.2) - playwright-ruby-client (1.59.1) + playwright-ruby-client (1.60.0) base64 concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) @@ -647,7 +647,7 @@ GEM base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) - rack-proxy (0.7.7) + rack-proxy (0.8.2) rack rack-session (2.1.2) base64 (>= 0.1.0) @@ -755,11 +755,11 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) rspec-support (3.13.7) - rubocop (1.84.2) + rubocop (1.87.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) - parallel (~> 1.10) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) @@ -779,7 +779,7 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.34.3) + rubocop-rails (2.35.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -802,7 +802,7 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger - rubyzip (3.3.0) + rubyzip (3.3.1) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) safety_net_attestation (0.5.0) @@ -816,7 +816,7 @@ GEM securerandom (0.4.1) shoulda-matchers (7.0.1) activesupport (>= 7.1) - sidekiq (8.1.5) + sidekiq (8.1.6) connection_pool (>= 3.0.0) json (>= 2.16.0) logger (>= 1.7.0) @@ -827,7 +827,7 @@ GEM sidekiq-scheduler (6.0.2) rufus-scheduler (~> 3.2) sidekiq (>= 7.3, < 9) - sidekiq-unique-jobs (8.0.13) + sidekiq-unique-jobs (8.1.0) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 7.0.0, < 9.0.0) thor (>= 1.0, < 3.0) @@ -901,7 +901,7 @@ GEM validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix - vite_rails (3.0.20) + vite_rails (3.11.0) railties (>= 5.1, < 9) vite_ruby (~> 3.0, >= 3.2.2) vite_ruby (3.10.2) @@ -937,7 +937,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.5) + zeitwerk (2.8.2) PLATFORMS ruby @@ -970,7 +970,7 @@ DEPENDENCIES devise devise-two-factor devise_pam_authenticatable2 (~> 9.2) - discard (~> 1.2) + discard (~> 2.0) doorkeeper (~> 5.6) dotenv fabrication @@ -1012,7 +1012,7 @@ DEPENDENCIES memory_profiler mime-types (~> 3.7.0) mutex_m - net-http (~> 0.6.0) + net-http (~> 0.9.0) net-ldap (~> 0.18) nokogiri (~> 1.15) omniauth (~> 2.0) @@ -1040,7 +1040,7 @@ DEPENDENCIES parslet pg (~> 1.5) pghero - playwright-ruby-client (= 1.59.1) + playwright-ruby-client (= 1.60.0) premailer-rails prometheus_exporter (~> 2.2) propshaft @@ -1089,7 +1089,7 @@ DEPENDENCIES tty-prompt (~> 0.23) twitter-text (~> 3.1.0) tzinfo-data (~> 1.2023) - vite_rails (~> 3.0.19) + vite_rails webauthn (~> 3.0) webmock (~> 3.18) webpush! @@ -1097,7 +1097,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 4.0.4 + ruby 4.0.5 BUNDLED WITH 4.0.11 diff --git a/Vagrantfile b/Vagrantfile index e2d703fac204de..df91fceea45b22 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -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 diff --git a/app/controllers/activitypub/featured_collections_controller.rb b/app/controllers/activitypub/featured_collections_controller.rb index 09de5583cc95e6..12c0648fae4200 100644 --- a/app/controllers/activitypub/featured_collections_controller.rb +++ b/app/controllers/activitypub/featured_collections_controller.rb @@ -9,7 +9,6 @@ class ActivityPub::FeaturedCollectionsController < ApplicationController vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } - before_action :check_feature_enabled before_action :require_account_signature!, if: -> { authorized_fetch_mode? } before_action :set_collections @@ -72,8 +71,4 @@ def collection_presenter ) end end - - def check_feature_enabled - raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? - end end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index 4277514f83c4df..4921d5cc956b1a 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -7,9 +7,6 @@ class CustomEmojisController < BaseController def index authorize :custom_emoji, :index? - # If filtering by local emojis, remove by_domain filter. - params.delete(:by_domain) if params[:local].present? - # If filtering by domain, ensure remote filter is set. if params[:by_domain].present? params.delete(:local) diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb index 10dbe846e4ce10..03234b0bde4f97 100644 --- a/app/controllers/admin/report_notes_controller.rb +++ b/app/controllers/admin/report_notes_controller.rb @@ -25,6 +25,8 @@ def create @action_logs = @report.history.includes(:target) @form = Admin::StatusBatchAction.new @statuses = @report.statuses.with_includes + @collections = @report.collections + @collection_form = Admin::CollectionBatchAction.new render 'admin/reports/show' end diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb index 9b5beeab67ee79..765996eb3cf3f4 100644 --- a/app/controllers/api/v1/admin/reports_controller.rb +++ b/app/controllers/api/v1/admin/reports_controller.rb @@ -16,6 +16,7 @@ class Api::V1::Admin::ReportsController < Api::BaseController FILTER_PARAMS = %i( resolved + unresolved account_id target_account_id ).freeze diff --git a/app/controllers/api/v1_alpha/collection_items_controller.rb b/app/controllers/api/v1/collection_items_controller.rb similarity index 84% rename from app/controllers/api/v1_alpha/collection_items_controller.rb rename to app/controllers/api/v1/collection_items_controller.rb index 2c46cc4f9fcda0..3ec5e18ed95954 100644 --- a/app/controllers/api/v1_alpha/collection_items_controller.rb +++ b/app/controllers/api/v1/collection_items_controller.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true -class Api::V1Alpha::CollectionItemsController < Api::BaseController +class Api::V1::CollectionItemsController < Api::BaseController include Authorization - before_action :check_feature_enabled - before_action -> { doorkeeper_authorize! :write, :'write:collections' } before_action :require_user! @@ -55,8 +53,4 @@ def set_account def set_collection_item @collection_item = @collection.collection_items.find(params[:id]) end - - def check_feature_enabled - raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? - end end diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1/collections_controller.rb similarity index 78% rename from app/controllers/api/v1_alpha/collections_controller.rb rename to app/controllers/api/v1/collections_controller.rb index 4e15b65ff58a2c..9acd535f465440 100644 --- a/app/controllers/api/v1_alpha/collections_controller.rb +++ b/app/controllers/api/v1/collections_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1Alpha::CollectionsController < Api::BaseController +class Api::V1::CollectionsController < Api::BaseController include Authorization DEFAULT_COLLECTIONS_LIMIT = 40 @@ -9,8 +9,6 @@ class Api::V1Alpha::CollectionsController < Api::BaseController render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422 end - before_action :check_feature_enabled - before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index, :show] before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create, :update, :destroy] @@ -28,10 +26,9 @@ def index cache_if_unauthenticated! authorize @account, :index_collections? - presenter = CollectionsPresenter.new(collections: @collections) - render json: presenter, serializer: REST::CollectionsWithAccountPreviewsSerializer + render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json rescue Mastodon::NotPermittedError - render json: { collections: [], partial_accounts: [] } + render json: { collections: [] } end def show @@ -74,7 +71,6 @@ def set_account def set_collections @collections = @account.collections .with_tag - .preload(top_items: :account) .order(created_at: :desc) .offset(offset_param) .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) @@ -93,20 +89,16 @@ def collection_update_params params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name) end - def check_feature_enabled - raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? - end - def next_path return unless records_continue? - api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT))) + api_v1_account_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT))) end def prev_path return if offset_param.zero? - api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT))) + api_v1_account_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT))) end def records_continue? diff --git a/app/controllers/api/v1_alpha/in_collections_controller.rb b/app/controllers/api/v1/in_collections_controller.rb similarity index 70% rename from app/controllers/api/v1_alpha/in_collections_controller.rb rename to app/controllers/api/v1/in_collections_controller.rb index 087464989ef0e5..c34845e463ecfe 100644 --- a/app/controllers/api/v1_alpha/in_collections_controller.rb +++ b/app/controllers/api/v1/in_collections_controller.rb @@ -1,12 +1,10 @@ # frozen_string_literal: true -class Api::V1Alpha::InCollectionsController < Api::BaseController +class Api::V1::InCollectionsController < Api::BaseController include Authorization DEFAULT_COLLECTIONS_LIMIT = 40 - before_action :check_feature_enabled - before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index] before_action :require_user! @@ -37,20 +35,16 @@ def set_collections .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) end - def check_feature_enabled - raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? - end - def next_path return unless records_continue? - api_v1_alpha_account_in_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT))) + api_v1_account_in_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT))) end def prev_path return if offset_param.zero? - api_v1_alpha_account_in_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT))) + api_v1_account_in_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT))) end def records_continue? diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 8e341aa48e61fd..a8653631c27379 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -23,10 +23,6 @@ def reported_account end def report_params - if Mastodon::Feature.collections_enabled? - params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], collection_ids: [], rule_ids: []) - else - params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], rule_ids: []) - end + params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], collection_ids: [], rule_ids: []) end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index acc3ad3be7c955..ffc8ae8b07616c 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -180,6 +180,6 @@ def unexpected_accounts_error_json(error) end def serialized_accounts(accounts) - ActiveModel::Serializer::CollectionSerializer.new(accounts, serializer: REST::AccountSerializer) + ActiveModel::Serializer::CollectionSerializer.new(accounts, serializer: REST::AccountSerializer, scope_name: :current_user, scope: current_user) end end diff --git a/app/controllers/collection_items_controller.rb b/app/controllers/collection_items_controller.rb index 09c1e0e192a63d..51044b5965456e 100644 --- a/app/controllers/collection_items_controller.rb +++ b/app/controllers/collection_items_controller.rb @@ -7,7 +7,6 @@ class CollectionItemsController < ApplicationController vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } - before_action :check_feature_enabled before_action :require_account_signature!, if: -> { authorized_fetch_mode? } before_action :set_collection_item @@ -35,8 +34,4 @@ def set_collection_item rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end - - def check_feature_enabled - raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? - end end diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb index 70541433f00ef7..628418557c7898 100644 --- a/app/controllers/collections_controller.rb +++ b/app/controllers/collections_controller.rb @@ -8,7 +8,6 @@ class CollectionsController < ApplicationController vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } - before_action :check_feature_enabled before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_collection @@ -51,8 +50,4 @@ def expiration_duration recently_updated = @collection.updated_at > 15.minutes.ago recently_updated ? 30.seconds : 5.minutes end - - def check_feature_enabled - raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? - end end diff --git a/app/controllers/concerns/settings/export_controller_concern.rb b/app/controllers/concerns/settings/export_controller_concern.rb index 2cf28cced87234..9917a5bd0495cb 100644 --- a/app/controllers/concerns/settings/export_controller_concern.rb +++ b/app/controllers/concerns/settings/export_controller_concern.rb @@ -19,15 +19,12 @@ def load_export def send_export_file respond_to do |format| - format.csv { send_data export_data, filename: export_filename } + format.csv { send_data export_data, filename: "#{controller_name}.csv" } + format.json { send_data export_data, filename: "#{controller_name}.json" } end end def export_data raise 'Override in controller' end - - def export_filename - "#{controller_name}.csv" - end end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index fd1e2fafb29ce6..1a876b0cadef70 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -157,7 +157,7 @@ def keypair_refresh_key!(keypair) end def check_keypair_validity!(keypair) - raise Mastodon::SignatureVerification, "Key #{signature_key_id} is revoked" if keypair.revoked? - raise Mastodon::SignatureVerification, "Key #{signature_key_id} has expired" if keypair.expired? + raise Mastodon::SignatureVerificationError, "Key #{signature_key_id} is revoked" if keypair.revoked? + raise Mastodon::SignatureVerificationError, "Key #{signature_key_id} has expired" if keypair.expired? end end diff --git a/app/controllers/settings/exports/custom_filters_controller.rb b/app/controllers/settings/exports/custom_filters_controller.rb new file mode 100644 index 00000000000000..ec7c0dfa040713 --- /dev/null +++ b/app/controllers/settings/exports/custom_filters_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Settings + module Exports + class CustomFiltersController < BaseController + include Settings::ExportControllerConcern + + def index + send_export_file + end + + private + + def export_data + @export.to_custom_filters_json + end + end + end +end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 40806a451586df..a3dd83ecca60aa 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -20,8 +20,9 @@ module Admin::FilterHelper def filter_link_to(text, link_to_params, link_class_params = link_to_params) new_url = filtered_url_for(link_to_params) new_class = filtered_url_for(link_class_params) + is_selected = selected?(link_class_params) - link_to text, new_url, class: filter_link_class(new_class) + link_to text, new_url, class: filter_link_class(new_class), 'aria-current': (is_selected ? 'true' : nil) end def table_link_to(icon, text, path, **options) diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 0edf8d75075b6c..df11ed32f5f145 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -35,7 +35,7 @@ module ContextHelper keywords: { 'schema' => 'http://schema.org#', 'keywords' => 'schema:keywords' }, license: { 'schema' => 'http://schema.org#', 'license' => 'schema:license' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, - attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, + attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@container' => '@set' } }, misskey_license: { 'misskey' => 'https://misskey-hub.net/ns#', '_misskey_license' => 'misskey:_misskey_license' }, profile_settings: { 'toot' => 'http://joinmastodon.org/ns#', @@ -53,6 +53,7 @@ module ContextHelper interaction_policies: { 'gts' => 'https://gotosocial.org/ns#', 'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, + 'canFeature' => { '@id' => 'https://w3id.org/fep/7aa9#canFeature', '@type' => '@id' }, 'canQuote' => { '@id' => 'gts:canQuote', '@type' => '@id' }, 'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' }, 'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' }, @@ -63,6 +64,22 @@ module ContextHelper 'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' }, 'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' }, }, + feature_requests: { 'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest' }, + featured_collections: { + 'FeaturedCollection' => 'https://w3id.org/fep/7aa9#FeaturedCollection', + 'FeaturedItem' => 'https://w3id.org/fep/7aa9#FeaturedItem', + 'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest', + 'FeatureAuthorization' => 'https://w3id.org/fep/7aa9#FeatureAuthorization', + 'topic' => { '@id' => 'https://w3id.org/fep/7aa9#topic', '@type' => '@id' }, + 'featuredObject' => { '@id' => 'https://w3id.org/fep/7aa9#featuredObject', '@type' => '@id' }, + 'featureAuthorization' => { '@id' => 'https://w3id.org/fep/7aa9#featureAuthorization', '@type' => '@id' }, + }, + feature_authorizations: { + 'gts' => 'https://gotosocial.org/ns#', + 'FeatureAuthorization' => 'https://w3id.org/fep/7aa9#FeatureAuthorization', + 'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' }, + 'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' }, + }, }.freeze def full_context diff --git a/app/javascript/mastodon/actions/accounts_typed.ts b/app/javascript/mastodon/actions/accounts_typed.ts index fe7c7327ce3956..3d8396c81a9680 100644 --- a/app/javascript/mastodon/actions/accounts_typed.ts +++ b/app/javascript/mastodon/actions/accounts_typed.ts @@ -3,6 +3,7 @@ import { createAction } from '@reduxjs/toolkit'; import { apiRemoveAccountFromFollowers, apiGetEndorsedAccounts, + apiGetAccounts, } from 'mastodon/api/accounts'; import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; @@ -113,3 +114,12 @@ export const fetchEndorsedAccounts = createDataLoadingThunk( return data; }, ); + +export const fetchAccounts = createDataLoadingThunk( + 'accounts/multi_accounts', + ({ accountIds }: { accountIds: string[] }) => apiGetAccounts(accountIds), + (data, { dispatch }) => { + dispatch(importFetchedAccounts(data)); + return data; + }, +); diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 3f08c068693d7c..c398fa0684d7ad 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -104,7 +104,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, @@ -344,7 +344,7 @@ export function submitComposeFail(error) { export function uploadCompose(files) { return function (dispatch, getState) { - const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']); + const uploadLimit = getState().getIn(['server', 'server', 'item', 'configuration', 'statuses', 'max_media_attachments']); const media = getState().getIn(['compose', 'media_attachments']); const pending = getState().getIn(['compose', 'pending_media_attachments']); const defaultSensitive = getState().getIn(['compose', 'default_sensitive']); diff --git a/app/javascript/mastodon/actions/importer/emoji.ts b/app/javascript/mastodon/actions/importer/emoji.ts index 9e06c88f66e0bd..e9356ab6215911 100644 --- a/app/javascript/mastodon/actions/importer/emoji.ts +++ b/app/javascript/mastodon/actions/importer/emoji.ts @@ -1,5 +1,8 @@ import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; import { loadCustomEmoji } from '@/mastodon/features/emoji'; +import { emojiLogger } from '@/mastodon/features/emoji/utils'; + +const log = emojiLogger('actions'); export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) { if (emojis.length === 0) { @@ -18,5 +21,11 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) { if (existingEmojis.length < emojis.length) { await clearCache('custom'); await loadCustomEmoji(); + + const { reloadCustomEmojis } = + await import('@/mastodon/features/emoji/picker'); + await reloadCustomEmojis(); + + log('Custom emojis updated, reloaded cache and picker data.'); } } diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index 6aee803556fb75..17905b95a9e6a9 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -36,9 +36,18 @@ function notificationTypeForFilter(type: NotificationType) { } function notificationTypeForQuickFilter(type: NotificationType) { - if (type === 'quoted_update') return 'update'; - else if (type === 'quote') return 'mention'; - else return type; + switch (type) { + case 'quoted_update': + return 'update'; + case 'quote': + return 'mention'; + case 'collection_update': + return 'collection'; + case 'added_to_collection': + return 'collection'; + default: + return type; + } } function excludeAllTypesExcept(filter: string) { diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 78844594426683..232c56b77035c7 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -111,6 +111,9 @@ export function setupBrowserNotifications() { }; } +/** + * @param {(NotificationPermission) => void} callback + */ export function requestBrowserPermission(callback = noOp) { return dispatch => { requestNotificationPermission((permission) => { diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js deleted file mode 100644 index c291eb772a017c..00000000000000 --- a/app/javascript/mastodon/actions/server.js +++ /dev/null @@ -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, -}); diff --git a/app/javascript/mastodon/actions/server.ts b/app/javascript/mastodon/actions/server.ts new file mode 100644 index 00000000000000..b301919b237cd0 --- /dev/null +++ b/app/javascript/mastodon/actions/server.ts @@ -0,0 +1,48 @@ +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)); + } + }, + { + condition: (_, { getState }) => !getState().server.server.isLoading, + }, +); + +export const fetchExtendedDescription = createDataLoadingThunk( + 'server/extended_description', + () => apiGetExtendedDescription(), + { + condition: (_, { getState }) => + !getState().server.extendedDescription.isLoading, + }, +); + +export const fetchServerTranslationLanguages = createDataLoadingThunk( + 'server/translation_languages', + () => apiGetTranslationLanguages(), + { + condition: (_, { getState }) => + !getState().server.translationLanguages.isLoading, + }, +); + +export const fetchDomainBlocks = createDataLoadingThunk( + 'server/domain_blocks', + () => apiGetDomainBlocks(), + { + condition: (_, { getState }) => !getState().server.domainBlocks.isLoading, + }, +); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 4aa683f68e7182..36d447ff6cd3ca 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -121,7 +121,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, diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index 2229d17c56026a..52c5b017d96023 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -19,6 +19,11 @@ import type { ApiProfileUpdateParams, } from '../api_types/profile'; +export const apiGetAccounts = (ids: string[]) => + apiRequestGet('v1/accounts', { + id: ids, + }); + export const apiSubmitAccountNote = (id: string, value: string) => apiRequestPost(`v1/accounts/${id}/note`, { comment: value, diff --git a/app/javascript/mastodon/api/collections.ts b/app/javascript/mastodon/api/collections.ts index 7f39651f02ad60..badd3b43976920 100644 --- a/app/javascript/mastodon/api/collections.ts +++ b/app/javascript/mastodon/api/collections.ts @@ -15,48 +15,40 @@ import type { } from '../api_types/collections'; export const apiCreateCollection = (collection: ApiCreateCollectionPayload) => - apiRequestPost('v1_alpha/collections', collection); + apiRequestPost('v1/collections', collection); export const apiUpdateCollection = ({ id, ...collection }: ApiUpdateCollectionPayload) => - apiRequestPut( - `v1_alpha/collections/${id}`, - collection, - ); + apiRequestPut(`v1/collections/${id}`, collection); export const apiDeleteCollection = (collectionId: string) => - apiRequestDelete(`v1_alpha/collections/${collectionId}`); + apiRequestDelete(`v1/collections/${collectionId}`); export const apiGetCollection = (collectionId: string) => apiRequestGet( - `v1_alpha/collections/${collectionId}`, + `v1/collections/${collectionId}`, ); export const apiGetCollectionsCreatedByAccount = (accountId: string) => - apiRequestGet( - `v1_alpha/accounts/${accountId}/collections`, - ); + apiRequestGet(`v1/accounts/${accountId}/collections`); export const apiGetCollectionsFeaturingAccount = (accountId: string) => - apiRequestGet( - `v1_alpha/accounts/${accountId}/in_collections`, - ); + apiRequestGet(`v1/accounts/${accountId}/in_collections`); export const apiAddCollectionItem = (collectionId: string, accountId: string) => apiRequestPost( - `v1_alpha/collections/${collectionId}/items`, + `v1/collections/${collectionId}/items`, { account_id: accountId }, ); export const apiRemoveCollectionItem = (collectionId: string, itemId: string) => apiRequestDelete( - `v1_alpha/collections/${collectionId}/items/${itemId}`, + `v1/collections/${collectionId}/items/${itemId}`, ); export const apiRevokeCollectionInclusion = ( collectionId: string, itemId: string, -) => - apiRequestPost(`v1_alpha/collections/${collectionId}/items/${itemId}/revoke`); +) => apiRequestPost(`v1/collections/${collectionId}/items/${itemId}/revoke`); diff --git a/app/javascript/mastodon/api/instance.ts b/app/javascript/mastodon/api/instance.ts index 764e8daab254dc..b3f04f12d1ff74 100644 --- a/app/javascript/mastodon/api/instance.ts +++ b/app/javascript/mastodon/api/instance.ts @@ -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('v1/instance/privacy_policy'); + +export const apiGetInstance = () => + apiRequestGet('v2/instance'); + +export const apiGetExtendedDescription = () => + apiRequestGet('v1/instance/extended_description'); + +export const apiGetTranslationLanguages = () => + apiRequestGet( + 'v1/instance/translation_languages', + ); + +export const apiGetDomainBlocks = () => + apiRequestGet('v1/instance/domain_blocks'); diff --git a/app/javascript/mastodon/api/lists.ts b/app/javascript/mastodon/api/lists.ts index fa7e6e4554b249..a3f4b5be95f934 100644 --- a/app/javascript/mastodon/api/lists.ts +++ b/app/javascript/mastodon/api/lists.ts @@ -15,7 +15,7 @@ export const apiUpdate = (list: Partial) => export const apiGetLists = () => apiRequestGet('v1/lists'); -export const apiGetAccounts = (listId: string) => +export const apiGetListAccounts = (listId: string) => apiRequestGet(`v1/lists/${listId}/accounts`, { limit: 0, }); diff --git a/app/javascript/mastodon/api_types/instance.ts b/app/javascript/mastodon/api_types/instance.ts index 3a29684b703170..d709b833f28aad 100644 --- a/app/javascript/mastodon/api_types/instance.ts +++ b/app/javascript/mastodon/api_types/instance.ts @@ -1,3 +1,5 @@ +import type { ApiAccountJSON } from './accounts'; + export interface ApiTermsOfServiceJSON { effective_date: string; effective: boolean; @@ -9,3 +11,146 @@ 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; +} + +export interface ApiExtendedDescriptionJSON { + updated_at: string; + content: string; +} + +export interface ApiDomainBlockJSON { + domain: string; + digest: string; + severity: string; + severity_ex?: string; + comment: string; +} + +export type ApiTranslationLanguagesJSON = Record; + +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; + }; + 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[]; + fedibird_capabilities: string[]; + 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; + + search: { + enabled: string; + }; + + quotes: { + auto_accept_legacy_quotes: string; + }; + }; +} diff --git a/app/javascript/mastodon/api_types/search.ts b/app/javascript/mastodon/api_types/search.ts index 795cbb2b41f77f..961dd65699aff9 100644 --- a/app/javascript/mastodon/api_types/search.ts +++ b/app/javascript/mastodon/api_types/search.ts @@ -1,4 +1,5 @@ import type { ApiAccountJSON } from './accounts'; +import type { ApiCollectionJSON } from './collections'; import type { ApiStatusJSON } from './statuses'; import type { ApiHashtagJSON } from './tags'; @@ -8,4 +9,5 @@ export interface ApiSearchResultsJSON { accounts: ApiAccountJSON[]; statuses: ApiStatusJSON[]; hashtags: ApiHashtagJSON[]; + collections: ApiCollectionJSON[]; } diff --git a/app/javascript/mastodon/components/account_header/buttons.tsx b/app/javascript/mastodon/components/account_header/buttons.tsx index 7a5ca4332cd300..7d7f6e7d8b659d 100644 --- a/app/javascript/mastodon/components/account_header/buttons.tsx +++ b/app/javascript/mastodon/components/account_header/buttons.tsx @@ -11,7 +11,7 @@ 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 { CopyIconButton } from '../copy_button'; import { FollowButton } from '../follow_button'; import { IconButton } from '../icon_button'; diff --git a/app/javascript/mastodon/components/account_header/name.tsx b/app/javascript/mastodon/components/account_header/name.tsx index 4a38b47ed8a1b0..6d690980edd3ee 100644 --- a/app/javascript/mastodon/components/account_header/name.tsx +++ b/app/javascript/mastodon/components/account_header/name.tsx @@ -7,17 +7,16 @@ import classNames from 'classnames'; import Overlay from 'react-overlays/esm/Overlay'; -import { showAlert } from '@/mastodon/actions/alerts'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useRelationship } from '@/mastodon/hooks/useRelationship'; -import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import { useAppSelector } from '@/mastodon/store'; import AtIcon from '@/material-icons/400-24px/alternate_email.svg?react'; 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 { CopyButton } from '../copy_button'; import { DisplayName } from '../display_name'; import { Icon } from '../icon'; @@ -92,17 +91,6 @@ const AccountNameHelp: FC<{ const handle = `@${username}@${domain}`; - const dispatch = useAppDispatch(); - const [copied, setCopied] = useState(false); - const handleCopy = useCallback(() => { - void navigator.clipboard.writeText(handle); - setCopied(true); - dispatch(showAlert({ message: messages.copied })); - setTimeout(() => { - setCopied(false); - }, 700); - }, [handle, dispatch]); - return ( <> + )} diff --git a/app/javascript/mastodon/components/account_header/styles.module.scss b/app/javascript/mastodon/components/account_header/styles.module.scss index 2daf3867346011..4de49f79aea6cc 100644 --- a/app/javascript/mastodon/components/account_header/styles.module.scss +++ b/app/javascript/mastodon/components/account_header/styles.module.scss @@ -110,6 +110,9 @@ word-break: break-all; text-align: left; + /* Allow the handle text to be selected */ + user-select: text; + > svg { width: 16px; height: 16px; @@ -480,6 +483,7 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; justify-content: center; align-items: flex-start; margin: 16px 0; + padding: 16px; } .bannerBaseCentered { diff --git a/app/javascript/mastodon/components/copy_button.tsx b/app/javascript/mastodon/components/copy_button.tsx new file mode 100644 index 00000000000000..1812054d427ad2 --- /dev/null +++ b/app/javascript/mastodon/components/copy_button.tsx @@ -0,0 +1,75 @@ +import { useState, useCallback } from 'react'; + +import { defineMessages } from 'react-intl'; + +import classNames from 'classnames'; + +import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; +import { showAlert } from 'mastodon/actions/alerts'; +import { IconButton } from 'mastodon/components/icon_button'; +import { useAppDispatch } from 'mastodon/store'; + +import { Button } from './button'; + +const messages = defineMessages({ + copied: { + id: 'copy_icon_button.copied', + defaultMessage: 'Copied to clipboard', + }, +}); + +export function useCopyToClipboard({ text }: { text: string }) { + const [wasCopied, setWasCopied] = useState(false); + const dispatch = useAppDispatch(); + + const copyText = useCallback(() => { + void navigator.clipboard.writeText(text); + setWasCopied(true); + dispatch(showAlert({ message: messages.copied })); + setTimeout(() => { + setWasCopied(false); + }, 700); + }, [setWasCopied, text, dispatch]); + + return { copyText, wasCopied }; +} + +export const CopyButton: React.FC< + Omit< + React.ComponentPropsWithoutRef, + 'onClick' | 'text' | 'children' + > & { + value: string; + children: React.ReactNode | ((wasCopied: boolean) => React.ReactNode); + } +> = ({ value, children, ...otherProps }) => { + const { copyText, wasCopied } = useCopyToClipboard({ text: value }); + + const label = typeof children === 'function' ? children(wasCopied) : children; + + return ( + + ); +}; + +export const CopyIconButton: React.FC<{ + title: string; + value: string; + className?: string; + 'aria-describedby'?: string; +}> = ({ title, value, className, 'aria-describedby': ariaDescribedBy }) => { + const { copyText, wasCopied } = useCopyToClipboard({ text: value }); + + return ( + + ); +}; diff --git a/app/javascript/mastodon/components/copy_icon_button.tsx b/app/javascript/mastodon/components/copy_icon_button.tsx deleted file mode 100644 index 51cffe6292e16f..00000000000000 --- a/app/javascript/mastodon/components/copy_icon_button.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useState, useCallback } from 'react'; - -import { defineMessages } from 'react-intl'; - -import classNames from 'classnames'; - -import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; -import { showAlert } from 'mastodon/actions/alerts'; -import { IconButton } from 'mastodon/components/icon_button'; -import { useAppDispatch } from 'mastodon/store'; - -const messages = defineMessages({ - copied: { - id: 'copy_icon_button.copied', - defaultMessage: 'Copied to clipboard', - }, -}); - -export const CopyIconButton: React.FC<{ - title: string; - value: string; - className?: string; - 'aria-describedby'?: string; -}> = ({ title, value, className, 'aria-describedby': ariaDescribedBy }) => { - const [copied, setCopied] = useState(false); - const dispatch = useAppDispatch(); - - const handleClick = useCallback(() => { - void navigator.clipboard.writeText(value); - setCopied(true); - dispatch(showAlert({ message: messages.copied })); - setTimeout(() => { - setCopied(false); - }, 700); - }, [setCopied, value, dispatch]); - - return ( - - ); -}; diff --git a/app/javascript/mastodon/components/empty_state/empty_state.module.scss b/app/javascript/mastodon/components/empty_state/empty_state.module.scss index 64d9a4e584e937..868c6ea03b2817 100644 --- a/app/javascript/mastodon/components/empty_state/empty_state.module.scss +++ b/app/javascript/mastodon/components/empty_state/empty_state.module.scss @@ -13,8 +13,7 @@ .content { max-width: 370px; - svg, - img { + > :where(svg, img) { width: 200px; aspect-ratio: 1; object-fit: contain; @@ -22,16 +21,10 @@ margin-bottom: 16px; } - h3 { - font-size: 17px; - font-weight: 500; - text-wrap: balance; - line-height: 1.2; - } - p { margin-top: 8px; font-size: 15px; + line-height: 1.4; color: var(--color-text-secondary); text-wrap: pretty; } @@ -41,6 +34,18 @@ } } +.heading { + font-size: 17px; + font-weight: 500; + text-wrap: balance; + line-height: 1.2; +} + +.errorImage { + width: 280px; + margin: -10% 0; +} + [data-color-scheme='dark'] .defaultImage { --color-skin-1: #3a3a50; --color-skin-2: #67678e; diff --git a/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx b/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx index 83fce034686ecf..c1faaf6f399198 100644 --- a/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx +++ b/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx @@ -29,6 +29,14 @@ export const Default: Story = { }, }; +export const Error: Story = { + args: { + image: 'error', + title: 'Error', + message: 'Something went wrong loading the page.', + }, +}; + export const WithAction: Story = { args: { ...Default.args, diff --git a/app/javascript/mastodon/components/empty_state/index.tsx b/app/javascript/mastodon/components/empty_state/index.tsx index e332aaedb5ce49..3f0738ce5a5596 100644 --- a/app/javascript/mastodon/components/empty_state/index.tsx +++ b/app/javascript/mastodon/components/empty_state/index.tsx @@ -4,10 +4,15 @@ import classNames from 'classnames'; import ElephantImage from '@/images/elephant_ui.svg?react'; +import { GIF } from '../gif'; + import classes from './empty_state.module.scss'; const images = { default: , + error: ( + + ), }; /** @@ -21,6 +26,7 @@ export const EmptyState: React.FC<{ title?: React.ReactNode; message?: React.ReactNode; children?: React.ReactNode; + headingLevel?: 'h2' | 'h3' | 'h4'; className?: string; }> = ({ image = 'default', @@ -29,6 +35,7 @@ export const EmptyState: React.FC<{ ), message, children, + headingLevel: Heading = 'h2', className, }) => { const imageToRender = typeof image === 'string' ? images[image] : image; @@ -38,7 +45,7 @@ export const EmptyState: React.FC<{ {(title || message || imageToRender) && (
{imageToRender} - {!!title &&

{title}

} + {!!title && {title}} {!!message &&

{message}

}
)} diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.tsx index f3e7b454765b77..a0e0a36f7900bb 100644 --- a/app/javascript/mastodon/components/form_fields/combobox_field.tsx +++ b/app/javascript/mastodon/components/form_fields/combobox_field.tsx @@ -100,6 +100,10 @@ interface ComboboxProps< * Icon to be displayed in the text input */ icon?: TextInputProps['icon'] | null; + /** + * Set to true to open as soon as there is focus + */ + openOnFocus?: boolean; /** * Set to false to keep the menu open when an item is selected */ @@ -217,8 +221,10 @@ const ComboboxWithRef = ( renderGroupTitle, renderItem, onSelectItem, + onFocus, onChange, onKeyDown, + openOnFocus = false, closeOnSelect = true, suppressMenu = false, icon = SearchIcon, @@ -288,6 +294,16 @@ const ComboboxWithRef = ( } }, []); + const handleFocus: React.FocusEventHandler = useCallback( + (e) => { + if (openOnFocus) { + setShouldMenuOpen(true); + } + onFocus?.(e); + }, + [onFocus, openOnFocus], + ); + const handleInputChange = useCallback( (e: React.ChangeEvent) => { onChange(e); @@ -487,6 +503,7 @@ const ComboboxWithRef = ( autoComplete='off' spellCheck='false' value={value} + onFocus={handleFocus} onChange={handleInputChange} onKeyDown={handleInputKeyDown} icon={icon ?? undefined} diff --git a/app/javascript/mastodon/components/form_fields/copy_link_field.tsx b/app/javascript/mastodon/components/form_fields/copy_link_field.tsx index d772315adeb5ea..ab8849d45aa10c 100644 --- a/app/javascript/mastodon/components/form_fields/copy_link_field.tsx +++ b/app/javascript/mastodon/components/form_fields/copy_link_field.tsx @@ -4,7 +4,7 @@ import { useIntl } from 'react-intl'; import classNames from 'classnames'; -import { CopyIconButton } from 'mastodon/components/copy_icon_button'; +import { CopyIconButton } from '@/mastodon/components/copy_button'; import classes from './copy_link_field.module.scss'; import { FormFieldWrapper } from './form_field_wrapper'; diff --git a/app/javascript/mastodon/components/gif.tsx b/app/javascript/mastodon/components/gif.tsx index 1cc0881a5a3ebb..7bce9857845f81 100644 --- a/app/javascript/mastodon/components/gif.tsx +++ b/app/javascript/mastodon/components/gif.tsx @@ -4,7 +4,7 @@ import { autoPlayGif } from 'mastodon/initial_state'; export const GIF: React.FC<{ src: string; staticSrc: string; - className: string; + className?: string; animate?: boolean; }> = ({ src, staticSrc, className, animate = autoPlayGif }) => { const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate); diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx index 8810fd204f59df..daba6d437b317b 100644 --- a/app/javascript/mastodon/components/hotkeys/index.tsx +++ b/app/javascript/mastodon/components/hotkeys/index.tsx @@ -109,8 +109,8 @@ const hotkeyMatcherMap = { mention: just('m'), open: any('enter', 'o'), openProfile: just('p'), - moveDown: just('j'), - moveUp: just('k'), + moveDown: any('j', 'pagedown'), + moveUp: any('k', 'pageup'), moveToTop: just('0'), toggleHidden: just('x'), toggleSensitive: just('h'), @@ -148,9 +148,15 @@ const hotkeyMatcherMap = { type HotkeyName = keyof typeof hotkeyMatcherMap; -export type HandlerMap = Partial< - Record void> ->; +type HandlerFunction = + // When a handler returns a boolean, it should indicate whether the + // hotkey was handled (i.e. it resulted in an action). + // If `false` is returned, `preventDefault` and `stopPropagation` + // will not be called on the keyboard event, restoring the key's + // native behaviour. + ((event: KeyboardEvent) => boolean) | ((event: KeyboardEvent) => void); + +export type HandlerMap = Partial>; export function useHotkeys(handlers: HandlerMap) { const ref = useRef(null); @@ -185,7 +191,7 @@ export function useHotkeys(handlers: HandlerMap) { const matchCandidates: { // A candidate will be have an undefined handler if it's matched, // but handled in a parent component rather than this one. - handler: ((event: KeyboardEvent) => void) | undefined; + handler: HandlerFunction | undefined; priority: number; }[] = []; @@ -210,9 +216,11 @@ export function useHotkeys(handlers: HandlerMap) { const bestMatchingHandler = matchCandidates.at(0)?.handler; if (bestMatchingHandler) { - bestMatchingHandler(event); - event.stopPropagation(); - event.preventDefault(); + const wasHandled = bestMatchingHandler(event); + if (wasHandled !== false) { + event.stopPropagation(); + event.preventDefault(); + } } // Add last keypress to buffer diff --git a/app/javascript/mastodon/components/hover_card_account.tsx b/app/javascript/mastodon/components/hover_card_account.tsx index 1bbfa01f3c4749..aba544bb59d25f 100644 --- a/app/javascript/mastodon/components/hover_card_account.tsx +++ b/app/javascript/mastodon/components/hover_card_account.tsx @@ -95,7 +95,7 @@ export const HoverCardAccount = forwardRef< ) : ( )} diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx index a0c704a4e7c995..dd7ff3b3e7e597 100644 --- a/app/javascript/mastodon/components/hover_card_controller.tsx +++ b/app/javascript/mastodon/components/hover_card_controller.tsx @@ -144,11 +144,16 @@ export const HoverCardController: React.FC = () => { setScrollTimeout(handleScrollEnd, 100); }; - const handleMouseMove = () => { + const handleMouseMove = (e: MouseEvent) => { if (isUsingTouch) { isUsingTouch = false; } + const hasMoved = Math.max(e.movementX, e.movementY) > 0; + if (!hasMoved) { + return; + } + delayEnterTimeout(enterDelay); cancelMoveTimeout(); diff --git a/app/javascript/mastodon/components/limited_account_hint.tsx b/app/javascript/mastodon/components/limited_account_hint.tsx index b780e802e5f112..62573345b6b20a 100644 --- a/app/javascript/mastodon/components/limited_account_hint.tsx +++ b/app/javascript/mastodon/components/limited_account_hint.tsx @@ -21,7 +21,7 @@ export const LimitedAccountHint: React.FC<{ accountId: string }> = ({

diff --git a/app/javascript/mastodon/components/scrollable_list/index.jsx b/app/javascript/mastodon/components/scrollable_list/index.jsx index 80c0aeb6553771..a921457a8558a9 100644 --- a/app/javascript/mastodon/components/scrollable_list/index.jsx +++ b/app/javascript/mastodon/components/scrollable_list/index.jsx @@ -285,7 +285,7 @@ class ScrollableList extends PureComponent { if (this.props.bindToDocument) { document.removeEventListener('scroll', this.handleScroll); document.removeEventListener('wheel', this.handleWheel, listenerOptions); - } else { + } else if (this.node) { this.node.removeEventListener('scroll', this.handleScroll); this.node.removeEventListener('wheel', this.handleWheel, listenerOptions); } diff --git a/app/javascript/mastodon/components/server_banner.jsx b/app/javascript/mastodon/components/server_banner.jsx index 939a92b2815052..b5484a1a379da4 100644 --- a/app/javascript/mastodon/components/server_banner.jsx +++ b/app/javascript/mastodon/components/server_banner.jsx @@ -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 (
@@ -50,8 +50,8 @@ class ServerBanner extends PureComponent { @@ -66,14 +66,14 @@ class ServerBanner extends PureComponent {
- ) : server.get('description')} + ) : server.item?.description}

- +
@@ -87,7 +87,7 @@ class ServerBanner extends PureComponent { ) : ( <> - +
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 48e26d851c4d17..0b460db05b9aae 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -576,7 +576,7 @@ class Status extends ImmutablePureComponent { ).find((item) => compareUrls(item.get('url'), cardUrl)); if (taggedCollection) { - media = ; + media = ; } else { media = ( + ); } } diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index 55d3c954359f6f..9f0adc2ab6c903 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -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; - collection?: Pick; + collectionId?: string; } export const HandledLink: FC> = ({ @@ -24,7 +23,7 @@ export const HandledLink: FC> = ({ prevText, hashtagAccountId, mention, - collection, + collectionId, className, children, ...props @@ -61,11 +60,11 @@ export const HandledLink: FC> = ({ {children} ); - } else if (collection) { + } else if (collectionId) { return ( {children} @@ -98,15 +97,18 @@ export const HandledLink: FC> = ({ export const useElementHandledLink = ({ hashtagAccountId, + hrefToCollectionId: hrefToCollection, hrefToMention, }: { hashtagAccountId?: string; + hrefToCollectionId?: (href: string) => string | undefined; hrefToMention?: (href: string) => ApiMentionJSON | undefined; } = {}) => { const onElement = useCallback( (element, { key, ...props }, children) => { if (element instanceof HTMLAnchorElement) { const mention = hrefToMention?.(element.href); + const collectionId = hrefToCollection?.(element.href); return ( {children} @@ -123,7 +126,7 @@ export const useElementHandledLink = ({ } return undefined; }, - [hashtagAccountId, hrefToMention], + [hashtagAccountId, hrefToCollection, hrefToMention], ); return { onElement }; }; diff --git a/app/javascript/mastodon/components/status/header.tsx b/app/javascript/mastodon/components/status/header.tsx index f191c8a922a8e6..36dad37ce1fca8 100644 --- a/app/javascript/mastodon/components/status/header.tsx +++ b/app/javascript/mastodon/components/status/header.tsx @@ -24,7 +24,8 @@ export interface StatusHeaderProps { status: Status; account?: Account; avatarSize?: number; - children?: ReactNode; + contentBeforeDate?: ReactNode; + contentAfterDate?: ReactNode; wrapperProps?: HTMLAttributes; displayNameProps?: DisplayNameProps; onHeaderClick?: MouseEventHandler; @@ -37,10 +38,11 @@ export type StatusHeaderRenderFn = (args: StatusHeaderProps) => ReactNode; export const StatusHeader: FC = ({ status, account, - children, className, avatarSize = 48, wrapperProps, + contentBeforeDate, + contentAfterDate, onHeaderClick, }) => { const statusAccount = status.get('account') as Account | undefined; @@ -73,6 +75,14 @@ export const StatusHeader: FC = ({ className={classNames('status__info', className)} /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ > + + + {contentBeforeDate} + = ({ {editedAt && } - - - {children} + {contentAfterDate}
); }; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index d794130b28ed32..ab6211faea4348 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -69,7 +69,7 @@ class TranslateButton extends PureComponent { } const mapStateToProps = state => ({ - languages: state.getIn(['server', 'translationLanguages', 'items']), + languages: state.server.translationLanguages.item, }); 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} @@ -187,7 +187,7 @@ class StatusContent extends PureComponent { const renderReadMore = this.props.onClick && status.get('collapsed'); const contentLocale = intl.locale.replace(/[_-].*/, ''); - const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); + const targetLanguages = this.props.languages?.[status.get('language') || 'und']; const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && (['public', 'unlisted'].includes(status.get('visibility')) || status.getIn(['account', 'other_settings', 'translatable_private'])) && diff --git a/app/javascript/mastodon/components/status_quoted.tsx b/app/javascript/mastodon/components/status_quoted.tsx index 5c9804fb40e8cf..b269792a5e3f1e 100644 --- a/app/javascript/mastodon/components/status_quoted.tsx +++ b/app/javascript/mastodon/components/status_quoted.tsx @@ -225,17 +225,20 @@ export const QuotedStatus: React.FC = ({ const intl = useIntl(); const headerRenderFn: StatusHeaderRenderFn = useCallback( (props) => ( - - {onQuoteCancel && ( - - )} - + + ) + } + /> ), [intl, onQuoteCancel], ); diff --git a/app/javascript/mastodon/features/about/components/rules.tsx b/app/javascript/mastodon/features/about/components/rules.tsx index 52339329fed71e..8fd7f2a3a7ff5e 100644 --- a/app/javascript/mastodon/features/about/components/rules.tsx +++ b/app/javascript/mastodon/features/about/components/rules.tsx @@ -5,8 +5,8 @@ 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 { ApiRuleJSON } from '@/mastodon/api_types/instance'; import type { SelectItem } from '@/mastodon/components/dropdown_selector'; import { Select } from '@/mastodon/components/form_fields'; import type { RootState } from '@/mastodon/store'; @@ -105,13 +105,13 @@ export const RulesSection: FC = ({ isLoading = false }) => { defaultMessage='Language' /> - {localeOptions.map((option) => ( - ))} @@ -122,16 +122,10 @@ export const RulesSection: FC = ({ isLoading = false }) => { ); }; -const selectRules = (state: RootState) => { - const rules = state.server.getIn([ - 'server', - 'rules', - ]) as ImmutableList | null; - if (!rules) { - return []; - } - return rules.toJS() as Rule[]; -}; +const selectRules = createSelector( + [(state: RootState) => state.server.server.item], + (item) => item?.rules ?? [], +); const rulesSelector = createSelector( [selectRules, (_state, locale: string) => locale], @@ -144,18 +138,19 @@ const rulesSelector = createSelector( return rule; } + const translatedRule: ApiRuleJSON = { ...rule }; const partialLocale = locale.split('-')[0]; if (partialLocale && translations[partialLocale]) { - rule.text = translations[partialLocale].text; - rule.hint = translations[partialLocale].hint; + translatedRule.text = translations[partialLocale].text; + translatedRule.hint = translations[partialLocale].hint; } if (translations[locale]) { - rule.text = translations[locale].text; - rule.hint = translations[locale].hint; + translatedRule.text = translations[locale].text; + translatedRule.hint = translations[locale].hint; } - return rule; + return translatedRule; }); }, ); diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 1aad1464bd5921..7427abeadf0254 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -19,6 +19,7 @@ import { LinkFooter} from 'mastodon/features/ui/components/link_footer'; import { CapabilityIcon } from './components/capability_icon'; import { Section } from './components/section'; import { RulesSection } from './components/rules'; +import { getColumnSkipLinkId } from '../ui/components/skip_links'; const messages = defineMessages({ title: { id: 'column.about', defaultMessage: 'About' }, @@ -58,10 +59,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 { @@ -92,34 +93,34 @@ class About extends PureComponent { }; render () { - const { multiColumn, intl, server, extendedDescription, domainBlocks, locale } = this.props; - const isLoading = server.get('isLoading'); + const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props; + const isLoading = server.isLoading; - const fedibirdCapabilities = server.get('fedibird_capabilities') || []; // thinking about isLoading is true + const fedibirdCapabilities = server.item?.fedibird_capabilities || []; // thinking about isLoading is true const isPublicUnlistedVisibility = fedibirdCapabilities.includes('kmyblue_visibility_public_unlisted'); const isPublicVisibility = !fedibirdCapabilities.includes('kmyblue_no_public_visibility'); const isEmojiReaction = fedibirdCapabilities.includes('emoji_reaction'); const isLocalTimeline = !fedibirdCapabilities.includes('timeline_no_local'); - const isFullTextSearch = server.getIn(['configuration', 'search', 'enabled']); - const isAcceptLegacyQuotes = server.getIn(['configuration', 'quotes', 'auto_accept_legacy_quotes']); + const isFullTextSearch = server.item?.search?.enabled; + const isAcceptLegacyQuotes = server.item?.quotes?.auto_accept_legacy_quotes; - const email = server.getIn(['contact', 'email']) || ''; + const email = server.item?.contact?.email || ''; const emailLink = email.startsWith('https://') ? email : `mailto:${email}`; return ( -
+
`${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' /> -

{isLoading ? : server.get('domain')}

+

{isLoading ? : server.domain}

Mastodon }} />

@@ -127,7 +128,7 @@ class About extends PureComponent {

- +

@@ -135,12 +136,12 @@ class About extends PureComponent {
- {extendedDescription.get('isLoading') ? ( + {extendedDescription.isLoading ? ( <>
@@ -150,10 +151,10 @@ class About extends PureComponent {
- ) : (extendedDescription.get('content')?.length > 0 ? ( + ) : (extendedDescription.item?.content?.length > 0 ? (
) : (

@@ -189,26 +190,26 @@ class About extends PureComponent {
- {domainBlocks.get('isLoading') ? ( + {domainBlocks.isLoading ? ( <>
- ) : (domainBlocks.get('isAvailable') ? ( + ) : (domainBlocks.isAvailable ? ( <>

- {domainBlocks.get('items').size > 0 && ( + {domainBlocks.items.length > 0 && (
- {domainBlocks.get('items').map(block => ( -
+ {domainBlocks.items.map(block => ( +
-
{block.get('domain')}
- {intl.formatMessage(severityMessages[block.get('severity_ex') || block.get('severity')].title)} +
{block.domain}
+ {intl.formatMessage(severityMessages[block.severity_ex || block.severity].title)}
-

{(block.get('comment') || '').length > 0 ? block.get('comment') : }

+

{(block.comment ?? '').length > 0 ? block.comment : }

))}
@@ -219,10 +220,10 @@ class About extends PureComponent { ))}
- +
-

+

diff --git a/app/javascript/mastodon/features/account_edit/components/column.tsx b/app/javascript/mastodon/features/account_edit/components/column.tsx index a9b0f8cbd5d780..2718aab7f21f98 100644 --- a/app/javascript/mastodon/features/account_edit/components/column.tsx +++ b/app/javascript/mastodon/features/account_edit/components/column.tsx @@ -9,7 +9,7 @@ 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'; -import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error'; +import { BundleColumnError } from '@/mastodon/features/ui/components/bundle_column_error'; import { useColumnsContext } from '../../ui/util/columns_context'; import classes from '../styles.module.scss'; diff --git a/app/javascript/mastodon/features/account_edit/featured_tags.tsx b/app/javascript/mastodon/features/account_edit/featured_tags.tsx index 5fc602208dff56..fedfde62e3d1d6 100644 --- a/app/javascript/mastodon/features/account_edit/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_edit/featured_tags.tsx @@ -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 ?? [], diff --git a/app/javascript/mastodon/features/account_edit/index.tsx b/app/javascript/mastodon/features/account_edit/index.tsx index 0199c3efa45aa1..d00473043e1d46 100644 --- a/app/javascript/mastodon/features/account_edit/index.tsx +++ b/app/javascript/mastodon/features/account_edit/index.tsx @@ -133,12 +133,7 @@ export const AccountEdit: FC = () => { const maxFieldCount = useAppSelector( (state) => - (state.server.getIn([ - 'server', - 'configuration', - 'accounts', - 'max_profile_fields', - ]) as number | undefined) ?? 4, + state.server.server.item?.configuration.accounts.max_profile_fields ?? 4, ); const handleOpenModal = useCallback( diff --git a/app/javascript/mastodon/features/account_edit/modals/bio_modal.tsx b/app/javascript/mastodon/features/account_edit/modals/bio_modal.tsx index ff4febeebb5cad..8d286dedce7b66 100644 --- a/app/javascript/mastodon/features/account_edit/modals/bio_modal.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/bio_modal.tsx @@ -36,13 +36,7 @@ export const BioModal: FC = ({ onClose }) => { ); const [newBio, setNewBio] = useState(bio ?? ''); const maxLength = useAppSelector( - (state) => - state.server.getIn([ - 'server', - 'configuration', - 'accounts', - 'max_note_length', - ]) as number | undefined, + (state) => state.server.server.item?.configuration.accounts.max_note_length, ); const dispatch = useAppDispatch(); diff --git a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx index 98324cf4dd2d9c..9fc26ac525a348 100644 --- a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx @@ -9,8 +9,6 @@ import type { FC, FocusEventHandler } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import type { Map as ImmutableMap } from 'immutable'; - import { closeModal } from '@/mastodon/actions/modal'; import { Button } from '@/mastodon/components/button'; import type { FieldStatus } from '@/mastodon/components/form_fields'; @@ -97,15 +95,10 @@ const messages = defineMessages({ // We have two different values- the hard limit set by the server, // and the soft limit for mobile display. const selectFieldLimits = createAppSelector( - [ - (state) => - state.server.getIn(['server', 'configuration', 'accounts']) as - | ImmutableMap - | undefined, - ], + [(state) => state.server.server.item?.configuration.accounts], (accounts) => ({ - nameLimit: accounts?.get('profile_field_name_limit'), - valueLimit: accounts?.get('profile_field_value_limit'), + nameLimit: accounts?.profile_field_name_limit, + valueLimit: accounts?.profile_field_value_limit, }), ); diff --git a/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx b/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx index d9d014f792b59f..61f5d5ffdd035e 100644 --- a/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx @@ -84,15 +84,8 @@ export const ImageAltTextField: FC<{ }> = ({ imageSrc, altText, onChange, hideTip }) => { const altLimit = useAppSelector( (state) => - state.server.getIn( - [ - 'server', - 'configuration', - 'accounts', - 'max_header_description_length', - ], - 150, - ) as number, + state.server.server.item?.configuration.accounts + .max_header_description_length ?? 0, ); const handleChange: ChangeEventHandler = useCallback( diff --git a/app/javascript/mastodon/features/account_edit/modals/name_modal.tsx b/app/javascript/mastodon/features/account_edit/modals/name_modal.tsx index 07779a02a7fd0e..de42977140019c 100644 --- a/app/javascript/mastodon/features/account_edit/modals/name_modal.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/name_modal.tsx @@ -33,12 +33,7 @@ export const NameModal: FC = ({ onClose }) => { ); const maxLength = useAppSelector( (state) => - state.server.getIn([ - 'server', - 'configuration', - 'accounts', - 'max_display_name_length', - ]) as number | undefined, + state.server.server.item?.configuration.accounts.max_display_name_length, ); const [newName, setNewName] = useState(displayName ?? ''); diff --git a/app/javascript/mastodon/features/account_featured/components/empty_message.tsx b/app/javascript/mastodon/features/account_featured/components/empty_message.tsx index 19ced4ce23cade..ad903d5a5d120a 100644 --- a/app/javascript/mastodon/features/account_featured/components/empty_message.tsx +++ b/app/javascript/mastodon/features/account_featured/components/empty_message.tsx @@ -2,14 +2,14 @@ import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useParams } from 'react-router'; import { Link } from 'react-router-dom'; import { openModal } from '@/mastodon/actions/modal'; import { Button } from '@/mastodon/components/button'; +import { DisplayName } from '@/mastodon/components/display_name'; import { EmptyState } from '@/mastodon/components/empty_state'; import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint'; -import { areCollectionsEnabled } from '@/mastodon/features/collections/utils'; +import { useAccount } from '@/mastodon/hooks/useAccount'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import { useAppDispatch } from '@/mastodon/store'; @@ -28,8 +28,8 @@ export const EmptyMessage: React.FC = ({ blockedBy, withoutAddCollectionButton, }) => { - const { acct } = useParams<{ acct?: string }>(); const me = useCurrentAccountId(); + const account = useAccount(accountId); const dispatch = useAppDispatch(); @@ -49,56 +49,39 @@ export const EmptyMessage: React.FC = ({ let title: React.ReactNode = null; let message: React.ReactNode = null; - const hasCollections = areCollectionsEnabled(); - if (me === accountId) { - if (hasCollections) { - // Return only here to insert the "Create a collection" button as the action for the empty state. - return ( - - } - message={ - - } - > - {!withoutAddCollectionButton && ( - - - - )} - - - ); - } else { - title = ( - - ); - message = ( - - ); - } + + )} + + + ); } else if (suspended) { title = ( = ({ /> ); } else { - if (acct) { + if (account) { title = ( }} /> ); } else { diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index 3082f19abc9997..b743a7302ec3dc 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -19,7 +19,7 @@ import { } from '@/mastodon/components/scrollable_list/components'; import type { TruncatedListItemInfo } from '@/mastodon/components/truncated_list'; import { TruncatedListItems } from '@/mastodon/components/truncated_list'; -import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error'; +import { BundleColumnError } from '@/mastodon/features/ui/components/bundle_column_error'; import Column from '@/mastodon/features/ui/components/column'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccountId } from '@/mastodon/hooks/useAccountId'; @@ -30,13 +30,10 @@ import AddIcon from '@/material-icons/400-24px/add.svg?react'; import { CollectionListItem } from '../collections/components/collection_list_item'; import { useCollectionsCreatedBy } from '../collections/overview/created_by_you'; -import { areCollectionsEnabled } from '../collections/utils'; import { EmptyMessage } from './components/empty_message'; import { Subheading, SubheadingLink } from './components/subheading'; -const collectionsEnabled = areCollectionsEnabled(); - const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ multiColumn, }) => { @@ -98,14 +95,11 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ); const hasCollections = - collectionsEnabled && - collectionsLoadStatus === 'idle' && - listedCollections.length > 0; + collectionsLoadStatus === 'idle' && listedCollections.length > 0; const hasFeaturedAccounts = !featuredAccountIds.isEmpty(); - const isLoading = - !accountId || (collectionsEnabled && collectionsLoadStatus !== 'idle'); + const isLoading = !accountId || collectionsLoadStatus !== 'idle'; if (accountId === null) { return ; @@ -165,57 +159,53 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ )} - {collectionsEnabled && ( - <> - -

- -

- {accountId === me && ( - + +

+ +

+ {accountId === me && ( + + + + )} +
+ {hasCollections ? ( + + -
- )} -
- {hasCollections ? ( - - - ), - subtitle: ( - - ), - }} - renderListItem={renderListItem} - /> - - ) : ( -