diff --git a/.browserslistrc b/.browserslistrc index 0376af4bccd..6367e4d3581 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,6 +1,7 @@ [production] defaults > 0.2% +firefox >= 78 ios >= 15.6 not dead not OperaMini all diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c6dcc4d46a3..3aa0bbf7da4 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -11,5 +11,8 @@ RUN apt-get update && \ export DEBIAN_FRONTEND=noninteractive && \ apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev +# Disable download prompt for Corepack +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + # Move welcome message to where VS Code expects it COPY .devcontainer/welcome-message.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index 1e2e1ba7de2..5c7263c8746 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -69,7 +69,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.5.7 + image: libretranslate/libretranslate:v1.6.1 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.env.production.sample b/.env.production.sample index 0bf01bdc361..0b458a1aa96 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -1,5 +1,5 @@ # This is a sample configuration file. You can generate your configuration -# with the `rake mastodon:setup` interactive setup wizard, but to customize +# with the `bundle exec rails mastodon:setup` interactive setup wizard, but to customize # your setup even further, you'll need to edit it manually. This sample does # not demonstrate all available configuration options. Please look at # https://docs.joinmastodon.org/admin/config/ for the full documentation. @@ -40,14 +40,14 @@ ES_PASS=password # Secrets # ------- -# Make sure to use `rake secret` to generate secrets +# Make sure to use `bundle exec rails secret` to generate secrets # ------- SECRET_KEY_BASE= OTP_SECRET= # Web Push # -------- -# Generate with `rake mastodon:webpush:generate_vapid_key` +# Generate with `bundle exec rails mastodon:webpush:generate_vapid_key` # -------- VAPID_PRIVATE_KEY= VAPID_PUBLIC_KEY= diff --git a/.eslintrc.js b/.eslintrc.js index d1182628263..b6e4253e61d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -316,7 +316,7 @@ module.exports = defineConfig({ ], parserOptions: { - project: true, + projectService: true, tsconfigRootDir: __dirname, }, diff --git a/.github/codecov.yml b/.github/codecov.yml index 701ba3af8f7..21af6d0d457 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -9,3 +9,5 @@ coverage: default: # GitHub status check is not blocking informational: true +github_checks: + annotations: false diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 03787dfac6c..8a106762838 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -7,6 +7,7 @@ ':prConcurrentLimitNone', // Remove limit for open PRs at any time. ':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour. ], + rebaseWhen: 'conflicted', minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it // packageRules order is important, they are applied from top to bottom and are merged, // meaning the most important ones must be at the bottom, for example grouping rules @@ -14,9 +15,6 @@ // to `null` after any other rule set it to something. dependencyDashboardHeader: 'This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. Before approving any upgrade: read the description and comments in the [`renovate.json5` file](https://github.com/mastodon/mastodon/blob/main/.github/renovate.json5).', postUpdateOptions: ['yarnDedupeHighest'], - lockFileMaintenance: { - enabled: true, - }, packageRules: [ { // Require Dependency Dashboard Approval for major version bumps of these node packages @@ -90,6 +88,7 @@ }, { // Update devDependencies every week, with one grouped PR + matchManagers: ['npm'], matchDepTypes: 'devDependencies', matchUpdateTypes: ['patch', 'minor'], groupName: 'devDependencies (non-major)', @@ -98,8 +97,7 @@ { // Group all eslint-related packages with `eslint` in the same PR matchManagers: ['npm'], - matchPackageNames: ['eslint'], - matchPackagePrefixes: ['eslint-', '@typescript-eslint/'], + matchPackageNames: ['eslint', 'eslint-*', '@typescript-eslint/*'], matchUpdateTypes: ['patch', 'minor'], groupName: 'eslint (non-major)', }, @@ -115,7 +113,8 @@ }, { // Update @types/* packages every week, with one grouped PR - matchPackagePrefixes: '@types/', + matchManagers: ['npm'], + matchPackageNames: '@types/*', matchUpdateTypes: ['patch', 'minor'], groupName: 'DefinitelyTyped types (non-major)', extends: ['schedule:weekly'], @@ -132,23 +131,21 @@ { // Group all RuboCop packages with `rubocop` in the same PR matchManagers: ['bundler'], - matchPackageNames: ['rubocop'], - matchPackagePrefixes: ['rubocop-'], + matchPackageNames: ['rubocop', 'rubocop-*'], matchUpdateTypes: ['patch', 'minor'], groupName: 'RuboCop (non-major)', }, { // Group all RSpec packages with `rspec` in the same PR matchManagers: ['bundler'], - matchPackageNames: ['rspec'], - matchPackagePrefixes: ['rspec-'], + matchPackageNames: ['rspec', 'rspec-*'], matchUpdateTypes: ['patch', 'minor'], groupName: 'RSpec (non-major)', }, { // Group all opentelemetry-ruby packages in the same PR matchManagers: ['bundler'], - matchPackagePrefixes: ['opentelemetry-'], + matchPackageNames: ['opentelemetry-*'], matchUpdateTypes: ['patch', 'minor'], groupName: 'opentelemetry-ruby (non-major)', }, diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index dbb32af9bf5..03a0f5bf37b 100644 --- a/.github/workflows/build-container-image.yml +++ b/.github/workflows/build-container-image.yml @@ -85,7 +85,7 @@ jobs: tags: ${{ inputs.tags }} labels: ${{ inputs.labels }} - - uses: docker/build-push-action@v5 + - uses: docker/build-push-action@v6 with: context: . file: ${{ inputs.file_to_build }} diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index e3e2da0c787..2341d6e67f6 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -1,8 +1,10 @@ name: Bundler Audit on: + merge_group: push: - branches-ignore: - - 'dependabot/**' + branches: + - 'main' + - 'stable-*' paths: - 'Gemfile*' - '.ruby-version' diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index ceb385933b2..5a1c0519665 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -2,9 +2,13 @@ name: Check i18n on: push: - branches: [main] + branches: + - 'main' + - 'stable-*' pull_request: - branches: [main] + branches: + - 'main' + - 'stable-*' env: RAILS_ENV: test diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6fb93b7fef4..8690e9ed6d1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,11 +1,15 @@ name: 'CodeQL' on: + merge_group: push: - branches: ['main'] + branches: + - 'main' + - 'stable-*' pull_request: - # The branches below must be a subset of the branches above - branches: ['main'] + branches: + - 'main' + - 'stable-*' schedule: - cron: '22 6 * * 1' diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index e9da7cb26f9..f1817b3e9a1 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -26,7 +26,7 @@ jobs: # Download the translation files from Crowdin - name: crowdin action - uses: crowdin/github-action@v1 + uses: crowdin/github-action@v2 with: upload_sources: false upload_translations: false @@ -52,7 +52,7 @@ jobs: # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@v6.0.5 + uses: peter-evans/create-pull-request@v7.0.1 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations (automated)' diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml index 705af12c025..b7a0a2b8199 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -1,9 +1,11 @@ name: Crowdin / Upload translations on: + merge_group: push: branches: - - main + - 'main' + - 'stable-*' paths: - crowdin.yml - app/javascript/mastodon/locales/en.json @@ -17,13 +19,14 @@ on: jobs: upload-translations: runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' steps: - name: Checkout uses: actions/checkout@v4 - name: crowdin action - uses: crowdin/github-action@v1 + uses: crowdin/github-action@v2 with: upload_sources: true upload_translations: false diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 2d483b50229..c10f350a02e 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -1,6 +1,10 @@ name: Check formatting on: + merge_group: push: + branches: + - 'main' + - 'stable-*' pull_request: jobs: diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index d3b8035cd86..95fcd569420 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -1,9 +1,10 @@ name: CSS Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' paths: - 'package.json' - 'yarn.lock' diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index ca4b0c80bfd..a1a9e99c902 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -1,9 +1,10 @@ name: Haml Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' paths: - '.github/workflows/haml-lint-problem-matcher.json' - '.github/workflows/lint-haml.yml' diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 1c1ecc2b220..7d31a5e20e7 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -1,9 +1,10 @@ name: JavaScript Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' paths: - 'package.json' - 'yarn.lock' diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index b3a89c3caf1..277e456146f 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -1,9 +1,10 @@ name: Ruby Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' paths: - 'Gemfile*' - '.rubocop*.yml' diff --git a/.github/workflows/rebase-needed.yml b/.github/workflows/rebase-needed.yml index 8784397a8fb..f0fc8b0db72 100644 --- a/.github/workflows/rebase-needed.yml +++ b/.github/workflows/rebase-needed.yml @@ -10,6 +10,7 @@ permissions: jobs: label-rebase-needed: runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 481afdba303..e9e43ac9e80 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -1,9 +1,10 @@ name: JavaScript Testing on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' paths: - 'package.json' - 'yarn.lock' diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 3eaf2c2d740..6a0e67c58ee 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -1,29 +1,29 @@ name: Historical data migration test on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' + paths: + - 'Gemfile*' + - '.ruby-version' + - '**/*.rb' + - '.github/workflows/test-migrations.yml' + - 'lib/tasks/tests.rake' + pull_request: + paths: + - 'Gemfile*' + - '.ruby-version' + - '**/*.rb' + - '.github/workflows/test-migrations.yml' + - 'lib/tasks/tests.rake' jobs: - pre_job: - runs-on: ubuntu-latest - - outputs: - should_skip: ${{ steps.skip_check.outputs.should_skip }} - - steps: - - id: skip_check - uses: fkirc/skip-duplicate-actions@v5 - with: - paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations.yml", "lib/tasks/tests.rake"]' - test: runs-on: ubuntu-latest - needs: pre_job - if: needs.pre_job.outputs.should_skip != 'true' strategy: fail-fast: false diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index dd71fd253b5..3da53c1ae8e 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -1,10 +1,11 @@ name: Ruby Testing on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' pull_request: env: @@ -41,11 +42,24 @@ jobs: with: onlyProduction: 'true' + - name: Cache assets from compilation + uses: actions/cache@v4 + with: + path: | + public/assets + public/packs + public/packs-test + tmp/cache/webpacker + key: ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }} + ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }} + ${{ matrix.mode }}-assets-main + ${{ matrix.mode }}-assets + - name: Precompile assets - # Previously had set this, but it's not supported - # export NODE_OPTIONS=--openssl-legacy-provider run: |- - ./bin/rails assets:precompile + bin/rails assets:precompile - name: Archive asset artifacts run: | @@ -132,15 +146,30 @@ jobs: additional-system-dependencies: ffmpeg libpam-dev - name: Load database schema - run: './bin/rails db:create db:schema:load db:seed' + run: | + bin/rails db:setup + bin/flatware fan bin/rails db:test:prepare - - run: bin/rspec + - name: Cache RSpec persistence file + uses: actions/cache@v4 + with: + path: | + tmp/rspec/examples.txt + key: rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }} + restore-keys: | + rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}-${{ matrix.ruby-version }} + rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }} + rspec-persistence-${{ github.head_ref || github.ref_name }} + rspec-persistence-main + rspec-persistence + + - run: bin/flatware rspec -r ./spec/flatware_helper.rb - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' uses: codecov/codecov-action@v4 with: - files: coverage/lcov/mastodon.lcov + files: coverage/lcov/*.lcov env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -221,7 +250,7 @@ jobs: - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' - - run: bin/rspec --tag paperclip_processing + - run: bin/rspec --tag attachment_processing - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' diff --git a/.gitignore b/.gitignore index a70f30f9528..a74317bd7d8 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ docker-compose.override.yml # Ignore dotenv .local files .env*.local + +# Ignore local-only rspec configuration +.rspec-local diff --git a/.nvmrc b/.nvmrc index cecb9362895..65da8ce3917 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.15 +20.17 diff --git a/.profile b/.profile deleted file mode 100644 index f4826ea3033..00000000000 --- a/.profile +++ /dev/null @@ -1 +0,0 @@ -LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio:/app/.apt/usr/lib/x86_64-linux-gnu/openblas-pthread diff --git a/.rspec b/.rspec index 9a8e706d091..83e16f80447 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,2 @@ --color --require spec_helper ---format Fuubar diff --git a/.rubocop/rails.yml b/.rubocop/rails.yml index 4e08f1ab91e..ae31c1f2661 100644 --- a/.rubocop/rails.yml +++ b/.rubocop/rails.yml @@ -1,4 +1,7 @@ --- +Rails/BulkChangeTable: + Enabled: false # Conflicts with strong_migrations features + Rails/FilePath: EnforcedStyle: arguments @@ -18,6 +21,3 @@ Rails/RakeEnvironment: Rails/SkipsModelValidations: Enabled: false - -Rails/UnusedIgnoredColumns: - Enabled: false # Preserve ability to migrate from arbitrary old versions diff --git a/.rubocop/strict.yml b/.rubocop/strict.yml index 2222c6d8b93..c2655a1470c 100644 --- a/.rubocop/strict.yml +++ b/.rubocop/strict.yml @@ -7,8 +7,13 @@ RSpec/Focus: # Require full spec run on CI Exclude: [] Rails/Output: # Remove any `puts` debugging + inherit_mode: + merge: + - Include Enabled: true Exclude: [] + Include: + - spec/**/*.rb Rails/FindEach: # Using `each` could impact performance, use `find_each` Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index b05d1bc6bd8..a6e51d6aee1 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.64.1. +# using RuboCop version 1.66.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -14,7 +14,7 @@ Lint/NonLocalExitFromIterator: Metrics/AbcSize: Max: 82 -# Configuration parameters: CountBlocks, Max. +# Configuration parameters: CountBlocks, CountModifierForms, Max. Metrics/BlockNesting: Exclude: - 'lib/tasks/mastodon.rake' @@ -35,17 +35,14 @@ Rails/OutputSafety: # Configuration parameters: AllowedVars. Style/FetchEnvVar: Exclude: - - 'app/lib/redis_configuration.rb' - 'app/lib/translation_service.rb' - 'config/environments/production.rb' - 'config/initializers/2_limited_federation_mode.rb' - 'config/initializers/3_omniauth.rb' - - 'config/initializers/blacklists.rb' - 'config/initializers/cache_buster.rb' - 'config/initializers/devise.rb' - 'config/initializers/paperclip.rb' - 'config/initializers/vapid.rb' - - 'lib/mastodon/redis_config.rb' - 'lib/tasks/repo.rake' # This cop supports safe autocorrection (--autocorrect). @@ -94,7 +91,6 @@ Style/OptionalBooleanParameter: - 'app/services/fetch_resource_service.rb' - 'app/workers/domain_block_worker.rb' - 'app/workers/unfollow_follow_worker.rb' - - 'lib/mastodon/redis_config.rb' # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. diff --git a/.ruby-version b/.ruby-version index 619b5376684..fa7adc7ac72 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.3 +3.3.5 diff --git a/Aptfile b/Aptfile index 5e033f13650..06c91d4c7b0 100644 --- a/Aptfile +++ b/Aptfile @@ -1,5 +1,5 @@ -ffmpeg -libopenblas0-pthread -libpq-dev -libxdamage1 -libxfixes3 +libidn12 +# for idn-ruby on heroku-24 stack + +# use https://github.com/heroku/heroku-buildpack-activestorage-preview +# in place for ffmpeg and its dependent packages to reduce slag size diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b24d6f159..8d1e0bfcf78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,395 @@ All notable changes to this project will be documented in this file. +## [4.3.0] - UNRELEASED + +The following changelog entries focus on changes visible to users, administrators, client developers or federated software developers, but there has also been a lot of code modernization, refactoring, and tooling work, in particular by @mjankowski. + +### Security + +- **Add confirmation interstitial instead of silently redirecting logged-out visitors to remote resources** (#27792, #28902, and #30651 by @ClearlyClaire and @Gargron)\ + This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared. +- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 by @ClearlyClaire) +- Update dependencies + +### Added + +- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610 and #31929 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\ + Group notifications of the same type for the same target, so that your notifications no longer get cluttered by boost and favorite notifications as soon as a couple of your posts get traction.\ + This is done server-side so that clients can efficiently get relevant groups without having to go through numerous pages of individual notifications.\ + As part of this, the visual design of the entire notifications feature has been revamped.\ + This feature is intended to eventually replace the existing notifications column, but for this first beta, users will have to enable it in the “Experimental features” section of the notifications column settings.\ + The API is not final yet, but it consists of: + - a new `group_key` attribute to `Notification` entities + - `GET /api/v2/notifications`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped + - `GET /api/v2/notifications/:group_key`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group + - `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts + - `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group + - `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count +- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, and #31723 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\ + The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\ + You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\ + Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\ + This adds the following REST API endpoints: + + - `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy + - `PATCH /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#update-the-filtering-policy-for-notifications + - `GET /api/v1/notifications/requests`: https://docs.joinmastodon.org/methods/notifications/#get-requests + - `GET /api/v1/notifications/requests/:id`: https://docs.joinmastodon.org/methods/notifications/#get-one-request + - `POST /api/v1/notifications/requests/:id/accept`: https://docs.joinmastodon.org/methods/notifications/#accept-request + - `POST /api/v1/notifications/requests/:id/dismiss`: https://docs.joinmastodon.org/methods/notifications/#dismiss-request + - `POST /api/v1/notifications/requests/accept`: https://docs.joinmastodon.org/methods/notifications/#accept-multiple-requests + - `POST /api/v1/notifications/requests/dismiss`: https://docs.joinmastodon.org/methods/notifications/#dismiss-multiple-requests + - `GET /api/v1/notifications/requests/merged`: https://docs.joinmastodon.org/methods/notifications/#requests-merged + + In addition, accepting one or more notification requests generates a new streaming event: + + - `notifications_merged`: an event of this type indicates accepted notification requests have finished merging, and the notifications list should be refreshed + +- **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\ + Notify local users when they lose relationships as a result of a local moderator blocking a remote account or server, allowing the affected user to retrieve the list of broken relationships.\ + Note that this does not notify remote users.\ + This adds the `severed_relationships` notification type to the REST API and streaming, with a new [`relationship_severance_event` attribute](https://docs.joinmastodon.org/entities/Notification/#relationship_severance_event). +- **Add hover cards in web UI** (#30754, #30864, #30850, #30879, #30928, #30949, #30948, #30931, and #31300 by @ClearlyClaire, @Gargron, and @renchap)\ + Hovering over an avatar or username will now display a hover card with the first two lines of the user's description and their first two profile fields.\ + This can be disabled in the “Animations and accessibility” section of the preferences. +- **Add "system" theme setting (light/dark theme depending on user system preference)** (#29748, #29553, #29795, #29918, #30839, and #30861 by @nshki, @ErikUden, @mjankowski, @renchap, and @vmstan)\ + Add a “system” theme that automatically switch between default dark and light themes depending on the user's system preferences.\ + Also changes the default server theme to this new “system” theme so that automatic theme selection happens even when logged out. +- **Add timeline of public posts about a trending link** (#30381 and #30840 by @Gargron)\ + You can now see public posts mentioning currently-trending articles from people who have opted into discovery features.\ + This adds a new REST API endpoint: https://docs.joinmastodon.org/methods/timelines/#link +- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, and #31900 by @Gargron and @oneiros)\ + This adds a mechanism to [highlight the author of news articles](https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/) shared on Mastodon.\ + Articles hosted outside the fediverse can indicate a fediverse author with a meta tag: + ```html + + ``` + On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors\ + Users can allow arbitrary domains to use `fediverse:creator` to credit them by visiting `/settings/verification`.\ + This is federated as a new `attributionDomains` property in the `http://joinmastodon.org/ns` namespace, containing an array of domain names: https://docs.joinmastodon.org/spec/activitypub/#properties-used-1 +- **Add in-app notifications for moderation actions and warnings** (#30065, #30082, and #30081 by @ClearlyClaire)\ + In addition to email notifications, also notify users of moderation actions or warnings against them directly within the app, so they are less likely to miss important communication from their moderators.\ + This adds the `moderation_warning` notification type to the REST API and streaming, with a new [`moderation_warning` attribute](https://docs.joinmastodon.org/entities/Notification/#moderation_warning). +- **Add domain information to profiles in web UI** (#29602 by @Gargron)\ + Clicking the domain of a user in their profile will now open a tooltip with a short explanation about servers and federation. +- **Add support for Redis sentinel** (#31694, #31623, #31744, #31767, and #31768 by @ThisIsMissEm and @oneiros)\ + See https://docs.joinmastodon.org/admin/scaling/#redis-sentinel +- Add ability to reorder uploaded media before posting in web UI (#28456 by @Gargron) +- Add moderation interface for searching hashtags (#30880 by @ThisIsMissEm) +- Add ability for admins to configure instance favicon and logo (#30040, #30208, #30259, #30375, #30734, #31016, and #30205 by @ClearlyClaire, @FawazFarid, @JasonPunyon, @mgmn, and @renchap)\ + This is also exposed through the REST API: https://docs.joinmastodon.org/entities/Instance/#icon +- Add `api_versions` to `/api/v2/instance` (#31354 by @ClearlyClaire)\ + Add API version number to make it easier for clients to detect compatible features going forward.\ + See API documentation at https://docs.joinmastodon.org/entities/Instance/#api-versions +- Add quick links to Administration and Moderation Reports from Web UI (#24838 by @ThisIsMissEm) +- Add link to `/admin/roles` in moderation interface when changing someone's role (#31791 by @ClearlyClaire) +- Add recent audit log entries in federation moderation interface (#27386 by @ThisIsMissEm) +- Add profile setup to onboarding in web UI (#27829, #27876, and #28453 by @Gargron) +- Add prominent share/copy button on profiles in web UI (#27865 and #27889 by @ClearlyClaire and @Gargron) +- Add optional hints for server rules (#29539 and #29758 by @ClearlyClaire and @Gargron)\ + Server rules can now be broken into a short rule name and a longer explanation of the rule.\ + This adds a new [`hint` attribute](https://docs.joinmastodon.org/entities/Rule/#hint) to `Rule` entities in the REST API. +- Add support for PKCE in OAuth flow (#31129 by @ThisIsMissEm) +- Add CDN cache busting on media deletion (#31353 and #31414 by @ClearlyClaire and @tribela) +- Add the OAuth application used in local reports (#30539 by @ThisIsMissEm) +- Add hint to user that other remote statuses may be missing (#26910, #31387, and #31516 by @Gargron, @audiodude, and @renchap) +- Add lang attribute on preview card title (#31303 by @c960657) +- Add check for `Content-Length` in `ResponseWithLimitAdapter` (#31285 by @c960657) +- Add `Accept-Language` header to fetch preview cards in the server's default language (#31232 by @c960657) +- Add support for PKCE Extension in OmniAuth OIDC through the `OIDC_USE_PKCE` environment variable (#31131 by @ThisIsMissEm) +- Add API endpoints for unread notifications count (#31191 by @ClearlyClaire)\ + This adds the following REST API endpoints: + - `GET /api/v1/notifications/unread_count`: https://docs.joinmastodon.org/methods/notifications/#unread-count +- Add `/` keyboard shortcut to focus the search field (#29921 by @ClearlyClaire) +- Add button to view the Hashtag on the instance from Hashtags in Moderation UI (#31533 by @ThisIsMissEm) +- Add list of pending releases directly in mail notifications for version updates (#29436 and #30035 by @ClearlyClaire) +- Add “Appeals” link under “Moderation” navigation category in moderation interface (#31071 by @ThisIsMissEm) +- Add badge on account card in report moderation interface when account is already suspended (#29592 by @ClearlyClaire) +- Add admin comments directly to the `admin/instances` page (#29240 by @tribela) +- Add ability to require approval when users sign up using specific email domains (#28468, #28732, #28607, and #28608 by @ClearlyClaire) +- Add banner for forwarded reports made by remote users about remote content (#27549 by @ClearlyClaire) +- Add support HTML ruby tags in remote posts for east-asian languages (#30897 by @ThisIsMissEm) +- Add link to manage warning presets in admin navigation (#26199 by @vmstan) +- Add volume saving/reuse to video player (#27488 by @thehydrogen) +- Add Elasticsearch index size, ffmpeg and ImageMagick versions to the admin dashboard (#27301, #30710, #31130, and #30845 by @vmstan) +- Add `MASTODON_SIDEKIQ_READY_FILENAME` environment variable to use a file for Sidekiq to signal it is ready to process jobs (#30971 and #30988 by @renchap)\ + In the official Docker image, this is set to `sidekiq_process_has_started_and_will_begin_processing_jobs` so that Sidekiq will touch `tmp/sidekiq_process_has_started_and_will_begin_processing_jobs` to signal readiness. +- Add `S3_RETRY_LIMIT` environment variable to make S3 retries configurable (#23215 by @smiba) +- Add `S3_KEY_PREFIX` environment variable (#30181 by @S0yKaf) +- Add support for multiple `redirect_uris` when creating OAuth 2.0 Applications (#29192 by @ThisIsMissEm) +- Add Interlingue and Interlingua to interface languages (#28630 and #30828 by @Dhghomon and @renchap) +- Add Kashubian, Pennsylvania Dutch, Vai, Jawi Malay, Mohawk and Low German to posting languages (#26024, #26634, #27136, #29098, #27115, and #27434 by @EngineerDali, @HelgeKrueger, and @gunchleoc) +- Add option to use native Ruby driver for Redis through `REDIS_DRIVER=ruby` (#30717 by @vmstan) +- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, and #30858 by @ClearlyClaire, @Gargron, and @mjankowski)\ + Server admins can now use libvips as a faster and lighter alternative to ImageMagick for processing user-uploaded images.\ + This requires libvips 8.13 or newer, and needs to be enabled with `MASTODON_USE_LIBVIPS=true`.\ + This is enabled by default in the official Docker images, and is intended to completely replace ImageMagick in the future. +- Add validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm) +- Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2) +- Add active animation to header settings button (#30221, #30307, and #30388 by @daudix) +- Add OpenTelemetry instrumentation (#30130, #30322, #30353, and #30350 by @julianocosta89, @renchap, and @robbkidd)\ + See https://docs.joinmastodon.org/admin/config/#otel for documentation +- Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\ + This adds `GET /api/v1/accounts` and `GET /api/v1/statuses` to the REST API, see https://docs.joinmastodon.org/methods/accounts/#index and https://docs.joinmastodon.org/methods/statuses/#index +- Add support for CORS to `POST /oauth/revoke` (#31743 by @ClearlyClaire) +- Add redirection back to previous page after site upload deletion (#30141 by @FawazFarid) +- Add RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm) +- Add loading indicator and empty result message to advanced interface search (#30085 by @ClearlyClaire) +- Add `profile` OAuth 2.0 scope, allowing more limited access to user data (#29087 and #30357 by @ThisIsMissEm) +- Add global Regexp timeout (#31928 by @ClearlyClaire) +- Add the role ID to the badge component (#29707 by @renchap) +- Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski) +- Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski) +- Add support for specifying custom CA cert for Elasticsearch through `ES_CA_FILE` (#29122 and #29147 by @ClearlyClaire) +- Add groundwork for annual reports for accounts (#28693 by @Gargron)\ + This lays the groundwork for a “year-in-review”/“wrapped” style report for local users, but is currently not in use. +- Add notification email on invalid second authenticator (#28822 by @ClearlyClaire) +- Add new emojis from `jdecked/twemoji` 15.0 (#28404 by @TheEssem) +- Add configurable error handling in attachment batch deletion (#28184 by @vmstan)\ + This makes the S3 batch size configurable through the `S3_BATCH_DELETE_LIMIT` environment variable (defaults to 1000), and adds some retry logic, configurable through the `S3_BATCH_DELETE_RETRY` environment variable (defaults to 3). +- Add VAPID public key to instance serializer (#28006 by @ThisIsMissEm) +- Add `nodeName` and `nodeDescription` to nodeinfo `metadata` (#28079 by @6543) +- Add Thai diacritics and tone marks in `HASHTAG_INVALID_CHARS_RE` (#26576 by @ppnplus) +- Add variable delay before link verification of remote account links (#27774 by @ClearlyClaire) +- Add support for invite codes in the registration API (#27805 by @ClearlyClaire) +- Add HTML lang attribute to preview card descriptions (#27503 by @srapilly) +- Add display of relevant account warnings to report action logs (#27425 by @ClearlyClaire) +- Add validation of allowed schemes on preview card URLs (#27485 by @mjankowski) +- Add token introspection without read scope to `/api/v1/apps/verify_credentials` (#27142 by @ThisIsMissEm) +- Add support for cross-origin request to `/nodeinfo/2.0` (#27413 by @palant) +- Add variable delay before link verification of remote account links (#27351 by @ClearlyClaire) +- Add PWA shortcut to `/explore` page (#27235 by @jake-anto) + +### Changed + +- **Change icons throughout the web interface** (#27385, #27539, #27555, #27579, #27700, #27817, #28519, #28709, #28064, #28775, #28780, #27924, #29294, #29395, #29537, #29569, #29610, #29612, #29649, #29844, #27780, #30974, #30963, #30962, #30961, #31362, #31363, #31359, #31371, #31360, #31512, #31511, and #31525 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\ + This changes all the interface icons from FontAwesome to Material Symbols for a more modern look, consistent with the official Mastodon Android app.\ + In addition, better care is given to pixel alignment, and icon variants are used to better highlight active/inactive state. +- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, and #31889 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\ + The compose form has been completely redesigned for a more modern and consistent look, as well as spelling out the chosen privacy setting and language name at all times.\ + As part of this, the “Unlisted” privacy setting has been renamed to “Quiet public”. +- **Change design of modals in the web UI** (#29576, #29614, #29640, #29644, #30131, #30884, #31399, #31555, #31752, #31801, #31883, #31844, #31864, and #31943 by @ClearlyClaire, @Gargron, @tribela and @vmstan)\ + The mute, block, and domain block confirmation modals have been completely redesigned to be clearer and include more detailed information on the action to be performed.\ + They also have a more modern and consistent design, along with other confirmation modals in the application. +- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, and #31510 by @ClearlyClaire, @Gargron, @renchap, and @vmstan) +- **Change onboarding prompt to follow suggestions carousel in web UI** (#28878, #29272, and #31912 by @Gargron) +- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, and #29879 by @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\ + All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients. +- **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\ + This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\ + In addition, the implementation has been significantly reworked, and all follow recommendations are now dismissable.\ + This change deprecates the `source` attribute in `Suggestion` entities in the REST API, and replaces it with the new [`sources` attribute](https://docs.joinmastodon.org/entities/Suggestion/#sources). +- Change account search algorithm (#30803 by @Gargron) +- **Change streaming server to use its own dependencies and its own docker image** (#24702, #27967, #26850, #28112, #28115, #28137, #28138, #28497, #28548, #30795, #31612, and #31615 by @TheEssem, @ThisIsMissEm, @jippi, @renchap, @timetinytim, and @vmstan)\ + In order to reduce the amount of runtime dependencies, the streaming server has been moved into a separate package and Docker image.\ + The `mastodon` image does not contain the streaming server anymore, as it has been moved to its own `mastodon-streaming` image.\ + Administrators may need to update their setup accordingly. +- Change how content warnings and filters are displayed in web UI (#31365, and #31761 by @Gargron) +- Change preview card processing to ignore `undefined` as canonical url (#31882 by @oneiros) +- Change embedded posts to use web UI (#31766 by @Gargron) +- Change inner borders in media galleries in web UI (#31852 by @Gargron) +- Change design of hide media button in web UI (#31807 by @Gargron) +- Change labels on thread indicators in web UI (#31806 by @Gargron) +- Change report action buttons to be disabled when action has already been taken (#31773, #31822, and #31899 by @ClearlyClaire and @ThisIsMissEm) +- Change width of columns in advanced web UI (#31762 by @Gargron) +- Change design of unread conversations in web UI (#31763 by @Gargron) +- Change Web UI to allow viewing and severing relationships with suspended accounts (#27667 by @ClearlyClaire)\ + This also adds a `with_suspended` parameter to `GET /api/v1/accounts/relationships` in the REST API. +- Change preview card image size limit from 2MB to 8MB when using libvips (#31904 by @ClearlyClaire) +- Change avatars border radius (#31390 by @renchap) +- Change counters to be displayed on profile timelines in web UI (#30525 by @Gargron) +- Change disabled buttons color in light mode to make the difference more visible (#30998 by @renchap) +- Change design of people tab on explore in web UI (#30059 by @Gargron) +- Change sidebar text in web UI (#30696 by @Gargron) +- Change "Follow" to "Follow back" and "Mutual" when appropriate in web UI (#28452, #28465, and #31934 by @ClearlyClaire, @Gargron and @renchap) +- Change media to be hidden/blurred by default in report modal (#28522 by @ClearlyClaire) +- Change order of the "muting" and "blocking" list options in “Data Exports” (#26088 by @fixermark) +- Change admin and moderation notes character limit from 500 to 2000 characters (#30288 by @ThisIsMissEm) +- Change mute options to be in dropdown on muted users list in web UI (#30049 and #31315 by @ClearlyClaire and @Gargron) +- Change out-of-band hashtags design in web UI (#29732 by @Gargron) +- Change design of metadata underneath detailed posts in web UI (#29585, #29605, and #29648 by @ClearlyClaire and @Gargron) +- Change action button to be last on profiles in web UI (#29533 and #29923 by @ClearlyClaire and @Gargron) +- Change confirmation prompts in trending moderation interface to be more specific (#19626 by @tribela) +- Change “Trends” moderation menu to “Recommendations & Trends” and move follow recommendations there (#31292 by @ThisIsMissEm) +- Change irrelevant fields in account cleanup settings to be disabled unless automatic cleanup is enabled (#26562 by @c960657) +- Change dropdown menu icon to not be replaced by close icon when open in web UI (#29532 by @Gargron) +- Change back button to always appear in advanced web UI (#29551 and #29669 by @Gargron) +- Change border of active compose field search inputs (#29832 and #29839 by @vmstan) +- Change instances of Nokogiri HTML4 parsing to HTML5 (#31812, #31815, #31813, and #31814 by @flavorjones) +- Change link detection to allow `@` at the end of an URL (#31124 by @adamniedzielski) +- Change User-Agent to use Mastodon as the product, and http.rb as platform details (#31192 by @ClearlyClaire) +- Change layout and wording of the Content Retention server settings page (#27733 by @vmstan) +- Change unconfirmed users to be kept for one week instead of two days (#30285 by @renchap) +- Change maximum page size for Admin Domain Management APIs from 200 to 500 (#31253 by @ThisIsMissEm) +- Change database pool size to default to Sidekiq concurrency settings in Sidekiq processes (#26488 by @sinoru) +- Change alt text to empty string for avatars (#21875 by @jasminjohal) +- Change Docker images to use custom-built libvips and ffmpeg (#30571, #30569, and #31498 by @vmstan) +- Change external links in the admin audit log to plain text or local administration pages (#27139 and #27150 by @ClearlyClaire and @ThisIsMissEm) +- Change YJIT to be enabled when available (#30310 and #27283 by @ClearlyClaire and @mjankowski)\ + Enable Ruby's built-in just-in-time compiler. This improves performances substantially, at the cost of a slightly increased memory usage. +- Change `.env` file loading from deprecated `dotenv-rails` gem to `dotenv` gem (#29173 and #30121 by @mjankowski)\ + This should have no effect except in the unlikely case an environment variable included a newline. +- Change “Panjabi” language name to the more common spelling “Punjabi” (#27117 by @gunchleoc) +- Change encryption of OTP secrets to use ActiveRecord Encryption (#29831, #28325, #30151, #30202, #30340, and #30344 by @ClearlyClaire and @mjankowski)\ + This requires a manual step from administrators of existing servers. Indeed, they need to generate new secrets, which can be done using `bundle exec rails db:encryption:init`.\ + Furthermore, there is a risk that the introduced migration fails if the server was misconfigured in the past. If that happens, the migration error will include the relevant information. +- Change `/api/v1/announcements` to return regular `Status` entities (#26736 by @ClearlyClaire) +- Change imports to convert case-insensitive fields to lowercase (#29739 and #29740 by @ThisIsMissEm) +- Change stats in the admin interface to be inclusive of the full selected range, from beginning of day to end of day (#29416 and #29841 by @mjankowski) +- Change materialized views to be refreshed concurrently to avoid locks (#29015 by @Gargron) +- Change compose form to use server-provided post character and poll options limits (#28928 and #29490 by @ClearlyClaire and @renchap) +- Change streaming server logging from `npmlog` to `pino` and `pino-http` (#27828 by @ThisIsMissEm)\ + This changes the Mastodon streaming server log format, so this might be considered a breaking change if you were parsing the logs. +- Change media “ALT” label to use a specific CSS class (#28777 by @ClearlyClaire) +- Change streaming API host to not be overridden to localhost in development mode (#28557 by @ClearlyClaire) +- Change cookie rotator to use SHA1 digest for new cookies (#27392 by @ClearlyClaire)\ + Note that this requires that no pre-4.2.0 Mastodon web server is running when this code is deployed, as those would not understand the new cookies.\ + Therefore, zero-downtime updates are only supported if you're coming from 4.2.0 or newer. If you want to skip Mastodon 4.2, you will need to completely stop Mastodon services before updating. +- Change preview card deletes to be done using batch method (#28183 by @vmstan) +- Change `img-src` and `media-src` CSP directives to not include `https:` (#28025 and #28561 by @ClearlyClaire) +- Change self-destruct procedure (#26439, #29049, and #29420 by @ClearlyClaire and @zunda)\ + Instead of enqueuing deletion jobs immediately, `tootctl self-destruct` now outputs a value for the `SELF_DESTRUCT` environment variable, which puts a server in self-destruct mode, processing deletions in the background, while giving users access to their export archives. + +### Removed + +- Remove StatsD integration (replaced by OpenTelemetry) (#30240 by @mjankowski) +- Remove `CacheBuster` default options (#30718 by @mjankowski) +- Remove home marker updates from the Web UI (#22721 by @davbeck)\ + The web interface was unconditionally updating the home marker to the most recent received post, discarding any value set by other clients, thus making the feature unreliable. +- Remove support for Ruby 3.0 (reaching EOL) (#29702 by @mjankowski) +- Remove setting for unfollow confirmation modal (#29373 by @ClearlyClaire)\ + Instead, the unfollow confirmation modal will always be displayed. +- Remove support for Capistrano (#27295 and #30009 by @mjankowski and @renchap) + +### Fixed + +- **Fix link preview cards not always preserving the original URL from the status** (#27312 by @Gargron) +- Fix log out from user menu not working on Safari (#31402 by @renchap) +- Fix various issues when in link preview card generation (#28748, #30017, #30362, #30173, #30853, #30929, #30933, #30957, #30987, and #31144 by @adamniedzielski, @oneiros, @phocks, @timothyjrogers, and @tribela) +- Fix handling of missing links in Webfinger responses (#31030 by @adamniedzielski) +- Fix HTTP 500 error in `/api/v1/polls/:id/votes` when required `choices` parameter is missing (#25598 by @danielmbrasil) +- Fix security context sometimes not being added in LD-Signed activities (#31871 by @ClearlyClaire) +- Fix cross-origin loading of `inert.css` polyfill (#30687 by @louis77) +- Fix cutoff of instance name in sign-up form (#30598 by @oneiros) +- Fix invalid date searches returning 503 errors (#31526 by @notchairmk) +- Fix invalid `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657) +- Fix some components re-rendering spuriously in web UI (#31879 and #31881 by @ClearlyClaire and @Gargron) +- Fix sort order of moderation notes on Reports and Accounts (#31528 by @ThisIsMissEm) +- Fix email language when recipient has no selected locale (#31747 by @ClearlyClaire) +- Fix frequently-used languages not correctly updating in the web UI (#31386 by @c960657) +- Fix `POST /api/v1/statuses` silently ignoring invalid `media_ids` parameter (#31681 by @c960657) +- Fix handling of the `BIND` environment variable in the streaming server (#31624 by @ThisIsMissEm) +- Fix empty `aria-hidden` attribute value in logo resources area (#30570 by @mjankowski) +- Fix “Redirect URI” field not being marked as required in “New application” form (#30311 by @ThisIsMissEm) +- Fix right-to-left text in preview cards (#30930 by @ClearlyClaire) +- Fix rack attack `match_type` value typo in logging config (#30514 by @mjankowski) +- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, and #31445 by @valtlai and @vmstan) +- Fix race condition in `POST /api/v1/push/subscription` (#30166 by @ClearlyClaire) +- Fix post deletion not being delayed when those are part of an account warning (#30163 by @ClearlyClaire) +- Fix rendering error on `/start` when not logged in (#30023 by @timothyjrogers) +- Fix logo pushing header buttons out of view on certain conditions in mobile layout (#29787 by @ClearlyClaire) +- Fix notification-related records not being reattributed when merging accounts (#29694 by @ClearlyClaire) +- Fix results/query in `api/v1/featured_tags/suggestions` (#29597 by @mjankowski) +- Fix distracting and confusing always-showing scrollbar track in boost confirmation modal (#31524 by @ClearlyClaire) +- Fix being able to upload more than 4 media attachments in some cases (#29183 by @mashirozx) +- Fix preview card player getting embedded when clicking on the external link button (#29457 by @ClearlyClaire) +- Fix full date display not respecting the locale 12/24h format (#29448 by @renchap) +- Fix filters title and keywords overflow (#29396 by @GeopJr) +- Fix incorrect date format in “Follows and followers” (#29390 by @JasonPunyon) +- Fix “Edit media” modal sizing and layout when space-constrained (#27095 by @ronilaukkarinen) +- Fix modal container bounds (#29185 by @nico3333fr) +- Fix inefficient HTTP signature parsing using regexps and `StringScanner` (#29133 by @ClearlyClaire) +- Fix moderation report updates through `PUT /api/v1/admin/reports/:id` not being logged in the audit log (#29044, #30342, and #31033 by @mjankowski, @tribela, and @vmstan) +- Fix moderation interface allowing to select rule violation when there are no server rules (#31458 by @ThisIsMissEm) +- Fix redirection from paths with url-encoded `@` to their decoded form (#31184 by @timothyjrogers) +- Fix Trending Tags pending review having an unstable sort order (#31473 by @ThisIsMissEm) +- Fix the emoji dropdown button always opening the dropdown instead of behaving like a toggle (#29012 by @jh97uk) +- Fix processing of incoming posts with bearcaps (#26527 by @kmycode) +- Fix support for IPv6 redis connections in streaming (#31229 by @ThisIsMissEm) +- Fix search form re-rendering spuriously in web UI (#28876 by @Gargron) +- Fix `RedownloadMediaWorker` not being called on transient S3 failure (#28714 by @ClearlyClaire) +- Fix ISO code for Canadian French from incorrect `fr-QC` to `fr-CA` (#26015 by @gunchleoc) +- Fix `.opus` file uploads being misidentified by Paperclip (#28580 by @vmstan) +- Fix loading local accounts with extraneous domain part in WebUI (#28559 by @ClearlyClaire) +- Fix destructive actions in dropdowns not using error color in light theme (#28484 by @logicalmoody) +- Fix call to inefficient `delete_matched` cache method in domain blocks (#28374 by @ClearlyClaire) +- Fix status edits not always being streamed to mentioned users (#28324 by @ClearlyClaire) +- Fix onboarding step descriptions being truncated on narrow screens (#28021 by @ClearlyClaire) +- Fix duplicate IDs in relationships and familiar_followers APIs (#27982 by @KevinBongart) +- Fix modal content not being selectable (#27813 by @pajowu) +- Fix Web UI not displaying appropriate explanation when a user hides their follows/followers (#27791 by @ClearlyClaire) +- Fix format-dependent redirects being cached regardless of requested format (#27632 by @ClearlyClaire) +- Fix confusing screen when visiting a confirmation link for an already-confirmed email (#27368 by @ClearlyClaire) +- Fix explore page reloading when you navigate back to it in web UI (#27489 by @Gargron) +- Fix missing redirection from `/home` to `/deck/home` in the advanced interface (#27378 by @Signez) +- Fix empty environment variables not using default nil value (#27400 by @renchap) +- Fix language sorting in settings (#27158 by @gunchleoc) + +## |4.2.11] - 2024-08-16 + +### Added + +- Add support for incoming `` tag ([mediaformat](https://github.com/mastodon/mastodon/pull/31375)) + +### Changed + +- Change logic of block/mute bypass for mentions from moderators to only apply to visible roles with moderation powers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31271)) + +### Fixed + +- Fix incorrect rate limit on PUT requests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31356)) +- Fix presence of `ß` in adjacent word preventing mention and hashtag matching ([adamniedzielski](https://github.com/mastodon/mastodon/pull/31122)) +- Fix processing of webfinger responses with multiple `self` links ([adamniedzielski](https://github.com/mastodon/mastodon/pull/31110)) +- Fix duplicate `orderedItems` in user archive's `outbox.json` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31099)) +- Fix click event handling when clicking outside of an open dropdown menu ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31251)) +- Fix status processing failing halfway when a remote post has a malformed `replies` attribute ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31246)) +- Fix `--verbose` option of `tootctl media remove`, which was previously erroneously removed ([mjankowski](https://github.com/mastodon/mastodon/pull/30536)) +- Fix division by zero on some video/GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30600)) +- Fix Web UI trying to save user settings despite being logged out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30324)) +- Fix hashtag regexp matching some link anchors ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30190)) +- Fix local account search on LDAP login being case-sensitive ([raucao](https://github.com/mastodon/mastodon/pull/30113)) +- Fix development environment admin account not being auto-approved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29958)) +- Fix report reason selector in moderation interface not unselecting rules when changing category ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29026)) +- Fix already-invalid reports failing to resolve ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29027)) +- Fix OCR when using S3/CDN for assets ([vmstan](https://github.com/mastodon/mastodon/pull/28551)) +- Fix error when encountering malformed `Tag` objects from Kbin ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/28235)) +- Fix not all allowed image formats showing in file picker when uploading custom emoji ([june128](https://github.com/mastodon/mastodon/pull/28076)) +- Fix search popout listing unusable search options when logged out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27918)) +- Fix processing of featured collections lacking an `items` attribute ([tribela](https://github.com/mastodon/mastodon/pull/27581)) +- Fix `mastodon:stats` decoration of stats rake task ([mjankowski](https://github.com/mastodon/mastodon/pull/31104)) + +## [4.2.10] - 2024-07-04 + +### Security + +- Fix incorrect permission checking on multiple API endpoints ([GHSA-58x8-3qxw-6hm7](https://github.com/mastodon/mastodon/security/advisories/GHSA-58x8-3qxw-6hm7)) +- Fix incorrect authorship checking when processing some activities (CVE-2024-37903, [GHSA-xjvf-fm67-4qc3](https://github.com/mastodon/mastodon/security/advisories/GHSA-xjvf-fm67-4qc3)) +- Fix ongoing streaming sessions not being invalidated when application tokens get revoked ([GHSA-vp5r-5pgw-jwqx](https://github.com/mastodon/mastodon/security/advisories/GHSA-vp5r-5pgw-jwqx)) +- Update dependencies + +### Added + +- Add yarn version specification to avoid confusion with Yarn 3 and Yarn 4 + +### Changed + +- Change preview cards generation to skip unusually long URLs ([oneiros](https://github.com/mastodon/mastodon/pull/30854)) +- Change search modifiers to be case-insensitive ([Gargron](https://github.com/mastodon/mastodon/pull/30865)) +- Change `STATSD_ADDR` handling to emit a warning rather than crashing if the address is unreachable ([timothyjrogers](https://github.com/mastodon/mastodon/pull/30691)) +- Change PWA start URL from `/home` to `/` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27377)) + +### Removed + +- Removed dependency on `posix-spawn` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18559)) + +### Fixed + +- Fix scheduled statuses scheduled in less than 5 minutes being immediately published ([danielmbrasil](https://github.com/mastodon/mastodon/pull/30584)) +- Fix encoding detection for link cards ([oneiros](https://github.com/mastodon/mastodon/pull/30780)) +- Fix `/admin/accounts/:account_id/statuses/:id` for edited posts with media attachments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30819)) +- Fix duplicate `@context` attribute in user archive export ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30653)) + ## [4.2.9] - 2024-05-30 ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b68a9bde3ea..8286fdd2f7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,11 @@ You can contribute in the following ways: If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). +Please review the org-level [contribution guidelines] for high-level acceptance +criteria guidance. + +[contribution guidelines]: https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md + ## API Changes and Additions Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation). diff --git a/Dockerfile b/Dockerfile index c3e43dac8dd..c7c02d9b467 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.8 +# syntax=docker/dockerfile:1.9 # This file is designed for production server deployment, not local development work # For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker @@ -12,16 +12,16 @@ ARG BUILDPLATFORM=${BUILDPLATFORM} # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"] # renovate: datasource=docker depName=docker.io/ruby -ARG RUBY_VERSION="3.3.3" +ARG RUBY_VERSION="3.3.5" # # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # renovate: datasource=node-version depName=node ARG NODE_MAJOR_VERSION="20" # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] ARG DEBIAN_VERSION="bookworm" # Node image to use for base image based on combined variables (ex: 20-bookworm-slim) -FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim as node +FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node # Ruby image to use for base image based on combined variables (ex: 3.3.x-slim-bookworm) -FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby +FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA # Example: v4.3.0-nightly.2023.11.09+pr-123456 @@ -67,7 +67,9 @@ ENV \ # Optimize jemalloc 5.x performance MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" \ # Enable libvips, should not be changed - MASTODON_USE_LIBVIPS=true + MASTODON_USE_LIBVIPS=true \ +# Sidekiq will touch tmp/sidekiq_process_has_started_and_will_begin_processing_jobs to indicate it is ready. This can be used for a readiness check in Kubernetes + MASTODON_SIDEKIQ_READY_FILENAME=sidekiq_process_has_started_and_will_begin_processing_jobs # Set default shell used for running commands SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-c"] @@ -117,7 +119,7 @@ RUN \ ; # Create temporary build layer from base image -FROM ruby as build +FROM ruby AS build # Copy Node package configuration files into working directory COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/ @@ -185,40 +187,46 @@ RUN \ corepack prepare --activate; # Create temporary libvips specific build layer from build layer -FROM build as libvips +FROM build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips -ARG VIPS_VERSION=8.15.2 +ARG VIPS_VERSION=8.15.3 # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] ARG VIPS_URL=https://github.com/libvips/libvips/releases/download WORKDIR /usr/local/libvips/src +# Download and extract libvips source code +ADD ${VIPS_URL}/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz /usr/local/libvips/src/ +RUN tar xf vips-${VIPS_VERSION}.tar.xz; +WORKDIR /usr/local/libvips/src/vips-${VIPS_VERSION} + +# Configure and compile libvips RUN \ - curl -sSL -o vips-${VIPS_VERSION}.tar.xz ${VIPS_URL}/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz; \ - tar xf vips-${VIPS_VERSION}.tar.xz; \ - cd vips-${VIPS_VERSION}; \ meson setup build --prefix /usr/local/libvips --libdir=lib -Ddeprecated=false -Dintrospection=disabled -Dmodules=disabled -Dexamples=false; \ cd build; \ ninja; \ ninja install; # Create temporary ffmpeg specific build layer from build layer -FROM build as ffmpeg +FROM build AS ffmpeg # ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"] # renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg -ARG FFMPEG_VERSION=7.0.1 +ARG FFMPEG_VERSION=7.0.2 # ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"] ARG FFMPEG_URL=https://ffmpeg.org/releases WORKDIR /usr/local/ffmpeg/src +# Download and extract ffmpeg source code +ADD ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz /usr/local/ffmpeg/src/ +RUN tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz; +WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION} + +# Configure and compile ffmpeg RUN \ - curl -sSL -o ffmpeg-${FFMPEG_VERSION}.tar.xz ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz; \ - tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz; \ - cd ffmpeg-${FFMPEG_VERSION}; \ ./configure \ --prefix=/usr/local/ffmpeg \ --toolchain=hardened \ @@ -247,7 +255,7 @@ RUN \ make install; # Create temporary bundler specific build layer from build layer -FROM build as bundler +FROM build AS bundler ARG TARGETPLATFORM @@ -269,7 +277,7 @@ RUN \ bundle install -j"$(nproc)"; # Create temporary node specific build layer from build layer -FROM build as yarn +FROM build AS yarn ARG TARGETPLATFORM @@ -286,7 +294,7 @@ RUN \ yarn workspaces focus --production @mastodon/mastodon; # Create temporary assets build layer from build layer -FROM build as precompiler +FROM build AS precompiler # Copy Mastodon sources into precompiler layer COPY . /opt/mastodon/ @@ -310,7 +318,7 @@ RUN \ rm -fr /opt/mastodon/tmp; # Prep final Mastodon Ruby layer -FROM ruby as mastodon +FROM ruby AS mastodon ARG TARGETPLATFORM diff --git a/Gemfile b/Gemfile index f2d7d098d5c..bcb19421ab9 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,7 @@ gem 'pghero' gem 'aws-sdk-s3', '~> 1.123', require: false gem 'blurhash', '~> 0.1' -gem 'fog-core', '<= 2.4.0' +gem 'fog-core', '<= 2.5.0' gem 'fog-openstack', '~> 1.0', require: false gem 'kt-paperclip', '~> 7.2' gem 'md-paperclip-azure', '~> 2.2', require: false @@ -25,7 +25,7 @@ gem 'ruby-vips', '~> 2.2', require: false gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' gem 'bootsnap', '~> 1.18.0', require: false -gem 'browser' +gem 'browser', '< 6' # https://github.com/fnando/browser/issues/543 gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' gem 'devise', '~> 4.9' @@ -47,7 +47,6 @@ gem 'color_diff', '~> 0.1' gem 'csv', '~> 3.2' gem 'discard', '~> 1.2' gem 'doorkeeper', '~> 5.6' -gem 'ed25519', '~> 1.3' gem 'fast_blank', '~> 1.0' gem 'fastimage' gem 'hiredis', '~> 0.6' @@ -64,7 +63,6 @@ gem 'link_header', '~> 0.0' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' gem 'nokogiri', '~> 1.15' -gem 'nsa' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' @@ -88,7 +86,7 @@ gem 'sidekiq-unique-jobs', '~> 7.1' gem 'simple_form', '~> 5.2' gem 'simple-navigation', '~> 4.4' gem 'stoplight', '~> 4.1' -gem 'strong_migrations', '1.8.0' +gem 'strong_migrations' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' gem 'tzinfo-data', '~> 1.2023' @@ -100,12 +98,10 @@ gem 'json-ld' gem 'json-ld-preloaded', '~> 3.2' gem 'rdf-normalize', '~> 0.5' -gem 'private_address_check', '~> 0.5' - -gem 'opentelemetry-api', '~> 1.2.5' +gem 'opentelemetry-api', '~> 1.4.0' group :opentelemetry do - gem 'opentelemetry-exporter-otlp', '~> 0.28.0', require: false + gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false @@ -114,21 +110,21 @@ group :opentelemetry do gem 'opentelemetry-instrumentation-http', '~> 0.23.2', require: false gem 'opentelemetry-instrumentation-http_client', '~> 0.22.3', require: false gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false - gem 'opentelemetry-instrumentation-pg', '~> 0.27.1', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false gem 'opentelemetry-instrumentation-rack', '~> 0.24.1', require: false - gem 'opentelemetry-instrumentation-rails', '~> 0.30.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.31.0', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false end group :test do + # Enable usage of all available CPUs/cores during spec runs + gem 'flatware-rspec' + # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab gem 'rspec-github', '~> 2.4', require: false - # RSpec progress bar formatter - gem 'fuubar', '~> 2.5' - # RSpec helpers for email specs gem 'email_spec' @@ -149,11 +145,13 @@ group :test do gem 'rails-controller-testing', '~> 1.0' # Validate schemas in specs - gem 'json-schema', '~> 4.0' + gem 'json-schema', '~> 5.0' # Test harness fo rack components gem 'rack-test', '~> 2.1' + gem 'shoulda-matchers' + # Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false gem 'simplecov', '~> 0.22', require: false gem 'simplecov-lcov', '~> 0.8', require: false @@ -210,7 +208,7 @@ group :development, :test do gem 'test-prof' # RSpec runner for rails - gem 'rspec-rails', '~> 6.0' + gem 'rspec-rails', '~> 7.0' end group :production do diff --git a/Gemfile.lock b/Gemfile.lock index 0fe1c03b25a..ed217ca1464 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,35 +10,35 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) + actioncable (7.1.4) + actionpack (= 7.1.4) + activesupport (= 7.1.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionmailbox (7.1.4) + actionpack (= 7.1.4) + activejob (= 7.1.4) + activerecord (= 7.1.4) + activestorage (= 7.1.4) + activesupport (= 7.1.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.3.4) - actionpack (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionmailer (7.1.4) + actionpack (= 7.1.4) + actionview (= 7.1.4) + activejob (= 7.1.4) + activesupport (= 7.1.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.3.4) - actionview (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionpack (7.1.4) + actionview (= 7.1.4) + activesupport (= 7.1.4) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -46,15 +46,15 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3.4) - actionpack (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + actiontext (7.1.4) + actionpack (= 7.1.4) + activerecord (= 7.1.4) + activestorage (= 7.1.4) + activesupport (= 7.1.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.4) - activesupport (= 7.1.3.4) + actionview (7.1.4) + activesupport (= 7.1.4) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -64,22 +64,22 @@ GEM activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (7.1.3.4) - activesupport (= 7.1.3.4) + activejob (7.1.4) + activesupport (= 7.1.4) globalid (>= 0.3.6) - activemodel (7.1.3.4) - activesupport (= 7.1.3.4) - activerecord (7.1.3.4) - activemodel (= 7.1.3.4) - activesupport (= 7.1.3.4) + activemodel (7.1.4) + activesupport (= 7.1.4) + activerecord (7.1.4) + activemodel (= 7.1.4) + activesupport (= 7.1.4) timeout (>= 0.4.0) - activestorage (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activesupport (= 7.1.3.4) + activestorage (7.1.4) + actionpack (= 7.1.4) + activejob (= 7.1.4) + activerecord (= 7.1.4) + activesupport (= 7.1.4) marcel (~> 1.0) - activesupport (7.1.3.4) + activesupport (7.1.4) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -100,20 +100,20 @@ GEM attr_required (1.0.2) awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.940.0) - aws-sdk-core (3.197.0) + aws-partitions (1.978.0) + aws-sdk-core (3.209.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.8) + aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.83.0) - aws-sdk-core (~> 3, >= 3.197.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.152.3) - aws-sdk-core (~> 3, >= 3.197.0) + aws-sdk-kms (1.94.0) + aws-sdk-core (~> 3, >= 3.207.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.166.0) + aws-sdk-core (~> 3, >= 3.207.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.8) - aws-sigv4 (1.8.0) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.10.0) aws-eventstream (~> 1, >= 1.0.2) azure-storage-blob (2.0.3) azure-storage-common (~> 2.0) @@ -135,16 +135,16 @@ GEM binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) blurhash (0.1.7) - bootsnap (1.18.3) + bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.1.2) + brakeman (6.2.1) racc browser (5.3.1) brpoplpush-redis_script (0.1.3) concurrent-ruby (~> 1.0, >= 1.0.5) redis (>= 1.0, < 6) builder (3.3.0) - bundler-audit (0.9.1) + bundler-audit (0.9.2) bundler (>= 1.2.0, < 3) thor (~> 1.0) capybara (3.40.0) @@ -159,28 +159,30 @@ GEM case_transform (0.2) activesupport cbor (0.5.9.8) - charlock_holmes (0.7.7) + charlock_holmes (0.7.9) chewy (7.6.0) activesupport (>= 5.2) elasticsearch (>= 7.14.0, < 8) elasticsearch-dsl + childprocess (5.1.0) + logger (~> 1.5) chunky_png (1.4.0) climate_control (1.2.0) cocoon (1.2.15) color_diff (0.1) - concurrent-ruby (1.3.3) + concurrent-ruby (1.3.4) connection_pool (2.4.1) - cose (1.3.0) + cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) crack (1.0.0) bigdecimal rexml crass (1.0.6) - css_parser (1.17.1) + css_parser (1.19.0) addressable csv (3.3.0) - database_cleaner-active_record (2.1.0) + database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) @@ -195,7 +197,7 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (5.1.0) + devise-two-factor (6.0.0) activesupport (~> 7.0) devise (~> 4.0) railties (~> 7.0) @@ -206,32 +208,32 @@ GEM diff-lcs (1.5.1) discard (1.3.0) activerecord (>= 4.2, < 8) - docile (1.4.0) + docile (1.4.1) domain_name (0.6.20240107) - doorkeeper (5.6.9) + doorkeeper (5.7.1) railties (>= 5) - dotenv (3.1.2) + dotenv (3.1.4) drb (2.2.1) - ed25519 (1.3.0) - elasticsearch (7.17.10) - elasticsearch-api (= 7.17.10) - elasticsearch-transport (= 7.17.10) - elasticsearch-api (7.17.10) + elasticsearch (7.17.11) + elasticsearch-api (= 7.17.11) + elasticsearch-transport (= 7.17.11) + elasticsearch-api (7.17.11) multi_json elasticsearch-dsl (0.1.10) - elasticsearch-transport (7.17.10) + elasticsearch-transport (7.17.11) + base64 faraday (>= 1, < 3) multi_json - email_spec (2.2.2) + email_spec (2.3.0) htmlentities (~> 4.3.3) - launchy (~> 2.1) + launchy (>= 2.1, < 4.0) mail (~> 2.7) erubi (1.13.0) et-orbi (1.2.11) tzinfo - excon (0.110.0) + excon (0.111.0) fabrication (2.31.0) - faker (3.4.1) + faker (3.4.2) i18n (>= 1.8.11, < 2) faraday (1.10.3) faraday-em_http (~> 1.0) @@ -251,7 +253,7 @@ GEM faraday-httpclient (1.0.1) faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) @@ -264,7 +266,13 @@ GEM ffi-compiler (1.3.2) ffi (>= 1.15.5) rake - fog-core (2.4.0) + flatware (2.3.3) + drb + thor (< 2.0) + flatware-rspec (2.3.3) + flatware (= 2.3.3) + rspec (>= 3.6) + fog-core (2.5.0) builder excon (~> 0.71) formatador (>= 0.2, < 2.0) @@ -276,17 +284,14 @@ GEM fog-core (~> 2.1) fog-json (>= 1.0) formatador (1.1.0) - fugit (1.10.1) - et-orbi (~> 1, >= 1.2.7) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) - fuubar (2.5.1) - rspec-core (~> 3.0) - ruby-progressbar (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - google-protobuf (3.25.3) - googleapis-common-protos-types (1.14.0) - google-protobuf (~> 3.18) + google-protobuf (3.25.5) + googleapis-common-protos-types (1.15.0) + google-protobuf (>= 3.18, < 5.a) haml (6.3.0) temple (>= 0.8.2) thor @@ -302,11 +307,12 @@ GEM rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hashdiff (1.1.0) + hashdiff (1.1.1) hashie (5.0.0) hcaptcha (7.1.0) json - highline (3.0.1) + highline (3.1.1) + reline hiredis (0.6.3) hkdf (0.3.0) htmlentities (4.3.4) @@ -324,7 +330,7 @@ GEM httplog (1.7.0) rack (>= 2.0) rainbow (>= 2.0.0) - i18n (1.14.5) + i18n (1.14.6) concurrent-ruby (~> 1.0) i18n-tasks (1.0.14) activesupport (>= 4.0.2) @@ -337,11 +343,11 @@ GEM rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) idn-ruby (0.1.5) - inline_svg (1.9.0) + inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.7.2) - irb (1.13.2) + irb (1.14.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) @@ -352,18 +358,19 @@ GEM aes_key_wrap bindata httpclient - json-ld (3.3.1) + json-ld (3.3.2) htmlentities (~> 4.3) json-canonicalization (~> 1.0) link_header (~> 0.0, >= 0.0.8) multi_json (~> 1.15) rack (>= 2.2, < 4) rdf (~> 3.3) + rexml (~> 3.2) json-ld-preloaded (3.3.0) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (4.3.0) - addressable (>= 2.8) + json-schema (5.0.0) + addressable (~> 2.8) jsonapi-renderer (0.2.2) jwt (2.7.1) kaminari (1.2.2) @@ -385,8 +392,9 @@ GEM mime-types terrapin (>= 0.6.0, < 2.0) language_server-protocol (3.17.0.3) - launchy (2.5.2) + launchy (3.0.1) addressable (~> 2.8) + childprocess (~> 5.0) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) @@ -398,7 +406,7 @@ GEM llhttp-ffi (0.5.0) ffi-compiler (~> 1.0) rake (~> 13.0) - logger (1.6.0) + logger (1.6.1) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) @@ -420,22 +428,22 @@ GEM addressable (~> 2.5) azure-storage-blob (~> 2.0.1) hashie (~> 5.0) - memory_profiler (1.0.2) + memory_profiler (1.1.0) mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2024.0604) + mime-types-data (3.2024.0820) mini_mime (1.1.5) mini_portile2 (2.8.7) - minitest (5.23.1) + minitest (5.25.1) msgpack (1.7.2) multi_json (1.15.0) - multipart-post (2.4.0) + multipart-post (2.4.1) mutex_m (0.2.0) net-http (0.4.1) uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.12) + net-imap (0.4.15) date net-protocol net-ldap (0.19.0) @@ -446,16 +454,12 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.3) - nokogiri (1.16.6) + nokogiri (1.16.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nsa (0.3.0) - activesupport (>= 4.2, < 7.2) - concurrent-ruby (~> 1.0, >= 1.0.2) - sidekiq (>= 3.5) - statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.16.4) + oj (3.16.6) bigdecimal (>= 3.0) + ostruct (>= 0.2) omniauth (2.1.2) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -464,12 +468,12 @@ GEM addressable (~> 2.8) nokogiri (~> 1.12) omniauth (~> 2.1) - omniauth-rails_csrf_protection (1.0.1) + omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) - omniauth-saml (2.1.0) - omniauth (~> 2.0) - ruby-saml (~> 1.12) + omniauth-saml (2.2.1) + omniauth (~> 2.1) + ruby-saml (~> 1.17) omniauth_openid_connect (0.6.1) omniauth (>= 1.9, < 3) openid_connect (~> 1.1) @@ -487,18 +491,18 @@ GEM openssl (3.2.0) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.2.5) - opentelemetry-common (0.20.1) + opentelemetry-api (1.4.0) + opentelemetry-common (0.21.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.28.0) + opentelemetry-exporter-otlp (0.29.0) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-sdk (~> 1.2) opentelemetry-semantic_conventions - opentelemetry-helpers-sql-obfuscation (0.1.0) - opentelemetry-common (~> 0.20) + opentelemetry-helpers-sql-obfuscation (0.2.0) + opentelemetry-common (~> 0.21) opentelemetry-instrumentation-action_mailer (0.1.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-active_support (~> 0.1) @@ -507,109 +511,110 @@ GEM opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.7.0) + opentelemetry-instrumentation-action_view (0.7.2) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-active_support (~> 0.1) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_job (0.7.1) + opentelemetry-instrumentation-active_job (0.7.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_model_serializers (0.20.1) + opentelemetry-instrumentation-active_model_serializers (0.20.2) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_record (0.7.2) + opentelemetry-instrumentation-active_record (0.7.3) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_support (0.5.1) + opentelemetry-instrumentation-active_support (0.6.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-base (0.22.3) + opentelemetry-instrumentation-base (0.22.6) opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) - opentelemetry-instrumentation-concurrent_ruby (0.21.3) + opentelemetry-instrumentation-concurrent_ruby (0.21.4) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-excon (0.22.3) + opentelemetry-instrumentation-excon (0.22.4) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-faraday (0.24.4) + opentelemetry-instrumentation-faraday (0.24.6) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http (0.23.3) + opentelemetry-instrumentation-http (0.23.4) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http_client (0.22.6) + opentelemetry-instrumentation-http_client (0.22.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-net_http (0.22.6) + opentelemetry-instrumentation-net_http (0.22.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-pg (0.27.3) + opentelemetry-instrumentation-pg (0.29.0) opentelemetry-api (~> 1.0) opentelemetry-helpers-sql-obfuscation opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (0.24.5) + opentelemetry-instrumentation-rack (0.24.6) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rails (0.30.2) + opentelemetry-instrumentation-rails (0.31.2) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-action_mailer (~> 0.1.0) opentelemetry-instrumentation-action_pack (~> 0.9.0) opentelemetry-instrumentation-action_view (~> 0.7.0) opentelemetry-instrumentation-active_job (~> 0.7.0) opentelemetry-instrumentation-active_record (~> 0.7.0) - opentelemetry-instrumentation-active_support (~> 0.5.0) + opentelemetry-instrumentation-active_support (~> 0.6.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-redis (0.25.6) + opentelemetry-instrumentation-redis (0.25.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-sidekiq (0.25.5) + opentelemetry-instrumentation-sidekiq (0.25.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-registry (0.3.1) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.4.1) + opentelemetry-sdk (1.5.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.10.0) + opentelemetry-semantic_conventions (1.10.1) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) + ostruct (0.6.0) ox (2.14.18) - parallel (1.25.1) - parser (3.3.3.0) + parallel (1.26.3) + parser (3.3.5.0) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.6) - pghero (3.5.0) - activerecord (>= 6) - premailer (1.23.0) + pg (1.5.8) + pghero (3.6.0) + activerecord (>= 6.1) + premailer (1.27.0) addressable - css_parser (>= 1.12.0) + css_parser (>= 1.19.0) htmlentities (>= 4.0.0) premailer-rails (1.12.0) actionmailer (>= 3) net-smtp premailer (~> 1.7, >= 1.7.9) - private_address_check (0.5.0) - propshaft (0.9.0) + propshaft (1.0.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack railties (>= 7.0.0) psych (5.1.2) stringio - public_suffix (6.0.0) - puma (6.4.2) + public_suffix (6.0.1) + puma (6.4.3) nio4r (~> 2.0) - pundit (2.3.2) + pundit (2.4.0) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.8.0) + racc (1.8.1) rack (2.2.9) rack-attack (6.7.0) rack (>= 1.0, < 4) @@ -633,20 +638,20 @@ GEM rackup (1.0.0) rack (< 3) webrick - rails (7.1.3.4) - actioncable (= 7.1.3.4) - actionmailbox (= 7.1.3.4) - actionmailer (= 7.1.3.4) - actionpack (= 7.1.3.4) - actiontext (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activemodel (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + rails (7.1.4) + actioncable (= 7.1.4) + actionmailbox (= 7.1.4) + actionmailer (= 7.1.4) + actionpack (= 7.1.4) + actiontext (= 7.1.4) + actionview (= 7.1.4) + activejob (= 7.1.4) + activemodel (= 7.1.4) + activerecord (= 7.1.4) + activestorage (= 7.1.4) + activesupport (= 7.1.4) bundler (>= 1.15.0) - railties (= 7.1.3.4) + railties (= 7.1.4) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -661,9 +666,9 @@ GEM rails-i18n (7.0.9) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) + railties (7.1.4) + actionpack (= 7.1.4) + activesupport (= 7.1.4) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -671,8 +676,9 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdf (3.3.1) + rdf (3.3.2) bcp47_spec (~> 0.2) + bigdecimal (~> 3.1, >= 3.1.5) link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.7.0) rdf (~> 3.3) @@ -685,25 +691,28 @@ GEM redlock (1.3.2) redis (>= 3.0.0, < 6.0) regexp_parser (2.9.2) - reline (0.5.9) + reline (0.5.10) io-console (~> 0.5) request_store (1.6.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.0) - strscan + rexml (3.3.7) rotp (6.3.0) - rouge (4.2.1) + rouge (4.3.0) rpam2 (4.0.2) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec-core (3.13.0) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.1) rspec-support (~> 3.13.0) - rspec-expectations (3.13.1) + rspec-expectations (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-github (2.4.0) @@ -711,10 +720,10 @@ GEM rspec-mocks (3.13.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.3) - actionpack (>= 6.1) - activesupport (>= 6.1) - railties (>= 6.1) + rspec-rails (7.0.1) + actionpack (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) rspec-core (~> 3.13) rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) @@ -725,70 +734,72 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 8) rspec-support (3.13.1) - rubocop (1.64.1) + rubocop (1.66.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.4, < 3.0) + rubocop-ast (>= 1.32.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.3) + rubocop-ast (1.32.3) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) rubocop-performance (1.21.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.25.0) + rubocop-rails (2.25.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.0.1) + rubocop-rspec (3.0.4) rubocop (~> 1.61) rubocop-rspec_rails (2.30.0) rubocop (~> 1.61) rubocop-rspec (~> 3, >= 3.0.1) ruby-prof (1.7.0) ruby-progressbar (1.13.0) - ruby-saml (1.16.0) + ruby-saml (1.17.0) nokogiri (>= 1.13.10) rexml - ruby-vips (2.2.1) + ruby-vips (2.2.2) ffi (~> 1.12) + logger ruby2_keywords (0.0.5) rubyzip (2.3.2) rufus-scheduler (3.9.1) fugit (~> 1.1, >= 1.1.6) safety_net_attestation (0.4.0) jwt (~> 2.0) - sanitize (6.1.1) + sanitize (6.1.3) crass (~> 1.0.2) nokogiri (>= 1.12.0) scenic (1.8.0) activerecord (>= 4.0.0) railties (>= 4.0.0) - selenium-webdriver (4.22.0) + selenium-webdriver (4.25.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) semantic_range (3.0.0) + shoulda-matchers (6.4.0) + activesupport (>= 5.2.0) sidekiq (6.5.12) connection_pool (>= 2.2.5, < 3) rack (~> 2.0) redis (>= 4.5.0, < 5) sidekiq-bulk (0.2.0) sidekiq - sidekiq-scheduler (5.0.3) + sidekiq-scheduler (5.0.6) rufus-scheduler (~> 3.2) sidekiq (>= 6, < 8) - tilt (>= 1.4.0) + tilt (>= 1.4.0, < 3) sidekiq-unique-jobs (7.1.33) brpoplpush-redis_script (> 0.1.1, <= 2.0.0) concurrent-ruby (~> 1.0, >= 1.0.5) @@ -808,13 +819,11 @@ GEM simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) stackprof (0.2.26) - statsd-ruby (1.5.0) stoplight (4.1.0) redlock (~> 1.0) stringio (3.1.1) - strong_migrations (1.8.0) - activerecord (>= 5.2) - strscan (3.1.0) + strong_migrations (2.0.0) + activerecord (>= 6.1) swd (1.3.0) activesupport (>= 3) attr_required (>= 0.0.5) @@ -825,11 +834,11 @@ GEM unicode-display_width (>= 1.1.1, < 3) terrapin (1.0.1) climate_control - test-prof (1.3.3) - thor (1.3.1) - tilt (2.3.0) + test-prof (1.4.2) + thor (1.3.2) + tilt (2.4.0) timeout (0.4.1) - tpm-key_attestation (0.12.0) + tpm-key_attestation (0.12.1) bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) @@ -848,13 +857,13 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2024.1) + tzinfo-data (1.2024.2) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext unf_ext (0.0.9.1) unicode-display_width (2.5.0) - uri (0.13.0) + uri (0.13.1) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -884,8 +893,8 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - webrick (1.8.1) - websocket (1.2.10) + webrick (1.8.2) + websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -893,7 +902,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.16) + zeitwerk (2.6.18) PLATFORMS ruby @@ -908,7 +917,7 @@ DEPENDENCIES blurhash (~> 0.1) bootsnap (~> 1.18.0) brakeman (~> 6.0) - browser + browser (< 6) bundler-audit (~> 0.9) capybara (~> 3.39) charlock_holmes (~> 0.7.7) @@ -927,15 +936,14 @@ DEPENDENCIES discard (~> 1.2) doorkeeper (~> 5.6) dotenv - ed25519 (~> 1.3) email_spec fabrication (~> 2.30) faker (~> 3.2) fast_blank (~> 1.0) fastimage - fog-core (<= 2.4.0) + flatware-rspec + fog-core (<= 2.5.0) fog-openstack (~> 1.0) - fuubar (~> 2.5) haml-rails (~> 2.0) haml_lint hcaptcha (~> 7.1) @@ -951,7 +959,7 @@ DEPENDENCIES irb (~> 1.8) json-ld json-ld-preloaded (~> 3.2) - json-schema (~> 4.0) + json-schema (~> 5.0) kaminari (~> 1.2) kt-paperclip (~> 7.2) letter_opener (~> 1.8) @@ -966,15 +974,14 @@ DEPENDENCIES net-http (~> 0.4.0) net-ldap (~> 0.18) nokogiri (~> 1.15) - nsa oj (~> 3.14) omniauth (~> 2.0) omniauth-cas (~> 3.0.0.beta.1) omniauth-rails_csrf_protection (~> 1.0) omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.6.1) - opentelemetry-api (~> 1.2.5) - opentelemetry-exporter-otlp (~> 0.28.0) + opentelemetry-api (~> 1.4.0) + opentelemetry-exporter-otlp (~> 0.29.0) opentelemetry-instrumentation-active_job (~> 0.7.1) opentelemetry-instrumentation-active_model_serializers (~> 0.20.1) opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2) @@ -983,9 +990,9 @@ DEPENDENCIES opentelemetry-instrumentation-http (~> 0.23.2) opentelemetry-instrumentation-http_client (~> 0.22.3) opentelemetry-instrumentation-net_http (~> 0.22.4) - opentelemetry-instrumentation-pg (~> 0.27.1) + opentelemetry-instrumentation-pg (~> 0.29.0) opentelemetry-instrumentation-rack (~> 0.24.1) - opentelemetry-instrumentation-rails (~> 0.30.0) + opentelemetry-instrumentation-rails (~> 0.31.0) opentelemetry-instrumentation-redis (~> 0.25.3) opentelemetry-instrumentation-sidekiq (~> 0.25.2) opentelemetry-sdk (~> 1.4) @@ -994,7 +1001,6 @@ DEPENDENCIES pg (~> 1.5) pghero premailer-rails - private_address_check (~> 0.5) propshaft public_suffix (~> 6.0) puma (~> 6.3) @@ -1012,7 +1018,7 @@ DEPENDENCIES redis-namespace (~> 1.10) rqrcode (~> 2.2) rspec-github (~> 2.4) - rspec-rails (~> 6.0) + rspec-rails (~> 7.0) rspec-sidekiq (~> 5.0) rubocop rubocop-capybara @@ -1027,6 +1033,7 @@ DEPENDENCIES sanitize (~> 6.0) scenic (~> 1.7) selenium-webdriver + shoulda-matchers sidekiq (~> 6.5) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 5.0) @@ -1037,7 +1044,7 @@ DEPENDENCIES simplecov-lcov (~> 0.8) stackprof stoplight (~> 4.1) - strong_migrations (= 1.8.0) + strong_migrations test-prof thor (~> 1.2) tty-prompt (~> 0.23) @@ -1050,7 +1057,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.3.2p78 + ruby 3.3.4p94 BUNDLED WITH - 2.5.11 + 2.5.18 diff --git a/Procfile b/Procfile index d15c835b867..f033fd36c6b 100644 --- a/Procfile +++ b/Procfile @@ -11,4 +11,4 @@ worker: bundle exec sidekiq # # and let the main app use the separate app: # -# heroku config:set STREAMING_API_BASE_URL=wss://.herokuapp.com -a +# heroku config:set STREAMING_API_BASE_URL=wss://.herokuapp.com -a diff --git a/app.json b/app.json index 4f05a64f516..5e5a3dc1e7b 100644 --- a/app.json +++ b/app.json @@ -90,9 +90,15 @@ } }, "buildpacks": [ + { + "url": "https://github.com/heroku/heroku-buildpack-activestorage-preview" + }, { "url": "https://github.com/heroku/heroku-buildpack-apt" }, + { + "url": "heroku/nodejs" + }, { "url": "heroku/ruby" } @@ -100,5 +106,6 @@ "scripts": { "postdeploy": "bundle exec rails db:migrate && bundle exec rails db:seed" }, - "addons": ["heroku-postgresql", "heroku-redis"] + "addons": ["heroku-postgresql", "heroku-redis"], + "stack": "heroku-24" } diff --git a/app/controllers/activitypub/claims_controller.rb b/app/controllers/activitypub/claims_controller.rb deleted file mode 100644 index 480baaf2bcc..00000000000 --- a/app/controllers/activitypub/claims_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class ActivityPub::ClaimsController < ActivityPub::BaseController - skip_before_action :authenticate_user! - - before_action :require_account_signature! - before_action :set_claim_result - - def create - render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer - end - - private - - def set_claim_result - @claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id]) - end -end diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index c25362c9bc0..ab1b98e646a 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -22,8 +22,6 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController @items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) } when 'tags' @items = for_signed_account { @account.featured_tags } - when 'devices' - @items = @account.devices else not_found end @@ -31,7 +29,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def set_size case params[:id] - when 'featured', 'devices', 'tags' + when 'featured', 'tags' @size = @items.size else not_found @@ -42,7 +40,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController case params[:id] when 'featured' @type = :ordered - when 'devices', 'tags' + when 'tags' @type = :unordered else not_found diff --git a/app/controllers/activitypub/likes_controller.rb b/app/controllers/activitypub/likes_controller.rb new file mode 100644 index 00000000000..4aa6a4a771f --- /dev/null +++ b/app/controllers/activitypub/likes_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::LikesController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_status + + def index + expires_in 0, public: @status.distributable? && public_fetch_mode? + render json: likes_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_status + @status = @account.statuses.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def likes_collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_status_likes_url(@account, @status), + type: :unordered, + size: @status.favourites_count + ) + end +end diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index 11aac48c9c3..0a19275d38e 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -12,7 +12,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController before_action :set_replies def index - expires_in 0, public: public_fetch_mode? + expires_in 0, public: @status.distributable? && public_fetch_mode? render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true end diff --git a/app/controllers/activitypub/shares_controller.rb b/app/controllers/activitypub/shares_controller.rb new file mode 100644 index 00000000000..65b4a5b3831 --- /dev/null +++ b/app/controllers/activitypub/shares_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::SharesController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_status + + def index + expires_in 0, public: @status.distributable? && public_fetch_mode? + render json: shares_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_status + @status = @account.statuses.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def shares_collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_status_shares_url(@account, @status), + type: :unordered, + size: @status.reblogs_count + ) + end +end diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb index 8b6c1a4454e..a3c4adf59a7 100644 --- a/app/controllers/admin/account_moderation_notes_controller.rb +++ b/app/controllers/admin/account_moderation_notes_controller.rb @@ -13,7 +13,7 @@ module Admin redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.created_msg') else @account = @account_moderation_note.target_account - @moderation_notes = @account.targeted_moderation_notes.latest + @moderation_notes = @account.targeted_moderation_notes.chronological.includes(:account) @warnings = @account.strikes.custom.latest render 'admin/accounts/show' diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 9beb8fde6b2..7b169ba26a3 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -33,7 +33,7 @@ module Admin @deletion_request = @account.deletion_request @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) - @moderation_notes = @account.targeted_moderation_notes.latest + @moderation_notes = @account.targeted_moderation_notes.chronological.includes(:account) @warnings = @account.strikes.includes(:target_account, :account, :appeal).latest @domain_block = DomainBlock.rule_for(@account.domain) end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 4b5afbe157c..48685db17a2 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -7,17 +7,12 @@ module Admin layout 'admin' - before_action :set_body_classes before_action :set_cache_headers after_action :verify_authorized private - def set_body_classes - @body_classes = 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 3a6df662ea2..5b0867dcfba 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -7,12 +7,12 @@ module Admin def index authorize :dashboard, :index? + @pending_appeals_count = Appeal.pending.async_count + @pending_reports_count = Report.unresolved.async_count + @pending_tags_count = Tag.pending_review.async_count + @pending_users_count = User.pending.async_count @system_checks = Admin::SystemCheck.perform(current_user) @time_period = (29.days.ago.to_date...Time.now.utc.to_date) - @pending_users_count = User.pending.count - @pending_reports_count = Report.unresolved.count - @pending_tags_count = Tag.pending_review.count - @pending_appeals_count = Appeal.pending.count end end end diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index a6997b62f7d..d7f88a71f36 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -13,6 +13,7 @@ module Admin def show authorize :instance, :show? @time_period = (6.days.ago.to_date...Time.now.utc.to_date) + @action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5) end def destroy diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb index b5f04a1caa0..6b16c29fc7d 100644 --- a/app/controllers/admin/report_notes_controller.rb +++ b/app/controllers/admin/report_notes_controller.rb @@ -21,7 +21,7 @@ module Admin redirect_to after_create_redirect_path, notice: I18n.t('admin.report_notes.created_msg') else - @report_notes = @report.notes.includes(:account).order(id: :desc) + @report_notes = @report.notes.chronological.includes(:account) @action_logs = @report.history.includes(:target) @form = Admin::StatusBatchAction.new @statuses = @report.statuses.with_includes diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 00d200d7c88..aa877f1448c 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -13,7 +13,7 @@ module Admin authorize @report, :show? @report_note = @report.notes.new - @report_notes = @report.notes.includes(:account).order(id: :desc) + @report_notes = @report.notes.chronological.includes(:account) @action_logs = @report.history.includes(:target) @form = Admin::StatusBatchAction.new @statuses = @report.statuses.with_includes diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 4f727c398a0..4759d15bc4b 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -2,7 +2,15 @@ module Admin class TagsController < BaseController - before_action :set_tag + before_action :set_tag, except: [:index] + + PER_PAGE = 20 + + def index + authorize :tag, :index? + + @tags = filtered_tags.page(params[:page]).per(PER_PAGE) + end def show authorize @tag, :show? @@ -31,5 +39,13 @@ module Admin def tag_params params.require(:tag).permit(:name, :display_name, :trendable, :usable, :listable) end + + def filtered_tags + TagFilter.new(filter_params.with_defaults(order: 'newest')).results + end + + def filter_params + params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS) + end end end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index c1a5e43f882..0980e0ebbc6 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -30,10 +30,10 @@ class Api::BaseController < ApplicationController protected - def limit_param(default_limit) + def limit_param(default_limit, max_limit = nil) return default_limit unless params[:limit] - [params[:limit].to_i.abs, default_limit * 2].min + [params[:limit].to_i.abs, max_limit || (default_limit * 2)].min end def params_slice(*keys) diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index 66da65bedaf..b7f22824a7a 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -7,7 +7,7 @@ class Api::OEmbedController < Api::BaseController before_action :require_public_status! def show - render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default + render json: @status, serializer: OEmbedSerializer, width: params[:maxwidth], height: params[:maxheight] end private @@ -23,12 +23,4 @@ class Api::OEmbedController < Api::BaseController def status_finder StatusFinder.new(params[:url]) end - - def maxwidth_or_default - (params[:maxwidth].presence || 400).to_i - end - - def maxheight_or_default - params[:maxheight].present? ? params[:maxheight].to_i : nil - end end diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb index 9801d832b8b..24f68aa1bd8 100644 --- a/app/controllers/api/v1/admin/domain_allows_controller.rb +++ b/app/controllers/api/v1/admin/domain_allows_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController include AccountableConcern LIMIT = 100 + MAX_LIMIT = 500 before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show] @@ -47,18 +48,13 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController private def set_domain_allows - @domain_allows = filtered_domain_allows.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + @domain_allows = DomainAllow.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT, MAX_LIMIT), params_slice(:max_id, :since_id, :min_id)) end def set_domain_allow @domain_allow = DomainAllow.find(params[:id]) end - def filtered_domain_allows - # TODO: no filtering yet - DomainAllow.all - end - def next_path api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -72,7 +68,7 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController end def records_continue? - @domain_allows.size == limit_param(LIMIT) + @domain_allows.size == limit_param(LIMIT, MAX_LIMIT) end def resource_params diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb index a20a4a9c7f8..b44ae2ae2a2 100644 --- a/app/controllers/api/v1/admin/domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController include AccountableConcern LIMIT = 100 + MAX_LIMIT = 500 before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_blocks' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_blocks' }, except: [:index, :show] @@ -59,18 +60,13 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController end def set_domain_blocks - @domain_blocks = filtered_domain_blocks.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + @domain_blocks = DomainBlock.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT, MAX_LIMIT), params_slice(:max_id, :since_id, :min_id)) end def set_domain_block @domain_block = DomainBlock.find(params[:id]) end - def filtered_domain_blocks - # TODO: no filtering yet - DomainBlock.all - end - def domain_block_params params.permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) end @@ -88,7 +84,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController end def records_continue? - @domain_blocks.size == limit_param(LIMIT) + @domain_blocks.size == limit_param(LIMIT, MAX_LIMIT) end def resource_params diff --git a/app/controllers/api/v1/crypto/deliveries_controller.rb b/app/controllers/api/v1/crypto/deliveries_controller.rb deleted file mode 100644 index aa9df6e03b2..00000000000 --- a/app/controllers/api/v1/crypto/deliveries_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::DeliveriesController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_current_device - - def create - devices.each do |device_params| - DeliverToDeviceService.new.call(current_account, @current_device, device_params) - end - - render_empty - end - - private - - def set_current_device - @current_device = Device.find_by!(access_token: doorkeeper_token) - end - - def resource_params - params.require(:device) - params.permit(device: [:account_id, :device_id, :type, :body, :hmac]) - end - - def devices - Array(resource_params[:device]) - end -end diff --git a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb deleted file mode 100644 index 93ae0e77713..00000000000 --- a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController - LIMIT = 80 - - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_current_device - - before_action :set_encrypted_messages, only: :index - after_action :insert_pagination_headers, only: :index - - def index - render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer - end - - def clear - @current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all - render_empty - end - - private - - def set_current_device - @current_device = Device.find_by!(access_token: doorkeeper_token) - end - - def set_encrypted_messages - @encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) - end - - def next_path - api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue? - end - - def prev_path - api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty? - end - - def pagination_collection - @encrypted_messages - end - - def records_continue? - @encrypted_messages.size == limit_param(LIMIT) - end -end diff --git a/app/controllers/api/v1/crypto/keys/claims_controller.rb b/app/controllers/api/v1/crypto/keys/claims_controller.rb deleted file mode 100644 index f9d202d67b8..00000000000 --- a/app/controllers/api/v1/crypto/keys/claims_controller.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_claim_results - - def create - render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer - end - - private - - def set_claim_results - @claim_results = devices.filter_map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) } - end - - def resource_params - params.permit(device: [:account_id, :device_id]) - end - - def devices - Array(resource_params[:device]) - end -end diff --git a/app/controllers/api/v1/crypto/keys/counts_controller.rb b/app/controllers/api/v1/crypto/keys/counts_controller.rb deleted file mode 100644 index ffd7151b782..00000000000 --- a/app/controllers/api/v1/crypto/keys/counts_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::CountsController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_current_device - - def show - render json: { one_time_keys: @current_device.one_time_keys.count } - end - - private - - def set_current_device - @current_device = Device.find_by!(access_token: doorkeeper_token) - end -end diff --git a/app/controllers/api/v1/crypto/keys/queries_controller.rb b/app/controllers/api/v1/crypto/keys/queries_controller.rb deleted file mode 100644 index e6ce9f9192a..00000000000 --- a/app/controllers/api/v1/crypto/keys/queries_controller.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::QueriesController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_accounts - before_action :set_query_results - - def create - render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer - end - - private - - def set_accounts - @accounts = Account.where(id: account_ids).includes(:devices) - end - - def set_query_results - @query_results = @accounts.filter_map { |account| ::Keys::QueryService.new.call(account) } - end - - def account_ids - Array(params[:id]).map(&:to_i) - end -end diff --git a/app/controllers/api/v1/crypto/keys/uploads_controller.rb b/app/controllers/api/v1/crypto/keys/uploads_controller.rb deleted file mode 100644 index fc4abf63b3a..00000000000 --- a/app/controllers/api/v1/crypto/keys/uploads_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::UploadsController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - - def create - device = Device.find_or_initialize_by(access_token: doorkeeper_token) - - device.transaction do - device.account = current_account - device.update!(resource_params[:device]) - - if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable) - resource_params[:one_time_keys].each do |one_time_key_params| - device.one_time_keys.create!(one_time_key_params) - end - end - end - - render json: device, serializer: REST::Keys::DeviceSerializer - end - - private - - def resource_params - params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature]) - end -end diff --git a/app/controllers/api/v1/domain_blocks/previews_controller.rb b/app/controllers/api/v1/domain_blocks/previews_controller.rb new file mode 100644 index 00000000000..a917bddd98e --- /dev/null +++ b/app/controllers/api/v1/domain_blocks/previews_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Api::V1::DomainBlocks::PreviewsController < Api::BaseController + before_action -> { doorkeeper_authorize! :follow, :write, :'write:blocks' } + before_action :require_user! + before_action :set_domain + before_action :set_domain_block_preview + + def show + render json: @domain_block_preview, serializer: REST::DomainBlockPreviewSerializer + end + + private + + def set_domain + @domain = TagManager.instance.normalize_domain(params[:domain]) + end + + def set_domain_block_preview + @domain_block_preview = with_read_replica do + DomainBlockPreviewPresenter.new( + following_count: current_account.following.where(domain: @domain).count, + followers_count: current_account.followers.where(domain: @domain).count + ) + end + end +end diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb index 1ec336f9a59..9d70c283bec 100644 --- a/app/controllers/api/v1/notifications/policies_controller.rb +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -8,12 +8,12 @@ class Api::V1::Notifications::PoliciesController < Api::BaseController before_action :set_policy def show - render json: @policy, serializer: REST::NotificationPolicySerializer + render json: @policy, serializer: REST::V1::NotificationPolicySerializer end def update @policy.update!(resource_params) - render json: @policy, serializer: REST::NotificationPolicySerializer + render json: @policy, serializer: REST::V1::NotificationPolicySerializer end private diff --git a/app/controllers/api/v1/notifications/requests_controller.rb b/app/controllers/api/v1/notifications/requests_controller.rb index 0e58379a382..36ee073b9ce 100644 --- a/app/controllers/api/v1/notifications/requests_controller.rb +++ b/app/controllers/api/v1/notifications/requests_controller.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true class Api::V1::Notifications::RequestsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :index - before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index + include Redisable + + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: [:index, :show, :merged?] + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: [:index, :show, :merged?] before_action :require_user! - before_action :set_request, except: :index + before_action :set_request, only: [:show, :accept, :dismiss] + before_action :set_requests, only: [:accept_bulk, :dismiss_bulk] after_action :insert_pagination_headers, only: :index @@ -18,6 +21,10 @@ class Api::V1::Notifications::RequestsController < Api::BaseController render json: @requests, each_serializer: REST::NotificationRequestSerializer, relationships: @relationships end + def merged? + render json: { merged: redis.get("notification_unfilter_jobs:#{current_account.id}").to_i <= 0 } + end + def show render json: @request, serializer: REST::NotificationRequestSerializer end @@ -28,14 +35,24 @@ class Api::V1::Notifications::RequestsController < Api::BaseController end def dismiss - @request.update!(dismissed: true) + DismissNotificationRequestService.new.call(@request) + render_empty + end + + def accept_bulk + @requests.each { |request| AcceptNotificationRequestService.new.call(request) } + render_empty + end + + def dismiss_bulk + @requests.each(&:destroy!) render_empty end private def load_requests - requests = NotificationRequest.where(account: current_account).where(dismissed: truthy_param?(:dismissed) || false).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id( + requests = NotificationRequest.where(account: current_account).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id( limit_param(DEFAULT_ACCOUNTS_LIMIT), params_slice(:max_id, :since_id, :min_id) ) @@ -53,14 +70,22 @@ class Api::V1::Notifications::RequestsController < Api::BaseController @request = NotificationRequest.where(account: current_account).find(params[:id]) end + def set_requests + @requests = NotificationRequest.where(account: current_account, id: Array(params[:id]).uniq.map(&:to_i)) + end + def next_path - api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) unless @requests.empty? + api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) if records_continue? end def prev_path api_v1_notifications_requests_url pagination_params(min_id: pagination_since_id) unless @requests.empty? end + def records_continue? + @requests.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + def pagination_max_id @requests.last.id end @@ -68,8 +93,4 @@ class Api::V1::Notifications::RequestsController < Api::BaseController def pagination_since_id @requests.first.id end - - def pagination_params(core_params) - params.slice(:dismissed).permit(:dismissed).merge(core_params) - end end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 1d0aa10d2e7..13919b400df 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -7,6 +7,8 @@ class Api::V1::NotificationsController < Api::BaseController after_action :insert_pagination_headers, only: :index DEFAULT_NOTIFICATIONS_LIMIT = 40 + DEFAULT_NOTIFICATIONS_COUNT_LIMIT = 100 + MAX_NOTIFICATIONS_COUNT_LIMIT = 1_000 def index with_read_replica do @@ -17,6 +19,14 @@ class Api::V1::NotificationsController < Api::BaseController render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships end + def unread_count + limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT) + + with_read_replica do + render json: { count: browserable_account_notifications.paginate_by_min_id(limit, notification_marker&.last_read_id).count } + end + end + def show @notification = current_account.notifications.without_suspended.find(params[:id]) render json: @notification, serializer: REST::NotificationSerializer @@ -54,6 +64,10 @@ class Api::V1::NotificationsController < Api::BaseController ) end + def notification_marker + current_user.markers.find_by(timeline: 'notifications') + end + def target_statuses_from_notifications @notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status) end diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb index 1780554c5d8..d9c82327022 100644 --- a/app/controllers/api/v1/peers/search_controller.rb +++ b/app/controllers/api/v1/peers/search_controller.rb @@ -7,6 +7,8 @@ class Api::V1::Peers::SearchController < Api::BaseController skip_before_action :require_authenticated_user!, unless: :limited_federation_mode? skip_around_action :set_locale + LIMIT = 10 + vary_by '' def index @@ -35,10 +37,10 @@ class Api::V1::Peers::SearchController < Api::BaseController field: 'accounts_count', modifier: 'log2p', }, - }).limit(10).pluck(:domain) + }).limit(LIMIT).pluck(:domain) else domain = normalized_domain - @domains = Instance.searchable.domain_starts_with(domain).limit(10).pluck(:domain) + @domains = Instance.searchable.domain_starts_with(domain).limit(LIMIT).pluck(:domain) end rescue Addressable::URI::InvalidURIError @domains = [] diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index 513b937ef2d..ad1b82cb522 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -8,7 +8,7 @@ class Api::V1::Polls::VotesController < Api::BaseController before_action :set_poll def create - VoteService.new.call(current_account, @poll, vote_params[:choices]) + VoteService.new.call(current_account, @poll, vote_params) render json: @poll, serializer: REST::PollSerializer end @@ -22,6 +22,6 @@ class Api::V1::Polls::VotesController < Api::BaseController end def vote_params - params.permit(choices: []) + params.require(:choices) end end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 300c9faa3f0..72f358bb5bc 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -10,7 +10,7 @@ class Api::V1::ReportsController < Api::BaseController @report = ReportService.new.call( current_account, reported_account, - report_params + report_params.merge(application: doorkeeper_token.application) ) render json: @report, serializer: REST::ReportSerializer diff --git a/app/controllers/api/v1/scheduled_statuses_controller.rb b/app/controllers/api/v1/scheduled_statuses_controller.rb index 45ee5865188..c62305d7114 100644 --- a/app/controllers/api/v1/scheduled_statuses_controller.rb +++ b/app/controllers/api/v1/scheduled_statuses_controller.rb @@ -6,6 +6,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, except: [:update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:update, :destroy] + before_action :require_user! before_action :set_statuses, only: :index before_action :set_status, except: :index diff --git a/app/controllers/api/v1/statuses/translations_controller.rb b/app/controllers/api/v1/statuses/translations_controller.rb index 7d406b0a36d..8cf495f78ac 100644 --- a/app/controllers/api/v1/statuses/translations_controller.rb +++ b/app/controllers/api/v1/statuses/translations_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseController before_action -> { doorkeeper_authorize! :read, :'read:statuses' } + before_action :require_user! before_action :set_translation rescue_from TranslationService::NotConfiguredError, with: :not_found diff --git a/app/controllers/api/v1/timelines/base_controller.rb b/app/controllers/api/v1/timelines/base_controller.rb index e79eba79ee5..1dba4a5bb21 100644 --- a/app/controllers/api/v1/timelines/base_controller.rb +++ b/app/controllers/api/v1/timelines/base_controller.rb @@ -3,8 +3,14 @@ class Api::V1::Timelines::BaseController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + before_action :require_user!, if: :require_auth? + private + def require_auth? + !Setting.timeline_preview + end + def pagination_collection @statuses end diff --git a/app/controllers/api/v1/timelines/link_controller.rb b/app/controllers/api/v1/timelines/link_controller.rb index af962c430ff..37ed084f062 100644 --- a/app/controllers/api/v1/timelines/link_controller.rb +++ b/app/controllers/api/v1/timelines/link_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth? + before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :set_preview_card before_action :set_statuses @@ -17,10 +17,6 @@ class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController private - def require_auth? - !Setting.timeline_preview - end - def set_preview_card @preview_card = PreviewCard.joins(:trend).merge(PreviewCardTrend.allowed).find_by!(url: params[:url]) end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index d164854d6a3..029e8fc2c13 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController - before_action :require_user!, only: [:show], if: :require_auth? + before_action -> { authorize_if_got_token! :read, :'read:statuses' } PERMITTED_PARAMS = %i(local remote limit only_media).freeze @@ -13,10 +13,6 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController private - def require_auth? - !Setting.timeline_preview - end - def load_statuses preloaded_public_statuses_page end diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 3bf8f374e19..2b097aab0f8 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth? + before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :load_tag PERMITTED_PARAMS = %i(local limit only_media).freeze diff --git a/app/controllers/api/v2/notifications/accounts_controller.rb b/app/controllers/api/v2/notifications/accounts_controller.rb new file mode 100644 index 00000000000..771e9663883 --- /dev/null +++ b/app/controllers/api/v2/notifications/accounts_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Api::V2::Notifications::AccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' } + before_action :require_user! + before_action :set_notifications! + after_action :insert_pagination_headers, only: :index + + def index + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def load_accounts + @paginated_notifications.map(&:from_account) + end + + def set_notifications! + @paginated_notifications = begin + current_account + .notifications + .without_suspended + .where(group_key: params[:notification_group_key]) + .includes(from_account: [:account_stat, :user]) + .paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + end + + def next_path + api_v2_notification_accounts_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v2_notification_accounts_url pagination_params(min_id: pagination_since_id) unless @paginated_notifications.empty? + end + + def pagination_collection + @paginated_notifications + end + + def records_continue? + @paginated_notifications.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end +end diff --git a/app/controllers/api/v2/notifications/policies_controller.rb b/app/controllers/api/v2/notifications/policies_controller.rb new file mode 100644 index 00000000000..637587967fe --- /dev/null +++ b/app/controllers/api/v2/notifications/policies_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Api::V2::Notifications::PoliciesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update + + before_action :require_user! + before_action :set_policy + + def show + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + def update + @policy.update!(resource_params) + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + private + + def set_policy + @policy = NotificationPolicy.find_or_initialize_by(account: current_account) + + with_read_replica do + @policy.summarize! + end + end + + def resource_params + params.permit( + :for_not_following, + :for_not_followers, + :for_new_accounts, + :for_private_mentions, + :for_limited_accounts + ) + end +end diff --git a/app/controllers/api/v2/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb new file mode 100644 index 00000000000..c070c0e5e71 --- /dev/null +++ b/app/controllers/api/v2/notifications_controller.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +class Api::V2::NotificationsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss] + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss] + before_action :require_user! + after_action :insert_pagination_headers, only: :index + + DEFAULT_NOTIFICATIONS_LIMIT = 40 + DEFAULT_NOTIFICATIONS_COUNT_LIMIT = 100 + MAX_NOTIFICATIONS_COUNT_LIMIT = 1_000 + + def index + with_read_replica do + @notifications = load_notifications + @grouped_notifications = load_grouped_notifications + @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) + @presenter = GroupedNotificationsPresenter.new(@grouped_notifications, expand_accounts: expand_accounts_param) + + # Preload associations to avoid N+1s + ActiveRecord::Associations::Preloader.new(records: @presenter.accounts, associations: [:account_stat, { user: :role }]).call + end + + MastodonOTELTracer.in_span('Api::V2::NotificationsController#index rendering') do |span| + statuses = @grouped_notifications.filter_map { |group| group.target_status&.id } + + span.add_attributes( + 'app.notification_grouping.count' => @grouped_notifications.size, + 'app.notification_grouping.account.count' => @presenter.accounts.size, + 'app.notification_grouping.partial_account.count' => @presenter.partial_accounts.size, + 'app.notification_grouping.status.count' => statuses.size, + 'app.notification_grouping.status.unique_count' => statuses.uniq.size, + 'app.notification_grouping.expand_accounts_param' => expand_accounts_param + ) + + render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param + end + end + + def unread_count + limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT) + + with_read_replica do + render json: { count: browserable_account_notifications.paginate_groups_by_min_id(limit, min_id: notification_marker&.last_read_id, grouped_types: params[:grouped_types]).count } + end + end + + def show + @notification = current_account.notifications.without_suspended.find_by!(group_key: params[:group_key]) + presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification])) + render json: presenter, serializer: REST::DedupNotificationGroupSerializer + end + + def clear + current_account.notifications.delete_all + render_empty + end + + def dismiss + current_account.notifications.where(group_key: params[:group_key]).destroy_all + render_empty + end + + private + + def load_notifications + MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_notifications') do + notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id( + limit_param(DEFAULT_NOTIFICATIONS_LIMIT), + params.slice(:max_id, :since_id, :min_id, :grouped_types).permit(:max_id, :since_id, :min_id, grouped_types: []) + ) + + Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| + preload_collection(target_statuses, Status) + end + end + end + + def load_grouped_notifications + return [] if @notifications.empty? + + MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_grouped_notifications') do + NotificationGroup.from_notifications(@notifications, pagination_range: (@notifications.last.id)..(@notifications.first.id), grouped_types: params[:grouped_types]) + end + end + + def browserable_account_notifications + current_account.notifications.without_suspended.browserable( + types: Array(browserable_params[:types]), + exclude_types: Array(browserable_params[:exclude_types]), + include_filtered: truthy_param?(:include_filtered) + ) + end + + def notification_marker + current_user.markers.find_by(timeline: 'notifications') + end + + def target_statuses_from_notifications + @notifications.filter_map(&:target_status) + end + + def next_path + api_v2_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? + end + + def prev_path + api_v2_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? + end + + def pagination_collection + @notifications + end + + def browserable_params + params.slice(:include_filtered, :types, :exclude_types, :grouped_types).permit(:include_filtered, types: [], exclude_types: [], grouped_types: []) + end + + def pagination_params(core_params) + params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types).permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: []).merge(core_params) + end + + def expand_accounts_param + case params[:expand_accounts] + when nil, 'full' + 'full' + when 'partial_avatars' + 'partial_avatars' + else + raise Mastodon::InvalidParameterError, "Invalid value for 'expand_accounts': '#{params[:expand_accounts]}', allowed values are 'full' and 'partial_avatars'" + end + end +end diff --git a/app/controllers/api/v2_alpha/notifications_controller.rb b/app/controllers/api/v2_alpha/notifications_controller.rb deleted file mode 100644 index edba23ab4a8..00000000000 --- a/app/controllers/api/v2_alpha/notifications_controller.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -class Api::V2Alpha::NotificationsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss] - before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss] - before_action :require_user! - after_action :insert_pagination_headers, only: :index - - DEFAULT_NOTIFICATIONS_LIMIT = 40 - - def index - with_read_replica do - @notifications = load_notifications - @group_metadata = load_group_metadata - @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) - end - - render json: @notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata - end - - def show - @notification = current_account.notifications.without_suspended.find_by!(group_key: params[:id]) - render json: NotificationGroup.from_notification(@notification), serializer: REST::NotificationGroupSerializer - end - - def clear - current_account.notifications.delete_all - render_empty - end - - def dismiss - current_account.notifications.where(group_key: params[:id]).destroy_all - render_empty - end - - private - - def load_notifications - notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id( - limit_param(DEFAULT_NOTIFICATIONS_LIMIT), - params_slice(:max_id, :since_id, :min_id) - ) - - Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| - preload_collection(target_statuses, Status) - end - end - - def load_group_metadata - return {} if @notifications.empty? - - browserable_account_notifications - .where(group_key: @notifications.filter_map(&:group_key)) - .where(id: (@notifications.last.id)..(@notifications.first.id)) - .group(:group_key) - .pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at') - .to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] } - end - - def browserable_account_notifications - current_account.notifications.without_suspended.browserable( - types: Array(browserable_params[:types]), - exclude_types: Array(browserable_params[:exclude_types]), - include_filtered: truthy_param?(:include_filtered) - ) - end - - def target_statuses_from_notifications - @notifications.filter_map(&:target_status) - end - - def next_path - api_v2_alpha_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? - end - - def prev_path - api_v2_alpha_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? - end - - def pagination_collection - @notifications - end - - def browserable_params - params.permit(:include_filtered, types: [], exclude_types: []) - end - - def pagination_params(core_params) - params.slice(:limit, :types, :exclude_types, :include_filtered).permit(:limit, :include_filtered, types: [], exclude_types: []).merge(core_params) - end -end diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index 63c3f2d90a7..f82c1c50d79 100644 --- a/app/controllers/api/web/embeds_controller.rb +++ b/app/controllers/api/web/embeds_controller.rb @@ -9,7 +9,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController return not_found if @status.hidden? if @status.local? - render json: @status, serializer: OEmbedSerializer, width: 400 + render json: @status, serializer: OEmbedSerializer else return not_found unless user_signed_in? diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 66e0f7e3051..62e3355ae62 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -20,7 +20,6 @@ class ApplicationController < ActionController::Base helper_method :current_theme helper_method :single_user_mode? helper_method :use_seamless_external_login? - helper_method :omniauth_only? helper_method :sso_account_settings helper_method :limited_federation_mode? helper_method :body_class_string @@ -137,10 +136,6 @@ class ApplicationController < ActionController::Base Devise.pam_authentication || Devise.ldap_authentication end - def omniauth_only? - ENV['OMNIAUTH_ONLY'] == 'true' - end - def sso_account_settings ENV.fetch('SSO_ACCOUNT_SETTINGS', nil) end diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index 7ca7be5f8ef..bf5c84ccf45 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -5,7 +5,6 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController layout 'auth' - before_action :set_body_classes before_action :set_confirmation_user!, only: [:show, :confirm_captcha] before_action :redirect_confirmed_user, if: :signed_in_confirmed_user? @@ -73,10 +72,6 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? end - def set_body_classes - @body_classes = 'lighter' - end - def after_resending_confirmation_instructions_path_for(_resource_name) if user_signed_in? if current_user.confirmed? && current_user.approved? diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index de001f062b0..7c1ff59671d 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -3,7 +3,6 @@ class Auth::PasswordsController < Devise::PasswordsController skip_before_action :check_self_destruct! before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid? - before_action :set_body_classes layout 'auth' @@ -24,10 +23,6 @@ class Auth::PasswordsController < Devise::PasswordsController redirect_to new_password_path(resource_name) end - def set_body_classes - @body_classes = 'lighter' - end - def reset_password_token_is_valid? resource_class.with_reset_password_token(params[:reset_password_token]).present? end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index e5a2ac0270f..4d94c801586 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -11,7 +11,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :configure_sign_up_params, only: [:create] before_action :set_sessions, only: [:edit, :update] before_action :set_strikes, only: [:edit, :update] - before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :require_not_suspended!, only: [:update] before_action :set_cache_headers, only: [:edit, :update] before_action :set_rules, only: :new @@ -104,10 +103,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController private - def set_body_classes - @body_classes = %w(edit update).include?(action_name) ? 'admin' : 'lighter' - end - def set_invite @invite = begin invite = Invite.find_by(code: invite_code) if invite_code.present? diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 6ed7b2baacc..ecac4c5ba8e 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -16,17 +16,10 @@ class Auth::SessionsController < Devise::SessionsController include Auth::TwoFactorAuthenticationConcern - before_action :set_body_classes - content_security_policy only: :new do |p| p.form_action(false) end - def check_suspicious! - user = find_user - @login_is_suspicious = suspicious_sign_in?(user) unless user.nil? - end - def create super do |resource| # We only need to call this if this hasn't already been @@ -103,8 +96,9 @@ class Auth::SessionsController < Devise::SessionsController private - def set_body_classes - @body_classes = 'lighter' + def check_suspicious! + user = find_user + @login_is_suspicious = suspicious_sign_in?(user) unless user.nil? end def home_paths(resource) @@ -193,4 +187,15 @@ class Auth::SessionsController < Devise::SessionsController def second_factor_attempts_key(user) "2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}" end + + def respond_to_on_destroy + respond_to do |format| + format.json do + render json: { + redirect_to: after_sign_out_path_for(resource_name), + }, status: 200 + end + format.all { super } + end + end end diff --git a/app/controllers/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb index 40916d28877..ad872dc6072 100644 --- a/app/controllers/auth/setup_controller.rb +++ b/app/controllers/auth/setup_controller.rb @@ -5,7 +5,6 @@ class Auth::SetupController < ApplicationController before_action :authenticate_user! before_action :require_unconfirmed_or_pending! - before_action :set_body_classes before_action :set_user skip_before_action :require_functional! @@ -35,10 +34,6 @@ class Auth::SetupController < ApplicationController @user = current_user end - def set_body_classes - @body_classes = 'lighter' - end - def user_params params.require(:user).permit(:email) end diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index d63bcc85c95..b75f3e35819 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -20,7 +20,7 @@ module AccountControllerConcern webfinger_account_link, actor_url_link, ] - ) + ).to_s end def webfinger_account_link diff --git a/app/controllers/concerns/api/pagination.rb b/app/controllers/concerns/api/pagination.rb index 7f06dc02023..b0b4ae4603f 100644 --- a/app/controllers/concerns/api/pagination.rb +++ b/app/controllers/concerns/api/pagination.rb @@ -19,7 +19,7 @@ module Api::Pagination links = [] links << [next_path, [%w(rel next)]] if next_path links << [prev_path, [%w(rel prev)]] if prev_path - response.headers['Link'] = LinkHeader.new(links) unless links.empty? + response.headers['Link'] = LinkHeader.new(links).to_s unless links.empty? end def require_valid_pagination_options! diff --git a/app/controllers/concerns/auth/two_factor_authentication_concern.rb b/app/controllers/concerns/auth/two_factor_authentication_concern.rb index 404164751a8..0fb11428dca 100644 --- a/app/controllers/concerns/auth/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/auth/two_factor_authentication_concern.rb @@ -83,7 +83,6 @@ module Auth::TwoFactorAuthenticationConcern def prompt_for_two_factor(user) register_attempt_in_session(user) - @body_classes = 'lighter' @webauthn_enabled = user.webauthn_enabled? @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank? 'webauthn' diff --git a/app/controllers/concerns/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb index 09874fb4054..c8d1a0bef7f 100644 --- a/app/controllers/concerns/challengable_concern.rb +++ b/app/controllers/concerns/challengable_concern.rb @@ -42,7 +42,6 @@ module ChallengableConcern end def render_challenge - @body_classes = 'lighter' render 'auth/challenges/new', layout: 'auth' end diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb index b8c909877b6..ebbdba59af0 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -8,6 +8,16 @@ module WebAppControllerConcern before_action :redirect_unauthenticated_to_permalinks! before_action :set_app_body_class + + content_security_policy do |p| + policy = ContentSecurityPolicy.new + + if policy.sso_host.present? + p.form_action policy.sso_host + else + p.form_action :none + end + end end def skip_csrf_meta_tags? @@ -21,7 +31,7 @@ module WebAppControllerConcern def redirect_unauthenticated_to_permalinks! return if user_signed_in? && current_account.moved_to_account_id.nil? - permalink_redirector = PermalinkRedirector.new(request.path) + permalink_redirector = PermalinkRedirector.new(request.original_fullpath) return if permalink_redirector.redirect_path.blank? expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb index 1054f3db805..dd24a1b7408 100644 --- a/app/controllers/disputes/base_controller.rb +++ b/app/controllers/disputes/base_controller.rb @@ -7,16 +7,11 @@ class Disputes::BaseController < ApplicationController skip_before_action :require_functional! - before_action :set_body_classes before_action :authenticate_user! before_action :set_cache_headers private - def set_body_classes - @body_classes = 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/filters/statuses_controller.rb b/app/controllers/filters/statuses_controller.rb index 94993f938b5..7ada13f680d 100644 --- a/app/controllers/filters/statuses_controller.rb +++ b/app/controllers/filters/statuses_controller.rb @@ -6,7 +6,6 @@ class Filters::StatusesController < ApplicationController before_action :authenticate_user! before_action :set_filter before_action :set_status_filters - before_action :set_body_classes before_action :set_cache_headers PER_PAGE = 20 @@ -42,10 +41,6 @@ class Filters::StatusesController < ApplicationController 'remove' if params[:remove] end - def set_body_classes - @body_classes = 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index bd9964426b8..8c4e867e93a 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -5,7 +5,6 @@ class FiltersController < ApplicationController before_action :authenticate_user! before_action :set_filter, only: [:edit, :update, :destroy] - before_action :set_body_classes before_action :set_cache_headers def index @@ -52,10 +51,6 @@ class FiltersController < ApplicationController params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end - def set_body_classes - @body_classes = 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 9bc5164d599..070852695ee 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -6,7 +6,6 @@ class InvitesController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_body_classes before_action :set_cache_headers def index @@ -47,10 +46,6 @@ class InvitesController < ApplicationController params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment) end - def set_body_classes - @body_classes = 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/mail_subscriptions_controller.rb b/app/controllers/mail_subscriptions_controller.rb index 1caeaaacf4c..34df75f63ad 100644 --- a/app/controllers/mail_subscriptions_controller.rb +++ b/app/controllers/mail_subscriptions_controller.rb @@ -5,7 +5,6 @@ class MailSubscriptionsController < ApplicationController skip_before_action :require_functional! - before_action :set_body_classes before_action :set_user before_action :set_type @@ -25,10 +24,6 @@ class MailSubscriptionsController < ApplicationController not_found unless @user end - def set_body_classes - @body_classes = 'lighter' - end - def set_type @type = email_type_from_param end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 53eee40012a..9d10468e693 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -19,9 +19,7 @@ class MediaController < ApplicationController redirect_to @media_attachment.file.url(:original) end - def player - @body_classes = 'player' - end + def player; end private diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 8440df6b7e6..267409a9ce4 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -6,7 +6,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio before_action :store_current_location before_action :authenticate_resource_owner! before_action :require_not_suspended!, only: :destroy - before_action :set_body_classes before_action :set_cache_headers before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json } @@ -17,15 +16,12 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio def destroy Web::PushSubscription.unsubscribe_for(params[:id], current_resource_owner) + Doorkeeper::Application.find_by(id: params[:id])&.close_streaming_sessions(current_resource_owner) super end private - def set_body_classes - @body_classes = 'admin' - end - def store_current_location store_location_for(:user, request.url) end diff --git a/app/controllers/redirect/base_controller.rb b/app/controllers/redirect/base_controller.rb index 90894ec1ed8..34558a41261 100644 --- a/app/controllers/redirect/base_controller.rb +++ b/app/controllers/redirect/base_controller.rb @@ -4,7 +4,6 @@ class Redirect::BaseController < ApplicationController vary_by 'Accept-Language' before_action :set_resource - before_action :set_app_body_class def show @redirect_path = ActivityPub::TagManager.instance.url_for(@resource) @@ -14,10 +13,6 @@ class Redirect::BaseController < ApplicationController private - def set_app_body_class - @body_classes = 'app-body' - end - def set_resource raise NotImplementedError end diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index dd794f3199e..d351afcfb72 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -6,7 +6,6 @@ class RelationshipsController < ApplicationController before_action :authenticate_user! before_action :set_accounts, only: :show before_action :set_relationships, only: :show - before_action :set_body_classes before_action :set_cache_headers helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship? @@ -68,10 +67,6 @@ class RelationshipsController < ApplicationController end end - def set_body_classes - @body_classes = 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index f15140aa2be..188334ac234 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -4,15 +4,10 @@ class Settings::BaseController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_body_classes before_action :set_cache_headers private - def set_body_classes - @body_classes = 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/settings/verifications_controller.rb b/app/controllers/settings/verifications_controller.rb index fc4f23bb186..4e0663253cc 100644 --- a/app/controllers/settings/verifications_controller.rb +++ b/app/controllers/settings/verifications_controller.rb @@ -2,14 +2,30 @@ class Settings::VerificationsController < Settings::BaseController before_action :set_account + before_action :set_verified_links - def show - @verified_links = @account.fields.select(&:verified?) + def show; end + + def update + if UpdateAccountService.new.call(@account, account_params) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end end private + def account_params + params.require(:account).permit(:attribution_domains_as_text) + end + def set_account @account = current_account end + + def set_verified_links + @verified_links = @account.fields.select(&:verified?) + end end diff --git a/app/controllers/severed_relationships_controller.rb b/app/controllers/severed_relationships_controller.rb index 168e85e3fe4..965753a26f8 100644 --- a/app/controllers/severed_relationships_controller.rb +++ b/app/controllers/severed_relationships_controller.rb @@ -4,7 +4,6 @@ class SeveredRelationshipsController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_body_classes before_action :set_cache_headers before_action :set_event, only: [:following, :followers] @@ -51,10 +50,6 @@ class SeveredRelationshipsController < ApplicationController account.local? ? account.local_username_and_domain : account.acct end - def set_body_classes - @body_classes = 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index 6546b849780..1aa0ce5a0d4 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -4,13 +4,6 @@ class SharesController < ApplicationController layout 'modal' before_action :authenticate_user! - before_action :set_body_classes def show; end - - private - - def set_body_classes - @body_classes = 'modal-layout compose-standalone' - end end diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb index 4a3fc10ca4f..e517bf3ae88 100644 --- a/app/controllers/statuses_cleanup_controller.rb +++ b/app/controllers/statuses_cleanup_controller.rb @@ -5,7 +5,6 @@ class StatusesCleanupController < ApplicationController before_action :authenticate_user! before_action :set_policy - before_action :set_body_classes before_action :set_cache_headers def show; end @@ -34,10 +33,6 @@ class StatusesCleanupController < ApplicationController params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs) end - def set_body_classes - @body_classes = 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index db7eddd78b3..341b0e64729 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -11,7 +11,6 @@ class StatusesController < ApplicationController before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_status before_action :redirect_to_original, only: :show - before_action :set_body_classes, only: :embed after_action :set_link_headers @@ -51,12 +50,10 @@ class StatusesController < ApplicationController private - def set_body_classes - @body_classes = 'with-modals' - end - def set_link_headers - response.headers['Link'] = LinkHeader.new([[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]) + response.headers['Link'] = LinkHeader.new( + [[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]] + ).to_s end def set_status diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 158a0815e12..d804566c935 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -19,14 +19,6 @@ module AccountsHelper end end - def account_action_button(account) - return if account.memorial? || account.moved? - - link_to ActivityPub::TagManager.instance.url_for(account), class: 'button logo-button', target: '_new' do - safe_join([logo_as_symbol, t('accounts.follow')]) - end - end - def account_formatted_stat(value) number_to_human(value, precision: 3, strip_insignificant_zeros: true) end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 140fc73ede4..40806a45158 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -25,7 +25,7 @@ module Admin::FilterHelper end def table_link_to(icon, text, path, **options) - link_to safe_join([fa_icon(icon), text]), path, options.merge(class: 'table-action-link') + link_to safe_join([material_symbol(icon), text]), path, options.merge(class: 'table-action-link') end def selected?(more_params) diff --git a/app/helpers/admin/tags_helper.rb b/app/helpers/admin/tags_helper.rb new file mode 100644 index 00000000000..eb928a6db2b --- /dev/null +++ b/app/helpers/admin/tags_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Admin::TagsHelper + def admin_tags_moderation_options + [ + [t('admin.tags.moderation.reviewed'), 'reviewed'], + [t('admin.tags.moderation.review_requested'), 'review_requested'], + [t('admin.tags.moderation.unreviewed'), 'unreviewed'], + [t('admin.tags.moderation.trendable'), 'trendable'], + [t('admin.tags.moderation.not_trendable'), 'not_trendable'], + [t('admin.tags.moderation.usable'), 'usable'], + [t('admin.tags.moderation.not_usable'), 'not_usable'], + ] + end +end diff --git a/app/helpers/admin/trends/statuses_helper.rb b/app/helpers/admin/trends/statuses_helper.rb index 79fee44dc4b..c7a59660cf7 100644 --- a/app/helpers/admin/trends/statuses_helper.rb +++ b/app/helpers/admin/trends/statuses_helper.rb @@ -5,7 +5,7 @@ module Admin::Trends::StatusesHelper text = if status.local? status.text.split("\n").first else - Nokogiri::HTML(status.text).css('html > body > *').first&.text + Nokogiri::HTML5(status.text).css('html > body > *').first&.text end return '' if text.blank? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7e9cfee3f68..de00f76d362 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -86,7 +86,7 @@ module ApplicationHelper def html_title safe_join( [content_for(:page_title).to_s.chomp, title] - .select(&:present?), + .compact_blank, ' - ' ) end @@ -105,19 +105,17 @@ module ApplicationHelper policy(record).public_send(:"#{action}?") end - def fa_icon(icon, attributes = {}) - class_names = attributes[:class]&.split || [] - class_names << 'fa' - class_names += icon.split.map { |cl| "fa-#{cl}" } - - content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) - end - def material_symbol(icon, attributes = {}) - inline_svg_tag( - "400-24px/#{icon}.svg", - class: %w(icon).concat(attributes[:class].to_s.split), - role: :img + safe_join( + [ + inline_svg_tag( + "400-24px/#{icon}.svg", + class: ['icon', "material-#{icon}"].concat(attributes[:class].to_s.split), + role: :img, + data: attributes[:data] + ), + ' ', + ] ) end @@ -127,23 +125,23 @@ module ApplicationHelper def visibility_icon(status) if status.public_visibility? - fa_icon('globe', title: I18n.t('statuses.visibilities.public')) + material_symbol('globe', title: I18n.t('statuses.visibilities.public')) elsif status.unlisted_visibility? - fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted')) + material_symbol('lock_open', title: I18n.t('statuses.visibilities.unlisted')) elsif status.private_visibility? || status.limited_visibility? - fa_icon('lock', title: I18n.t('statuses.visibilities.private')) + material_symbol('lock', title: I18n.t('statuses.visibilities.private')) elsif status.direct_visibility? - fa_icon('at', title: I18n.t('statuses.visibilities.direct')) + material_symbol('alternate_email', title: I18n.t('statuses.visibilities.direct')) end end def interrelationships_icon(relationships, account_id) if relationships.following[account_id] && relationships.followed_by[account_id] - fa_icon('exchange', title: I18n.t('relationships.mutual'), class: 'fa-fw active passive') + material_symbol('sync_alt', title: I18n.t('relationships.mutual'), class: 'active passive') elsif relationships.following[account_id] - fa_icon(locale_direction == 'ltr' ? 'arrow-right' : 'arrow-left', title: I18n.t('relationships.following'), class: 'fa-fw active') + material_symbol(locale_direction == 'ltr' ? 'arrow_right_alt' : 'arrow_left_alt', title: I18n.t('relationships.following'), class: 'active') elsif relationships.followed_by[account_id] - fa_icon(locale_direction == 'ltr' ? 'arrow-left' : 'arrow-right', title: I18n.t('relationships.followers'), class: 'fa-fw passive') + material_symbol(locale_direction == 'ltr' ? 'arrow_left_alt' : 'arrow_right_alt', title: I18n.t('relationships.followers'), class: 'passive') end end @@ -161,6 +159,7 @@ module ApplicationHelper def body_classes output = body_class_string.split + output << content_for(:body_classes) output << "theme-#{current_theme.parameterize}" output << 'system-font' if current_account&.user&.setting_system_font_ui output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion') @@ -244,22 +243,6 @@ module ApplicationHelper full_asset_url(instance_presenter.mascot&.file&.url || frontend_asset_path('images/elephant_ui_plane.svg')) end - def instance_presenter - @instance_presenter ||= InstancePresenter.new - end - - def favicon_path(size = '48') - instance_presenter.favicon&.file&.url(size) - end - - def app_icon_path(size = '48') - instance_presenter.app_icon&.file&.url(size) - end - - def use_mask_icon? - instance_presenter.app_icon.blank? - end - private def storage_host_var diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index cbefe0fe538..18bb088b48a 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -23,24 +23,8 @@ module ContextHelper indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' }, memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, - olm: { - 'toot' => 'http://joinmastodon.org/ns#', - 'Device' => 'toot:Device', - 'Ed25519Signature' => 'toot:Ed25519Signature', - 'Ed25519Key' => 'toot:Ed25519Key', - 'Curve25519Key' => 'toot:Curve25519Key', - 'EncryptedMessage' => 'toot:EncryptedMessage', - 'publicKeyBase64' => 'toot:publicKeyBase64', - 'deviceId' => 'toot:deviceId', - 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, - 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, - 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, - 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, - 'messageFranking' => 'toot:messageFranking', - 'messageType' => 'toot:messageType', - 'cipherText' => 'toot:cipherText', - }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, + attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, }.freeze def full_context diff --git a/app/helpers/instance_helper.rb b/app/helpers/instance_helper.rb index 893afdd51ff..018c69e6207 100644 --- a/app/helpers/instance_helper.rb +++ b/app/helpers/instance_helper.rb @@ -13,6 +13,22 @@ module InstanceHelper safe_join([description_prefix(invite), I18n.t('auth.description.suffix')], ' ') end + def instance_presenter + @instance_presenter ||= InstancePresenter.new + end + + def favicon_path(size = '48') + instance_presenter.favicon&.file&.url(size) + end + + def app_icon_path(size = '48') + instance_presenter.app_icon&.file&.url(size) + end + + def use_mask_icon? + instance_presenter.app_icon.blank? + end + private def description_prefix(invite) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 932a3420db9..ba096427cfc 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -200,14 +200,6 @@ module JsonLdHelper nil end - def merge_context(context, new_context) - if context.is_a?(Array) - context << new_context - else - [context, new_context] - end - end - def response_successful?(response) (200...300).cover?(response.code) end diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 9e1c0a7db1d..b6c09b73147 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -238,9 +238,7 @@ module LanguagesHelper # Helper for self.sorted_locale_keys private_class_method def self.locale_name_for_sorting(locale) - if locale.blank? || locale == 'und' - '000' - elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym]) + if (supported_locale = SUPPORTED_LOCALES[locale.to_sym]) ASCIIFolding.new.fold(supported_locale[1]).downcase elsif (regional_locale = REGIONAL_LOCALE_NAMES[locale.to_sym]) ASCIIFolding.new.fold(regional_locale).downcase diff --git a/app/helpers/media_component_helper.rb b/app/helpers/media_component_helper.rb index fa8f34fb4d3..60ccdd08359 100644 --- a/app/helpers/media_component_helper.rb +++ b/app/helpers/media_component_helper.rb @@ -57,26 +57,6 @@ module MediaComponentHelper end end - def render_card_component(status, **options) - component_params = { - sensitive: sensitive_viewer?(status, current_account), - card: serialize_status_card(status).as_json, - }.merge(**options) - - react_component :card, component_params - end - - def render_poll_component(status, **options) - component_params = { - disabled: true, - poll: serialize_status_poll(status).as_json, - }.merge(**options) - - react_component :poll, component_params do - render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: prefers_autoplay? } - end - end - private def serialize_media_attachment(attachment) @@ -86,22 +66,6 @@ module MediaComponentHelper ) end - def serialize_status_card(status) - ActiveModelSerializers::SerializableResource.new( - status.preview_card, - serializer: REST::PreviewCardSerializer - ) - end - - def serialize_status_poll(status) - ActiveModelSerializers::SerializableResource.new( - status.preloadable_poll, - serializer: REST::PollSerializer, - scope: current_user, - scope_name: :current_user - ) - end - def sensitive_viewer?(status, account) if !account.nil? && account.id == status.account_id status.sensitive diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 10863a316c9..64f2ad70a66 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -26,11 +26,11 @@ module SettingsHelper device = session.detection.device if device.mobile? - 'mobile' + 'smartphone' elsif device.tablet? 'tablet' else - 'desktop' + 'desktop_mac' end end diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index ca693a8a78a..bba6d64a478 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -4,6 +4,13 @@ module StatusesHelper EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' + VISIBLITY_ICONS = { + public: 'globe', + unlisted: 'lock_open', + private: 'lock', + direct: 'alternate_email', + }.freeze + def nothing_here(extra_classes = '') content_tag(:div, class: "nothing-here #{extra_classes}") do t('accounts.nothing_here') @@ -57,17 +64,8 @@ module StatusesHelper embedded_view? ? '_blank' : nil end - def fa_visibility_icon(status) - case status.visibility - when 'public' - fa_icon 'globe fw' - when 'unlisted' - fa_icon 'unlock fw' - when 'private' - fa_icon 'lock fw' - when 'direct' - fa_icon 'at fw' - end + def visibility_icon(status) + VISIBLITY_ICONS[status.visibility.to_sym] end def embedded_view? diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb index d15259851c0..fab899a5332 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -3,8 +3,10 @@ module ThemeHelper def theme_style_tags(theme) if theme == 'system' - stylesheet_pack_tag('mastodon-light', media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') + - stylesheet_pack_tag('default', media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') + ''.html_safe.tap do |tags| + tags << stylesheet_pack_tag('mastodon-light', media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') + tags << stylesheet_pack_tag('default', media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') + end else stylesheet_pack_tag theme, media: 'all', crossorigin: 'anonymous' end @@ -12,8 +14,10 @@ module ThemeHelper def theme_color_tags(theme) if theme == 'system' - tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)') + - tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)') + ''.html_safe.tap do |tags| + tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)') + tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)') + end else tag.meta name: 'theme-color', content: theme_color_for(theme) end diff --git a/app/javascript/entrypoints/embed.tsx b/app/javascript/entrypoints/embed.tsx new file mode 100644 index 00000000000..f8c824d287a --- /dev/null +++ b/app/javascript/entrypoints/embed.tsx @@ -0,0 +1,74 @@ +import './public-path'; +import { createRoot } from 'react-dom/client'; + +import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal'; + +import { start } from '../mastodon/common'; +import { Status } from '../mastodon/features/standalone/status'; +import { loadPolyfills } from '../mastodon/polyfills'; +import ready from '../mastodon/ready'; + +start(); + +function loaded() { + const mountNode = document.getElementById('mastodon-status'); + + if (mountNode) { + const attr = mountNode.getAttribute('data-props'); + + if (!attr) return; + + const props = JSON.parse(attr) as { id: string; locale: string }; + const root = createRoot(mountNode); + + root.render(); + } +} + +function main() { + ready(loaded).catch((error: unknown) => { + console.error(error); + }); +} + +loadPolyfills() + .then(main) + .catch((error: unknown) => { + console.error(error); + }); + +interface SetHeightMessage { + type: 'setHeight'; + id: string; + height: number; +} + +function isSetHeightMessage(data: unknown): data is SetHeightMessage { + if ( + data && + typeof data === 'object' && + 'type' in data && + data.type === 'setHeight' + ) + return true; + else return false; +} + +window.addEventListener('message', (e) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases + if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; + + const data = e.data; + + // We use a timeout to allow for the React page to render before calculating the height + afterInitialRender(() => { + window.parent.postMessage( + { + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0]?.scrollHeight, + }, + '*', + ); + }); +}); diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 40a9b7c0ca6..d33e00d5da8 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -37,43 +37,6 @@ const messages = defineMessages({ }, }); -interface SetHeightMessage { - type: 'setHeight'; - id: string; - height: number; -} - -function isSetHeightMessage(data: unknown): data is SetHeightMessage { - if ( - data && - typeof data === 'object' && - 'type' in data && - data.type === 'setHeight' - ) - return true; - else return false; -} - -window.addEventListener('message', (e) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases - if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; - - const data = e.data; - - ready(() => { - window.parent.postMessage( - { - type: 'setHeight', - id: data.id, - height: document.getElementsByTagName('html')[0]?.scrollHeight, - }, - '*', - ); - }).catch((e: unknown) => { - console.error('Error in setHeightMessage postMessage', e); - }); -}); - function loaded() { const { messages: localeData } = getLocale(); @@ -316,8 +279,8 @@ function loaded() { const message = statusEl.dataset.spoiler === 'expanded' - ? localeData['status.show_less'] ?? 'Show less' - : localeData['status.show_more'] ?? 'Show more'; + ? (localeData['status.show_less'] ?? 'Show less') + : (localeData['status.show_more'] ?? 'Show more'); spoilerLink.textContent = new IntlMessageFormat( message, locale, @@ -431,6 +394,42 @@ Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => { target.src = target.dataset.static; }); +const setInputDisabled = ( + input: HTMLInputElement | HTMLSelectElement, + disabled: boolean, +) => { + input.disabled = disabled; + + const wrapper = input.closest('.with_label'); + if (wrapper) { + wrapper.classList.toggle('disabled', input.disabled); + + const hidden = + input.type === 'checkbox' && + wrapper.querySelector('input[type=hidden][value="0"]'); + if (hidden) { + hidden.disabled = input.disabled; + } + } +}; + +Rails.delegate( + document, + '#account_statuses_cleanup_policy_enabled', + 'change', + ({ target }) => { + if (!(target instanceof HTMLInputElement) || !target.form) return; + + target.form + .querySelectorAll< + HTMLInputElement | HTMLSelectElement + >('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select') + .forEach((input) => { + setInputDisabled(input, !target.checked); + }); + }, +); + // Empty the honeypot fields in JS in case something like an extension // automatically filled them. Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { diff --git a/app/javascript/hooks/useLinks.ts b/app/javascript/hooks/useLinks.ts new file mode 100644 index 00000000000..f08b9500da1 --- /dev/null +++ b/app/javascript/hooks/useLinks.ts @@ -0,0 +1,61 @@ +import { useCallback } from 'react'; + +import { useHistory } from 'react-router-dom'; + +import { openURL } from 'mastodon/actions/search'; +import { useAppDispatch } from 'mastodon/store'; + +const isMentionClick = (element: HTMLAnchorElement) => + element.classList.contains('mention'); + +const isHashtagClick = (element: HTMLAnchorElement) => + element.textContent?.[0] === '#' || + element.previousSibling?.textContent?.endsWith('#'); + +export const useLinks = () => { + const history = useHistory(); + const dispatch = useAppDispatch(); + + const handleHashtagClick = useCallback( + (element: HTMLAnchorElement) => { + const { textContent } = element; + + if (!textContent) return; + + history.push(`/tags/${textContent.replace(/^#/, '')}`); + }, + [history], + ); + + const handleMentionClick = useCallback( + (element: HTMLAnchorElement) => { + dispatch( + openURL(element.href, history, () => { + window.location.href = element.href; + }), + ); + }, + [dispatch, history], + ); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + const target = (e.target as HTMLElement).closest('a'); + + if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) { + return; + } + + if (isMentionClick(target)) { + e.preventDefault(); + handleMentionClick(target); + } else if (isHashtagClick(target)) { + e.preventDefault(); + handleHashtagClick(target); + } + }, + [handleMentionClick, handleHashtagClick], + ); + + return handleClick; +}; diff --git a/app/javascript/hooks/useRenderSignal.ts b/app/javascript/hooks/useRenderSignal.ts new file mode 100644 index 00000000000..740df4a35a3 --- /dev/null +++ b/app/javascript/hooks/useRenderSignal.ts @@ -0,0 +1,32 @@ +// This hook allows a component to signal that it's done rendering in a way that +// can be used by e.g. our embed code to determine correct iframe height + +let renderSignalReceived = false; + +type Callback = () => void; + +let onInitialRender: Callback; + +export const afterInitialRender = (callback: Callback) => { + if (renderSignalReceived) { + callback(); + } else { + onInitialRender = callback; + } +}; + +export const useRenderSignal = () => { + return () => { + if (renderSignalReceived) { + return; + } + + renderSignalReceived = true; + + if (typeof onInitialRender !== 'undefined') { + window.requestAnimationFrame(() => { + onInitialRender(); + }); + } + }; +}; diff --git a/app/javascript/hooks/useSearchParam.ts b/app/javascript/hooks/useSearchParam.ts new file mode 100644 index 00000000000..2df8c0b3a9e --- /dev/null +++ b/app/javascript/hooks/useSearchParam.ts @@ -0,0 +1,31 @@ +import { useMemo, useCallback } from 'react'; + +import { useLocation, useHistory } from 'react-router'; + +export function useSearchParams() { + const { search } = useLocation(); + + return useMemo(() => new URLSearchParams(search), [search]); +} + +export function useSearchParam(name: string, defaultValue?: string) { + const searchParams = useSearchParams(); + const history = useHistory(); + + const value = searchParams.get(name) ?? defaultValue; + + const setValue = useCallback( + (value: string | null) => { + if (value === null) { + searchParams.delete(name); + } else { + searchParams.set(name, value); + } + + history.push({ search: searchParams.toString() }); + }, + [history, name, searchParams], + ); + + return [value, setValue] as const; +} diff --git a/app/javascript/hooks/useTimeout.ts b/app/javascript/hooks/useTimeout.ts new file mode 100644 index 00000000000..bb1e8848dd5 --- /dev/null +++ b/app/javascript/hooks/useTimeout.ts @@ -0,0 +1,44 @@ +import { useRef, useCallback, useEffect } from 'react'; + +export const useTimeout = () => { + const timeoutRef = useRef>(); + const callbackRef = useRef<() => void>(); + + const set = useCallback((callback: () => void, delay: number) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + callbackRef.current = callback; + timeoutRef.current = setTimeout(callback, delay); + }, []); + + const delay = useCallback((delay: number) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + if (!callbackRef.current) { + return; + } + + timeoutRef.current = setTimeout(callbackRef.current, delay); + }, []); + + const cancel = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = undefined; + callbackRef.current = undefined; + } + }, []); + + useEffect( + () => () => { + cancel(); + }, + [cancel], + ); + + return [set, cancel, delay] as const; +}; diff --git a/app/javascript/images/filter-stripes.svg b/app/javascript/images/filter-stripes.svg new file mode 100755 index 00000000000..4c1b58cb744 --- /dev/null +++ b/app/javascript/images/filter-stripes.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/mastodon/actions/account_notes.ts b/app/javascript/mastodon/actions/account_notes.ts index c2ebaf54a43..9e7d199dc9a 100644 --- a/app/javascript/mastodon/actions/account_notes.ts +++ b/app/javascript/mastodon/actions/account_notes.ts @@ -6,5 +6,4 @@ export const submitAccountNote = createDataLoadingThunk( ({ accountId, note }: { accountId: string; note: string }) => apiSubmitAccountNote(accountId, note), (relationship) => ({ relationship }), - { skipLoading: true }, ); diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index cea915e5f17..3d0e8b8c905 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -1,3 +1,6 @@ +import { browserHistory } from 'mastodon/components/router'; +import { debounceWithDispatchAndArguments } from 'mastodon/utils/debounce'; + import api, { getLinks } from '../api'; import { @@ -447,6 +450,20 @@ export function expandFollowingFail(id, error) { }; } +const debouncedFetchRelationships = debounceWithDispatchAndArguments((dispatch, ...newAccountIds) => { + if (newAccountIds.length === 0) { + return; + } + + dispatch(fetchRelationshipsRequest(newAccountIds)); + + api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + dispatch(fetchRelationshipsSuccess({ relationships: response.data })); + }).catch(error => { + dispatch(fetchRelationshipsFail(error)); + }); +}, { delay: 500 }); + export function fetchRelationships(accountIds) { return (dispatch, getState) => { const state = getState(); @@ -458,13 +475,7 @@ export function fetchRelationships(accountIds) { return; } - dispatch(fetchRelationshipsRequest(newAccountIds)); - - api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { - dispatch(fetchRelationshipsSuccess({ relationships: response.data })); - }).catch(error => { - dispatch(fetchRelationshipsFail(error)); - }); + debouncedFetchRelationships(dispatch, ...newAccountIds); }; } @@ -676,3 +687,13 @@ export const updateAccount = ({ displayName, note, avatar, header, discoverable, dispatch(importFetchedAccount(response.data)); }); }; + +export const navigateToProfile = (accountId) => { + return (_dispatch, getState) => { + const acct = getState().accounts.getIn([accountId, 'acct']); + + if (acct) { + browserHistory.push(`/@${acct}`); + } + }; +}; diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js index 42834146bf5..48dee2587fe 100644 --- a/app/javascript/mastodon/actions/alerts.js +++ b/app/javascript/mastodon/actions/alerts.js @@ -1,5 +1,7 @@ import { defineMessages } from 'react-intl'; +import { AxiosError } from 'axios'; + const messages = defineMessages({ unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, @@ -50,6 +52,11 @@ export const showAlertForError = (error, skipNotFound = false) => { }); } + // An aborted request, e.g. due to reloading the browser window, it not really error + if (error.code === AxiosError.ECONNABORTED) { + return { type: ALERT_NOOP }; + } + console.error(error); return showAlert({ diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index b07dff182a8..aa1c6de20eb 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -4,6 +4,7 @@ import axios from 'axios'; import { throttle } from 'lodash'; import api from 'mastodon/api'; +import { browserHistory } from 'mastodon/components/router'; import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light'; import { tagHistory } from 'mastodon/settings'; @@ -88,9 +89,9 @@ const messages = defineMessages({ saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, }); -export const ensureComposeIsVisible = (getState, routerHistory) => { +export const ensureComposeIsVisible = (getState) => { if (!getState().getIn(['compose', 'mounted'])) { - routerHistory.push('/publish'); + browserHistory.push('/publish'); } }; @@ -110,14 +111,26 @@ export function changeCompose(text) { }; } -export function replyCompose(status, routerHistory) { +export function replyCompose(status) { return (dispatch, getState) => { dispatch({ type: COMPOSE_REPLY, status: status, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); + }; +} + +export function replyComposeById(statusId) { + return (dispatch, getState) => { + const state = getState(); + const status = state.statuses.get(statusId); + + if (status) { + const account = state.accounts.get(status.get('account')); + dispatch(replyCompose(status.set('account', account))); + } }; } @@ -133,38 +146,44 @@ export function resetCompose() { }; } -export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => { +export const focusCompose = (defaultText) => (dispatch, getState) => { dispatch({ type: COMPOSE_FOCUS, defaultText, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); }; -export function mentionCompose(account, routerHistory) { +export function mentionCompose(account) { return (dispatch, getState) => { dispatch({ type: COMPOSE_MENTION, account: account, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); }; } -export function directCompose(account, routerHistory) { +export function mentionComposeById(accountId) { + return (dispatch, getState) => { + dispatch(mentionCompose(getState().accounts.get(accountId))); + }; +} + +export function directCompose(account) { return (dispatch, getState) => { dispatch({ type: COMPOSE_DIRECT, account: account, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); }; } -export function submitCompose(routerHistory) { +export function submitCompose() { return function (dispatch, getState) { const status = getState().getIn(['compose', 'text'], ''); const media = getState().getIn(['compose', 'media_attachments']); @@ -214,8 +233,8 @@ export function submitCompose(routerHistory) { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, }).then(function (response) { - if (routerHistory && (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new') && window.history.state) { - routerHistory.goBack(); + if ((browserHistory.location.pathname === '/publish' || browserHistory.location.pathname === '/statuses/new') && window.history.state) { + browserHistory.goBack(); } dispatch(insertIntoTagHistory(response.data.tags, status)); @@ -249,7 +268,7 @@ export function submitCompose(routerHistory) { message: statusId === null ? messages.published : messages.saved, action: messages.open, dismissAfter: 10000, - onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), + onClick: () => browserHistory.push(`/@${response.data.account.username}/${response.data.id}`), })); }).catch(function (error) { dispatch(submitComposeFail(error)); @@ -279,7 +298,7 @@ export function submitComposeFail(error) { export function uploadCompose(files) { return function (dispatch, getState) { - const uploadLimit = 4; + const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']); const media = getState().getIn(['compose', 'media_attachments']); const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); @@ -299,7 +318,7 @@ export function uploadCompose(files) { dispatch(uploadComposeRequest()); for (const [i, file] of Array.from(files).entries()) { - if (media.size + i > 3) break; + if (media.size + i > (uploadLimit - 1)) break; const data = new FormData(); data.append('file', file); diff --git a/app/javascript/mastodon/actions/directory.js b/app/javascript/mastodon/actions/directory.js deleted file mode 100644 index 7a0748029d9..00000000000 --- a/app/javascript/mastodon/actions/directory.js +++ /dev/null @@ -1,62 +0,0 @@ -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; - -export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; -export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; -export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; - -export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; -export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; -export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; - -export const fetchDirectory = params => (dispatch) => { - dispatch(fetchDirectoryRequest()); - - api().get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchDirectorySuccess(data)); - dispatch(fetchRelationships(data.map(x => x.id))); - }).catch(error => dispatch(fetchDirectoryFail(error))); -}; - -export const fetchDirectoryRequest = () => ({ - type: DIRECTORY_FETCH_REQUEST, -}); - -export const fetchDirectorySuccess = accounts => ({ - type: DIRECTORY_FETCH_SUCCESS, - accounts, -}); - -export const fetchDirectoryFail = error => ({ - type: DIRECTORY_FETCH_FAIL, - error, -}); - -export const expandDirectory = params => (dispatch, getState) => { - dispatch(expandDirectoryRequest()); - - const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; - - api().get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(expandDirectorySuccess(data)); - dispatch(fetchRelationships(data.map(x => x.id))); - }).catch(error => dispatch(expandDirectoryFail(error))); -}; - -export const expandDirectoryRequest = () => ({ - type: DIRECTORY_EXPAND_REQUEST, -}); - -export const expandDirectorySuccess = accounts => ({ - type: DIRECTORY_EXPAND_SUCCESS, - accounts, -}); - -export const expandDirectoryFail = error => ({ - type: DIRECTORY_EXPAND_FAIL, - error, -}); diff --git a/app/javascript/mastodon/actions/directory.ts b/app/javascript/mastodon/actions/directory.ts new file mode 100644 index 00000000000..34ac309c66c --- /dev/null +++ b/app/javascript/mastodon/actions/directory.ts @@ -0,0 +1,37 @@ +import type { List as ImmutableList } from 'immutable'; + +import { apiGetDirectory } from 'mastodon/api/directory'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +export const fetchDirectory = createDataLoadingThunk( + 'directory/fetch', + async (params: Parameters[0]) => + apiGetDirectory(params), + (data, { dispatch }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((x) => x.id))); + + return { accounts: data }; + }, +); + +export const expandDirectory = createDataLoadingThunk( + 'directory/expand', + async (params: Parameters[0], { getState }) => { + const loadedItems = getState().user_lists.getIn([ + 'directory', + 'items', + ]) as ImmutableList; + + return apiGetDirectory({ ...params, offset: loadedItems.size }, 20); + }, + (data, { dispatch }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((x) => x.id))); + + return { accounts: data }; + }, +); diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index d906bdfb14c..516a7a79733 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -76,8 +76,8 @@ export function importFetchedStatuses(statuses) { pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); } - if (status.card?.author_account) { - pushUnique(accounts, status.card.author_account); + if (status.card) { + status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account)); } } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index be76b0f3916..c09a3f442c7 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -36,8 +36,15 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.poll = status.poll.id; } - if (status.card?.author_account) { - normalStatus.card = { ...status.card, author_account: status.card.author_account.id }; + if (status.card) { + normalStatus.card = { + ...status.card, + authors: status.card.authors.map(author => ({ + ...author, + accountId: author.account?.id, + account: undefined, + })), + }; } if (status.filtered) { diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 57f2459c016..d3538a8850a 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -1,7 +1,11 @@ +import { boostModal } from 'mastodon/initial_state'; + import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; +import { unreblog, reblog } from './interactions_typed'; +import { openModal } from './modal'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -432,3 +436,49 @@ export function unpinFail(status, error) { skipLoading: true, }; } + +function toggleReblogWithoutConfirmation(status, visibility) { + return (dispatch) => { + if (status.get('reblogged')) { + dispatch(unreblog({ statusId: status.get('id') })); + } else { + dispatch(reblog({ statusId: status.get('id'), visibility })); + } + }; +} + +export function toggleReblog(statusId, skipModal = false) { + return (dispatch, getState) => { + const state = getState(); + let status = state.statuses.get(statusId); + + if (!status) + return; + + // The reblog modal expects a pre-filled account in status + // TODO: fix this by having the reblog modal get a statusId and do the work itself + status = status.set('account', state.accounts.get(status.get('account'))); + + if (boostModal && !skipModal) { + dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: (status, privacy) => dispatch(toggleReblogWithoutConfirmation(status, privacy)) } })); + } else { + dispatch(toggleReblogWithoutConfirmation(status)); + } + }; +} + +export function toggleFavourite(statusId) { + return (dispatch, getState) => { + const state = getState(); + const status = state.statuses.get(statusId); + + if (!status) + return; + + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }; +} diff --git a/app/javascript/mastodon/actions/languages.js b/app/javascript/mastodon/actions/languages.js deleted file mode 100644 index ad186ba0ccd..00000000000 --- a/app/javascript/mastodon/actions/languages.js +++ /dev/null @@ -1,12 +0,0 @@ -import { saveSettings } from './settings'; - -export const LANGUAGE_USE = 'LANGUAGE_USE'; - -export const useLanguage = language => dispatch => { - dispatch({ - type: LANGUAGE_USE, - language, - }); - - dispatch(saveSettings()); -}; diff --git a/app/javascript/mastodon/actions/markers.ts b/app/javascript/mastodon/actions/markers.ts index 03f577c540f..0b3280c2128 100644 --- a/app/javascript/mastodon/actions/markers.ts +++ b/app/javascript/mastodon/actions/markers.ts @@ -64,7 +64,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk( client.setRequestHeader('Content-Type', 'application/json'); client.setRequestHeader('Authorization', `Bearer ${accessToken}`); client.send(JSON.stringify(params)); - } catch (e) { + } catch { // Do not make the BeforeUnload handler error out } }, @@ -75,9 +75,7 @@ interface MarkerParam { } function getLastNotificationId(state: RootState): string | undefined { - // @ts-expect-error state.notifications is not yet typed - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call - return state.getIn(['notifications', 'lastReadId']); + return state.notificationGroups.lastReadId; } const buildPostMarkersParams = (state: RootState) => { diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts new file mode 100644 index 00000000000..b40b04f8ccf --- /dev/null +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -0,0 +1,239 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { + apiClearNotifications, + apiFetchNotificationGroups, +} from 'mastodon/api/notifications'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import type { + ApiNotificationGroupJSON, + ApiNotificationJSON, +} from 'mastodon/api_types/notifications'; +import { allNotificationTypes } from 'mastodon/api_types/notifications'; +import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; +import { usePendingItems } from 'mastodon/initial_state'; +import type { NotificationGap } from 'mastodon/reducers/notification_groups'; +import { + selectSettingsNotificationsExcludedTypes, + selectSettingsNotificationsQuickFilterActive, + selectSettingsNotificationsShows, +} from 'mastodon/selectors/settings'; +import type { AppDispatch, RootState } from 'mastodon/store'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from 'mastodon/store/typed_functions'; + +import { importFetchedAccounts, importFetchedStatuses } from './importer'; +import { NOTIFICATIONS_FILTER_SET } from './notifications'; +import { saveSettings } from './settings'; + +function excludeAllTypesExcept(filter: string) { + return allNotificationTypes.filter((item) => item !== filter); +} + +function getExcludedTypes(state: RootState) { + const activeFilter = selectSettingsNotificationsQuickFilterActive(state); + + return activeFilter === 'all' + ? selectSettingsNotificationsExcludedTypes(state) + : excludeAllTypesExcept(activeFilter); +} + +function dispatchAssociatedRecords( + dispatch: AppDispatch, + notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[], +) { + const fetchedAccounts: ApiAccountJSON[] = []; + const fetchedStatuses: ApiStatusJSON[] = []; + + notifications.forEach((notification) => { + if (notification.type === 'admin.report') { + fetchedAccounts.push(notification.report.target_account); + } + + if (notification.type === 'moderation_warning') { + fetchedAccounts.push(notification.moderation_warning.target_account); + } + + if ('status' in notification && notification.status) { + fetchedStatuses.push(notification.status); + } + }); + + if (fetchedAccounts.length > 0) + dispatch(importFetchedAccounts(fetchedAccounts)); + + if (fetchedStatuses.length > 0) + dispatch(importFetchedStatuses(fetchedStatuses)); +} + +const supportedGroupedNotificationTypes = ['favourite', 'reblog']; + +export const fetchNotifications = createDataLoadingThunk( + 'notificationGroups/fetch', + async (_params, { getState }) => + apiFetchNotificationGroups({ + grouped_types: supportedGroupedNotificationTypes, + exclude_types: getExcludedTypes(getState()), + }), + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + const payload: (ApiNotificationGroupJSON | NotificationGap)[] = + notifications; + + // TODO: might be worth not using gaps for that… + // if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri }); + if (notifications.length > 1) + payload.push({ type: 'gap', maxId: notifications.at(-1)?.page_min_id }); + + return payload; + // dispatch(submitMarkers()); + }, +); + +export const fetchNotificationsGap = createDataLoadingThunk( + 'notificationGroups/fetchGap', + async (params: { gap: NotificationGap }, { getState }) => + apiFetchNotificationGroups({ + grouped_types: supportedGroupedNotificationTypes, + max_id: params.gap.maxId, + exclude_types: getExcludedTypes(getState()), + }), + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications }; + }, +); + +export const pollRecentNotifications = createDataLoadingThunk( + 'notificationGroups/pollRecentNotifications', + async (_params, { getState }) => { + return apiFetchNotificationGroups({ + grouped_types: supportedGroupedNotificationTypes, + max_id: undefined, + exclude_types: getExcludedTypes(getState()), + // In slow mode, we don't want to include notifications that duplicate the already-displayed ones + since_id: usePendingItems + ? getState().notificationGroups.groups.find( + (group) => group.type !== 'gap', + )?.page_max_id + : undefined, + }); + }, + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications }; + }, +); + +export const processNewNotificationForGroups = createAppAsyncThunk( + 'notificationGroups/processNew', + (notification: ApiNotificationJSON, { dispatch, getState }) => { + const state = getState(); + const activeFilter = selectSettingsNotificationsQuickFilterActive(state); + const notificationShows = selectSettingsNotificationsShows(state); + + const showInColumn = + activeFilter === 'all' + ? notificationShows[notification.type] + : activeFilter === notification.type; + + if (!showInColumn) return; + + if ( + (notification.type === 'mention' || notification.type === 'update') && + notification.status?.filtered + ) { + const filters = notification.status.filtered.filter((result) => + result.filter.context.includes('notifications'), + ); + + if (filters.some((result) => result.filter.filter_action === 'hide')) { + return; + } + } + + dispatchAssociatedRecords(dispatch, [notification]); + + return notification; + }, +); + +export const loadPending = createAction('notificationGroups/loadPending'); + +export const updateScrollPosition = createAppAsyncThunk( + 'notificationGroups/updateScrollPosition', + ({ top }: { top: boolean }, { dispatch, getState }) => { + if ( + top && + getState().notificationGroups.mergedNotifications === 'needs-reload' + ) { + void dispatch(fetchNotifications()); + } + + return { top }; + }, +); + +export const setNotificationsFilter = createAppAsyncThunk( + 'notifications/filter/set', + ({ filterType }: { filterType: string }, { dispatch }) => { + dispatch({ + type: NOTIFICATIONS_FILTER_SET, + path: ['notifications', 'quickFilter', 'active'], + value: filterType, + }); + void dispatch(fetchNotifications()); + dispatch(saveSettings()); + }, +); + +export const clearNotifications = createDataLoadingThunk( + 'notifications/clear', + () => apiClearNotifications(), +); + +export const markNotificationsAsRead = createAction( + 'notificationGroups/markAsRead', +); + +export const mountNotifications = createAppAsyncThunk( + 'notificationGroups/mount', + (_, { dispatch, getState }) => { + const state = getState(); + + if ( + state.notificationGroups.mounted === 0 && + state.notificationGroups.mergedNotifications === 'needs-reload' + ) { + void dispatch(fetchNotifications()); + } + }, +); + +export const unmountNotifications = createAction('notificationGroups/unmount'); + +export const refreshStaleNotificationGroups = createAppAsyncThunk<{ + deferredRefresh: boolean; +}>('notificationGroups/refreshStale', (_, { dispatch, getState }) => { + const state = getState(); + + if ( + state.notificationGroups.scrolledToTop || + !state.notificationGroups.mounted + ) { + void dispatch(fetchNotifications()); + return { deferredRefresh: false }; + } + + return { deferredRefresh: true }; +}); diff --git a/app/javascript/mastodon/actions/notification_policies.ts b/app/javascript/mastodon/actions/notification_policies.ts index fcc9919c499..fd798eaad7e 100644 --- a/app/javascript/mastodon/actions/notification_policies.ts +++ b/app/javascript/mastodon/actions/notification_policies.ts @@ -1,3 +1,5 @@ +import { createAction } from '@reduxjs/toolkit'; + import { apiGetNotificationPolicy, apiUpdateNotificationsPolicy, @@ -14,3 +16,7 @@ export const updateNotificationsPolicy = createDataLoadingThunk( 'notificationPolicy/update', (policy: Partial) => apiUpdateNotificationsPolicy(policy), ); + +export const decreasePendingRequestsCount = createAction( + 'notificationPolicy/decreasePendingRequestsCount', +); diff --git a/app/javascript/mastodon/actions/notification_requests.ts b/app/javascript/mastodon/actions/notification_requests.ts new file mode 100644 index 00000000000..8352ff2aadb --- /dev/null +++ b/app/javascript/mastodon/actions/notification_requests.ts @@ -0,0 +1,214 @@ +import { + apiFetchNotificationRequest, + apiFetchNotificationRequests, + apiFetchNotifications, + apiAcceptNotificationRequest, + apiDismissNotificationRequest, + apiAcceptNotificationRequests, + apiDismissNotificationRequests, +} from 'mastodon/api/notifications'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import type { + ApiNotificationGroupJSON, + ApiNotificationJSON, +} from 'mastodon/api_types/notifications'; +import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; +import type { AppDispatch } from 'mastodon/store'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +import { importFetchedAccounts, importFetchedStatuses } from './importer'; +import { decreasePendingRequestsCount } from './notification_policies'; + +// TODO: refactor with notification_groups +function dispatchAssociatedRecords( + dispatch: AppDispatch, + notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[], +) { + const fetchedAccounts: ApiAccountJSON[] = []; + const fetchedStatuses: ApiStatusJSON[] = []; + + notifications.forEach((notification) => { + if (notification.type === 'admin.report') { + fetchedAccounts.push(notification.report.target_account); + } + + if (notification.type === 'moderation_warning') { + fetchedAccounts.push(notification.moderation_warning.target_account); + } + + if ('status' in notification && notification.status) { + fetchedStatuses.push(notification.status); + } + }); + + if (fetchedAccounts.length > 0) + dispatch(importFetchedAccounts(fetchedAccounts)); + + if (fetchedStatuses.length > 0) + dispatch(importFetchedStatuses(fetchedStatuses)); +} + +export const fetchNotificationRequests = createDataLoadingThunk( + 'notificationRequests/fetch', + async (_params, { getState }) => { + let sinceId = undefined; + + if (getState().notificationRequests.items.length > 0) { + sinceId = getState().notificationRequests.items[0]?.id; + } + + return apiFetchNotificationRequests({ + since_id: sinceId, + }); + }, + ({ requests, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatch(importFetchedAccounts(requests.map((request) => request.account))); + + return { requests, next: next?.uri }; + }, + { + condition: (_params, { getState }) => + !getState().notificationRequests.isLoading, + }, +); + +export const fetchNotificationRequest = createDataLoadingThunk( + 'notificationRequest/fetch', + async ({ id }: { id: string }) => apiFetchNotificationRequest(id), + { + condition: ({ id }, { getState }) => + !( + getState().notificationRequests.current.item?.id === id || + getState().notificationRequests.current.isLoading + ), + }, +); + +export const expandNotificationRequests = createDataLoadingThunk( + 'notificationRequests/expand', + async (_, { getState }) => { + const nextUrl = getState().notificationRequests.next; + if (!nextUrl) throw new Error('missing URL'); + + return apiFetchNotificationRequests(undefined, nextUrl); + }, + ({ requests, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatch(importFetchedAccounts(requests.map((request) => request.account))); + + return { requests, next: next?.uri }; + }, + { + condition: (_, { getState }) => + !!getState().notificationRequests.next && + !getState().notificationRequests.isLoading, + }, +); + +export const fetchNotificationsForRequest = createDataLoadingThunk( + 'notificationRequest/fetchNotifications', + async ({ accountId }: { accountId: string }, { getState }) => { + const sinceId = + // @ts-expect-error current.notifications.items is not yet typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + getState().notificationRequests.current.notifications.items[0]?.get( + 'id', + ) as string | undefined; + + return apiFetchNotifications({ + since_id: sinceId, + account_id: accountId, + }); + }, + ({ notifications, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications, next: next?.uri }; + }, + { + condition: ({ accountId }, { getState }) => { + const current = getState().notificationRequests.current; + return !( + current.item?.account_id === accountId && + current.notifications.isLoading + ); + }, + }, +); + +export const expandNotificationsForRequest = createDataLoadingThunk( + 'notificationRequest/expandNotifications', + async (_, { getState }) => { + const nextUrl = getState().notificationRequests.current.notifications.next; + if (!nextUrl) throw new Error('missing URL'); + + return apiFetchNotifications(undefined, nextUrl); + }, + ({ notifications, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications, next: next?.uri }; + }, + { + condition: ({ accountId }: { accountId: string }, { getState }) => { + const url = getState().notificationRequests.current.notifications.next; + + return ( + !!url && + !getState().notificationRequests.current.notifications.isLoading && + getState().notificationRequests.current.item?.account_id === accountId + ); + }, + }, +); + +export const acceptNotificationRequest = createDataLoadingThunk( + 'notificationRequest/accept', + ({ id }: { id: string }) => apiAcceptNotificationRequest(id), + (_data, { dispatch, discardLoadData }) => { + dispatch(decreasePendingRequestsCount(1)); + + // The payload is not used in any functions + return discardLoadData; + }, +); + +export const dismissNotificationRequest = createDataLoadingThunk( + 'notificationRequest/dismiss', + ({ id }: { id: string }) => apiDismissNotificationRequest(id), + (_data, { dispatch, discardLoadData }) => { + dispatch(decreasePendingRequestsCount(1)); + + // The payload is not used in any functions + return discardLoadData; + }, +); + +export const acceptNotificationRequests = createDataLoadingThunk( + 'notificationRequests/acceptBulk', + ({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids), + (_data, { dispatch, discardLoadData, actionArg: { ids } }) => { + dispatch(decreasePendingRequestsCount(ids.length)); + + // The payload is not used in any functions + return discardLoadData; + }, +); + +export const dismissNotificationRequests = createDataLoadingThunk( + 'notificationRequests/dismissBulk', + ({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids), + (_data, { dispatch, discardLoadData, actionArg: { ids } }) => { + dispatch(decreasePendingRequestsCount(ids.length)); + + // The payload is not used in any functions + return discardLoadData; + }, +); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 6a59d5624e8..4c6e27cd5f8 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -10,7 +10,7 @@ import api, { getLinks } from '../api'; import { unescapeHTML } from '../utils/html'; import { requestNotificationPermission } from '../utils/notifications'; -import { fetchFollowRequests, fetchRelationships } from './accounts'; +import { fetchFollowRequests } from './accounts'; import { importFetchedAccount, importFetchedAccounts, @@ -32,7 +32,6 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; -export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; @@ -44,47 +43,19 @@ export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; -export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST'; -export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS'; -export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL'; +export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST'; +export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS'; +export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL'; -export const NOTIFICATION_REQUESTS_EXPAND_REQUEST = 'NOTIFICATION_REQUESTS_EXPAND_REQUEST'; -export const NOTIFICATION_REQUESTS_EXPAND_SUCCESS = 'NOTIFICATION_REQUESTS_EXPAND_SUCCESS'; -export const NOTIFICATION_REQUESTS_EXPAND_FAIL = 'NOTIFICATION_REQUESTS_EXPAND_FAIL'; - -export const NOTIFICATION_REQUEST_FETCH_REQUEST = 'NOTIFICATION_REQUEST_FETCH_REQUEST'; -export const NOTIFICATION_REQUEST_FETCH_SUCCESS = 'NOTIFICATION_REQUEST_FETCH_SUCCESS'; -export const NOTIFICATION_REQUEST_FETCH_FAIL = 'NOTIFICATION_REQUEST_FETCH_FAIL'; - -export const NOTIFICATION_REQUEST_ACCEPT_REQUEST = 'NOTIFICATION_REQUEST_ACCEPT_REQUEST'; -export const NOTIFICATION_REQUEST_ACCEPT_SUCCESS = 'NOTIFICATION_REQUEST_ACCEPT_SUCCESS'; -export const NOTIFICATION_REQUEST_ACCEPT_FAIL = 'NOTIFICATION_REQUEST_ACCEPT_FAIL'; - -export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMISS_REQUEST'; -export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS'; -export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL'; - -export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST'; -export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS'; -export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL'; - -export const NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST'; -export const NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS'; -export const NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL'; +export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST'; +export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS'; +export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL'; defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, }); -const fetchRelatedRelationships = (dispatch, notifications) => { - const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id); - - if (accountIds.length > 0) { - dispatch(fetchRelationships(accountIds)); - } -}; - export const loadPending = () => ({ type: NOTIFICATIONS_LOAD_PENDING, }); @@ -127,8 +98,6 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered})); - - fetchRelatedRelationships(dispatch, [notification]); } else if (playSound && !filtered) { dispatch({ type: NOTIFICATIONS_UPDATE_NOOP, @@ -174,8 +143,8 @@ const noOp = () => {}; let expandNotificationsController = new AbortController(); -export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { - return (dispatch, getState) => { +export function expandNotifications({ maxId = undefined, forceLoad = false }) { + return async (dispatch, getState) => { const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); const notifications = getState().get('notifications'); const isLoadingMore = !!maxId; @@ -185,7 +154,6 @@ export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { expandNotificationsController.abort(); expandNotificationsController = new AbortController(); } else { - done(); return; } } @@ -212,7 +180,8 @@ export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { dispatch(expandNotificationsRequest(isLoadingMore)); - api().get('/api/v1/notifications', { params, signal: expandNotificationsController.signal }).then(response => { + try { + const response = await api().get('/api/v1/notifications', { params, signal: expandNotificationsController.signal }); const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data.map(item => item.account))); @@ -220,13 +189,10 @@ export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); - fetchRelatedRelationships(dispatch, response.data); dispatch(submitMarkers()); - }).catch(error => { + } catch(error) { dispatch(expandNotificationsFail(error, isLoadingMore)); - }).finally(() => { - done(); - }); + } }; } @@ -257,16 +223,6 @@ export function expandNotificationsFail(error, isLoadingMore) { }; } -export function clearNotifications() { - return (dispatch) => { - dispatch({ - type: NOTIFICATIONS_CLEAR, - }); - - api().post('/api/v1/notifications/clear'); - }; -} - export function scrollTopNotifications(top) { return { type: NOTIFICATIONS_SCROLL_TOP, @@ -341,236 +297,3 @@ export function setBrowserPermission (value) { value, }; } - -export const fetchNotificationRequests = () => (dispatch, getState) => { - const params = {}; - - if (getState().getIn(['notificationRequests', 'isLoading'])) { - return; - } - - if (getState().getIn(['notificationRequests', 'items'])?.size > 0) { - params.since_id = getState().getIn(['notificationRequests', 'items', 0, 'id']); - } - - dispatch(fetchNotificationRequestsRequest()); - - api().get('/api/v1/notifications/requests', { params }).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(fetchNotificationRequestsSuccess(response.data, next ? next.uri : null)); - }).catch(err => { - dispatch(fetchNotificationRequestsFail(err)); - }); -}; - -export const fetchNotificationRequestsRequest = () => ({ - type: NOTIFICATION_REQUESTS_FETCH_REQUEST, -}); - -export const fetchNotificationRequestsSuccess = (requests, next) => ({ - type: NOTIFICATION_REQUESTS_FETCH_SUCCESS, - requests, - next, -}); - -export const fetchNotificationRequestsFail = error => ({ - type: NOTIFICATION_REQUESTS_FETCH_FAIL, - error, -}); - -export const expandNotificationRequests = () => (dispatch, getState) => { - const url = getState().getIn(['notificationRequests', 'next']); - - if (!url || getState().getIn(['notificationRequests', 'isLoading'])) { - return; - } - - dispatch(expandNotificationRequestsRequest()); - - api().get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(expandNotificationRequestsSuccess(response.data, next?.uri)); - }).catch(err => { - dispatch(expandNotificationRequestsFail(err)); - }); -}; - -export const expandNotificationRequestsRequest = () => ({ - type: NOTIFICATION_REQUESTS_EXPAND_REQUEST, -}); - -export const expandNotificationRequestsSuccess = (requests, next) => ({ - type: NOTIFICATION_REQUESTS_EXPAND_SUCCESS, - requests, - next, -}); - -export const expandNotificationRequestsFail = error => ({ - type: NOTIFICATION_REQUESTS_EXPAND_FAIL, - error, -}); - -export const fetchNotificationRequest = id => (dispatch, getState) => { - const current = getState().getIn(['notificationRequests', 'current']); - - if (current.getIn(['item', 'id']) === id || current.get('isLoading')) { - return; - } - - dispatch(fetchNotificationRequestRequest(id)); - - api().get(`/api/v1/notifications/requests/${id}`).then(({ data }) => { - dispatch(fetchNotificationRequestSuccess(data)); - }).catch(err => { - dispatch(fetchNotificationRequestFail(id, err)); - }); -}; - -export const fetchNotificationRequestRequest = id => ({ - type: NOTIFICATION_REQUEST_FETCH_REQUEST, - id, -}); - -export const fetchNotificationRequestSuccess = request => ({ - type: NOTIFICATION_REQUEST_FETCH_SUCCESS, - request, -}); - -export const fetchNotificationRequestFail = (id, error) => ({ - type: NOTIFICATION_REQUEST_FETCH_FAIL, - id, - error, -}); - -export const acceptNotificationRequest = id => (dispatch) => { - dispatch(acceptNotificationRequestRequest(id)); - - api().post(`/api/v1/notifications/requests/${id}/accept`).then(() => { - dispatch(acceptNotificationRequestSuccess(id)); - }).catch(err => { - dispatch(acceptNotificationRequestFail(id, err)); - }); -}; - -export const acceptNotificationRequestRequest = id => ({ - type: NOTIFICATION_REQUEST_ACCEPT_REQUEST, - id, -}); - -export const acceptNotificationRequestSuccess = id => ({ - type: NOTIFICATION_REQUEST_ACCEPT_SUCCESS, - id, -}); - -export const acceptNotificationRequestFail = (id, error) => ({ - type: NOTIFICATION_REQUEST_ACCEPT_FAIL, - id, - error, -}); - -export const dismissNotificationRequest = id => (dispatch) => { - dispatch(dismissNotificationRequestRequest(id)); - - api().post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{ - dispatch(dismissNotificationRequestSuccess(id)); - }).catch(err => { - dispatch(dismissNotificationRequestFail(id, err)); - }); -}; - -export const dismissNotificationRequestRequest = id => ({ - type: NOTIFICATION_REQUEST_DISMISS_REQUEST, - id, -}); - -export const dismissNotificationRequestSuccess = id => ({ - type: NOTIFICATION_REQUEST_DISMISS_SUCCESS, - id, -}); - -export const dismissNotificationRequestFail = (id, error) => ({ - type: NOTIFICATION_REQUEST_DISMISS_FAIL, - id, - error, -}); - -export const fetchNotificationsForRequest = accountId => (dispatch, getState) => { - const current = getState().getIn(['notificationRequests', 'current']); - const params = { account_id: accountId }; - - if (current.getIn(['item', 'account']) === accountId) { - if (current.getIn(['notifications', 'isLoading'])) { - return; - } - - if (current.getIn(['notifications', 'items'])?.size > 0) { - params.since_id = current.getIn(['notifications', 'items', 0, 'id']); - } - } - - dispatch(fetchNotificationsForRequestRequest()); - - api().get('/api/v1/notifications', { params }).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedAccounts(response.data.map(item => item.account))); - dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); - dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); - - dispatch(fetchNotificationsForRequestSuccess(response.data, next?.uri)); - }).catch(err => { - dispatch(fetchNotificationsForRequestFail(err)); - }); -}; - -export const fetchNotificationsForRequestRequest = () => ({ - type: NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST, -}); - -export const fetchNotificationsForRequestSuccess = (notifications, next) => ({ - type: NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS, - notifications, - next, -}); - -export const fetchNotificationsForRequestFail = (error) => ({ - type: NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL, - error, -}); - -export const expandNotificationsForRequest = () => (dispatch, getState) => { - const url = getState().getIn(['notificationRequests', 'current', 'notifications', 'next']); - - if (!url || getState().getIn(['notificationRequests', 'current', 'notifications', 'isLoading'])) { - return; - } - - dispatch(expandNotificationsForRequestRequest()); - - api().get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedAccounts(response.data.map(item => item.account))); - dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); - dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); - - dispatch(expandNotificationsForRequestSuccess(response.data, next?.uri)); - }).catch(err => { - dispatch(expandNotificationsForRequestFail(err)); - }); -}; - -export const expandNotificationsForRequestRequest = () => ({ - type: NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST, -}); - -export const expandNotificationsForRequestSuccess = (notifications, next) => ({ - type: NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS, - notifications, - next, -}); - -export const expandNotificationsForRequestFail = (error) => ({ - type: NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL, - error, -}); diff --git a/app/javascript/mastodon/actions/notifications_migration.tsx b/app/javascript/mastodon/actions/notifications_migration.tsx new file mode 100644 index 00000000000..cd9f5ca3d6d --- /dev/null +++ b/app/javascript/mastodon/actions/notifications_migration.tsx @@ -0,0 +1,10 @@ +import { createAppAsyncThunk } from 'mastodon/store'; + +import { fetchNotifications } from './notification_groups'; + +export const initializeNotifications = createAppAsyncThunk( + 'notifications/initialize', + (_, { dispatch }) => { + void dispatch(fetchNotifications()); + }, +); diff --git a/app/javascript/mastodon/actions/notifications_typed.ts b/app/javascript/mastodon/actions/notifications_typed.ts index 176362f4b1e..88d942d45e5 100644 --- a/app/javascript/mastodon/actions/notifications_typed.ts +++ b/app/javascript/mastodon/actions/notifications_typed.ts @@ -1,11 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; -import type { ApiAccountJSON } from '../api_types/accounts'; -// To be replaced once ApiNotificationJSON type exists -interface FakeApiNotificationJSON { - type: string; - account: ApiAccountJSON; -} +import type { ApiNotificationJSON } from 'mastodon/api_types/notifications'; export const notificationsUpdate = createAction( 'notifications/update', @@ -13,7 +8,7 @@ export const notificationsUpdate = createAction( playSound, ...args }: { - notification: FakeApiNotificationJSON; + notification: ApiNotificationJSON; usePendingItems: boolean; playSound: boolean; }) => ({ diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index a60b80dc2c0..1e4e545d8c9 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -1,3 +1,5 @@ +import { browserHistory } from 'mastodon/components/router'; + import api from '../api'; import { ensureComposeIsVisible, setComposeToStatus } from './compose'; @@ -47,11 +49,13 @@ export function fetchStatusRequest(id, skipLoading) { }; } -export function fetchStatus(id, forceFetch = false) { +export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) { return (dispatch, getState) => { const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; - dispatch(fetchContext(id)); + if (alsoFetchContext) { + dispatch(fetchContext(id)); + } if (skipLoading) { return; @@ -93,7 +97,7 @@ export function redraft(status, raw_text) { }; } -export const editStatus = (id, routerHistory) => (dispatch, getState) => { +export const editStatus = (id) => (dispatch, getState) => { let status = getState().getIn(['statuses', id]); if (status.get('poll')) { @@ -104,7 +108,7 @@ export const editStatus = (id, routerHistory) => (dispatch, getState) => { api().get(`/api/v1/statuses/${id}/source`).then(response => { dispatch(fetchStatusSourceSuccess()); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text)); }).catch(error => { dispatch(fetchStatusSourceFail(error)); @@ -124,7 +128,7 @@ export const fetchStatusSourceFail = error => ({ error, }); -export function deleteStatus(id, routerHistory, withRedraft = false) { +export function deleteStatus(id, withRedraft = false) { return (dispatch, getState) => { let status = getState().getIn(['statuses', id]); @@ -141,7 +145,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) { if (withRedraft) { dispatch(redraft(status, response.data.text)); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); } }).catch(error => { dispatch(deleteStatusFail(id, error)); @@ -308,6 +312,21 @@ export function revealStatus(ids) { }; } +export function toggleStatusSpoilers(statusId) { + return (dispatch, getState) => { + const status = getState().statuses.get(statusId); + + if (!status) + return; + + if (status.get('hidden')) { + dispatch(revealStatus(statusId)); + } else { + dispatch(hideStatus(statusId)); + } + }; +} + export function toggleStatusCollapse(id, isCollapsed) { return { type: STATUS_COLLAPSE, @@ -348,3 +367,15 @@ export const undoStatusTranslation = (id, pollId) => ({ id, pollId, }); + +export const navigateToStatus = (statusId) => { + return (_dispatch, getState) => { + const state = getState(); + const accountId = state.statuses.getIn([statusId, 'account']); + const acct = state.accounts.getIn([accountId, 'acct']); + + if (acct) { + browserHistory.push(`/@${acct}/${statusId}`); + } + }; +}; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index e7fe1c53ed0..b6fc31fae85 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -10,6 +10,7 @@ import { deleteAnnouncement, } from './announcements'; import { updateConversations } from './conversations'; +import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups'; import { updateNotifications, expandNotifications } from './notifications'; import { updateStatus } from './statuses'; import { @@ -36,7 +37,7 @@ const randomUpTo = max => * @param {string} channelName * @param {Object.} params * @param {Object} options - * @param {function(Function, Function): void} [options.fallback] + * @param {function(Function, Function): Promise} [options.fallback] * @param {function(): void} [options.fillGaps] * @param {function(object): boolean} [options.accept] * @returns {function(): void} @@ -51,14 +52,13 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti let pollingId; /** - * @param {function(Function, Function): void} fallback + * @param {function(Function, Function): Promise} fallback */ - const useFallback = fallback => { - fallback(dispatch, () => { - // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook - pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); - }); + const useFallback = async fallback => { + await fallback(dispatch, getState); + // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook + pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); }; return { @@ -98,9 +98,19 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'delete': dispatch(deleteFromTimelines(data.payload)); break; - case 'notification': + case 'notification': { // @ts-expect-error - dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + const notificationJSON = JSON.parse(data.payload); + dispatch(updateNotifications(notificationJSON, messages, locale)); + // TODO: remove this once the groups feature replaces the previous one + dispatch(processNewNotificationForGroups(notificationJSON)); + break; + } + case 'notifications_merged': + const state = getState(); + if (state.notifications.top || !state.notifications.mounted) + dispatch(expandNotifications({ forceLoad: true, maxId: undefined })); + dispatch(refreshStaleNotificationGroups()); break; case 'conversation': // @ts-expect-error @@ -125,21 +135,24 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti /** * @param {Function} dispatch - * @param {function(): void} done */ -const refreshHomeTimelineAndNotification = (dispatch, done) => { - // @ts-expect-error - dispatch(expandHomeTimeline({}, () => - // @ts-expect-error - dispatch(expandNotifications({}, () => - dispatch(fetchAnnouncements(done)))))); -}; +async function refreshHomeTimelineAndNotification(dispatch) { + await dispatch(expandHomeTimeline({ maxId: undefined })); + + // TODO: polling for merged notifications + try { + await dispatch(pollRecentGroupNotifications()); + } catch { + // TODO + } + + await dispatch(fetchAnnouncements()); +} /** * @returns {function(): void} */ export const connectUserStream = () => - // @ts-expect-error connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps }); /** diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 4ca3c3a151d..65b6d804511 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -76,21 +76,18 @@ export function clearTimeline(timeline) { }; } -const noOp = () => {}; - const parseTags = (tags = {}, mode) => { return (tags[mode] || []).map((tag) => { return tag.value; }); }; -export function expandTimeline(timelineId, path, params = {}, done = noOp) { - return (dispatch, getState) => { +export function expandTimeline(timelineId, path, params = {}) { + return async (dispatch, getState) => { const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); const isLoadingMore = !!params.max_id; if (timeline.get('isLoading')) { - done(); return; } @@ -109,7 +106,8 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { dispatch(expandTimelineRequest(timelineId, isLoadingMore)); - api().get(path, { params }).then(response => { + try { + const response = await api().get(path, { params }); const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); @@ -127,51 +125,48 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { if (timelineId === 'home') { dispatch(submitMarkers()); } - }).catch(error => { + } catch(error) { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); - }).finally(() => { - done(); - }); + } }; } -export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) { - return (dispatch, getState) => { +export function fillTimelineGaps(timelineId, path, params = {}) { + return async (dispatch, getState) => { const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); const items = timeline.get('items'); const nullIndexes = items.map((statusId, index) => statusId === null ? index : null); const gaps = nullIndexes.map(index => index > 0 ? items.get(index - 1) : null); // Only expand at most two gaps to avoid doing too many requests - done = gaps.take(2).reduce((done, maxId) => { - return (() => dispatch(expandTimeline(timelineId, path, { ...params, maxId }, done))); - }, done); - - done(); + for (const maxId of gaps.take(2)) { + await dispatch(expandTimeline(timelineId, path, { ...params, maxId })); + } }; } -export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); -export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done); -export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); +export const expandHomeTimeline = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }); +export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }); +export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }); export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId }); export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); -export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); -export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => { +export const expandListTimeline = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }); +export const expandLinkTimeline = (url, { maxId } = {}) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }); +export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}) => { return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, any: parseTags(tags, 'any'), all: parseTags(tags, 'all'), none: parseTags(tags, 'none'), local: local, - }, done); + }); }; -export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home', '/api/v1/timelines/home', {}, done); -export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia }, done); -export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done); -export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done); +export const fillHomeTimelineGaps = () => fillTimelineGaps('home', '/api/v1/timelines/home', {}); +export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote } = {}) => fillTimelineGaps(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia }); +export const fillCommunityTimelineGaps = ({ onlyMedia } = {}) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }); +export const fillListTimelineGaps = (id) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}); export function expandTimelineRequest(timeline, isLoadingMore) { return { diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js index 01089fccbb7..0bdf17a5d26 100644 --- a/app/javascript/mastodon/actions/trends.js +++ b/app/javascript/mastodon/actions/trends.js @@ -51,7 +51,7 @@ export const fetchTrendingLinks = () => (dispatch) => { api() .get('/api/v1/trends/links', { params: { limit: 20 } }) .then(({ data }) => { - dispatch(importFetchedAccounts(data.map(link => link.author_account).filter(account => !!account))); + dispatch(importFetchedAccounts(data.flatMap(link => link.authors.map(author => author.account)).filter(account => !!account))); dispatch(fetchTrendingLinksSuccess(data)); }) .catch(err => dispatch(fetchTrendingLinksFail(err))); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index e133125a29c..51cbe0b6954 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -42,6 +42,9 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => { // eslint-disable-next-line import/no-default-export export default function api(withAuthorization = true) { return axios.create({ + transitional: { + clarifyTimeoutError: true, + }, headers: { ...csrfHeader, ...(withAuthorization ? authorizationTokenFromInitialState() : {}), @@ -59,16 +62,50 @@ export default function api(withAuthorization = true) { }); } +type RequestParamsOrData = Record; + export async function apiRequest( method: Method, url: string, - params?: Record, + args: { + params?: RequestParamsOrData; + data?: RequestParamsOrData; + timeout?: number; + } = {}, ) { const { data } = await api().request({ method, url: '/api/' + url, - data: params, + ...args, }); return data; } + +export async function apiRequestGet( + url: string, + params?: RequestParamsOrData, +) { + return apiRequest('GET', url, { params }); +} + +export async function apiRequestPost( + url: string, + data?: RequestParamsOrData, +) { + return apiRequest('POST', url, { data }); +} + +export async function apiRequestPut( + url: string, + data?: RequestParamsOrData, +) { + return apiRequest('PUT', url, { data }); +} + +export async function apiRequestDelete( + url: string, + params?: RequestParamsOrData, +) { + return apiRequest('DELETE', url, { params }); +} diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index 3d89e44b267..bd1757e827e 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -1,7 +1,7 @@ -import { apiRequest } from 'mastodon/api'; +import { apiRequestPost } from 'mastodon/api'; import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships'; export const apiSubmitAccountNote = (id: string, value: string) => - apiRequest('post', `v1/accounts/${id}/note`, { + apiRequestPost(`v1/accounts/${id}/note`, { comment: value, }); diff --git a/app/javascript/mastodon/api/directory.ts b/app/javascript/mastodon/api/directory.ts new file mode 100644 index 00000000000..cd39f8f2690 --- /dev/null +++ b/app/javascript/mastodon/api/directory.ts @@ -0,0 +1,15 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; + +export const apiGetDirectory = ( + params: { + order: string; + local: boolean; + offset?: number; + }, + limit = 20, +) => + apiRequestGet('v1/directory', { + ...params, + limit, + }); diff --git a/app/javascript/mastodon/api/interactions.ts b/app/javascript/mastodon/api/interactions.ts index 4c466a1b46d..118b5f06d20 100644 --- a/app/javascript/mastodon/api/interactions.ts +++ b/app/javascript/mastodon/api/interactions.ts @@ -1,10 +1,10 @@ -import { apiRequest } from 'mastodon/api'; +import { apiRequestPost } from 'mastodon/api'; import type { Status, StatusVisibility } from 'mastodon/models/status'; export const apiReblog = (statusId: string, visibility: StatusVisibility) => - apiRequest<{ reblog: Status }>('post', `v1/statuses/${statusId}/reblog`, { + apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, { visibility, }); export const apiUnreblog = (statusId: string) => - apiRequest('post', `v1/statuses/${statusId}/unreblog`); + apiRequestPost(`v1/statuses/${statusId}/unreblog`); diff --git a/app/javascript/mastodon/api/notification_policies.ts b/app/javascript/mastodon/api/notification_policies.ts index b2a1e5ac31f..3bc8174139d 100644 --- a/app/javascript/mastodon/api/notification_policies.ts +++ b/app/javascript/mastodon/api/notification_policies.ts @@ -1,10 +1,9 @@ -import { apiRequest } from 'mastodon/api'; +import { apiRequestGet, apiRequestPut } from 'mastodon/api'; import type { NotificationPolicyJSON } from 'mastodon/api_types/notification_policies'; export const apiGetNotificationPolicy = () => - apiRequest('GET', '/v1/notifications/policy'); + apiRequestGet('v2/notifications/policy'); export const apiUpdateNotificationsPolicy = ( policy: Partial, -) => - apiRequest('PUT', '/v1/notifications/policy', policy); +) => apiRequestPut('v2/notifications/policy', policy); diff --git a/app/javascript/mastodon/api/notifications.ts b/app/javascript/mastodon/api/notifications.ts new file mode 100644 index 00000000000..813e2f3a170 --- /dev/null +++ b/app/javascript/mastodon/api/notifications.ts @@ -0,0 +1,96 @@ +import api, { + apiRequest, + getLinks, + apiRequestGet, + apiRequestPost, +} from 'mastodon/api'; +import type { + ApiNotificationGroupsResultJSON, + ApiNotificationRequestJSON, + ApiNotificationJSON, +} from 'mastodon/api_types/notifications'; + +export const apiFetchNotifications = async ( + params?: { + account_id?: string; + since_id?: string; + }, + url?: string, +) => { + const response = await api().request({ + method: 'GET', + url: url ?? '/api/v1/notifications', + params, + }); + + return { + notifications: response.data, + links: getLinks(response), + }; +}; + +export const apiFetchNotificationGroups = async (params?: { + url?: string; + grouped_types?: string[]; + exclude_types?: string[]; + max_id?: string; + since_id?: string; +}) => { + const response = await api().request({ + method: 'GET', + url: '/api/v2/notifications', + params, + }); + + const { statuses, accounts, notification_groups } = response.data; + + return { + statuses, + accounts, + notifications: notification_groups, + links: getLinks(response), + }; +}; + +export const apiClearNotifications = () => + apiRequest('POST', 'v1/notifications/clear'); + +export const apiFetchNotificationRequests = async ( + params?: { + since_id?: string; + }, + url?: string, +) => { + const response = await api().request({ + method: 'GET', + url: url ?? '/api/v1/notifications/requests', + params, + }); + + return { + requests: response.data, + links: getLinks(response), + }; +}; + +export const apiFetchNotificationRequest = async (id: string) => { + return apiRequestGet( + `v1/notifications/requests/${id}`, + ); +}; + +export const apiAcceptNotificationRequest = async (id: string) => { + return apiRequestPost(`v1/notifications/requests/${id}/accept`); +}; + +export const apiDismissNotificationRequest = async (id: string) => { + return apiRequestPost(`v1/notifications/requests/${id}/dismiss`); +}; + +export const apiAcceptNotificationRequests = async (id: string[]) => { + return apiRequestPost('v1/notifications/requests/accept', { id }); +}; + +export const apiDismissNotificationRequests = async (id: string[]) => { + return apiRequestPost('v1/notifications/requests/dismiss', { id }); +}; diff --git a/app/javascript/mastodon/api_types/notification_policies.ts b/app/javascript/mastodon/api_types/notification_policies.ts index 0f4a2d132e0..1c3970782cb 100644 --- a/app/javascript/mastodon/api_types/notification_policies.ts +++ b/app/javascript/mastodon/api_types/notification_policies.ts @@ -1,10 +1,13 @@ // See app/serializers/rest/notification_policy_serializer.rb +export type NotificationPolicyValue = 'accept' | 'filter' | 'drop'; + export interface NotificationPolicyJSON { - filter_not_following: boolean; - filter_not_followers: boolean; - filter_new_accounts: boolean; - filter_private_mentions: boolean; + for_not_following: NotificationPolicyValue; + for_not_followers: NotificationPolicyValue; + for_new_accounts: NotificationPolicyValue; + for_private_mentions: NotificationPolicyValue; + for_limited_accounts: NotificationPolicyValue; summary: { pending_requests_count: number; pending_notifications_count: number; diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts new file mode 100644 index 00000000000..28ba7eb5c20 --- /dev/null +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -0,0 +1,160 @@ +// See app/serializers/rest/notification_group_serializer.rb + +import type { AccountWarningAction } from 'mastodon/models/notification_group'; + +import type { ApiAccountJSON } from './accounts'; +import type { ApiReportJSON } from './reports'; +import type { ApiStatusJSON } from './statuses'; + +// See app/model/notification.rb +export const allNotificationTypes = [ + 'follow', + 'follow_request', + 'favourite', + 'reblog', + 'mention', + 'poll', + 'status', + 'update', + 'admin.sign_up', + 'admin.report', + 'moderation_warning', + 'severed_relationships', +]; + +export type NotificationWithStatusType = + | 'favourite' + | 'reblog' + | 'status' + | 'mention' + | 'poll' + | 'update'; + +export type NotificationType = + | NotificationWithStatusType + | 'follow' + | 'follow_request' + | 'moderation_warning' + | 'severed_relationships' + | 'admin.sign_up' + | 'admin.report'; + +export interface BaseNotificationJSON { + id: string; + type: NotificationType; + created_at: string; + group_key: string; + account: ApiAccountJSON; +} + +export interface BaseNotificationGroupJSON { + group_key: string; + notifications_count: number; + type: NotificationType; + sample_account_ids: string[]; + latest_page_notification_at: string; // FIXME: This will only be present if the notification group is returned in a paginated list, not requested directly + most_recent_notification_id: string; + page_min_id?: string; + page_max_id?: string; +} + +interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { + type: NotificationWithStatusType; + status_id: string | null; +} + +interface NotificationWithStatusJSON extends BaseNotificationJSON { + type: NotificationWithStatusType; + status: ApiStatusJSON | null; +} + +interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON { + type: 'admin.report'; + report: ApiReportJSON; +} + +interface ReportNotificationJSON extends BaseNotificationJSON { + type: 'admin.report'; + report: ApiReportJSON; +} + +type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up'; +interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON { + type: SimpleNotificationTypes; +} + +interface SimpleNotificationJSON extends BaseNotificationJSON { + type: SimpleNotificationTypes; +} + +export interface ApiAccountWarningJSON { + id: string; + action: AccountWarningAction; + text: string; + status_ids: string[]; + created_at: string; + target_account: ApiAccountJSON; + appeal: unknown; +} + +interface ModerationWarningNotificationGroupJSON + extends BaseNotificationGroupJSON { + type: 'moderation_warning'; + moderation_warning: ApiAccountWarningJSON; +} + +interface ModerationWarningNotificationJSON extends BaseNotificationJSON { + type: 'moderation_warning'; + moderation_warning: ApiAccountWarningJSON; +} + +export interface ApiAccountRelationshipSeveranceEventJSON { + id: string; + type: 'account_suspension' | 'domain_block' | 'user_domain_block'; + purged: boolean; + target_name: string; + followers_count: number; + following_count: number; + created_at: string; +} + +interface AccountRelationshipSeveranceNotificationGroupJSON + extends BaseNotificationGroupJSON { + type: 'severed_relationships'; + event: ApiAccountRelationshipSeveranceEventJSON; +} + +interface AccountRelationshipSeveranceNotificationJSON + extends BaseNotificationJSON { + type: 'severed_relationships'; + event: ApiAccountRelationshipSeveranceEventJSON; +} + +export type ApiNotificationJSON = + | SimpleNotificationJSON + | ReportNotificationJSON + | AccountRelationshipSeveranceNotificationJSON + | NotificationWithStatusJSON + | ModerationWarningNotificationJSON; + +export type ApiNotificationGroupJSON = + | SimpleNotificationGroupJSON + | ReportNotificationGroupJSON + | AccountRelationshipSeveranceNotificationGroupJSON + | NotificationGroupWithStatusJSON + | ModerationWarningNotificationGroupJSON; + +export interface ApiNotificationGroupsResultJSON { + accounts: ApiAccountJSON[]; + statuses: ApiStatusJSON[]; + notification_groups: ApiNotificationGroupJSON[]; +} + +export interface ApiNotificationRequestJSON { + id: string; + created_at: string; + updated_at: string; + notifications_count: string; + account: ApiAccountJSON; + last_status?: ApiStatusJSON; +} diff --git a/app/javascript/mastodon/api_types/reports.ts b/app/javascript/mastodon/api_types/reports.ts new file mode 100644 index 00000000000..b11cfdd2eb5 --- /dev/null +++ b/app/javascript/mastodon/api_types/reports.ts @@ -0,0 +1,16 @@ +import type { ApiAccountJSON } from './accounts'; + +export type ReportCategory = 'other' | 'spam' | 'legal' | 'violation'; + +export interface ApiReportJSON { + id: string; + action_taken: unknown; + action_taken_at: unknown; + category: ReportCategory; + comment: string; + forwarded: boolean; + created_at: string; + status_ids: string[]; + rule_ids: string[]; + target_account: ApiAccountJSON; +} diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index c7dd33b5dad..2c59645ea78 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -30,6 +30,12 @@ export interface ApiMentionJSON { acct: string; } +export interface ApiPreviewCardAuthorJSON { + name: string; + url: string; + account?: ApiAccountJSON; +} + export interface ApiPreviewCardJSON { url: string; title: string; @@ -38,6 +44,7 @@ export interface ApiPreviewCardJSON { type: string; author_name: string; author_url: string; + author_account?: ApiAccountJSON; provider_name: string; provider_url: string; html: string; @@ -48,6 +55,30 @@ export interface ApiPreviewCardJSON { embed_url: string; blurhash: string; published_at: string; + authors: ApiPreviewCardAuthorJSON[]; +} + +export type FilterContext = + | 'home' + | 'notifications' + | 'public' + | 'thread' + | 'account'; + +export interface ApiFilterJSON { + id: string; + title: string; + context: FilterContext; + expires_at: string; + filter_action: 'warn' | 'hide'; + keywords?: unknown[]; // TODO: FilterKeywordSerializer + statuses?: unknown[]; // TODO: FilterStatusSerializer +} + +export interface ApiFilterResultJSON { + filter: ApiFilterJSON; + keyword_matches: string[]; + status_matches: string[]; } export interface ApiStatusJSON { @@ -72,8 +103,7 @@ export interface ApiStatusJSON { bookmarked?: boolean; pinned?: boolean; - // filtered: FilterResult[] - filtered: unknown; // TODO + filtered?: ApiFilterResultJSON[]; content?: string; text?: string; diff --git a/app/javascript/mastodon/common.js b/app/javascript/mastodon/common.js index 511568aa0f7..c61e02250c2 100644 --- a/app/javascript/mastodon/common.js +++ b/app/javascript/mastodon/common.js @@ -1,12 +1,11 @@ import Rails from '@rails/ujs'; -import 'font-awesome/css/font-awesome.css'; export function start() { require.context('../images/', true, /\.(jpg|png|svg)$/); try { Rails.start(); - } catch (e) { + } catch { // If called twice } } diff --git a/app/javascript/mastodon/components/__tests__/hashtag_bar.tsx b/app/javascript/mastodon/components/__tests__/hashtag_bar.tsx index b7225fc92e0..f86c1a2a6bc 100644 --- a/app/javascript/mastodon/components/__tests__/hashtag_bar.tsx +++ b/app/javascript/mastodon/components/__tests__/hashtag_bar.tsx @@ -165,7 +165,7 @@ describe('computeHashtagBarForStatus', () => { ); }); - it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => { + it('does not put the hashtags in the bar if a status content has hashtags in the only line and has a media', () => { const status = createStatus( '

