diff --git a/.browserslistrc b/.browserslistrc index 6367e4d358..0135379d6e 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,10 +1,6 @@ -[production] defaults > 0.2% firefox >= 78 ios >= 15.6 not dead not OperaMini all - -[development] -supports es6-module diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index 705d26e0ab..ced5ecfe88 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -9,7 +9,9 @@ services: environment: RAILS_ENV: development NODE_ENV: development + VITE_RUBY_HOST: 0.0.0.0 BIND: 0.0.0.0 + BOOTSNAP_CACHE_DIR: /tmp REDIS_HOST: redis REDIS_PORT: '6379' DB_HOST: db @@ -20,12 +22,14 @@ services: ES_HOST: es ES_PORT: '9200' LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000 + LOCAL_DOMAIN: ${LOCAL_DOMAIN:-localhost:3000} + VITE_DEV_SERVER_PUBLIC: ${VITE_DEV_SERVER_PUBLIC:-localhost:3036} # Overrides default command so things don't shut down after the process ends. command: sleep infinity ports: - - '127.0.0.1:3000:3000' - - '127.0.0.1:3035:3035' - - '127.0.0.1:4000:4000' + - '3000:3000' + - '3036:3036' + - '4000:4000' networks: - external_network - internal_network diff --git a/.dockerignore b/.dockerignore index 41da718049..9d990ab9ce 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,3 +20,9 @@ postgres14 redis elasticsearch chart +.yarn/ +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/.env.production.sample b/.env.production.sample index 3dd66abae4..15004b9d0d 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -43,14 +43,13 @@ ES_PASS=password # Make sure to use `bundle exec rails secret` to generate secrets # ------- SECRET_KEY_BASE= -OTP_SECRET= # Encryption secrets # ------------------ # Must be available (and set to same values) for all server processes # These are private/secret values, do not share outside hosting environment # Use `bin/rails db:encryption:init` to generate fresh secrets -# Do not change these secrets once in use, as this would cause data loss and other issues +# Do NOT change these secrets once in use, as this would cause data loss and other issues # ------------------ # ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY= # ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT= @@ -79,6 +78,9 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= S3_ALIAS_HOST=files.example.com +# Optional list of hosts that are allowed to serve media for your instance +# EXTRA_MEDIA_HOSTS=https://data.example1.com,https://data.example2.com + # IP and session retention # ----------------------- # Make sure to modify the scheduling of ip_cleanup_scheduler in config/sidekiq.yml @@ -86,3 +88,24 @@ S3_ALIAS_HOST=files.example.com # ----------------------- IP_RETENTION_PERIOD=31556952 SESSION_RETENTION_PERIOD=31556952 + +# Fetch All Replies Behavior +# -------------------------- +# When a user expands a post (DetailedStatus view), fetch all of its replies +# (default: false) +FETCH_REPLIES_ENABLED=false + +# Period to wait between fetching replies (in minutes) +FETCH_REPLIES_COOLDOWN_MINUTES=15 + +# Period to wait after a post is first created before fetching its replies (in minutes) +FETCH_REPLIES_INITIAL_WAIT_MINUTES=5 + +# Max number of replies to fetch - total, recursively through a whole reply tree +FETCH_REPLIES_MAX_GLOBAL=1000 + +# Max number of replies to fetch - for a single post +FETCH_REPLIES_MAX_SINGLE=500 + +# Max number of replies Collection pages to fetch - total +FETCH_REPLIES_MAX_PAGES=500 diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index d4930e1f52..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,13 +0,0 @@ -/build/** -/coverage/** -/db/** -/lib/** -/log/** -/node_modules/** -/nonobox/** -/public/** -!/public/embed.js -/spec/** -/tmp/** -/vendor/** -!.eslintrc.js diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 93ff1d7b59..0000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,367 +0,0 @@ -// @ts-check -const { defineConfig } = require('eslint-define-config'); - -module.exports = defineConfig({ - root: true, - - extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:jsx-a11y/recommended', - 'plugin:import/recommended', - 'plugin:promise/recommended', - 'plugin:jsdoc/recommended', - ], - - env: { - browser: true, - node: true, - es6: true, - }, - - parser: '@typescript-eslint/parser', - - plugins: [ - 'react', - 'jsx-a11y', - 'import', - 'promise', - '@typescript-eslint', - 'formatjs', - ], - - parserOptions: { - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - ecmaVersion: 2021, - requireConfigFile: false, - babelOptions: { - configFile: false, - presets: ['@babel/react', '@babel/env'], - }, - }, - - settings: { - react: { - version: 'detect', - }, - 'import/ignore': [ - 'node_modules', - '\\.(css|scss|json)$', - ], - 'import/resolver': { - typescript: {}, - }, - }, - - rules: { - 'consistent-return': 'error', - 'dot-notation': 'error', - eqeqeq: ['error', 'always', { 'null': 'ignore' }], - 'indent': ['error', 2], - 'jsx-quotes': ['error', 'prefer-single'], - 'semi': ['error', 'always'], - 'no-catch-shadow': 'error', - 'no-console': [ - 'warn', - { - allow: [ - 'error', - 'warn', - ], - }, - ], - 'no-empty': ['error', { "allowEmptyCatch": true }], - 'no-restricted-properties': [ - 'error', - { property: 'substring', message: 'Use .slice instead of .substring.' }, - { property: 'substr', message: 'Use .slice instead of .substr.' }, - ], - 'no-restricted-syntax': [ - 'error', - { - // eslint-disable-next-line no-restricted-syntax - selector: 'Literal[value=/•/], JSXText[value=/•/]', - // eslint-disable-next-line no-restricted-syntax - message: "Use '·' (middle dot) instead of '•' (bullet)", - }, - ], - 'no-unused-expressions': 'error', - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': [ - 'error', - { - vars: 'all', - args: 'after-used', - destructuredArrayIgnorePattern: '^_', - ignoreRestSiblings: true, - }, - ], - 'valid-typeof': 'error', - - 'react/jsx-filename-extension': ['error', { extensions: ['.jsx', 'tsx'] }], - 'react/jsx-boolean-value': 'error', - 'react/display-name': 'off', - 'react/jsx-fragments': ['error', 'syntax'], - 'react/jsx-equals-spacing': 'error', - 'react/jsx-no-bind': 'error', - 'react/jsx-no-useless-fragment': 'error', - 'react/jsx-no-target-blank': 'off', - 'react/jsx-tag-spacing': 'error', - 'react/jsx-uses-react': 'off', // not needed with new JSX transform - 'react/jsx-wrap-multilines': 'error', - 'react/react-in-jsx-scope': 'off', // not needed with new JSX transform - 'react/self-closing-comp': 'error', - - // recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46 - 'jsx-a11y/click-events-have-key-events': 'off', - 'jsx-a11y/label-has-associated-control': 'off', - 'jsx-a11y/media-has-caption': 'off', - 'jsx-a11y/no-autofocus': 'off', - // recommended rule is: - // 'jsx-a11y/no-interactive-element-to-noninteractive-role': [ - // 'error', - // { - // tr: ['none', 'presentation'], - // canvas: ['img'], - // }, - // ], - 'jsx-a11y/no-interactive-element-to-noninteractive-role': 'off', - // recommended rule is: - // 'jsx-a11y/no-noninteractive-tabindex': [ - // 'error', - // { - // tags: [], - // roles: ['tabpanel'], - // allowExpressionValues: true, - // }, - // ], - 'jsx-a11y/no-noninteractive-tabindex': 'off', - // recommended is full 'error' - 'jsx-a11y/no-static-element-interactions': [ - 'warn', - { - handlers: [ - 'onClick', - ], - }, - ], - - // See https://github.com/import-js/eslint-plugin-import/blob/v2.29.1/config/recommended.js - 'import/extensions': [ - 'error', - 'always', - { - js: 'never', - jsx: 'never', - mjs: 'never', - ts: 'never', - tsx: 'never', - }, - ], - 'import/first': 'error', - 'import/newline-after-import': 'error', - 'import/no-anonymous-default-export': 'error', - 'import/no-extraneous-dependencies': [ - 'error', - { - devDependencies: [ - '.eslintrc.js', - 'config/webpack/**', - 'app/javascript/mastodon/performance.js', - 'app/javascript/mastodon/test_setup.js', - 'app/javascript/**/__tests__/**', - ], - }, - ], - 'import/no-amd': 'error', - 'import/no-commonjs': 'error', - 'import/no-import-module-exports': 'error', - 'import/no-relative-packages': 'error', - 'import/no-self-import': 'error', - 'import/no-useless-path-segments': 'error', - 'import/no-webpack-loader-syntax': 'error', - - 'import/order': [ - 'error', - { - alphabetize: { order: 'asc' }, - 'newlines-between': 'always', - groups: [ - 'builtin', - 'external', - 'internal', - 'parent', - ['index', 'sibling'], - 'object', - ], - pathGroups: [ - // React core packages - { - pattern: '{react,react-dom,react-dom/client,prop-types}', - group: 'builtin', - position: 'after', - }, - // I18n - { - pattern: '{react-intl,intl-messageformat}', - group: 'builtin', - position: 'after', - }, - // Common React utilities - { - pattern: '{classnames,react-helmet,react-router,react-router-dom}', - group: 'external', - position: 'before', - }, - // Immutable / Redux / data store - { - pattern: '{immutable,@reduxjs/toolkit,react-redux,react-immutable-proptypes,react-immutable-pure-component}', - group: 'external', - position: 'before', - }, - // Internal packages - { - pattern: '{mastodon/**}', - group: 'internal', - position: 'after', - }, - ], - pathGroupsExcludedImportTypes: [], - }, - ], - - 'promise/always-return': 'off', - 'promise/catch-or-return': [ - 'error', - { - allowFinally: true, - }, - ], - 'promise/no-callback-in-promise': 'off', - 'promise/no-nesting': 'off', - 'promise/no-promise-in-callback': 'off', - - 'formatjs/blocklist-elements': 'error', - 'formatjs/enforce-default-message': ['error', 'literal'], - 'formatjs/enforce-description': 'off', // description values not currently used - 'formatjs/enforce-id': 'off', // Explicit IDs are used in the project - 'formatjs/enforce-placeholders': 'off', // Issues in short_number.jsx - 'formatjs/enforce-plural-rules': 'error', - 'formatjs/no-camel-case': 'off', // disabledAccount is only non-conforming - 'formatjs/no-complex-selectors': 'error', - 'formatjs/no-emoji': 'error', - 'formatjs/no-id': 'off', // IDs are used for translation keys - 'formatjs/no-invalid-icu': 'error', - 'formatjs/no-literal-string-in-jsx': 'off', // Should be looked at, but mainly flagging punctuation outside of strings - 'formatjs/no-multiple-whitespaces': 'error', - 'formatjs/no-offset': 'error', - 'formatjs/no-useless-message': 'error', - 'formatjs/prefer-formatted-message': 'error', - 'formatjs/prefer-pound-in-plural': 'error', - - 'jsdoc/check-types': 'off', - 'jsdoc/no-undefined-types': 'off', - 'jsdoc/require-jsdoc': 'off', - 'jsdoc/require-param-description': 'off', - 'jsdoc/require-property-description': 'off', - 'jsdoc/require-returns-description': 'off', - 'jsdoc/require-returns': 'off', - }, - - overrides: [ - { - files: [ - '.eslintrc.js', - '*.config.js', - '.*rc.js', - 'ide-helper.js', - 'config/webpack/**/*', - 'config/formatjs-formatter.js', - ], - - env: { - commonjs: true, - }, - - parserOptions: { - sourceType: 'script', - }, - - rules: { - 'import/no-commonjs': 'off', - }, - }, - { - files: [ - '**/*.ts', - '**/*.tsx', - ], - - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/strict-type-checked', - 'plugin:@typescript-eslint/stylistic-type-checked', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:jsx-a11y/recommended', - 'plugin:import/recommended', - 'plugin:import/typescript', - 'plugin:promise/recommended', - 'plugin:jsdoc/recommended-typescript', - ], - - parserOptions: { - projectService: true, - tsconfigRootDir: __dirname, - }, - - rules: { - // Disable formatting rules that have been enabled in the base config - 'indent': 'off', - - // This is not needed as we use noImplicitReturns, which handles this in addition to understanding types - 'consistent-return': 'off', - - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - - '@typescript-eslint/consistent-type-definitions': ['warn', 'interface'], - '@typescript-eslint/consistent-type-exports': 'error', - '@typescript-eslint/consistent-type-imports': 'error', - "@typescript-eslint/prefer-nullish-coalescing": ['error', { ignorePrimitives: { boolean: true } }], - "@typescript-eslint/no-restricted-imports": [ - "warn", - { - "name": "react-redux", - "importNames": ["useSelector", "useDispatch"], - "message": "Use typed hooks `useAppDispatch` and `useAppSelector` instead." - } - ], - "@typescript-eslint/restrict-template-expressions": ['warn', { allowNumber: true }], - 'jsdoc/require-jsdoc': 'off', - - // Those rules set stricter rules for TS files - // to enforce better practices when converting from JS - 'import/no-default-export': 'warn', - 'react/prefer-stateless-function': 'warn', - 'react/function-component-definition': ['error', { namedComponents: 'arrow-function' }], - 'react/jsx-uses-react': 'off', // not needed with new JSX transform - 'react/react-in-jsx-scope': 'off', // not needed with new JSX transform - 'react/prop-types': 'off', - }, - }, - { - files: [ - '**/__tests__/*.js', - '**/__tests__/*.jsx', - ], - - env: { - jest: true, - }, - } - ], -}); diff --git a/.github/.well-known/funding-manifest-urls b/.github/.well-known/funding-manifest-urls new file mode 100644 index 0000000000..7014c08f7a --- /dev/null +++ b/.github/.well-known/funding-manifest-urls @@ -0,0 +1 @@ +https://joinmastodon.org/funding.json diff --git a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml index f897a7d7da..bb4b71dd9f 100644 --- a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml @@ -48,8 +48,8 @@ body: attributes: label: Mastodon version description: | - This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` - placeholder: v4.3.0 + This is displayed at the bottom of the About page, eg. `v4.4.0-beta.1` + placeholder: v4.4.0-beta.1 validations: required: true - type: input @@ -57,7 +57,7 @@ body: label: Browser name and version description: | What browser are you using when getting this bug? Please specify the version as well. - placeholder: Firefox 131.0.0 + placeholder: Firefox 139.0.0 validations: required: true - type: input @@ -65,7 +65,7 @@ body: label: Operating system description: | What OS are you running? Please specify the version as well. - placeholder: macOS 15.0.1 + placeholder: macOS 15.5 validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml index a66f5c1076..c6d7e8e16b 100644 --- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml @@ -49,8 +49,8 @@ body: attributes: label: Mastodon version description: | - This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` - placeholder: v4.3.0 + This is displayed at the bottom of the About page, eg. `v4.4.0-beta.1` + placeholder: v4.4.0-beta.1 validations: required: false - type: textarea @@ -60,7 +60,7 @@ body: Any additional technical details you may have, like logs or error traces value: | If this is happening on your own Mastodon server, please fill out those: - - Ruby version: (from `ruby --version`, eg. v3.3.5) - - Node.js version: (from `node --version`, eg. v20.18.0) + - Ruby version: (from `ruby --version`, eg. v3.4.4) + - Node.js version: (from `node --version`, eg. v22.16.0) validations: required: false diff --git a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml index eeb74b160b..004e74bf42 100644 --- a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml +++ b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml @@ -50,7 +50,7 @@ body: label: Mastodon version description: | This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` - placeholder: v4.3.0 + placeholder: v4.4.0-beta.1 validations: required: false - type: textarea @@ -60,9 +60,9 @@ body: Details about your environment, like how Mastodon is deployed, if containers are used, version numbers, etc. value: | Please at least include those informations: - - Operating system: (eg. Ubuntu 22.04) - - Ruby version: (from `ruby --version`, eg. v3.3.5) - - Node.js version: (from `node --version`, eg. v20.18.0) + - Operating system: (eg. Ubuntu 24.04.2) + - Ruby version: (from `ruby --version`, eg. v3.4.4) + - Node.js version: (from `node --version`, eg. v22.16.0) validations: required: false - type: textarea diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 8a10676283..1850a45bbc 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -15,6 +15,8 @@ // 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'], + // The types are now included in recent versions,we ignore them here until we upgrade and remove the dependency + ignoreDeps: ['@types/emoji-mart'], packageRules: [ { // Require Dependency Dashboard Approval for major version bumps of these node packages @@ -23,26 +25,12 @@ 'tesseract.js', // Requires code changes 'react-hotkeys', // Requires code changes - // Requires Webpacker upgrade or replacement - '@svgr/webpack', - '@types/webpack', - 'babel-loader', - 'compression-webpack-plugin', - 'css-loader', - 'imports-loader', - 'mini-css-extract-plugin', - 'postcss-loader', - 'sass-loader', - 'terser-webpack-plugin', - 'webpack', - 'webpack-assets-manifest', - 'webpack-bundle-analyzer', - 'webpack-dev-server', - 'webpack-cli', - // react-router: Requires manual upgrade 'history', 'react-router-dom', + + // react-spring: Requires manual upgrade when upgrading react + '@react-spring/web', ], matchUpdateTypes: ['major'], dependencyDashboardApproval: true, @@ -51,7 +39,6 @@ // Require Dependency Dashboard Approval for major version bumps of these Ruby packages matchManagers: ['bundler'], matchPackageNames: [ - 'rack', // Needs to be synced with Rails version 'strong_migrations', // Requires manual upgrade 'sidekiq', // Requires manual upgrade 'sidekiq-unique-jobs', // Requires manual upgrades and sync with Sidekiq version @@ -97,7 +84,13 @@ { // Group all eslint-related packages with `eslint` in the same PR matchManagers: ['npm'], - matchPackageNames: ['eslint', 'eslint-*', '@typescript-eslint/*'], + matchPackageNames: [ + 'eslint', + 'eslint-*', + 'typescript-eslint', + '@eslint/*', + 'globals', + ], matchUpdateTypes: ['patch', 'minor'], groupName: 'eslint (non-major)', }, diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index 6204319a63..260730004c 100644 --- a/.github/workflows/build-container-image.yml +++ b/.github/workflows/build-container-image.yml @@ -1,14 +1,9 @@ on: workflow_call: inputs: - platforms: - required: true - type: string cache: type: boolean default: true - use_native_arm64_builder: - type: boolean push_to_images: type: string version_prerelease: @@ -24,42 +19,36 @@ on: file_to_build: type: string +# This builds multiple images with one runner each, allowing us to build for multiple architectures +# using Github's runners. +# The two-step process is adapted form: +# https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners jobs: + # Build each (amd64 and arm64) image separately build-image: - runs-on: ubuntu-latest + runs-on: ${{ startsWith(matrix.platform, 'linux/arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - if: contains(inputs.platforms, 'linux/arm64') && !inputs.use_native_arm64_builder + - name: Prepare + env: + PUSH_TO_IMAGES: ${{ inputs.push_to_images }} + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + # Transform multi-line variable into comma-separated variable + image_names=${PUSH_TO_IMAGES//$'\n'/,} + echo "IMAGE_NAMES=${image_names%,}" >> $GITHUB_ENV - uses: docker/setup-buildx-action@v3 id: buildx - if: ${{ !(inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')) }} - - - name: Start a local Docker Builder - if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64') - run: | - docker run --rm -d --name buildkitd -p 1234:1234 --privileged moby/buildkit:latest --addr tcp://0.0.0.0:1234 - - - uses: docker/setup-buildx-action@v3 - id: buildx-native - if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64') - with: - driver: remote - endpoint: tcp://localhost:1234 - platforms: linux/amd64 - append: | - - endpoint: tcp://${{ vars.DOCKER_BUILDER_HETZNER_ARM64_01_HOST }}:13865 - platforms: linux/arm64 - name: mastodon-docker-builder-arm64-01 - driver-opts: - - servername=mastodon-docker-builder-arm64-01 - env: - BUILDER_NODE_1_AUTH_TLS_CACERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CACERT }} - BUILDER_NODE_1_AUTH_TLS_CERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CERT }} - BUILDER_NODE_1_AUTH_TLS_KEY: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_KEY }} - name: Log in to Docker Hub if: contains(inputs.push_to_images, 'tootsuite') @@ -76,16 +65,18 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 + - name: Docker meta id: meta + uses: docker/metadata-action@v5 if: ${{ inputs.push_to_images != '' }} with: images: ${{ inputs.push_to_images }} flavor: ${{ inputs.flavor }} - tags: ${{ inputs.tags }} labels: ${{ inputs.labels }} - - uses: docker/build-push-action@v6 + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 with: context: . file: ${{ inputs.file_to_build }} @@ -93,11 +84,87 @@ jobs: MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }} MASTODON_VERSION_METADATA=${{ inputs.version_metadata }} SOURCE_COMMIT=${{ github.sha }} - platforms: ${{ inputs.platforms }} + platforms: ${{ matrix.platform }} provenance: false - builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }} push: ${{ inputs.push_to_images != '' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} cache-from: ${{ inputs.cache && 'type=gha' || '' }} cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }} + outputs: type=image,"name=${{ env.IMAGE_NAMES }}",push-by-digest=true,name-canonical=true,push=${{ inputs.push_to_images != '' }} + + - name: Export digest + if: ${{ inputs.push_to_images != '' }} + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + if: ${{ inputs.push_to_images != '' }} + uses: actions/upload-artifact@v4 + with: + # `hashFiles` is used to disambiguate between streaming and non-streaming images + name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + # Then merge the docker images into a single one + merge-images: + if: ${{ inputs.push_to_images != '' }} + runs-on: ubuntu-24.04 + needs: + - build-image + + env: + PUSH_TO_IMAGES: ${{ inputs.push_to_images }} + + steps: + - uses: actions/checkout@v4 + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + # `hashFiles` is used to disambiguate between streaming and non-streaming images + pattern: digests-${{ hashFiles(inputs.file_to_build) }}-* + merge-multiple: true + + - name: Log in to Docker Hub + if: contains(inputs.push_to_images, 'tootsuite') + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to the GitHub Container registry + if: contains(inputs.push_to_images, 'ghcr.io') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + if: ${{ inputs.push_to_images != '' }} + with: + images: ${{ inputs.push_to_images }} + flavor: ${{ inputs.flavor }} + tags: ${{ inputs.tags }} + labels: ${{ inputs.labels }} + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + echo "$PUSH_TO_IMAGES" | xargs -I{} \ + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '{}@sha256:%s ' *) + + - name: Inspect image + run: | + echo "$PUSH_TO_IMAGES" | xargs -i{} \ + docker buildx imagetools inspect {}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml index 7c6f74b457..4a56f720e1 100644 --- a/.github/workflows/build-nightly.yml +++ b/.github/workflows/build-nightly.yml @@ -26,8 +26,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon @@ -48,8 +46,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon-streaming diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml index d3bc8e5df8..418993475f 100644 --- a/.github/workflows/build-push-pr.yml +++ b/.github/workflows/build-push-pr.yml @@ -32,8 +32,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true push_to_images: | ghcr.io/mastodon/mastodon version_metadata: ${{ needs.compute-suffix.outputs.metadata }} @@ -49,8 +47,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true push_to_images: | ghcr.io/mastodon/mastodon-streaming version_metadata: ${{ needs.compute-suffix.outputs.metadata }} diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml index da9a458282..db17b2169c 100644 --- a/.github/workflows/build-releases.yml +++ b/.github/workflows/build-releases.yml @@ -13,8 +13,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true push_to_images: | tootsuite/mastodon ghcr.io/mastodon/mastodon @@ -30,12 +28,9 @@ jobs: secrets: inherit build-image-streaming: - if: startsWith(github.ref, 'refs/tags/v4.3.') uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true push_to_images: | tootsuite/mastodon-streaming ghcr.io/mastodon/mastodon-streaming diff --git a/.github/workflows/build-security.yml b/.github/workflows/build-security.yml index 1e2455d3d9..d3cb4e5e0a 100644 --- a/.github/workflows/build-security.yml +++ b/.github/workflows/build-security.yml @@ -24,8 +24,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon @@ -46,8 +44,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon-streaming diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index 4f87f0fe5f..c46090c1b5 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -18,7 +18,7 @@ permissions: jobs: check-i18n: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 0000000000..4e6179bc77 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,41 @@ +name: 'Chromatic' + +on: + push: + branches-ignore: + - renovate/* + - stable-* + paths: + - 'package.json' + - 'yarn.lock' + - '**/*.js' + - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' + - '**/*.css' + - '**/*.scss' + - '.github/workflows/chromatic.yml' + +jobs: + chromatic: + name: Run Chromatic + runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Build Storybook + run: yarn build-storybook + + - name: Run Chromatic + uses: chromaui/action@v12 + with: + # ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + zip: true + storybookBuildDir: 'storybook-static' diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index ef28258cca..6d9a058629 100644 --- a/.github/workflows/crowdin-download-stable.yml +++ b/.github/workflows/crowdin-download-stable.yml @@ -50,7 +50,7 @@ jobs: # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@v7.0.5 + uses: peter-evans/create-pull-request@v7.0.6 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index e9b909b9e0..d247a514d9 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -52,7 +52,7 @@ jobs: # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@v7.0.5 + uses: peter-evans/create-pull-request@v7 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 4f4d917d15..d0d79d9199 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -14,6 +14,7 @@ on: - config/locales/devise.en.yml - config/locales/doorkeeper.en.yml - .github/workflows/crowdin-upload.yml + workflow_dispatch: jobs: upload-translations: diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 95fcd56942..c1385bf789 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -40,4 +40,4 @@ jobs: uses: ./.github/actions/setup-javascript - name: Stylelint - run: yarn lint:css -f github + run: yarn lint:css --custom-formatter @csstools/stylelint-formatter-github diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 7d31a5e20e..86e9af23e7 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -11,7 +11,7 @@ on: - 'tsconfig.json' - '.nvmrc' - '.prettier*' - - '.eslint*' + - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' - '**/*.ts' @@ -25,7 +25,7 @@ on: - 'tsconfig.json' - '.nvmrc' - '.prettier*' - - '.eslint*' + - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' - '**/*.ts' @@ -44,7 +44,7 @@ jobs: uses: ./.github/actions/setup-javascript - name: ESLint - run: yarn lint:js --max-warnings 0 + run: yarn workspaces foreach --all --parallel run lint:js --max-warnings 0 - name: Typecheck run: yarn typecheck diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index 277e456146..87f8aee24e 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -9,6 +9,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' + - 'bin/rubocop' - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' @@ -19,6 +20,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' + - 'bin/rubocop' - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' diff --git a/.github/workflows/test-image-build.yml b/.github/workflows/test-image-build.yml index 980e071897..121b8b0ddf 100644 --- a/.github/workflows/test-image-build.yml +++ b/.github/workflows/test-image-build.yml @@ -8,6 +8,7 @@ on: - .github/workflows/test-image-build.yml - Dockerfile - streaming/Dockerfile + - .dockerignore permissions: contents: read @@ -20,7 +21,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64 # Testing only on native platform so it is performant cache: true build-image-streaming: @@ -31,5 +31,4 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64 # Testing only on native platform so it is performant cache: true diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index e9e43ac9e8..0699e6c9ef 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -40,4 +40,4 @@ jobs: uses: ./.github/actions/setup-javascript - name: JavaScript testing - run: yarn jest --reporters github-actions summary + run: yarn test:js diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 306191fb8e..7aab34f0cf 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -64,7 +64,6 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: true RAILS_ENV: test BUNDLE_CLEAN: true BUNDLE_FROZEN: true @@ -78,6 +77,18 @@ jobs: - name: Set up Ruby environment uses: ./.github/actions/setup-ruby + - name: Ensure no errors with `db:prepare` + run: | + bin/rails db:drop + bin/rails db:prepare + bin/rails db:migrate + + - name: Ensure no errors with `db:prepare` and SKIP_POST_DEPLOYMENT_MIGRATIONS + run: | + bin/rails db:drop + SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:prepare + bin/rails db:migrate + - name: Test "one step migration" flow run: | bin/rails db:drop diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 770cd72a1b..63d3172504 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -49,7 +49,7 @@ jobs: public/assets public/packs public/packs-test - tmp/cache/webpacker + tmp/cache/vite 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 }} @@ -63,7 +63,7 @@ jobs: - name: Archive asset artifacts run: | - tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* + tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json - uses: actions/upload-artifact@v4 if: matrix.mode == 'test' @@ -107,7 +107,7 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }} + COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} RAILS_ENV: test ALLOW_NOPAM: true PAM_ENABLED: true @@ -125,6 +125,7 @@ jobs: matrix: ruby-version: - '3.2' + - '3.3' - '.ruby-version' steps: - uses: actions/checkout@v4 @@ -142,7 +143,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg imagemagick libpam-dev + additional-system-dependencies: ffmpeg libpam-dev - name: Load database schema run: | @@ -166,15 +167,15 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage/lcov/*.lcov env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - test-libvips: - name: Libvips tests - runs-on: ubuntu-24.04 + test-imagemagick: + name: ImageMagick tests + runs-on: ubuntu-latest needs: - build @@ -207,7 +208,7 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }} + COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} RAILS_ENV: test ALLOW_NOPAM: true PAM_ENABLED: true @@ -219,13 +220,14 @@ jobs: CAS_ENABLED: true BUNDLE_WITH: 'pam_authentication test' GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} - MASTODON_USE_LIBVIPS: true + MASTODON_USE_LIBVIPS: false strategy: fail-fast: false matrix: ruby-version: - '3.2' + - '3.3' - '.ruby-version' steps: - uses: actions/checkout@v4 @@ -243,7 +245,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg libpam-dev + additional-system-dependencies: ffmpeg imagemagick libpam-dev - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' @@ -252,7 +254,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage/lcov/mastodon.lcov env: @@ -293,7 +295,6 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: true RAILS_ENV: test BUNDLE_WITH: test LOCAL_DOMAIN: localhost:3000 @@ -304,6 +305,7 @@ jobs: matrix: ruby-version: - '3.2' + - '3.3' - '.ruby-version' steps: @@ -322,7 +324,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg imagemagick + additional-system-dependencies: ffmpeg - name: Set up Javascript environment uses: ./.github/actions/setup-javascript @@ -330,6 +332,21 @@ jobs: - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' + - name: Cache Playwright Chromium browser + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-browsers-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + + - name: Install Playwright Chromium browser (with deps) + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: yarn run playwright install --with-deps chromium + + - name: Install Playwright Chromium browser deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: yarn run playwright install-deps chromium + - run: bin/rspec spec/system --tag streaming --tag js - name: Archive logs @@ -343,7 +360,7 @@ jobs: uses: actions/upload-artifact@v4 if: failure() with: - name: e2e-screenshots + name: e2e-screenshots-${{ matrix.ruby-version }} path: tmp/capybara/ test-search: @@ -408,7 +425,6 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: true RAILS_ENV: test BUNDLE_WITH: test ES_ENABLED: true @@ -420,6 +436,7 @@ jobs: matrix: ruby-version: - '3.2' + - '3.3' - '.ruby-version' search-image: - docker.elastic.co/elasticsearch/elasticsearch:7.17.13 @@ -441,7 +458,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg imagemagick + additional-system-dependencies: ffmpeg - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.gitignore b/.gitignore index a74317bd7d..db63bc07f0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,10 +21,11 @@ /public/system /public/assets /public/packs +/public/packs-dev /public/packs-test .env .env.production -/node_modules/ +node_modules/ /build/ # Ignore Vagrant files @@ -74,3 +75,6 @@ docker-compose.override.yml # Ignore local-only rspec configuration .rspec-local + +*storybook.log +storybook-static diff --git a/.nvmrc b/.nvmrc index 35d2d08ea1..4a203c23d8 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.12 +22.17 diff --git a/.prettierignore b/.prettierignore index 6b2f0c1889..098dac6717 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,10 +18,6 @@ !/log/.keep /tmp /coverage -/public/system -/public/assets -/public/packs -/public/packs-test .env .env.production .env.development @@ -60,9 +56,11 @@ docker-compose.override.yml /public/packs /public/packs-test /public/system +/public/vite* # Ignore emoji map file /app/javascript/mastodon/features/emoji/emoji_map.json +/app/javascript/mastodon/features/emoji/emoji_data.json # Ignore locale files /app/javascript/mastodon/locales/*.json @@ -83,3 +81,6 @@ AUTHORS.md # Process a few selected JS files !lint-staged.config.js + +# Ignore config YAML files that include ERB/ruby code prettier does not understand +/config/email.yml diff --git a/.prettierrc.js b/.prettierrc.js index af39b253f6..65ec869c33 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,4 +1,4 @@ module.exports = { singleQuote: true, jsxSingleQuote: true -} +}; diff --git a/.rubocop.yml b/.rubocop.yml index ebeed6ea49..1bbba515af 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,6 +18,7 @@ inherit_from: - .rubocop/rspec_rails.yml - .rubocop/rspec.yml - .rubocop/style.yml + - .rubocop/i18n.yml - .rubocop/custom.yml - .rubocop_todo.yml - .rubocop/strict.yml @@ -26,9 +27,10 @@ inherit_mode: merge: - Exclude -require: +plugins: + - rubocop-capybara + - rubocop-i18n + - rubocop-performance - rubocop-rails - rubocop-rspec - rubocop-rspec_rails - - rubocop-performance - - rubocop-capybara diff --git a/.rubocop/i18n.yml b/.rubocop/i18n.yml new file mode 100644 index 0000000000..de395d3a79 --- /dev/null +++ b/.rubocop/i18n.yml @@ -0,0 +1,12 @@ +I18n/RailsI18n: + Enabled: true + Exclude: + - 'config/**/*' + - 'db/**/*' + - 'lib/**/*' + - 'spec/**/*' +I18n/GetText: + Enabled: false + +I18n/RailsI18n/DecorateStringFormattingUsingInterpolation: + Enabled: false diff --git a/.rubocop/naming.yml b/.rubocop/naming.yml index da6ad4ac57..37d3a17efd 100644 --- a/.rubocop/naming.yml +++ b/.rubocop/naming.yml @@ -1,3 +1,6 @@ --- Naming/BlockForwarding: EnforcedStyle: explicit + +Naming/PredicateMethod: + Enabled: false diff --git a/.rubocop/rails.yml b/.rubocop/rails.yml index ae31c1f266..bbd172e656 100644 --- a/.rubocop/rails.yml +++ b/.rubocop/rails.yml @@ -2,6 +2,9 @@ Rails/BulkChangeTable: Enabled: false # Conflicts with strong_migrations features +Rails/Delegate: + Enabled: false + Rails/FilePath: EnforcedStyle: arguments diff --git a/.rubocop/rspec.yml b/.rubocop/rspec.yml index d2d2f8325d..27f703444e 100644 --- a/.rubocop/rspec.yml +++ b/.rubocop/rspec.yml @@ -23,5 +23,6 @@ RSpec/SpecFilePathFormat: ActivityPub: activitypub DeepL: deepl FetchOEmbedService: fetch_oembed_service + OAuth: oauth OEmbedController: oembed_controller OStatus: ostatus diff --git a/.rubocop/style.yml b/.rubocop/style.yml index 03e35a70ac..f59340d452 100644 --- a/.rubocop/style.yml +++ b/.rubocop/style.yml @@ -1,4 +1,7 @@ --- +Style/ArrayIntersect: + Enabled: false + Style/ClassAndModuleChildren: Enabled: false @@ -19,6 +22,13 @@ Style/HashSyntax: EnforcedShorthandSyntax: either EnforcedStyle: ruby19_no_mixed_keys +Style/IfUnlessModifier: + Exclude: + - '**/*.haml' + +Style/KeywordArgumentsMerging: + Enabled: false + Style/NumericLiterals: AllowedPatterns: - \d{4}_\d{2}_\d{2}_\d{6} @@ -37,6 +47,9 @@ Style/RedundantFetchBlock: Style/RescueStandardError: EnforcedStyle: implicit +Style/SafeNavigationChainLength: + Enabled: false + Style/SymbolArray: Enabled: false @@ -45,3 +58,6 @@ Style/TrailingCommaInArrayLiteral: Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: comma + +Style/WordArray: + MinSize: 3 # Override default of 2 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index a6e51d6aee..4ec92f3412 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.66.1. +# using RuboCop version 1.77.0. # 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 @@ -8,7 +8,7 @@ Lint/NonLocalExitFromIterator: Exclude: - - 'app/helpers/jsonld_helper.rb' + - 'app/helpers/json_ld_helper.rb' # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: @@ -27,87 +27,13 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 27 -Rails/OutputSafety: - Exclude: - - 'config/initializers/simple_form.rb' - # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowedVars. +# Configuration parameters: AllowedVars, DefaultToNil. Style/FetchEnvVar: Exclude: - - 'app/lib/translation_service.rb' - - 'config/environments/production.rb' - - 'config/initializers/2_limited_federation_mode.rb' - - 'config/initializers/3_omniauth.rb' - - 'config/initializers/cache_buster.rb' - - 'config/initializers/devise.rb' - 'config/initializers/paperclip.rb' - - 'config/initializers/vapid.rb' - - 'lib/tasks/repo.rake' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns. -# SupportedStyles: annotated, template, unannotated -# AllowedMethods: redirect -Style/FormatStringToken: - Exclude: - - 'config/initializers/devise.rb' - - 'lib/paperclip/color_extractor.rb' # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. Style/GuardClause: Enabled: false - -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/HashTransformValues: - Exclude: - - 'app/serializers/rest/web_push_subscription_serializer.rb' - - 'app/services/import_service.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/MapToHash: - Exclude: - - 'app/models/status.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: literals, strict -Style/MutableConstant: - Exclude: - - 'app/models/tag.rb' - - 'app/services/delete_account_service.rb' - - 'lib/mastodon/migration_warning.rb' - -# Configuration parameters: AllowedMethods. -# AllowedMethods: respond_to_missing? -Style/OptionalBooleanParameter: - Exclude: - - 'app/helpers/jsonld_helper.rb' - - 'app/lib/admin/system_check/message.rb' - - 'app/lib/request.rb' - - 'app/lib/webfinger.rb' - - 'app/services/block_domain_service.rb' - - 'app/services/fetch_resource_service.rb' - - 'app/workers/domain_block_worker.rb' - - 'app/workers/unfollow_follow_worker.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: short, verbose -Style/PreferredHashMethods: - Exclude: - - 'config/initializers/paperclip.rb' - -# This cop supports safe autocorrection (--autocorrect). -Style/RedundantConstantBase: - Exclude: - - 'config/environments/production.rb' - - 'config/initializers/sidekiq.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: WordRegex. -# SupportedStyles: percent, brackets -Style/WordArray: - EnforcedStyle: percent - MinSize: 3 diff --git a/.ruby-version b/.ruby-version index 9c25013dbb..f9892605c7 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.6 +3.4.4 diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000000..72321cbf3f --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,31 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../app/javascript/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-docs', + '@storybook/addon-a11y', + '@storybook/addon-vitest', + ], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + staticDirs: [ + './static', + // We need to manually specify the assets because of the symlink in public/sw.js + ...[ + 'avatars', + 'emoji', + 'headers', + 'sounds', + 'badge.png', + 'loading.gif', + 'loading.png', + 'oops.gif', + 'oops.png', + ].map((path) => ({ from: `../public/${path}`, to: `/${path}` })), + ], +}; + +export default config; diff --git a/.storybook/manager.ts b/.storybook/manager.ts new file mode 100644 index 0000000000..53dfaa15ab --- /dev/null +++ b/.storybook/manager.ts @@ -0,0 +1,7 @@ +import { addons } from 'storybook/manager-api'; + +import theme from './storybook-theme'; + +addons.setConfig({ + theme, +}); diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000000..0a4f196752 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,18 @@ + diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000000..f25d0547e8 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from 'react'; + +import { IntlProvider } from 'react-intl'; + +import { MemoryRouter, Route } from 'react-router'; + +import { configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; + +import type { Preview } from '@storybook/react-vite'; +import { initialize, mswLoader } from 'msw-storybook-addon'; +import { action } from 'storybook/actions'; + +import type { LocaleData } from '@/mastodon/locales'; +import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers'; +import { defaultMiddleware } from '@/mastodon/store/store'; +import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; + +// If you want to run the dark theme during development, +// you can change the below to `/application.scss` +import '../app/javascript/styles/mastodon-light.scss'; + +const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { + query: { as: 'json' }, +}); + +// Initialize MSW +initialize({ + onUnhandledRequest: unhandledRequestHandler, +}); + +const preview: Preview = { + // Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + globalTypes: { + locale: { + description: 'Locale for the story', + toolbar: { + title: 'Locale', + icon: 'globe', + items: Object.keys(localeFiles).map((path) => + path.replace('/mastodon/locales/', '').replace('.json', ''), + ), + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + locale: 'en', + }, + decorators: [ + (Story, { parameters }) => { + const { state = {} } = parameters; + let reducer = rootReducer; + if (typeof state === 'object' && state) { + reducer = reducerWithInitialState(state as Record); + } + const store = configureStore({ + reducer, + middleware(getDefaultMiddleware) { + return getDefaultMiddleware(defaultMiddleware); + }, + }); + return ( + + + + ); + }, + (Story, { globals }) => { + const currentLocale = (globals.locale as string) || 'en'; + const [messages, setMessages] = useState< + Record> + >({}); + const currentLocaleData = messages[currentLocale]; + + useEffect(() => { + async function loadLocaleData() { + const { default: localeFile } = (await import( + `@/mastodon/locales/${currentLocale}.json` + )) as { default: LocaleData['messages'] }; + setMessages((prevLocales) => ({ + ...prevLocales, + [currentLocale]: localeFile, + })); + } + if (!currentLocaleData) { + void loadLocaleData(); + } + }, [currentLocale, currentLocaleData]); + + return ( + + + + ); + }, + (Story) => ( + + + { + if (location.pathname !== '/') { + action(`route change to ${location.pathname}`)(location); + } + return null; + }} + /> + + ), + ], + loaders: [mswLoader], + parameters: { + layout: 'centered', + + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + }, + + state: {}, + + docs: {}, + + msw: { + handlers: mockHandlers, + }, + }, +}; + +export default preview; diff --git a/.storybook/static/mockServiceWorker.js b/.storybook/static/mockServiceWorker.js new file mode 100644 index 0000000000..de7bc0f292 --- /dev/null +++ b/.storybook/static/mockServiceWorker.js @@ -0,0 +1,344 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.10.2' +const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + */ +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @returns {Promise} + */ +async function getResponse(event, client, requestId) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/.storybook/storybook-addon-vitest.d.ts b/.storybook/storybook-addon-vitest.d.ts new file mode 100644 index 0000000000..86852faca9 --- /dev/null +++ b/.storybook/storybook-addon-vitest.d.ts @@ -0,0 +1,7 @@ +// The addon package.json incorrectly exports types, so we need to override them here. +// See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76 +declare module '@storybook/addon-vitest/vitest-plugin' { + export * from '@storybook/addon-vitest/dist/vitest-plugin/index'; +} + +export {}; diff --git a/.storybook/storybook-theme.ts b/.storybook/storybook-theme.ts new file mode 100644 index 0000000000..7a72ba1c75 --- /dev/null +++ b/.storybook/storybook-theme.ts @@ -0,0 +1,7 @@ +import { create } from 'storybook/theming'; + +export default create({ + base: 'light', + brandTitle: 'Mastodon Storybook', + brandImage: 'https://joinmastodon.org/logos/wordmark-black-text.svg', +}); diff --git a/.storybook/vitest.setup.ts b/.storybook/vitest.setup.ts new file mode 100644 index 0000000000..a08badd02f --- /dev/null +++ b/.storybook/vitest.setup.ts @@ -0,0 +1,8 @@ +import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; +import { setProjectAnnotations } from '@storybook/react-vite'; + +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc166a48a..59a8a92682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,462 @@ All notable changes to this project will be documented in this file. +## [4.4.0] - 2025-07-08 + +### Added + +- **Add “Followers you know” widget to user profiles and hover cards** (#34652, #34678, #34681, #34697, #34699, #34769, #34774 and #34914 by @diondiondion) +- **Add featured tab to profiles on web UI and rework pinned posts** (#34405, #34483, #34491, #34754, #34855, #34858, #34868, #34869, #34927, #34995, #35056 and #34931 by @ChaosExAnima, @ClearlyClaire, @Gargron, and @diondiondion) +- Add endorsed accounts to featured tab in web UI (#34421 and #34568 by @Gargron)\ + This also includes the following new REST API endpoints: + - `GET /api/v1/accounts/:id/endorsements`: https://docs.joinmastodon.org/methods/accounts/#endorsements + - `POST /api/v1/accounts/:id/endorse`: https://docs.joinmastodon.org/methods/accounts/#endorse + - `POST /api/v1/accounts/:id/unendorse`: https://docs.joinmastodon.org/methods/accounts/#unendorse +- Add ability to add and remove hashtags from featured tags in web UI (#34489, #34887, and #34490 by @ClearlyClaire and @Gargron)\ + This is achieved through the new REST API endpoints: + - `POST /api/v1/tags/:id/feature`: https://docs.joinmastodon.org/methods/tags/#feature + - `POST /api/v1/tags/:id/unfeature`: https://docs.joinmastodon.org/methods/tags/#unfeature +- Add reminder when about to post without alt text in web UI (#33760 and #33784 by @Gargron) +- Add a warning in Web UI when composing a post when the selected and detected language are different (#33042, #33683, #33700, #33724, #33770, and #34193 by @ClearlyClaire and @Gargron) +- Add support for verifying and displaying remote quote posts (#34370, #34481, #34510, #34551, #34480, #34479, #34553, #34584, #34623, #34738, #34766, #34770, #34772, #34773, #34786, #34790, #34864, #34957, #34961, #35016, #35022, #35036, #34946, #34945 and #34958 by @ClearlyClaire and @diondiondion)\ + Support for verifying remote quotes according to [FEP-044f](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) and displaying them in the Web UI has been implemented.\ + Quoting other people is not implemented yet, and it is currently not possible to mark your own posts as allowing quotes. However, a new “Who can quote” setting has been added to the “Posting defaults” section of the user settings. This setting allows you to set a default that will be used for new posts made on Mastodon 4.5 and newer, when quote posts will be fully implemented.\ + In the REST API, quote posts are represented by a new `quote` attribute on `Status` and `StatusEdit` entities: https://docs.joinmastodon.org/entities/StatusEdit/#quote https://docs.joinmastodon.org/entities/Status/#quote +- Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, #34820, #34997, #35170, #35174 and #35174 by @ChaosExAnima and @ClearlyClaire)\ + Rules are now shown in the user’s language, if a translation has been set.\ + In the REST API, `Rule` entities now have a new `translations` attribute: https://docs.joinmastodon.org/entities/Rule/#translations +- Add emoji from Twemoji 15.1.0, including in the emoji picker/completion (#33395, #34321, #34620, and #34677 by @ChaosExAnima, @ClearlyClaire, @TheEssem, and @eramdam) +- Add option to remove account from followers in web UI (#34488 by @Gargron) +- Add relationship tags to profiles and hover cards in web UI (#34467 and #34792 by @Gargron and @diondiondion) +- Add ability to open posts in a new tab by middle-clicking in web UI (#32988, #33106, #33419, and #34700 by @ClearlyClaire, @Gargron, and @tribela) +- Add new filter action to blur media (#34256 by @ClearlyClaire)\ + In the REST API, this adds a new possible value of `blur` to the `filter_action` attribute: https://docs.joinmastodon.org/entities/Filter/#filter_action +- Add dropdown menu to hashtag links in web UI (#34393 by @Gargron) +- **Add server setting to allow referrer** (#33214, #33239, #33903, and #34731 by @ChaosExAnima, @ClearlyClaire, @Gargron, and @renchap)\ + In order to protect the privacy of users of small or thematic servers, Mastodon previously avoided transmitting referrer information when clicking outside links, which unfortunately made Mastodon completely invisible to other websites, even though the privacy implications on large generic servers are very limited.\ + Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path. +- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron) +- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm) +- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\ + Server administrators can now fill in Terms of Service and notify their users of upcoming changes. +- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\ + This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\ + When `BULK_SMTP_SERVER` is set, this group of variables is used instead of the regular ones for sending announcement notification emails and Terms of Service notification emails. +- **Add age verification on sign-up** (#34150, #34663, and #34636 by @ClearlyClaire and @Gargron)\ + Server administrators now have a setting to set a minimum age requirement for creating a new server, asking users for their date of birth. The date of birth is checked against the minimum age requirement server-side but not stored.\ + The following REST API changes have been made to accommodate this: + - `registrations.min_age` has been added to the `Instance` entity: https://docs.joinmastodon.org/entities/Instance/#registrations-min_age + - the `date_of_birth` parameter has been added to the account creation API: https://docs.joinmastodon.org/methods/accounts/#create +- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron) +- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron) +- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm) +- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\ + This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org). +- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\ + This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users. +- Add Server Moderation Notes (#31529 by @ThisIsMissEm) +- Add loading spinner to “Post” button when sending a post (#35153 by @diondiondion) +- Add option to use system scrollbar styling (#32117 by @vmstan) +- Add hover cards to follow suggestions (#33749 by @ClearlyClaire) +- Add `t` hotkey for post translations (#33441 by @ClearlyClaire) +- Add timestamp to all announcements in Web UI (#18329 by @ClearlyClaire) +- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk) +- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\ + Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action. +- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033, #35109 and #35278 by @oneiros)\ + For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests). +- Add experimental Async Refreshes API (#34918 by @oneiros) +- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\ + This experimental feature causes the server to recursively fetch replies in background tasks whenever a user opens a remote post. This happens asynchronously and the client is currently not notified of the existence of new replies, which will thus only be displayed the next time this post’s context gets requested.\ + This feature needs to be explicitly enabled server-side by setting `FETCH_REPLIES_ENABLED` environment variable to `true`. +- Add simple feature flag system through the `EXPERIMENTAL_FEATURES` environment variable (#34038 and #34124 by @oneiros)\ + This allows enabling comma-separated feature flags for experimental features.\ + The current supported feature flags are `inbound_quotes`, `fasp` and `http_message_signatures`. +- Add `dev:populate_sample_data` rake task to populate test data (#34676, #34733, #34771, #34787, and #34791 by @ClearlyClaire and @diondiondion) +- Add support for displaying fallback representation when receiving MathML (#27107 by @4e554c4c) +- Add warning for Elasticsearch index analyzers mismatch (#34515 and #34567 by @ClearlyClaire and @Gargron) +- Add `-only-mapping` option to `tootctl search deploy` (#34466 and #34566 by @Gargron) +- Add server-side support for grouping account sign-up notifications (#34298 by @ClearlyClaire) +- Add `registrations.reason_required` attribute to `/api/v2/instance` response (#34280 by @ClearlyClaire)\ + This is documented at https://docs.joinmastodon.org/entities/Instance/#registrations-reason_required +- Add `EXTRA_MEDIA_HOSTS` environment variable to add extra hosts to Content-Security-Policy (#34184 by @shleeable) +- Add `Deprecation` headers on deprecated API endpoints (#34262 and #34397 by @ClearlyClaire)\ + This is documented at https://docs.joinmastodon.org/api/guidelines/#deprecations +- Add `about`, `privacy_policy` and `terms_of_service` URLs to `/api/v2/instance` (#33849 by @ClearlyClaire) +- Add API to delete media attachments that are not in use (#33991 and #34035 by @ClearlyClaire and @ThisIsMissEm)\ + `DELETE /api/v1/media/:id`: https://docs.joinmastodon.org/methods/media/#delete +- Add optional `delete_media` parameter to `DELETE /api/v1/statuses/:id` (#33988 by @ClearlyClaire)\ + This is documented at https://docs.joinmastodon.org/methods/statuses/#delete +- Add `og:locale` to expose status language in OpenGraph previews (#34012 by @ThisIsMissEm) +- Add `-skip-filled-timeline` option to `tootctl feed build` to skip half-filled feeds (#33844 by @ClearlyClaire) +- Add support for changing the base Docker registry with the `BASE_REGISTRY` `ARG` (#33712 by @wolfspyre) +- Add an optional metric exporter (#33734, #33840, #34172, #34192, #34223, and #35005 by @oneiros and @renchap)\ + Optionally enable the `prometheus_exporter` ruby gem (see https://github.com/discourse/prometheus_exporter) to collect and expose metrics. See the documentation for all the details: https://docs.joinmastodon.org/admin/config/#prometheus +- Add `attribution_domains` attribute to `PATCH /api/v1/accounts/update_credentials` (#32730 by @c960657)\ + This is documented at https://docs.joinmastodon.org/methods/accounts/#update_credentials +- Add support for standard WebPush in addition to previous draft (#33572, #33528, and #33587 by @ClearlyClaire and @p1gp1g) +- Add support for Active Record query log tags (#33342 by @renchap) +- Add OTel trace & span IDs to logs (#33339 and #33362 by @renchap) +- Add missing `on_delete: :cascade` foreign keys option to various database columns (#33175 by @mjankowski) +- Add explicit migration breakpoints (#33089 by @ClearlyClaire) +- Add rel alternate rss/json links to pages for tags (#33179 by @mjankowski) +- Add media attachment description limit to instance API response (#33153 by @mjankowski)\ + This adds the `configuration.media_attachments.description_limit` attribute to the `Instance` entity, documented at https://docs.joinmastodon.org/entities/Instance/#description_limit +- Add `maxlength` to registration reason input (#33162 by @mjankowski) +- Add `REPLICA_PREPARED_STATEMENTS` and `REPLICA_DB_TASKS` environment variables (#32908 by @shleeable)\ + See documentation at https://docs.joinmastodon.org/admin/scaling/#read-replicas +- Add a range of reserved usernames to reduce potential misuse by malicious actors (#32828 by @jmking-iftas) +- Add operations on relays to the admin audit log (#32819 by @ThisIsMissEm) +- Add userinfo OAuth endpoint (#32548 by @ThisIsMissEm) +- Add the standard VCS attributes to OpenTelemetry spans (#32904 by @renchap) +- Add endpoint to remove web push subscription (#32626 by @oneiros)\ + Mastodon now sets a new `Unsubscribe-URL` request header when performing WebPush requests. This URL can be used by the WebPush server to disable the WebPush subscription on Mastodon’s side in case of unfixable errors. +- Add missing content warning text to RSS feeds (#32406 by @mjankowski) +- Add Swiss German to languages dropdown (#29281 by @FlohEinstein) + +### Changed + +- Change design of navigation panel in Web UI, change layout on narrow screens (#34910, #34987, #35017, #34986, #35029, #35065, #35067, #35072, #35074, #35075, #35101, #35173, #35183, #35193 and #35225 by @ClearlyClaire, @Gargron, and @diondiondion) +- Change design of lists in web UI (#32881, #33054, and #33036 by @Gargron) +- Change design of edit media modal in web UI (#33516, #33702, #33725, #33725, #33771, and #34345 by @Gargron) +- Change design of audio player in web UI (#34520, #34740, #34865, #34929, #34933, and #35034 by @ClearlyClaire, @Gargron, and @diondiondion) +- Change design of interaction modal in web UI (#33278 by @Gargron) +- Change list timelines to reflect added and removed users retroactively (#32930 by @Gargron) +- Change account search to be more forgiving of spaces (#34455 by @Gargron) +- Change unfollow button label from “Mutual” to “Unfollow” in web UI (#34392 by @Gargron) +- Change “Specific people” to “Private mention” in menu in web UI (#33963 by @Gargron) +- Change "Explore" to "Trending" and remove explanation banners (#34985 by @Gargron) +- Change media attachments of moderated posts to not be accessible (#34872 by @Gargron) + Moderators will still be able to access them while they are kept, but they won't be accessible to the public in the meantime. +- Change language names in compose box language picker to be localized (#33402 by @c960657) +- Change onboarding flow in web UI (#32998, #33119, #33471 and #34962 by @ClearlyClaire and @Gargron) +- Change Advanced Web UI to use the new main menu instead of the “Getting started” column (#35117 by @diondiondion) +- Change emoji categories in admin interface to be ordered by name (#33630 by @ShadowJonathan) +- Change design of rich text elements in web UI (#32633 by @Gargron) +- Change wording of “single choice” to “pick one” in poll authoring form (#32397 by @ThisIsMissEm) +- Change returned favorite and boost counts to use those provided by the remote server, if available (#32620, #34594, #34618, and #34619 by @ClearlyClaire and @sneakers-the-rat) +- Change label of favourite notifications on private mentions (#31659 by @ClearlyClaire) +- Change wording of "discard draft?" confirmation dialogs (#35192 by @diondiondion) +- Change `libvips` to be enabled by default in place of ImageMagick (#34741 and #34753 by @ClearlyClaire and @diondiondion) +- Change avatar and header size limits from 2MB to 8MB when using libvips (#33002 by @Gargron) +- Change search to use query params in web UI (#32949 and #33670 by @ClearlyClaire and @Gargron) +- Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, #34732, #35007, #35035 and #35177 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap) +- Change account creation API to forbid creation from user tokens (#34828 by @ThisIsMissEm) +- Change `/api/v2/instance` to be enabled without authentication when limited federation mode is enabled (#34576 by @ClearlyClaire) +- Change `DEFAULT_LOCALE` to not override unauthenticated users’ browser language (#34535 by @ClearlyClaire)\ + If you want to preserve the old behavior, you can add `FORCE_DEFAULT_LOCALE=true`. +- Change size of profile picture on profile page from 90px to 92px (#34807 by @larouxn) +- Change passthrough video processing to emit `moov` atom at start of video (#34726 by @ClearlyClaire) +- Change kerning to be disabled for Japanese text to preserve monospaced alignment for readability (#34448 by @nagutabby) +- Change error handling of various endpoints to return 422 instead of 500 on invalid parameters (#29308, #34434, and #34452 by @danielmbrasil and @mjankowski) +- Change Web UI to use `