This is my content! #hashtag

', ['hashtag'], diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx index 18a31cba244..265c68697ba 100644 --- a/app/javascript/mastodon/components/account.jsx +++ b/app/javascript/mastodon/components/account.jsx @@ -106,7 +106,7 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica ); } else if (defaultAction === 'mute') { - buttons = - ); -}; - -BackButton.propTypes = { - onlyIcon: PropTypes.bool, -}; - -class ColumnHeader extends PureComponent { - static propTypes = { - identity: identityContextPropShape, - intl: PropTypes.object.isRequired, - title: PropTypes.node, - icon: PropTypes.string, - iconComponent: PropTypes.func, - active: PropTypes.bool, - multiColumn: PropTypes.bool, - extraButton: PropTypes.node, - showBackButton: PropTypes.bool, - children: PropTypes.node, - pinned: PropTypes.bool, - placeholder: PropTypes.bool, - onPin: PropTypes.func, - onMove: PropTypes.func, - onClick: PropTypes.func, - appendContent: PropTypes.node, - collapseIssues: PropTypes.bool, - ...WithRouterPropTypes, - }; - - state = { - collapsed: true, - animating: false, - }; - - handleToggleClick = (e) => { - e.stopPropagation(); - this.setState({ collapsed: !this.state.collapsed, animating: true }); - }; - - handleTitleClick = () => { - this.props.onClick?.(); - }; - - handleMoveLeft = () => { - this.props.onMove(-1); - }; - - handleMoveRight = () => { - this.props.onMove(1); - }; - - handleTransitionEnd = () => { - this.setState({ animating: false }); - }; - - handlePin = () => { - if (!this.props.pinned) { - this.props.history.replace('/'); - } - - this.props.onPin(); - }; - - render () { - const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = this.props; - const { collapsed, animating } = this.state; - - const wrapperClassName = classNames('column-header__wrapper', { - 'active': active, - }); - - const buttonClassName = classNames('column-header', { - 'active': active, - }); - - const collapsibleClassName = classNames('column-header__collapsible', { - 'collapsed': collapsed, - 'animating': animating, - }); - - const collapsibleButtonClassName = classNames('column-header__button', { - 'active': !collapsed, - }); - - let extraContent, pinButton, moveButtons, backButton, collapseButton; - - if (children) { - extraContent = ( -
- {children} -
- ); - } - - if (multiColumn && pinned) { - pinButton = ; - - moveButtons = ( -
- - -
- ); - } else if (multiColumn && this.props.onPin) { - pinButton = ; - } - - if (history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) { - backButton = ; - } - - const collapsedContent = [ - extraContent, - ]; - - if (multiColumn) { - collapsedContent.push( -
- {pinButton} - {moveButtons} -
- ); - } - - if (this.props.identity.signedIn && (children || (multiColumn && this.props.onPin))) { - collapseButton = ( - - ); - } - - const hasTitle = (icon || iconComponent) && title; - - const component = ( -
-

- {hasTitle && ( - <> - {backButton} - - - - )} - - {!hasTitle && backButton} - -
- {extraButton} - {collapseButton} -
-

- -
-
- {(!collapsed || animating) && collapsedContent} -
-
- - {appendContent} -
- ); - - if (placeholder) { - return component; - } else { - return ( - {component} - ); - } - } - -} - -export default injectIntl(withIdentity(withRouter(ColumnHeader))); diff --git a/app/javascript/mastodon/components/column_header.tsx b/app/javascript/mastodon/components/column_header.tsx new file mode 100644 index 00000000000..ec946cab3ed --- /dev/null +++ b/app/javascript/mastodon/components/column_header.tsx @@ -0,0 +1,301 @@ +import { useCallback, useState } from 'react'; + +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import AddIcon from '@/material-icons/400-24px/add.svg?react'; +import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; +import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import SettingsIcon from '@/material-icons/400-24px/settings.svg?react'; +import type { IconProp } from 'mastodon/components/icon'; +import { Icon } from 'mastodon/components/icon'; +import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; +import { useIdentity } from 'mastodon/identity_context'; + +import { useAppHistory } from './router'; + +const messages = defineMessages({ + show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, + hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, + moveLeft: { + id: 'column_header.moveLeft_settings', + defaultMessage: 'Move column to the left', + }, + moveRight: { + id: 'column_header.moveRight_settings', + defaultMessage: 'Move column to the right', + }, + back: { id: 'column_back_button.label', defaultMessage: 'Back' }, +}); + +const BackButton: React.FC<{ + onlyIcon: boolean; +}> = ({ onlyIcon }) => { + const history = useAppHistory(); + const intl = useIntl(); + + const handleBackClick = useCallback(() => { + if (history.location.state?.fromMastodon) { + history.goBack(); + } else { + history.push('/'); + } + }, [history]); + + return ( + + ); +}; + +export interface Props { + title?: string; + icon?: string; + iconComponent?: IconProp; + active?: boolean; + children?: React.ReactNode; + pinned?: boolean; + multiColumn?: boolean; + extraButton?: React.ReactNode; + showBackButton?: boolean; + placeholder?: boolean; + appendContent?: React.ReactNode; + collapseIssues?: boolean; + onClick?: () => void; + onMove?: (arg0: number) => void; + onPin?: () => void; +} + +export const ColumnHeader: React.FC = ({ + title, + icon, + iconComponent, + active, + children, + pinned, + multiColumn, + extraButton, + showBackButton, + placeholder, + appendContent, + collapseIssues, + onClick, + onMove, + onPin, +}) => { + const intl = useIntl(); + const { signedIn } = useIdentity(); + const history = useAppHistory(); + const [collapsed, setCollapsed] = useState(true); + const [animating, setAnimating] = useState(false); + + const handleToggleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setCollapsed((value) => !value); + setAnimating(true); + }, + [setCollapsed, setAnimating], + ); + + const handleTitleClick = useCallback(() => { + onClick?.(); + }, [onClick]); + + const handleMoveLeft = useCallback(() => { + onMove?.(-1); + }, [onMove]); + + const handleMoveRight = useCallback(() => { + onMove?.(1); + }, [onMove]); + + const handleTransitionEnd = useCallback(() => { + setAnimating(false); + }, [setAnimating]); + + const handlePin = useCallback(() => { + if (!pinned) { + history.replace('/'); + } + + onPin?.(); + }, [history, pinned, onPin]); + + const wrapperClassName = classNames('column-header__wrapper', { + active, + }); + + const buttonClassName = classNames('column-header', { + active, + }); + + const collapsibleClassName = classNames('column-header__collapsible', { + collapsed, + animating, + }); + + const collapsibleButtonClassName = classNames('column-header__button', { + active: !collapsed, + }); + + let extraContent, pinButton, moveButtons, backButton, collapseButton; + + if (children) { + extraContent = ( +
+ {children} +
+ ); + } + + if (multiColumn && pinned) { + pinButton = ( + + ); + + moveButtons = ( +
+ + +
+ ); + } else if (multiColumn && onPin) { + pinButton = ( + + ); + } + + if ( + !pinned && + ((multiColumn && history.location.state?.fromMastodon) || showBackButton) + ) { + backButton = ; + } + + const collapsedContent = [extraContent]; + + if (multiColumn) { + collapsedContent.push( +
+ {pinButton} + {moveButtons} +
, + ); + } + + if (signedIn && (children || (multiColumn && onPin))) { + collapseButton = ( + + ); + } + + const hasIcon = icon && iconComponent; + const hasTitle = hasIcon && title; + + const component = ( +
+

+ {hasTitle && ( + <> + {backButton} + + + + )} + + {!hasTitle && backButton} + +
+ {extraButton} + {collapseButton} +
+

+ +
+
+ {(!collapsed || animating) && collapsedContent} +
+
+ + {appendContent} +
+ ); + + if (placeholder) { + return component; + } else { + return {component}; + } +}; + +// eslint-disable-next-line import/no-default-export +export default ColumnHeader; diff --git a/app/javascript/mastodon/components/content_warning.tsx b/app/javascript/mastodon/components/content_warning.tsx new file mode 100644 index 00000000000..df8afca74d6 --- /dev/null +++ b/app/javascript/mastodon/components/content_warning.tsx @@ -0,0 +1,15 @@ +import { StatusBanner, BannerVariant } from './status_banner'; + +export const ContentWarning: React.FC<{ + text: string; + expanded?: boolean; + onClick?: () => void; +}> = ({ text, expanded, onClick }) => ( + +

+ +); diff --git a/app/javascript/mastodon/components/copy_paste_text.tsx b/app/javascript/mastodon/components/copy_paste_text.tsx new file mode 100644 index 00000000000..f888acd0f7c --- /dev/null +++ b/app/javascript/mastodon/components/copy_paste_text.tsx @@ -0,0 +1,90 @@ +import { useRef, useState, useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; +import { useTimeout } from 'mastodon/../hooks/useTimeout'; +import { Icon } from 'mastodon/components/icon'; + +export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => { + const inputRef = useRef(null); + const [copied, setCopied] = useState(false); + const [focused, setFocused] = useState(false); + const [setAnimationTimeout] = useTimeout(); + + const handleInputClick = useCallback(() => { + setCopied(false); + + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + inputRef.current.setSelectionRange(0, value.length); + } + }, [setCopied, value]); + + const handleButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + void navigator.clipboard.writeText(value); + inputRef.current?.blur(); + setCopied(true); + setAnimationTimeout(() => { + setCopied(false); + }, 700); + }, + [setCopied, setAnimationTimeout, value], + ); + + const handleKeyUp = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== ' ') return; + void navigator.clipboard.writeText(value); + setCopied(true); + setAnimationTimeout(() => { + setCopied(false); + }, 700); + }, + [setCopied, setAnimationTimeout, value], + ); + + const handleFocus = useCallback(() => { + setFocused(true); + }, [setFocused]); + + const handleBlur = useCallback(() => { + setFocused(false); + }, [setFocused]); + + return ( +

+