diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 0000000000..df8e92b247 --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,59 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: true +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: true +:exclude_sti_subclasses: true +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_complete_foreign_keys: false +:show_foreign_keys: false +:show_indexes: false +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:active_admin: false +:command: +:debug: false +:hide_default_column_types: '' +:hide_limit_column_types: 'integer,boolean' +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: + - app/models +:require: [] +:root_dir: + - '' + +:show_check_constraints: false diff --git a/.browserslistrc b/.browserslistrc index 54dd3aaf34..0135379d6e 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,7 +1,6 @@ -[production] defaults -not IE 11 +> 0.2% +firefox >= 78 +ios >= 15.6 not dead - -[development] -supports es6-module +not OperaMini all diff --git a/.bundler-audit.yml b/.bundler-audit.yml deleted file mode 100644 index 0671df390f..0000000000 --- a/.bundler-audit.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -ignore: - # devise-two-factor advisory about brute-forcing TOTP - # We have rate-limits on authentication endpoints in place (including second - # factor verification) since Mastodon v3.2.0 - - CVE-2024-0227 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b5e72a0973..3aa0bbf7da 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,20 +1,18 @@ # For details, see https://github.com/devcontainers/images/tree/main/src/ruby -FROM mcr.microsoft.com/devcontainers/ruby:1-3.2-bullseye +FROM mcr.microsoft.com/devcontainers/ruby:1-3.3-bookworm -# Install Rails -# RUN gem install rails webdrivers +# Install node version from .nvmrc +WORKDIR /app +COPY .nvmrc . +RUN /bin/bash --login -i -c "nvm install" -ARG NODE_VERSION="20" -RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1" +# Install additional OS packages +RUN apt-get update && \ + export DEBIAN_FRONTEND=noninteractive && \ + apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev -# [Optional] Uncomment this section to install additional OS packages. -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libpam-dev +# Disable download prompt for Corepack +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -# [Optional] Uncomment this line to install additional gems. -RUN gem install foreman - -# [Optional] Uncomment this line to install global node packages. -RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && corepack enable" 2>&1 - -COPY welcome-message.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt +# 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/codespaces/devcontainer.json b/.devcontainer/codespaces/devcontainer.json index ca9156fdaa..d2358657f6 100644 --- a/.devcontainer/codespaces/devcontainer.json +++ b/.devcontainer/codespaces/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Mastodon on GitHub Codespaces", - "dockerComposeFile": "../docker-compose.yml", + "dockerComposeFile": "../compose.yaml", "service": "app", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", @@ -23,6 +23,8 @@ } }, + "remoteUser": "root", + "otherPortsAttributes": { "onAutoForward": "silent" }, @@ -37,7 +39,7 @@ }, "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", - "postCreateCommand": ".devcontainer/post-create.sh", + "postCreateCommand": "bin/setup", "waitFor": "postCreateCommand", "customizations": { diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/compose.yaml similarity index 84% rename from .devcontainer/docker-compose.yml rename to .devcontainer/compose.yaml index 40dc72c12d..5da1ec3a24 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/compose.yaml @@ -1,16 +1,16 @@ -version: '3' - services: app: + working_dir: /workspaces/mastodon/ build: - context: . - dockerfile: Dockerfile + context: .. + dockerfile: .devcontainer/Dockerfile volumes: - - ../..:/workspaces:cached + - ..:/workspaces/mastodon:cached environment: RAILS_ENV: development NODE_ENV: development BIND: 0.0.0.0 + BOOTSNAP_CACHE_DIR: /tmp REDIS_HOST: redis REDIS_PORT: '6379' DB_HOST: db @@ -21,12 +21,13 @@ services: ES_HOST: es ES_PORT: '9200' LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000 + LOCAL_DOMAIN: ${LOCAL_DOMAIN:-localhost:3000} # 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' + - '3035:3035' + - '4000:4000' networks: - external_network - internal_network @@ -70,7 +71,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.5.2 + image: libretranslate/libretranslate:v1.6.2 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fa8d6542c1..fb88f7801f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Mastodon on local machine", - "dockerComposeFile": "docker-compose.yml", + "dockerComposeFile": "compose.yaml", "service": "app", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", @@ -23,12 +23,14 @@ } }, + "remoteUser": "root", + "otherPortsAttributes": { "onAutoForward": "silent" }, "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", - "postCreateCommand": ".devcontainer/post-create.sh", + "postCreateCommand": "bin/setup", "waitFor": "postCreateCommand", "customizations": { diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh deleted file mode 100755 index 82a2ccbb6c..0000000000 --- a/.devcontainer/post-create.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash - -set -e # Fail the whole script on first error - -# Fetch Ruby gem dependencies -bundle config path 'vendor/bundle' -bundle config with 'development test' -bundle install - -# Make Gemfile.lock pristine again -git checkout -- Gemfile.lock - -# Fetch Javascript dependencies -corepack prepare -yarn install --immutable - -# [re]create, migrate, and seed the test database -RAILS_ENV=test ./bin/rails db:setup - -# [re]create, migrate, and seed the development database -RAILS_ENV=development ./bin/rails db:setup - -# Precompile assets for development -RAILS_ENV=development ./bin/rails assets:precompile - -# Precompile assets for test -RAILS_ENV=test ./bin/rails assets:precompile diff --git a/.devcontainer/welcome-message.txt b/.devcontainer/welcome-message.txt index 488cf92857..dbc19c910c 100644 --- a/.devcontainer/welcome-message.txt +++ b/.devcontainer/welcome-message.txt @@ -1,8 +1,7 @@ -πŸ‘‹ Welcome to "Mastodon" in GitHub Codespaces! +πŸ‘‹ Welcome to your Mastodon Dev Container! -πŸ› οΈ Your environment is fully setup with all the required software. +πŸ› οΈ Your environment is fully setup with all the required software. -πŸ” To explore VS Code to its fullest, search using the Command Palette (Cmd/Ctrl + Shift + P or F1). - -πŸ“ Edit away, run your app as usual, and we'll automatically make it available for you to access. +πŸ’₯ Run `bin/dev` to start the application processes. +πŸ₯Ό Run `RAILS_ENV=test bin/rails assets:precompile && RAILS_ENV=test bin/rspec` to run the test suite. 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.development b/.env.development new file mode 100644 index 0000000000..0330da8377 --- /dev/null +++ b/.env.development @@ -0,0 +1,4 @@ +# Required by ActiveRecord encryption feature +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=fkSxKD2bF396kdQbrP1EJ7WbU7ZgNokR +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=r0hvVmzBVsjxC7AMlwhOzmtc36ZCOS1E +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=PhdFyyfy5xJ7WVd2lWBpcPScRQHzRTNr diff --git a/.env.production.sample b/.env.production.sample index 0bf01bdc36..a311ad5f8d 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,25 @@ 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= +# 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 +# ------------------ +# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY= +# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT= +# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY= + # 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= @@ -68,6 +79,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 @@ -75,3 +89,27 @@ 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 + +# Maximum allowed character count +MAX_CHARS=5555 diff --git a/.env.test b/.env.test index 871f85d136..52379687b4 100644 --- a/.env.test +++ b/.env.test @@ -5,3 +5,9 @@ LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_HTTPS=true # Elasticsearch ES_PREFIX=test + +# Secret values required by ActiveRecord encryption feature +# Use `bin/rails db:encryption:init` to generate fresh secrets +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=test_determinist_key_DO_NOT_USE_IN_PRODUCTION +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=test_salt_DO_NOT_USE_IN_PRODUCTION +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=test_primary_key_DO_NOT_USE_IN_PRODUCTION 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 e2d16a54a8..0000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,380 +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, - }, - - globals: { - ATTACHMENT_HOST: false, - }, - - 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-case-declarations': 'off', - 'no-catch-shadow': 'error', - 'no-console': [ - 'warn', - { - allow: [ - 'error', - 'warn', - ], - }, - ], - 'no-empty': 'off', - '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-self-assign': 'off', - '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/no-deprecated': 'off', - '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/main/src/index.js - 'jsx-a11y/accessible-emoji': 'warn', - '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-element-interactions': [ - // 'error', - // { - // body: ['onError', 'onLoad'], - // iframe: ['onError', 'onLoad'], - // img: ['onError', 'onLoad'], - // }, - // ], - 'jsx-a11y/no-noninteractive-element-interactions': [ - 'warn', - { - handlers: [ - 'onClick', - ], - }, - ], - // recommended rule is: - // 'jsx-a11y/no-noninteractive-tabindex': [ - // 'error', - // { - // tags: [], - // roles: ['tabpanel'], - // allowExpressionValues: true, - // }, - // ], - 'jsx-a11y/no-noninteractive-tabindex': 'off', - 'jsx-a11y/no-onchange': 'warn', - // recommended is full 'error' - 'jsx-a11y/no-static-element-interactions': [ - 'warn', - { - handlers: [ - 'onClick', - ], - }, - ], - - // See https://github.com/import-js/eslint-plugin-import/blob/main/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,react-redux,react-immutable-proptypes,react-immutable-pure-component,reselect}', - 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', - 'plugin:prettier/recommended', - ], - - parserOptions: { - project: true, - tsconfigRootDir: __dirname, - }, - - rules: { - '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 } }], - - '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/ISSUE_TEMPLATE/3.troubleshooting.yml b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml new file mode 100644 index 0000000000..fa9bfc7c80 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml @@ -0,0 +1,74 @@ +name: Deployment troubleshooting +description: | + You are a server administrator and you are encountering a technical issue during installation, upgrade or operations of Mastodon. +labels: ['status/to triage'] +type: 'Troubleshooting' +body: + - type: markdown + attributes: + value: | + Make sure that you are submitting a new bug that was not previously reported or already fixed. + + Please use a concise and distinct title for the issue. + - type: textarea + attributes: + label: Steps to reproduce the problem + description: What were you trying to do? + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: input + attributes: + label: Expected behaviour + description: What should have happened? + validations: + required: true + - type: input + attributes: + label: Actual behaviour + description: What happened? + validations: + required: true + - type: textarea + attributes: + label: Detailed description + validations: + required: false + - type: input + attributes: + label: Mastodon instance + description: The address of the Mastodon instance where you experienced the issue + placeholder: mastodon.social + validations: + required: true + - type: input + 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 + validations: + required: false + - type: textarea + attributes: + label: Environment + description: | + 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.4.1) + - Node.js version: (from `node --version`, eg. v20.18.0) + validations: + required: false + - type: textarea + attributes: + label: Technical details + description: | + Any additional technical details you may have, like logs or error traces + validations: + required: false diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 07fd4d08d3..808adc7de6 100644 --- a/.github/actions/setup-javascript/action.yml +++ b/.github/actions/setup-javascript/action.yml @@ -23,7 +23,7 @@ runs: shell: bash run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml index 3a6fba9402..3e232f134c 100644 --- a/.github/actions/setup-ruby/action.yml +++ b/.github/actions/setup-ruby/action.yml @@ -14,7 +14,7 @@ runs: shell: bash run: | sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev ${{ inputs.additional-system-dependencies }} + sudo apt-get install -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }} - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/codecov.yml b/.github/codecov.yml index 5532c49618..21af6d0d45 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -1,13 +1,13 @@ +comment: false # Do not leave PR comments coverage: status: project: default: - # Github status check is not blocking + # GitHub status check is not blocking informational: true patch: default: - # Github status check is not blocking + # GitHub status check is not blocking informational: true -comment: - # Only write a comment in PR if there are changes - require_changes: true +github_checks: + annotations: false diff --git a/.github/renovate.json5 b/.github/renovate.json5 index dab99829a1..e638b9c548 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -2,10 +2,12 @@ $schema: 'https://docs.renovatebot.com/renovate-schema.json', extends: [ 'config:recommended', + 'customManagers:dockerfileVersions', ':labels(dependencies)', ':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 @@ -13,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 @@ -59,7 +63,7 @@ dependencyDashboardApproval: true, }, { - // Update Github Actions and Docker images weekly + // Update GitHub Actions and Docker images weekly matchManagers: ['github-actions', 'dockerfile', 'docker-compose'], extends: ['schedule:weekly'], }, @@ -86,6 +90,7 @@ }, { // Update devDependencies every week, with one grouped PR + matchManagers: ['npm'], matchDepTypes: 'devDependencies', matchUpdateTypes: ['patch', 'minor'], groupName: 'devDependencies (non-major)', @@ -94,8 +99,13 @@ { // Group all eslint-related packages with `eslint` in the same PR matchManagers: ['npm'], - matchPackageNames: ['eslint'], - matchPackagePrefixes: ['eslint-', '@typescript-eslint/'], + matchPackageNames: [ + 'eslint', + 'eslint-*', + 'typescript-eslint', + '@eslint/*', + 'globals', + ], matchUpdateTypes: ['patch', 'minor'], groupName: 'eslint (non-major)', }, @@ -111,7 +121,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'], @@ -125,6 +136,27 @@ ], groupName: null, // We dont want them to belong to any group }, + { + // Group all RuboCop packages with `rubocop` in the same PR + matchManagers: ['bundler'], + matchPackageNames: ['rubocop', 'rubocop-*'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'RuboCop (non-major)', + }, + { + // Group all RSpec packages with `rspec` in the same PR + matchManagers: ['bundler'], + matchPackageNames: ['rspec', 'rspec-*'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'RSpec (non-major)', + }, + { + // Group all opentelemetry-ruby packages in the same PR + matchManagers: ['bundler'], + matchPackageNames: ['opentelemetry-*'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'opentelemetry-ruby (non-major)', + }, // Add labels depending on package manager { matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] }, { matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] }, diff --git a/.github/stylelint-matcher.json b/.github/stylelint-matcher.json deleted file mode 100644 index cdfd4086bd..0000000000 --- a/.github/stylelint-matcher.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "stylelint", - "pattern": [ - { - "regexp": "^([^\\s].*)$", - "file": 1 - }, - { - "regexp": "^\\s+((\\d+):(\\d+))?\\s+(βœ–|Γ—)\\s+(.*)\\s{2,}(.*)$", - "line": 2, - "column": 3, - "message": 5, - "code": 6, - "loop": true - } - ] - } - ] -} diff --git a/.github/workflows/build-security.yml b/.github/workflows/build-security.yml new file mode 100644 index 0000000000..d3cb4e5e0a --- /dev/null +++ b/.github/workflows/build-security.yml @@ -0,0 +1,60 @@ +name: Build security nightly container image +on: + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + compute-suffix: + runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' + steps: + - id: version_vars + env: + TZ: Etc/UTC + run: | + echo mastodon_version_prerelease=nightly.$(date --date='next day' +'%Y-%m-%d')-security>> $GITHUB_OUTPUT + outputs: + prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }} + + build-image: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: Dockerfile + cache: false + push_to_images: | + tootsuite/mastodon + ghcr.io/mastodon/mastodon + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + tags: | + type=raw,value=edge + type=raw,value=nightly + type=raw,value=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit + + build-image-streaming: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: streaming/Dockerfile + cache: false + push_to_images: | + tootsuite/mastodon-streaming + ghcr.io/mastodon/mastodon-streaming + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + tags: | + type=raw,value=edge + type=raw,value=nightly + type=raw,value=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index bbc31598c7..c96e4429af 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -1,19 +1,22 @@ name: Bundler Audit on: + merge_group: push: - branches-ignore: - - 'dependabot/**' + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' paths: - 'Gemfile*' - '.ruby-version' - - '.bundler-audit.yml' - '.github/workflows/bundler-audit.yml' pull_request: paths: - 'Gemfile*' - '.ruby-version' - - '.bundler-audit.yml' - '.github/workflows/bundler-audit.yml' schedule: @@ -23,12 +26,17 @@ jobs: security: runs-on: ubuntu-latest + env: + BUNDLE_ONLY: development + steps: - name: Clone repository uses: actions/checkout@v4 - - name: Set up Ruby environment - uses: ./.github/actions/setup-ruby + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true - name: Run bundler-audit - run: bundle exec bundler-audit + run: bin/bundler-audit check --update diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index ceb385933b..63529e4f16 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -2,9 +2,19 @@ name: Check i18n on: push: - branches: [main] + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' pull_request: - branches: [main] + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' env: RAILS_ENV: test @@ -14,7 +24,7 @@ permissions: jobs: check-i18n: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -31,18 +41,18 @@ jobs: git diff --exit-code - name: Check locale file normalization - run: bundle exec i18n-tasks check-normalized + run: bin/i18n-tasks check-normalized - name: Check for unused strings - run: bundle exec i18n-tasks unused + run: bin/i18n-tasks unused - name: Check for missing strings in English YML run: | - bundle exec i18n-tasks add-missing -l en + bin/i18n-tasks add-missing -l en git diff --exit-code - name: Check for wrong string interpolations - run: bundle exec i18n-tasks check-consistent-interpolations + run: bin/i18n-tasks check-consistent-interpolations - name: Check that all required locale files exist - run: bundle exec rake repo:check_locales_files + run: bin/rake repo:check_locales_files diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6fb93b7fef..827629d8e6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,11 +1,21 @@ name: 'CodeQL' on: + merge_group: push: - branches: ['main'] + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' pull_request: - # The branches below must be a subset of the branches above - branches: ['main'] + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' schedule: - cron: '22 6 * * 1' diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml new file mode 100644 index 0000000000..6d9a058629 --- /dev/null +++ b/.github/workflows/crowdin-download-stable.yml @@ -0,0 +1,69 @@ +name: Crowdin / Download translations (stable branches) +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + download-translations-stable: + runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Increase Git http.postBuffer + # This is needed due to a bug in Ubuntu's cURL version? + # See https://github.com/orgs/community/discussions/55820 + run: | + git config --global http.version HTTP/1.1 + git config --global http.postBuffer 157286400 + + # Download the translation files from Crowdin + - name: crowdin action + uses: crowdin/github-action@v2 + with: + upload_sources: false + upload_translations: false + download_translations: true + crowdin_branch_name: ${{ github.base_ref || github.ref_name }} + push_translations: false + create_pull_request: false + env: + CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + # As the files are extracted from a Docker container, they belong to root:root + # We need to fix this before the next steps + - name: Fix file permissions + run: sudo chown -R runner:docker . + + # This is needed to run the normalize step + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Run i18n normalize task + run: bin/i18n-tasks normalize + + # Create or update the pull request + - name: Create Pull Request + 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)' + author: 'GitHub Actions ' + body: | + New Crowdin translations, automated with GitHub Actions + + See `.github/workflows/crowdin-download.yml` + + This PR will be updated every day with new translations. + + Due to a limitation in GitHub Actions, checks are not running on this PR without manual action. + If you want to run the checks, then close and re-open it. + branch: i18n/crowdin/translations-${{ github.base_ref || github.ref_name }} + base: ${{ github.base_ref || github.ref_name }} + labels: i18n diff --git a/.github/workflows/elasticsearch-settings/elasticsearch.yml b/.github/workflows/elasticsearch-settings/elasticsearch.yml deleted file mode 100644 index f6d5efa079..0000000000 --- a/.github/workflows/elasticsearch-settings/elasticsearch.yml +++ /dev/null @@ -1,98 +0,0 @@ -# ======================== Elasticsearch Configuration ========================= -# -# NOTE: Elasticsearch comes with reasonable defaults for most settings. -# Before you set out to tweak and tune the configuration, make sure you -# understand what are you trying to accomplish and the consequences. -# -# The primary way of configuring a node is via this file. This template lists -# the most important settings you may want to configure for a production cluster. -# -# Please consult the documentation for further information on configuration options: -# https://www.elastic.co/guide/en/elasticsearch/reference/index.html -# -# ---------------------------------- Cluster ----------------------------------- -# -# Use a descriptive name for your cluster: -# -#cluster.name: my-application -# -# ------------------------------------ Node ------------------------------------ -# -# Use a descriptive name for the node: -# -#node.name: node-1 -# -# Add custom attributes to the node: -# -#node.attr.rack: r1 -# -# ----------------------------------- Paths ------------------------------------ -# -# Path to directory where to store the data (separate multiple locations by comma): -# -path.data: /var/lib/elasticsearch -# -# Path to log files: -# -path.logs: /var/log/elasticsearch -# -# ----------------------------------- Memory ----------------------------------- -# -# Lock the memory on startup: -# -#bootstrap.memory_lock: true -# -# Make sure that the heap size is set to about half the memory available -# on the system and that the owner of the process is allowed to use this -# limit. -# -# Elasticsearch performs poorly when the system is swapping the memory. -# -# ---------------------------------- Network ----------------------------------- -# -# By default Elasticsearch is only accessible on localhost. Set a different -# address here to expose this node on the network: -# -#network.host: 192.168.0.1 -# -# By default Elasticsearch listens for HTTP traffic on the first free port it -# finds starting at 9200. Set a specific HTTP port here: -# -#http.port: 9200 -# -# For more information, consult the network module documentation. -# -# --------------------------------- Discovery ---------------------------------- -# -# Pass an initial list of hosts to perform discovery when this node is started: -# The default list of hosts is ["127.0.0.1", "[::1]"] -# -#discovery.seed_hosts: ["host1", "host2"] -# -# Bootstrap the cluster using an initial set of master-eligible nodes: -# -#cluster.initial_master_nodes: ["node-1", "node-2"] -# -# For more information, consult the discovery and cluster formation module documentation. -# -# ---------------------------------- Various ----------------------------------- -# -# Require explicit names when deleting indices: -# -#action.destructive_requires_name: true -# -# ---------------------------------- Security ---------------------------------- -# -# *** WARNING *** -# -# Elasticsearch security features are not enabled by default. -# These features are free, but require configuration changes to enable them. -# This means that users don’t have to provide credentials and can get full access -# to the cluster. Network connections are also not encrypted. -# -# To protect your data, we strongly encourage you to enable the Elasticsearch security features. -# Refer to the following documentation for instructions. -# -xpack.security.enabled: false -discovery.type: single-node -# https://www.elastic.co/guide/en/elasticsearch/reference/7.16/configuring-stack-security.html diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml new file mode 100644 index 0000000000..a190ece7df --- /dev/null +++ b/.github/workflows/format-check.yml @@ -0,0 +1,25 @@ +name: Check formatting +on: + merge_group: + push: + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Check formatting with Prettier + run: yarn format:check diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 7229bec582..ffab4880e1 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -1,9 +1,13 @@ name: CSS Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' paths: - 'package.json' - 'yarn.lock' @@ -38,9 +42,5 @@ jobs: - name: Set up Javascript environment uses: ./.github/actions/setup-javascript - - uses: xt0rted/stylelint-problem-matcher@v1 - - - run: echo "::add-matcher::.github/stylelint-matcher.json" - - name: Stylelint - run: yarn lint:sass + run: yarn lint:css --custom-formatter @csstools/stylelint-formatter-github diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index 8dcab845ee..c596261eb0 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -1,9 +1,13 @@ name: Haml Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' paths: - '.github/workflows/haml-lint-problem-matcher.json' - '.github/workflows/lint-haml.yml' @@ -26,14 +30,20 @@ on: jobs: lint: runs-on: ubuntu-latest + + env: + BUNDLE_ONLY: development + steps: - name: Clone repository uses: actions/checkout@v4 - - name: Set up Ruby environment - uses: ./.github/actions/setup-ruby + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true - name: Run haml-lint run: | echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" - bundle exec haml-lint + bin/haml-lint --reporter github diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 1c1ecc2b22..13468e7799 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -1,16 +1,20 @@ name: JavaScript Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' paths: - 'package.json' - 'yarn.lock' - 'tsconfig.json' - '.nvmrc' - '.prettier*' - - '.eslint*' + - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' - '**/*.ts' @@ -24,7 +28,7 @@ on: - 'tsconfig.json' - '.nvmrc' - '.prettier*' - - '.eslint*' + - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' - '**/*.ts' @@ -43,7 +47,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-json.yml b/.github/workflows/lint-json.yml deleted file mode 100644 index 7796bf92c4..0000000000 --- a/.github/workflows/lint-json.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: JSON Linting -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.json' - - '.github/workflows/lint-json.yml' - - '!app/javascript/mastodon/locales/*.json' - - pull_request: - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.json' - - '.github/workflows/lint-json.yml' - - '!app/javascript/mastodon/locales/*.json' - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Prettier - run: yarn lint:json diff --git a/.github/workflows/lint-md.yml b/.github/workflows/lint-md.yml deleted file mode 100644 index 51c59937a3..0000000000 --- a/.github/workflows/lint-md.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Markdown Linting -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - paths: - - '.github/workflows/lint-md.yml' - - '.nvmrc' - - '.prettier*' - - '**/*.md' - - '!AUTHORS.md' - - 'package.json' - - 'yarn.lock' - - pull_request: - paths: - - '.github/workflows/lint-md.yml' - - '.nvmrc' - - '.prettier*' - - '**/*.md' - - '!AUTHORS.md' - - 'package.json' - - 'yarn.lock' - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Prettier - run: yarn lint:md diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index 411b323486..5bb67b108c 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -1,13 +1,18 @@ name: Ruby Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' paths: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' + - 'bin/rubocop' - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' @@ -18,6 +23,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' + - 'bin/rubocop' - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' @@ -27,19 +33,24 @@ jobs: lint: runs-on: ubuntu-latest + env: + BUNDLE_ONLY: development + steps: - name: Clone repository uses: actions/checkout@v4 - - name: Set up Ruby environment - uses: ./.github/actions/setup-ruby + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true - name: Set-up RuboCop Problem Matcher uses: r7kamura/rubocop-problem-matchers-action@v1 - name: Run rubocop - run: bundle exec rubocop + run: bin/rubocop - name: Run brakeman if: always() # Run both checks, even if the first failed - run: bundle exec brakeman + run: bin/brakeman diff --git a/.github/workflows/lint-yml.yml b/.github/workflows/lint-yml.yml deleted file mode 100644 index 908bdef5cc..0000000000 --- a/.github/workflows/lint-yml.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: YML Linting -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.yaml' - - '**/*.yml' - - '.github/workflows/lint-yml.yml' - - '!config/locales/*.yml' - - pull_request: - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.yaml' - - '**/*.yml' - - '.github/workflows/lint-yml.yml' - - '!config/locales/*.yml' - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Prettier - run: yarn lint:yml diff --git a/.github/workflows/rebase-needed.yml b/.github/workflows/rebase-needed.yml index 06d835c090..f0fc8b0db7 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 }} @@ -17,7 +18,7 @@ jobs: steps: - name: Check for merge conflicts - uses: eps1lon/actions-label-merge-conflict@releases/2.x + uses: eps1lon/actions-label-merge-conflict@v3 with: dirtyLabel: 'rebase needed :construction:' repoToken: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 79622b6c1f..f962f5c36f 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -1,9 +1,13 @@ name: JavaScript Testing on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' paths: - 'package.json' - 'yarn.lock' @@ -38,5 +42,5 @@ jobs: - name: Set up Javascript environment uses: ./.github/actions/setup-javascript - - name: Jest testing + - name: JavaScript testing run: yarn jest --reporters github-actions summary diff --git a/.github/workflows/test-migrations-one-step.yml b/.github/workflows/test-migrations-one-step.yml deleted file mode 100644 index 5dca8e376d..0000000000 --- a/.github/workflows/test-migrations-one-step.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: Test one step migrations -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - pull_request: - -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-one-step.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 - - matrix: - postgres: - - 14-alpine - - 15-alpine - - services: - postgres: - image: postgres:${{ matrix.postgres}} - env: - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - - env: - CONTINUOUS_INTEGRATION: true - DB_HOST: localhost - DB_USER: postgres - DB_PASS: postgres - DISABLE_SIMPLECOV: true - RAILS_ENV: test - BUNDLE_CLEAN: true - BUNDLE_FROZEN: true - BUNDLE_WITHOUT: 'development production' - BUNDLE_JOBS: 3 - BUNDLE_RETRY: 3 - - steps: - - uses: actions/checkout@v4 - - - name: Set up Ruby environment - uses: ./.github/actions/setup-ruby - - - name: Create database - run: './bin/rails db:create' - - - name: Run migrations up to v2.0.0 - run: './bin/rails db:migrate VERSION=20171010025614' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2' - - - name: Run migrations up to v2.4.0 - run: './bin/rails db:migrate VERSION=20180514140000' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4' - - - name: Run migrations up to v2.4.3 - run: './bin/rails db:migrate VERSION=20180707154237' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4_3' - - - name: Run all remaining migrations - run: './bin/rails db:migrate' - - - name: Check migration result - run: './bin/rails tests:migrations:check_database' diff --git a/.github/workflows/test-migrations-two-step.yml b/.github/workflows/test-migrations-two-step.yml deleted file mode 100644 index 59485d285d..0000000000 --- a/.github/workflows/test-migrations-two-step.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Test two step migrations -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - pull_request: - -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-two-step.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 - - matrix: - postgres: - - 14-alpine - - 15-alpine - - services: - postgres: - image: postgres:${{ matrix.postgres}} - env: - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - - env: - CONTINUOUS_INTEGRATION: true - DB_HOST: localhost - DB_USER: postgres - DB_PASS: postgres - DISABLE_SIMPLECOV: true - RAILS_ENV: test - BUNDLE_CLEAN: true - BUNDLE_FROZEN: true - BUNDLE_WITHOUT: 'development production' - BUNDLE_JOBS: 3 - BUNDLE_RETRY: 3 - - steps: - - uses: actions/checkout@v4 - - - name: Set up Ruby environment - uses: ./.github/actions/setup-ruby - - - name: Create database - run: './bin/rails db:create' - - - name: Run migrations up to v2.0.0 - run: './bin/rails db:migrate VERSION=20171010025614' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2' - - - name: Run pre-deployment migrations up to v2.4.0 - run: './bin/rails db:migrate VERSION=20180514140000' - env: - SKIP_POST_DEPLOYMENT_MIGRATIONS: true - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4' - - - name: Run migrations up to v2.4.3 - run: './bin/rails db:migrate VERSION=20180707154237' - env: - SKIP_POST_DEPLOYMENT_MIGRATIONS: true - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4_3' - - - name: Run all remaining pre-deployment migrations - run: './bin/rails db:migrate' - env: - SKIP_POST_DEPLOYMENT_MIGRATIONS: true - - - name: Run all post-deployment migrations - run: './bin/rails db:migrate' - - - name: Check migration result - run: './bin/rails tests:migrations:check_database' diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml new file mode 100644 index 0000000000..c4a716e8f9 --- /dev/null +++ b/.github/workflows/test-migrations.yml @@ -0,0 +1,115 @@ +name: Historical data migration test + +on: + merge_group: + push: + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' + paths: + - 'Gemfile*' + - '.ruby-version' + - '**/*.rb' + - '.github/workflows/test-migrations.yml' + - 'lib/tasks/tests.rake' + - 'lib/tasks/db.rake' + + pull_request: + paths: + - 'Gemfile*' + - '.ruby-version' + - '**/*.rb' + - '.github/workflows/test-migrations.yml' + - 'lib/tasks/tests.rake' + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + + matrix: + postgres: + - 14-alpine + - 15-alpine + - 16-alpine + - 17-alpine + + services: + postgres: + image: postgres:${{ matrix.postgres}} + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 6379:6379 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + RAILS_ENV: test + BUNDLE_CLEAN: true + BUNDLE_FROZEN: true + BUNDLE_WITHOUT: 'development:production' + BUNDLE_JOBS: 3 + BUNDLE_RETRY: 3 + + steps: + - uses: actions/checkout@v4 + + - 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 + bin/rails db:create + bin/rails tests:migrations:prepare_database + bin/rails db:migrate + bin/rails tests:migrations:check_database + + - name: Test "two step migration" flow + run: | + bin/rails db:drop + bin/rails db:create + SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database + + # Migrate up to v4.2.0 breakpoint + bin/rails db:migrate VERSION=20230907150100 + + # Migrate the rest + SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate + bin/rails db:migrate + bin/rails tests:migrations:check_database diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 5274d92ac1..fd4c666059 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -1,10 +1,14 @@ name: Ruby Testing on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'kb*' + - 'upstream-*' + - 'releases/*' + - 'stable-*' pull_request: env: @@ -28,8 +32,7 @@ jobs: env: RAILS_ENV: ${{ matrix.mode }} BUNDLE_WITH: ${{ matrix.mode }} - OTP_SECRET: precompile_placeholder - SECRET_KEY_BASE: precompile_placeholder + SECRET_KEY_BASE_DUMMY: 1 steps: - uses: actions/checkout@v4 @@ -42,17 +45,30 @@ 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: | tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: matrix.mode == 'test' with: path: |- @@ -63,6 +79,403 @@ jobs: test: runs-on: ubuntu-latest + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 6379:6379 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} + RAILS_ENV: test + ALLOW_NOPAM: true + PAM_ENABLED: true + PAM_DEFAULT_SERVICE: pam_test + PAM_CONTROLLED_SERVICE: pam_test_controlled + OIDC_ENABLED: true + OIDC_SCOPE: read + SAML_ENABLED: true + CAS_ENABLED: true + BUNDLE_WITH: 'pam_authentication test' + GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} + ES_ENABLED: false + + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.2' + - '3.3' + - '.ruby-version' + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: './' + name: ${{ github.sha }} + + - name: Expand archived asset artifacts + run: | + tar xvzf artifacts.tar.gz + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version}} + additional-system-dependencies: ffmpeg imagemagick libpam-dev + + - name: Load database schema + run: | + bin/rails db:setup + bin/flatware fan bin/rails db:test:prepare + + - 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@v5 + with: + files: coverage/lcov/*.lcov + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + test-libvips: + name: Libvips tests + runs-on: ubuntu-latest + + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 6379:6379 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} + RAILS_ENV: test + ALLOW_NOPAM: true + PAM_ENABLED: true + PAM_DEFAULT_SERVICE: pam_test + PAM_CONTROLLED_SERVICE: pam_test_controlled + OIDC_ENABLED: true + OIDC_SCOPE: read + SAML_ENABLED: true + CAS_ENABLED: true + BUNDLE_WITH: 'pam_authentication test' + GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} + MASTODON_USE_LIBVIPS: true + + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.2' + - '3.3' + - '.ruby-version' + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: './' + name: ${{ github.sha }} + + - name: Expand archived asset artifacts + run: | + tar xvzf artifacts.tar.gz + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version}} + additional-system-dependencies: ffmpeg libpam-dev + + - name: Load database schema + run: './bin/rails db:create db:schema:load db:seed' + + - run: bin/rspec --tag attachment_processing + + - name: Upload coverage reports to Codecov + if: matrix.ruby-version == '.ruby-version' + uses: codecov/codecov-action@v5 + with: + files: coverage/lcov/mastodon.lcov + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + test-e2e: + name: End to End testing + runs-on: ubuntu-latest + + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 6379:6379 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + RAILS_ENV: test + BUNDLE_WITH: test + ES_ENABLED: false + LOCAL_DOMAIN: localhost:3000 + LOCAL_HTTPS: false + + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.2' + - '3.3' + - '.ruby-version' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: './' + name: ${{ github.sha }} + + - name: Expand archived asset artifacts + run: | + tar xvzf artifacts.tar.gz + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version}} + additional-system-dependencies: ffmpeg imagemagick + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Load database schema + run: './bin/rails db:create db:schema:load db:seed' + + - run: bin/rspec spec/system --tag streaming --tag js + + - name: Archive logs + uses: actions/upload-artifact@v4 + if: failure() + with: + name: e2e-logs-${{ matrix.ruby-version }} + path: log/ + + - name: Archive test screenshots + uses: actions/upload-artifact@v4 + if: failure() + with: + name: e2e-screenshots + path: tmp/capybara/ + + test-search: + name: Elastic Search integration testing + runs-on: ubuntu-latest + + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 6379:6379 + + elasticsearch: + image: ${{ contains(matrix.search-image, 'elasticsearch') && matrix.search-image || '' }} + env: + discovery.type: single-node + xpack.security.enabled: false + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 2s + --health-timeout 3s + --health-retries 50 + ports: + - 9200:9200 + + opensearch: + image: ${{ contains(matrix.search-image, 'opensearch') && matrix.search-image || '' }} + env: + discovery.type: single-node + DISABLE_INSTALL_DEMO_CONFIG: true + DISABLE_SECURITY_PLUGIN: true + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 2s + --health-timeout 3s + --health-retries 50 + ports: + - 9200:9200 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + RAILS_ENV: test + BUNDLE_WITH: test + ES_ENABLED: true + ES_HOST: localhost + ES_PORT: 9200 + + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.2' + - '3.3' + - '.ruby-version' + search-image: + - docker.elastic.co/elasticsearch/elasticsearch:7.17.13 + include: + - ruby-version: '.ruby-version' + search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2 + - ruby-version: '.ruby-version' + search-image: opensearchproject/opensearch:2 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: './' + name: ${{ github.sha }} + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version}} + additional-system-dependencies: ffmpeg imagemagick + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Load database schema + run: './bin/rails db:create db:schema:load db:seed' + + - run: bin/rspec --tag search + + - name: Archive logs + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-search-logs-${{ matrix.ruby-version }} + path: log/ + + - name: Archive test screenshots + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-search-screenshots + path: tmp/capybara/ + + test-back-and-return: + name: Back to original and return test + runs-on: ubuntu-latest + needs: - build @@ -107,18 +520,17 @@ jobs: BUNDLE_WITH: 'pam_authentication test' GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} ES_ENABLED: false + BACK_UPSTREAM_FORCE: true strategy: fail-fast: false matrix: ruby-version: - - '3.0' - - '3.1' - '.ruby-version' steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: path: './' name: ${{ github.sha }} @@ -136,215 +548,16 @@ jobs: - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' + - name: Back to upstream schema + run: 'bundle exec rake dangerous:back_upstream' + + - name: Return to kmyblue + run: './bin/rails db:migrate' + - run: bin/rspec - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' uses: codecov/codecov-action@v3 with: - files: coverage/lcov/mastodon.lcov - - test-e2e: - name: End to End testing - runs-on: ubuntu-latest - - needs: - - build - - services: - postgres: - image: postgres:14-alpine - env: - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - - env: - DB_HOST: localhost - DB_USER: postgres - DB_PASS: postgres - DISABLE_SIMPLECOV: true - RAILS_ENV: test - BUNDLE_WITH: test - ES_ENABLED: false - - strategy: - fail-fast: false - matrix: - ruby-version: - - '3.0' - - '3.1' - - '.ruby-version' - - steps: - - uses: actions/checkout@v4 - - - uses: actions/download-artifact@v3 - with: - path: './public' - name: ${{ github.sha }} - - - name: Set up Ruby environment - uses: ./.github/actions/setup-ruby - with: - ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg imagemagick - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Load database schema - run: './bin/rails db:create db:schema:load db:seed' - - - run: bundle exec rake spec:system - - - name: Archive logs - uses: actions/upload-artifact@v3 - if: failure() - with: - name: e2e-logs-${{ matrix.ruby-version }} - path: log/ - - - name: Archive test screenshots - uses: actions/upload-artifact@v3 - if: failure() - with: - name: e2e-screenshots - path: tmp/screenshots/ - - test-search: - name: Elastic Search integration testing - runs-on: ubuntu-latest - - needs: - - build - - services: - postgres: - image: postgres:14-alpine - env: - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - - env: - DB_HOST: localhost - DB_USER: postgres - DB_PASS: postgres - DISABLE_SIMPLECOV: true - RAILS_ENV: test - BUNDLE_WITH: test - ES_ENABLED: true - ES_HOST: localhost - ES_PORT: 9200 - - strategy: - fail-fast: false - matrix: - ruby-version: - - '3.0' - - '3.1' - - '.ruby-version' - search-image: - - docker.elastic.co/elasticsearch/elasticsearch:7.17.13 - include: - - ruby-version: '.ruby-version' - search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2 - - steps: - - uses: actions/checkout@v4 - - - uses: actions/download-artifact@v3 - with: - path: './public' - name: ${{ github.sha }} - - - name: Set up Ruby environment - uses: ./.github/actions/setup-ruby - with: - ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg imagemagick - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Configure sysctl limits - run: | - sudo swapoff -a - sudo sysctl -w vm.swappiness=1 - sudo sysctl -w fs.file-max=262144 - sudo sysctl -w vm.max_map_count=262144 - - - name: Install Elasticsearch - run: | - wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.17.10-amd64.deb - wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.17.10-amd64.deb.sha512 - shasum -a 512 -c elasticsearch-7.17.10-amd64.deb.sha512 - sudo dpkg -i elasticsearch-7.17.10-amd64.deb - sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install https://github.com/WorksApplications/elasticsearch-sudachi/releases/download/v3.1.0/elasticsearch-7.17.10-analysis-sudachi-3.1.0.zip - - - name: Install dictionary - run: | - wget http://sudachi.s3-website-ap-northeast-1.amazonaws.com/sudachidict/sudachi-dictionary-latest-core.zip - unzip sudachi-dictionary-latest-core.zip - sudo mkdir /etc/elasticsearch/sudachi -p - sudo cp sudachi-dictionary-*/system_core.dic /etc/elasticsearch/sudachi - - - name: Set security settings - run: | - sudo cp .github/workflows/elasticsearch-settings/elasticsearch.yml /etc/elasticsearch - - - name: Running Elasticsearch - run: | - sudo systemctl start elasticsearch - - - name: Load database schema - run: './bin/rails db:create db:schema:load db:seed' - - - run: bin/rspec --tag search - - - name: Archive logs - uses: actions/upload-artifact@v3 - if: failure() - with: - name: test-search-logs-${{ matrix.ruby-version }} - path: log/ - - - name: Archive test screenshots - uses: actions/upload-artifact@v3 - if: failure() - with: - name: test-search-screenshots - path: tmp/screenshots/ + files: coverage/lcov/mastodon-back-ret.lcov diff --git a/.gitignore b/.gitignore index c5af8eb67f..7d60baebf8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,10 +24,12 @@ /public/packs-test .env .env.production -.env.development /node_modules/ /build/ +# Ignore elasticsearch config +/.elasticsearch.yml + # Ignore Vagrant files .vagrant/ @@ -69,3 +71,9 @@ yarn-debug.log # Ignore Docker option files docker-compose.override.yml + +# Ignore dotenv .local files +.env*.local + +# Ignore local-only rspec configuration +.rspec-local diff --git a/.haml-lint.yml b/.haml-lint.yml index 8cfcaec8d9..7dbc88e9db 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -2,7 +2,6 @@ inherits_from: .haml-lint_todo.yml exclude: - 'vendor/**/*' - - lib/templates/haml/scaffold/_form.html.haml require: - ./lib/linter/haml_middle_dot.rb @@ -13,4 +12,6 @@ linters: MiddleDot: enabled: true LineLength: - max: 320 + max: 300 + ViewLength: + max: 200 # Override default value of 100 inherited from rubocop diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml index 4aa51fb18d..841561291f 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -1,28 +1,17 @@ # This configuration was generated by # `haml-lint --auto-gen-config` -# on 2023-12-15 11:02:19 -0500 using Haml-Lint version 0.52.0. +# on 2024-01-09 11:30:07 -0500 using Haml-Lint version 0.53.0. # The point is for the user to remove these configuration records # one by one as the lints are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of Haml-Lint, may require this file to be generated again. linters: - # Offense count: 11 + # Offense count: 1 LineLength: exclude: + - 'app/views/admin/ng_rules/_ng_rule_fields.html.haml' - 'app/views/admin/roles/_form.html.haml' - - 'app/views/auth/registrations/edit.html.haml' - - 'app/views/auth/registrations/new.html.haml' - - 'app/views/media/player.html.haml' - - 'app/views/settings/applications/_fields.html.haml' - - 'app/views/settings/imports/index.html.haml' - - 'app/views/settings/preferences/appearance/show.html.haml' - - 'app/views/settings/preferences/notifications/show.html.haml' - - 'app/views/settings/preferences/other/show.html.haml' - - 'app/views/settings/preferences/reaching/show.html.haml' - - 'app/views/settings/privacy/show.html.haml' - - 'app/views/settings/privacy_extra/show.html.haml' - - 'app/views/settings/profiles/show.html.haml' # Offense count: 9 RuboCop: @@ -31,8 +20,17 @@ linters: ViewLength: exclude: + - 'app/views/admin/accounts/index.html.haml' - 'app/views/admin/instances/show.html.haml' + - 'app/views/admin/ng_rules/_ng_rule_fields.html.haml' + - 'app/views/admin/settings/discovery/show.html.haml' + - 'app/views/settings/preferences/appearance/show.html.haml' + - 'app/views/settings/preferences/other/show.html.haml' InstanceVariables: exclude: - 'app/views/application/_sidebar.html.haml' + - 'app/views/admin/ng_rules/_ng_rule_fields.html.haml' + - 'app/views/admin/ng_words/keywords/_ng_word.html.haml' + - 'app/views/admin/ng_words/white_list/_specified_domain.html.haml' + - 'app/views/admin/sensitive_words/_sensitive_word.html.haml' diff --git a/.husky/pre-commit b/.husky/pre-commit index d2ae35e84b..3723623171 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - yarn lint-staged diff --git a/.nanoignore b/.nanoignore deleted file mode 100644 index 80e9397035..0000000000 --- a/.nanoignore +++ /dev/null @@ -1,19 +0,0 @@ -.DS_Store -.git/ -.gitignore - -.bundle/ -.cache/ -config/deploy/* -coverage -docs/ -.env -log/*.log -neo4j/ -node_modules/ -public/assets/ -public/system/ -spec/ -tmp/ -.vagrant/ -vendor/bundle/ diff --git a/.nvmrc b/.nvmrc index d4c3d320cc..744ca17ec0 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.10 +22.14 diff --git a/.prettierignore b/.prettierignore index 305f0fd753..80b4c0159e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -54,8 +54,16 @@ # Ignore Docker option files docker-compose.override.yml +# Ignore public +/public/assets +/public/emoji +/public/packs +/public/packs-test +/public/system + # Ignore emoji map file /app/javascript/mastodon/features/emoji/emoji_map.json +/app/javascript/mastodon/features/emoji/emoji_sheet.json # Ignore locale files /app/javascript/mastodon/locales/*.json @@ -73,3 +81,6 @@ app/javascript/styles/mastodon/reset.scss # Ignore the generated AUTHORS.md AUTHORS.md + +# Process a few selected JS files +!lint-staged.config.js 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/.profile b/.profile deleted file mode 100644 index f4826ea303..0000000000 --- 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 9a8e706d09..83e16f8044 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,2 @@ --color --require spec_helper ---format Fuubar diff --git a/.rubocop.yml b/.rubocop.yml index 89c04eeef9..1bbba515af 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,227 +1,36 @@ -# Can be removed once all rules are addressed or moved to this file as documented overrides -inherit_from: .rubocop_todo.yml +--- +AllCops: + CacheRootDirectory: tmp + DisplayStyleGuide: true + Exclude: + - Vagrantfile + - config/initializers/json_ld* + - lib/mastodon/migration_helpers.rb + ExtraDetails: true + NewCops: enable + TargetRubyVersion: 3.2 # Oldest supported ruby version + +inherit_from: + - .rubocop/layout.yml + - .rubocop/metrics.yml + - .rubocop/naming.yml + - .rubocop/rails.yml + - .rubocop/rspec_rails.yml + - .rubocop/rspec.yml + - .rubocop/style.yml + - .rubocop/i18n.yml + - .rubocop/custom.yml + - .rubocop_todo.yml + - .rubocop/strict.yml -# Used for merging with exclude lists with .rubocop_todo.yml inherit_mode: merge: - Exclude -require: +plugins: + - rubocop-capybara + - rubocop-i18n + - rubocop-performance - rubocop-rails - rubocop-rspec - - rubocop-performance - - rubocop-capybara - - ./lib/linter/rubocop_middle_dot - -AllCops: - TargetRubyVersion: 3.0 # Set to minimum supported version of CI - DisplayCopNames: true - DisplayStyleGuide: true - ExtraDetails: true - UseCache: true - CacheRootDirectory: tmp - NewCops: enable # Opt-in to newly added rules - Exclude: - - 'db/schema.rb' - - 'bin/*' - - 'node_modules/**/*' - - 'Vagrantfile' - - 'vendor/**/*' - - 'config/initializers/json_ld*' # Generated files - - 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab - - 'lib/templates/**/*' - -# Reason: Prefer Hashes without extreme indentation -# https://docs.rubocop.org/rubocop/cops_layout.html#layoutfirsthashelementindentation -Layout/FirstHashElementIndentation: - EnforcedStyle: consistent - -# Reason: Currently disabled in .rubocop_todo.yml -# https://docs.rubocop.org/rubocop/cops_layout.html#layoutlinelength -Layout/LineLength: - Max: 320 # Default of 120 causes a duplicate entry in generated todo file - -# Reason: -# https://docs.rubocop.org/rubocop/cops_lint.html#lintuselessaccessmodifier -Lint/UselessAccessModifier: - ContextCreatingMethods: - - class_methods - -## Disable most Metrics/*Length cops -# Reason: those are often triggered and force significant refactors when this happend -# but the team feel they are not really improving the code quality. - -# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocklength -Metrics/BlockLength: - Enabled: false - -# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsclasslength -Metrics/ClassLength: - Enabled: false - -# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmethodlength -Metrics/MethodLength: - Enabled: false - -# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmodulelength -Metrics/ModuleLength: - Enabled: false - -## End Disable Metrics/*Length cops - -# Reason: Currently disabled in .rubocop_todo.yml -# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsabcsize -Metrics/AbcSize: - Exclude: - - 'app/serializers/initial_state_serializer.rb' - - 'lib/mastodon/cli/*.rb' - -# Reason: Currently disabled in .rubocop_todo.yml -# https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity -Metrics/CyclomaticComplexity: - Exclude: - - 'app/lib/feed_manager.rb' - - 'app/policies/status_policy.rb' - - 'app/services/activitypub/process_account_service.rb' - - 'app/services/delivery_antenna_service.rb' - - 'app/services/post_status_service.rb' - - lib/mastodon/cli/*.rb - -# Reason: -# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsparameterlists -Metrics/ParameterLists: - CountKeywordArgs: false - -Metrics/PerceivedComplexity: - Exclude: - - 'app/policies/status_policy.rb' - - 'app/services/delivery_antenna_service.rb' - - 'app/services/post_status_service.rb' - -# Reason: Prevailing style is argument file paths -# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsfilepath -Rails/FilePath: - EnforcedStyle: arguments - -# Reason: Prevailing style uses numeric status codes, matches RSpec/Rails/HttpStatus -# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railshttpstatus -Rails/HttpStatus: - EnforcedStyle: numeric - -# Reason: Allowed in `tootctl` CLI code and in boot ENV checker -# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsexit -Rails/Exit: - Exclude: - - 'config/boot.rb' - - 'lib/mastodon/cli/*.rb' - -Rails/SkipsModelValidations: - Exclude: - - 'db/*migrate/**/*' - -# Reason: We want to preserve the ability to migrate from arbitrary old versions, -# and cannot guarantee that every installation has run every migration as they upgrade. -# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsunusedignoredcolumns -Rails/UnusedIgnoredColumns: - Enabled: false - -# Reason: Prevailing style choice -# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsnegateinclude -Rails/NegateInclude: - Enabled: false - -# Reason: Some single letter camel case files shouldn't be split -# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecfilepath -RSpec/FilePath: - CustomTransform: - ActivityPub: activitypub - DeepL: deepl - FetchOEmbedService: fetch_oembed_service - OEmbedController: oembed_controller - OStatus: ostatus - -# Reason: -# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnamedsubject -RSpec/NamedSubject: - EnforcedStyle: named_only - -# Reason: Prevailing style choice -# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnottonot -RSpec/NotToNot: - EnforcedStyle: to_not - -# Reason: Prevailing style uses numeric status codes, matches Rails/HttpStatus -# https://docs.rubocop.org/rubocop-rspec/cops_rspec_rails.html#rspecrailshttpstatus -RSpec/Rails/HttpStatus: - EnforcedStyle: numeric - -# Reason: Match overrides from Rspec/FilePath rule above -# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecspecfilepathformat -RSpec/SpecFilePathFormat: - CustomTransform: - ActivityPub: activitypub - DeepL: deepl - FetchOEmbedService: fetch_oembed_service - OEmbedController: oembed_controller - OStatus: ostatus - -# Reason: -# https://docs.rubocop.org/rubocop/cops_style.html#styleclassandmodulechildren -Style/ClassAndModuleChildren: - Enabled: false - -# Reason: Classes mostly self-document with their names -# https://docs.rubocop.org/rubocop/cops_style.html#styledocumentation -Style/Documentation: - Enabled: false - -# Reason: Enforce modern Ruby style -# https://docs.rubocop.org/rubocop/cops_style.html#stylehashsyntax -Style/HashSyntax: - EnforcedStyle: ruby19_no_mixed_keys - -# Reason: -# https://docs.rubocop.org/rubocop/cops_style.html#stylenumericliterals -Style/NumericLiterals: - AllowedPatterns: - - \d{4}_\d{2}_\d{2}_\d{6} # For DB migration date version number readability - -# Reason: -# https://docs.rubocop.org/rubocop/cops_style.html#stylepercentliteraldelimiters -Style/PercentLiteralDelimiters: - PreferredDelimiters: - '%i': '()' - '%w': '()' - -# Reason: Prefer less indentation in conditional assignments -# https://docs.rubocop.org/rubocop/cops_style.html#styleredundantbegin -Style/RedundantBegin: - Enabled: false - -# Reason: Overridden to reduce implicit StandardError rescues -# https://docs.rubocop.org/rubocop/cops_style.html#stylerescuestandarderror -Style/RescueStandardError: - EnforcedStyle: implicit - -# Reason: Simplify some spec layouts -# https://docs.rubocop.org/rubocop/cops_style.html#stylesemicolon -Style/Semicolon: - AllowAsExpressionSeparator: true - -# Reason: Originally disabled for CodeClimate, and no config consensus has been found -# https://docs.rubocop.org/rubocop/cops_style.html#stylesymbolarray -Style/SymbolArray: - Enabled: false - -# Reason: -# https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainarrayliteral -Style/TrailingCommaInArrayLiteral: - EnforcedStyleForMultiline: 'comma' - -# Reason: -# https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainhashliteral -Style/TrailingCommaInHashLiteral: - EnforcedStyleForMultiline: 'comma' - -Style/MiddleDot: - Enabled: true + - rubocop-rspec_rails diff --git a/.rubocop/custom.yml b/.rubocop/custom.yml new file mode 100644 index 0000000000..63035837f8 --- /dev/null +++ b/.rubocop/custom.yml @@ -0,0 +1,6 @@ +--- +require: + - ../lib/linter/rubocop_middle_dot + +Style/MiddleDot: + Enabled: true 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/layout.yml b/.rubocop/layout.yml new file mode 100644 index 0000000000..487879ca2c --- /dev/null +++ b/.rubocop/layout.yml @@ -0,0 +1,6 @@ +--- +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +Layout/LineLength: + Max: 300 # Default of 120 causes a duplicate entry in generated todo file diff --git a/.rubocop/metrics.yml b/.rubocop/metrics.yml new file mode 100644 index 0000000000..2828d5e4a3 --- /dev/null +++ b/.rubocop/metrics.yml @@ -0,0 +1,29 @@ +--- +Metrics/AbcSize: + Exclude: + - 'app/serializers/initial_state_serializer.rb' + - lib/mastodon/cli/*.rb + +Metrics/BlockLength: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CyclomaticComplexity: + Exclude: + - 'app/lib/feed_manager.rb' + - 'app/policies/status_policy.rb' + - 'app/services/activitypub/process_account_service.rb' + - 'app/services/delivery_antenna_service.rb' + - 'app/services/post_status_service.rb' + - lib/mastodon/cli/*.rb + +Metrics/MethodLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + +Metrics/ParameterLists: + CountKeywordArgs: false diff --git a/.rubocop/naming.yml b/.rubocop/naming.yml new file mode 100644 index 0000000000..da6ad4ac57 --- /dev/null +++ b/.rubocop/naming.yml @@ -0,0 +1,3 @@ +--- +Naming/BlockForwarding: + EnforcedStyle: explicit diff --git a/.rubocop/rails.yml b/.rubocop/rails.yml new file mode 100644 index 0000000000..bbd172e656 --- /dev/null +++ b/.rubocop/rails.yml @@ -0,0 +1,26 @@ +--- +Rails/BulkChangeTable: + Enabled: false # Conflicts with strong_migrations features + +Rails/Delegate: + Enabled: false + +Rails/FilePath: + EnforcedStyle: arguments + +Rails/HttpStatus: + EnforcedStyle: numeric + +Rails/NegateInclude: + Enabled: false + +Rails/RakeEnvironment: + Exclude: # Tasks are doing local work which do not need full env loaded + - lib/tasks/auto_annotate_models.rake + - lib/tasks/emojis.rake + - lib/tasks/mastodon.rake + - lib/tasks/repo.rake + - lib/tasks/statistics.rake + +Rails/SkipsModelValidations: + Enabled: false diff --git a/.rubocop/rspec.yml b/.rubocop/rspec.yml new file mode 100644 index 0000000000..940701c722 --- /dev/null +++ b/.rubocop/rspec.yml @@ -0,0 +1,29 @@ +--- +RSpec/ExampleLength: + CountAsOne: ['array', 'heredoc', 'method_call'] + Max: 20 # Override default of 5 + +RSpec/MultipleExpectations: + Max: 10 # Overrides default of 1 + +RSpec/MultipleMemoizedHelpers: + Max: 20 # Overrides default of 5 + Exclude: + - 'spec/services/delete_account_service_spec.rb' + +RSpec/NamedSubject: + EnforcedStyle: named_only + +RSpec/NestedGroups: + Max: 10 # Overrides default of 3 + +RSpec/NotToNot: + EnforcedStyle: to_not + +RSpec/SpecFilePathFormat: + CustomTransform: + ActivityPub: activitypub + DeepL: deepl + FetchOEmbedService: fetch_oembed_service + OEmbedController: oembed_controller + OStatus: ostatus diff --git a/.rubocop/rspec_rails.yml b/.rubocop/rspec_rails.yml new file mode 100644 index 0000000000..993a5689ad --- /dev/null +++ b/.rubocop/rspec_rails.yml @@ -0,0 +1,3 @@ +--- +RSpecRails/HttpStatus: + EnforcedStyle: numeric diff --git a/.rubocop/strict.yml b/.rubocop/strict.yml new file mode 100644 index 0000000000..c2655a1470 --- /dev/null +++ b/.rubocop/strict.yml @@ -0,0 +1,24 @@ +Lint/Debugger: # Remove any `binding.pry` + Enabled: true + Exclude: [] + +RSpec/Focus: # Require full spec run on CI + Enabled: true + 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 + Exclude: [] + +Rails/UniqBeforePluck: # Require `uniq.pluck` and not `pluck.uniq` + Enabled: true + Exclude: [] diff --git a/.rubocop/style.yml b/.rubocop/style.yml new file mode 100644 index 0000000000..f59340d452 --- /dev/null +++ b/.rubocop/style.yml @@ -0,0 +1,63 @@ +--- +Style/ArrayIntersect: + Enabled: false + +Style/ClassAndModuleChildren: + Enabled: false + +Style/Documentation: + Enabled: false + +Style/FormatStringToken: + AllowedMethods: + - redirect_with_vary # Route redirects are not token-formatted + inherit_mode: + merge: + - AllowedMethods + +Style/HashAsLastArrayItem: + Enabled: false + +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} + +Style/PercentLiteralDelimiters: + PreferredDelimiters: + '%i': () + '%w': () + +Style/RedundantBegin: + Enabled: false + +Style/RedundantFetchBlock: + Enabled: false + +Style/RescueStandardError: + EnforcedStyle: implicit + +Style/SafeNavigationChainLength: + Enabled: false + +Style/SymbolArray: + Enabled: false + +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: comma + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: comma + +Style/WordArray: + MinSize: 3 # Override default of 2 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 2cba621595..13fb25d333 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,34 +1,20 @@ # This configuration was generated by -# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.57.2. +# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` +# using RuboCop version 1.75.2. # 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 # versions of RuboCop, may require this file to be generated again. -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. -# Include: **/*.gemfile, **/Gemfile, **/gems.rb -Bundler/OrderedGems: - Exclude: - - 'Gemfile' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. -# URISchemes: http, https -Layout/LineLength: - Exclude: - - 'app/models/account.rb' - Lint/NonLocalExitFromIterator: Exclude: - - 'app/helpers/jsonld_helper.rb' + - 'app/helpers/json_ld_helper.rb' # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Max: 100 + Max: 82 -# Configuration parameters: CountBlocks, Max. +# Configuration parameters: CountBlocks, CountModifierForms, Max. Metrics/BlockNesting: Exclude: - 'lib/tasks/mastodon.rake' @@ -40,279 +26,46 @@ Metrics/CyclomaticComplexity: # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/PerceivedComplexity: Max: 27 - -# Configuration parameters: CountAsOne. -RSpec/ExampleLength: - Max: 22 - -RSpec/MultipleExpectations: - Max: 8 - -# Configuration parameters: AllowSubject. -RSpec/MultipleMemoizedHelpers: - Max: 21 Exclude: - - 'spec/services/delete_account_service_spec.rb' - -# Configuration parameters: AllowedGroups. -RSpec/NestedGroups: - Max: 6 - -# This cop supports unsafe autocorrection (--autocorrect-all). -Rails/ApplicationController: - Exclude: - - 'app/controllers/health_controller.rb' - -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/HasAndBelongsToMany: - Exclude: - - 'app/models/concerns/account/associations.rb' - - 'app/models/preview_card.rb' - - 'app/models/status.rb' - - 'app/models/tag.rb' - -# Configuration parameters: Include. -# Include: app/controllers/**/*.rb, app/mailers/**/*.rb -Rails/LexicallyScopedActionFilter: - Exclude: - - 'app/controllers/auth/passwords_controller.rb' - - 'app/controllers/auth/registrations_controller.rb' + - 'app/policies/status_policy.rb' + - 'app/services/delivery_antenna_service.rb' + - 'app/services/post_status_service.rb' Rails/OutputSafety: Exclude: - 'config/initializers/simple_form.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: Include. -# Include: **/Rakefile, **/*.rake -Rails/RakeEnvironment: - Exclude: - - 'lib/tasks/auto_annotate_models.rake' - - 'lib/tasks/db.rake' - - 'lib/tasks/emojis.rake' - - 'lib/tasks/mastodon.rake' - - 'lib/tasks/repo.rake' - - 'lib/tasks/statistics.rake' - -# Configuration parameters: ForbiddenMethods, AllowedMethods. -# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all -Rails/SkipsModelValidations: - Exclude: - - 'app/controllers/admin/invites_controller.rb' - - 'app/controllers/concerns/session_tracking_concern.rb' - - 'app/models/concerns/account/merging.rb' - - 'app/models/concerns/expireable.rb' - - 'app/models/status.rb' - - 'app/models/trends/links.rb' - - 'app/models/trends/preview_card_batch.rb' - - 'app/models/trends/preview_card_provider_batch.rb' - - 'app/models/trends/status_batch.rb' - - 'app/models/trends/statuses.rb' - - 'app/models/trends/tag_batch.rb' - - 'app/models/trends/tags.rb' - - 'app/models/user.rb' - - 'app/services/activitypub/process_status_update_service.rb' - - 'app/services/approve_appeal_service.rb' - - 'app/services/block_domain_service.rb' - - 'app/services/delete_account_service.rb' - - 'app/services/process_mentions_service.rb' - - 'app/services/unallow_domain_service.rb' - - 'app/services/unblock_domain_service.rb' - - 'app/services/update_status_service.rb' - - 'app/workers/activitypub/post_upgrade_worker.rb' - - 'app/workers/move_worker.rb' - - 'app/workers/scheduler/ip_cleanup_scheduler.rb' - - 'app/workers/scheduler/scheduled_statuses_scheduler.rb' - - 'lib/mastodon/cli/accounts.rb' - - 'lib/mastodon/cli/maintenance.rb' - - 'spec/lib/activitypub/activity/follow_spec.rb' - - 'spec/services/follow_service_spec.rb' - - 'spec/services/update_account_service_spec.rb' - -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/UniqueValidationWithoutIndex: - Exclude: - - 'app/models/account_alias.rb' - - 'app/models/custom_filter_status.rb' - - 'app/models/identity.rb' - - 'app/models/webauthn_credential.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: exists, where -Rails/WhereExists: - Exclude: - - 'app/controllers/activitypub/inboxes_controller.rb' - - 'app/controllers/admin/email_domain_blocks_controller.rb' - - 'app/controllers/auth/registrations_controller.rb' - - 'app/lib/activitypub/activity/create.rb' - - 'app/lib/delivery_failure_tracker.rb' - - 'app/lib/feed_manager.rb' - - 'app/lib/status_cache_hydrator.rb' - - 'app/lib/suspicious_sign_in_detector.rb' - - 'app/models/concerns/account/interactions.rb' - - 'app/models/featured_tag.rb' - - 'app/models/poll.rb' - - 'app/models/session_activation.rb' - - 'app/models/status.rb' - - 'app/models/user.rb' - - 'app/policies/status_policy.rb' - - 'app/serializers/rest/announcement_serializer.rb' - - 'app/serializers/rest/tag_serializer.rb' - - 'app/services/activitypub/fetch_remote_status_service.rb' - - 'app/services/app_sign_up_service.rb' - - 'app/services/vote_service.rb' - - 'app/validators/reaction_validator.rb' - - 'app/validators/vote_validator.rb' - - 'app/workers/move_worker.rb' - - 'lib/tasks/tests.rake' - - 'spec/models/account_spec.rb' - - 'spec/services/activitypub/process_collection_service_spec.rb' - - 'spec/services/purge_domain_service_spec.rb' - - 'spec/services/unallow_domain_service_spec.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowOnConstant, AllowOnSelfClass. -Style/CaseEquality: - Exclude: - - 'config/initializers/trusted_proxies.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: AllowedMethods, AllowedPatterns. -# AllowedMethods: ==, equal?, eql? -Style/ClassEqualityComparison: - Exclude: - - 'app/helpers/jsonld_helper.rb' - - 'app/serializers/activitypub/outbox_serializer.rb' - -Style/ClassVars: - Exclude: - - 'config/initializers/devise.rb' - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowedVars. Style/FetchEnvVar: Exclude: - - 'app/lib/redis_configuration.rb' - - 'app/lib/translation_service.rb' - - 'config/environments/development.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/premailer_webpack_strategy.rb' - - 'lib/mastodon/redis_config.rb' - 'lib/tasks/repo.rake' - - 'spec/features/profile_spec.rb' # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns. +# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, Mode, AllowedMethods, AllowedPatterns. # SupportedStyles: annotated, template, unannotated # AllowedMethods: redirect Style/FormatStringToken: Exclude: - - 'app/models/privacy_policy.rb' - 'config/initializers/devise.rb' - 'lib/paperclip/color_extractor.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/GlobalStdStream: - Exclude: - - 'config/boot.rb' - - 'config/environments/development.rb' - - 'config/environments/production.rb' - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. Style/GuardClause: - Exclude: - - 'app/controllers/admin/confirmations_controller.rb' - - 'app/controllers/auth/confirmations_controller.rb' - - 'app/controllers/auth/passwords_controller.rb' - - 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb' - - 'app/lib/activitypub/activity/block.rb' - - 'app/lib/request.rb' - - 'app/lib/request_pool.rb' - - 'app/lib/webfinger.rb' - - 'app/lib/webfinger_resource.rb' - - 'app/models/concerns/account/counters.rb' - - 'app/models/concerns/user/ldap_authenticable.rb' - - 'app/models/tag.rb' - - 'app/models/user.rb' - - 'app/services/fan_out_on_write_service.rb' - - 'app/services/post_status_service.rb' - - 'app/services/process_hashtags_service.rb' - - 'app/workers/move_worker.rb' - - 'app/workers/redownload_avatar_worker.rb' - - 'app/workers/redownload_header_worker.rb' - - 'app/workers/redownload_media_worker.rb' - - 'app/workers/remote_account_refresh_worker.rb' - - 'config/initializers/devise.rb' - - 'lib/devise/strategies/two_factor_ldap_authenticatable.rb' - - 'lib/devise/strategies/two_factor_pam_authenticatable.rb' - - 'lib/mastodon/cli/accounts.rb' - - 'lib/mastodon/cli/maintenance.rb' - - 'lib/mastodon/cli/media.rb' - - 'lib/paperclip/attachment_extensions.rb' - - 'lib/tasks/repo.rake' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: braces, no_braces -Style/HashAsLastArrayItem: - Exclude: - - 'app/controllers/admin/statuses_controller.rb' - - 'app/controllers/api/v1/statuses_controller.rb' - - 'app/models/concerns/account/counters.rb' - - 'app/models/concerns/status/threading_concern.rb' - - 'app/models/status.rb' - - 'app/services/batched_remove_status_service.rb' - - 'app/services/notify_service.rb' - -# 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 safe autocorrection (--autocorrect). -Style/IfUnlessModifier: - Exclude: - - 'config/environments/production.rb' - - 'config/initializers/devise.rb' - - 'config/initializers/ffmpeg.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' - -# This cop supports safe autocorrection (--autocorrect). -Style/NilLambda: - Exclude: - - 'config/initializers/paperclip.rb' + Enabled: false # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? Style/OptionalBooleanParameter: Exclude: - - 'app/helpers/admin/account_moderation_notes_helper.rb' - - 'app/helpers/jsonld_helper.rb' - 'app/lib/admin/system_check/message.rb' - 'app/lib/request.rb' - 'app/lib/webfinger.rb' @@ -320,14 +73,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 safe autocorrection (--autocorrect). -# Configuration parameters: PreferredDelimiters. -Style/PercentLiteralDelimiters: - Exclude: - - 'config/deploy.rb' - - 'config/initializers/doorkeeper.rb' # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. @@ -341,81 +86,3 @@ Style/RedundantConstantBase: Exclude: - 'config/environments/production.rb' - 'config/initializers/sidekiq.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: SafeForConstants. -Style/RedundantFetchBlock: - Exclude: - - 'config/initializers/1_hosts.rb' - - 'config/initializers/chewy.rb' - - 'config/initializers/devise.rb' - - 'config/initializers/paperclip.rb' - - 'config/puma.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. -# AllowedMethods: present?, blank?, presence, try, try! -Style/SafeNavigation: - Exclude: - - 'app/models/concerns/account/finder_concern.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: only_raise, only_fail, semantic -Style/SignalException: - Exclude: - - 'lib/devise/strategies/two_factor_ldap_authenticatable.rb' - - 'lib/devise/strategies/two_factor_pam_authenticatable.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/SingleArgumentDig: - Exclude: - - 'lib/webpacker/manifest_extensions.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: Mode. -Style/StringConcatenation: - Exclude: - - 'config/initializers/paperclip.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. -# SupportedStyles: single_quotes, double_quotes -Style/StringLiterals: - Exclude: - - 'config/environments/production.rb' - - 'config/initializers/backtrace_silencers.rb' - - 'config/initializers/http_client_proxy.rb' - - 'config/initializers/rack_attack.rb' - - 'config/initializers/webauthn.rb' - - 'config/routes.rb' - - 'db/schema.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, AllowSafeAssignment. -# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex -Style/TernaryParentheses: - Exclude: - - 'config/environments/development.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyleForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma -Style/TrailingCommaInArguments: - Exclude: - - 'config/initializers/paperclip.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyleForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma -Style/TrailingCommaInHashLiteral: - Exclude: - - 'config/environments/production.rb' - - 'config/environments/test.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, MinSize, WordRegex. -# SupportedStyles: percent, brackets -Style/WordArray: - Exclude: - - 'app/helpers/languages_helper.rb' diff --git a/.ruby-version b/.ruby-version index b347b11eac..6cb9d3dd0d 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.3 +3.4.3 diff --git a/.simplecov b/.simplecov deleted file mode 100644 index fbd0207bec..0000000000 --- a/.simplecov +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -if ENV['CI'] - require 'simplecov-lcov' - SimpleCov::Formatter::LcovFormatter.config.report_with_single_file = true - SimpleCov.formatter = SimpleCov::Formatter::LcovFormatter -else - SimpleCov.formatter = SimpleCov::Formatter::HTMLFormatter -end - -SimpleCov.start 'rails' do - enable_coverage :branch - - add_filter 'lib/linter' - - add_group 'Libraries', 'lib' - add_group 'Policies', 'app/policies' - add_group 'Presenters', 'app/presenters' - add_group 'Serializers', 'app/serializers' - add_group 'Services', 'app/services' - add_group 'Validators', 'app/validators' -end diff --git a/AUTHORS_KB.md b/AUTHORS_KB.md index 054daa72ed..2cda5fd31d 100644 --- a/AUTHORS_KB.md +++ b/AUTHORS_KB.md @@ -5,12 +5,14 @@ kmyblueフォークは、δ»₯δΈ‹γζ–Ήγθ²’ηŒγ«γ‚ˆγ£γ¦ζˆγ‚Šη«‹γ£γ¦γ„ます。 本εΆMastodonγθ²’ηŒθ€…に぀いては、`AUTHORS.md`をご覧ください。 +- [aoisensi](https://github.com/aoisensi) - [KMY](https://github.com/kmycode) - [S-H-GAMELINKS](https://github.com/S-H-GAMELINKS) +- [Yuicho](https://github.com/yuicho) ## η‰Ήθ¨˜ kmyblueフォークγι–‹η™Ίγ«γ‚γŸγ£γ¦γ€API・Activityδ»•ζ§˜γθ¨­θ¨ˆοΌˆδΈ€ιƒ¨ζ©Ÿθƒ½γ«γ€γ„γ¦γ―ε†…ιƒ¨δ»•ζ§˜οΌ‰η­–εšγιŽη¨‹γ§δΈ‹θ¨˜γƒͺγƒγ‚Έγƒˆγƒͺγγ‚³γƒΌγƒ‰γ‚’ε‚θ€ƒγ«γ—γΎγ—γŸγ€‚ -kmyblueフォークに直ζŽ₯θ²’ηŒγ—γŸγ‚γ‘γ§γ―γ‚γ‚ŠγΎγ›γ‚“γŒγ€δ»₯δΈ‹γγƒͺγƒγ‚Έγƒˆγƒͺにある硡文字γƒͺγ‚’γ‚―γ‚·γƒ§γƒ³ζ©Ÿθƒ½γƒ»ζ€œη΄’η―„ε›²ζ©Ÿθƒ½γγ‚³γƒΌγƒ‰γγ†γ‘γ€γ”γδΈ€ιƒ¨γ§γ―γ‚γ‚ŠγΎγ™γŒkmyblueγΈθ»’ε†™γ—γŸη‡ζ‰€γŒγ”γ–γ„γΎγ™γŸγ‚γ€γŠεε‰θ¨˜θΌ‰γ•γ›γ¦γ„γŸγ γγΎγ™γ€‚ +kmyblueフォークに直ζŽ₯θ²’ηŒγ—γŸγ‚γ‘γ§γ―γ‚γ‚ŠγΎγ›γ‚“γŒγ€δ»₯δΈ‹γγƒͺγƒγ‚Έγƒˆγƒͺにある硡文字γƒͺγ‚’γ‚―γ‚·γƒ§γƒ³ζ©Ÿθƒ½γƒ»ζ€œη΄’η―„ε›²ζ©Ÿθƒ½γγ‚³γƒΌγƒ‰γγ†γ‘一部にkmyblueγΈθ»’ε†™γ—γŸη‡ζ‰€γŒγ”γ–γ„γΎγ™γŸγ‚γ€γŠεε‰θ¨˜θΌ‰γ•γ›γ¦γ„γŸγ γγΎγ™γ€‚ - [Fedibird](https://github.com/fedibird/mastodon) diff --git a/Aptfile b/Aptfile index 5e033f1365..06c91d4c7b 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 11cd633870..4dd4783597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,700 @@ All notable changes to this project will be documented in this file. +## [4.3.7] - 2025-04-02 + +### Add + +- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire) +- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire) + +### Changed + +- Change account suspensions to be federated to recently-followed accounts as well (#34294 by @ClearlyClaire) +- Change `AccountReachFinder` to consider statuses based on suspension date (#32805 and #34291 by @ClearlyClaire and @mjankowski) +- Change user archive signed URL TTL from 10 seconds to 1 hour (#34254 by @ClearlyClaire) + +### Fixed + +- Fix static version of animated PNG emojis not being properly extracted (#34337 by @ClearlyClaire) +- Fix filters not applying in detailed view, favourites and bookmarks (#34259 and #34260 by @ClearlyClaire) +- Fix handling of malformed/unusual HTML (#34201 by @ClearlyClaire) +- Fix `CacheBuster` being queued for missing media attachments (#34253 by @ClearlyClaire) +- Fix incorrect URL being used when cache busting (#34189 by @ClearlyClaire) +- Fix streaming server refusing unix socket path in `DATABASE_URL` (#34091 by @ClearlyClaire) +- Fix β€œx” hotkey not working on boosted filtered posts (#33758 by @ClearlyClaire) + +## [4.3.6] - 2025-03-13 + +### Security + +- Update dependency `omniauth-saml` +- Update dependency `rack` + +### Fixed + +- Fix Stoplight errors when using `REDIS_NAMESPACE` (#34126 by @ClearlyClaire) + +## [4.3.5] - 2025-03-10 + +### Changed + +- Change hashtag suggestion to prefer personal history capitalization (#34070 by @ClearlyClaire) + +### Fixed + +- Fix processing errors for some HEIF images from iOS 18 (#34086 by @renchap) +- Fix streaming server not filtering unknown-language posts from public timelines (#33774 by @ClearlyClaire) +- Fix preview cards under Content Warnings not being shown in detailed statuses (#34068 by @ClearlyClaire) +- Fix username and display name being hidden on narrow screens in moderation interface (#33064 by @ClearlyClaire) + +## [4.3.4] - 2025-02-27 + +### Security + +- Update dependencies +- Change HTML sanitization to remove unusable and unused `embed` tag (#34021 by @ClearlyClaire, [GHSA-mq2m-hr29-8gqf](https://github.com/mastodon/mastodon/security/advisories/GHSA-mq2m-hr29-8gqf)) +- Fix rate-limit on sign-up email verification ([GHSA-v39f-c9jj-8w7h](https://github.com/mastodon/mastodon/security/advisories/GHSA-v39f-c9jj-8w7h)) +- Fix improper disclosure of domain blocks to unverified users ([GHSA-94h4-fj37-c825](https://github.com/mastodon/mastodon/security/advisories/GHSA-94h4-fj37-c825)) + +### Changed + +- Change preview cards to be shown when Content Warnings are expanded (#33827 by @ClearlyClaire) +- Change warnings against changing encryption secrets to be even more noticeable (#33631 by @ClearlyClaire) +- Change `mastodon:setup` to prevent overwriting already-configured servers (#33603, #33616, and #33684 by @ClearlyClaire and @mjankowski) +- Change notifications from moderators to not be filtered (#32974 and #33654 by @ClearlyClaire and @mjankowski) + +### Fixed + +- Fix `GET /api/v2/notifications/:id` and `POST /api/v2/notifications/:id/dismiss` for ungrouped notifications (#33990 by @ClearlyClaire) +- Fix issue with some versions of libvips on some systems (#33853 by @kleisauke) +- Fix handling of duplicate mentions in incoming status `Update` (#33911 by @ClearlyClaire) +- Fix inefficiencies in timeline generation (#33839 and #33842 by @ClearlyClaire) +- Fix emoji rewrite adding unnecessary curft to the DOM for most emoji (#33818 by @ClearlyClaire) +- Fix `tootctl feeds build` not building list timelines (#33783 by @ClearlyClaire) +- Fix flaky test in `/api/v2/notifications` tests (#33773 by @ClearlyClaire) +- Fix incorrect signature after HTTP redirect (#33757 and #33769 by @ClearlyClaire) +- Fix polls not being validated on edition (#33755 by @ClearlyClaire) +- Fix media preview height in compose form when 3 or more images are attached (#33571 by @ClearlyClaire) +- Fix preview card sizing in β€œAuthor attribution” in profile settings (#33482 by @ClearlyClaire) +- Fix processing of incoming notifications for unfilterable types (#33429 by @ClearlyClaire) +- Fix featured tags for remote accounts not being kept up to date (#33372, #33406, and #33425 by @ClearlyClaire and @mjankowski) +- Fix notification polling showing a loading bar in web UI (#32960 by @Gargron) +- Fix accounts table long display name (#29316 by @WebCoder49) +- Fix exclusive lists interfering with notifications (#28162 by @ShadowJonathan) + +## [4.3.3] - 2025-01-16 + +### Security + +- Fix insufficient validation of account URIs ([GHSA-5wxh-3p65-r4g6](https://github.com/mastodon/mastodon/security/advisories/GHSA-5wxh-3p65-r4g6)) +- Update dependencies + +### Fixed + +- Fix `libyaml` missing from `Dockerfile` build stage (#33591 by @vmstan) +- Fix incorrect notification settings migration for non-followers (#33348 by @ClearlyClaire) +- Fix down clause for notification policy v2 migrations (#33340 by @jesseplusplus) +- Fix error decrementing status count when `FeaturedTags#last_status_at` is `nil` (#33320 by @ClearlyClaire) +- Fix last paginated notification group only including data on a single notification (#33271 by @ClearlyClaire) +- Fix processing of mentions for post edits with an existing corresponding silent mention (#33227 by @ClearlyClaire) +- Fix deletion of unconfirmed users with Webauthn set (#33186 by @ClearlyClaire) +- Fix empty authors preview card serialization (#33151, #33466 by @mjankowski and @ClearlyClaire) + +## [4.3.2] - 2024-12-03 + +### Added + +- Add `tootctl feeds vacuum` (#33065 by @ClearlyClaire) +- Add error message when user tries to follow their own account (#31910 by @lenikadali) +- Add client_secret_expires_at to OAuth Applications (#30317 by @ThisIsMissEm) + +### Changed + +- Change design of Content Warnings and filters (#32543 by @ClearlyClaire) + +### Fixed + +- Fix processing incoming post edits with mentions to unresolvable accounts (#33129 by @ClearlyClaire) +- Fix error when including multiple instances of `embed.js` (#33107 by @YKWeyer) +- Fix inactive users' timelines being backfilled on follow and unsuspend (#33094 by @ClearlyClaire) +- Fix direct inbox delivery pushing posts into inactive followers' timelines (#33067 by @ClearlyClaire) +- Fix `TagFollow` records not being correctly handled in account operations (#33063 by @ClearlyClaire) +- Fix pushing hashtag-followed posts to feeds of inactive users (#33018 by @Gargron) +- Fix duplicate notifications in notification groups when using slow mode (#33014 by @ClearlyClaire) +- Fix posts made in the future being allowed to trend (#32996 by @ClearlyClaire) +- Fix uploading higher-than-wide GIF profile picture with libvips enabled (#32911 by @ClearlyClaire) +- Fix domain attribution field having autocorrect and autocapitalize enabled (#32903 by @ClearlyClaire) +- Fix titles being escaped twice (#32889 by @ClearlyClaire) +- Fix list creation limit check (#32869 by @ClearlyClaire) +- Fix error in `tootctl email_domain_blocks` when supplying `--with-dns-records` (#32863 by @mjankowski) +- Fix `min_id` and `max_id` causing error in search API (#32857 by @Gargron) +- Fix inefficiencies when processing removal of posts that use featured tags (#32787 by @ClearlyClaire) +- Fix alt-text pop-in not using the translated description (#32766 by @ClearlyClaire) +- Fix preview cards with long titles erroneously causing layout changes (#32678 by @ClearlyClaire) +- Fix embed modal layout on mobile (#32641 by @DismalShadowX) +- Fix and improve batch attachment deletion handling when using OpenStack Swift (#32637 by @hugogameiro) +- Fix blocks not being applied on link timeline (#32625 by @tribela) +- Fix follow counters being incorrectly changed (#32622 by @oneiros) +- Fix 'unknown' media attachment type rendering (#32613 and #32713 by @ThisIsMissEm and @renatolond) +- Fix tl language native name (#32606 by @seav) + +### Security + +- Update dependencies + +## [4.3.1] - 2024-10-21 + +### Added + +- Add more explicit explanations about author attribution and `fediverse:creator` (#32383 by @ClearlyClaire) +- Add ability to group follow notifications in WebUI, can be disabled in the column settings (#32520 by @renchap) +- Add back a 6 hours mute duration option (#32522 by @renchap) +- Add note about not changing ActiveRecord encryption secrets once they are set (#32413, #32476, #32512, and #32537 by @ClearlyClaire and @mjankowski) + +### Changed + +- Change translation feature to translate to selected regional variant (e.g. pt-BR) if available (#32428 by @c960657) + +### Removed + +- Remove ability to get embed code for remote posts (#32578 by @ClearlyClaire)\ + Getting the embed code is only reliable for local posts.\ + It never worked for non-Mastodon servers, and stopped working correctly with the changes made in 4.3.0.\ + We have therefore decided to remove the menu entry while we investigate solutions. + +### Fixed + +- Fix follow recommendation moderation page default language when using regional variant (#32580 by @ClearlyClaire) +- Fix column-settings spacing in local timeline in advanced view (#32567 by @lindwurm) +- Fix broken i18n in text welcome mailer tags area (#32571 by @mjankowski) +- Fix missing or incorrect cache-control headers for Streaming server (#32551 by @ThisIsMissEm) +- Fix only the first paragraph being displayed in some notifications (#32348 by @ClearlyClaire) +- Fix reblog icons on account media view (#32506 by @tribela) +- Fix Content-Security-Policy not allowing OpenStack SWIFT object storage URI (#32439 by @kenkiku1021) +- Fix back arrow pointing to the incorrect direction in RTL languages (#32485 by @renchap) +- Fix streaming server using `REDIS_USERNAME` instead of `REDIS_USER` (#32493 by @ThisIsMissEm) +- Fix follow recommendation carrousel scrolling on RTL layouts (#32462 and #32505 by @ClearlyClaire) +- Fix follow recommendation suppressions not applying immediately (#32392 by @ClearlyClaire) +- Fix language of push notifications (#32415 by @ClearlyClaire) +- Fix mute duration not being shown in list of muted accounts in web UI (#32388 by @ClearlyClaire) +- Fix β€œMark every notification as read” not updating the read marker if scrolled down (#32385 by @ClearlyClaire) +- Fix β€œMention” appearing for otherwise filtered posts (#32356 by @ClearlyClaire) +- Fix notification requests from suspended accounts still being listed (#32354 by @ClearlyClaire) +- Fix list edition modal styling (#32358 and #32367 by @ClearlyClaire and @vmstan) +- Fix 4 columns barely not fitting on 1920px screen (#32361 by @ClearlyClaire) +- Fix icon alignment in applications list (#32293 by @mjankowski) + +## [4.3.0] - 2024-10-08 + +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. +- Fix ReDoS vulnerability on some Ruby versions ([GHSA-jpxp-r43f-rhvx](https://github.com/mastodon/mastodon/security/advisories/GHSA-jpxp-r43f-rhvx)) +- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 and #32241 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, #31929, #32089, #32085, #32243, #32179 and #32254 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/dismiss`: 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, #31723, #32062 and #32281 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 [`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, #31900 and #32188 by @Gargron, @mjankowski 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 and #32093 by @Gargron) +- Add β€œA Mastodon update is available.” message on admin dashboard for non-bugfix updates (#32106 by @ClearlyClaire) +- Add ability to view alt text by clicking the ALT badge in web UI (#32058 by @Gargron) +- Add preview of followers removed in domain block modal in web UI (#32032 and #32105 by @ClearlyClaire and @Gargron) +- Add reblogs and favourites counts to statuses in ActivityPub (#32007 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, #30858 and #32104 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, #30350 and #31998 by @julianocosta89, @renchap, @robbkidd and @timetinytim)\ + 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 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 date of account deletion in list of accounts in the admin interface (#25640 by @tribela) +- 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 support for serving JRD `/.well-known/host-meta.json` in addition to XRD host-meta (#32206 by @c960657) +- 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, #31525, #32153, and #32201 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, #31889 and #32033 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, #31510 and #32128 by @ClearlyClaire, @Gargron, @mjankowski, @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, #29879, #32073 and #32132 by @c960657, @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, #32135 and #32271 by @Gargron) +- Change inner borders in media galleries in web UI (#31852 by @Gargron) +- Change design of media attachments and profile media tab in web UI (#31807, #32048, #31967, #32217, #32224 and #32257 by @ClearlyClaire and @Gargron) +- Change labels on thread indicators in web UI (#31806 by @Gargron) +- Change label of "Data export" menu item in settings interface (#32099 by @c960657) +- Change responsive break points on navigation panel in web UI (#32034 by @Gargron) +- Change cursor to `not-allowed` on disabled buttons (#32076 by @mjankowski) +- Change OAuth authorization prompt to not refer to apps as β€œthird-party” (#32005 by @Gargron) +- Change Mastodon to issue correct HTTP signatures by default (#31994 by @ClearlyClaire) +- Change zoom icon in web UI (#29683 by @Gargron) +- Change directory page to use URL query strings for options (#31980, #31977 and #31984 by @ClearlyClaire and @renchap) +- 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 unused E2EE messaging code and related `crypto` OAuth scope (#31193, #31945, #31963, and #31964 by @ClearlyClaire and @mjankowski) +- 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 error when accepting an appeal for sensitive posts deleted in the meantime (#32037 by @ClearlyClaire) +- Fix error when encountering reblog of deleted post in feed rebuild (#32001 by @ClearlyClaire) +- Fix Safari browser glitch related to horizontal scrolling (#31960 by @Gargron) +- Fix unresolvable mentions sometimes preventing processing incoming posts (#29215 by @tribela and @ClearlyClaire) +- Fix too many requests caused by relationship look-ups in web UI (#32042 by @Gargron) +- Fix links for reblogs in moderation interface (#31979 by @ClearlyClaire) +- Fix the appearance of avatars when they do not load (#31966 and #32270 by @Gargron and @renchap) +- Fix spurious error notifications for aborted requests in web UI (#31952 by @c960657) +- 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 wrapping in dashboard quick access buttons (#32043 by @renchap) +- Fix recently used tags hint being displayed in profile edition page when there is none (#32120 by @mjankowski) +- Fix checkbox lists on narrow screens in the settings interface (#32112 by @mjankowski) +- Fix the position of status action buttons being affected by interaction counters (#32084 by @renchap) +- Fix the summary of converted ActivityPub object types to be treated as HTML (#28629 by @Menrath) +- 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, #31445, #32091, #32147 and #32137 by @ClearlyClaire, @mjankowski, @valtlai and @vmstan) +- Fix editing description of media uploads with custom thumbnails (#32221 by @ClearlyClaire) +- 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 unneeded requests to blocked domains when receiving relayed signed activities from them (#31161 by @ClearlyClaire) +- 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 navigation item active highlight for some paths (#32159 by @mjankowski) +- 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 + +- Update dependencies +- Fix private mention filtering ([GHSA-5fq7-3p3j-9vrf](https://github.com/mastodon/mastodon/security/advisories/GHSA-5fq7-3p3j-9vrf)) +- Fix password change endpoint not being rate-limited ([GHSA-q3rg-xx5v-4mxh](https://github.com/mastodon/mastodon/security/advisories/GHSA-q3rg-xx5v-4mxh)) +- Add hardening around rate-limit bypass ([GHSA-c2r5-cfqr-c553](https://github.com/mastodon/mastodon/security/advisories/GHSA-c2r5-cfqr-c553)) + +### Added + +- Add rate-limit on OAuth application registration ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/30316)) +- Add fallback redirection when getting a webfinger query `WEB_DOMAIN@WEB_DOMAIN` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28592)) +- Add `digest` attribute to `Admin::DomainBlock` entity in REST API ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/29092)) + +### Removed + +- Remove superfluous application-level caching in some controllers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29862)) +- Remove aggressive OAuth application vacuuming ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/30316)) + +### Fixed + +- Fix leaking Elasticsearch connections in Sidekiq processes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30450)) +- Fix language of remote posts not being recognized when using unusual casing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30403)) +- Fix off-by-one in `tootctl media` commands ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30306)) +- Fix removal of allowed domains (in `LIMITED_FEDERATION_MODE`) not being recorded in the audit log ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/30125)) +- Fix not being able to block a subdomain of an already-blocked domain through the API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30119)) +- Fix `Idempotency-Key` being ignored when scheduling a post ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30084)) +- Fix crash when supplying the `FFMPEG_BINARY` environment variable ([timothyjrogers](https://github.com/mastodon/mastodon/pull/30022)) +- Fix improper email address validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29838)) +- Fix results/query in `api/v1/featured_tags/suggestions` ([mjankowski](https://github.com/mastodon/mastodon/pull/29597)) +- Fix unblocking internationalized domain names under certain conditions ([tribela](https://github.com/mastodon/mastodon/pull/29530)) +- Fix admin account created by `mastodon:setup` not being auto-approved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29379)) +- Fix reference to non-existent var in CLI maintenance command ([mjankowski](https://github.com/mastodon/mastodon/pull/28363)) + +## [4.2.8] - 2024-02-23 + +### Added + +- Add hourly task to automatically require approval for new registrations in the absence of moderators ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29318), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/29355)) + In order to prevent future abandoned Mastodon servers from being used for spam, harassment and other malicious activity, Mastodon will now automatically switch new user registrations to require moderator approval whenever they are left open and no activity (including non-moderation actions from apps) from any logged-in user with permission to access moderation reports has been detected in a full week. + When this happens, users with the permission to change server settings will receive an email notification. + This feature is disabled when `EMAIL_DOMAIN_ALLOWLIST` is used, and can also be disabled with `DISABLE_AUTOMATIC_SWITCHING_TO_APPROVED_REGISTRATIONS=true`. + +### Changed + +- Change registrations to be closed by default on new installations ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29280)) + If you are running a server and never changed your registrations mode from the default, updating will automatically close your registrations. + Simply re-enable them through the administration interface or using `tootctl settings registrations open` if you want to enable them again. + +### Fixed + +- Fix processing of remote ActivityPub actors making use of `Link` objects as `Image` `url` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29335)) +- Fix link verifications when page size exceeds 1MB ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29358)) + +## [4.2.7] - 2024-02-16 + +### Fixed + +- Fix OmniAuth tests and edge cases in error handling ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29201), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/29207)) +- Fix new installs by upgrading to the latest release of the `nsa` gem, instead of a no longer existing commit ([mjankowski](https://github.com/mastodon/mastodon/pull/29065)) + +### Security + +- Fix insufficient checking of remote posts ([GHSA-jhrq-qvrm-qr36](https://github.com/mastodon/mastodon/security/advisories/GHSA-jhrq-qvrm-qr36)) + +## [4.2.6] - 2024-02-14 + +### Security + +- Update the `sidekiq-unique-jobs` dependency (see [GHSA-cmh9-rx85-xj38](https://github.com/mhenrixon/sidekiq-unique-jobs/security/advisories/GHSA-cmh9-rx85-xj38)) + In addition, we have disabled the web interface for `sidekiq-unique-jobs` out of caution. + If you need it, you can re-enable it by setting `ENABLE_SIDEKIQ_UNIQUE_JOBS_UI=true`. + If you only need to clear all locks, you can now use `bundle exec rake sidekiq_unique_jobs:delete_all_locks`. +- Update the `nokogiri` dependency (see [GHSA-xc9x-jj77-9p9j](https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-xc9x-jj77-9p9j)) +- Disable administrative Doorkeeper routes ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/29187)) +- Fix ongoing streaming sessions not being invalidated when applications get deleted in some cases ([GHSA-7w3c-p9j8-mq3x](https://github.com/mastodon/mastodon/security/advisories/GHSA-7w3c-p9j8-mq3x)) + In some rare cases, the streaming server was not notified of access tokens revocation on application deletion. +- Change external authentication behavior to never reattach a new identity to an existing user by default ([GHSA-vm39-j3vx-pch3](https://github.com/mastodon/mastodon/security/advisories/GHSA-vm39-j3vx-pch3)) + Up until now, Mastodon has allowed new identities from external authentication providers to attach to an existing local user based on their verified e-mail address. + This allowed upgrading users from a database-stored password to an external authentication provider, or move from one authentication provider to another. + However, this behavior may be unexpected, and means that when multiple authentication providers are configured, the overall security would be that of the least secure authentication provider. + For these reasons, this behavior is now locked under the `ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH` environment variable. + In addition, regardless of this environment variable, Mastodon will refuse to attach two identities from the same authentication provider to the same account. + +## [4.2.5] - 2024-02-01 + +### Security + +- Fix insufficient origin validation (CVE-2024-23832, [GHSA-3fjr-858r-92rw](https://github.com/mastodon/mastodon/security/advisories/GHSA-3fjr-858r-92rw)) + ## [4.2.4] - 2024-01-24 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6ed08590a..43f8a79249 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,17 +4,17 @@ kmyblueγ―γ€γ‚³γƒŸγƒ₯ニティγζ„θ¦‹γ‚‚θžγγ«γ―θžγγΎγ™γŒε°Žε…₯す ## γƒγ‚°ε ±ε‘Š -γƒγ‚°γ«γ€γ„γ¦γ€ζœ€ζ–°γ‚ˆγ‚Šγ‚‚ιŽεŽ»γγƒγƒΌγ‚Έγƒ§γƒ³γΈγε―ΎεΏœγ―η‰Ήεˆ₯γͺ場合δ»₯ε€–γ―θ‘Œγ„γΎγ›γ‚“γ€‚ +バグに぀いて、Lζœ€ζ–°γ‚ˆγ‚Šγ‚‚ιŽεŽ»γγƒγƒΌγ‚Έγƒ§γƒ³γΈγε―ΎεΏœγ―、LTSγ‚„η‰Ήεˆ₯γͺ場合δ»₯ε€–γ―θ‘Œγ„γΎγ›γ‚“γ€‚ δ»₯δΈ‹γγ„γšγ‚Œγ‹γζ–Ήζ³•γ§ε ±ε‘Šγ—γ¦γγ γ•γ„γ€‚ -- [GitHub Issues](https://github.com/kmycode/mastodon/issues) +- [GitHub Issues](https://github.com/kmycode/mastodon/issues) οΌˆγ‚»γ‚­γƒ₯γƒͺγƒ†γ‚£γ‚€γƒ³γ‚·γƒ‡γƒ³γƒˆγ―γ“γ“γδΈ€η•ͺ下から) - [kmyblue開発者へγι€£η΅‘](https://kmy.blue/@askyq) - [kmyblue開発者へγγƒ‘ール](https://kmy.blue/about) ## 翻訳、プルγƒͺγ‚―γ‚¨γ‚Ήγƒˆ -ζ–°γ—γ„ζ©Ÿθƒ½γ‚„ζ—’ε­˜ζ©Ÿθƒ½γδΏζ­£γ«γ€γ„ては、プルγƒͺγ‚―γ‚¨γ‚ΉγƒˆγγŸγ‚γ«γ‚³γƒΌγƒ‰γ‚’δ½œζˆγ™γ‚‹ε‰γ«γ€γΎγšGitHub Issuesγ§ζ©Ÿθƒ½γζζ‘ˆγ‚’θ‘Œγ„kmyblueι–‹η™Ίθ€…γθ€ƒγˆγ‚’θžγγ“γ¨γ‚’γŠγ™γ™γ‚γ—γΎγ™γ€‚γƒγ‚°δΏζ­£γ€ηΏ»θ¨³γ€γƒ†γ‚Ήγƒˆγ‚³γƒΌγƒ‰γͺγ©γ―εŸΊζœ¬ε—γ‘ε…₯γ‚ŒγΎγ™γŒγ€δΎε­˜γƒ’γ‚Έγƒ₯ールγγƒγƒΌγ‚Έγƒ§γƒ³γ‚’γƒƒγƒ—γ«γ€γ„γ¦γ―ζœ¬εΆMastodonγ‚ˆγ‚Šγ‚‚ε…ˆγ«θ‘Œγ‹γͺγ„γ‚ˆγ†γ«γ—γ¦γγ γ•γ„γ€‚ +ζ–°γ—γ„ζ©Ÿθƒ½γ‚„ζ—’ε­˜ζ©Ÿθƒ½γδΏζ­£γ«γ€γ„ては、プルγƒͺγ‚―γ‚¨γ‚ΉγƒˆγγŸγ‚γ«γ‚³γƒΌγƒ‰γ‚’δ½œζˆγ™γ‚‹ε‰γ«γ€γΎγšGitHub Issuesγ§ζ©Ÿθƒ½γζζ‘ˆγ‚’θ‘Œγ„kmyblueι–‹η™Ίθ€…γθ€ƒγˆγ‚’θžγγ“γ¨γ‚’γŠγ™γ™γ‚γ—γΎγ™γ€‚γƒγ‚°δΏζ­£γ€ηΏ»θ¨³γ€γƒ†γ‚Ήγƒˆγ‚³γƒΌγƒ‰γͺγ©γ―εŸΊζœ¬ε—γ‘ε…₯γ‚ŒγΎγ™γŒγ€δΎε­˜γƒ’γ‚Έγƒ₯ールγγƒγƒΌγ‚Έγƒ§γƒ³γ‚’ップに぀いては特εˆ₯γͺδΊ‹ζƒ…γŒγͺγ‘γ‚Œγ°ζœ¬εΆMastodonγ‚ˆγ‚Šγ‚‚ε…ˆγ«θ‘Œγ‹γͺγ„γ‚ˆγ†γ«γ—γ¦γγ γ•γ„γ€‚ プルγƒͺγ‚―γ‚¨γ‚Ήγƒˆγγ‚Ώγ‚€γƒˆγƒ«γ«γ―、プルγƒͺγ‚―γ‚¨γ‚Ήγƒˆγε†…εΉγŒζ˜Žη’Ίγ«γͺγ‚‹γ‚ˆγ†γͺγ‚‚γγ‚’θ¨­εšγ—てください。 @@ -30,7 +30,6 @@ kmyblueγ―γ€γ‚³γƒŸγƒ₯ニティγζ„θ¦‹γ‚‚θžγγ«γ―θžγγΎγ™γŒε°Žε…₯す kmyblueγŒζ„ε›³ηš„γ«εŸθ£…していγͺγ„ζ©Ÿθƒ½γ―γ€δΎ‹γˆγ°δ»₯δΈ‹γγ‚‚γγŒγ‚γ‚ŠγΎγ™γ€‚θ©³γ—γ„η†η”±γŒηŸ₯γ‚ŠγŸγ„ε ΄εˆγ―[こγθ¨˜δΊ‹γ‚’参照するか](https://note.com/kmycode/n/n463410b5e03c)、εˆ₯ι€”γŠε•γ„εˆγ‚γ›γγ γ•γ„γ€‚γ‚‚γ‘γ‚γ‚“ζ˜Žη’Ίγͺζ Ήζ‹ γŒγ‚γ‚‹ε ΄εˆγ€γ‚γͺγŸγ―γ“γ‚Œγ«ζŠ—θ­°γ™γ‚‹ζ¨©εˆ©γ‚’ζœ‰γ—γΎγ™γŒγ€γ‚γͺγŸγŒγ“γkmyblueをフォークして新しいγƒͺγƒγ‚Έγƒˆγƒͺγ‚’δ½œγ‚‹γ»γ†γŒγ‚ˆγ‚Šθ‡ͺ由でしょう。 -- εΌ•η”¨ζ©Ÿθƒ½ - γŠζ°—γ«ε…₯γ‚ŠδΈ€θ¦§γε…¬ι–‹ - γƒ–γƒƒγ‚―γƒžγƒΌγ‚―εˆ†ι‘žγε…¬ι–‹ -- δ»–γγ‚΅γƒΌγƒγƒΌγζŠ•η¨Ώγ«ε―Ύγ—γ¦δ»–γγ‚΅γƒΌγƒγƒΌγγ‚’γ‚«γ‚¦γƒ³γƒˆγŒθ‘Œγ£γŸη΅΅ζ–‡ε­—γƒͺをクションγε—けε…₯γ‚Œ +- Fedibird、Misskeyγ«γ‚γ‚‹γ‚ˆγ†γͺθ©³η΄°γͺ画青葨瀺γ‚ͺプション diff --git a/Dockerfile b/Dockerfile index 119c266b89..6620f4c096 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ -# syntax=docker/dockerfile:1.4 +# syntax=docker/dockerfile:1.12 + +# 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/docs/DEVELOPMENT.md#docker # Please see https://docs.docker.com/engine/reference/builder for information about # the extended buildx capabilities used in this file. @@ -6,30 +9,35 @@ # See: https://docs.docker.com/build/building/multi-platform/ ARG TARGETPLATFORM=${TARGETPLATFORM} ARG BUILDPLATFORM=${BUILDPLATFORM} +ARG BASE_REGISTRY="docker.io" -# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.2.3"] -ARG RUBY_VERSION="3.2.3" -# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] -ARG NODE_MAJOR_VERSION="20" +# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"] +# renovate: datasource=docker depName=docker.io/ruby +ARG RUBY_VERSION="3.4.2" +# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] +# renovate: datasource=node-version depName=node +ARG NODE_MAJOR_VERSION="22" # 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 -# Ruby image to use for base image based on combined variables (ex: 3.2.3-slim-bookworm) -FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby +# Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim) +FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node +# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm) +FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA -# Example: v4.2.0-nightly.2023.11.09+something -# Overwrite existance of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] +# Example: v4.3.0-nightly.2023.11.09+pr-123456 +# Overwrite existence of 'alpha.X' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] ARG MASTODON_VERSION_PRERELEASE="" -# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="something"] +# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-123456"] ARG MASTODON_VERSION_METADATA="" +# Will be available as Mastodon::Version.source_commit +ARG SOURCE_COMMIT="" # Allow Ruby on Rails to serve static files # See: https://docs.joinmastodon.org/admin/config/#rails_serve_static_files ARG RAILS_SERVE_STATIC_FILES="true" # Allow to use YJIT compiler -# See: https://github.com/ruby/ruby/blob/master/doc/yjit/yjit.md +# See: https://github.com/ruby/ruby/blob/v3_2_4/doc/yjit/yjit.md ARG RUBY_YJIT_ENABLE="1" # Timezone used by the Docker container and runtime, change with [--build-arg TZ=Europe/Berlin] ARG TZ="Etc/UTC" @@ -40,27 +48,32 @@ ARG GID="991" # Apply Mastodon build options based on options above ENV \ -# Apply Mastodon version information + # Apply Mastodon version information MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \ MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \ -# Apply Mastodon static files and YJIT options + SOURCE_COMMIT="${SOURCE_COMMIT}" \ + # Apply Mastodon static files and YJIT options RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \ RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \ -# Apply timezone + # Apply timezone TZ=${TZ} ENV \ -# Configure the IP to bind Mastodon to when serving traffic + # Configure the IP to bind Mastodon to when serving traffic BIND="0.0.0.0" \ -# Use production settings for Yarn, Node and related nodejs based tools + # Use production settings for Yarn, Node.js and related tools NODE_ENV="production" \ -# Use production settings for Ruby on Rails + # Use production settings for Ruby on Rails RAILS_ENV="production" \ -# Add Ruby and Mastodon installation to the PATH + # Add Ruby and Mastodon installation to the PATH DEBIAN_FRONTEND="noninteractive" \ PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \ -# Optimize jemalloc 5.x performance - MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" + # 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 \ + # 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"] @@ -70,89 +83,174 @@ ARG TARGETPLATFORM RUN echo "Target platform is $TARGETPLATFORM" RUN \ -# Remove automatic apt cache Docker cleanup scripts + # Remove automatic apt cache Docker cleanup scripts rm -f /etc/apt/apt.conf.d/docker-clean; \ -# Sets timezone + # Sets timezone echo "${TZ}" > /etc/localtime; \ -# Creates mastodon user/group and sets home directory + # Creates mastodon user/group and sets home directory groupadd -g "${GID}" mastodon; \ useradd -l -u "${UID}" -g "${GID}" -m -d /opt/mastodon mastodon; \ -# Creates /mastodon symlink to /opt/mastodon + # Creates /mastodon symlink to /opt/mastodon ln -s /opt/mastodon /mastodon; # Set /opt/mastodon as working directory WORKDIR /opt/mastodon +# Add backport repository for some specific packages where we need the latest version +RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list + # hadolint ignore=DL3008,DL3005 RUN \ -# Mount Apt cache and lib directories from Docker buildx caches ---mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ ---mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ -# Apt update & upgrade to check for security updates to Debian image + # Mount Apt cache and lib directories from Docker buildx caches + --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Apt update & upgrade to check for security updates to Debian image apt-get update; \ apt-get dist-upgrade -yq; \ -# Install jemalloc, curl and other necessary components + # Install jemalloc, curl and other necessary components apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - ffmpeg \ - file \ - imagemagick \ - libjemalloc2 \ - patchelf \ - procps \ - tini \ - tzdata \ - wget \ + curl \ + file \ + libjemalloc2 \ + patchelf \ + procps \ + tini \ + tzdata \ + wget \ ; \ -# Patch Ruby to use jemalloc + # Patch Ruby to use jemalloc patchelf --add-needed libjemalloc.so.2 /usr/local/bin/ruby; \ -# Discard patchelf after use + # Discard patchelf after use apt-get purge -y \ - patchelf \ + patchelf \ ; # Create temporary build layer from base image -FROM ruby as build - -# Copy Node package configuration files into working directory -COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/ -COPY .yarn /opt/mastodon/.yarn - -COPY --from=node /usr/local/bin /usr/local/bin -COPY --from=node /usr/local/lib /usr/local/lib +FROM ruby AS build ARG TARGETPLATFORM # hadolint ignore=DL3008 RUN \ -# Mount Apt cache and lib directories from Docker buildx caches ---mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ ---mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ -# Install build tools and bundler dependencies from APT + # Mount Apt cache and lib directories from Docker buildx caches + --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Install build tools and bundler dependencies from APT apt-get install -y --no-install-recommends \ - g++ \ - gcc \ - git \ - libgdbm-dev \ - libgmp-dev \ - libicu-dev \ - libidn-dev \ - libpq-dev \ - libssl-dev \ - make \ - shared-mime-info \ - zlib1g-dev \ + autoconf \ + automake \ + build-essential \ + cmake \ + git \ + libgdbm-dev \ + libglib2.0-dev \ + libgmp-dev \ + libicu-dev \ + libidn-dev \ + libpq-dev \ + libssl-dev \ + libtool \ + libyaml-dev \ + meson \ + nasm \ + pkg-config \ + shared-mime-info \ + xz-utils \ + # libvips components + libcgif-dev \ + libexif-dev \ + libexpat1-dev \ + libgirepository1.0-dev \ + libheif-dev/bookworm-backports \ + libimagequant-dev \ + libjpeg62-turbo-dev \ + liblcms2-dev \ + liborc-dev \ + libspng-dev \ + libtiff-dev \ + libwebp-dev \ + # ffmpeg components + libdav1d-dev \ + liblzma-dev \ + libmp3lame-dev \ + libopus-dev \ + libsnappy-dev \ + libvorbis-dev \ + libvpx-dev \ + libx264-dev \ + libx265-dev \ ; +# Create temporary libvips specific build layer from build layer +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.16.1 +# 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 \ -# Configure Corepack - rm /usr/local/bin/yarn*; \ - corepack enable; \ - corepack prepare --activate; + 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 + +# 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.1 +# 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 \ + ./configure \ + --prefix=/usr/local/ffmpeg \ + --toolchain=hardened \ + --disable-debug \ + --disable-devices \ + --disable-doc \ + --disable-ffplay \ + --disable-network \ + --disable-static \ + --enable-ffmpeg \ + --enable-ffprobe \ + --enable-gpl \ + --enable-libdav1d \ + --enable-libmp3lame \ + --enable-libopus \ + --enable-libsnappy \ + --enable-libvorbis \ + --enable-libvpx \ + --enable-libwebp \ + --enable-libx264 \ + --enable-libx265 \ + --enable-shared \ + --enable-version3 \ + ; \ + make -j$(nproc); \ + make install; # Create temporary bundler specific build layer from build layer -FROM build as bundler +FROM build AS bundler ARG TARGETPLATFORM @@ -160,76 +258,109 @@ ARG TARGETPLATFORM COPY Gemfile* /opt/mastodon/ RUN \ -# Mount Ruby Gem caches ---mount=type=cache,id=gem-cache-${TARGETPLATFORM},target=/usr/local/bundle/cache/,sharing=locked \ -# Configure bundle to prevent changes to Gemfile and Gemfile.lock + # Mount Ruby Gem caches + --mount=type=cache,id=gem-cache-${TARGETPLATFORM},target=/usr/local/bundle/cache/,sharing=locked \ + # Configure bundle to prevent changes to Gemfile and Gemfile.lock bundle config set --global frozen "true"; \ -# Configure bundle to not cache downloaded Gems + # Configure bundle to not cache downloaded Gems bundle config set --global cache_all "false"; \ -# Configure bundle to only process production Gems + # Configure bundle to only process production Gems bundle config set --local without "development test"; \ -# Configure bundle to not warn about root user + # Configure bundle to not warn about root user bundle config set silence_root_warning "true"; \ -# Download and install required Gems + # Download and install required Gems bundle install -j"$(nproc)"; -# Create temporary node specific build layer from build layer -FROM build as yarn +# Create temporary assets build layer from build layer +FROM build AS precompiler ARG TARGETPLATFORM -# Copy Node package configuration files into working directory -COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/ -COPY streaming/package.json /opt/mastodon/streaming/ -COPY .yarn /opt/mastodon/.yarn +# Copy Mastodon sources into layer +COPY . /opt/mastodon/ + +# Copy Node.js binaries/libraries into layer +COPY --from=node /usr/local/bin /usr/local/bin +COPY --from=node /usr/local/lib /usr/local/lib + +RUN \ + # Configure Corepack + rm /usr/local/bin/yarn*; \ + corepack enable; \ + corepack prepare --activate; # hadolint ignore=DL3008 RUN \ ---mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ ---mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ -# Install Node packages + --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ + --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ + # Install Node.js packages yarn workspaces focus --production @mastodon/mastodon; -# Create temporary assets build layer from build layer -FROM build as precompiler - -# Copy Mastodon sources into precompiler layer -COPY . /opt/mastodon/ - -# Copy bundler and node packages from build layer to container -COPY --from=yarn /opt/mastodon /opt/mastodon/ +# Copy libvips components into layer for precompiler +COPY --from=libvips /usr/local/libvips/bin /usr/local/bin +COPY --from=libvips /usr/local/libvips/lib /usr/local/lib +# Copy bundler packages into layer for precompiler COPY --from=bundler /opt/mastodon /opt/mastodon/ COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/ -ARG TARGETPLATFORM - RUN \ -# Use Ruby on Rails to create Mastodon assets - OTP_SECRET=precompile_placeholder SECRET_KEY_BASE=precompile_placeholder bundle exec rails assets:precompile; \ -# Cleanup temporary files + ldconfig; \ + # Use Ruby on Rails to create Mastodon assets + SECRET_KEY_BASE_DUMMY=1 \ + bundle exec rails assets:precompile; \ + # Cleanup temporary files rm -fr /opt/mastodon/tmp; # Prep final Mastodon Ruby layer -FROM ruby as mastodon +FROM ruby AS mastodon ARG TARGETPLATFORM # hadolint ignore=DL3008 RUN \ -# Mount Apt cache and lib directories from Docker buildx caches ---mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ ---mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ -# Mount Corepack and Yarn caches from Docker buildx caches ---mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ ---mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ -# Apt update install non-dev versions of necessary components + # Mount Apt cache and lib directories from Docker buildx caches + --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Mount Corepack and Yarn caches from Docker buildx caches + --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ + --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ + # Apt update install non-dev versions of necessary components apt-get install -y --no-install-recommends \ - libssl3 \ - libpq5 \ - libicu72 \ - libidn12 \ - libreadline8 \ - libyaml-0-2 \ + libexpat1 \ + libglib2.0-0 \ + libicu72 \ + libidn12 \ + libpq5 \ + libreadline8 \ + libssl3 \ + libyaml-0-2 \ + # libvips components + libcgif0 \ + libexif12 \ + libheif1/bookworm-backports \ + libimagequant0 \ + libjpeg62-turbo \ + liblcms2-2 \ + liborc-0.4-0 \ + libspng0 \ + libtiff6 \ + libwebp7 \ + libwebpdemux2 \ + libwebpmux3 \ + # ffmpeg components + libdav1d6 \ + libmp3lame0 \ + libopencore-amrnb0 \ + libopencore-amrwb0 \ + libopus0 \ + libsnappy1v5 \ + libtheora0 \ + libvorbis0a \ + libvorbisenc2 \ + libvorbisfile3 \ + libvpx7 \ + libx264-164 \ + libx265-199 \ ; # Copy Mastodon sources into final layer @@ -240,16 +371,29 @@ COPY --from=precompiler /opt/mastodon/public/packs /opt/mastodon/public/packs COPY --from=precompiler /opt/mastodon/public/assets /opt/mastodon/public/assets # Copy bundler components to layer COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/ +# Copy libvips components to layer +COPY --from=libvips /usr/local/libvips/bin /usr/local/bin +COPY --from=libvips /usr/local/libvips/lib /usr/local/lib +# Copy ffpmeg components to layer +COPY --from=ffmpeg /usr/local/ffmpeg/bin /usr/local/bin +COPY --from=ffmpeg /usr/local/ffmpeg/lib /usr/local/lib RUN \ -# Precompile bootsnap code for faster Rails startup + ldconfig; \ + # Smoketest media processors + vips -v; \ + ffmpeg -version; \ + ffprobe -version; + +RUN \ + # Precompile bootsnap code for faster Rails startup bundle exec bootsnap precompile --gemfile app/ lib/; RUN \ -# Pre-create and chown system volume to Mastodon user + # Pre-create and chown system volume to Mastodon user mkdir -p /opt/mastodon/public/system; \ chown mastodon:mastodon /opt/mastodon/public/system; \ -# Set Mastodon user as owner of tmp folder + # Set Mastodon user as owner of tmp folder chown -R mastodon:mastodon /opt/mastodon/tmp; # Set the running user for resulting container @@ -257,4 +401,4 @@ USER mastodon # Expose default Puma ports EXPOSE 3000 # Set container tini as default entry point -ENTRYPOINT ["/usr/bin/tini", "--"] \ No newline at end of file +ENTRYPOINT ["/usr/bin/tini", "--"] diff --git a/FEDERATION.md b/FEDERATION.md index e3721d7241..2819fa935a 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -1,19 +1,35 @@ -## ActivityPub federation in Mastodon +# Federation + +## Supported federation protocols and standards + +- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server) +- [WebFinger](https://webfinger.net/) +- [Http Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) +- [NodeInfo](https://nodeinfo.diaspora.software/) + +## Supported FEPs + +- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) +- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md) +- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) +- [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) + +## ActivityPub in Mastodon Mastodon largely follows the ActivityPub server-to-server specification but it makes uses of some non-standard extensions, some of which are required for interacting with Mastodon at all. -Supported vocabulary: https://docs.joinmastodon.org/spec/activitypub/ +- [Supported ActivityPub vocabulary](https://docs.joinmastodon.org/spec/activitypub/) ### Required extensions -#### Webfinger +#### WebFinger In Mastodon, users are identified by a `username` and `domain` pair (e.g., `Gargron@mastodon.social`). This is used both for discovery and for unambiguously mentioning users across the fediverse. Furthermore, this is part of Mastodon's database design from its very beginnings. As a result, Mastodon requires that each ActivityPub actor uniquely maps back to an `acct:` URI that can be resolved via WebFinger. -More information and examples are available at: https://docs.joinmastodon.org/spec/webfinger/ +- [WebFinger information and examples](https://docs.joinmastodon.org/spec/webfinger/) #### HTTP Signatures @@ -21,11 +37,13 @@ In order to authenticate activities, Mastodon relies on HTTP Signatures, signing Mastodon requires all `POST` requests to be signed, and MAY require `GET` requests to be signed, depending on the configuration of the Mastodon server. -More information on HTTP Signatures, as well as examples, can be found here: https://docs.joinmastodon.org/spec/security/#http +- [HTTP Signatures information and examples](https://docs.joinmastodon.org/spec/security/#http) ### Optional extensions -- Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld -- Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/ -- Followers collection synchronization: https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md -- Search indexing consent for actors: https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md +- [Linked-Data Signatures](https://docs.joinmastodon.org/spec/security/#ld) +- [Bearcaps](https://docs.joinmastodon.org/spec/bearcaps/) + +### Additional documentation + +- [Mastodon documentation](https://docs.joinmastodon.org/) diff --git a/Gemfile b/Gemfile index fa71060637..9e5955e0b8 100644 --- a/Gemfile +++ b/Gemfile @@ -1,37 +1,36 @@ # frozen_string_literal: true source 'https://rubygems.org' -ruby '>= 3.0.0' +ruby '>= 3.2.0', '< 3.5.0' -gem 'puma', '~> 6.3' -gem 'rails', '~> 7.1.1' gem 'propshaft' -gem 'thor', '~> 1.2' +gem 'puma', '~> 6.3' gem 'rack', '~> 2.2.7' +gem 'rails', '~> 8.0' +gem 'thor', '~> 1.2' -# For why irb is in the Gemfile, see: https://ruby.social/@st0012/111444685161478182 -gem 'irb', '~> 1.8' - +gem 'dotenv' gem 'haml-rails', '~>2.0' gem 'pg', '~> 1.5' gem 'pghero' -gem 'dotenv-rails', '~> 2.8' +gem 'aws-sdk-core', '< 3.216.0', require: false # TODO: https://github.com/mastodon/mastodon/pull/34173#issuecomment-2733378873 gem 'aws-sdk-s3', '~> 1.123', require: false -gem 'fog-core', '<= 2.4.0' -gem 'fog-openstack', '~> 1.0', require: false -gem 'kt-paperclip', '~> 7.2' -gem 'md-paperclip-azure', '~> 2.2', require: false gem 'blurhash', '~> 0.1' +gem 'fog-core', '<= 2.6.0' +gem 'fog-openstack', '~> 1.0', require: false +gem 'jd-paperclip-azure', '~> 3.0', require: false +gem 'kt-paperclip', '~> 7.2' +gem 'ruby-vips', '~> 2.2', require: false gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' -gem 'bootsnap', '~> 1.17.0', require: false +gem 'bootsnap', '~> 1.18.0', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' gem 'devise', '~> 4.9' -gem 'devise-two-factor', '~> 4.1' +gem 'devise-two-factor' group :pam_authentication, optional: true do gem 'devise_pam_authenticatable2', '~> 9.2' @@ -39,83 +38,104 @@ end gem 'net-ldap', '~> 0.18' -# TODO: Point back at released omniauth-cas gem when PR merged -# https://github.com/dlindahl/omniauth-cas/pull/68 -gem 'omniauth-cas', github: 'stanhu/omniauth-cas', ref: '4211e6d05941b4a981f9a36b49ec166cecd0e271' -gem 'omniauth-saml', '~> 2.0' -gem 'omniauth_openid_connect', '~> 0.6.1' gem 'omniauth', '~> 2.0' +gem 'omniauth-cas', '~> 3.0.0.beta.1' +gem 'omniauth_openid_connect', '~> 0.8.0' gem 'omniauth-rails_csrf_protection', '~> 1.0' +gem 'omniauth-saml', '~> 2.0' gem 'color_diff', '~> 0.1' +gem 'csv', '~> 3.2' gem 'discard', '~> 1.2' gem 'doorkeeper', '~> 5.6' -gem 'ed25519', '~> 1.3' +gem 'faraday-httpclient' gem 'fast_blank', '~> 1.0' gem 'fastimage' gem 'hiredis', '~> 0.6' -gem 'redis-namespace', '~> 1.10' gem 'htmlentities', '~> 4.3' -gem 'http', '~> 5.1' +gem 'http', '~> 5.2.0' gem 'http_accept_language', '~> 2.1' -gem 'httplog', '~> 1.6.2' +gem 'httplog', '~> 1.7.0', require: false +gem 'i18n' gem 'idn-ruby', require: 'idn' +gem 'inline_svg' +gem 'irb', '~> 1.8' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' -gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' +gem 'linzer', '~> 0.6.1' +gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' +gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar' +gem 'mutex_m' gem 'nokogiri', '~> 1.15' -gem 'nsa', github: 'jhawthorn/nsa', ref: 'e020fcc3a54d993ab45b7194d89ab720296c111b' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' -gem 'posix-spawn' -gem 'public_suffix', '~> 5.0' -gem 'pundit', '~> 2.3' gem 'premailer-rails' +gem 'public_suffix', '~> 6.0' +gem 'pundit', '~> 2.3' gem 'rack-attack', '~> 6.6' gem 'rack-cors', '~> 2.0', require: 'rack/cors' -gem 'rails-i18n', '~> 7.0' -gem 'rails-settings-cached', '~> 0.6', git: 'https://github.com/mastodon/rails-settings-cached.git', branch: 'v0.6.6-aliases-true' +gem 'rails-i18n', '~> 8.0' gem 'redcarpet', '~> 3.6' gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] -gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' +gem 'redis-namespace', '~> 1.10' gem 'rqrcode', '~> 2.2' gem 'ruby-progressbar', '~> 1.13' -gem 'sanitize', '~> 6.0' +gem 'sanitize', '~> 7.0' gem 'scenic', '~> 1.7' gem 'sidekiq', '~> 6.5' +gem 'sidekiq-bulk', '~> 0.2.0' gem 'sidekiq-scheduler', '~> 5.0' gem 'sidekiq-unique-jobs', '~> 7.1' -gem 'sidekiq-bulk', '~> 0.2.0' -gem 'simple-navigation', '~> 4.4' gem 'simple_form', '~> 5.2' -gem 'stoplight', '~> 3.0.1' -gem 'strong_migrations', '1.6.4' +gem 'simple-navigation', '~> 4.4' +gem 'stoplight', '~> 4.1' +gem 'strong_migrations' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' gem 'tzinfo-data', '~> 1.2023' -gem 'webpacker', '~> 5.4' -gem 'webpush', github: 'ClearlyClaire/webpush', ref: 'f14a4d52e201128b1b00245d11b6de80d6cfdcd9' gem 'webauthn', '~> 3.0' +gem 'webpacker', '~> 5.4' +gem 'webpush', github: 'mastodon/webpush', ref: '9631ac63045cfabddacc69fc06e919b4c13eb913' gem 'json-ld' gem 'json-ld-preloaded', '~> 3.2' gem 'rdf-normalize', '~> 0.5' -gem 'private_address_check', '~> 0.5' +gem 'prometheus_exporter', '~> 2.2', require: false + +gem 'opentelemetry-api', '~> 1.5.0' + +group :opentelemetry do + gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false + gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false + gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false + gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.24.0', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false + gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.36.0', require: false + gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false + gem 'opentelemetry-sdk', '~> 1.4', require: false +end group :test do - # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab - gem 'rspec-github', '~> 2.4', require: false + # Enable usage of all available CPUs/cores during spec runs + gem 'flatware-rspec' - # RSpec progress bar formatter - gem 'fuubar', '~> 2.5' + # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab + gem 'rspec-github', '~> 3.0', require: false # RSpec helpers for email specs gem 'email_spec' - # Extra RSpec extenion methods and helpers for sidekiq - gem 'rspec-sidekiq', '~> 4.0' + # Extra RSpec extension methods and helpers for sidekiq + gem 'rspec-sidekiq', '~> 5.0' # Browser integration testing gem 'capybara', '~> 3.39' @@ -125,43 +145,36 @@ group :test do gem 'database_cleaner-active_record' # Used to mock environment variables - gem 'climate_control', '~> 0.2' - - # Generating fake data for specs - gem 'faker', '~> 3.2' - - # Generate test objects for specs - gem 'fabrication', '~> 2.30' - - # Add back helpers functions removed in Rails 5.1 - gem 'rails-controller-testing', '~> 1.0' + gem 'climate_control' # Validate schemas in specs - gem 'json-schema', '~> 4.0' + gem 'json-schema', '~> 5.0' # Test harness fo rack components gem 'rack-test', '~> 2.1' - # Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false + gem 'shoulda-matchers' + + # Coverage formatter for RSpec gem 'simplecov', '~> 0.22', require: false gem 'simplecov-lcov', '~> 0.8', require: false # Stub web requests for specs gem 'webmock', '~> 3.18' - - gem 'rspec-retry', '>= 0.6.2' end group :development do # Code linting CLI and plugins gem 'rubocop', require: false gem 'rubocop-capybara', require: false + gem 'rubocop-i18n', require: false gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false gem 'rubocop-rspec', require: false + gem 'rubocop-rspec_rails', require: false # Annotates modules with schema - gem 'annotate', '~> 3.2' + gem 'annotaterb', '~> 4.13', require: false # Enhanced error message pages for development gem 'better_errors', '~> 2.9' @@ -169,10 +182,10 @@ group :development do # Preview mail in the browser gem 'letter_opener', '~> 1.8' - gem 'letter_opener_web', '~> 2.0' + gem 'letter_opener_web', '~> 3.0' # Security analysis CLI tools - gem 'brakeman', '~> 6.0', require: false + gem 'brakeman', '~> 7.0', require: false gem 'bundler-audit', '~> 0.9', require: false # Linter CLI for HAML files @@ -184,28 +197,36 @@ end group :development, :test do # Interactive Debugging tools - gem 'debug', '~> 1.8' + gem 'debug', '~> 1.8', require: false + + # Generate fake data values + gem 'faker', '~> 3.2' + + # Generate factory objects + gem 'fabrication', '~> 2.30' # Profiling tools gem 'memory_profiler', require: false gem 'ruby-prof', require: false gem 'stackprof', require: false - gem 'test-prof' + gem 'test-prof', require: false # RSpec runner for rails - gem 'rspec-rails', '~> 6.0' + gem 'rspec-rails', '~> 7.0' end group :production do gem 'lograge', '~> 0.12' end +gem 'cocoon', '~> 1.2' gem 'concurrent-ruby', require: false gem 'connection_pool', require: false gem 'xorcist', '~> 1.1' -gem 'cocoon', '~> 1.2' -gem 'net-http', '~> 0.4.0' +gem 'net-http', '~> 0.6.0' gem 'rubyzip', '~> 2.3' gem 'hcaptcha', '~> 7.1' + +gem 'mail', '~> 2.8' diff --git a/Gemfile.lock b/Gemfile.lock index 6e6993c008..f13df0c43f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,332 +1,276 @@ GIT - remote: https://github.com/ClearlyClaire/webpush.git - revision: f14a4d52e201128b1b00245d11b6de80d6cfdcd9 - ref: f14a4d52e201128b1b00245d11b6de80d6cfdcd9 + remote: https://github.com/mastodon/webpush.git + revision: 9631ac63045cfabddacc69fc06e919b4c13eb913 + ref: 9631ac63045cfabddacc69fc06e919b4c13eb913 specs: - webpush (0.3.8) + webpush (1.1.0) hkdf (~> 0.2) jwt (~> 2.0) -GIT - remote: https://github.com/jhawthorn/nsa.git - revision: e020fcc3a54d993ab45b7194d89ab720296c111b - ref: e020fcc3a54d993ab45b7194d89ab720296c111b - specs: - nsa (0.2.8) - activesupport (>= 4.2, < 7.2) - concurrent-ruby (~> 1.0, >= 1.0.2) - sidekiq (>= 3.5) - statsd-ruby (~> 1.4, >= 1.4.0) - -GIT - remote: https://github.com/mastodon/rails-settings-cached.git - revision: 86328ef0bd04ce21cc0504ff5e334591e8c2ccab - branch: v0.6.6-aliases-true - specs: - rails-settings-cached (0.6.6) - rails (>= 4.2.0) - -GIT - remote: https://github.com/stanhu/omniauth-cas.git - revision: 4211e6d05941b4a981f9a36b49ec166cecd0e271 - ref: 4211e6d05941b4a981f9a36b49ec166cecd0e271 - specs: - omniauth-cas (2.0.0) - addressable (~> 2.3) - nokogiri (~> 1.5) - omniauth (>= 1.2, < 3) - GEM remote: https://rubygems.org/ specs: - actioncable (7.1.2) - actionpack (= 7.1.2) - activesupport (= 7.1.2) + actioncable (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.2) - actionpack (= 7.1.2) - activejob (= 7.1.2) - activerecord (= 7.1.2) - activestorage (= 7.1.2) - activesupport (= 7.1.2) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.2) - actionpack (= 7.1.2) - actionview (= 7.1.2) - activejob (= 7.1.2) - activesupport (= 7.1.2) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + mail (>= 2.8.0) + actionmailer (8.0.2) + actionpack (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activesupport (= 8.0.2) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.2) - actionview (= 7.1.2) - activesupport (= 7.1.2) + actionpack (8.0.2) + actionview (= 8.0.2) + activesupport (= 8.0.2) nokogiri (>= 1.8.5) - racc rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.2) - actionpack (= 7.1.2) - activerecord (= 7.1.2) - activestorage (= 7.1.2) - activesupport (= 7.1.2) + useragent (~> 0.16) + actiontext (8.0.2) + actionpack (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.2) - activesupport (= 7.1.2) + actionview (8.0.2) + activesupport (= 8.0.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - active_model_serializers (0.10.14) + active_model_serializers (0.10.15) actionpack (>= 4.1) activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (7.1.2) - activesupport (= 7.1.2) + activejob (8.0.2) + activesupport (= 8.0.2) globalid (>= 0.3.6) - activemodel (7.1.2) - activesupport (= 7.1.2) - activerecord (7.1.2) - activemodel (= 7.1.2) - activesupport (= 7.1.2) + activemodel (8.0.2) + activesupport (= 8.0.2) + activerecord (8.0.2) + activemodel (= 8.0.2) + activesupport (= 8.0.2) timeout (>= 0.4.0) - activestorage (7.1.2) - actionpack (= 7.1.2) - activejob (= 7.1.2) - activerecord (= 7.1.2) - activesupport (= 7.1.2) + activestorage (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activesupport (= 8.0.2) marcel (~> 1.0) - activesupport (7.1.2) + activesupport (8.0.2) base64 + benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) - annotate (3.2.0) - activerecord (>= 3.2, < 8.0) - rake (>= 10.4, < 14.0) - ast (2.4.2) - attr_encrypted (4.0.0) - encryptor (~> 3.0.0) - attr_required (1.0.1) - awrence (1.2.1) - aws-eventstream (1.3.0) - aws-partitions (1.873.0) - aws-sdk-core (3.190.1) + annotaterb (4.14.0) + ast (2.4.3) + attr_required (1.0.2) + aws-eventstream (1.3.2) + aws-partitions (1.1087.0) + aws-sdk-core (3.215.1) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.8) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.75.0) - aws-sdk-core (~> 3, >= 3.188.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.142.0) - aws-sdk-core (~> 3, >= 3.189.0) + aws-sdk-kms (1.96.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.177.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.8) - aws-sigv4 (1.8.0) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.11.0) aws-eventstream (~> 1, >= 1.0.2) - azure-storage-blob (2.0.3) - azure-storage-common (~> 2.0) - nokogiri (~> 1, >= 1.10.8) - azure-storage-common (2.0.4) - faraday (~> 1.0) - faraday_middleware (~> 1.0, >= 1.0.0.rc1) - net-http-persistent (~> 4.0) - nokogiri (~> 1, >= 1.10.8) + azure-blob (0.5.7) + rexml base64 (0.2.0) bcp47_spec (0.2.1) bcrypt (3.1.20) + benchmark (0.4.0) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - better_html (2.0.2) - actionview (>= 6.0) - activesupport (>= 6.0) - ast (~> 2.0) - erubi (~> 1.4) - parser (>= 2.4) - smart_properties - bigdecimal (3.1.5) - bindata (2.4.15) - binding_of_caller (1.0.0) - debug_inspector (>= 0.0.1) - blurhash (0.1.7) - bootsnap (1.17.0) + bigdecimal (3.1.9) + bindata (2.5.1) + binding_of_caller (1.0.1) + debug_inspector (>= 1.2.0) + blurhash (0.1.8) + bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.1.1) + brakeman (7.0.2) racc - browser (5.3.1) + browser (6.2.0) brpoplpush-redis_script (0.1.3) concurrent-ruby (~> 1.0, >= 1.0.5) redis (>= 1.0, < 6) - builder (3.2.4) - bundler-audit (0.9.1) + builder (3.3.0) + bundler-audit (0.9.2) bundler (>= 1.2.0, < 3) thor (~> 1.0) - capybara (3.39.2) + capybara (3.40.0) addressable matrix mini_mime (>= 0.1.3) - nokogiri (~> 1.8) + nokogiri (~> 1.11) rack (>= 1.6.0) rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) case_transform (0.2) activesupport - cbor (0.5.9.6) - charlock_holmes (0.7.7) - chewy (7.4.0) + cbor (0.5.9.8) + charlock_holmes (0.7.9) + chewy (7.6.0) activesupport (>= 5.2) - elasticsearch (>= 7.12.0, < 7.14.0) + elasticsearch (>= 7.14.0, < 8) elasticsearch-dsl + childprocess (5.1.0) + logger (~> 1.5) chunky_png (1.4.0) - climate_control (0.2.0) + climate_control (1.2.0) cocoon (1.2.15) color_diff (0.1) - concurrent-ruby (1.2.2) - connection_pool (2.4.1) - cose (1.3.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.0) + cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) - crack (0.4.5) + crack (1.0.0) + bigdecimal rexml crass (1.0.6) - css_parser (1.14.0) + css_parser (1.21.1) addressable - database_cleaner-active_record (2.1.0) + csv (3.3.4) + database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.3.4) - debug (1.9.1) + date (3.4.1) + debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) - debug_inspector (1.1.0) - devise (4.9.3) + debug_inspector (1.2.0) + devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (4.1.1) - activesupport (~> 7.0) - attr_encrypted (>= 1.3, < 5, != 2) + devise-two-factor (6.1.0) + activesupport (>= 7.0, < 8.1) devise (~> 4.0) - railties (~> 7.0) + railties (>= 7.0, < 8.1) rotp (~> 6.0) devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.0) rpam2 (~> 4.0) - diff-lcs (1.5.0) - discard (1.3.0) - activerecord (>= 4.2, < 8) - docile (1.4.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.6.8) + diff-lcs (1.6.1) + discard (1.4.0) + activerecord (>= 4.2, < 9.0) + docile (1.4.1) + domain_name (0.6.20240107) + doorkeeper (5.8.2) railties (>= 5) - dotenv (2.8.1) - dotenv-rails (2.8.1) - dotenv (= 2.8.1) - railties (>= 3.2) - drb (2.2.0) - ruby2_keywords - ed25519 (1.3.0) - elasticsearch (7.13.3) - elasticsearch-api (= 7.13.3) - elasticsearch-transport (= 7.13.3) - elasticsearch-api (7.13.3) + dotenv (3.1.8) + drb (2.2.1) + 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.13.3) - faraday (~> 1) + 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) - encryptor (3.0.0) - erubi (1.12.0) - et-orbi (1.2.7) + email_validator (2.2.4) + activemodel + erubi (1.13.1) + et-orbi (1.2.11) tzinfo - excon (0.104.0) + excon (1.2.5) + logger fabrication (2.31.0) - faker (3.2.2) + faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (1.10.3) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (1.0.1) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.3) - faraday_middleware (1.2.0) - faraday (~> 1.0) + faraday (2.13.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) + faraday-httpclient (2.0.1) + httpclient (>= 2.2) + faraday-net_http (3.4.0) + net-http (>= 0.5.0) fast_blank (1.0.1) - fastimage (2.3.0) - ffi (1.15.5) - ffi-compiler (1.0.1) - ffi (>= 1.0.0) + fastimage (2.4.0) + ffi (1.17.2) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) rake - fog-core (2.3.0) + flatware (2.3.4) + drb + thor (< 2.0) + flatware-rspec (2.3.4) + flatware (= 2.3.4) + rspec (>= 3.6) + fog-core (2.6.0) builder - excon (~> 0.71) + excon (~> 1.0) formatador (>= 0.2, < 2.0) mime-types fog-json (1.2.0) fog-core multi_json (~> 1.10) - fog-openstack (1.1.0) + fog-openstack (1.1.5) fog-core (~> 2.1) fog-json (>= 1.0) formatador (1.1.0) - fugit (1.8.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 (4.30.2) + bigdecimal + rake (>= 13) + googleapis-common-protos-types (1.19.0) + google-protobuf (>= 3.18, < 5.a) haml (6.3.0) temple (>= 0.8.2) thor @@ -336,73 +280,89 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.52.0) - haml (>= 4.0) + haml_lint (0.62.0) + haml (>= 5.0) parallel (~> 1.10) rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hashdiff (1.0.1) + hashdiff (1.1.2) hashie (5.0.0) hcaptcha (7.1.0) json - highline (2.1.0) + highline (3.1.2) + reline hiredis (0.6.3) hkdf (0.3.0) htmlentities (4.3.4) - http (5.1.1) + http (5.2.0) addressable (~> 2.8) + base64 (~> 0.1) http-cookie (~> 1.0) http-form_data (~> 2.2) - llhttp-ffi (~> 0.4.0) - http-cookie (1.0.5) + llhttp-ffi (~> 0.5.0) + http-cookie (1.0.8) domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) - httpclient (2.8.3) - httplog (1.6.2) + httpclient (2.9.0) + mutex_m + httplog (1.7.0) rack (>= 2.0) rainbow (>= 2.0.0) - i18n (1.14.1) + i18n (1.14.7) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.13) + i18n-tasks (1.0.15) activesupport (>= 4.0.2) ast (>= 2.1.0) - better_html (>= 1.0, < 3.0) erubi highline (>= 2.0.0) i18n parser (>= 3.2.2.1) rails-i18n rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.8, >= 1.8.1) terminal-table (>= 1.5.1) idn-ruby (0.1.5) - io-console (0.7.1) - irb (1.11.0) - rdoc - reline (>= 0.3.8) + inline_svg (1.10.0) + activesupport (>= 3.0) + nokogiri (>= 1.6) + io-console (0.8.0) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jd-paperclip-azure (3.0.0) + addressable (~> 2.5) + azure-blob (~> 0.5.2) + hashie (~> 5.0) jmespath (1.6.2) - json (2.7.1) + json (2.10.2) json-canonicalization (1.0.0) - json-jwt (1.15.3) + json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap + base64 bindata - httpclient - json-ld (3.3.1) + faraday (~> 2.0) + faraday-follow_redirects + 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) - json-ld-preloaded (3.3.0) + rexml (~> 3.2) + json-ld-preloaded (3.3.1) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (4.1.1) - addressable (>= 2.8) + json-schema (5.1.1) + addressable (~> 2.8) + bigdecimal (~> 3.1) jsonapi-renderer (0.2.2) - jwt (2.7.1) + jwt (2.10.1) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -415,32 +375,42 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - kt-paperclip (7.2.1) + kt-paperclip (7.2.2) activemodel (>= 4.2.0) activesupport (>= 4.2.0) marcel (~> 1.0.1) mime-types - terrapin (~> 0.6.0) - language_server-protocol (3.17.0.3) - launchy (2.5.2) + terrapin (>= 0.6.0, < 2.0) + language_server-protocol (3.17.0.4) + launchy (3.1.1) addressable (~> 2.8) - letter_opener (1.8.1) - launchy (>= 2.2, < 3) - letter_opener_web (2.0.0) - actionmailer (>= 5.2) - letter_opener (~> 1.7) - railties (>= 5.2) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + letter_opener_web (3.0.0) + actionmailer (>= 6.1) + letter_opener (~> 1.9) + railties (>= 6.1) rexml link_header (0.0.8) - llhttp-ffi (0.4.0) + lint_roller (1.1.0) + linzer (0.6.5) + openssl (~> 3.0, >= 3.0.0) + rack (>= 2.2, < 4.0) + starry (~> 0.2) + stringio (~> 3.1, >= 3.1.2) + uri (~> 1.0, >= 1.0.2) + llhttp-ffi (0.5.1) ffi-compiler (~> 1.0) rake (~> 13.0) + logger (1.7.0) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.22.0) + loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -448,30 +418,24 @@ GEM net-imap net-pop net-smtp - marcel (1.0.2) + marcel (1.0.4) mario-redis-lock (1.2.1) redis (>= 3.0.5) matrix (0.4.2) - md-paperclip-azure (2.2.0) - addressable (~> 2.5) - azure-storage-blob (~> 2.0.1) - hashie (~> 5.0) - memory_profiler (1.0.1) - mime-types (3.5.2) + memory_profiler (1.1.0) + mime-types (3.6.2) + logger mime-types-data (~> 3.2015) - mime-types-data (3.2023.1205) + mime-types-data (3.2025.0408) mini_mime (1.1.5) - mini_portile2 (2.8.5) - minitest (5.20.0) - msgpack (1.7.2) + mini_portile2 (2.8.8) + minitest (5.25.5) + msgpack (1.8.0) multi_json (1.15.0) - multipart-post (2.3.0) - mutex_m (0.2.0) - net-http (0.4.0) + mutex_m (0.3.0) + net-http (0.6.0) uri - net-http-persistent (4.0.2) - connection_pool (~> 2.2) - net-imap (0.4.4) + net-imap (0.5.6) date net-protocol net-ldap (0.19.0) @@ -479,254 +443,379 @@ GEM net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0) + net-smtp (0.5.1) net-protocol - net-ssh (7.1.0) - nio4r (2.7.0) - nokogiri (1.16.2) + nio4r (2.7.4) + nokogiri (1.18.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.3) + oj (3.16.10) bigdecimal (>= 3.0) - omniauth (2.1.1) + ostruct (>= 0.2) + omniauth (2.1.3) hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection - omniauth-rails_csrf_protection (1.0.1) + omniauth-cas (3.0.1) + addressable (~> 2.8) + nokogiri (~> 1.12) + omniauth (~> 2.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_openid_connect (0.6.1) + omniauth-saml (2.2.3) + omniauth (~> 2.1) + ruby-saml (~> 1.18) + omniauth_openid_connect (0.8.0) omniauth (>= 1.9, < 3) - openid_connect (~> 1.1) - openid_connect (1.4.2) + openid_connect (~> 2.2) + openid_connect (2.3.1) activemodel attr_required (>= 1.0.0) - json-jwt (>= 1.15.0) - net-smtp - rack-oauth2 (~> 1.21) - swd (~> 1.3) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) tzinfo - validate_email validate_url - webfinger (~> 1.2) - openssl (3.2.0) + webfinger (~> 2.0) + openssl (3.3.0) openssl-signature_algorithm (1.3.0) openssl (> 2.0) + opentelemetry-api (1.5.0) + opentelemetry-common (0.22.0) + opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp (0.30.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.3.0) + opentelemetry-common (~> 0.21) + opentelemetry-instrumentation-action_mailer (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (~> 0.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-action_pack (0.12.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rack (~> 0.21) + opentelemetry-instrumentation-action_view (0.9.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (~> 0.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_job (0.8.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_model_serializers (0.22.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (>= 0.7.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_record (0.9.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_storage (0.1.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (~> 0.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_support (0.8.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-base (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.21) + opentelemetry-registry (~> 0.1) + opentelemetry-instrumentation-concurrent_ruby (0.22.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-excon (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-faraday (0.26.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-http (0.24.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-http_client (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-net_http (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-pg (0.30.0) + opentelemetry-api (~> 1.0) + opentelemetry-helpers-sql-obfuscation + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rack (0.26.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rails (0.36.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-action_mailer (~> 0.4.0) + opentelemetry-instrumentation-action_pack (~> 0.12.0) + opentelemetry-instrumentation-action_view (~> 0.9.0) + opentelemetry-instrumentation-active_job (~> 0.8.0) + opentelemetry-instrumentation-active_record (~> 0.9.0) + opentelemetry-instrumentation-active_storage (~> 0.1.0) + opentelemetry-instrumentation-active_support (~> 0.8.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) + opentelemetry-instrumentation-redis (0.26.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-sidekiq (0.26.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-registry (0.4.0) + opentelemetry-api (~> 1.1) + opentelemetry-sdk (1.8.0) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-registry (~> 0.2) + opentelemetry-semantic_conventions + opentelemetry-semantic_conventions (1.11.0) + opentelemetry-api (~> 1.0) orm_adapter (0.5.0) - ox (2.14.17) - parallel (1.24.0) - parser (3.2.2.4) + ostruct (0.6.1) + ox (2.14.22) + bigdecimal (>= 3.0) + parallel (1.27.0) + parser (3.3.8.0) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.4) - pghero (3.4.0) - activerecord (>= 6) - posix-spawn (0.3.15) - premailer (1.21.0) + pg (1.5.9) + pghero (3.6.2) + activerecord (>= 6.1) + pp (0.6.2) + prettyprint + 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.8.0) + prettyprint (0.2.0) + prism (1.4.0) + prometheus_exporter (2.2.0) + webrick + propshaft (1.1.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.1.2) + psych (5.2.3) + date stringio - public_suffix (5.0.4) - puma (6.4.2) + public_suffix (6.0.1) + puma (6.6.0) nio4r (~> 2.0) - pundit (2.3.1) + pundit (2.5.0) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.7.3) - rack (2.2.8) + racc (1.8.1) + rack (2.2.13) rack-attack (6.7.0) rack (>= 1.0, < 4) - rack-cors (2.0.1) + rack-cors (2.0.2) rack (>= 2.0.0) - rack-oauth2 (1.21.3) + rack-oauth2 (2.2.1) activesupport attr_required - httpclient + faraday (~> 2.0) + faraday-follow_redirects json-jwt (>= 1.11.0) rack (>= 2.1.0) - rack-protection (3.0.5) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rack-proxy (0.7.7) rack - rack-proxy (0.7.6) - rack - rack-session (1.0.1) + rack-session (1.0.2) rack (< 3) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) - rackup (1.0.0) + rackup (1.0.1) rack (< 3) webrick - rails (7.1.2) - actioncable (= 7.1.2) - actionmailbox (= 7.1.2) - actionmailer (= 7.1.2) - actionpack (= 7.1.2) - actiontext (= 7.1.2) - actionview (= 7.1.2) - activejob (= 7.1.2) - activemodel (= 7.1.2) - activerecord (= 7.1.2) - activestorage (= 7.1.2) - activesupport (= 7.1.2) + rails (8.0.2) + actioncable (= 8.0.2) + actionmailbox (= 8.0.2) + actionmailer (= 8.0.2) + actionpack (= 8.0.2) + actiontext (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activemodel (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) bundler (>= 1.15.0) - railties (= 7.1.2) - rails-controller-testing (1.0.5) - actionpack (>= 5.0.1.rc1) - actionview (>= 5.0.1.rc1) - activesupport (>= 5.0.1.rc1) + railties (= 8.0.2) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) - rails-i18n (7.0.8) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (8.0.1) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 8) - railties (7.1.2) - actionpack (= 7.1.2) - activesupport (= 7.1.2) - irb + railties (>= 8.0.0, < 9) + railties (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.1.0) - rdf (3.3.1) + rake (13.2.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.6.1) - rdf (~> 3.2) - rdoc (6.6.2) + rdf-normalize (0.7.0) + rdf (~> 3.3) + rdoc (6.13.1) psych (>= 4.0.0) - redcarpet (3.6.0) + redcarpet (3.6.1) redis (4.8.1) redis-namespace (1.11.0) redis (>= 4) redlock (1.3.2) redis (>= 3.0.0, < 6.0) - regexp_parser (2.8.3) - reline (0.4.1) + regexp_parser (2.10.0) + reline (0.6.1) io-console (~> 0.5) - request_store (1.5.1) + request_store (1.7.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.6) + rexml (3.4.1) rotp (6.3.0) - rouge (4.1.2) + rouge (4.5.1) rpam2 (4.0.2) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.3) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-github (2.4.0) + rspec-support (~> 3.13.0) + rspec-github (3.0.0) rspec-core (~> 3.0) - rspec-mocks (3.12.6) + rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-rails (6.1.0) - actionpack (>= 6.1) - activesupport (>= 6.1) - railties (>= 6.1) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) - rspec-retry (0.6.2) - rspec-core (> 3.3) - rspec-sidekiq (4.1.0) + rspec-support (~> 3.13.0) + rspec-rails (7.1.1) + actionpack (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-sidekiq (5.1.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) - sidekiq (>= 5, < 8) - rspec-support (3.12.1) - rubocop (1.59.0) + sidekiq (>= 5, < 9) + rspec-support (3.13.2) + rubocop (1.75.2) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) - parser (>= 3.2.2.4) + 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.30.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) - rubocop-capybara (2.20.0) - rubocop (~> 1.41) - rubocop-factory_bot (2.24.0) - rubocop (~> 1.33) - rubocop-performance (1.20.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.30.0, < 2.0) - rubocop-rails (2.23.1) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.44.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-capybara (2.22.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-i18n (3.2.3) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.31.0) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.30.0, < 2.0) - rubocop-rspec (2.25.0) - rubocop (~> 1.40) - rubocop-capybara (~> 2.17) - rubocop-factory_bot (~> 2.22) - ruby-prof (1.6.3) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rspec (3.5.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec_rails (2.31.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec (~> 3.5) + ruby-prof (1.7.1) ruby-progressbar (1.13.0) - ruby-saml (1.15.0) + ruby-saml (1.18.0) nokogiri (>= 1.13.10) rexml - ruby2_keywords (0.0.5) - rubyzip (2.3.2) - rufus-scheduler (3.9.1) - fugit (~> 1.1, >= 1.1.6) + ruby-vips (2.2.3) + ffi (~> 1.12) + logger + rubyzip (2.4.1) + rufus-scheduler (3.9.2) + fugit (~> 1.1, >= 1.11.1) safety_net_attestation (0.4.0) jwt (~> 2.0) - sanitize (6.1.0) + sanitize (7.0.0) crass (~> 1.0.2) - nokogiri (>= 1.12.0) - scenic (1.7.0) + nokogiri (>= 1.16.8) + scenic (1.8.0) activerecord (>= 4.0.0) railties (>= 4.0.0) - selenium-webdriver (4.16.0) + securerandom (0.4.1) + selenium-webdriver (4.31.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) + semantic_range (3.1.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) @@ -735,39 +824,40 @@ GEM thor (>= 0.20, < 3.0) simple-navigation (4.4.0) activesupport (>= 2.3.2) - simple_form (5.3.0) + simple_form (5.3.1) actionpack (>= 5.2) activemodel (>= 5.2) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) + simplecov-html (0.13.1) simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) - smart_properties (1.17.0) - stackprof (0.2.25) - statsd-ruby (1.5.0) - stoplight (3.0.2) + stackprof (0.2.27) + starry (0.2.0) + base64 + stoplight (4.1.1) redlock (~> 1.0) - stringio (3.1.0) - strong_migrations (1.6.4) - activerecord (>= 5.2) - swd (1.3.0) + stringio (3.1.6) + strong_migrations (2.3.0) + activerecord (>= 7) + swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) - httpclient (>= 2.4) + faraday (~> 2.0) + faraday-follow_redirects sysexits (1.2.0) temple (0.10.3) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) - terrapin (0.6.0) - climate_control (>= 0.0.3, < 1.0) - test-prof (1.3.1) - thor (1.3.0) - tilt (2.3.0) - timeout (0.4.1) - tpm-key_attestation (0.12.0) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + terrapin (1.1.0) + climate_control + test-prof (1.4.4) + thor (1.3.2) + tilt (2.6.0) + timeout (0.4.3) + tpm-key_attestation (0.14.0) bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) @@ -780,40 +870,40 @@ GEM tty-cursor (~> 0.7) tty-screen (~> 0.8) wisper (~> 2.0) - tty-screen (0.8.1) + tty-screen (0.8.2) twitter-text (3.1.0) idn-ruby unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2023.4) + tzinfo-data (1.2025.2) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext - unf_ext (0.0.8.2) - unicode-display_width (2.5.0) - uri (0.12.2) - validate_email (0.1.6) - activemodel (>= 3.0) - mail (>= 2.2.5) + unf_ext (0.0.9.1) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.3) + useragent (0.16.11) validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix warden (1.2.9) rack (>= 2.0.9) - webauthn (3.1.0) + webauthn (3.4.0) android_key_attestation (~> 0.3.0) - awrence (~> 1.1) bindata (~> 2.4) cbor (~> 0.5.9) cose (~> 1.1) openssl (>= 2.2) safety_net_attestation (~> 0.4.0) - tpm-key_attestation (~> 0.12.0) - webfinger (1.2.0) + tpm-key_attestation (~> 0.14.0) + webfinger (2.1.3) activesupport - httpclient (>= 2.4) - webmock (3.19.1) + faraday (~> 2.0) + faraday-follow_redirects + webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -822,16 +912,17 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - webrick (1.8.1) - websocket (1.2.10) - websocket-driver (0.7.6) + webrick (1.9.1) + websocket (1.2.11) + websocket-driver (0.7.7) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) wisper (2.0.1) xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.12) + zeitwerk (2.7.2) PLATFORMS ruby @@ -839,113 +930,135 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10) addressable (~> 2.8) - annotate (~> 3.2) + annotaterb (~> 4.13) + aws-sdk-core (< 3.216.0) aws-sdk-s3 (~> 1.123) better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.17.0) - brakeman (~> 6.0) + bootsnap (~> 1.18.0) + brakeman (~> 7.0) browser bundler-audit (~> 0.9) capybara (~> 3.39) charlock_holmes (~> 0.7.7) chewy (~> 7.3) - climate_control (~> 0.2) + climate_control cocoon (~> 1.2) color_diff (~> 0.1) concurrent-ruby connection_pool + csv (~> 3.2) database_cleaner-active_record debug (~> 1.8) devise (~> 4.9) - devise-two-factor (~> 4.1) + devise-two-factor devise_pam_authenticatable2 (~> 9.2) discard (~> 1.2) doorkeeper (~> 5.6) - dotenv-rails (~> 2.8) - ed25519 (~> 1.3) + dotenv email_spec fabrication (~> 2.30) faker (~> 3.2) + faraday-httpclient fast_blank (~> 1.0) fastimage - fog-core (<= 2.4.0) + flatware-rspec + fog-core (<= 2.6.0) fog-openstack (~> 1.0) - fuubar (~> 2.5) haml-rails (~> 2.0) haml_lint hcaptcha (~> 7.1) hiredis (~> 0.6) htmlentities (~> 4.3) - http (~> 5.1) + http (~> 5.2.0) http_accept_language (~> 2.1) - httplog (~> 1.6.2) + httplog (~> 1.7.0) + i18n i18n-tasks (~> 1.0) idn-ruby + inline_svg irb (~> 1.8) + jd-paperclip-azure (~> 3.0) 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) - letter_opener_web (~> 2.0) + letter_opener_web (~> 3.0) link_header (~> 0.0) + linzer (~> 0.6.1) lograge (~> 0.12) + mail (~> 2.8) mario-redis-lock (~> 1.2) - md-paperclip-azure (~> 2.2) memory_profiler - mime-types (~> 3.5.0) - net-http (~> 0.4.0) + mime-types (~> 3.6.0) + mutex_m + net-http (~> 0.6.0) net-ldap (~> 0.18) nokogiri (~> 1.15) - nsa! oj (~> 3.14) omniauth (~> 2.0) - omniauth-cas! + omniauth-cas (~> 3.0.0.beta.1) omniauth-rails_csrf_protection (~> 1.0) omniauth-saml (~> 2.0) - omniauth_openid_connect (~> 0.6.1) + omniauth_openid_connect (~> 0.8.0) + opentelemetry-api (~> 1.5.0) + opentelemetry-exporter-otlp (~> 0.30.0) + opentelemetry-instrumentation-active_job (~> 0.8.0) + opentelemetry-instrumentation-active_model_serializers (~> 0.22.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) + opentelemetry-instrumentation-excon (~> 0.23.0) + opentelemetry-instrumentation-faraday (~> 0.26.0) + opentelemetry-instrumentation-http (~> 0.24.0) + opentelemetry-instrumentation-http_client (~> 0.23.0) + opentelemetry-instrumentation-net_http (~> 0.23.0) + opentelemetry-instrumentation-pg (~> 0.30.0) + opentelemetry-instrumentation-rack (~> 0.26.0) + opentelemetry-instrumentation-rails (~> 0.36.0) + opentelemetry-instrumentation-redis (~> 0.26.0) + opentelemetry-instrumentation-sidekiq (~> 0.26.0) + opentelemetry-sdk (~> 1.4) ox (~> 2.14) parslet pg (~> 1.5) pghero - posix-spawn premailer-rails - private_address_check (~> 0.5) + prometheus_exporter (~> 2.2) propshaft - public_suffix (~> 5.0) + public_suffix (~> 6.0) puma (~> 6.3) pundit (~> 2.3) rack (~> 2.2.7) rack-attack (~> 6.6) rack-cors (~> 2.0) rack-test (~> 2.1) - rails (~> 7.1.1) - rails-controller-testing (~> 1.0) - rails-i18n (~> 7.0) - rails-settings-cached (~> 0.6)! + rails (~> 8.0) + rails-i18n (~> 8.0) rdf-normalize (~> 0.5) redcarpet (~> 3.6) redis (~> 4.5) redis-namespace (~> 1.10) rqrcode (~> 2.2) - rspec-github (~> 2.4) - rspec-rails (~> 6.0) - rspec-retry (>= 0.6.2) - rspec-sidekiq (~> 4.0) + rspec-github (~> 3.0) + rspec-rails (~> 7.0) + rspec-sidekiq (~> 5.0) rubocop rubocop-capybara + rubocop-i18n rubocop-performance rubocop-rails rubocop-rspec + rubocop-rspec_rails ruby-prof ruby-progressbar (~> 1.13) + ruby-vips (~> 2.2) rubyzip (~> 2.3) - sanitize (~> 6.0) + sanitize (~> 7.0) scenic (~> 1.7) selenium-webdriver + shoulda-matchers sidekiq (~> 6.5) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 5.0) @@ -955,8 +1068,8 @@ DEPENDENCIES simplecov (~> 0.22) simplecov-lcov (~> 0.8) stackprof - stoplight (~> 3.0.1) - strong_migrations (= 1.6.4) + stoplight (~> 4.1) + strong_migrations test-prof thor (~> 1.2) tty-prompt (~> 0.23) @@ -969,7 +1082,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.2.2p53 + ruby 3.4.1p0 BUNDLED WITH - 2.4.20 + 2.6.8 diff --git a/Procfile b/Procfile index d15c835b86..f033fd36c6 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/README.md b/README.md index 4a5b76b209..854e8ac3d9 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,27 @@ -# ![kmyblue icon](https://raw.githubusercontent.com/kmycode/mastodon/kb_development/app/javascript/icons/favicon-32x32.png) kmyblue +NAS is an KMY & Mastodon Fork -[![Ruby Testing](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml) +The following are just a few of the most common features. There are many other minor changes to the specifications. -kmyblueは[Mastodon](https://github.com/mastodon/mastodon)γγƒ•γ‚©γƒΌγ‚―γ§γ™γ€‚ε‰΅δ½œδ½œεΆγγŸγ‚γMastodonγ‚’η›ζŒ‡γ—γ¦ι–‹η™Ίγ—γΎγ—γŸγ€‚ +Emoji reactions -kmyblueγ―γƒ•γ‚©γƒΌγ‚―εγ§γ‚γ‚Šγ€εŒζ™‚γ«[ァーバー名](https://kmy.blue)γ§γ‚‚γ‚γ‚ŠγΎγ™γ€‚δ»₯δΈ‹γ―η‰Ήγ«θ¨˜θΏ°γŒγͺγ„ι™γ‚Šγ€γƒ•γ‚©γƒΌγ‚―γ¨γ—γ¦γkmyblueをさします。 +Local Public (Does not appear on the federated timeline of remote servers, but does appear on followers' home timelines. This is different from local only) -kmyblueは AGPL γƒ©γ‚€γ‚»γƒ³γ‚Ήγ§ε…¬ι–‹γ•γ‚Œγ¦γ„γ‚‹γŸγ‚γ€γ©γͺγŸγ§γ‚‚θ‡ͺ由にフォークし、こγγ‚½γƒΌγ‚Ήγ‚³γƒΌγƒ‰γ‚’元にθ‡ͺεˆ†γ§γ‚΅γƒΌγƒγƒΌγ‚’η«‹γ¦γ¦ε…¬ι–‹γ™γ‚‹γ“γ¨γŒγ§γγΎγ™γ€‚η’Ίγ‹γ«γ‚΅γƒΌγƒγƒΌkmyblueγ―ε‰΅δ½œδ½œεΆε‘けγγ‚‚γγ§γ™γŒγ€γƒ•γ‚©γƒΌγ‚―としてγkmyblueはAGPLγ§γƒ©γ‚€γ‚»γƒ³γ‚Ήγ€γ‘γ‚‰γ‚Œγ¦γŠγ‚Šγ€γƒ«γƒΌγƒ«γ―ε…¨γγεˆ₯η‰©γ§γ™γ€‚ε‰΅δ½œζ΄»ε‹•γδΈ€ιƒ¨οΌˆγ‚¨γƒ­ι–’δΏ‚ε«γ‚€οΌ‰γΎγŸγ―ε…¨δ½“γ‚’ε¦εšγ™γ‚‹γ‚³γƒŸγƒ₯ニティγͺγ©γ«γ‚‚εΉ³η­‰γ«γŠδ½Ώγ„γ„γŸγ γ‘γΎγ™γ€‚γ‚΅γƒΌγƒγƒΌkmyblueγγƒ«γƒΌγƒ«γ‚’適用する必要もγͺγγ€γ€ŒAnyone But Kmyblue」γͺルールを設εšγ™γ‚‹γ“とすら許εΉγ•γ‚ŒγΎγ™γ€‚ -kmyblueγ―γ€η‰Ήγ«ιžεŽθΌ‰ζŠ•η¨Ώγζ€œη΄’γŒεΌ·εŒ–γ•γ‚Œγ¦γ„γ‚‹γŸγ‚γ€γƒ­γƒΌγ‚«γƒ«γ‚Ώγ‚€γƒ γƒ©γ‚€γƒ³γ«ζŽ²θΌ‰γ•γ‚Œγ¦γ„γͺγ„ζŠ•η¨Ώγ‚‚ζ€œη΄’γƒ»θ³Όθͺ­γ™γ‚‹γ“γ¨γŒε―能γͺε ΄εˆγŒγ‚γ‚ŠγΎγ™γ€‚ι–‰ιŽ–ηš„γͺγ‚³γƒŸγƒ₯γƒ‹γƒ†γ‚£γ€γ‚γΎγ‚Šη›η«‹γ‘たくγͺγ„γ‚³γƒŸγƒ₯γƒ‹γƒ†γ‚£γ«γ―η‰Ήγ«εΌ·εŠ›γͺζ©Ÿθƒ½γ‚’ζδΎ›γ—γΎγ™γ€‚γγ‚Œδ»₯ε€–γγ‚³γƒŸγƒ₯ニティに対しても、kmyblueはプラむバシーを考ζ…γ—γŸγ†γˆγ§εΌ·εŠ›γͺζ€œη΄’γƒ»θ³Όθͺ­ζ©Ÿθƒ½γ‚’ζδΎ›γ™γ‚‹γŸγ‚γ€ζ±Žη”¨γ‚΅γƒΌγƒγƒΌγ¨γ—γ¦εˆ©η”¨γ™γ‚‹γ«γ‚‚γ‚γ‚‹η¨‹εΊ¦εεˆ†γͺζ©Ÿθƒ½γŒζƒγ£γ¦γ„γΎγ™γ€‚ +Bookmark classification -γŸγ γ—kmyblueγ«γŠγ„γ¦**γƒ†γ‚Ήγƒˆγ‚³γƒΌγƒ‰γ―ι£Ύγ‚Š**γ§γ—γ‹γ‚γ‚ŠγΎγ›γ‚“γ€‚γ“γ‚Œγ―kmyblueγ‚’εˆ©η”¨γ™γ‚‹δΊΊγŒζœ¬εΆMastodonγ‚ˆγ‚Šεœ§ε€’ηš„γ«ε°‘γͺく、バグやセキγƒ₯γƒͺγƒ†γ‚£γ‚€γƒ³γ‚·γƒ‡γƒ³γƒˆγ‚’η™Ίθ¦‹γ™γ‚‹γ γ‘γδΊΊζ•°γŒθΆ³γ‚Šγͺいことを意味します。kmyblueは対策としてθ‡ͺε‹•γƒ†γ‚Ήγƒˆγ‚’ζ‹‘ε……γ—γ¦γ„γΎγ™γ€‚η‹¬θ‡ͺζ©Ÿθƒ½γγƒ†γ‚Ήγƒˆγ‚’θ¨˜θΏ°γ™γ‚‹γ γ‘γ§γͺγγ€ζœ¬εΆγγƒ†γ‚Ήγƒˆγ‚³γƒΌγƒ‰γθ£œεΌ·γ‚‚θ‘Œγ£γ¦γŠγ‚ŠγΎγ™γŒγ€η’ΊθͺζΌγ‚Œγ―εΏ…γšη™Ίη”Ÿγ™γ‚‹γ‚‚γγ§γ™γ€‚δΈε…·εˆγŒη™Ίη”Ÿγ—γ¦γ‚‚θ‡ͺ己責任にγͺγ‚ŠγΎγ™γ€‚ζ—’ηŸ₯γγƒγ‚°γ‚‚γ„γγ€γ‹γ‚γ‚ŠγΎγ™γ—γ€η›΄γ™δΊˆεšγγͺいもγγ‚‚ε«γΎγ‚ŒγΎγ™γ€‚ +Set who can search your posts for each post (Searchability) -γƒ†γ‚Ήγƒˆγ‚³γƒΌγƒ‰γ€Lint どけらも動いています。 +Quote posts, modest quotes (references) -## γ‚€γƒ³γ‚ΉγƒˆγƒΌγƒ«ζ–Ήζ³• +Record posts that meet certain conditions such as domains, accounts, and keywords (Subscriptions/Antennas) -[Wiki](https://github.com/kmycode/mastodon/wiki/Installation)を参照してください。 +Send posts to a designated set of followers (Circles) (different from direct messages) -## 開発へγε‚εŠ ζ–Ήζ³• +Notification of new posts on lists -CONTRIBUTING.mdを参照してください。 +Exclude posts from people you follow when filtering posts -## γƒ†γ‚Ήγƒˆ +Hide number of followers and followings -``` -# デバッグεŸθ‘ŒοΌˆδ»₯δΈ‹γγ„γšγ‚Œγ‹οΌ‰ -foreman start -DB_USER=postgres DB_PASS=password foreman start +Automatically delete posts after a specified time has passed -# 一部を陀く全てγγƒ†γ‚Ήγƒˆγ‚’θ‘Œγ† -RAILS_ENV=test bundle exec rspec spec - -# ElasticSearchι€£ζΊγƒ†γ‚Ήγƒˆγ‚’θ‘Œγ† -ζ–° -RAILS_ENV=test ES_ENABLED=true bundle exec rspec --tag search -ζ—§ -RAILS_ENV=test ES_ENABLED=true RUN_SEARCH_SPECS=true bundle exec rspec spec/search -``` - -## kmyblueγγƒ–ランチ - -- **main** - η‘η†θ€…γŒζœ¬εΆMastodonにPRγ™γ‚‹γ¨γγ«δ½Ώγ†γ“γ¨γŒγ‚γ‚ŠγΎγ™ -- **kb_development** - ι–‹η™ΊδΈ­γζœ€ζ–°γγ‚½γƒΌγ‚Ήγ‚³γƒΌγƒ‰γ§γ™γ€‚γƒ‘γ‚Έγƒ£γƒΌγƒγƒΌγ‚Έγƒ§γƒ³γ‚’γƒƒγƒ—γƒ‡γƒΌγƒˆγ―ι€šεΈΈγ“γγƒ–γƒ©γƒ³γƒγ‹γ‚‰ε…¬ι–‹γ•γ‚ŒγΎγ™ -- **kb_lts** - LTSγη‘理に使います。LTSはこγγƒ–γƒ©γƒ³γƒγ‹γ‚‰ε…¬ι–‹γ•γ‚ŒγΎγ™ -- **kb_patch** - δΏζ­£γƒ‘ッチγη‘η†γ«δ½Ώγ„γΎγ™γ€‚γƒžγ‚€γƒŠγƒΌγƒγƒΌγ‚Έγƒ§γƒ³γ‚’γƒƒγƒ—γƒ‡γƒΌγƒˆγ―ι€šεΈΈγ“γγƒ–γƒ©γƒ³γƒγ‹γ‚‰ε…¬ι–‹γ•γ‚ŒγΎγ™ - -## kmyblueγεΌ·γΏ - -追加γθ©³η΄°γ―δΈ‹θ¨˜θ¨˜δΊ‹γ‚‚γ”θ¦§γγ γ•γ„γ€‚ - -https://note.com/kmycode/n/n5fd5e823ed40 - -δ»₯下に書いているもγδ»₯ε€–γ«γ‚‚ε€šζ•°γζ©Ÿθƒ½γŒε­˜εœ¨γ—ます。 - -### 本εΆMastodonへγη©ζ₯΅ηš„θΏ½εΎ“ - -kmyblueγ―γ€θΏ½εŠ ζ©Ÿθƒ½γ‚’ζŽ§γˆγ‚γ«γ™γ‚‹δ»£γ‚γ‚Šγ«ζœ¬εΆMastodonに積ζ₯΅ηš„γ«θΏ½εΎ“γ‚’θ‘Œγ„γΎγ™γ€‚kmyblueγθΏ½εŠ ζ©Ÿθƒ½γγγΎγΎγ«γ€Mastodonγζ–°ζ©Ÿθƒ½γ‚‚εˆ©η”¨γ§γγ‚‹γ‚ˆγ†θͺΏζ•΄γ‚’θ‘Œγ„γΎγ™γ€‚ - -### ゆるやかγͺε†…θΌͺでγι‹η”¨ - -kmyblueγ―εŒδΊΊε‘γ‘γ‚΅γƒΌγƒγƒΌγ¨γ—γ¦ε‡Ίη™Ίγ—γŸγŸγ‚γ€εŒδΊΊδ½œεΆγ«ιœ€θ¦γγ‚γ‚‹γ€Œε†…θΌͺγƒŽγƒͺを倖部にできるだけもらさγͺγ„γ€γ¨γ„γ†ιƒ¨εˆ†γ«η‰ΉεŒ–γ—γ¦γ„γΎγ™γ€‚ - -γ€Œγƒ­γƒΌγ‚«γƒ«ε…¬ι–‹γ€γ¨γ„γ†ζ©Ÿθƒ½γ«γ‚ˆγ£γ¦γ€γ€Œγƒ­γƒΌγ‚«γƒ«γ‚Ώγ‚€γƒ γƒ©γ‚€γƒ³γ«ζ΅γ™γŒδ»–γγ‚΅γƒΌγƒγƒΌγι€£εˆγ‚Ώγ‚€γƒ γƒ©γ‚€γƒ³γ«ζ΅γ•γͺγ„γ€ζŠ•η¨ΏγŒε―θƒ½γ§γ™γ€‚γŸγ γ—Misskeyγγƒ­γƒΌγ‚«γƒ«ι™εšγ¨γ―η•°γͺγ‚Šγ€δ»–γγ‚΅γƒΌγƒγƒΌγγƒ•γ‚©γƒ­γƒ―γƒΌγγ‚Ώγ‚€γƒ γƒ©γ‚€γƒ³γ«γ‚‚ζŠ•η¨Ώγ―ζ΅γ‚ŒγΎγ™γ€‚θ‡ͺεˆ†γγ‚΅γƒΌγƒγƒΌγδΈ­γ§ε†…θΌͺγ§η››γ‚ŠδΈŠγŒγ£γ¦γ€δ»–γγ‚΅γƒΌγƒγƒΌγι€£εˆγ‚Ώγ‚€γƒ γƒ©γ‚€γƒ³γ«γ―倖青だけγζŠ•η¨Ώγ‚’ζ΅γ™γ“γ¨γ‚‚ε―能です。 - -γ€Œγ‚΅γƒΌγ‚―γƒ«γ€γ¨γ„γ†ζ©Ÿθƒ½γ«γ‚ˆγ£γ¦γ€η‰Ήεšγγƒ•γ‚©γƒ­γƒ―γƒΌγ«γ γ‘θ¦‹γˆγ‚‹ζŠ•η¨Ώγ‚’θ‘Œγ†γ“γ¨γ‚‚ε―θƒ½γ§γ™γ€‚γγζŠ•η¨Ώγ«θΏ”δΏ‘することで、相手ァークルγδΌšθ©±γ«ε‚εŠ γ™γ‚‹γ“γ¨γ‚‚ε―θƒ½γ§γ™γ€‚γŸγ γ—γ‚΅γƒΌγ‚―γƒ«ζŠ•η¨Ώγ‚’ζ­£εΈΈγ«ε‡¦η†γ§γγ‚‹γ‚½γƒ•γƒˆγ‚¦γ‚§γ‚’γ―ηΎεœ¨γ€kmyblue・Fedibirdγ«ι™γ‚ŠγΎγ™γ€‚ - -γΎγŸγ€ι€šεΈΈγMastodonγ§γ―ε…¬ι–‹ζŠ•η¨Ώγ‚’δ»–γγ‚΅γƒΌγƒγƒΌγδΊΊγ«θ‡ͺη”±γ«ζ€œη΄’γ§γγ‚‹γ‚ˆγ†γ«γ™γ‚‹γ“γ¨γ‚‚ε―θƒ½γ§γ™γŒγ€kmyblueγ§γ―ιžεŽθΌ‰ζŠ•η¨Ώγ«ε―Ύγ—γ¦εŒζ§˜γθ¨­εšγŒε―θƒ½γ§γ™γ€‚γ€γΎγ‚Šγ€γƒ­γƒΌγ‚«γƒ«γ‚Ώγ‚€γƒ γƒ©γ‚€γƒ³γ«γ‚‚ι€£εˆγ‚Ώγ‚€γƒ γƒ©γ‚€γƒ³γ«γ‚‚ζ΅γ‚Œγͺい、θͺ°γ‹γη›γ«θ‡ͺη„Άγ«θ§¦γ‚Œγ‚‹γ“γ¨γ―γͺい、でも特εšγ‚­γƒΌγƒ―γƒΌγƒ‰γ‚’δ½Ώγ£γŸζ€œη΄’γ§γ―εΌ•γ£γ‹γ‹γ‚ŠγŸγ„γ€γγγ‚ˆγ†γͺιœ€θ¦γ«ε―ΎεΏœγ§γγΎγ™γ€‚ - -ε†…θΌͺとはθ‡ͺεˆ†γγ‚΅γƒΌγƒγƒΌγ«ι™γ£γŸγ‚‚γγ§γ―γ‚γ‚ŠγΎγ›γ‚“γ€‚ε†…θΌͺεŒε£«γ§θ€‡ζ•°γγ‚΅γƒΌγƒγƒΌγ‚’ι‹ε–Άγ™γ‚‹γ¨γγ€γŠδΊ’γ„γŒζ·±γηΉ‹γŒγ‚Œγ‚‹γƒ•γƒ¬γƒ³γƒ‰γ‚΅γƒΌγƒγƒΌγ¨γ„γ†γ‚·γ‚Ήγƒ†γƒ γ‚‚η”¨ζ„γ—γ¦γ„γΎγ™γ€‚ - -γŸγ γ—kmyblueγ―γ€εŒζ™‚γ«ι€£εˆγ‚‚ι‡θ¦–γ—γ¦γ„γΎγ™γ€‚γƒ­γƒΌγ‚«γƒ«ι™εšζŠ•η¨Ώγͺγ©γ€ι€£εˆγ‚’ε€§γγεˆΆι™γ•γ›γ‚‹γ‚ˆγ†γͺζ©Ÿθƒ½γ―δ½œγ‚‹δΊˆεšγ―γ‚γ‚ŠγΎγ›γ‚“γ€‚ - -### 少人数ァーバーでγι‹η”¨ - -kmyblueは、人γε°‘γͺいァーバーでγι‹η”¨γ‚’考ζ…γ—γ¦θ¨­θ¨ˆγ—γ¦γ„γΎγ™γ€‚γγγŸγ‚γ€δ»–γγ‚΅γƒΌγƒγƒΌγγ‚’γ‚«γ‚¦γƒ³γƒˆγθ³Όθͺ­ζ©Ÿθƒ½γ―Fedibirdほど発達していませんし、人γε€šγ„ァーバー向けγη‹¬θ‡ͺζ”Ήι€ γ‚‚γ»γ¨γ‚“γ©ε­˜εœ¨γ—γΎγ›γ‚“γ€‚ - -γŸγ γ—γ‚΅γƒΌγƒγƒΌγθ² θ·γ«γ€γ„γ¦γ―δΈ€ιƒ¨εΊ¦ε€–θ¦–γ—γ¦γ„γ‚‹ιƒ¨εˆ†γŒγ‚γ‚ŠγΎγ™γ€‚γŸγ¨γˆγ°γ‚Ήγ‚Ώγƒ³γƒ—ζ©Ÿθƒ½γ―γ‚΅γƒΌγƒγƒΌγΈθ‘—γ—γ„θ² θ·γ‚’γ‹γ‘γ‚‹ε ΄εˆγŒγ‚γ‚ŠγΎγ™γ€‚γ‚Ήγ‚Ώγƒ³γƒ—ζ©Ÿθƒ½γγγ‚‚γγ‚’η„‘εŠΉγ«γ™γ‚‹η‘理者γ‚ͺγƒ—γ‚·γƒ§γƒ³γ‚‚ε­˜εœ¨γ—γΎγ™γ€‚ - -もけろん人γε€šγ„ァーバーでγι‹η”¨γŒδΈδΎΏγ«γͺγ‚‹γ‚ˆγ†γͺδΏζ­£γ―θ‘Œγ£γ¦γ„γΎγ›γ‚“γ€‚δΊΊγε€šγ„ァーバーでもそγγΎγΎγŠδ½Ώγ„γ„γŸγ γ‘γΎγ™γ€‚ - -### ζ―”θΌƒηš„ι«˜γ„ι˜²εΎ‘εŠ› - -kmyblueγ§γ―γ€γ€ŒFediverseは将ζ₯ηš„γ«θ’γ‚€γγ§γ―γͺγ„γ‹γ€γ€ŒFediverseは将ζ₯ηš„γ«γ‚Ήγƒ‘γƒ γ«ζΊ’γ‚Œγ‚‹γγ§γ―γͺγ„γ‹γ€γ‚’εΏ΅ι ­γ«θ¨­θ¨ˆγ—γ¦γ„γ‚‹ιƒ¨εˆ†γŒγ‚γ‚ŠγΎγ™γ€‚ - -個εˆ₯ユーアー向けγθ¨­εšι …η›γŒθ€‡ζ•°γ‚γ‚ŠγΎγ™γ€‚ - -- Misskeyγ―γ€γŸγ¨γˆMastodonγζŠ•η¨Ώγ§γ‚γ£γ¦γ‚‚ιžεŽθΌ‰ζŠ•η¨Ώγ‚’θ‡ͺη”±γ«ζ€œη΄’γ§γγΎγ™γŒγ€kmyblueγ§γ―γγ‚Œγ‚’γƒ–γƒ­γƒƒγ‚―γ§γγ‚‹γƒ¦γƒΌγ‚ΆγƒΌθ¨­εšγŒε­˜εœ¨γ—ます -- δ»–γδΊΊγ‹γ‚‰γγ‚Ήγ‚Ώγƒ³γƒ—γε—けε…₯γ‚Œγ‚’εˆΆι™γ™γ‚‹θ¨­εšγ‚‚ε―θƒ½γ§γ‚γ‚Šγ€δΎ‹γˆγ°δ»–γγ‚΅γƒΌγƒγƒΌγ‹γ‚‰ε₯½γΎγ—くγͺγ„γ‚Ήγ‚Ώγƒ³γƒ—γ‚’ε—γ‘ε–γ‚‹γ“γ¨γ‚’ι˜²ζ­’γ§γγΎγ™ -- 公開タむムラむンγεΌ•η”¨θ‘¨η€Ίγ―γƒ‡γƒ•γ‚©γƒ«γƒˆγ§η„‘εŠΉγ«γͺっています。不快γͺζŠ•η¨Ώγ‚’εΌ•η”¨γ—γŸγ‚‚γγŒε…¬ι–‹γ‚Ώγ‚€γƒ γƒ©γ‚€γƒ³γ«ζ΅γ‚Œγ¦γ‚‚γ€γ‚γ‚‹η¨‹εΊ¦γ―ι˜²ζ­’γ§γγΎγ™ - - γƒ•γ‚£γƒ«γ‚ΏγƒΌοΌˆγƒ―γƒΌγƒ‰γƒŸγƒ₯γƒΌγƒˆοΌ‰γ―γ€εΌ•η”¨γ•γ‚ŒγŸζŠ•η¨Ώγε†…εΉγ«γ‚‚ι©η”¨γ•γ‚ŒγΎγ™γ€‚γ“γε ΄εˆγ€εΌ•η”¨ζŠ•η¨Ώγγγ‚‚γγŒθ‘¨η€Ίγ•γ‚Œγͺくγͺγ‚ŠγΎγ™ -- θ‡ͺεˆ†γγƒ•ォローしている相手γζŠ•η¨Ώγ‚’γƒ•γ‚£γƒ«γ‚ΏγƒΌγ‹γ‚‰ι™€ε€–γ™γ‚‹θ¨­εšγŒε­˜εœ¨γ—γΎγ™γ€‚ι˜²εΎ‘γ‚’δΈŠγ’γ™γŽγ‚‹γ¨δΈδΎΏγͺη‡ζ‰€γŒε‡Ίγ¦γγ‚‹γγ§γ€γγ‘γ‚‰γ‚‚η·©ε’Œγ§γγ‚‹γ‚ˆγ†ε―θƒ½γͺι™γ‚Šι…ζ…しています - -η‘理者向けには、スパムへγεˆ©η”¨γ‚’ε‰ζγ¨γ—γŸζ­£θ¦θ‘¨ηΎε―能γͺNGワード設εšγ€η΄°γ‹γ„ζŒ‡εšγŒε―能γͺζ‹‘εΌ΅γƒ‰γƒ‘γ‚€γƒ³γƒ–γƒ­γƒƒγ‚―ζ©Ÿθƒ½γ‚’η”¨ζ„γ—γ¦γ„γΎγ™γ€‚ - -γŸγ γ—ι˜²εΎ‘εŠ›γι«˜γ•はθ‡ͺη”±γ‚’ηŠ η‰²γ«γ—γΎγ™γ€‚δΎ‹γˆγ°kmyblueは、硡文字γƒͺをクションγθ‘¨η€Ίγ‚΅γ‚€γ‚ΊθͺΏζ•΄ζ©Ÿθƒ½γͺど、Misskeyγ‚„Fedibirdγ«γ―ε½“γŸγ‚Šε‰γγ‚ˆγ†γ«γ‚る葨瀺設εšγ―ε­˜εœ¨γ—γΎγ›γ‚“γ€‚ι¨’γŒγ—γγͺγ‚‹γ‚ˆγ†γͺγ‚‚γγ―γ‚γΎγ‚Šδ½œγ‚ŠγŸγ„γ¨γ―θ€ƒγˆγ¦γ„γΎγ›γ‚“γ€‚ - -### そγδ»–γδΈ»γͺζ©Ÿθƒ½ - -- γ‚Ήγ‚Ώγƒ³γƒ—οΌˆη΅΅ζ–‡ε­—γƒͺγ‚’γ‚―γ‚·γƒ§γƒ³οΌ‰γ«γ‚ˆγ‚‹ζ‰‹θ»½γͺ亀桁 -- η΅΅ζ–‡ε­—γƒ‡γƒƒγ‚­γ«γ‚ˆγ‚‹ι »ηΉγ«δ½Ώη”¨γ™γ‚‹η΅΅ζ–‡ε­—γη™»ιŒ²γƒ»ιΈζŠž -- ζ€œη΄’ζ©Ÿθƒ½γεΌ·εŒ–οΌˆζ€œη΄’許可) -- ζŠ•η¨ΏγεΌ•用 -- γƒ–γƒƒγ‚―γƒžγƒΌγ‚―γεˆ†ι‘ž - -## kmyblueは何でγͺいか - -kmyblueは、Misskeyγ§γ―γ‚γ‚ŠγΎγ›γ‚“γ€‚η΅΅ζ–‡ε­—γƒͺをクションγͺどMisskeyと同様γζ©Ÿθƒ½γ―γ‚γ‚ŠγΎγ™γŒγ€ζ Ήζœ¬ηš„γ«UIγδ½Ώγ„ε‹ζ‰‹γŒι•γ†δ»–γ«γ‚‚γ€δΎ‹γˆγ°γƒ–γƒƒγ‚―γƒžγƒΌγ‚―γ‚’εˆ†ι‘žγ§γγ¦γ‚‚γγ‚Œγ‚’ε…¬ι–‹γ™γ‚‹ζ©Ÿθƒ½γ‚’δ½œγ£γ¦γ„γΎγ›γ‚“γ€‚Misskeyγ―γ€Œζ₯½γ—γ‚€γ€γ‚’γ‚³γƒ³γ‚»γƒ—γƒˆγ«γ—γ¦γ„γΎγ™γŒγ€kmyblueはMastodonγζ€ζƒ³γ‚’ε—γ‘ηΆ™γŽγ€η‚ŽδΈŠγ‚„ε–§ι¨’γ‚’ιΏγ‘γ‚‹γ“γ¨γγ§γγ‚‹θ½γ‘η€γ„γŸε ΄ζ‰€γ‚’η›ζŒ‡γ—ています。そγγŸγ‚γ€ζ€ζƒ³γ«εˆγ‚γͺγ„ζ©Ÿθƒ½γ―εŸθ£…しγͺγ„γ‹γ€ε€§εΉ…γ«εΌ±δ½“εŒ–γ—γ¦γ„γΎγ™γ€‚ - -kmyblueは、Fedibirdγ§γ―γ‚γ‚ŠγΎγ›γ‚“γ€‚η’Ίγ‹γ«γƒ­γƒΌγ‚«γƒ«γ‚Ώγ‚€γƒ γƒ©γ‚€γƒ³γ‚’η„‘εŠΉγ«γ—Fedibirdγγ‚ˆγ†γͺ運喢を可能にする設εšγ―ε­˜εœ¨γ—γΎγ™γ€‚γ—γ‹γ—kmyblueは本εΆθΏ½εΎ“γ‚’ε„ͺε…ˆγ™γ‚‹θ¦³η‚Ήγ‹γ‚‰Webγ§ε―ΎεΏœγ™γ‚‹η―„ε›²γ‚’γ‚γ‚‹η¨‹εΊ¦η΅žγ‚ŠθΎΌγ‚“γ§γ„γ‚‹γŸγ‚γ€Fedibirdγ«γ‚γ‚‹γ‚ˆγ†γͺ豊富γͺ葨瀺設εšγ―δ½œγ£γ¦γ„γΎγ›γ‚“γ€‚η΅΅ζ–‡ε­—γε€§γγ•すらθͺΏζ•΄γ™γ‚‹γ“γ¨γ―γ§γγΎγ›γ‚“γ€‚γΎγŸγ€Fedibirdγ§γ―γ‚’γ‚«γ‚¦γƒ³γƒˆγθ³Όθͺ­ζ©Ÿθƒ½γŒγ‚γ‚ŠγΎγ™γ€‚kmyblueγ«γ‚‚εŒζ§˜γζ©Ÿθƒ½γ―あるもγγγ€Fedibirdγγ‚ˆγ†γ«δΈ€η™Ίγ§γ™γγ§γγ‚‹γ‚ˆγ†γͺUIγ§γ―γ‚γ‚ŠγΎγ›γ‚“γ€‚θ³Όθͺ­ζ©Ÿθƒ½γ―相手γγƒ•ォローを伴わγͺγ„γŸγ‚γ€η‰Ήγ«εˆ©η”¨θ€…γ«ζ“¬δΌΌηš„γͺγƒ•γ‚©γƒ­γƒΌδ½“ι¨“γ‚’δΈŽγˆγ‚‹γ‚’γ‚«γ‚¦γƒ³γƒˆθ³Όθͺ­γ―、人γε°‘γͺγ„ε°θ¦ζ¨‘γ‚΅γƒΌγƒγƒΌγ«γ―ε‘γ„γ¦γ„γΎγ›γ‚“γ€‚γ“γ‚Œγ―γ€ε°θ¦ζ¨‘γ‚΅γƒΌγƒγƒΌγι‹η”¨γ‚’想εšγ—ているkmyblueγŒγ‚γˆγ¦δ½œγ£γ¦γ„γͺγ„ιƒ¨εˆ†γ§γ™γ€‚ - -kmyblueは、企ζ₯­γƒ»ζ”ΏεΊœζ©Ÿι–’ε‘γ‘γ«ι–‹η™Ίγ•γ‚ŒγŸγ‚‚γγ§γ―γ‚γ‚ŠγΎγ›γ‚“γ€‚γγ‚‚γγ‚‚η‘理者はセキγƒ₯γƒͺティに閒する資格や専門ηŸ₯θ­˜γ‚’ζœ‰γ—γ¦γŠγ‚‰γšγ€ι«˜εΊ¦γͺγ‚»γ‚­γƒ₯γƒͺティγζ±‚γ‚γ‚‰γ‚Œγ‚‹ζ©Ÿι–’向けγγ‚½γƒ•γƒˆγ‚¦γ‚§γ‚’γ‚’εˆΆδ½œγ™γ‚‹θƒ½εŠ›γ―γ‚γ‚ŠγΎγ›γ‚“γ€‚kmyblueγ―η’Ίγ‹γ«ζœ¬εΆMastodonγ«ε―Ύγ—γ¦ε€§εΉ…γ«ζ©Ÿθƒ½γ‚’θΏ½εŠ γ—γ¦γ„γΎγ™γŒγ€γγ‚‚γγ‚‚ε€‹δΊΊγ«γ‚ˆγ‚‹γƒ•γ‚©γƒΌγ‚―γ―γ€ι–‹η™Ίθ€…γŒι£½γγŸγ‚‰γγ“γ§η΅‚γ‚γ‚Šγ¨γ„γ†γƒͺγ‚Ήγ‚―γ‚‚δΌ΄γ„γΎγ™γ€‚ι«˜γ„δΏ‘ι Όζ€§γƒ»ε‰ε…¨ζ€§γ‚’保証することはできγͺいγγ§γ€ε°Žε…₯γιš›γ―ごθ‡ͺ身でε‰ε…¨γ‚’εεˆ†γ«η’Ίθͺγ—γ¦γ‹γ‚‰γŠδ½Ώγ„γ«γͺγ‚‹γ“γ¨γ‚’εΌ·γγŠγ™γ™γ‚γ—γΎγ™γ€‚ +Expanding moderation functions diff --git a/Rakefile b/Rakefile index e51cf0e17e..488c551fee 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,6 @@ # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. -require File.expand_path('config/application', __dir__) +require_relative 'config/application' Rails.application.load_tasks diff --git a/SECURITY.md b/SECURITY.md index d5b27adfac..993dcfb546 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,9 +17,4 @@ kmyblueにセキγƒ₯γƒͺγƒ†γ‚£γ‚€γƒ³γ‚·γƒ‡γƒ³γƒˆγ‚’ε ±ε‘Šγ™γ‚‹ε ΄εˆγ€δ»₯下だ## γ‚΅γƒγƒΌγƒˆγ™γ‚‹γƒγƒΌγ‚Έγƒ§γƒ³ -δΈ‹θ¨˜δ»₯ε€–γγƒγƒΌγ‚Έγƒ§γƒ³γ―、セキγƒ₯γƒͺγƒ†γ‚£γ‚€γƒ³γ‚·γƒ‡γƒ³γƒˆγ‚’θ΅·η₯¨γ•γ‚Œγ¦γ‚‚ε―ΎεΏœγ—γΎγ›γ‚“γ€‚ - -- ζœ€ζ–°γƒ‘γ‚Έγƒ£γƒΌγƒγƒΌγ‚Έγƒ§γƒ³γ€γ‹γ€γ€ζœ€ζ–°γƒžγ‚€γƒŠγƒΌγƒγƒΌγ‚Έγƒ§γƒ³ - - ζœ€ζ–°γƒ‘γ‚Έγƒ£γƒΌγƒγƒΌγ‚Έγƒ§γƒ³γγ‚΅γƒγƒΌγƒˆγ―、欑γγƒ‘γ‚Έγƒ£γƒΌγƒγƒΌγ‚Έγƒ§γƒ³γŒε‡ΊγŸζ™‚η‚Ήγ§η΅‚δΊ†γ—γΎγ™ -- LTS - - LTSγγ‚΅γƒγƒΌγƒˆγ―、欑γLTSγŒε‡ΊγŸζ™‚η‚Ήγ§η΅‚δΊ†γ—γΎγ™οΌˆγŸγ γ—η§»θ‘ŒζœŸι–“γŒγ‚γ£γ¦γ‚‚γ„γ„γ¨ζ€γ£γ¦γ‚‹γγ§γ€οΌ‘γ€œοΌ“γƒΆζœˆδ»₯ε†…γͺらセキγƒ₯γƒͺγƒ†γ‚£γ‚€γƒ³γ‚·γƒ‡γƒ³γƒˆγη¨‹εΊ¦γ«εΏœγ˜γ¦ε―ΎεΏœγ™γ‚‹ε―θƒ½ζ€§γŒγ‚γ‚ŠγΎγ™οΌ‰ +(γ‚΅γƒγƒΌγƒˆζœŸι–“)[https://github.com/kmycode/mastodon/wiki/%E3%82%B5%E3%83%9D%E3%83%BC%E3%83%88%E6%9C%9F%E9%96%93] を参照してください。 diff --git a/Vagrantfile b/Vagrantfile index 6f0f511095..ce456060cd 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -151,6 +151,12 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| vb.customize ["modifyvm", :id, "--nictype2", "virtio"] end + config.vm.provider :libvirt do |libvirt| + libvirt.cpus = 3 + libvirt.memory = 8192 + end + + # This uses the vagrant-hostsupdater plugin, and lets you # access the development site at http://mastodon.local. # If you change it, also change it in .env.vagrant before provisioning @@ -168,11 +174,12 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| if config.vm.networks.any? { |type, options| type == :private_network } config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'actimeo=1'] else - config.vm.synced_folder ".", "/vagrant" + config.vm.synced_folder ".", "/vagrant", type: "rsync", create: true, rsync__args: ["--verbose", "--archive", "--delete", "-z"] end # Otherwise, you can access the site at http://localhost:3000 and http://localhost:4000 , http://localhost:8080 config.vm.network :forwarded_port, guest: 3000, host: 3000 + config.vm.network :forwarded_port, guest: 3035, host: 3035 config.vm.network :forwarded_port, guest: 4000, host: 4000 config.vm.network :forwarded_port, guest: 8080, host: 8080 config.vm.network :forwarded_port, guest: 9200, host: 9200 @@ -188,7 +195,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.post_up_message = <(account) { account.searchable_properties }) field(:last_status_at, type: 'date', value: ->(account) { clamp_date(account.last_status_at || account.created_at) }) field(:domain, type: 'keyword', value: ->(account) { account.domain || '' }) - field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } - field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } - field(:text, type: 'text', analyzer: 'sudachi_analyzer', value: ->(account) { account.searchable_text }) + field(:display_name, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('display_name', 'analyzer')) do + field :edge_ngram, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('display_name', 'edge_ngram', 'analyzer'), search_analyzer: ChewyConfig.instance.accounts_analyzers.dig('display_name', 'edge_ngram', 'search_analyzer') + end + field(:username, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('username', 'analyzer'), value: lambda { |account| + [account.username, account.domain].compact.join('@') + }) do + field :edge_ngram, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('username', 'edge_ngram', 'analyzer'), + search_analyzer: ChewyConfig.instance.accounts_analyzers.dig('username', 'edge_ngram', 'search_analyzer') + end + field(:text, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('text', 'analyzer'), value: ->(account) { account.searchable_text }) { field(:stemmed, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('text', 'stemmed', 'analyzer')) } end end diff --git a/app/chewy/public_statuses_index.rb b/app/chewy/public_statuses_index.rb index 806fd42ecb..b71406d3e3 100644 --- a/app/chewy/public_statuses_index.rb +++ b/app/chewy/public_statuses_index.rb @@ -3,81 +3,9 @@ class PublicStatusesIndex < Chewy::Index include DatetimeClampingConcern - settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: { - filter: { - english_stop: { - type: 'stop', - stopwords: '_english_', - }, - - english_stemmer: { - type: 'stemmer', - language: 'english', - }, - - english_possessive_stemmer: { - type: 'stemmer', - language: 'possessive_english', - }, - - my_posfilter: { - type: 'sudachi_part_of_speech', - stoptags: [ - '助詞', - 'εŠ©ε‹•θ©ž', - '補助記号,ε₯η‚Ή', - '補助記号,θͺ­η‚Ή', - ], - }, - }, - - analyzer: { - content: { - tokenizer: 'uax_url_email', - filter: %w( - english_possessive_stemmer - lowercase - asciifolding - cjk_width - english_stop - english_stemmer - ), - }, - - hashtag: { - tokenizer: 'keyword', - filter: %w( - word_delimiter_graph - lowercase - asciifolding - cjk_width - ), - }, - - sudachi_analyzer: { - tokenizer: 'sudachi_tokenizer', - type: 'custom', - filter: %w( - english_possessive_stemmer - lowercase - asciifolding - cjk_width - english_stop - english_stemmer - my_posfilter - sudachi_normalizedform - ), - }, - }, - tokenizer: { - sudachi_tokenizer: { - resources_path: '/etc/elasticsearch/sudachi', - split_mode: 'A', - type: 'sudachi_tokenizer', - discard_punctuation: 'true', - }, - }, - } + # ElasticSearch config is moved to "/config/elasticsearch.default.yml". + # Edit it when original Mastodon changed ElasticSearch config. + settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: ChewyConfig.instance.public_statuses index_scope ::Status.unscoped .kept @@ -87,8 +15,8 @@ class PublicStatusesIndex < Chewy::Index root date_detection: false do field(:id, type: 'long') field(:account_id, type: 'long') - field(:text, type: 'text', analyzer: 'sudachi_analyzer', value: ->(status) { status.searchable_text }) - field(:tags, type: 'text', analyzer: 'hashtag', value: ->(status) { status.tags.map(&:display_name) }) + field(:text, type: 'text', analyzer: ChewyConfig.instance.public_statuses_analyzers.dig('text', 'analyzer'), value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: ChewyConfig.instance.public_statuses_analyzers.dig('text', 'stemmed', 'analyzer')) } + field(:tags, type: 'text', analyzer: ChewyConfig.instance.public_statuses_analyzers.dig('tags', 'analyzer'), value: ->(status) { status.tags.map(&:display_name) }) field(:language, type: 'keyword') field(:domain, type: 'keyword', value: ->(status) { status.account.domain || '' }) field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties }) diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index fbf59d6404..44cb86d755 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -3,85 +3,9 @@ class StatusesIndex < Chewy::Index include DatetimeClampingConcern - settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: { - filter: { - english_stop: { - type: 'stop', - stopwords: '_english_', - }, - - english_stemmer: { - type: 'stemmer', - language: 'english', - }, - - english_possessive_stemmer: { - type: 'stemmer', - language: 'possessive_english', - }, - - my_posfilter: { - type: 'sudachi_part_of_speech', - stoptags: [ - '助詞', - 'εŠ©ε‹•θ©ž', - '補助記号,ε₯η‚Ή', - '補助記号,θͺ­η‚Ή', - ], - }, - }, - analyzer: { - verbatim: { - tokenizer: 'uax_url_email', - filter: %w(lowercase), - }, - - content: { - tokenizer: 'uax_url_email', - filter: %w( - english_possessive_stemmer - lowercase - asciifolding - cjk_width - english_stop - english_stemmer - ), - }, - - hashtag: { - tokenizer: 'keyword', - filter: %w( - word_delimiter_graph - lowercase - asciifolding - cjk_width - ), - }, - - sudachi_analyzer: { - tokenizer: 'sudachi_tokenizer', - type: 'custom', - filter: %w( - english_possessive_stemmer - lowercase - asciifolding - cjk_width - english_stop - english_stemmer - my_posfilter - sudachi_normalizedform - ), - }, - }, - tokenizer: { - sudachi_tokenizer: { - resources_path: '/etc/elasticsearch/sudachi', - split_mode: 'A', - type: 'sudachi_tokenizer', - discard_punctuation: 'true', - }, - }, - } + # ElasticSearch config is moved to "/config/elasticsearch.default.yml". + # Edit it when original Mastodon changed ElasticSearch config. + settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: ChewyConfig.instance.statuses index_scope ::Status.unscoped.kept.without_reblogs.includes( :account, @@ -107,8 +31,8 @@ class StatusesIndex < Chewy::Index root date_detection: false do field(:id, type: 'long') field(:account_id, type: 'long') - field(:text, type: 'text', analyzer: 'sudachi_analyzer', value: ->(status) { status.searchable_text }) - field(:tags, type: 'text', analyzer: 'hashtag', value: ->(status) { status.tags.map(&:display_name) }) + field(:text, type: 'text', analyzer: ChewyConfig.instance.statuses_analyzers.dig('text', 'analyzer'), value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: ChewyConfig.instance.statuses_analyzers.dig('text', 'stemmed', 'analyzer')) } + field(:tags, type: 'text', analyzer: ChewyConfig.instance.statuses_analyzers.dig('tags', 'analyzer'), value: ->(status) { status.tags.map(&:display_name) }) field(:searchable_by, type: 'long', value: ->(status) { status.searchable_by }) field(:mentioned_by, type: 'long', value: ->(status) { status.mentioned_by }) field(:favourited_by, type: 'long', value: ->(status) { status.favourited_by }) diff --git a/app/chewy/tags_index.rb b/app/chewy/tags_index.rb index c99218a47f..965718e83e 100644 --- a/app/chewy/tags_index.rb +++ b/app/chewy/tags_index.rb @@ -3,36 +3,9 @@ class TagsIndex < Chewy::Index include DatetimeClampingConcern - settings index: index_preset(refresh_interval: '30s'), analysis: { - analyzer: { - content: { - tokenizer: 'keyword', - filter: %w( - word_delimiter_graph - lowercase - asciifolding - cjk_width - ), - }, - - edge_ngram: { - tokenizer: 'edge_ngram', - filter: %w( - lowercase - asciifolding - cjk_width - ), - }, - }, - - tokenizer: { - edge_ngram: { - type: 'edge_ngram', - min_gram: 2, - max_gram: 15, - }, - }, - } + # ElasticSearch config is moved to "/config/elasticsearch.default.yml". + # Edit it when original Mastodon changed ElasticSearch config. + settings index: index_preset(refresh_interval: '30s'), analysis: ChewyConfig.instance.tags index_scope ::Tag.listable @@ -41,7 +14,9 @@ class TagsIndex < Chewy::Index end root date_detection: false do - field(:name, type: 'text', analyzer: 'content', value: :display_name) { field(:edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content') } + field(:name, type: 'text', analyzer: ChewyConfig.instance.tags_analyzers.dig('name', 'analyzer'), value: :display_name) do + field(:edge_ngram, type: 'text', analyzer: ChewyConfig.instance.tags_analyzers.dig('name', 'edge_ngram', 'analyzer'), search_analyzer: ChewyConfig.instance.tags_analyzers.dig('name', 'edge_ngram', 'search_analyzer')) + end field(:reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }) field(:usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts }) field(:last_status_at, type: 'date', value: ->(tag) { clamp_date(tag.last_status_at || tag.created_at) }) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c7b82b987e..98e68bd873 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -25,7 +25,7 @@ class AccountsController < ApplicationController limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE @statuses = filtered_statuses.without_reblogs.limit(limit) - @statuses = cache_collection(@statuses, Status) + @statuses = preload_collection(@statuses, Status) end format.json do @@ -46,9 +46,11 @@ class AccountsController < ApplicationController end def default_statuses - visibilities = [:public, :unlisted, :public_unlisted] - visibilities << :login unless current_account.nil? - @account.statuses.where(visibility: visibilities) + if current_account.present? + @account.statuses.distributable_visibility + else + @account.statuses.distributable_visibility_for_anonymous + end end def only_media_scope diff --git a/app/controllers/activitypub/base_controller.rb b/app/controllers/activitypub/base_controller.rb index 388d4b9e1d..c2563c492e 100644 --- a/app/controllers/activitypub/base_controller.rb +++ b/app/controllers/activitypub/base_controller.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class ActivityPub::BaseController < Api::BaseController + include SignatureVerification + include AccountOwnedConcern + skip_before_action :require_authenticated_user! skip_before_action :require_not_suspended! skip_around_action :set_locale diff --git a/app/controllers/activitypub/claims_controller.rb b/app/controllers/activitypub/claims_controller.rb deleted file mode 100644 index 339333e462..0000000000 --- a/app/controllers/activitypub/claims_controller.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -class ActivityPub::ClaimsController < ActivityPub::BaseController - include SignatureVerification - include AccountOwnedConcern - - 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 2188eb72a3..c80db3500d 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class ActivityPub::CollectionsController < ActivityPub::BaseController - include SignatureVerification - include AccountOwnedConcern - vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature!, if: :authorized_fetch_mode? @@ -21,12 +18,10 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def set_items case params[:id] when 'featured' - @items = for_signed_account { cache_collection(@account.pinned_statuses, Status) } + @items = for_signed_account { preload_collection(@account.pinned_statuses, Status) } @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 @@ -34,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 @@ -45,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 @@ -54,7 +49,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def collection_presenter ActivityPub::CollectionPresenter.new( - id: account_collection_url(@account, params[:id]), + id: ActivityPub::TagManager.instance.collection_uri_for(@account, params[:id]), type: @type, size: @size, items: @items diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb index 976caa3445..392dd36bcd 100644 --- a/app/controllers/activitypub/followers_synchronizations_controller.rb +++ b/app/controllers/activitypub/followers_synchronizations_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController - include SignatureVerification - include AccountOwnedConcern - vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature! @@ -24,7 +21,7 @@ class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseContro end def set_items - @items = @account.followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(uri_prefix)}/%", false, true)).or(@account.followers.where(uri: uri_prefix)).pluck(:uri) + @items = @account.followers.matches_uri_prefix(uri_prefix).pluck(:uri) end def collection_presenter diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 5ee85474e7..49cfc8ad1c 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class ActivityPub::InboxesController < ActivityPub::BaseController - include SignatureVerification include JsonLdHelper - include AccountOwnedConcern before_action :skip_unknown_actor_activity before_action :require_actor_signature! @@ -24,7 +22,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController def unknown_affected_account? json = Oj.load(body, mode: :strict) - json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists? + json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.exists?(uri: json['actor']) rescue Oj::ParseError false end @@ -62,11 +60,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true' || signed_request_account.nil? # Re-using the syntax for signature parameters - tree = SignatureParamsParser.new.parse(raw_params) - params = SignatureParamsTransformer.new.apply(tree) + params = SignatureParser.parse(raw_params) ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params) - rescue Parslet::ParseFailed + rescue SignatureParser::ParsingError Rails.logger.warn 'Error parsing Collection-Synchronization header' end diff --git a/app/controllers/activitypub/likes_controller.rb b/app/controllers/activitypub/likes_controller.rb new file mode 100644 index 0000000000..4aa6a4a771 --- /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/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index cce10cfccc..171161d491 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -3,9 +3,6 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController LIMIT = 20 - include SignatureVerification - include AccountOwnedConcern - vary_by -> { 'Signature' if authorized_fetch_mode? || page_requested? } before_action :require_account_signature!, if: :authorized_fetch_mode? @@ -44,12 +41,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController end end - def outbox_url(**kwargs) - if params[:account_username].present? - account_outbox_url(@account, **kwargs) - else - instance_actor_outbox_url(**kwargs) - end + def outbox_url(...) + ActivityPub::TagManager.instance.outbox_uri_for(@account, ...) end def next_page @@ -63,7 +56,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController def set_statuses return unless page_requested? - @statuses = cache_collection_paginated_by_id( + @statuses = preload_collection_paginated_by_id( AccountStatusesFilter.new(@account, signed_request_account).results, Status, LIMIT, diff --git a/app/controllers/activitypub/references_controller.rb b/app/controllers/activitypub/references_controller.rb index 58c70e2771..7412540fe4 100644 --- a/app/controllers/activitypub/references_controller.rb +++ b/app/controllers/activitypub/references_controller.rb @@ -31,7 +31,7 @@ class ActivityPub::ReferencesController < ActivityPub::BaseController end def cached_references - cache_collection(Status.where(id: results).reorder(:id), Status) + preload_collection(Status.where(id: results).reorder(:id), Status) end def results @@ -69,6 +69,7 @@ class ActivityPub::ReferencesController < ActivityPub::BaseController ActivityPub::CollectionPresenter.new( type: :unordered, id: ActivityPub::TagManager.instance.references_uri_for(@status), + size: @status.status_referred_by_count, first: page ) end diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index f7afc9ba31..0a19275d38 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class ActivityPub::RepliesController < ActivityPub::BaseController - include SignatureVerification include Authorization - include AccountOwnedConcern DESCENDANTS_LIMIT = 60 @@ -14,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 @@ -33,7 +31,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController def set_replies @replies = only_other_accounts? ? Status.where.not(account_id: @account.id).joins(:account).merge(Account.without_suspended) : @account.statuses - @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted, :public_unlisted, :login]) + @replies = @replies.distributable_visibility.where(in_reply_to_id: @status.id) @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) end diff --git a/app/controllers/activitypub/shares_controller.rb b/app/controllers/activitypub/shares_controller.rb new file mode 100644 index 0000000000..65b4a5b383 --- /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_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index e674bf55a0..91849811e3 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -34,7 +34,8 @@ module Admin end def resource_params - params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses) + params + .expect(admin_account_action: [:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses]) end end end diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb index 8b6c1a4454..7f65ced517 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' @@ -29,10 +29,8 @@ module Admin private def resource_params - params.require(:account_moderation_note).permit( - :content, - :target_account_id - ) + params + .expect(account_moderation_note: [:content, :target_account_id]) end def set_account_moderation_note diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 9beb8fde6b..a779a0cf51 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -3,13 +3,13 @@ module Admin class AccountsController < BaseController before_action :set_account, except: [:index, :batch] - before_action :require_remote_account!, only: [:redownload] + before_action :require_remote_account!, only: [:redownload, :approve_remote, :reject_remote] before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] def index authorize :account, :index? - @accounts = filtered_accounts.page(params[:page]) + @accounts = filtered_accounts.page(params[:page]).without_count @form = Form::AccountBatch.new end @@ -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 @@ -66,6 +66,20 @@ module Admin redirect_to admin_accounts_path(status: 'pending'), notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct) end + def approve_remote + authorize @account, :approve_remote? + @account.approve_remote! + log_action :approve_remote, @account + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.approved_msg', username: @account.acct) + end + + def reject_remote + authorize @account, :reject_remote? + @account.reject_remote! + log_action :reject_remote, @account + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct) + end + def destroy authorize @account, :destroy? Admin::AccountDeletionWorker.perform_async(@account.id) @@ -158,7 +172,8 @@ module Admin end def form_account_batch_params - params.require(:form_account_batch).permit(:action, account_ids: []) + params + .expect(form_account_batch: [:action, account_ids: []]) end def action_from_button @@ -168,6 +183,12 @@ module Admin 'approve' elsif params[:reject] 'reject' + elsif params[:approve_remote] + 'approve_remote' + elsif params[:approve_remote_domain] + 'approve_remote_domain' + elsif params[:reject_remote] + 'reject_remote' end end end diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb index 37a00ad225..8b8e83fde7 100644 --- a/app/controllers/admin/action_logs_controller.rb +++ b/app/controllers/admin/action_logs_controller.rb @@ -6,7 +6,7 @@ module Admin def index authorize :audit_log, :index? - @auditable_accounts = Account.where(id: Admin::ActionLog.select('distinct account_id')).select(:id, :username) + @auditable_accounts = Account.auditable.select(:id, :username) end private diff --git a/app/controllers/admin/announcements/distributions_controller.rb b/app/controllers/admin/announcements/distributions_controller.rb new file mode 100644 index 0000000000..4bd8769834 --- /dev/null +++ b/app/controllers/admin/announcements/distributions_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Admin::Announcements::DistributionsController < Admin::BaseController + before_action :set_announcement + + def create + authorize @announcement, :distribute? + @announcement.touch(:notification_sent_at) + Admin::DistributeAnnouncementNotificationWorker.perform_async(@announcement.id) + redirect_to admin_announcements_path + end + + private + + def set_announcement + @announcement = Announcement.find(params[:announcement_id]) + end +end diff --git a/app/controllers/admin/announcements/previews_controller.rb b/app/controllers/admin/announcements/previews_controller.rb new file mode 100644 index 0000000000..d77f931a7f --- /dev/null +++ b/app/controllers/admin/announcements/previews_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Admin::Announcements::PreviewsController < Admin::BaseController + before_action :set_announcement + + def show + authorize @announcement, :distribute? + @user_count = @announcement.scope_for_notification.count + end + + private + + def set_announcement + @announcement = Announcement.find(params[:announcement_id]) + end +end diff --git a/app/controllers/admin/announcements/tests_controller.rb b/app/controllers/admin/announcements/tests_controller.rb new file mode 100644 index 0000000000..f2457eb23a --- /dev/null +++ b/app/controllers/admin/announcements/tests_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Admin::Announcements::TestsController < Admin::BaseController + before_action :set_announcement + + def create + authorize @announcement, :distribute? + UserMailer.announcement_published(current_user, @announcement).deliver_later! + redirect_to admin_announcements_path + end + + private + + def set_announcement + @announcement = Announcement.find(params[:announcement_id]) + end +end diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb index 8f9708183a..eaf84aab25 100644 --- a/app/controllers/admin/announcements_controller.rb +++ b/app/controllers/admin/announcements_controller.rb @@ -6,6 +6,7 @@ class Admin::AnnouncementsController < Admin::BaseController def index authorize :announcement, :index? + @published_announcements_count = Announcement.published.async_count end def new @@ -83,6 +84,7 @@ class Admin::AnnouncementsController < Admin::BaseController end def resource_params - params.require(:announcement).permit(:text, :scheduled_at, :starts_at, :ends_at, :all_day) + params + .expect(announcement: [:text, :scheduled_at, :starts_at, :ends_at, :all_day]) end end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 4b5afbe157..14338dd293 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -7,19 +7,14 @@ module Admin layout 'admin' - before_action :set_body_classes - before_action :set_cache_headers + before_action :set_referrer_policy_header 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) + def set_referrer_policy_header + response.headers['Referrer-Policy'] = 'same-origin' end def set_user diff --git a/app/controllers/admin/change_emails_controller.rb b/app/controllers/admin/change_emails_controller.rb index a689d3a530..c923b94b1a 100644 --- a/app/controllers/admin/change_emails_controller.rb +++ b/app/controllers/admin/change_emails_controller.rb @@ -41,9 +41,8 @@ module Admin end def resource_params - params.require(:user).permit( - :unconfirmed_email - ) + params + .expect(user: [:unconfirmed_email]) end end end diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb index 6f4e426797..702550eecc 100644 --- a/app/controllers/admin/confirmations_controller.rb +++ b/app/controllers/admin/confirmations_controller.rb @@ -3,11 +3,11 @@ module Admin class ConfirmationsController < BaseController before_action :set_user - before_action :check_confirmation, only: [:resend] + before_action :redirect_confirmed_user, only: [:resend], if: :user_confirmed? def create authorize @user, :confirm? - @user.confirm! + @user.mark_email_as_confirmed! log_action :confirm, @user redirect_to admin_accounts_path end @@ -25,11 +25,13 @@ module Admin private - def check_confirmation - if @user.confirmed? - flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') - redirect_to admin_accounts_path - end + def redirect_confirmed_user + flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') + redirect_to admin_accounts_path + end + + def user_confirmed? + @user.confirmed? end end end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index d54bda4385..596b167249 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -7,7 +7,7 @@ module Admin def index authorize :custom_emoji, :index? - @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) + @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]).without_count @form = Form::CustomEmojiBatch.new end @@ -40,7 +40,7 @@ module Admin @custom_emoji.assign_attributes(update_params) if @custom_emoji.save - log_action :create, @custom_emoji + log_action :update, @custom_emoji redirect_to admin_custom_emojis_path(filter_params), notice: I18n.t('admin.custom_emojis.updated_msg') else render :new @@ -67,11 +67,13 @@ module Admin end def resource_params - params.require(:custom_emoji).permit(:shortcode, :image, :category_id, :visible_in_picker, :aliases_raw, :license) + params + .expect(custom_emoji: [:shortcode, :image, :category_id, :visible_in_picker, :aliases_raw, :license]) end def update_params - params.require(:custom_emoji).permit(:category_id, :visible_in_picker, :aliases_raw, :license) + params + .expect(custom_emoji: [:category_id, :visible_in_picker, :aliases_raw, :license]) end def filtered_custom_emojis @@ -101,7 +103,8 @@ module Admin end def form_custom_emoji_batch_params - params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: []) + params + .expect(form_custom_emoji_batch: [:action, :category_id, :category_name, custom_emoji_ids: []]) end end end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 3a6df662ea..5b0867dcfb 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/disputes/appeals_controller.rb b/app/controllers/admin/disputes/appeals_controller.rb index 5e342409b0..0c41553676 100644 --- a/app/controllers/admin/disputes/appeals_controller.rb +++ b/app/controllers/admin/disputes/appeals_controller.rb @@ -6,6 +6,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController def index authorize :appeal, :index? + @pending_appeals_count = Appeal.pending.async_count @appeals = filtered_appeals.page(params[:page]) end diff --git a/app/controllers/admin/domain_allows_controller.rb b/app/controllers/admin/domain_allows_controller.rb index 31be1978bb..913c1a8246 100644 --- a/app/controllers/admin/domain_allows_controller.rb +++ b/app/controllers/admin/domain_allows_controller.rb @@ -25,6 +25,8 @@ class Admin::DomainAllowsController < Admin::BaseController def destroy authorize @domain_allow, :destroy? UnallowDomainService.new.call(@domain_allow) + log_action :destroy, @domain_allow + redirect_to admin_instances_path, notice: I18n.t('admin.domain_allows.destroyed_msg') end @@ -35,6 +37,7 @@ class Admin::DomainAllowsController < Admin::BaseController end def resource_params - params.require(:domain_allow).permit(:domain) + params + .expect(domain_allow: [:domain]) end end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 987c82e7e3..520db814f2 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -4,6 +4,28 @@ module Admin class DomainBlocksController < BaseController before_action :set_domain_block, only: [:destroy, :edit, :update] + PERMITTED_PARAMS = %i( + block_trends + detect_invalid_subscription + domain + hidden + obfuscate + private_comment + public_comment + reject_favourite + reject_friend + reject_hashtag + reject_media + reject_new_follow + reject_reply_exclude_followers + reject_reports + reject_send_sensitive + reject_straight_follow + severity + ).freeze + + PERMITTED_UPDATE_PARAMS = PERMITTED_PARAMS.without(:domain).freeze + def batch authorize :domain_block, :create? @form = Form::DomainBlockBatch.new(form_domain_block_batch_params.merge(current_account: current_account, action: action_from_button)) @@ -13,7 +35,9 @@ module Admin rescue Mastodon::NotPermittedError flash[:alert] = I18n.t('admin.domain_blocks.not_permitted') else - redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') + flash[:notice] = I18n.t('admin.domain_blocks.created_msg') + ensure + redirect_to admin_instances_path(limited: '1') end def new @@ -88,18 +112,28 @@ module Admin end def update_params - params.require(:domain_block).permit(:severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, - :reject_straight_follow, :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden) + params + .require(:domain_block) + .slice(*PERMITTED_UPDATE_PARAMS) + .permit(*PERMITTED_UPDATE_PARAMS) end def resource_params - params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, - :reject_straight_follow, :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden) + params + .require(:domain_block) + .slice(*PERMITTED_PARAMS) + .permit(*PERMITTED_PARAMS) end def form_domain_block_batch_params - params.require(:form_domain_block_batch).permit(domain_blocks_attributes: [:enabled, :domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, - :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden]) + params + .expect( + form_domain_block_batch: [ + domain_blocks_attributes: [[:enabled, :domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate, + :reject_favourite, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, + :reject_straight_follow, :reject_new_follow, :reject_friend, :block_trends, :detect_invalid_subscription, :hidden]], + ] + ) end def action_from_button diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index ff754bc0b4..12f221164f 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -5,7 +5,7 @@ module Admin def index authorize :email_domain_block, :index? - @email_domain_blocks = EmailDomainBlock.where(parent_id: nil).includes(:children).order(id: :desc).page(params[:page]) + @email_domain_blocks = EmailDomainBlock.parents.includes(:children).order(id: :desc).page(params[:page]) @form = Form::EmailDomainBlockBatch.new end @@ -38,7 +38,7 @@ module Admin log_action :create, @email_domain_block (@email_domain_block.other_domains || []).uniq.each do |domain| - next if EmailDomainBlock.where(domain: domain).exists? + next if EmailDomainBlock.exists?(domain: domain) other_email_domain_block = EmailDomainBlock.create!(domain: domain, allow_with_approval: @email_domain_block.allow_with_approval, parent: @email_domain_block) log_action :create, other_email_domain_block @@ -58,18 +58,17 @@ module Admin private def set_resolved_records - Resolv::DNS.open do |dns| - dns.timeouts = 5 - @resolved_records = dns.getresources(@email_domain_block.domain, Resolv::DNS::Resource::IN::MX).to_a - end + @resolved_records = DomainResource.new(@email_domain_block.domain).mx end def resource_params - params.require(:email_domain_block).permit(:domain, :allow_with_approval, other_domains: []) + params + .expect(email_domain_block: [:domain, :allow_with_approval, other_domains: []]) end def form_email_domain_block_batch_params - params.require(:form_email_domain_block_batch).permit(email_domain_block_ids: []) + params + .expect(form_email_domain_block_batch: [email_domain_block_ids: []]) end def action_from_button diff --git a/app/controllers/admin/export_domain_blocks_controller.rb b/app/controllers/admin/export_domain_blocks_controller.rb index ffc4478172..8d7350c765 100644 --- a/app/controllers/admin/export_domain_blocks_controller.rb +++ b/app/controllers/admin/export_domain_blocks_controller.rb @@ -36,7 +36,17 @@ module Admin reject_reports: row.fetch('#reject_reports', false), private_comment: @global_private_comment, public_comment: row['#public_comment'], - obfuscate: row.fetch('#obfuscate', false)) + obfuscate: row.fetch('#obfuscate', false), + reject_favourite: row.fetch('#reject_favourite', false), + reject_send_sensitive: row.fetch('#reject_send_sensitive', false), + reject_hashtag: row.fetch('#reject_hashtag', false), + reject_straight_follow: row.fetch('#reject_straight_follow', false), + reject_new_follow: row.fetch('#reject_new_follow', false), + hidden: row.fetch('#hidden', false), + detect_invalid_subscription: row.fetch('#detect_invalid_subscription', false), + reject_reply_exclude_followers: row.fetch('#reject_reply_exclude_followers', false), + reject_friend: row.fetch('#reject_friend', false), + block_trends: row.fetch('#block_trends', false)) if domain_block.invalid? flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: domain_block.errors.full_messages.join(', ')) @@ -49,7 +59,7 @@ module Admin next end - @warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain) + @warning_domains = instances_from_imported_blocks.pluck(:domain) rescue ActionController::ParameterMissing flash.now[:alert] = I18n.t('admin.export_domain_blocks.no_file') set_dummy_import! @@ -58,18 +68,56 @@ module Admin private + def instances_from_imported_blocks + Instance.with_domain_follows(@domain_blocks.map(&:domain)) + end + def export_filename 'domain_blocks.csv' end def export_headers - %w(#domain #severity #reject_media #reject_reports #public_comment #obfuscate) + %w( + #domain + #severity + #reject_media + #reject_reports + #public_comment + #obfuscate + #reject_favourite + #reject_send_sensitive + #reject_hashtag + #reject_straight_follow + #reject_new_follow + #hidden + #detect_invalid_subscription + #reject_reply_exclude_followers + #reject_friend + #block_trends + ) end def export_data CSV.generate(headers: export_headers, write_headers: true) do |content| DomainBlock.with_limitations.order(id: :asc).each do |instance| - content << [instance.domain, instance.severity, instance.reject_media, instance.reject_reports, instance.public_comment, instance.obfuscate] + content << [ + instance.domain, + instance.severity, + instance.reject_media, + instance.reject_reports, + instance.public_comment, + instance.obfuscate, + instance.reject_favourite, + instance.reject_send_sensitive, + instance.reject_hashtag, + instance.reject_straight_follow, + instance.reject_new_follow, + instance.hidden, + instance.detect_invalid_subscription, + instance.reject_reply_exclude_followers, + instance.reject_friend, + instance.block_trends, + ] end end end diff --git a/app/controllers/admin/fasp/debug/callbacks_controller.rb b/app/controllers/admin/fasp/debug/callbacks_controller.rb new file mode 100644 index 0000000000..28aba5e489 --- /dev/null +++ b/app/controllers/admin/fasp/debug/callbacks_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Admin::Fasp::Debug::CallbacksController < Admin::BaseController + def index + authorize [:admin, :fasp, :provider], :update? + + @callbacks = Fasp::DebugCallback + .includes(:fasp_provider) + .order(created_at: :desc) + end + + def destroy + authorize [:admin, :fasp, :provider], :update? + + callback = Fasp::DebugCallback.find(params[:id]) + callback.destroy + + redirect_to admin_fasp_debug_callbacks_path + end +end diff --git a/app/controllers/admin/fasp/debug_calls_controller.rb b/app/controllers/admin/fasp/debug_calls_controller.rb new file mode 100644 index 0000000000..1e1b6dbf3c --- /dev/null +++ b/app/controllers/admin/fasp/debug_calls_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Admin::Fasp::DebugCallsController < Admin::BaseController + before_action :set_provider + + def create + authorize [:admin, @provider], :update? + + @provider.perform_debug_call + + redirect_to admin_fasp_providers_path + end + + private + + def set_provider + @provider = Fasp::Provider.find(params[:provider_id]) + end +end diff --git a/app/controllers/admin/fasp/providers_controller.rb b/app/controllers/admin/fasp/providers_controller.rb new file mode 100644 index 0000000000..4f1f1271bf --- /dev/null +++ b/app/controllers/admin/fasp/providers_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Admin::Fasp::ProvidersController < Admin::BaseController + before_action :set_provider, only: [:show, :edit, :update, :destroy] + + def index + authorize [:admin, :fasp, :provider], :index? + + @providers = Fasp::Provider.order(confirmed: :asc, created_at: :desc) + end + + def show + authorize [:admin, @provider], :show? + end + + def edit + authorize [:admin, @provider], :update? + end + + def update + authorize [:admin, @provider], :update? + + if @provider.update(provider_params) + redirect_to admin_fasp_providers_path + else + render :edit + end + end + + def destroy + authorize [:admin, @provider], :destroy? + + @provider.destroy + + redirect_to admin_fasp_providers_path + end + + private + + def provider_params + params.expect(fasp_provider: [capabilities_attributes: {}]) + end + + def set_provider + @provider = Fasp::Provider.find(params[:id]) + end +end diff --git a/app/controllers/admin/fasp/registrations_controller.rb b/app/controllers/admin/fasp/registrations_controller.rb new file mode 100644 index 0000000000..52c46c2eb6 --- /dev/null +++ b/app/controllers/admin/fasp/registrations_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Admin::Fasp::RegistrationsController < Admin::BaseController + before_action :set_provider + + def new + authorize [:admin, @provider], :create? + end + + def create + authorize [:admin, @provider], :create? + + @provider.update_info!(confirm: true) + + redirect_to edit_admin_fasp_provider_path(@provider) + end + + private + + def set_provider + @provider = Fasp::Provider.find(params[:provider_id]) + end +end diff --git a/app/controllers/admin/follow_recommendations_controller.rb b/app/controllers/admin/follow_recommendations_controller.rb index a54e41bd8c..b060cfbe94 100644 --- a/app/controllers/admin/follow_recommendations_controller.rb +++ b/app/controllers/admin/follow_recommendations_controller.rb @@ -37,7 +37,8 @@ module Admin end def form_account_batch_params - params.require(:form_account_batch).permit(:action, account_ids: []) + params + .expect(form_account_batch: [:action, account_ids: []]) end def filter_params diff --git a/app/controllers/admin/friend_servers_controller.rb b/app/controllers/admin/friend_servers_controller.rb index 729d3b3912..ec41ba672c 100644 --- a/app/controllers/admin/friend_servers_controller.rb +++ b/app/controllers/admin/friend_servers_controller.rb @@ -79,11 +79,11 @@ module Admin end def resource_params - params.require(:friend_domain).permit(:domain, :inbox_url, :available, :pseudo_relay, :delivery_local, :unlocked, :allow_all_posts) + params.expect(friend_domain: [:domain, :inbox_url, :available, :pseudo_relay, :delivery_local, :unlocked, :allow_all_posts]) end def update_resource_params - params.require(:friend_domain).permit(:inbox_url, :available, :pseudo_relay, :delivery_local, :unlocked, :allow_all_posts) + params.expect(friend_domain: [:inbox_url, :available, :pseudo_relay, :delivery_local, :unlocked, :allow_all_posts]) end def warn_signatures_not_enabled! diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index a6997b62f7..a48c4773ed 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -5,6 +5,8 @@ module Admin before_action :set_instances, only: :index before_action :set_instance, except: :index + LOGS_LIMIT = 5 + def index authorize :instance, :index? preload_delivery_failures! @@ -13,6 +15,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(LOGS_LIMIT) end def destroy diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb index dabfe97655..ac4ee35271 100644 --- a/app/controllers/admin/invites_controller.rb +++ b/app/controllers/admin/invites_controller.rb @@ -32,14 +32,15 @@ module Admin def deactivate_all authorize :invite, :deactivate_all? - Invite.available.in_batches.update_all(expires_at: Time.now.utc) + Invite.available.in_batches.touch_all(:expires_at) redirect_to admin_invites_path end private def resource_params - params.require(:invite).permit(:max_uses, :expires_in) + params + .expect(invite: [:max_uses, :expires_in]) end def filtered_invites diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb index 1bd7ec8059..afabda1b88 100644 --- a/app/controllers/admin/ip_blocks_controller.rb +++ b/app/controllers/admin/ip_blocks_controller.rb @@ -44,7 +44,8 @@ module Admin private def resource_params - params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in) + params + .expect(ip_block: [:ip, :severity, :comment, :expires_in]) end def action_from_button @@ -52,7 +53,8 @@ module Admin end def form_ip_block_batch_params - params.require(:form_ip_block_batch).permit(ip_block_ids: []) + params + .expect(form_ip_block_batch: [ip_block_ids: []]) end end end diff --git a/app/controllers/admin/ng_rule_histories_controller.rb b/app/controllers/admin/ng_rule_histories_controller.rb new file mode 100644 index 0000000000..9dccefaf49 --- /dev/null +++ b/app/controllers/admin/ng_rule_histories_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Admin + class NgRuleHistoriesController < BaseController + before_action :set_ng_rule + before_action :set_histories + + PER_PAGE = 20 + + def show + authorize :ng_words, :show? + end + + private + + def set_ng_rule + @ng_rule = ::NgRule.find(params[:id]) + end + + def set_histories + @histories = NgRuleHistory.where(ng_rule_id: params[:id]).order(id: :desc).page(params[:page]).per(PER_PAGE) + end + end +end diff --git a/app/controllers/admin/ng_rules_controller.rb b/app/controllers/admin/ng_rules_controller.rb new file mode 100644 index 0000000000..0bdda41c0c --- /dev/null +++ b/app/controllers/admin/ng_rules_controller.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Admin + class NgRulesController < BaseController + before_action :set_ng_rule, only: [:edit, :update, :destroy, :duplicate] + + def index + authorize :ng_words, :show? + + @ng_rules = ::NgRule.order(id: :asc) + end + + def new + authorize :ng_words, :show? + + @ng_rule = ::NgRule.build + end + + def edit + authorize :ng_words, :show? + end + + def create + authorize :ng_words, :create? + + begin + test_words! + rescue + flash[:alert] = I18n.t('admin.ng_rules.test_error') + redirect_to new_admin_ng_rule_path + return + end + + @ng_rule = ::NgRule.build(resource_params) + + if @ng_rule.save + redirect_to admin_ng_rules_path + else + render :new + end + end + + def update + authorize :ng_words, :create? + + begin + test_words! + rescue + flash[:alert] = I18n.t('admin.ng_rules.test_error') + redirect_to edit_admin_ng_rule_path(id: @ng_rule.id) + return + end + + if @ng_rule.update(resource_params) + redirect_to admin_ng_rules_path + else + render :edit + end + end + + def duplicate + authorize :ng_words, :create? + + @ng_rule = @ng_rule.copy! + + flash[:alert] = I18n.t('admin.ng_rules.copy_error') unless @ng_rule.save + + redirect_to admin_ng_rules_path + end + + def destroy + authorize :ng_words, :create? + + @ng_rule.destroy + redirect_to admin_ng_rules_path + end + + private + + def set_ng_rule + @ng_rule = ::NgRule.find(params[:id]) + end + + def resource_params + params.expect(ng_rule: [:title, :expires_in, :available, :account_domain, :account_username, :account_display_name, + :account_note, :account_field_name, :account_field_value, :account_avatar_state, + :account_header_state, :account_include_local, :status_spoiler_text, :status_text, :status_tag, + :status_sensitive_state, :status_cw_state, :status_media_state, :status_poll_state, + :status_mention_state, :status_reference_state, + :status_quote_state, :status_reply_state, :status_media_threshold, :status_poll_threshold, + :status_mention_threshold, :status_allow_follower_mention, + :reaction_allow_follower, :emoji_reaction_name, :emoji_reaction_origin_domain, + :status_reference_threshold, :account_allow_followed_by_local, :record_history_also_local, + status_visibility: [], status_searchability: [], reaction_type: []]) + end + + def test_words! + arr = [ + resource_params[:account_domain], + resource_params[:account_username], + resource_params[:account_display_name], + resource_params[:account_note], + resource_params[:account_field_name], + resource_params[:account_field_value], + resource_params[:status_spoiler_text], + resource_params[:status_text], + resource_params[:status_tag], + resource_params[:emoji_reaction_name], + resource_params[:emoji_reaction_origin_domain], + ].compact_blank.join("\n") + + Admin::NgRule.extract_test!(arr) if arr.present? + end + end +end diff --git a/app/controllers/admin/ng_words/keywords_controller.rb b/app/controllers/admin/ng_words/keywords_controller.rb new file mode 100644 index 0000000000..10969204e8 --- /dev/null +++ b/app/controllers/admin/ng_words/keywords_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Admin + class NgWords::KeywordsController < NgWordsController + def show + super + @ng_words = ::NgWord.caches.presence || [::NgWord.new] + end + + protected + + def validate + begin + ::NgWord.save_from_raws(settings_params_test) + return true + rescue + flash[:alert] = I18n.t('admin.ng_words.test_error') + redirect_to after_update_redirect_path + end + + false + end + + def avoid_save? + true + end + + private + + def after_update_redirect_path + admin_ng_words_keywords_path + end + end +end diff --git a/app/controllers/admin/ng_words/settings_controller.rb b/app/controllers/admin/ng_words/settings_controller.rb new file mode 100644 index 0000000000..63edadfce5 --- /dev/null +++ b/app/controllers/admin/ng_words/settings_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Admin + class NgWords::SettingsController < NgWordsController + protected + + def after_update_redirect_path + admin_ng_words_settings_path + end + end +end diff --git a/app/controllers/admin/ng_words/white_list_controller.rb b/app/controllers/admin/ng_words/white_list_controller.rb new file mode 100644 index 0000000000..8fdb7df327 --- /dev/null +++ b/app/controllers/admin/ng_words/white_list_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Admin + class NgWords::WhiteListController < NgWordsController + def show + super + @white_list_domains = SpecifiedDomain.white_list_domain_caches.presence || [SpecifiedDomain.new] + end + + protected + + def validate + begin + SpecifiedDomain.save_from_raws_as_white_list(settings_params_list) + return true + rescue + flash[:alert] = I18n.t('admin.ng_words.save_error') + redirect_to after_update_redirect_path + end + + false + end + + def after_update_redirect_path + admin_ng_words_white_list_path + end + + private + + def settings_params_list + params.require(:form_admin_settings)[:specified_domains] + end + end +end diff --git a/app/controllers/admin/ng_words_controller.rb b/app/controllers/admin/ng_words_controller.rb index 17a6306d1a..9e437f8c8b 100644 --- a/app/controllers/admin/ng_words_controller.rb +++ b/app/controllers/admin/ng_words_controller.rb @@ -11,10 +11,10 @@ module Admin def create authorize :ng_words, :create? - begin - test_words - rescue - flash[:alert] = I18n.t('admin.ng_words.test_error') + return unless validate + + if avoid_save? + flash[:notice] = I18n.t('generic.changes_saved_msg') redirect_to after_update_redirect_path return end @@ -29,19 +29,28 @@ module Admin end end - private + protected - def test_words - ng_words = "#{settings_params['ng_words']}\n#{settings_params['ng_words_for_stranger_mention']}".split(/\r\n|\r|\n/).filter(&:present?) - Admin::NgWord.reject_with_custom_words?('Sample text', ng_words) + def validate + true end def after_update_redirect_path admin_ng_words_path end + def avoid_save? + false + end + + private + def settings_params - params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS) + params.expect(form_admin_settings: [*Form::AdminSettings::KEYS]) + end + + def settings_params_test + params.expect(form_admin_settings: [ng_words_test: [keywords: [], regexps: [], strangers: [], temporary_ids: []]])['ng_words_test'] end end end diff --git a/app/controllers/admin/ngword_histories_controller.rb b/app/controllers/admin/ngword_histories_controller.rb new file mode 100644 index 0000000000..90f13db2fe --- /dev/null +++ b/app/controllers/admin/ngword_histories_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Admin + class NgwordHistoriesController < BaseController + before_action :set_histories + + PER_PAGE = 20 + + def index + authorize :ng_words, :show? + end + + private + + def set_histories + @histories = NgwordHistory.order(id: :desc).page(params[:page]).per(PER_PAGE) + end + end +end diff --git a/app/controllers/admin/relays_controller.rb b/app/controllers/admin/relays_controller.rb index c893802159..9a796949de 100644 --- a/app/controllers/admin/relays_controller.rb +++ b/app/controllers/admin/relays_controller.rb @@ -21,6 +21,7 @@ module Admin @relay = Relay.new(resource_params) if @relay.save + log_action :create, @relay @relay.enable! redirect_to admin_relays_path else @@ -31,18 +32,21 @@ module Admin def destroy authorize :relay, :update? @relay.destroy + log_action :destroy, @relay redirect_to admin_relays_path end def enable authorize :relay, :update? @relay.enable! + log_action :enable, @relay redirect_to admin_relays_path end def disable authorize :relay, :update? @relay.disable! + log_action :disable, @relay redirect_to admin_relays_path end @@ -53,7 +57,8 @@ module Admin end def resource_params - params.require(:relay).permit(:inbox_url) + params + .expect(relay: [:inbox_url]) end def warn_signatures_not_enabled! diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb index b5f04a1caa..10dbe846e4 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 @@ -47,10 +47,8 @@ module Admin end def resource_params - params.require(:report_note).permit( - :content, - :report_id - ) + params + .expect(report_note: [:content, :report_id]) end def set_report_note diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 00d200d7c8..aa877f1448 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/roles_controller.rb b/app/controllers/admin/roles_controller.rb index bcfc11159c..2f9af8a6fc 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -61,7 +61,8 @@ module Admin end def resource_params - params.require(:user_role).permit(:name, :color, :highlighted, :position, permissions_as_keys: []) + params + .expect(user_role: [:name, :color, :highlighted, :position, permissions_as_keys: []]) end end end diff --git a/app/controllers/admin/rules_controller.rb b/app/controllers/admin/rules_controller.rb index d31aec6ea8..289b6a98c3 100644 --- a/app/controllers/admin/rules_controller.rb +++ b/app/controllers/admin/rules_controller.rb @@ -53,7 +53,8 @@ module Admin end def resource_params - params.require(:rule).permit(:text, :priority) + params + .expect(rule: [:text, :hint, :priority]) end end end diff --git a/app/controllers/admin/sensitive_words_controller.rb b/app/controllers/admin/sensitive_words_controller.rb index eaa6ae802e..716dcc708a 100644 --- a/app/controllers/admin/sensitive_words_controller.rb +++ b/app/controllers/admin/sensitive_words_controller.rb @@ -6,13 +6,14 @@ module Admin authorize :sensitive_words, :show? @admin_settings = Form::AdminSettings.new + @sensitive_words = ::SensitiveWord.caches.presence || [::SensitiveWord.new] end def create authorize :sensitive_words, :create? begin - test_words + ::SensitiveWord.save_from_raws(settings_params_test) rescue flash[:alert] = I18n.t('admin.ng_words.test_error') redirect_to after_update_redirect_path @@ -31,18 +32,16 @@ module Admin private - def test_words - sensitive_words = settings_params['sensitive_words'].split(/\r\n|\r|\n/) - sensitive_words_for_full = settings_params['sensitive_words_for_full'].split(/\r\n|\r|\n/) - Admin::NgWord.reject_with_custom_words?('Sample text', sensitive_words + sensitive_words_for_full) - end - def after_update_redirect_path admin_sensitive_words_path end def settings_params - params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS) + params.expect(form_admin_settings: [*Form::AdminSettings::KEYS]) + end + + def settings_params_test + params.require(:form_admin_settings)[:sensitive_words_test] end end end diff --git a/app/controllers/admin/settings/registrations_controller.rb b/app/controllers/admin/settings/registrations_controller.rb index b4a74349c0..6dbc86df9a 100644 --- a/app/controllers/admin/settings/registrations_controller.rb +++ b/app/controllers/admin/settings/registrations_controller.rb @@ -1,9 +1,18 @@ # frozen_string_literal: true class Admin::Settings::RegistrationsController < Admin::SettingsController + include RegistrationLimitationHelper + + before_action :set_limitation_counts, only: :show # rubocop:disable Rails/LexicallyScopedActionFilter + private def after_update_redirect_path admin_settings_registrations_path end + + def set_limitation_counts + @current_users_count = user_count_for_registration + @current_users_count_today = today_increase_user_count + end end diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index 338a3638c4..2ae5ec8255 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -28,7 +28,8 @@ module Admin end def settings_params - params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS) + params + .expect(form_admin_settings: [*Form::AdminSettings::KEYS]) end end end diff --git a/app/controllers/admin/site_uploads_controller.rb b/app/controllers/admin/site_uploads_controller.rb index a5d2cf41cf..96e61cf6bb 100644 --- a/app/controllers/admin/site_uploads_controller.rb +++ b/app/controllers/admin/site_uploads_controller.rb @@ -9,7 +9,7 @@ module Admin @site_upload.destroy! - redirect_to admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg') + redirect_back fallback_location: admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg') end private diff --git a/app/controllers/admin/software_updates_controller.rb b/app/controllers/admin/software_updates_controller.rb index 52d8cb41e6..c9be97eb71 100644 --- a/app/controllers/admin/software_updates_controller.rb +++ b/app/controllers/admin/software_updates_controller.rb @@ -6,7 +6,7 @@ module Admin def index authorize :software_update, :index? - @software_updates = SoftwareUpdate.all.sort_by(&:gem_version) + @software_updates = SoftwareUpdate.by_version.filter(&:pending?) end private diff --git a/app/controllers/admin/special_domains_controller.rb b/app/controllers/admin/special_domains_controller.rb new file mode 100644 index 0000000000..b36fe28d6e --- /dev/null +++ b/app/controllers/admin/special_domains_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Admin + class SpecialDomainsController < BaseController + def show + authorize :instance, :show? + + @admin_settings = Form::AdminSettings.new + end + + def create + authorize :instance, :destroy? + + @admin_settings = Form::AdminSettings.new(settings_params) + + if @admin_settings.save + flash[:notice] = I18n.t('generic.changes_saved_msg') + redirect_to after_update_redirect_path + else + render :show + end + end + + private + + def after_update_redirect_path + admin_special_domains_path + end + + def settings_params + params.expect(form_admin_settings: [*Form::AdminSettings::KEYS]) + end + end +end diff --git a/app/controllers/admin/special_instances_controller.rb b/app/controllers/admin/special_instances_controller.rb index 3fd35d474e..a16bae13ef 100644 --- a/app/controllers/admin/special_instances_controller.rb +++ b/app/controllers/admin/special_instances_controller.rb @@ -28,7 +28,7 @@ module Admin end def settings_params - params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS) + params.expect(form_admin_settings: [*Form::AdminSettings::KEYS]) end end end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index 5fdea8c52f..956950fe0d 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -16,6 +16,8 @@ module Admin def show authorize [:admin, @status], :show? + + @status_batch_action = Admin::StatusBatchAction.new end def batch @@ -34,7 +36,8 @@ module Admin UpdateStatusService.new.call( @status, edit_status_account_id, - no_history: true + no_history: true, + bypass_validation: true ) log_action(:remove_history, @status) redirect_to admin_account_status_path @@ -46,7 +49,8 @@ module Admin @status, edit_status_account_id, media_ids: [], - media_attributes: [] + media_attributes: [], + bypass_validation: true ) log_action(:remove_media, @status) redirect_to admin_account_status_path @@ -57,7 +61,8 @@ module Admin UpdateStatusService.new.call( @status, edit_status_account_id, - sensitive: true + sensitive: true, + bypass_validation: true ) log_action(:force_sensitive, @status) redirect_to admin_account_status_path @@ -68,7 +73,8 @@ module Admin UpdateStatusService.new.call( @status, edit_status_account_id, - spoiler_text: 'CW' + spoiler_text: 'CW', + bypass_validation: true ) log_action(:force_cw, @status) redirect_to admin_account_status_path @@ -92,7 +98,8 @@ module Admin helper_method :batched_ordered_status_edits def admin_status_batch_action_params - params.require(:admin_status_batch_action).permit(status_ids: []) + params + .expect(admin_status_batch_action: [status_ids: []]) end def after_create_redirect_path @@ -121,7 +128,7 @@ module Admin return @edit_account_id || @account.id if @edit_account_checked @edit_account_checked = true - @edit_account_id = Account.local.find_by(username: 'official')&.id || @account.id + @edit_account_id = Account.representative.id end def filter_params diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 4f727c398a..a7bfd64794 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? @@ -29,7 +37,16 @@ module Admin end def tag_params - params.require(:tag).permit(:name, :display_name, :trendable, :usable, :listable) + params + .expect(tag: [: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/admin/terms_of_service/distributions_controller.rb b/app/controllers/admin/terms_of_service/distributions_controller.rb new file mode 100644 index 0000000000..c639b083dd --- /dev/null +++ b/app/controllers/admin/terms_of_service/distributions_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::DistributionsController < Admin::BaseController + before_action :set_terms_of_service + + def create + authorize @terms_of_service, :distribute? + @terms_of_service.touch(:notification_sent_at) + Admin::DistributeTermsOfServiceNotificationWorker.perform_async(@terms_of_service.id) + redirect_to admin_terms_of_service_index_path + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.find(params[:terms_of_service_id]) + end +end diff --git a/app/controllers/admin/terms_of_service/drafts_controller.rb b/app/controllers/admin/terms_of_service/drafts_controller.rb new file mode 100644 index 0000000000..0c67eb9df8 --- /dev/null +++ b/app/controllers/admin/terms_of_service/drafts_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::DraftsController < Admin::BaseController + before_action :set_terms_of_service + + def show + authorize :terms_of_service, :create? + end + + def update + authorize @terms_of_service, :update? + + @terms_of_service.published_at = Time.now.utc if params[:action_type] == 'publish' + + if @terms_of_service.update(resource_params) + log_action(:publish, @terms_of_service) if @terms_of_service.published? + redirect_to @terms_of_service.published? ? admin_terms_of_service_index_path : admin_terms_of_service_draft_path + else + render :show + end + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text, effective_date: 10.days.from_now) + end + + def current_terms_of_service + TermsOfService.live.first + end + + def resource_params + params + .expect(terms_of_service: [:text, :changelog, :effective_date]) + end +end diff --git a/app/controllers/admin/terms_of_service/generates_controller.rb b/app/controllers/admin/terms_of_service/generates_controller.rb new file mode 100644 index 0000000000..0edc87893e --- /dev/null +++ b/app/controllers/admin/terms_of_service/generates_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::GeneratesController < Admin::BaseController + before_action :set_instance_presenter + + def show + authorize :terms_of_service, :create? + + @generator = TermsOfService::Generator.new( + domain: @instance_presenter.domain, + admin_email: @instance_presenter.contact.email + ) + end + + def create + authorize :terms_of_service, :create? + + @generator = TermsOfService::Generator.new(resource_params) + + if @generator.valid? + TermsOfService.create!(text: @generator.render) + redirect_to admin_terms_of_service_draft_path + else + render :show + end + end + + private + + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + + def resource_params + params + .expect(terms_of_service_generator: [*TermsOfService::Generator::VARIABLES]) + end +end diff --git a/app/controllers/admin/terms_of_service/histories_controller.rb b/app/controllers/admin/terms_of_service/histories_controller.rb new file mode 100644 index 0000000000..8f12341aea --- /dev/null +++ b/app/controllers/admin/terms_of_service/histories_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::HistoriesController < Admin::BaseController + def show + authorize :terms_of_service, :index? + @terms_of_service = TermsOfService.published.all + end +end diff --git a/app/controllers/admin/terms_of_service/previews_controller.rb b/app/controllers/admin/terms_of_service/previews_controller.rb new file mode 100644 index 0000000000..0a1a966751 --- /dev/null +++ b/app/controllers/admin/terms_of_service/previews_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::PreviewsController < Admin::BaseController + before_action :set_terms_of_service + + def show + authorize @terms_of_service, :distribute? + @user_count = @terms_of_service.scope_for_notification.count + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.find(params[:terms_of_service_id]) + end +end diff --git a/app/controllers/admin/terms_of_service/tests_controller.rb b/app/controllers/admin/terms_of_service/tests_controller.rb new file mode 100644 index 0000000000..e2483c1005 --- /dev/null +++ b/app/controllers/admin/terms_of_service/tests_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::TestsController < Admin::BaseController + before_action :set_terms_of_service + + def create + authorize @terms_of_service, :distribute? + UserMailer.terms_of_service_changed(current_user, @terms_of_service).deliver_later! + redirect_to admin_terms_of_service_preview_path(@terms_of_service) + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.find(params[:terms_of_service_id]) + end +end diff --git a/app/controllers/admin/terms_of_service_controller.rb b/app/controllers/admin/terms_of_service_controller.rb new file mode 100644 index 0000000000..10aa5c66ca --- /dev/null +++ b/app/controllers/admin/terms_of_service_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Admin::TermsOfServiceController < Admin::BaseController + def index + authorize :terms_of_service, :index? + @terms_of_service = TermsOfService.published.first + end +end diff --git a/app/controllers/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/admin/trends/links/preview_card_providers_controller.rb index 768b79f8db..5a650d5d8c 100644 --- a/app/controllers/admin/trends/links/preview_card_providers_controller.rb +++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb @@ -4,6 +4,7 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll def index authorize :preview_card_provider, :review? + @pending_preview_card_providers_count = PreviewCardProvider.unreviewed.async_count @preview_card_providers = filtered_preview_card_providers.page(params[:page]) @form = Trends::PreviewCardProviderBatch.new end @@ -30,7 +31,8 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll end def trends_preview_card_provider_batch_params - params.require(:trends_preview_card_provider_batch).permit(:action, preview_card_provider_ids: []) + params + .expect(trends_preview_card_provider_batch: [:action, preview_card_provider_ids: []]) end def action_from_button diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb index 83d68eba63..68aa73c992 100644 --- a/app/controllers/admin/trends/links_controller.rb +++ b/app/controllers/admin/trends/links_controller.rb @@ -4,7 +4,7 @@ class Admin::Trends::LinksController < Admin::BaseController def index authorize :preview_card, :review? - @locales = PreviewCardTrend.pluck('distinct language') + @locales = PreviewCardTrend.locales @preview_cards = filtered_preview_cards.page(params[:page]) @form = Trends::PreviewCardBatch.new end @@ -31,7 +31,8 @@ class Admin::Trends::LinksController < Admin::BaseController end def trends_preview_card_batch_params - params.require(:trends_preview_card_batch).permit(:action, preview_card_ids: []) + params + .expect(trends_preview_card_batch: [:action, preview_card_ids: []]) end def action_from_button diff --git a/app/controllers/admin/trends/statuses_controller.rb b/app/controllers/admin/trends/statuses_controller.rb index 3d8b53ea8a..873d777fe3 100644 --- a/app/controllers/admin/trends/statuses_controller.rb +++ b/app/controllers/admin/trends/statuses_controller.rb @@ -4,7 +4,7 @@ class Admin::Trends::StatusesController < Admin::BaseController def index authorize [:admin, :status], :review? - @locales = StatusTrend.pluck('distinct language') + @locales = StatusTrend.locales @statuses = filtered_statuses.page(params[:page]) @form = Trends::StatusBatch.new end @@ -31,7 +31,8 @@ class Admin::Trends::StatusesController < Admin::BaseController end def trends_status_batch_params - params.require(:trends_status_batch).permit(:action, status_ids: []) + params + .expect(trends_status_batch: [:action, status_ids: []]) end def action_from_button diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb index f5946448ae..1ccd740686 100644 --- a/app/controllers/admin/trends/tags_controller.rb +++ b/app/controllers/admin/trends/tags_controller.rb @@ -4,6 +4,7 @@ class Admin::Trends::TagsController < Admin::BaseController def index authorize :tag, :review? + @pending_tags_count = Tag.pending_review.async_count @tags = filtered_tags.page(params[:page]) @form = Trends::TagBatch.new end @@ -30,7 +31,8 @@ class Admin::Trends::TagsController < Admin::BaseController end def trends_tag_batch_params - params.require(:trends_tag_batch).permit(:action, tag_ids: []) + params + .expect(trends_tag_batch: [:action, tag_ids: []]) end def action_from_button diff --git a/app/controllers/admin/users/roles_controller.rb b/app/controllers/admin/users/roles_controller.rb index f5dfc643d4..e8b58de504 100644 --- a/app/controllers/admin/users/roles_controller.rb +++ b/app/controllers/admin/users/roles_controller.rb @@ -28,7 +28,8 @@ module Admin end def resource_params - params.require(:user).permit(:role_id) + params + .expect(user: [:role_id]) end end end diff --git a/app/controllers/admin/warning_presets_controller.rb b/app/controllers/admin/warning_presets_controller.rb index efbf65b119..dcf88294ee 100644 --- a/app/controllers/admin/warning_presets_controller.rb +++ b/app/controllers/admin/warning_presets_controller.rb @@ -52,7 +52,8 @@ module Admin end def warning_preset_params - params.require(:account_warning_preset).permit(:title, :text) + params + .expect(account_warning_preset: [:title, :text]) end end end diff --git a/app/controllers/admin/webhooks_controller.rb b/app/controllers/admin/webhooks_controller.rb index f1aad7c4b5..31db369637 100644 --- a/app/controllers/admin/webhooks_controller.rb +++ b/app/controllers/admin/webhooks_controller.rb @@ -74,7 +74,8 @@ module Admin end def resource_params - params.require(:webhook).permit(:url, :template, events: []) + params + .expect(webhook: [:url, :template, events: []]) end end end diff --git a/app/controllers/antennas_controller.rb b/app/controllers/antennas_controller.rb deleted file mode 100644 index ca7ee5d2a2..0000000000 --- a/app/controllers/antennas_controller.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -class AntennasController < ApplicationController - layout 'admin' - - before_action :authenticate_user! - before_action :set_antenna, only: [:edit, :update, :destroy] - before_action :set_body_classes - before_action :set_cache_headers - - def index - @antennas = current_account.antennas.includes(:antenna_domains).includes(:antenna_tags).includes(:antenna_accounts) - end - - def edit; end - - def update - if @antenna.update(resource_params) - redirect_to antennas_path - else - render action: :edit - end - end - - def destroy - @antenna.destroy - redirect_to antennas_path - end - - private - - def set_antenna - @antenna = current_account.antennas.find(params[:id]) - end - - def resource_params - params.require(:antenna).permit(:title, :available, :expires_in) - end - - def thin_resource_params - params.require(:antenna).permit(:title) - end - - def set_body_classes - @body_classes = 'admin' - end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end -end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 20e1c8223b..06a113511c 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -8,6 +8,8 @@ class Api::BaseController < ApplicationController include Api::AccessTokenTrackingConcern include Api::CachingConcern include Api::ContentSecurityPolicy + include Api::ErrorHandling + include Api::Pagination skip_before_action :require_functional!, unless: :limited_federation_mode? @@ -18,51 +20,6 @@ class Api::BaseController < ApplicationController protect_from_forgery with: :null_session - rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| - render json: { error: e.to_s }, status: 422 - end - - rescue_from ActiveRecord::RecordNotUnique do - render json: { error: 'Duplicate record' }, status: 422 - end - - rescue_from Date::Error do - render json: { error: 'Invalid date supplied' }, status: 422 - end - - rescue_from ActiveRecord::RecordNotFound do - render json: { error: 'Record not found' }, status: 404 - end - - rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do - render json: { error: 'Remote data could not be fetched' }, status: 503 - end - - rescue_from OpenSSL::SSL::SSLError do - render json: { error: 'Remote SSL certificate could not be verified' }, status: 503 - end - - rescue_from Mastodon::NotPermittedError do - render json: { error: 'This action is not allowed' }, status: 403 - end - - rescue_from Seahorse::Client::NetworkingError do |e| - Rails.logger.warn "Storage server error: #{e}" - render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 - end - - rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do - render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 - end - - rescue_from Mastodon::RateLimitExceededError do - render json: { error: I18n.t('errors.429') }, status: 429 - end - - rescue_from ActionController::ParameterMissing, Mastodon::InvalidParameterError do |e| - render json: { error: e.to_s }, status: 400 - end - def doorkeeper_unauthorized_render_options(error: nil) { json: { error: error.try(:description) || 'Not authorized' } } end @@ -73,17 +30,10 @@ class Api::BaseController < ApplicationController protected - def set_pagination_headers(next_path = nil, prev_path = nil) - 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? - end - - 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) @@ -108,10 +58,6 @@ class Api::BaseController < ApplicationController render json: { error: 'Your login is currently disabled' }, status: 403 if current_user&.account&.unavailable? end - def require_valid_pagination_options! - render json: { error: 'Pagination values for `offset` and `limit` must be positive' }, status: 400 if pagination_options_invalid? - end - def require_user! if !current_user render json: { error: 'This method requires an authenticated user' }, status: 422 @@ -140,10 +86,6 @@ class Api::BaseController < ApplicationController private - def pagination_options_invalid? - params.slice(:limit, :offset).values.map(&:to_i).any?(&:negative?) - end - def respond_with_error(code) render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code end diff --git a/app/controllers/api/fasp/base_controller.rb b/app/controllers/api/fasp/base_controller.rb new file mode 100644 index 0000000000..690f7e419a --- /dev/null +++ b/app/controllers/api/fasp/base_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class Api::Fasp::BaseController < ApplicationController + class Error < ::StandardError; end + + DIGEST_PATTERN = /sha-256=:(.*?):/ + KEYID_PATTERN = /keyid="(.*?)"/ + + attr_reader :current_provider + + skip_forgery_protection + + before_action :check_fasp_enabled + before_action :require_authentication + after_action :sign_response + + private + + def require_authentication + validate_content_digest! + validate_signature! + rescue Error, Linzer::Error, ActiveRecord::RecordNotFound => e + logger.debug("FASP Authentication error: #{e}") + authentication_error + end + + def authentication_error + respond_to do |format| + format.json { head 401 } + end + end + + def validate_content_digest! + content_digest_header = request.headers['content-digest'] + raise Error, 'content-digest missing' if content_digest_header.blank? + + digest_received = content_digest_header.match(DIGEST_PATTERN)[1] + + digest_computed = OpenSSL::Digest.base64digest('sha256', request.body&.string || '') + + raise Error, 'content-digest does not match' if digest_received != digest_computed + end + + def validate_signature! + signature_input = request.headers['signature-input']&.encode('UTF-8') + raise Error, 'signature-input is missing' if signature_input.blank? + + keyid = signature_input.match(KEYID_PATTERN)[1] + provider = Fasp::Provider.find(keyid) + linzer_request = Linzer.new_request( + request.method, + request.original_url, + {}, + { + 'content-digest' => request.headers['content-digest'], + 'signature-input' => signature_input, + 'signature' => request.headers['signature'], + } + ) + message = Linzer::Message.new(linzer_request) + key = Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid) + signature = Linzer::Signature.build(message.headers) + Linzer.verify(key, message, signature) + @current_provider = provider + end + + def sign_response + response.headers['content-digest'] = "sha-256=:#{OpenSSL::Digest.base64digest('sha256', response.body || '')}:" + + linzer_response = Linzer.new_response(response.body, response.status, { 'content-digest' => response.headers['content-digest'] }) + message = Linzer::Message.new(linzer_response) + key = Linzer.new_ed25519_key(current_provider.server_private_key_pem) + signature = Linzer.sign(key, message, %w(@status content-digest)) + + response.headers.merge!(signature.to_h) + end + + def check_fasp_enabled + raise ActionController::RoutingError unless Mastodon::Feature.fasp_enabled? + end +end diff --git a/app/controllers/api/fasp/debug/v0/callback/responses_controller.rb b/app/controllers/api/fasp/debug/v0/callback/responses_controller.rb new file mode 100644 index 0000000000..794e53f095 --- /dev/null +++ b/app/controllers/api/fasp/debug/v0/callback/responses_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Api::Fasp::Debug::V0::Callback::ResponsesController < Api::Fasp::BaseController + def create + Fasp::DebugCallback.create( + fasp_provider: current_provider, + ip: request.remote_ip, + request_body: request.raw_post + ) + + respond_to do |format| + format.json { head 201 } + end + end +end diff --git a/app/controllers/api/fasp/registrations_controller.rb b/app/controllers/api/fasp/registrations_controller.rb new file mode 100644 index 0000000000..fecc992fec --- /dev/null +++ b/app/controllers/api/fasp/registrations_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Api::Fasp::RegistrationsController < Api::Fasp::BaseController + skip_before_action :require_authentication + + def create + @current_provider = Fasp::Provider.create!( + name: params[:name], + base_url: params[:baseUrl], + remote_identifier: params[:serverId], + provider_public_key_base64: params[:publicKey] + ) + + render json: registration_confirmation + end + + private + + def registration_confirmation + { + faspId: current_provider.id.to_s, + publicKey: current_provider.server_public_key_base64, + registrationCompletionUri: new_admin_fasp_provider_registration_url(current_provider), + } + end +end diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index 66da65beda..b7f22824a7 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/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 7dacad9f9c..bdd7732b87 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::Accounts::CredentialsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, except: [:update] + before_action -> { doorkeeper_authorize! :profile, :read, :'read:accounts' }, except: [:update] before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:update] before_action :require_user! @@ -14,7 +14,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController @account = current_account UpdateAccountService.new.call(@account, account_params, raise_error: true) current_user.update(user_params) if user_params - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer rescue ActiveRecord::RecordInvalid => e render json: ValidationErrorFormatter.new(e).as_json, status: 422 @@ -32,9 +32,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController :bot, :discoverable, :searchability, - :dissubscribable, :hide_collections, :indexable, + attribution_domains: [], fields_attributes: [:name, :value] ) end diff --git a/app/controllers/api/v1/accounts/endorsements_controller.rb b/app/controllers/api/v1/accounts/endorsements_controller.rb new file mode 100644 index 0000000000..1e21994a90 --- /dev/null +++ b/app/controllers/api/v1/accounts/endorsements_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::EndorsementsController < Api::BaseController + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:accounts' }, only: :index + before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index + before_action :require_user!, except: :index + before_action :set_account + before_action :set_endorsed_accounts, only: :index + after_action :insert_pagination_headers, only: :index + + def index + cache_if_unauthenticated! + render json: @endorsed_accounts, each_serializer: REST::AccountSerializer + end + + def create + AccountPin.find_or_create_by!(account: current_account, target_account: @account) + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter + end + + def destroy + pin = AccountPin.find_by(account: current_account, target_account: @account) + pin&.destroy! + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def set_endorsed_accounts + @endorsed_accounts = @account.unavailable? ? [] : paginated_endorsed_accounts + end + + def paginated_endorsed_accounts + @account.endorsed_accounts.without_suspended.includes(:account_stat, :user).paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def relationships_presenter + AccountRelationshipsPresenter.new([@account], current_user.account_id) + end + + def next_path + api_v1_account_endorsements_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_account_endorsements_url pagination_params(since_id: pagination_since_id) unless @endorsed_accounts.empty? + end + + def pagination_collection + @endorsed_accounts + end + + def records_continue? + @endorsed_accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end +end diff --git a/app/controllers/api/v1/accounts/exclude_antennas_controller.rb b/app/controllers/api/v1/accounts/exclude_antennas_controller.rb index c1f5c5981c..65d75dbc6e 100644 --- a/app/controllers/api/v1/accounts/exclude_antennas_controller.rb +++ b/app/controllers/api/v1/accounts/exclude_antennas_controller.rb @@ -6,7 +6,7 @@ class Api::V1::Accounts::ExcludeAntennasController < Api::BaseController before_action :set_account def index - @antennas = @account.suspended? ? [] : current_account.antennas.where('exclude_accounts @> \'[?]\'', @account.id) + @antennas = @account.suspended? ? [] : current_account.antennas.where("exclude_accounts @> '#{@account.id}'") render json: @antennas, each_serializer: REST::AntennaSerializer end diff --git a/app/controllers/api/v1/accounts/familiar_followers_controller.rb b/app/controllers/api/v1/accounts/familiar_followers_controller.rb index a49eb2eb27..81f0a9ed0f 100644 --- a/app/controllers/api/v1/accounts/familiar_followers_controller.rb +++ b/app/controllers/api/v1/accounts/familiar_followers_controller.rb @@ -12,7 +12,7 @@ class Api::V1::Accounts::FamiliarFollowersController < Api::BaseController private def set_accounts - @accounts = Account.without_suspended.where(id: account_ids).select('id, hide_collections') + @accounts = Account.without_suspended.where(id: account_ids).select(:id, :hide_collections) end def familiar_followers diff --git a/app/controllers/api/v1/accounts/featured_tags_controller.rb b/app/controllers/api/v1/accounts/featured_tags_controller.rb index 0101fb469b..f95846366c 100644 --- a/app/controllers/api/v1/accounts/featured_tags_controller.rb +++ b/app/controllers/api/v1/accounts/featured_tags_controller.rb @@ -17,6 +17,6 @@ class Api::V1::Accounts::FeaturedTagsController < Api::BaseController end def set_featured_tags - @featured_tags = @account.suspended? ? [] : @account.featured_tags + @featured_tags = @account.unavailable? ? [] : @account.featured_tags end end diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index 21b1095f18..3f2ecb892d 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -21,7 +21,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController return [] if hide_results? scope = default_accounts - scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? || current_account.id == @account.id + scope = scope.not_excluded_by_account(current_account) unless current_account.nil? || current_account.id == @account.id scope.merge(paginated_follows).to_a end @@ -30,7 +30,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController end def default_accounts - Account.includes(:active_relationships, :account_stat).references(:active_relationships) + Account.includes(:active_relationships, :account_stat, :user).references(:active_relationships) end def paginated_follows @@ -41,10 +41,6 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_account_followers_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -64,8 +60,4 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController def records_continue? @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 1db521f79c..7c16a3487e 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -21,7 +21,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController return [] if hide_results? scope = default_accounts - scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? || current_account.id == @account.id + scope = scope.not_excluded_by_account(current_account) unless current_account.nil? || current_account.id == @account.id scope.merge(paginated_follows).to_a end @@ -30,7 +30,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController end def default_accounts - Account.includes(:passive_relationships, :account_stat).references(:passive_relationships) + Account.includes(:passive_relationships, :account_stat, :user).references(:passive_relationships) end def paginated_follows @@ -41,10 +41,6 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_account_following_index_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -64,8 +60,4 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController def records_continue? @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb index 48f293f47a..02a45e8758 100644 --- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb +++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Api::V1::Accounts::IdentityProofsController < Api::BaseController + include DeprecationConcern + + deprecate_api '2022-03-30' + before_action :require_user! before_action :set_account diff --git a/app/controllers/api/v1/accounts/pins_controller.rb b/app/controllers/api/v1/accounts/pins_controller.rb deleted file mode 100644 index 0eb13c048c..0000000000 --- a/app/controllers/api/v1/accounts/pins_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Accounts::PinsController < Api::BaseController - include Authorization - - before_action -> { doorkeeper_authorize! :write, :'write:accounts' } - before_action :require_user! - before_action :set_account - - def create - AccountPin.find_or_create_by!(account: current_account, target_account: @account) - render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter - end - - def destroy - pin = AccountPin.find_by(account: current_account, target_account: @account) - pin&.destroy! - render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter - end - - private - - def set_account - @account = Account.find(params[:account_id]) - end - - def relationships_presenter - AccountRelationshipsPresenter.new([@account], current_user.account_id) - end -end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index be26332937..6213f6f9e1 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -4,7 +4,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :set_account - after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) } + after_action :insert_pagination_headers def index cache_if_unauthenticated! @@ -21,11 +21,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def load_statuses - @account.unavailable? ? [] : cached_account_statuses + @account.unavailable? ? [] : preloaded_account_statuses end - def cached_account_statuses - cache_collection_paginated_by_id( + def preloaded_account_statuses + preload_collection_paginated_by_id( AccountStatusesFilter.new(@account, current_account, params).results, Status, limit_param(DEFAULT_STATUSES_LIMIT), @@ -37,10 +37,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController params.slice(:limit, *AccountStatusesFilter::KEYS).permit(:limit, *AccountStatusesFilter::KEYS).merge(core_params) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_account_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -53,11 +49,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) end - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + def pagination_collection + @statuses end end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 1886919d92..46838aeb66 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -9,16 +9,23 @@ class Api::V1::AccountsController < Api::BaseController before_action -> { doorkeeper_authorize! :follow, :write, :'write:blocks' }, only: [:block, :unblock] before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:create] - before_action :require_user!, except: [:show, :create] - before_action :set_account, except: [:create] - before_action :check_account_approval, except: [:create] - before_action :check_account_confirmation, except: [:create] + before_action :require_user!, except: [:index, :show, :create] + before_action :set_account, except: [:index, :create] + before_action :set_accounts, only: [:index] + before_action :check_account_approval, except: [:index, :create] + before_action :check_account_confirmation, except: [:index, :create] before_action :check_enabled_registrations, only: [:create] + before_action :check_accounts_limit, only: [:index] + before_action :check_following_self, only: [:follow] skip_before_action :require_authenticated_user!, only: :create override_rate_limit_headers :follow, family: :follows + def index + render json: @accounts, each_serializer: REST::AccountSerializer + end + def show cache_if_unauthenticated! render json: @account, serializer: REST::AccountSerializer @@ -38,7 +45,12 @@ class Api::V1::AccountsController < Api::BaseController def follow follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, languages: params.key?(:languages) ? params[:languages] : nil, with_rate_limit: true) - options = @account.locked? || current_user.account.silenced? || (current_user.account.bot? && @account.user&.setting_lock_follow_from_bot) ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify?, languages: follow.languages } }, requested_map: { @account.id => false } } + options = if @account.locked? || current_user.account.silenced? || (current_user.account.bot? && @account.user&.setting_lock_follow_from_bot) + {} + else + { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify?, languages: follow.languages } }, + requested_map: { @account.id => false } } + end render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(**options) end @@ -79,6 +91,10 @@ class Api::V1::AccountsController < Api::BaseController @account = Account.find(params[:id]) end + def set_accounts + @accounts = Account.where(id: account_ids).without_unapproved + end + def check_account_approval raise(ActiveRecord::RecordNotFound) if @account.local? && @account.user_pending? end @@ -87,12 +103,28 @@ class Api::V1::AccountsController < Api::BaseController raise(ActiveRecord::RecordNotFound) if @account.local? && !@account.user_confirmed? end - def relationships(**options) - AccountRelationshipsPresenter.new([@account], current_user.account_id, **options) + def check_accounts_limit + raise(Mastodon::ValidationError) if account_ids.size > DEFAULT_ACCOUNTS_LIMIT + end + + def check_following_self + render json: { error: I18n.t('accounts.self_follow_error') }, status: 403 if current_user.account.id == @account.id + end + + def relationships(**) + AccountRelationshipsPresenter.new([@account], current_user.account_id, **) + end + + def account_ids + Array(accounts_params[:id]).uniq.map(&:to_i) + end + + def accounts_params + params.permit(id: []) end def account_params - params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code) + params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code, :date_of_birth) end def invite diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index ff9cae6398..ff6f41e01d 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -125,10 +125,6 @@ class Api::V1::Admin::AccountsController < Api::BaseController translated_params end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -137,12 +133,8 @@ class Api::V1::Admin::AccountsController < Api::BaseController api_v1_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty? end - def pagination_max_id - @accounts.last.id - end - - def pagination_since_id - @accounts.first.id + def pagination_collection + @accounts end def records_continue? diff --git a/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb b/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb index 7b192b979f..c144a9e0f9 100644 --- a/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb +++ b/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb @@ -16,8 +16,6 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController after_action :verify_authorized after_action :insert_pagination_headers, only: :index - PAGINATION_PARAMS = %i(limit).freeze - def index authorize :canonical_email_block, :index? render json: @canonical_email_blocks, each_serializer: REST::Admin::CanonicalEmailBlockSerializer @@ -65,10 +63,6 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController @canonical_email_block = CanonicalEmailBlock.find(params[:id]) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_canonical_email_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -77,19 +71,11 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController api_v1_admin_canonical_email_blocks_url(pagination_params(min_id: pagination_since_id)) unless @canonical_email_blocks.empty? end - def pagination_max_id - @canonical_email_blocks.last.id - end - - def pagination_since_id - @canonical_email_blocks.first.id + def pagination_collection + @canonical_email_blocks end def records_continue? @canonical_email_blocks.size == limit_param(LIMIT) end - - def pagination_params(core_params) - params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) - 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 dd54d67106..24f68aa1bd 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] @@ -14,8 +15,6 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController after_action :verify_authorized after_action :insert_pagination_headers, only: :index - PAGINATION_PARAMS = %i(limit).freeze - def index authorize :domain_allow, :index? render json: @domain_allows, each_serializer: REST::Admin::DomainAllowSerializer @@ -49,22 +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 insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -73,20 +63,12 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController api_v1_admin_domain_allows_url(pagination_params(min_id: pagination_since_id)) unless @domain_allows.empty? end - def pagination_max_id - @domain_allows.last.id - end - - def pagination_since_id - @domain_allows.first.id + def pagination_collection + @domain_allows end def records_continue? - @domain_allows.size == limit_param(LIMIT) - end - - def pagination_params(core_params) - params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) + @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 c79a191407..d84c4a8695 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] @@ -14,8 +15,6 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController after_action :verify_authorized after_action :insert_pagination_headers, only: :index - PAGINATION_PARAMS = %i(limit).freeze - def index authorize :domain_block, :index? render json: @domain_blocks, each_serializer: REST::Admin::DomainBlockSerializer @@ -29,10 +28,11 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController def create authorize :domain_block, :create? + @domain_block = DomainBlock.new(resource_params) existing_domain_block = resource_params[:domain].present? ? DomainBlock.rule_for(resource_params[:domain]) : nil - return render json: existing_domain_block, serializer: REST::Admin::ExistingDomainBlockErrorSerializer, status: 422 if existing_domain_block.present? + return render json: existing_domain_block, serializer: REST::Admin::ExistingDomainBlockErrorSerializer, status: 422 if conflicts_with_existing_block?(@domain_block, existing_domain_block) - @domain_block = DomainBlock.create!(resource_params) + @domain_block.save! DomainBlockWorker.perform_async(@domain_block.id) log_action :create, @domain_block render json: @domain_block, serializer: REST::Admin::DomainBlockSerializer @@ -55,26 +55,21 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController private + def conflicts_with_existing_block?(domain_block, existing_domain_block) + existing_domain_block.present? && (existing_domain_block.domain == TagManager.instance.normalize_domain(domain_block.domain) || !domain_block.stricter_than?(existing_domain_block)) + 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_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_reports, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, - :reject_new_follow, :reject_friend, :detect_invalid_subscription, :private_comment, :public_comment, :obfuscate, :hidden) - end - - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) + params.permit(:severity, :reject_media, :reject_favourite, :reject_reply_exclude_followers, :reject_reports, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, + :reject_new_follow, :reject_friend, :block_trends, :detect_invalid_subscription, :private_comment, :public_comment, :obfuscate, :hidden) end def next_path @@ -85,24 +80,16 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController api_v1_admin_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @domain_blocks.empty? end - def pagination_max_id - @domain_blocks.last.id - end - - def pagination_since_id - @domain_blocks.first.id + def pagination_collection + @domain_blocks end def records_continue? - @domain_blocks.size == limit_param(LIMIT) - end - - def pagination_params(core_params) - params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) + @domain_blocks.size == limit_param(LIMIT, MAX_LIMIT) end def resource_params - params.permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, - :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden) + params.permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, + :reject_new_follow, :reject_friend, :block_trends, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden) end end diff --git a/app/controllers/api/v1/admin/email_domain_blocks_controller.rb b/app/controllers/api/v1/admin/email_domain_blocks_controller.rb index df54b9f0a4..e7bd804e36 100644 --- a/app/controllers/api/v1/admin/email_domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/email_domain_blocks_controller.rb @@ -14,10 +14,6 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController after_action :verify_authorized after_action :insert_pagination_headers, only: :index - PAGINATION_PARAMS = %i( - limit - ).freeze - def index authorize :email_domain_block, :index? render json: @email_domain_blocks, each_serializer: REST::Admin::EmailDomainBlockSerializer @@ -58,10 +54,6 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController params.permit(:domain, :allow_with_approval) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_email_domain_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -70,19 +62,11 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController api_v1_admin_email_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @email_domain_blocks.empty? end - def pagination_max_id - @email_domain_blocks.last.id - end - - def pagination_since_id - @email_domain_blocks.first.id + def pagination_collection + @email_domain_blocks end def records_continue? @email_domain_blocks.size == limit_param(LIMIT) end - - def pagination_params(core_params) - params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) - end end diff --git a/app/controllers/api/v1/admin/ip_blocks_controller.rb b/app/controllers/api/v1/admin/ip_blocks_controller.rb index 61c1912344..e132a3a87d 100644 --- a/app/controllers/api/v1/admin/ip_blocks_controller.rb +++ b/app/controllers/api/v1/admin/ip_blocks_controller.rb @@ -14,10 +14,6 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController after_action :verify_authorized after_action :insert_pagination_headers, only: :index - PAGINATION_PARAMS = %i( - limit - ).freeze - def index authorize :ip_block, :index? render json: @ip_blocks, each_serializer: REST::Admin::IpBlockSerializer @@ -63,10 +59,6 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController params.permit(:ip, :severity, :comment, :expires_in) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_ip_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -75,19 +67,11 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController api_v1_admin_ip_blocks_url(pagination_params(min_id: pagination_since_id)) unless @ip_blocks.empty? end - def pagination_max_id - @ip_blocks.last.id - end - - def pagination_since_id - @ip_blocks.first.id + def pagination_collection + @ip_blocks end def records_continue? @ip_blocks.size == limit_param(LIMIT) end - - def pagination_params(core_params) - params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) - end end diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb index 9dfb181a28..9b5beeab67 100644 --- a/app/controllers/api/v1/admin/reports_controller.rb +++ b/app/controllers/api/v1/admin/reports_controller.rb @@ -35,6 +35,7 @@ class Api::V1::Admin::ReportsController < Api::BaseController def update authorize @report, :update? @report.update!(report_params) + log_action :update, @report render json: @report, serializer: REST::Admin::ReportSerializer end @@ -88,10 +89,6 @@ class Api::V1::Admin::ReportsController < Api::BaseController params.permit(*FILTER_PARAMS) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_reports_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -100,12 +97,8 @@ class Api::V1::Admin::ReportsController < Api::BaseController api_v1_admin_reports_url(pagination_params(min_id: pagination_since_id)) unless @reports.empty? end - def pagination_max_id - @reports.last.id - end - - def pagination_since_id - @reports.first.id + def pagination_collection + @reports end def records_continue? diff --git a/app/controllers/api/v1/admin/tags_controller.rb b/app/controllers/api/v1/admin/tags_controller.rb index 6a7c9f5bf3..283383acb4 100644 --- a/app/controllers/api/v1/admin/tags_controller.rb +++ b/app/controllers/api/v1/admin/tags_controller.rb @@ -12,7 +12,13 @@ class Api::V1::Admin::TagsController < Api::BaseController after_action :verify_authorized LIMIT = 100 - PAGINATION_PARAMS = %i(limit).freeze + + PERMITTED_PARAMS = %i( + display_name + listable + trendable + usable + ).freeze def index authorize :tag, :index? @@ -41,11 +47,9 @@ class Api::V1::Admin::TagsController < Api::BaseController end def tag_params - params.permit(:display_name, :trendable, :usable, :listable) - end - - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) + params + .slice(*PERMITTED_PARAMS) + .permit(*PERMITTED_PARAMS) end def next_path @@ -56,19 +60,11 @@ class Api::V1::Admin::TagsController < Api::BaseController api_v1_admin_tags_url(pagination_params(min_id: pagination_since_id)) unless @tags.empty? end - def pagination_max_id - @tags.last.id - end - - def pagination_since_id - @tags.first.id + def pagination_collection + @tags end def records_continue? @tags.size == limit_param(LIMIT) end - - def pagination_params(core_params) - params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) - end end diff --git a/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb index 5d9fcc82c0..2b0f39b98f 100644 --- a/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb +++ b/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb @@ -12,8 +12,6 @@ class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseC after_action :verify_authorized after_action :insert_pagination_headers, only: :index - PAGINATION_PARAMS = %i(limit).freeze - def index authorize :preview_card_provider, :index? @@ -42,10 +40,6 @@ class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseC @providers = PreviewCardProvider.all.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_trends_links_preview_card_providers_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -54,19 +48,11 @@ class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseC api_v1_admin_trends_links_preview_card_providers_url(pagination_params(min_id: pagination_since_id)) unless @providers.empty? end - def pagination_max_id - @providers.last.id - end - - def pagination_since_id - @providers.first.id + def pagination_collection + @providers end def records_continue? @providers.size == limit_param(LIMIT) end - - def pagination_params(core_params) - params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) - end end diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb new file mode 100644 index 0000000000..b1aee288dd --- /dev/null +++ b/app/controllers/api/v1/annual_reports_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Api::V1::AnnualReportsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index + before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index + before_action :require_user! + before_action :set_annual_report, except: :index + + def index + with_read_replica do + @presenter = AnnualReportsPresenter.new(GeneratedAnnualReport.where(account_id: current_account.id).pending) + @relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id) + end + + render json: @presenter, + serializer: REST::AnnualReportsSerializer, + relationships: @relationships + end + + def show + with_read_replica do + @presenter = AnnualReportsPresenter.new([@annual_report]) + @relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id) + end + + render json: @presenter, + serializer: REST::AnnualReportsSerializer, + relationships: @relationships + end + + def read + @annual_report.view! + render_empty + end + + private + + def set_annual_report + @annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id]) + end +end diff --git a/app/controllers/api/v1/antennas_controller.rb b/app/controllers/api/v1/antennas_controller.rb index 37bfb7f552..4040263c00 100644 --- a/app/controllers/api/v1/antennas_controller.rb +++ b/app/controllers/api/v1/antennas_controller.rb @@ -21,7 +21,7 @@ class Api::V1::AntennasController < Api::BaseController end def create - @antenna = Antenna.create!(antenna_params.merge(account: current_account, list_id: 0)) + @antenna = Antenna.create!(antenna_params.merge(account: current_account)) render json: @antenna, serializer: REST::AntennaSerializer end @@ -42,6 +42,6 @@ class Api::V1::AntennasController < Api::BaseController end def antenna_params - params.permit(:title, :list_id, :insert_feeds, :stl, :ltl, :with_media_only, :ignore_reblog) + params.permit(:title, :list_id, :insert_feeds, :stl, :ltl, :with_media_only, :ignore_reblog, :favourite) end end diff --git a/app/controllers/api/v1/apps/credentials_controller.rb b/app/controllers/api/v1/apps/credentials_controller.rb index 6256bed64c..29ab920383 100644 --- a/app/controllers/api/v1/apps/credentials_controller.rb +++ b/app/controllers/api/v1/apps/credentials_controller.rb @@ -4,6 +4,6 @@ class Api::V1::Apps::CredentialsController < Api::BaseController def show return doorkeeper_render_error unless valid_doorkeeper_token? - render json: doorkeeper_token.application, serializer: REST::ApplicationSerializer, fields: %i(name website vapid_key client_id scopes) + render json: doorkeeper_token.application, serializer: REST::ApplicationSerializer end end diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index 97177547a2..50feaf1854 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -5,7 +5,7 @@ class Api::V1::AppsController < Api::BaseController def create @app = Doorkeeper::Application.create!(application_options) - render json: @app, serializer: REST::ApplicationSerializer + render json: @app, serializer: REST::CredentialApplicationSerializer end private @@ -24,6 +24,6 @@ class Api::V1::AppsController < Api::BaseController end def app_params - params.permit(:client_name, :redirect_uris, :scopes, :website) + params.permit(:client_name, :scopes, :website, :redirect_uris, redirect_uris: []) end end diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index 06a8bfa891..d7516c927b 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -17,7 +17,7 @@ class Api::V1::BlocksController < Api::BaseController end def paginated_blocks - @paginated_blocks ||= Block.eager_load(target_account: :account_stat) + @paginated_blocks ||= Block.eager_load(target_account: [:account_stat, :user]) .joins(:target_account) .merge(Account.without_suspended) .where(account: current_account) @@ -28,10 +28,6 @@ class Api::V1::BlocksController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_blocks_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -40,19 +36,11 @@ class Api::V1::BlocksController < Api::BaseController api_v1_blocks_url pagination_params(since_id: pagination_since_id) unless paginated_blocks.empty? end - def pagination_max_id - paginated_blocks.last.id - end - - def pagination_since_id - paginated_blocks.first.id + def pagination_collection + paginated_blocks end def records_continue? paginated_blocks.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb index 0985366964..009367d078 100644 --- a/app/controllers/api/v1/bookmarks_controller.rb +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -15,11 +15,11 @@ class Api::V1::BookmarksController < Api::BaseController private def load_statuses - cached_bookmarks + preloaded_bookmarks end - def cached_bookmarks - cache_collection(results.map(&:status), Status) + def preloaded_bookmarks + preload_collection(results.map(&:status), Status) end def results @@ -33,10 +33,6 @@ class Api::V1::BookmarksController < Api::BaseController current_account.bookmarks end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -45,19 +41,11 @@ class Api::V1::BookmarksController < Api::BaseController api_v1_bookmarks_url pagination_params(min_id: pagination_since_id) unless results.empty? end - def pagination_max_id - results.last.id - end - - def pagination_since_id - results.first.id + def pagination_collection + results end def records_continue? results.size == limit_param(DEFAULT_STATUSES_LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb index 6a3567e624..60db082a8e 100644 --- a/app/controllers/api/v1/conversations_controller.rb +++ b/app/controllers/api/v1/conversations_controller.rb @@ -38,25 +38,21 @@ class Api::V1::ConversationsController < Api::BaseController def paginated_conversations AccountConversation.where(account: current_account) .includes( - account: :account_stat, + account: [:account_stat, user: :role], last_status: [ :media_attachments, :status_stat, :tags, { - preview_cards_status: :preview_card, - active_mentions: [account: :account_stat], - account: :account_stat, + preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } }, + active_mentions: :account, + account: [:account_stat, user: :role], }, ] ) .to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_conversations_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -76,8 +72,4 @@ class Api::V1::ConversationsController < Api::BaseController def records_continue? @conversations.size == limit_param(LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end 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 aa9df6e03b..0000000000 --- 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 68cf4384f7..0000000000 --- a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb +++ /dev/null @@ -1,59 +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 insert_pagination_headers - set_pagination_headers(next_path, prev_path) - 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_max_id - @encrypted_messages.last.id - end - - def pagination_since_id - @encrypted_messages.first.id - end - - def records_continue? - @encrypted_messages.size == limit_param(LIMIT) - end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - 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 f9d202d67b..0000000000 --- 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 ffd7151b78..0000000000 --- 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 e6ce9f9192..0000000000 --- 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 fc4abf63b3..0000000000 --- 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/directories_controller.rb b/app/controllers/api/v1/directories_controller.rb index e79b20ce42..6c540404ea 100644 --- a/app/controllers/api/v1/directories_controller.rb +++ b/app/controllers/api/v1/directories_controller.rb @@ -27,7 +27,7 @@ class Api::V1::DirectoriesController < Api::BaseController scope.merge!(local_account_scope) if local_accounts? scope.merge!(account_exclusion_scope) if current_account scope.merge!(account_domain_block_scope) if current_account && !local_accounts? - end + end.includes(:account_stat, user: :role) end def local_accounts? 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 0000000000..a917bddd98 --- /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/domain_blocks_controller.rb b/app/controllers/api/v1/domain_blocks_controller.rb index 34def3c44a..780ecbf189 100644 --- a/app/controllers/api/v1/domain_blocks_controller.rb +++ b/app/controllers/api/v1/domain_blocks_controller.rb @@ -38,10 +38,6 @@ class Api::V1::DomainBlocksController < Api::BaseController current_account.domain_blocks end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_domain_blocks_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -50,22 +46,14 @@ class Api::V1::DomainBlocksController < Api::BaseController api_v1_domain_blocks_url pagination_params(since_id: pagination_since_id) unless @blocks.empty? end - def pagination_max_id - @blocks.last.id - end - - def pagination_since_id - @blocks.first.id + def pagination_collection + @blocks end def records_continue? @blocks.size == limit_param(BLOCK_LIMIT) end - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end - def domain_block_params params.permit(:domain) end diff --git a/app/controllers/api/v1/emoji_reactions_controller.rb b/app/controllers/api/v1/emoji_reactions_controller.rb index 43b39921db..5e913eef2a 100644 --- a/app/controllers/api/v1/emoji_reactions_controller.rb +++ b/app/controllers/api/v1/emoji_reactions_controller.rb @@ -19,7 +19,7 @@ class Api::V1::EmojiReactionsController < Api::BaseController end def cached_emoji_reactions - cache_collection(results.map(&:status), EmojiReaction) + preload_collection(results.map(&:status), EmojiReaction) end def results diff --git a/app/controllers/api/v1/endorsements_controller.rb b/app/controllers/api/v1/endorsements_controller.rb index 46e3fcd647..09bafe0231 100644 --- a/app/controllers/api/v1/endorsements_controller.rb +++ b/app/controllers/api/v1/endorsements_controller.rb @@ -25,11 +25,7 @@ class Api::V1::EndorsementsController < Api::BaseController end def endorsed_accounts - current_account.endorsed_accounts.includes(:account_stat).without_suspended - end - - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) + current_account.endorsed_accounts.includes(:account_stat, :user).without_suspended end def next_path @@ -44,22 +40,14 @@ class Api::V1::EndorsementsController < Api::BaseController api_v1_endorsements_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? end - def pagination_max_id - @accounts.last.id - end - - def pagination_since_id - @accounts.first.id + def pagination_collection + @accounts end def records_continue? @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end - def unlimited? params[:limit] == '0' end diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index 984f3d3051..9b7d663bc1 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -15,11 +15,11 @@ class Api::V1::FavouritesController < Api::BaseController private def load_statuses - cached_favourites + preloaded_favourites end - def cached_favourites - cache_collection(results.map(&:status), Status) + def preloaded_favourites + preload_collection(results.map(&:status), Status) end def results @@ -33,10 +33,6 @@ class Api::V1::FavouritesController < Api::BaseController current_account.favourites end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_favourites_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -45,19 +41,11 @@ class Api::V1::FavouritesController < Api::BaseController api_v1_favourites_url pagination_params(min_id: pagination_since_id) unless results.empty? end - def pagination_max_id - results.last.id - end - - def pagination_since_id - results.first.id + def pagination_collection + results end def records_continue? results.size == limit_param(DEFAULT_STATUSES_LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb index 76633210a1..d533b1af7b 100644 --- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb +++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb @@ -5,6 +5,8 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController before_action :require_user! before_action :set_recently_used_tags, only: :index + RECENT_TAGS_LIMIT = 10 + def index render json: @recently_used_tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@recently_used_tags, current_user&.account_id) end @@ -12,6 +14,6 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController private def set_recently_used_tags - @recently_used_tags = Tag.recently_used(current_account).where.not(id: current_account.featured_tags).limit(10) + @recently_used_tags = Tag.suggestions_for_account(current_account).limit(RECENT_TAGS_LIMIT) end end diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index 4345b61ac7..f8d91c5f7f 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Api::V1::FiltersController < Api::BaseController + include DeprecationConcern + + deprecate_api '2022-11-14' + before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show] before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show] before_action :require_user! @@ -52,11 +56,11 @@ class Api::V1::FiltersController < Api::BaseController end def resource_params - params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :whole_word, context: []) + params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :with_profile, :whole_word, context: []) end def filter_params - resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :context) + resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :with_profile, :context) end def keyword_params diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index ee717ebbcc..4b44cfe531 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -28,8 +28,8 @@ class Api::V1::FollowRequestsController < Api::BaseController @account ||= Account.find(params[:id]) end - def relationships(**options) - AccountRelationshipsPresenter.new([account], current_user.account_id, **options) + def relationships(**) + AccountRelationshipsPresenter.new([account], current_user.account_id, **) end def load_accounts @@ -37,7 +37,7 @@ class Api::V1::FollowRequestsController < Api::BaseController end def default_accounts - Account.without_suspended.includes(:follow_requests, :account_stat).references(:follow_requests) + Account.without_suspended.includes(:follow_requests, :account_stat, :user).references(:follow_requests) end def paginated_follow_requests @@ -48,10 +48,6 @@ class Api::V1::FollowRequestsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_follow_requests_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -71,8 +67,4 @@ class Api::V1::FollowRequestsController < Api::BaseController def records_continue? @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end diff --git a/app/controllers/api/v1/followed_tags_controller.rb b/app/controllers/api/v1/followed_tags_controller.rb index eae2bdc010..7d8f0eda1e 100644 --- a/app/controllers/api/v1/followed_tags_controller.rb +++ b/app/controllers/api/v1/followed_tags_controller.rb @@ -22,10 +22,6 @@ class Api::V1::FollowedTagsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_followed_tags_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -34,19 +30,11 @@ class Api::V1::FollowedTagsController < Api::BaseController api_v1_followed_tags_url pagination_params(since_id: pagination_since_id) unless @results.empty? end - def pagination_max_id - @results.last.id - end - - def pagination_since_id - @results.first.id + def pagination_collection + @results end def records_continue? @results.size == limit_param(TAGS_LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end diff --git a/app/controllers/api/v1/instances/domain_blocks_controller.rb b/app/controllers/api/v1/instances/domain_blocks_controller.rb index 7ec94312f4..bf96fbaaa8 100644 --- a/app/controllers/api/v1/instances/domain_blocks_controller.rb +++ b/app/controllers/api/v1/instances/domain_blocks_controller.rb @@ -31,7 +31,7 @@ class Api::V1::Instances::DomainBlocksController < Api::V1::Instances::BaseContr end def show_domain_blocks_to_user? - Setting.show_domain_blocks == 'users' && user_signed_in? + Setting.show_domain_blocks == 'users' && user_signed_in? && current_user.functional_or_moved? end def set_domain_blocks @@ -47,6 +47,6 @@ class Api::V1::Instances::DomainBlocksController < Api::V1::Instances::BaseContr end def show_rationale_for_user? - Setting.show_domain_blocks_rationale == 'users' && user_signed_in? + Setting.show_domain_blocks_rationale == 'users' && user_signed_in? && current_user.functional_or_moved? end end diff --git a/app/controllers/api/v1/instances/extended_descriptions_controller.rb b/app/controllers/api/v1/instances/extended_descriptions_controller.rb index 73d2248117..db3d082f61 100644 --- a/app/controllers/api/v1/instances/extended_descriptions_controller.rb +++ b/app/controllers/api/v1/instances/extended_descriptions_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Instances::ExtendedDescriptionsController < Api::V1::Instances::B before_action :set_extended_description - # Override `current_user` to avoid reading session cookies unless in whitelist mode + # Override `current_user` to avoid reading session cookies unless in limited federation mode def current_user super if limited_federation_mode? end diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index 83116472bb..fac763b405 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Instances::PeersController < Api::V1::Instances::BaseController skip_around_action :set_locale - # Override `current_user` to avoid reading session cookies unless in whitelist mode + # Override `current_user` to avoid reading session cookies unless in limited federation mode def current_user super if limited_federation_mode? end diff --git a/app/controllers/api/v1/instances/rules_controller.rb b/app/controllers/api/v1/instances/rules_controller.rb index d240d72464..3930eec0dd 100644 --- a/app/controllers/api/v1/instances/rules_controller.rb +++ b/app/controllers/api/v1/instances/rules_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Instances::RulesController < Api::V1::Instances::BaseController before_action :set_rules - # Override `current_user` to avoid reading session cookies unless in whitelist mode + # Override `current_user` to avoid reading session cookies unless in limited federation mode def current_user super if limited_federation_mode? end diff --git a/app/controllers/api/v1/instances/terms_of_services_controller.rb b/app/controllers/api/v1/instances/terms_of_services_controller.rb new file mode 100644 index 0000000000..0a861dd7bb --- /dev/null +++ b/app/controllers/api/v1/instances/terms_of_services_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseController + before_action :set_terms_of_service + + def show + cache_even_if_authenticated! + render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer + end + + private + + def set_terms_of_service + @terms_of_service = begin + if params[:date].present? + TermsOfService.published.find_by!(effective_date: params[:date]) + else + TermsOfService.live.first || TermsOfService.published.first! # For the case when none of the published terms have become effective yet + end + end + end +end diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index df4a14af15..e01267c000 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -1,15 +1,9 @@ # frozen_string_literal: true -class Api::V1::InstancesController < Api::BaseController - skip_before_action :require_authenticated_user!, unless: :limited_federation_mode? - skip_around_action :set_locale +class Api::V1::InstancesController < Api::V2::InstancesController + include DeprecationConcern - vary_by '' - - # Override `current_user` to avoid reading session cookies unless in whitelist mode - def current_user - super if limited_federation_mode? - end + deprecate_api '2022-11-14' def show cache_even_if_authenticated! diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb index 8e12cb7b65..616159f05f 100644 --- a/app/controllers/api/v1/lists/accounts_controller.rb +++ b/app/controllers/api/v1/lists/accounts_controller.rb @@ -15,17 +15,12 @@ class Api::V1::Lists::AccountsController < Api::BaseController end def create - ApplicationRecord.transaction do - list_accounts.each do |account| - @list.accounts << account - end - end - + AddAccountsToListService.new.call(@list, Account.find(account_ids)) render_empty end def destroy - ListAccount.where(list: @list, account_id: account_ids).destroy_all + RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids)) render_empty end @@ -37,16 +32,12 @@ class Api::V1::Lists::AccountsController < Api::BaseController def load_accounts if unlimited? - @list.accounts.without_suspended.includes(:account_stat).all + @list.accounts.without_suspended.includes(:account_stat, :user).all else - @list.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + @list.accounts.without_suspended.includes(:account_stat, :user).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) end end - def list_accounts - Account.find(account_ids) - end - def account_ids Array(resource_params[:account_ids]) end @@ -55,10 +46,6 @@ class Api::V1::Lists::AccountsController < Api::BaseController params.permit(account_ids: []) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path return if unlimited? @@ -71,22 +58,14 @@ class Api::V1::Lists::AccountsController < Api::BaseController api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? end - def pagination_max_id - @accounts.last.id - end - - def pagination_since_id - @accounts.first.id + def pagination_collection + @accounts end def records_continue? @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end - def unlimited? params[:limit] == '0' end diff --git a/app/controllers/api/v1/lists_controller.rb b/app/controllers/api/v1/lists_controller.rb index 0bacd7fdb0..b019ab6018 100644 --- a/app/controllers/api/v1/lists_controller.rb +++ b/app/controllers/api/v1/lists_controller.rb @@ -7,10 +7,6 @@ class Api::V1::ListsController < Api::BaseController before_action :require_user! before_action :set_list, except: [:index, :create] - rescue_from ArgumentError do |e| - render json: { error: e.to_s }, status: 422 - end - def index @lists = List.where(account: current_account).all render json: @lists, each_serializer: REST::ListSerializer @@ -38,6 +34,16 @@ class Api::V1::ListsController < Api::BaseController render_empty end + def favourite + @list.favourite! + render json: @list, serializer: REST::ListSerializer + end + + def unfavourite + @list.unfavourite! + render json: @list, serializer: REST::ListSerializer + end + private def set_list @@ -45,6 +51,6 @@ class Api::V1::ListsController < Api::BaseController end def list_params - params.permit(:title, :replies_policy, :exclusive, :notify) + params.permit(:title, :replies_policy, :exclusive, :notify, :favourite) end end diff --git a/app/controllers/api/v1/markers_controller.rb b/app/controllers/api/v1/markers_controller.rb index f8dfba8a94..8eaf7767df 100644 --- a/app/controllers/api/v1/markers_controller.rb +++ b/app/controllers/api/v1/markers_controller.rb @@ -19,7 +19,7 @@ class Api::V1::MarkersController < Api::BaseController @markers = {} resource_params.each_pair do |timeline, timeline_params| - @markers[timeline] = current_user.markers.find_or_initialize_by(timeline: timeline) + @markers[timeline] = current_user.markers.find_or_create_by(timeline: timeline) @markers[timeline].update!(timeline_params) end end diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 5ea26d55bd..c427e055ea 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -3,8 +3,8 @@ class Api::V1::MediaController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:media' } before_action :require_user! - before_action :set_media_attachment, except: [:create] - before_action :check_processing, except: [:create] + before_action :set_media_attachment, except: [:create, :destroy] + before_action :check_processing, except: [:create, :destroy] def show render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment @@ -25,6 +25,15 @@ class Api::V1::MediaController < Api::BaseController render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment end + def destroy + @media_attachment = current_account.media_attachments.find(params[:id]) + + return render json: in_usage_error, status: 422 unless @media_attachment.status_id.nil? + + @media_attachment.destroy + render_empty + end + private def status_code_for_media_attachment @@ -54,4 +63,8 @@ class Api::V1::MediaController < Api::BaseController def processing_error { error: 'Error processing thumbnail for uploaded media' } end + + def in_usage_error + { error: 'Media attachment is currently used by a status' } + end end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index 555485823c..d2b50e3336 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -17,7 +17,7 @@ class Api::V1::MutesController < Api::BaseController end def paginated_mutes - @paginated_mutes ||= Mute.eager_load(:target_account) + @paginated_mutes ||= Mute.eager_load(target_account: [:account_stat, :user]) .joins(:target_account) .merge(Account.without_suspended) .where(account: current_account) @@ -28,10 +28,6 @@ class Api::V1::MutesController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_mutes_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -40,19 +36,11 @@ class Api::V1::MutesController < Api::BaseController api_v1_mutes_url pagination_params(since_id: pagination_since_id) unless paginated_mutes.empty? end - def pagination_max_id - paginated_mutes.last.id - end - - def pagination_since_id - paginated_mutes.first.id + def pagination_collection + paginated_mutes end def records_continue? paginated_mutes.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb new file mode 100644 index 0000000000..9d70c283be --- /dev/null +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Api::V1::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::V1::NotificationPolicySerializer + end + + def update + @policy.update!(resource_params) + render json: @policy, serializer: REST::V1::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( + :filter_not_following, + :filter_not_followers, + :filter_new_accounts, + :filter_private_mentions + ) + end +end diff --git a/app/controllers/api/v1/notifications/requests_controller.rb b/app/controllers/api/v1/notifications/requests_controller.rb new file mode 100644 index 0000000000..3c90f13ce2 --- /dev/null +++ b/app/controllers/api/v1/notifications/requests_controller.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Api::V1::Notifications::RequestsController < Api::BaseController + 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, only: [:show, :accept, :dismiss] + before_action :set_requests, only: [:accept_bulk, :dismiss_bulk] + + after_action :insert_pagination_headers, only: :index + + def index + with_read_replica do + @requests = load_requests + @relationships = relationships + end + + 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 + + def accept + AcceptNotificationRequestService.new.call(@request) + render_empty + end + + def dismiss + 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).without_suspended.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) + ) + + NotificationRequest.preload_cache_collection(requests) do |statuses| + preload_collection(statuses, Status) + end + end + + def relationships + StatusRelationshipsPresenter.new(@requests.map(&:last_status), current_user&.account_id) + end + + def set_request + @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) 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 + + def pagination_since_id + @requests.first.id + end +end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 406ab97538..13919b400d 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 @@ -41,7 +51,7 @@ class Api::V1::NotificationsController < Api::BaseController ) Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| - cache_collection(target_statuses, Status) + preload_collection(target_statuses, Status) end end @@ -49,18 +59,19 @@ class Api::V1::NotificationsController < Api::BaseController current_account.notifications.without_suspended.browserable( types: Array(browserable_params[:types]), exclude_types: Array(browserable_params[:exclude_types]), - from_account_id: browserable_params[:account_id] + from_account_id: browserable_params[:account_id], + include_filtered: truthy_param?(:include_filtered) ) 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 - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? end @@ -69,19 +80,15 @@ class Api::V1::NotificationsController < Api::BaseController api_v1_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? end - def pagination_max_id - @notifications.last.id - end - - def pagination_since_id - @notifications.first.id + def pagination_collection + @notifications end def browserable_params - params.permit(:account_id, types: [], exclude_types: []) + params.permit(:account_id, :include_filtered, types: [], exclude_types: []) end def pagination_params(core_params) - params.slice(:limit, :account_id, :types, :exclude_types).permit(:limit, :account_id, types: [], exclude_types: []).merge(core_params) + params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered).permit(:limit, :account_id, :include_filtered, types: [], exclude_types: []).merge(core_params) end end diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb index 0c503d9bc5..d9c8232702 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 @@ -27,7 +29,7 @@ class Api::V1::Peers::SearchController < Api::BaseController @domains = InstancesIndex.query(function_score: { query: { prefix: { - domain: TagManager.instance.normalize_domain(params[:q].strip), + domain: normalized_domain, }, }, @@ -35,13 +37,20 @@ class Api::V1::Peers::SearchController < Api::BaseController field: 'accounts_count', modifier: 'log2p', }, - }).limit(10).pluck(:domain) + }).limit(LIMIT).pluck(:domain) else - domain = params[:q].strip - domain = TagManager.instance.normalize_domain(domain) - @domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain) + domain = normalized_domain + @domains = Instance.searchable.domain_starts_with(domain).limit(LIMIT).pluck(:domain) end rescue Addressable::URI::InvalidURIError @domains = [] end + + def normalized_domain + TagManager.instance.normalize_domain(query_value) + end + + def query_value + params[:q].strip + end end diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index 513b937ef2..2833687a38 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -8,20 +8,20 @@ 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 private def set_poll - @poll = Poll.attached.find(params[:poll_id]) + @poll = Poll.find(params[:poll_id]) authorize @poll.status, :show? rescue Mastodon::NotPermittedError not_found end def vote_params - params.permit(choices: []) + params.require(:choices) end end diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index ffc70a8496..b4c25476e8 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -15,7 +15,7 @@ class Api::V1::PollsController < Api::BaseController private def set_poll - @poll = Poll.attached.find(params[:id]) + @poll = Poll.find(params[:id]) authorize @poll.status, :show? rescue Mastodon::NotPermittedError not_found diff --git a/app/controllers/api/v1/profile/avatars_controller.rb b/app/controllers/api/v1/profile/avatars_controller.rb index bc4d01a597..e6c954ed63 100644 --- a/app/controllers/api/v1/profile/avatars_controller.rb +++ b/app/controllers/api/v1/profile/avatars_controller.rb @@ -7,7 +7,7 @@ class Api::V1::Profile::AvatarsController < Api::BaseController def destroy @account = current_account UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer end end diff --git a/app/controllers/api/v1/profile/headers_controller.rb b/app/controllers/api/v1/profile/headers_controller.rb index 9f4daa2f77..4472a01b05 100644 --- a/app/controllers/api/v1/profile/headers_controller.rb +++ b/app/controllers/api/v1/profile/headers_controller.rb @@ -7,7 +7,7 @@ class Api::V1::Profile::HeadersController < Api::BaseController def destroy @account = current_account UpdateAccountService.new.call(@account, { header: nil }, raise_error: true) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer end end diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 3634acf956..f2c52f2846 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -1,9 +1,12 @@ # frozen_string_literal: true class Api::V1::Push::SubscriptionsController < Api::BaseController + include Redisable + include Lockable + before_action -> { doorkeeper_authorize! :push } before_action :require_user! - before_action :set_push_subscription + before_action :set_push_subscription, only: [:show, :update] before_action :check_push_subscription, only: [:show, :update] def show @@ -11,16 +14,19 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController end def create - @push_subscription&.destroy! + with_redis_lock("push_subscription:#{current_user.id}") do + destroy_web_push_subscriptions! - @push_subscription = Web::PushSubscription.create!( - endpoint: subscription_params[:endpoint], - key_p256dh: subscription_params[:keys][:p256dh], - key_auth: subscription_params[:keys][:auth], - data: data_params, - user_id: current_user.id, - access_token_id: doorkeeper_token.id - ) + @push_subscription = Web::PushSubscription.create!( + endpoint: subscription_params[:endpoint], + key_p256dh: subscription_params[:keys][:p256dh], + key_auth: subscription_params[:keys][:auth], + standard: subscription_params[:standard] || false, + data: data_params, + user_id: current_user.id, + access_token_id: doorkeeper_token.id + ) + end render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end @@ -31,14 +37,18 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController end def destroy - @push_subscription&.destroy! + destroy_web_push_subscriptions! render_empty end private + def destroy_web_push_subscriptions! + doorkeeper_token.web_push_subscriptions.destroy_all + end + def set_push_subscription - @push_subscription = Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id) + @push_subscription = doorkeeper_token.web_push_subscriptions.first end def check_push_subscription @@ -46,12 +56,12 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController end def subscription_params - params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) + params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]]) end def data_params return {} if params[:data].blank? - params.require(:data).permit(:policy, alerts: Notification::TYPES) + params.expect(data: [:policy, alerts: Notification::TYPES]) end end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 300c9faa3f..72f358bb5b 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 2220b6d22e..c62305d711 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 @@ -43,14 +44,6 @@ class Api::V1::ScheduledStatusesController < Api::BaseController params.permit(:scheduled_at) end - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end - - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_scheduled_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -63,11 +56,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) end - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + def pagination_collection + @statuses end end diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb index 3cca246ce8..5a5c2fdc97 100644 --- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb @@ -14,14 +14,14 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::V1::Statuses::Bas def load_accounts scope = default_accounts - scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope = scope.not_excluded_by_account(current_account) unless current_account.nil? scope.merge(paginated_favourites).to_a end def default_accounts Account .without_suspended - .includes(:favourites, :account_stat) + .includes(:favourites, :account_stat, :user) .references(:favourites) .where(favourites: { status_id: @status.id }) end @@ -34,10 +34,6 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::V1::Statuses::Bas ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_status_favourited_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -57,8 +53,4 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::V1::Statuses::Bas def records_continue? @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb index a050259f96..0eba4fae32 100644 --- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb @@ -14,26 +14,22 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::V1::Statuses::Base def load_accounts scope = default_accounts - scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope = scope.not_excluded_by_account(current_account) unless current_account.nil? scope.merge(paginated_statuses).to_a end def default_accounts - Account.without_suspended.includes(:statuses, :account_stat).references(:statuses) + Account.without_suspended.includes(:statuses, :account_stat, :user).references(:statuses) end def paginated_statuses - Status.where(reblog_of_id: @status.id).where(visibility: [:public, :unlisted, :public_unlisted, :login]).paginate_by_max_id( + Status.where(reblog_of_id: @status.id).distributable_visibility.paginate_by_max_id( limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id] ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_status_reblogged_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -53,8 +49,4 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::V1::Statuses::Base def records_continue? @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end end diff --git a/app/controllers/api/v1/statuses/referred_by_statuses_controller.rb b/app/controllers/api/v1/statuses/referred_by_statuses_controller.rb index b0ad6754ea..c13c5ff0e8 100644 --- a/app/controllers/api/v1/statuses/referred_by_statuses_controller.rb +++ b/app/controllers/api/v1/statuses/referred_by_statuses_controller.rb @@ -33,7 +33,7 @@ class Api::V1::Statuses::ReferredByStatusesController < Api::BaseController domains = statuses.filter_map(&:account_domain).uniq relations = account&.relations_map(account_ids, domains) || {} - statuses = cache_collection_paginated_by_id( + statuses = preload_collection_paginated_by_id( statuses, Status, limit_param(DEFAULT_STATUSES_LIMIT), diff --git a/app/controllers/api/v1/statuses/translations_controller.rb b/app/controllers/api/v1/statuses/translations_controller.rb index 7d406b0a36..bd5cd9bb07 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 @@ -22,6 +23,6 @@ class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseControl private def set_translation - @translation = TranslateStatusService.new.call(@status, content_locale) + @translation = TranslateStatusService.new.call(@status, I18n.locale.to_s) end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index b3aaafe82c..1217b70752 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -5,9 +5,11 @@ class Api::V1::StatusesController < Api::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy] - before_action :require_user!, except: [:show, :context] - before_action :set_status, only: [:show, :context] - before_action :set_thread, only: [:create] + before_action :require_user!, except: [:index, :show, :context] + before_action :set_statuses, only: [:index] + before_action :set_status, only: [:show, :context] + before_action :set_thread, only: [:create] + before_action :check_statuses_limit, only: [:index] override_rate_limit_headers :create, family: :statuses override_rate_limit_headers :update, family: :statuses @@ -23,9 +25,14 @@ class Api::V1::StatusesController < Api::BaseController DESCENDANTS_LIMIT = 60 DESCENDANTS_DEPTH_LIMIT = 20 + def index + @statuses = preload_collection(@statuses, Status) + render json: @statuses, each_serializer: REST::StatusSerializer + end + def show cache_if_unauthenticated! - @status = cache_collection([@status], Status).first + @status = preload_collection([@status], Status).first render json: @status, serializer: REST::StatusSerializer end @@ -45,9 +52,9 @@ class Api::V1::StatusesController < Api::BaseController ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit) references_results = @status.readable_references(current_account) - loaded_ancestors = cache_collection(ancestors_results, Status) - loaded_descendants = cache_collection(descendants_results, Status) - loaded_references = cache_collection(references_results, Status) + loaded_ancestors = preload_collection(ancestors_results, Status) + loaded_descendants = preload_collection(descendants_results, Status) + loaded_references = preload_collection(references_results, Status) if params[:with_reference] loaded_references.reject! { |status| loaded_ancestors.any? { |ancestor| ancestor.id == status.id } } @@ -60,6 +67,8 @@ class Api::V1::StatusesController < Api::BaseController statuses = [@status] + @context.ancestors + @context.descendants + @context.references render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + + ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies? end def create @@ -84,13 +93,9 @@ class Api::V1::StatusesController < Api::BaseController with_rate_limit: true ) - render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer + render json: @status, serializer: serializer_for_status rescue PostStatusService::UnexpectedMentionsError => e - unexpected_accounts = ActiveModel::Serializer::CollectionSerializer.new( - e.accounts, - serializer: REST::AccountSerializer - ) - render json: { error: e.message, unexpected_accounts: unexpected_accounts }, status: 422 + render json: unexpected_accounts_error_json(e), status: 422 end def update @@ -122,13 +127,17 @@ class Api::V1::StatusesController < Api::BaseController @status.account.statuses_count = @status.account.statuses_count - 1 json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true - RemovalWorker.perform_async(@status.id, { 'redraft' => true }) + RemovalWorker.perform_async(@status.id, { 'redraft' => !truthy_param?(:delete_media) }) render json: json end private + def set_statuses + @statuses = Status.permitted_statuses_from_ids(status_ids, current_account) + end + def set_status @status = Status.find(params[:id]) authorize @status, :show? @@ -143,6 +152,18 @@ class Api::V1::StatusesController < Api::BaseController render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 end + def check_statuses_limit + raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT + end + + def status_ids + Array(statuses_params[:id]).uniq.map(&:to_i) + end + + def statuses_params + params.permit(id: []) + end + def status_params params.permit( :status, @@ -174,7 +195,18 @@ class Api::V1::StatusesController < Api::BaseController ) end - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) + def serializer_for_status + @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer + end + + def unexpected_accounts_error_json(error) + { + error: error.message, + unexpected_accounts: serialized_accounts(error.accounts), + } + end + + def serialized_accounts(accounts) + ActiveModel::Serializer::CollectionSerializer.new(accounts, serializer: REST::AccountSerializer) end end diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb index 9ba1cef63c..df9346832f 100644 --- a/app/controllers/api/v1/suggestions_controller.rb +++ b/app/controllers/api/v1/suggestions_controller.rb @@ -2,6 +2,9 @@ class Api::V1::SuggestionsController < Api::BaseController include Authorization + include DeprecationConcern + + deprecate_api '2021-05-16', only: [:index] before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index diff --git a/app/controllers/api/v1/timelines/antenna_controller.rb b/app/controllers/api/v1/timelines/antenna_controller.rb index fc54e663d4..69554361be 100644 --- a/app/controllers/api/v1/timelines/antenna_controller.rb +++ b/app/controllers/api/v1/timelines/antenna_controller.rb @@ -25,7 +25,7 @@ class Api::V1::Timelines::AntennaController < Api::V1::Timelines::BaseController end def cached_list_statuses - cache_collection list_statuses, Status + preload_collection list_statuses, Status end def list_statuses diff --git a/app/controllers/api/v1/timelines/base_controller.rb b/app/controllers/api/v1/timelines/base_controller.rb index 173e173cc9..1dba4a5bb2 100644 --- a/app/controllers/api/v1/timelines/base_controller.rb +++ b/app/controllers/api/v1/timelines/base_controller.rb @@ -3,18 +3,16 @@ class Api::V1::Timelines::BaseController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + before_action :require_user!, if: :require_auth? + private - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) + def require_auth? + !Setting.timeline_preview end - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + def pagination_collection + @statuses end def next_path_params diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index 57214e4a1e..e48c5ae251 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -23,11 +23,11 @@ class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController private def load_statuses - cached_home_statuses + preloaded_home_statuses end - def cached_home_statuses - cache_collection home_statuses, Status + def preloaded_home_statuses + preload_collection home_statuses, Status end def home_statuses diff --git a/app/controllers/api/v1/timelines/link_controller.rb b/app/controllers/api/v1/timelines/link_controller.rb new file mode 100644 index 0000000000..37ed084f06 --- /dev/null +++ b/app/controllers/api/v1/timelines/link_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController + before_action -> { authorize_if_got_token! :read, :'read:statuses' } + before_action :set_preview_card + before_action :set_statuses + + PERMITTED_PARAMS = %i( + url + limit + ).freeze + + def show + cache_if_unauthenticated! + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def set_preview_card + @preview_card = PreviewCard.joins(:trend).merge(PreviewCardTrend.allowed).find_by!(url: params[:url]) + end + + def set_statuses + @statuses = @preview_card.nil? ? [] : preload_collection(link_timeline_statuses, Status) + end + + def link_timeline_statuses + link_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id], + params[:min_id] + ) + end + + def link_feed + LinkFeed.new(@preview_card, current_account) + end + + def next_path + api_v1_timelines_link_url next_path_params + end + + def prev_path + api_v1_timelines_link_url prev_path_params + end +end diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb index 14b884ecd9..d8cdbdb74c 100644 --- a/app/controllers/api/v1/timelines/list_controller.rb +++ b/app/controllers/api/v1/timelines/list_controller.rb @@ -21,11 +21,11 @@ class Api::V1::Timelines::ListController < Api::V1::Timelines::BaseController end def set_statuses - @statuses = cached_list_statuses + @statuses = preloaded_list_statuses end - def cached_list_statuses - cache_collection list_statuses, Status + def preloaded_list_statuses + preload_collection list_statuses, Status end def list_statuses diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 203eda7a81..73dcc5aee4 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 @@ -15,16 +15,12 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController private - def require_auth? - !Setting.timeline_preview - end - def load_statuses - cached_public_statuses_page + preloaded_public_statuses_page end - def cached_public_statuses_page - cache_collection(public_statuses, Status) + def preloaded_public_statuses_page + preload_collection(public_statuses, Status) end def public_statuses diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index a0865d42c7..b9855fb67a 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 @@ -25,11 +25,11 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController end def load_statuses - cached_tagged_statuses + preloaded_tagged_statuses end - def cached_tagged_statuses - @tag.nil? ? [] : cache_collection(tag_timeline_statuses, Status) + def preloaded_tagged_statuses + @tag.nil? ? [] : preload_collection(tag_timeline_statuses, Status) end def tag_timeline_statuses diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb index 57cfa0b7e4..3c5aecff43 100644 --- a/app/controllers/api/v1/trends/links_controller.rb +++ b/app/controllers/api/v1/trends/links_controller.rb @@ -34,14 +34,6 @@ class Api::V1::Trends::LinksController < Api::BaseController scope end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end - def next_path api_v1_trends_links_url pagination_params(offset: offset_param + limit_param(DEFAULT_LINKS_LIMIT)) if records_continue? end diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb index c186864c3b..cdbfce0685 100644 --- a/app/controllers/api/v1/trends/statuses_controller.rb +++ b/app/controllers/api/v1/trends/statuses_controller.rb @@ -20,7 +20,7 @@ class Api::V1::Trends::StatusesController < Api::BaseController def set_statuses @statuses = if enabled? - cache_collection(statuses_from_trends.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status) + preload_collection(statuses_from_trends.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status) else [] end @@ -32,14 +32,6 @@ class Api::V1::Trends::StatusesController < Api::BaseController scope end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end - def next_path api_v1_trends_statuses_url pagination_params(offset: offset_param + limit_param(DEFAULT_STATUSES_LIMIT)) if records_continue? end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index aca3dd7089..ecac3579fc 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true class Api::V1::Trends::TagsController < Api::BaseController + include DeprecationConcern + before_action :set_tags after_action :insert_pagination_headers - DEFAULT_TAGS_LIMIT = 10 + DEFAULT_TAGS_LIMIT = (ENV['MAX_TRENDING_TAGS'] || 10).to_i + + deprecate_api '2022-03-30', only: :index, if: -> { request.path == '/api/v1/trends' } def index cache_if_unauthenticated! @@ -27,15 +31,9 @@ class Api::V1::Trends::TagsController < Api::BaseController end def tags_from_trends - Trends.tags.query.allowed - end - - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) + scope = Trends.tags.query.allowed.in_locale(content_locale) + scope = scope.filtered_for(current_account) if user_signed_in? + scope end def next_path diff --git a/app/controllers/api/v2/filters_controller.rb b/app/controllers/api/v2/filters_controller.rb index 5e39d77416..51e778e1d6 100644 --- a/app/controllers/api/v2/filters_controller.rb +++ b/app/controllers/api/v2/filters_controller.rb @@ -35,7 +35,7 @@ class Api::V2::FiltersController < Api::BaseController private def set_filters - @filters = current_account.custom_filters.includes(:keywords) + @filters = current_account.custom_filters.includes(:keywords, :statuses) end def set_filter @@ -43,6 +43,6 @@ class Api::V2::FiltersController < Api::BaseController end def resource_params - params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, :with_profile, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end end diff --git a/app/controllers/api/v2/instances_controller.rb b/app/controllers/api/v2/instances_controller.rb index 8346e28830..62adf95260 100644 --- a/app/controllers/api/v2/instances_controller.rb +++ b/app/controllers/api/v2/instances_controller.rb @@ -1,6 +1,16 @@ # frozen_string_literal: true -class Api::V2::InstancesController < Api::V1::InstancesController +class Api::V2::InstancesController < Api::BaseController + skip_before_action :require_authenticated_user!, unless: :limited_federation_mode? + skip_around_action :set_locale + + vary_by '' + + # Override `current_user` to avoid reading session cookies unless in limited federation mode + def current_user + super if limited_federation_mode? + end + def show cache_even_if_authenticated! render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance' 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 0000000000..771e966388 --- /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 0000000000..637587967f --- /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 0000000000..848c361cfc --- /dev/null +++ b/app/controllers/api/v2/notifications_controller.rb @@ -0,0 +1,154 @@ +# 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.by_group_key(params[:group_key]).take! + 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.by_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 + pagination_range = (@notifications.last.id)..@notifications.first.id + + # If the page is incomplete, we know we are on the last page + if incomplete_page? + if paginating_up? + pagination_range = @notifications.last.id...(params[:max_id]&.to_i) + else + range_start = params[:since_id]&.to_i + range_start += 1 unless range_start.nil? + pagination_range = range_start..(@notifications.first.id) + end + end + + NotificationGroup.from_notifications(@notifications, pagination_range: pagination_range, grouped_types: params[:grouped_types]) + end + end + + def incomplete_page? + @notifications.size < limit_param(DEFAULT_NOTIFICATIONS_LIMIT) + end + + def paginating_up? + params[:min_id].present? + 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/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index 63c3f2d90a..f82c1c50d7 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/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 167d16fc4d..2711071b4a 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::Web::PushSubscriptionsController < Api::Web::BaseController - before_action :require_user! + before_action :require_user!, except: :destroy before_action :set_push_subscription, only: :update before_action :destroy_previous_subscriptions, only: :create, if: :prior_subscriptions? after_action :update_session_with_subscription, only: :create @@ -17,6 +17,13 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end + def destroy + push_subscription = ::Web::PushSubscription.find_by_token_for(:unsubscribe, params[:id]) + push_subscription&.destroy + + head 200 + end + private def active_session @@ -59,7 +66,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController end def subscription_params - @subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) + @subscription_params ||= params.expect(subscription: [:standard, :endpoint, keys: [:auth, :p256dh]]) end def web_push_subscription_params @@ -69,11 +76,12 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController endpoint: subscription_params[:endpoint], key_auth: subscription_params[:keys][:auth], key_p256dh: subscription_params[:keys][:p256dh], + standard: subscription_params[:standard] || false, user_id: active_session.user_id, } end def data_params - @data_params ||= params.require(:data).permit(:policy, alerts: Notification::TYPES) + @data_params ||= params.expect(data: [:policy, alerts: Notification::TYPES]) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5f8725f6fc..1b071e8655 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base include UserTrackingConcern include SessionTrackingConcern include CacheConcern + include PreloadingConcern include DomainControlHelper include DatabaseHelper include AuthorizedFetchHelper @@ -19,10 +20,8 @@ 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 helper_method :skip_csrf_meta_tags? rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request @@ -32,7 +31,7 @@ class ApplicationController < ActionController::Base rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests - rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable rescue_from Seahorse::Client::NetworkingError do |e| @@ -71,7 +70,13 @@ class ApplicationController < ActionController::Base end def require_functional! - redirect_to edit_user_registration_path unless current_user.functional? + return if current_user.functional? + + if current_user.confirmed? + redirect_to edit_user_registration_path + else + redirect_to auth_setup_path + end end def skip_csrf_meta_tags? @@ -129,17 +134,13 @@ class ApplicationController < ActionController::Base end def single_user_mode? - @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? + @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.without_internal.exists? end def use_seamless_external_login? 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 @@ -162,10 +163,6 @@ class ApplicationController < ActionController::Base current_user.setting_theme end - def body_class_string - @body_classes || '' - end - def respond_with_error(code) respond_to do |format| format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } @@ -178,7 +175,7 @@ class ApplicationController < ActionController::Base respond_to do |format| format.any { render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html] } - format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: code } + format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: 410 } end end diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index d9cd630905..13c2468eb8 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -2,12 +2,12 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController include Auth::CaptchaConcern + include RegistrationLimitationHelper layout 'auth' - before_action :set_body_classes before_action :set_confirmation_user!, only: [:show, :confirm_captcha] - before_action :require_unconfirmed! + before_action :redirect_confirmed_user, if: :signed_in_confirmed_user? before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha] before_action :require_captcha_if_needed!, only: [:show] @@ -16,6 +16,11 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController skip_before_action :require_functional! def show + if reach_registrations_limit? && !current_user&.valid_invitation? + render :limitation_error + return + end + old_session_values = session.to_hash reset_session session.update old_session_values.except('session_id') @@ -65,14 +70,12 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController @confirmation_user.nil? || @confirmation_user.confirmed? end - def require_unconfirmed! - if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? - redirect_to(current_user.approved? ? root_path : edit_user_registration_path) - end + def redirect_confirmed_user + redirect_to(current_user.approved? ? root_path : edit_user_registration_path) end - def set_body_classes - @body_classes = 'lighter' + def signed_in_confirmed_user? + user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? end def after_resending_confirmation_instructions_path_for(_resource_name) diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index a752194d5b..7c1ff59671 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -2,8 +2,7 @@ class Auth::PasswordsController < Devise::PasswordsController skip_before_action :check_self_destruct! - before_action :check_validity_of_reset_password_token, only: :edit - before_action :set_body_classes + before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid? layout 'auth' @@ -19,15 +18,9 @@ class Auth::PasswordsController < Devise::PasswordsController private - def check_validity_of_reset_password_token - unless reset_password_token_is_valid? - flash[:error] = I18n.t('auth.invalid_reset_password_token') - redirect_to new_password_path(resource_name) - end - end - - def set_body_classes - @body_classes = 'lighter' + def redirect_invalid_reset_token + flash[:error] = I18n.t('auth.invalid_reset_password_token') + redirect_to new_password_path(resource_name) end def reset_password_token_is_valid? diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index acfc0af0d9..0b6f5b3af4 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -11,9 +11,7 @@ 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 before_action :require_rules_acceptance!, only: :new before_action :set_registration_form_time, only: :new @@ -25,6 +23,14 @@ class Auth::RegistrationsController < Devise::RegistrationsController super(&:build_invite_request) end + def edit # rubocop:disable Lint/UselessMethodDefinition + super + end + + def create # rubocop:disable Lint/UselessMethodDefinition + super + end + def update super do |resource| resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password? @@ -44,7 +50,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def build_resource(hash = nil) - super(hash) + super resource.locale = I18n.locale resource.invite_code = @invite&.code if resource.invite_code.blank? @@ -56,7 +62,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController def configure_sign_up_params devise_parameter_sanitizer.permit(:sign_up) do |user_params| - user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password) + user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password, :date_of_birth) end end @@ -96,10 +102,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? @@ -136,7 +138,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController set_locale { render :rules } end - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) + def is_flashing_format? # rubocop:disable Naming/PredicateName + if params[:action] == 'create' + false # Disable flash messages for sign-up + else + super + end end end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 6bc48a7804..5f9f133659 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 @@ -80,7 +73,11 @@ class Auth::SessionsController < Devise::SessionsController end def user_params - params.require(:user).permit(:email, :password, :otp_attempt, credential: {}) + params.expect(user: [:email, :password, :otp_attempt, :disable_css, credential: {}]) + end + + def login_page_params + params.permit(:with_options) end def after_sign_in_path_for(resource) @@ -103,8 +100,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) @@ -119,6 +117,11 @@ class Auth::SessionsController < Devise::SessionsController truthy_param?(:continue) end + def with_login_options? + login_page_params[:with_options] == '1' + end + helper_method :with_login_options? + def restart_session clear_attempt_from_session redirect_to new_user_session_path, alert: I18n.t('devise.failure.timeout') @@ -157,6 +160,8 @@ class Auth::SessionsController < Devise::SessionsController sign_in(user) flash.delete(:notice) + disable_custom_css!(user) if disable_custom_css? + LoginActivity.create( user: user, success: true, @@ -168,6 +173,15 @@ class Auth::SessionsController < Devise::SessionsController UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious end + def disable_custom_css? + user_params[:disable_css].present? && user_params[:disable_css] == '1' + end + + def disable_custom_css!(user) + user.settings['web.use_custom_css'] = false + user.save! + end + def suspicious_sign_in?(user) SuspiciousSignInDetector.new(user).suspicious?(request) end @@ -181,9 +195,27 @@ class Auth::SessionsController < Devise::SessionsController ip: request.remote_ip, user_agent: request.user_agent ) + + # Only send a notification email every hour at most + return if redis.get("2fa_failure_notification:#{user.id}").present? + + redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour) + + UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! end 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 40916d2887..5e7b14646a 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! @@ -19,7 +18,7 @@ class Auth::SetupController < ApplicationController if @user.update(user_params) @user.resend_confirmation_instructions unless @user.confirmed? - redirect_to auth_setup_path, notice: I18n.t('auth.setup.new_confirmation_instructions_sent') + redirect_to auth_setup_path, notice: t('auth.setup.new_confirmation_instructions_sent') else render :show end @@ -35,11 +34,7 @@ class Auth::SetupController < ApplicationController @user = current_user end - def set_body_classes - @body_classes = 'lighter' - end - def user_params - params.require(:user).permit(:email) + params.expect(user: [:email]) end end diff --git a/app/controllers/backups_controller.rb b/app/controllers/backups_controller.rb index 5df1af5f2f..076d19874b 100644 --- a/app/controllers/backups_controller.rb +++ b/app/controllers/backups_controller.rb @@ -9,13 +9,15 @@ class BackupsController < ApplicationController before_action :authenticate_user! before_action :set_backup + BACKUP_LINK_TIMEOUT = 1.hour.freeze + def download case Paperclip::Attachment.default_options[:storage] when :s3, :azure - redirect_to @backup.dump.expiring_url(10), allow_other_host: true + redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.to_i), allow_other_host: true when :fog if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present? - redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true + redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.from_now), allow_other_host: true else redirect_to full_asset_url(@backup.dump.url), allow_other_host: true end diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index d63bcc85c9..b75f3e3581 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/admin/export_controller_concern.rb b/app/controllers/concerns/admin/export_controller_concern.rb index 6228ae67fe..ce03b2a24a 100644 --- a/app/controllers/concerns/admin/export_controller_concern.rb +++ b/app/controllers/concerns/admin/export_controller_concern.rb @@ -24,6 +24,6 @@ module Admin::ExportControllerConcern end def import_params - params.require(:admin_import).permit(:data) + params.expect(admin_import: [:data]) end end diff --git a/app/controllers/concerns/api/error_handling.rb b/app/controllers/concerns/api/error_handling.rb new file mode 100644 index 0000000000..9ce4795b02 --- /dev/null +++ b/app/controllers/concerns/api/error_handling.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Api::ErrorHandling + extend ActiveSupport::Concern + + included do + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| + render json: { error: e.to_s }, status: 422 + end + + rescue_from ActiveRecord::RecordNotUnique do + render json: { error: 'Duplicate record' }, status: 422 + end + + rescue_from Date::Error do + render json: { error: 'Invalid date supplied' }, status: 422 + end + + rescue_from ActiveRecord::RecordNotFound do + render json: { error: 'Record not found' }, status: 404 + end + + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, Mastodon::UnexpectedResponseError) do + render json: { error: 'Remote data could not be fetched' }, status: 503 + end + + rescue_from OpenSSL::SSL::SSLError do + render json: { error: 'Remote SSL certificate could not be verified' }, status: 503 + end + + rescue_from Mastodon::NotPermittedError do + render json: { error: 'This action is not allowed' }, status: 403 + end + + rescue_from Seahorse::Client::NetworkingError do |e| + Rails.logger.warn "Storage server error: #{e}" + render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do + render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from Mastodon::RateLimitExceededError do + render json: { error: I18n.t('errors.429') }, status: 429 + end + + rescue_from ActionController::ParameterMissing, Mastodon::InvalidParameterError do |e| + render json: { error: e.to_s }, status: 400 + end + end +end diff --git a/app/controllers/concerns/api/pagination.rb b/app/controllers/concerns/api/pagination.rb new file mode 100644 index 0000000000..b0b4ae4603 --- /dev/null +++ b/app/controllers/concerns/api/pagination.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Api::Pagination + extend ActiveSupport::Concern + + PAGINATION_PARAMS = %i(limit).freeze + + protected + + def pagination_max_id + pagination_collection.last.id + end + + def pagination_since_id + pagination_collection.first.id + end + + def set_pagination_headers(next_path = nil, prev_path = nil) + 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).to_s unless links.empty? + end + + def require_valid_pagination_options! + render json: { error: 'Pagination values for `offset` and `limit` must be positive' }, status: 400 if pagination_options_invalid? + end + + def pagination_params(core_params) + params + .slice(*PAGINATION_PARAMS) + .permit(*PAGINATION_PARAMS) + .merge(core_params) + end + + private + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_options_invalid? + params.slice(:limit, :offset).values.map(&:to_i).any?(&:negative?) + end +end diff --git a/app/controllers/concerns/auth/captcha_concern.rb b/app/controllers/concerns/auth/captcha_concern.rb index cfd93978ce..c01da21249 100644 --- a/app/controllers/concerns/auth/captcha_concern.rb +++ b/app/controllers/concerns/auth/captcha_concern.rb @@ -10,7 +10,7 @@ module Auth::CaptchaConcern end def captcha_available? - ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? + Rails.configuration.x.captcha.secret_key.present? && Rails.configuration.x.captcha.site_key.present? end def captcha_enabled? diff --git a/app/controllers/concerns/auth/two_factor_authentication_concern.rb b/app/controllers/concerns/auth/two_factor_authentication_concern.rb index 404164751a..0fb11428dc 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/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index 6beecc9791..1cdd4d7cf0 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -3,150 +3,6 @@ module CacheConcern extend ActiveSupport::Concern - module ActiveRecordCoder - EMPTY_HASH = {}.freeze - - class << self - def dump(record) - instances = InstanceTracker.new - serialized_associations = serialize_associations(record, instances) - serialized_records = instances.map { |r| serialize_record(r) } - [serialized_associations, *serialized_records] - end - - def load(payload) - instances = InstanceTracker.new - serialized_associations, *serialized_records = payload - serialized_records.each { |attrs| instances.push(deserialize_record(*attrs)) } - deserialize_associations(serialized_associations, instances) - end - - private - - # Records without associations, or which have already been visited before, - # are serialized by their id alone. - # - # Records with associations are serialized as a two-element array including - # their id and the record's association cache. - # - def serialize_associations(record, instances) - return unless record - - if (id = instances.lookup(record)) - payload = id - else - payload = instances.push(record) - - cached_associations = record.class.reflect_on_all_associations.select do |reflection| - record.association_cached?(reflection.name) - end - - unless cached_associations.empty? - serialized_associations = cached_associations.map do |reflection| - association = record.association(reflection.name) - - serialized_target = if reflection.collection? - association.target.map { |target_record| serialize_associations(target_record, instances) } - else - serialize_associations(association.target, instances) - end - - [reflection.name, serialized_target] - end - - payload = [payload, serialized_associations] - end - end - - payload - end - - def deserialize_associations(payload, instances) - return unless payload - - id, associations = payload - record = instances.fetch(id) - - associations&.each do |name, serialized_target| - begin - association = record.association(name) - rescue ActiveRecord::AssociationNotFoundError - raise AssociationMissingError, "undefined association: #{name}" - end - - target = if association.reflection.collection? - serialized_target.map! { |serialized_record| deserialize_associations(serialized_record, instances) } - else - deserialize_associations(serialized_target, instances) - end - - association.target = target - end - - record - end - - def serialize_record(record) - arguments = [record.class.name, attributes_for_database(record)] - arguments << true if record.new_record? - arguments - end - - def attributes_for_database(record) - attributes = record.attributes_for_database - attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr } - attributes - end - - def deserialize_record(class_name, attributes_from_database, new_record = false) # rubocop:disable Style/OptionalBooleanParameter - begin - klass = Object.const_get(class_name) - rescue NameError - raise ClassMissingError, "undefined class: #{class_name}" - end - - # Ideally we'd like to call `klass.instantiate`, however it doesn't allow to pass - # wether the record was persisted or not. - attributes = klass.attributes_builder.build_from_database(attributes_from_database, EMPTY_HASH) - klass.allocate.init_with_attributes(attributes, new_record) - end - end - - class Error < StandardError - end - - class ClassMissingError < Error - end - - class AssociationMissingError < Error - end - - class InstanceTracker - def initialize - @instances = [] - @ids = {}.compare_by_identity - end - - def map(&block) - @instances.map(&block) - end - - def fetch(...) - @instances.fetch(...) - end - - def push(instance) - id = @ids[instance] = @instances.size - @instances << instance - id - end - - def lookup(instance) - @ids[instance] - end - end - end - class_methods do def vary_by(value, **kwargs) before_action(**kwargs) do |controller| @@ -182,7 +38,7 @@ module CacheConcern return render(options) end - key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':') + key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields]&.join(',')].compact.join(':') expires_in = options.delete(:expires_in) || 3.minutes body = Rails.cache.read(key, raw: true) @@ -199,35 +55,4 @@ module CacheConcern Rails.cache.write(key, response.body, expires_in: expires_in, raw: true) end end - - def cache_collection(raw, klass) - return raw unless klass.respond_to?(:with_includes) - - raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation) - return [] if raw.empty? - - cached_keys_with_value = begin - Rails.cache.read_multi(*raw).transform_keys(&:id).transform_values { |r| ActiveRecordCoder.load(r) } - rescue ActiveRecordCoder::Error - {} # The serialization format may have changed, let's pretend it's a cache miss. - end - - uncached_ids = raw.map(&:id) - cached_keys_with_value.keys - - klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!) - - unless uncached_ids.empty? - uncached = klass.where(id: uncached_ids).with_includes.index_by(&:id) - - uncached.each_value do |item| - Rails.cache.write(item, ActiveRecordCoder.dump(item)) - end - end - - raw.filter_map { |item| cached_keys_with_value[item.id] || uncached[item.id] } - end - - def cache_collection_paginated_by_id(raw, klass, limit, options) - cache_collection raw.cache_ids.to_a_paginated_by_id(limit, options), klass - end end diff --git a/app/controllers/concerns/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb index 09874fb405..7fbc469bdf 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 @@ -59,6 +58,6 @@ module ChallengableConcern end def challenge_params - params.require(:form_challenge).permit(:current_password, :return_to) + params.expect(form_challenge: [:current_password, :return_to]) end end diff --git a/app/controllers/concerns/deprecation_concern.rb b/app/controllers/concerns/deprecation_concern.rb new file mode 100644 index 0000000000..ad8de724a1 --- /dev/null +++ b/app/controllers/concerns/deprecation_concern.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module DeprecationConcern + extend ActiveSupport::Concern + + class_methods do + def deprecate_api(date, sunset: nil, **kwargs) + deprecation_timestamp = "@#{date.to_datetime.to_i}" + sunset = sunset&.to_date&.httpdate + + before_action(**kwargs) do + response.headers['Deprecation'] = deprecation_timestamp + response.headers['Sunset'] = sunset if sunset + end + end + end +end diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb index ede299d5a4..14742e3b5c 100644 --- a/app/controllers/concerns/localized.rb +++ b/app/controllers/concerns/localized.rb @@ -25,7 +25,7 @@ module Localized end def available_locale_or_nil(locale_name) - locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym) + locale_name.to_sym if locale_name.respond_to?(:to_sym) && I18n.available_locales.include?(locale_name.to_sym) end def content_locale diff --git a/app/controllers/concerns/preloading_concern.rb b/app/controllers/concerns/preloading_concern.rb new file mode 100644 index 0000000000..61e2213649 --- /dev/null +++ b/app/controllers/concerns/preloading_concern.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module PreloadingConcern + extend ActiveSupport::Concern + + def preload_collection(scope, klass) + return scope unless klass.respond_to?(:preload_cacheable_associations) + + scope.to_a.tap do |records| + klass.preload_cacheable_associations(records) + end + end + + def preload_collection_paginated_by_id(scope, klass, limit, options) + preload_collection scope.to_a_paginated_by_id(limit, options), klass + end +end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 92f1eb5a16..ffe612f468 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -10,41 +10,6 @@ module SignatureVerification EXPIRATION_WINDOW_LIMIT = 12.hours CLOCK_SKEW_MARGIN = 1.hour - class SignatureVerificationError < StandardError; end - - class SignatureParamsParser < Parslet::Parser - rule(:token) { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) } - rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') } - # qdtext and quoted_pair are not exactly according to spec but meh - rule(:qdtext) { match('[^\\\\"]') } - rule(:quoted_pair) { str('\\') >> any } - rule(:bws) { match('\s').repeat } - rule(:param) { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) } - rule(:comma) { bws >> str(',') >> bws } - # Old versions of node-http-signature add an incorrect "Signature " prefix to the header - rule(:buggy_prefix) { str('Signature ') } - rule(:params) { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) } - root(:params) - end - - class SignatureParamsTransformer < Parslet::Transform - rule(params: subtree(:param)) do - (param.is_a?(Array) ? param : [param]).each_with_object({}) { |(key, value), hash| hash[key] = value } - end - - rule(param: { key: simple(:key), value: simple(:val) }) do - [key, val] - end - - rule(quoted_string: simple(:string)) do - string.to_s - end - - rule(token: simple(:string)) do - string.to_s - end - end - def require_account_signature! render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account end @@ -67,7 +32,7 @@ module SignatureVerification def signature_key_id signature_params['keyId'] - rescue SignatureVerificationError + rescue Mastodon::SignatureVerificationError nil end @@ -78,17 +43,17 @@ module SignatureVerification def signed_request_actor return @signed_request_actor if defined?(@signed_request_actor) - raise SignatureVerificationError, 'Request not signed' unless signed_request? - raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters? - raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm) - raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window? + raise Mastodon::SignatureVerificationError, 'Request not signed' unless signed_request? + raise Mastodon::SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters? + raise Mastodon::SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm) + raise Mastodon::SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window? verify_signature_strength! verify_body_digest! actor = actor_from_key_id(signature_params['keyId']) - raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil? + raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil? signature = Base64.decode64(signature_params['signature']) compare_signed_string = build_signed_string(include_query_string: true) @@ -99,9 +64,9 @@ module SignatureVerification compare_signed_string = build_signed_string(include_query_string: false) return actor unless verify_signature(actor, signature, compare_signed_string).nil? - actor = stoplight_wrap_request { actor_refresh_key!(actor) } + actor = stoplight_wrapper.run { actor_refresh_key!(actor) } - raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil? + raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil? compare_signed_string = build_signed_string(include_query_string: true) return actor unless verify_signature(actor, signature, compare_signed_string).nil? @@ -111,9 +76,9 @@ module SignatureVerification return actor unless verify_signature(actor, signature, compare_signed_string).nil? fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature'] - rescue SignatureVerificationError => e + rescue Mastodon::SignatureVerificationError => e fail_with! e.message - rescue HTTP::Error, OpenSSL::SSL::SSLError => e + rescue *Mastodon::HTTP_CONNECTION_ERRORS => e fail_with! "Failed to fetch remote data: #{e.message}" rescue Mastodon::UnexpectedResponseError fail_with! 'Failed to fetch remote data (got unexpected reply from server)' @@ -135,13 +100,9 @@ module SignatureVerification end def signature_params - @signature_params ||= begin - raw_signature = request.headers['Signature'] - tree = SignatureParamsParser.new.parse(raw_signature) - SignatureParamsTransformer.new.apply(tree) - end - rescue Parslet::ParseFailed - raise SignatureVerificationError, 'Error parsing signature parameters' + @signature_params ||= SignatureParser.parse(request.headers['Signature']) + rescue SignatureParser::ParsingError + raise Mastodon::SignatureVerificationError, 'Error parsing signature parameters' end def signature_algorithm @@ -153,31 +114,31 @@ module SignatureVerification end def verify_signature_strength! - raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)') - raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest') - raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host') - raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest') + raise Mastodon::SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)') + raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest') + raise Mastodon::SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host') + raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest') end def verify_body_digest! return unless signed_headers.include?('digest') - raise SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest') + raise Mastodon::SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest') digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] } sha256 = digests.assoc('sha-256') - raise SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil? + raise Mastodon::SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil? return if body_digest == sha256[1] digest_size = begin Base64.strict_decode64(sha256[1].strip).length rescue ArgumentError - raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}" + raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}" end - raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32 + raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32 - raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}" + raise Mastodon::SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}" end def verify_signature(actor, signature, compare_signed_string) @@ -192,23 +153,23 @@ module SignatureVerification def build_signed_string(include_query_string: true) signed_headers.map do |signed_header| case signed_header - when Request::REQUEST_TARGET + when HttpSignatureDraft::REQUEST_TARGET if include_query_string - "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}" + "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}" else # Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header. # Therefore, temporarily support such incorrect signatures for compatibility. # TODO: remove eventually some time after release of the fixed version - "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" end when '(created)' - raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019' - raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank? + raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019' + raise Mastodon::SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank? "(created): #{signature_params['created']}" when '(expires)' - raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019' - raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank? + raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019' + raise Mastodon::SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank? "(expires): #{signature_params['expires']}" else @@ -230,7 +191,7 @@ module SignatureVerification expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present? rescue ArgumentError => e - raise SignatureVerificationError, "Invalid Date header: #{e.message}" + raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}" end expires_time ||= created_time + 5.minutes unless created_time.nil? @@ -263,24 +224,23 @@ module SignatureVerification end if key_id.start_with?('acct:') - stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) } + stoplight_wrapper.run { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) } elsif !ActivityPub::TagManager.instance.local_uri?(key_id) account = ActivityPub::TagManager.instance.uri_to_actor(key_id) - account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) } + account ||= stoplight_wrapper.run { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) } account end rescue Mastodon::PrivateNetworkAddressError => e - raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" + raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, ActivityPub::FetchRemoteKeyService::Error, Webfinger::Error => e - raise SignatureVerificationError, e.message + raise Mastodon::SignatureVerificationError, e.message end - def stoplight_wrap_request(&block) - Stoplight("source:#{request.remote_ip}", &block) + def stoplight_wrapper + Stoplight("source:#{request.remote_ip}") .with_threshold(1) .with_cool_off_time(5.minutes.seconds) .with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) } - .run end def actor_refresh_key!(actor) @@ -289,8 +249,8 @@ module SignatureVerification ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false) rescue Mastodon::PrivateNetworkAddressError => e - raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" + raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, Webfinger::Error => e - raise SignatureVerificationError, e.message + raise Mastodon::SignatureVerificationError, e.message end end diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb index 5687d6e5b6..ec2256aa9c 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -7,24 +7,45 @@ module WebAppControllerConcern vary_by 'Accept, Accept-Language, Cookie' before_action :redirect_unauthenticated_to_permalinks! - before_action :set_app_body_class + before_action :set_referer_header + + content_security_policy do |p| + policy = ContentSecurityPolicy.new + + if policy.sso_host.present? + p.form_action policy.sso_host, -> { "https://#{request.host}/auth/auth/" } + else + p.form_action :none + end + end end def skip_csrf_meta_tags? !(ENV['ONE_CLICK_SSO_LOGIN'] == 'true' && ENV['OMNIAUTH_ONLY'] == 'true' && Devise.omniauth_providers.length == 1) && current_user.nil? end - def set_app_body_class - @body_classes = 'app-body' - end - def redirect_unauthenticated_to_permalinks! return if user_signed_in? && current_account.moved_to_account_id.nil? - redirect_path = PermalinkRedirector.new(request.path).redirect_path - return if redirect_path.blank? + 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? - redirect_to(redirect_path) + + respond_to do |format| + format.html do + redirect_to(permalink_redirector.redirect_confirmation_path, allow_other_host: false) + end + + format.json do + redirect_to(permalink_redirector.redirect_uri, allow_other_host: true) + end + end + end + + protected + + def set_referer_header + response.set_header('Referrer-Policy', Setting.allow_referrer_origin ? 'strict-origin-when-cross-origin' : 'same-origin') end end diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb index e7a02ea89c..5b98914114 100644 --- a/app/controllers/custom_css_controller.rb +++ b/app/controllers/custom_css_controller.rb @@ -2,7 +2,14 @@ class CustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController def show - expires_in 3.minutes, public: true + expires_in 1.month, public: true render content_type: 'text/css' end + + private + + def custom_css_styles + Setting.custom_css + end + helper_method :custom_css_styles end diff --git a/app/controllers/disputes/appeals_controller.rb b/app/controllers/disputes/appeals_controller.rb index 98b58d2117..797f31cf78 100644 --- a/app/controllers/disputes/appeals_controller.rb +++ b/app/controllers/disputes/appeals_controller.rb @@ -21,6 +21,6 @@ class Disputes::AppealsController < Disputes::BaseController end def appeal_params - params.require(:appeal).permit(:text) + params.expect(appeal: [:text]) end end diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb index 1054f3db80..07677fd3f3 100644 --- a/app/controllers/disputes/base_controller.rb +++ b/app/controllers/disputes/base_controller.rb @@ -7,17 +7,5 @@ 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 end diff --git a/app/controllers/filters/statuses_controller.rb b/app/controllers/filters/statuses_controller.rb index 94993f938b..d85b017aaa 100644 --- a/app/controllers/filters/statuses_controller.rb +++ b/app/controllers/filters/statuses_controller.rb @@ -6,8 +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 @@ -35,18 +33,10 @@ class Filters::StatusesController < ApplicationController end def status_filter_batch_action_params - params.require(:form_status_filter_batch_action).permit(status_filter_ids: []) + params.expect(form_status_filter_batch_action: [status_filter_ids: []]) end def action_from_button '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 end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index e5c48e278c..20b8135908 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -5,8 +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 @filters = current_account.custom_filters.includes(:keywords, :statuses).order(:phrase) @@ -49,14 +47,6 @@ class FiltersController < ApplicationController end def resource_params - params.require(:custom_filter).permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, 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) + params.expect(custom_filter: [:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :exclude_quote, :exclude_profile, context: [], keywords_attributes: [[:id, :keyword, :whole_word, :_destroy]]]) end end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 44d90ec671..85f6ccc5e4 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -46,7 +46,7 @@ class FollowerAccountsController < ApplicationController end def page_url(page) - account_followers_url(@account, page: page) unless page.nil? + ActivityPub::TagManager.instance.followers_uri_for(@account, page: page) unless page.nil? end def next_page_url diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 2a22a05570..7bc424d0a4 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class HealthController < ActionController::Base +class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController def show render plain: 'OK' end diff --git a/app/controllers/instance_actors_controller.rb b/app/controllers/instance_actors_controller.rb index 8422d74bc3..f2b1eaa3e7 100644 --- a/app/controllers/instance_actors_controller.rb +++ b/app/controllers/instance_actors_controller.rb @@ -6,6 +6,8 @@ class InstanceActorsController < ActivityPub::BaseController serialization_scope nil before_action :set_account + + skip_before_action :authenticate_user! # From `AccountOwnedConcern` skip_before_action :require_functional! skip_before_action :update_user_sign_in @@ -16,6 +18,11 @@ class InstanceActorsController < ActivityPub::BaseController private + # Skips various `before_action` from `AccountOwnedConcern` + def account_required? + false + end + def set_account @account = Account.representative end diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb index ea024e30e6..65c315208d 100644 --- a/app/controllers/intents_controller.rb +++ b/app/controllers/intents_controller.rb @@ -1,27 +1,26 @@ # frozen_string_literal: true class IntentsController < ApplicationController - before_action :check_uri + EXPECTED_SCHEME = 'web+mastodon' + before_action :handle_invalid_uri, unless: :valid_uri? rescue_from Addressable::URI::InvalidURIError, with: :handle_invalid_uri def show - if uri.scheme == 'web+mastodon' - case uri.host - when 'follow' - return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:')) - when 'share' - return redirect_to share_path(text: uri.query_values['text']) - end + case uri.host + when 'follow' + redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:')) + when 'share' + redirect_to share_path(text: uri.query_values['text']) + else + handle_invalid_uri end - - not_found end private - def check_uri - not_found if uri.blank? + def valid_uri? + uri.present? && uri.scheme == EXPECTED_SCHEME end def handle_invalid_uri diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 9bc5164d59..fc65333ac4 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -6,8 +6,6 @@ class InvitesController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_body_classes - before_action :set_cache_headers def index authorize :invite, :create? @@ -44,14 +42,6 @@ class InvitesController < ApplicationController end def resource_params - 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) + params.expect(invite: [:max_uses, :expires_in, :autofollow, :comment]) end end diff --git a/app/controllers/mail_subscriptions_controller.rb b/app/controllers/mail_subscriptions_controller.rb index 1caeaaacf4..34df75f63a 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 53eee40012..9d10468e69 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/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index c4230d62c3..f68d85e44e 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -13,7 +13,7 @@ class MediaProxyController < ApplicationController rescue_from ActiveRecord::RecordInvalid, with: :not_found rescue_from Mastodon::UnexpectedResponseError, with: :not_found rescue_from Mastodon::NotPermittedError, with: :not_found - rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) def show with_redis_lock("media_download:#{params[:id]}") do diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 66e774425d..deafedeaef 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -5,7 +5,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController before_action :store_current_location before_action :authenticate_resource_owner! - before_action :set_cache_headers content_security_policy do |p| p.form_action(false) @@ -32,8 +31,4 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController def truthy_param?(key) ActiveModel::Type::Boolean.new.cast(params[key]) end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end end diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 8440df6b7e..8b11a519ea 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -6,8 +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 +15,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 @@ -34,17 +29,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio forbidden if current_account.unavailable? end - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end - def set_last_used_at_by_app - @last_used_at_by_app = Doorkeeper::AccessToken - .select('DISTINCT ON (application_id) application_id, last_used_at') - .where(resource_owner_id: current_resource_owner.id) - .where.not(last_used_at: nil) - .order(application_id: :desc, last_used_at: :desc) - .pluck(:application_id, :last_used_at) - .to_h + @last_used_at_by_app = current_resource_owner.applications_last_used end end diff --git a/app/controllers/oauth/userinfo_controller.rb b/app/controllers/oauth/userinfo_controller.rb new file mode 100644 index 0000000000..e268b70dcc --- /dev/null +++ b/app/controllers/oauth/userinfo_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Oauth::UserinfoController < Api::BaseController + before_action -> { doorkeeper_authorize! :profile }, only: [:show] + before_action :require_user! + + def show + @account = current_account + render json: @account, serializer: OauthUserinfoSerializer + end +end diff --git a/app/controllers/redirect/accounts_controller.rb b/app/controllers/redirect/accounts_controller.rb new file mode 100644 index 0000000000..713ccf2ca1 --- /dev/null +++ b/app/controllers/redirect/accounts_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Redirect::AccountsController < Redirect::BaseController + private + + def set_resource + @resource = Account.find(params[:id]) + not_found if @resource.local? + end +end diff --git a/app/controllers/redirect/base_controller.rb b/app/controllers/redirect/base_controller.rb new file mode 100644 index 0000000000..34558a4126 --- /dev/null +++ b/app/controllers/redirect/base_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Redirect::BaseController < ApplicationController + vary_by 'Accept-Language' + + before_action :set_resource + + def show + @redirect_path = ActivityPub::TagManager.instance.url_for(@resource) + + render 'redirects/show', layout: 'application' + end + + private + + def set_resource + raise NotImplementedError + end +end diff --git a/app/controllers/redirect/statuses_controller.rb b/app/controllers/redirect/statuses_controller.rb new file mode 100644 index 0000000000..37a938c651 --- /dev/null +++ b/app/controllers/redirect/statuses_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Redirect::StatusesController < Redirect::BaseController + private + + def set_resource + @resource = Status.find(params[:id]) + not_found if @resource.local? || !@resource.distributable? + end +end diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index dd794f3199..7e793fc734 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -6,8 +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? @@ -37,7 +35,7 @@ class RelationshipsController < ApplicationController end def form_account_batch_params - params.require(:form_account_batch).permit(:action, account_ids: []) + params.expect(form_account_batch: [:action, account_ids: []]) end def following_relationship? @@ -67,12 +65,4 @@ class RelationshipsController < ApplicationController 'remove_domains_from_followers' end end - - def set_body_classes - @body_classes = 'admin' - end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end end diff --git a/app/controllers/settings/aliases_controller.rb b/app/controllers/settings/aliases_controller.rb index a421b8ede3..c21d43eeb3 100644 --- a/app/controllers/settings/aliases_controller.rb +++ b/app/controllers/settings/aliases_controller.rb @@ -30,7 +30,7 @@ class Settings::AliasesController < Settings::BaseController private def resource_params - params.require(:account_alias).permit(:acct) + params.expect(account_alias: [:acct]) end def set_alias diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb index d4b7205681..8e39741f89 100644 --- a/app/controllers/settings/applications_controller.rb +++ b/app/controllers/settings/applications_controller.rb @@ -2,7 +2,6 @@ class Settings::ApplicationsController < Settings::BaseController before_action :set_application, only: [:show, :update, :destroy, :regenerate] - before_action :prepare_scopes, only: [:create, :update] def index @applications = current_user.applications.order(id: :desc).page(params[:page]) @@ -13,7 +12,7 @@ class Settings::ApplicationsController < Settings::BaseController def new @application = Doorkeeper::Application.new( redirect_uri: Doorkeeper.configuration.native_redirect_uri, - scopes: 'read write follow' + scopes: 'profile' ) end @@ -60,16 +59,6 @@ class Settings::ApplicationsController < Settings::BaseController end def application_params - params.require(:doorkeeper_application).permit( - :name, - :redirect_uri, - :scopes, - :website - ) - end - - def prepare_scopes - scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil) - params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array + params.expect(doorkeeper_application: [:name, :redirect_uri, :website, scopes: []]) end end diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index f15140aa2b..7f2279aa8f 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -4,19 +4,9 @@ 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 - def require_not_suspended! forbidden if current_account.unavailable? end diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index 16c201b6b3..815d95ad83 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -21,7 +21,7 @@ class Settings::DeletesController < Settings::BaseController private def resource_params - params.require(:form_delete_confirmation).permit(:password, :username) + params.expect(form_delete_confirmation: [:password, :username]) end def require_not_suspended! diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb index 076ed5dadb..263d20eaea 100644 --- a/app/controllers/settings/exports_controller.rb +++ b/app/controllers/settings/exports_controller.rb @@ -9,7 +9,7 @@ class Settings::ExportsController < Settings::BaseController skip_before_action :require_functional! def show - @export = Export.new(current_account) + @export_summary = ExportSummary.new(preloaded_account) @backups = current_user.backups end @@ -25,4 +25,15 @@ class Settings::ExportsController < Settings::BaseController redirect_to settings_export_path end + + private + + def preloaded_account + current_account.tap do |account| + ActiveRecord::Associations::Preloader.new( + records: [account], + associations: :account_stat + ).call + end + end end diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index c384402650..0f352e1913 100644 --- a/app/controllers/settings/featured_tags_controller.rb +++ b/app/controllers/settings/featured_tags_controller.rb @@ -5,6 +5,8 @@ class Settings::FeaturedTagsController < Settings::BaseController before_action :set_featured_tag, except: [:index, :create] before_action :set_recently_used_tags, only: :index + RECENT_TAGS_LIMIT = 10 + def index @featured_tag = FeaturedTag.new end @@ -38,10 +40,10 @@ class Settings::FeaturedTagsController < Settings::BaseController end def set_recently_used_tags - @recently_used_tags = Tag.recently_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10) + @recently_used_tags = Tag.suggestions_for_account(current_account).limit(RECENT_TAGS_LIMIT) end def featured_tag_params - params.require(:featured_tag).permit(:name) + params.expect(featured_tag: [:name]) end end diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb index 983caf22fa..be1699315f 100644 --- a/app/controllers/settings/imports_controller.rb +++ b/app/controllers/settings/imports_controller.rb @@ -24,6 +24,8 @@ class Settings::ImportsController < Settings::BaseController lists: false, }.freeze + RECENT_IMPORTS_LIMIT = 10 + def index @import = Form::Import.new(current_account: current_account) end @@ -31,7 +33,7 @@ class Settings::ImportsController < Settings::BaseController def show; end def failures - @bulk_import = current_account.bulk_imports.where(state: :finished).find(params[:id]) + @bulk_import = current_account.bulk_imports.state_finished.find(params[:id]) respond_to do |format| format.csv do @@ -88,14 +90,14 @@ class Settings::ImportsController < Settings::BaseController private def import_params - params.require(:form_import).permit(:data, :type, :mode) + params.expect(form_import: [:data, :type, :mode]) end def set_bulk_import - @bulk_import = current_account.bulk_imports.where(state: :unconfirmed).find(params[:id]) + @bulk_import = current_account.bulk_imports.state_unconfirmed.find(params[:id]) end def set_recent_imports - @recent_imports = current_account.bulk_imports.reorder(id: :desc).limit(10) + @recent_imports = current_account.bulk_imports.reorder(id: :desc).limit(RECENT_IMPORTS_LIMIT) end end diff --git a/app/controllers/settings/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb index 6d469f3842..d850e05e94 100644 --- a/app/controllers/settings/migration/redirects_controller.rb +++ b/app/controllers/settings/migration/redirects_controller.rb @@ -33,6 +33,6 @@ class Settings::Migration::RedirectsController < Settings::BaseController private def resource_params - params.require(:form_redirect).permit(:acct, :current_password, :current_username) + params.expect(form_redirect: [:acct, :current_password, :current_username]) end end diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb index 62603aba81..92e3611fd9 100644 --- a/app/controllers/settings/migrations_controller.rb +++ b/app/controllers/settings/migrations_controller.rb @@ -27,7 +27,7 @@ class Settings::MigrationsController < Settings::BaseController private def resource_params - params.require(:account_migration).permit(:acct, :current_password, :current_username) + params.expect(account_migration: [:acct, :current_password, :current_username]) end def set_migrations diff --git a/app/controllers/settings/pictures_controller.rb b/app/controllers/settings/pictures_controller.rb index 58a4325307..7e61e6d580 100644 --- a/app/controllers/settings/pictures_controller.rb +++ b/app/controllers/settings/pictures_controller.rb @@ -8,7 +8,7 @@ module Settings def destroy if valid_picture? if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' }) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg'), status: 303 else redirect_to settings_profile_path diff --git a/app/controllers/settings/preferences/base_controller.rb b/app/controllers/settings/preferences/base_controller.rb index c1f8b49898..c2e705da3c 100644 --- a/app/controllers/settings/preferences/base_controller.rb +++ b/app/controllers/settings/preferences/base_controller.rb @@ -19,6 +19,16 @@ class Settings::Preferences::BaseController < Settings::BaseController end def user_params - params.require(:user).permit(:locale, :time_zone, chosen_languages: [], settings_attributes: UserSettings.keys) + original_user_params.tap do |params| + params[:settings_attributes]&.merge!(disabled_visibilities_params[:settings_attributes] || {}) + end + end + + def original_user_params + params.expect(user: [:locale, :time_zone, :custom_css_text, chosen_languages: [], settings_attributes: UserSettings.keys]) + end + + def disabled_visibilities_params + params.expect(user: [settings_attributes: { enabled_visibilities: [] }]) end end diff --git a/app/controllers/settings/preferences/custom_css_controller.rb b/app/controllers/settings/preferences/custom_css_controller.rb new file mode 100644 index 0000000000..6a7369ec49 --- /dev/null +++ b/app/controllers/settings/preferences/custom_css_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Settings::Preferences::CustomCssController < Settings::Preferences::BaseController + private + + def after_update_redirect_path + settings_preferences_custom_css_path + end +end diff --git a/app/controllers/settings/privacy_controller.rb b/app/controllers/settings/privacy_controller.rb index 1102c89fad..96efa03ccf 100644 --- a/app/controllers/settings/privacy_controller.rb +++ b/app/controllers/settings/privacy_controller.rb @@ -8,7 +8,7 @@ class Settings::PrivacyController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params.except(:settings)) current_user.update!(settings_attributes: account_params[:settings]) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_privacy_path, notice: I18n.t('generic.changes_saved_msg') else render :show @@ -18,7 +18,7 @@ class Settings::PrivacyController < Settings::BaseController private def account_params - params.require(:account).permit(:discoverable, :unlocked, :indexable, :show_collections, settings: UserSettings.keys) + params.expect(account: [:discoverable, :unlocked, :indexable, :show_collections, settings: UserSettings.keys]) end def set_account diff --git a/app/controllers/settings/privacy_extra_controller.rb b/app/controllers/settings/privacy_extra_controller.rb index 49c71d5071..f1292e644c 100644 --- a/app/controllers/settings/privacy_extra_controller.rb +++ b/app/controllers/settings/privacy_extra_controller.rb @@ -18,7 +18,7 @@ class Settings::PrivacyExtraController < Settings::BaseController private def account_params - params.require(:account).permit(:subscription_policy, settings: UserSettings.keys) + params.expect(account: [settings: UserSettings.keys]) end def set_account diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index c33567956c..04a10fbfb9 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -9,7 +9,7 @@ class Settings::ProfilesController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') else @account.build_fields @@ -20,8 +20,7 @@ class Settings::ProfilesController < Settings::BaseController private def account_params - # params.require(:account).permit(:display_name, :note, :bio_markdown, :avatar, :header, :locked, :my_actor_type, :searchability, :dissubscribable, :group_allow_private_message, :discoverable, :discoverable_local, :hide_collections, fields_attributes: [:name, :value]) - params.require(:account).permit(:display_name, :note, :bio_markdown, :avatar, :header, :bot, :my_actor_type, :group_allow_private_message, :dissubscribable, fields_attributes: [:name, :value]) + params.expect(account: [:display_name, :note, :bio_markdown, :avatar, :header, :bot, :my_actor_type, fields_attributes: [[:name, :value]]]) end def set_account diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index 1a0afe58b0..eae990e79b 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -38,7 +38,7 @@ module Settings private def confirmation_params - params.require(:form_two_factor_confirmation).permit(:otp_attempt) + params.expect(form_two_factor_confirmation: [:otp_attempt]) end def prepare_two_factor_form diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb index 0bff01ec27..ca8d46afe4 100644 --- a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb +++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb @@ -15,7 +15,7 @@ module Settings end def create - session[:new_otp_secret] = User.generate_otp_secret(32) + session[:new_otp_secret] = User.generate_otp_secret redirect_to new_settings_two_factor_authentication_confirmation_path end diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb index c86ede4f3a..9714d54f95 100644 --- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb +++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb @@ -6,8 +6,8 @@ module Settings skip_before_action :check_self_destruct! skip_before_action :require_functional! - before_action :require_otp_enabled - before_action :require_webauthn_enabled, only: [:index, :destroy] + before_action :redirect_invalid_otp, unless: -> { current_user.otp_enabled? } + before_action :redirect_invalid_webauthn, only: [:index, :destroy], unless: -> { current_user.webauthn_enabled? } def index; end def new; end @@ -85,18 +85,14 @@ module Settings private - def require_otp_enabled - unless current_user.otp_enabled? - flash[:error] = t('webauthn_credentials.otp_required') - redirect_to settings_two_factor_authentication_methods_path - end + def redirect_invalid_otp + flash[:error] = t('webauthn_credentials.otp_required') + redirect_to settings_two_factor_authentication_methods_path end - def require_webauthn_enabled - unless current_user.webauthn_enabled? - flash[:error] = t('webauthn_credentials.not_enabled') - redirect_to settings_two_factor_authentication_methods_path - end + def redirect_invalid_webauthn + flash[:error] = t('webauthn_credentials.not_enabled') + redirect_to settings_two_factor_authentication_methods_path end end end diff --git a/app/controllers/settings/verifications_controller.rb b/app/controllers/settings/verifications_controller.rb index fc4f23bb18..4b949ca72d 100644 --- a/app/controllers/settings/verifications_controller.rb +++ b/app/controllers/settings/verifications_controller.rb @@ -2,14 +2,32 @@ 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_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) + redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end end private + def account_params + params.expect(account: [:attribution_domains]).tap do |params| + params[:attribution_domains] = params[:attribution_domains].split if params[:attribution_domains] + end + 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 new file mode 100644 index 0000000000..817abebf62 --- /dev/null +++ b/app/controllers/severed_relationships_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class SeveredRelationshipsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + + before_action :set_event, only: [:following, :followers] + + def index + @events = AccountRelationshipSeveranceEvent.where(account: current_account) + end + + def following + respond_to do |format| + format.csv { send_data following_data, filename: "following-#{@event.target_name}-#{@event.created_at.to_date.iso8601}.csv" } + end + end + + def followers + respond_to do |format| + format.csv { send_data followers_data, filename: "followers-#{@event.target_name}-#{@event.created_at.to_date.iso8601}.csv" } + end + end + + private + + def set_event + @event = AccountRelationshipSeveranceEvent.find(params[:id]) + end + + def following_data + CSV.generate(headers: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'], write_headers: true) do |csv| + @event.severed_relationships.active.about_local_account(current_account).includes(:remote_account).reorder(id: :desc).each do |follow| + csv << [acct(follow.target_account), follow.show_reblogs, follow.notify, follow.languages&.join(', ')] + end + end + end + + def followers_data + CSV.generate(headers: ['Account address'], write_headers: true) do |csv| + @event.severed_relationships.passive.about_local_account(current_account).includes(:remote_account).reorder(id: :desc).each do |follow| + csv << [acct(follow.account)] + end + end + end + + def acct(account) + account.local? ? account.local_username_and_domain : account.acct + end +end diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index 6546b84978..1aa0ce5a0d 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 04c3c0e05c..a25e544392 100644 --- a/app/controllers/statuses_cleanup_controller.rb +++ b/app/controllers/statuses_cleanup_controller.rb @@ -5,8 +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 @@ -16,8 +14,6 @@ class StatusesCleanupController < ApplicationController else render :show end - rescue ActionController::ParameterMissing - # Do nothing end def require_functional! @@ -31,14 +27,6 @@ class StatusesCleanupController < ApplicationController end def resource_params - 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, :keep_self_emoji, :min_favs, :min_reblogs, :min_emojis) - end - - def set_body_classes - @body_classes = 'admin' - end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) + params.expect(account_statuses_cleanup_policy: [:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :keep_self_emoji, :min_favs, :min_reblogs, :min_emojis]) end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 1e7a8f406c..4779f79c5a 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 @@ -78,14 +75,10 @@ class StatusesController < ApplicationController @misskey_software = false - return false if !@status.local? || signed_request_account&.domain.blank? + return false if !@status.local? || signed_request_account&.domain.blank? || !@status.sending_maybe_compromised_privacy? + return @misskey_software = true if DomainBlock.detect_invalid_subscription?(signed_request_account.domain) - info = InstanceInfo.find_by(domain: signed_request_account.domain) - return false if info.nil? - - @misskey_software = %w(misskey calckey cherrypick sharkey).include?(info.software) && - ((@status.public_unlisted_visibility? && @status.account.user&.setting_reject_public_unlisted_subscription) || - (@status.unlisted_visibility? && @status.account.user&.setting_reject_unlisted_subscription)) + @misskey_software = InstanceInfo.invalid_subscription_software?(signed_request_account.domain) end def status_activity_serializer diff --git a/app/controllers/system_css_controller.rb b/app/controllers/system_css_controller.rb new file mode 100644 index 0000000000..dd90491894 --- /dev/null +++ b/app/controllers/system_css_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class SystemCssController < ActionController::Base # rubocop:disable Rails/ApplicationController + def show + expires_in 3.minutes, public: true + render content_type: 'text/css' + end +end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index b0bdbde956..d6c0d872c8 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -45,7 +45,7 @@ class TagsController < ApplicationController end def set_statuses - @statuses = cache_collection(TagFeed.new(@tag, nil, local: @local).get(limit_param), Status) + @statuses = preload_collection(TagFeed.new(@tag, nil, local: @local).get(limit_param), Status) end def limit_param diff --git a/app/controllers/terms_of_service_controller.rb b/app/controllers/terms_of_service_controller.rb new file mode 100644 index 0000000000..672fb07915 --- /dev/null +++ b/app/controllers/terms_of_service_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class TermsOfServiceController < ApplicationController + include WebAppControllerConcern + + skip_before_action :require_functional! + + def show + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? + end +end diff --git a/app/controllers/user_custom_css_controller.rb b/app/controllers/user_custom_css_controller.rb new file mode 100644 index 0000000000..2535e07c03 --- /dev/null +++ b/app/controllers/user_custom_css_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class UserCustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController + before_action :authenticate_user! + + def show + render content_type: 'text/css' + end + + private + + def user_custom_css + current_user.custom_css_text + end + helper_method :user_custom_css +end diff --git a/app/controllers/well_known/host_meta_controller.rb b/app/controllers/well_known/host_meta_controller.rb index 201da9fbc3..6dee587baf 100644 --- a/app/controllers/well_known/host_meta_controller.rb +++ b/app/controllers/well_known/host_meta_controller.rb @@ -7,7 +7,23 @@ module WellKnown def show @webfinger_template = "#{webfinger_url}?resource={uri}" expires_in 3.days, public: true - render content_type: 'application/xrd+xml', formats: [:xml] + + respond_to do |format| + format.any do + render content_type: 'application/xrd+xml', formats: [:xml] + end + + format.json do + render json: { + links: [ + { + rel: 'lrdd', + template: @webfinger_template, + }, + ], + } + end + end end end end diff --git a/app/controllers/well_known/oauth_metadata_controller.rb b/app/controllers/well_known/oauth_metadata_controller.rb new file mode 100644 index 0000000000..c80be2d652 --- /dev/null +++ b/app/controllers/well_known/oauth_metadata_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module WellKnown + class OauthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController + include CacheConcern + + # Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user` + # and thus re-issuing session cookies + serialization_scope nil + + def show + # Due to this document potentially changing between Mastodon versions (as + # new OAuth scopes are added), we don't use expires_in to cache upstream, + # instead just caching in the rails cache: + render_with_cache( + json: ::OauthMetadataPresenter.new, + serializer: ::OauthMetadataSerializer, + content_type: 'application/json', + expires_in: 15.minutes + ) + end + end +end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index ce7e5455fd..bdb6ca979c 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -19,28 +19,24 @@ 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 + def account_formatted_stat(value) + number_to_human(value, precision: 3, strip_insignificant_zeros: true) end def account_description(account) prepend_str = [ [ - number_to_human(account.public_statuses_count, precision: 3, strip_insignificant_zeros: true), + account_formatted_stat(account.public_statuses_count), I18n.t('accounts.posts', count: account.public_statuses_count), ].join(' '), [ - number_to_human(account.public_following_count, precision: 3, strip_insignificant_zeros: true), + account_formatted_stat(account.public_following_count), I18n.t('accounts.following', count: account.public_following_count), ].join(' '), [ - number_to_human(account.public_followers_count, precision: 3, strip_insignificant_zeros: true), + account_formatted_stat(account.public_followers_count), I18n.t('accounts.followers', count: account.public_followers_count), ].join(' '), ].join(', ') diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb index 3b9d580499..7c931c1157 100644 --- a/app/helpers/admin/account_moderation_notes_helper.rb +++ b/app/helpers/admin/account_moderation_notes_helper.rb @@ -4,27 +4,42 @@ module Admin::AccountModerationNotesHelper def admin_account_link_to(account, path: nil) return if account.nil? - link_to path || admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do - safe_join([ - image_tag(account.avatar.url, width: 15, height: 15, alt: '', class: 'avatar'), - content_tag(:span, account.acct, class: 'username'), - ], ' ') - end + link_to( + labeled_account_avatar(account), + path || admin_account_path(account.id), + class: class_names('name-tag', suspended: suspended_account?(account)), + title: account.acct + ) end - def admin_account_inline_link_to(account) + def admin_account_inline_link_to(account, path: nil) return if account.nil? - link_to admin_account_path(account.id), class: name_tag_classes(account, true), title: account.acct do - content_tag(:span, account.acct, class: 'username') - end + link_to( + account_inline_text(account), + path || admin_account_path(account.id), + class: class_names('inline-name-tag', suspended: suspended_account?(account)), + title: account.acct + ) end private - def name_tag_classes(account, inline = false) - classes = [inline ? 'inline-name-tag' : 'name-tag'] - classes << 'suspended' if account.suspended? || (account.local? && account.user.nil?) - classes.join(' ') + def labeled_account_avatar(account) + safe_join( + [ + image_tag(account.avatar.url, width: 15, height: 15, alt: '', class: 'avatar'), + account_inline_text(account), + ], + ' ' + ) + end + + def account_inline_text(account) + content_tag(:span, account.acct, class: 'username') + end + + def suspended_account?(account) + account.suspended? || (account.local? && account.user.nil?) end end diff --git a/app/helpers/admin/accounts_helper.rb b/app/helpers/admin/accounts_helper.rb index a936797e88..a2d2f75308 100644 --- a/app/helpers/admin/accounts_helper.rb +++ b/app/helpers/admin/accounts_helper.rb @@ -7,6 +7,7 @@ module Admin::AccountsHelper [t('admin.accounts.moderation.silenced'), 'silenced'], [t('admin.accounts.moderation.disabled'), 'disabled'], [t('admin.accounts.moderation.suspended'), 'suspended'], + [t('admin.accounts.moderation.remote_pending'), 'remote_pending'], [safe_join([t('admin.accounts.moderation.pending'), "(#{pending_user_count_label})"], ' '), 'pending'], ] end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 4018ef6b1c..07381cc6ad 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -15,15 +15,17 @@ module Admin::ActionLogsHelper link_to log.human_identifier, admin_roles_path(log.target_id) when 'Report' link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) - when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain' - link_to log.human_identifier, "https://#{log.human_identifier.presence}" + when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' + log.human_identifier.present? ? link_to(log.human_identifier, admin_instance_path(log.human_identifier)) : I18n.t('admin.action_logs.unavailable_instance') when 'Status' link_to log.human_identifier, log.permalink when 'AccountWarning' link_to log.human_identifier, disputes_strike_path(log.target_id) when 'Announcement' link_to truncate(log.human_identifier), edit_admin_announcement_path(log.target_id) - when 'IpBlock', 'Instance', 'CustomEmoji' + when 'CustomEmoji' + link_to log.human_identifier, edit_admin_custom_emoji_path(log.target_id) + when 'IpBlock', 'EmailDomainBlock' log.human_identifier when 'CanonicalEmailBlock' content_tag(:samp, (log.human_identifier.presence || '')[0...7], title: log.human_identifier) @@ -33,6 +35,15 @@ module Admin::ActionLogsHelper else I18n.t('admin.action_logs.deleted_account') end + when 'Relay' + link_to log.human_identifier, admin_relays_path end end + + def sorted_action_log_types + Admin::ActionLogFilter::ACTION_TYPE_MAP + .keys + .map { |key| [I18n.t("admin.action_logs.action_types.#{key}"), key] } + .sort_by(&:first) + end end diff --git a/app/helpers/admin/dashboard_helper.rb b/app/helpers/admin/dashboard_helper.rb index 6096ff1381..f87fdad708 100644 --- a/app/helpers/admin/dashboard_helper.rb +++ b/app/helpers/admin/dashboard_helper.rb @@ -18,6 +18,11 @@ module Admin::DashboardHelper end end + def date_range(range) + [l(range.first), l(range.last)] + .join(' - ') + end + def relevant_account_timestamp(account) timestamp, exact = if account.user_current_sign_in_at && account.user_current_sign_in_at < 24.hours.ago [account.user_current_sign_in_at, true] @@ -25,6 +30,8 @@ module Admin::DashboardHelper [account.user_current_sign_in_at, false] elsif account.user_pending? [account.user_created_at, true] + elsif account.suspended_at.present? && account.local? && account.user.nil? + [account.suspended_at, true] elsif account.last_status_at.present? [account.last_status_at, true] else diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 140fc73ede..40806a4515 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/settings_helper.rb b/app/helpers/admin/settings_helper.rb index 552a3ee5a8..9b950d5a63 100644 --- a/app/helpers/admin/settings_helper.rb +++ b/app/helpers/admin/settings_helper.rb @@ -2,6 +2,62 @@ module Admin::SettingsHelper def captcha_available? - ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? + Rails.configuration.x.captcha.secret_key.present? && Rails.configuration.x.captcha.site_key.present? + end + + def login_activity_title(activity) + t( + "login_activities.#{login_activity_key(activity)}", + method: login_activity_method(activity), + ip: login_activity_ip(activity), + browser: login_activity_browser(activity) + ) + end + + private + + def login_activity_key(activity) + activity.success? ? 'successful_sign_in_html' : 'failed_sign_in_html' + end + + def login_activity_method(activity) + content_tag( + :span, + login_activity_method_string(activity), + class: 'target' + ) + end + + def login_activity_ip(activity) + content_tag( + :span, + activity.ip, + class: 'target' + ) + end + + def login_activity_browser(activity) + content_tag( + :span, + login_activity_browser_description(activity), + class: 'target', + title: activity.user_agent + ) + end + + def login_activity_method_string(activity) + if activity.omniauth? + t("auth.providers.#{activity.provider}") + else + t("login_activities.authentication_methods.#{activity.authentication_method}") + end + end + + def login_activity_browser_description(activity) + t( + 'sessions.description', + browser: t(activity.browser, scope: 'sessions.browsers', default: activity.browser.to_s), + platform: t(activity.platform, scope: 'sessions.platforms', default: activity.platform.to_s) + ) end end diff --git a/app/helpers/admin/tags_helper.rb b/app/helpers/admin/tags_helper.rb new file mode 100644 index 0000000000..eb928a6db2 --- /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 79fee44dc4..33da1f7216 100644 --- a/app/helpers/admin/trends/statuses_helper.rb +++ b/app/helpers/admin/trends/statuses_helper.rb @@ -2,11 +2,18 @@ module Admin::Trends::StatusesHelper def one_line_preview(status) - text = if status.local? - status.text.split("\n").first - else - Nokogiri::HTML(status.text).css('html > body > *').first&.text - end + text = begin + if status.local? + status.text.split("\n").first + else + Nokogiri::HTML5(status.text).css('html > body > *').first&.text + end + rescue ArgumentError + # This can happen if one of the Nokogumbo limits is encountered + # Unfortunately, it does not use a more precise error class + # nor allows more graceful handling + '' + end return '' if text.blank? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2a189ea602..0d7d0e8117 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,11 +1,7 @@ # frozen_string_literal: true module ApplicationHelper - DANGEROUS_SCOPES = %w( - read - write - follow - ).freeze + include RegistrationLimitationHelper RTL_LOCALES = %i( ar @@ -28,20 +24,12 @@ module ApplicationHelper number_to_human(number, **options) end - def active_nav_class(*paths) - paths.any? { |path| current_page?(path) } ? 'active' : '' - end - - def show_landing_strip? - !user_signed_in? && !single_user_mode? - end - def open_registrations? - Setting.registrations_mode == 'open' + Setting.registrations_mode == 'open' && registrations_in_time? end def approved_registrations? - Setting.registrations_mode == 'approved' + Setting.registrations_mode == 'approved' || (Setting.registrations_mode == 'open' && !registrations_in_time?) end def closed_registrations? @@ -93,8 +81,8 @@ module ApplicationHelper def html_title safe_join( - [content_for(:page_title).to_s.chomp, title] - .select(&:present?), + [content_for(:page_title), title] + .compact_blank, ' - ' ) end @@ -103,8 +91,11 @@ module ApplicationHelper Rails.env.production? ? site_title : "#{site_title} (Dev)" end - def class_for_scope(scope) - 'scope-danger' if DANGEROUS_SCOPES.include?(scope.to_s) + def label_for_scope(scope) + safe_join [ + tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }), + tag.span(t("doorkeeper.scopes.#{scope}"), class: :hint), + ] end def can?(action, record) @@ -113,41 +104,31 @@ 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(' '))) + def material_symbol(icon, attributes = {}) + 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 def check_icon - content_tag(:svg, tag.path('fill-rule': 'evenodd', 'clip-rule': 'evenodd', d: 'M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'), xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 20 20', fill: 'currentColor') - end - - def visibility_icon(status) - if status.public_visibility? - fa_icon('globe', title: I18n.t('statuses.visibilities.public')) - elsif status.unlisted_visibility? - fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted')) - elsif status.public_unlisted_visibility? - fa_icon('cloud', title: I18n.t('statuses.visibilities.public_unlisted')) - elsif status.login_visibility? - fa_icon('key', title: I18n.t('statuses.visibilities.login')) - elsif status.private_visibility? || status.limited_visibility? - fa_icon('lock', title: I18n.t('statuses.visibilities.private')) - elsif status.direct_visibility? - fa_icon('at', title: I18n.t('statuses.visibilities.direct')) - end + inline_svg_tag 'check.svg' 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 @@ -164,11 +145,14 @@ module ApplicationHelper end def body_classes - output = body_class_string.split + output = [] + output << content_for(:body_classes) output << "theme-#{current_theme.parameterize}" output << 'system-font' if current_account&.user&.setting_system_font_ui + output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion') output << 'rtl' if locale_direction == 'rtl' + output << "content-font-size__#{current_account&.user&.setting_content_font_size}" output.compact_blank.join(' ') end @@ -221,7 +205,7 @@ module ApplicationHelper state_params[:moved_to_account] = current_account.moved_to_account end - state_params[:owner] = Account.local.without_suspended.where('id > 0').first if single_user_mode? + state_params[:owner] = Account.local.without_suspended.without_internal.first if single_user_mode? json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json # rubocop:disable Rails/OutputSafety @@ -248,10 +232,43 @@ module ApplicationHelper EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s end - def prerender_custom_emojis_from_hash(html, custom_emojis_hash) - # rubocop:disable Style/OpenStructUse - prerender_custom_emojis(html, JSON.parse([custom_emojis_hash].to_json, object_class: OpenStruct)) - # rubocop:enable Style/OpenStructUse + def mascot_url + full_asset_url(instance_presenter.mascot&.file&.url || frontend_asset_path('images/elephant_ui_plane.svg')) + end + + def server_css? + return true if current_account&.user.nil? + + current_account.user.setting_use_server_css + end + + def user_custom_css? + return false if current_account&.user.nil? + + current_account.user.setting_use_custom_css && current_account.user.custom_css_text.present? + end + + def user_custom_css_version + return '0' if current_account&.user&.custom_css.nil? + + current_account&.user&.custom_css&.updated_at.to_s + end + + def copyable_input(options = {}) + tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options) + end + + def recent_tag_usage(tag) + people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts + I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people + end + + def app_store_url_ios + 'https://apps.apple.com/app/mastodon-for-iphone-and-ipad/id1571998974' + end + + def app_store_url_android + 'https://play.google.com/store/apps/details?id=org.joinmastodon.android' end private diff --git a/app/helpers/branding_helper.rb b/app/helpers/branding_helper.rb index 2b9c233c23..8201f36e3c 100644 --- a/app/helpers/branding_helper.rb +++ b/app/helpers/branding_helper.rb @@ -19,17 +19,6 @@ module BrandingHelper end def render_logo - image_pack_tag('logo.svg', alt: 'Mastodon', class: 'logo logo--icon') - end - - def render_symbol(version = :icon) - path = case version - when :icon - 'logo-symbol-icon.svg' - when :wordmark - 'logo-symbol-wordmark.svg' - end - - render(file: Rails.root.join('app', 'javascript', 'images', path)).html_safe # rubocop:disable Rails/OutputSafety + image_tag(frontend_asset_path('images/logo.svg'), alt: 'Mastodon', class: 'logo logo--icon') end end diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index ab0d44ca69..077c5272a5 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -32,15 +32,9 @@ module ContextHelper quote_uri: { 'fedibird' => 'http://fedibird.com/ns#', 'quoteUri' => 'fedibird:quoteUri' }, keywords: { 'schema' => 'http://schema.org#', 'keywords' => 'schema:keywords' }, license: { 'schema' => 'http://schema.org#', 'license' => 'schema:license' }, - 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' } }, + misskey_license: { 'misskey' => 'https://misskey-hub.net/ns#', '_misskey_license' => 'misskey:_misskey_license' }, }.freeze def full_context @@ -48,13 +42,11 @@ module ContextHelper end def serialized_context(named_contexts_map, context_extensions_map) - context_array = [] - named_contexts = named_contexts_map.keys context_extensions = context_extensions_map.keys - named_contexts.each do |key| - context_array << NAMED_CONTEXT_MAP[key] + context_array = named_contexts.map do |key| + NAMED_CONTEXT_MAP[key] end extensions = context_extensions.each_with_object({}) do |key, h| diff --git a/app/helpers/follow_helper.rb b/app/helpers/follow_helper.rb new file mode 100644 index 0000000000..a02e2a8832 --- /dev/null +++ b/app/helpers/follow_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module FollowHelper + def request_pending_follow?(source_account, target_account) + target_account.locked? || source_account.silenced? || block_straight_follow?(source_account) || + ((source_account.bot? || proxy_account?(source_account)) && target_account.user&.setting_lock_follow_from_bot) + end + + def block_straight_follow?(account) + return false if account.local? + + DomainBlock.reject_straight_follow?(account.domain) + end + + def proxy_account?(account) + (account.username.downcase.include?('_proxy') || + account.username.downcase.end_with?('proxy') || + account.username.downcase.include?('_bot_') || + account.username.downcase.end_with?('bot') || + account.display_name&.downcase&.include?('proxy') || + account.display_name&.include?('γƒ—γƒ­γ‚­γ‚·') || + account.note&.include?('γƒ—γƒ­γ‚­γ‚·')) && + (account.following_count.zero? || account.following_count > account.followers_count) && + proxyable_software?(account) + end + + def proxyable_software?(account) + return false if account.local? + + InstanceInfo.proxy_account_software?(account.domain) + end +end diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index f0faf69b43..dc7442ac33 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true module FormattingHelper + SYNDICATED_EMOJI_STYLES = <<~CSS.squish + height: 1.1em; + margin: -.2ex .15em .2ex; + object-fit: contain; + vertical-align: middle; + width: 1.1em; + CSS + def html_aware_format(text, local, options = {}) HtmlAwareFormatter.new(text, local, options).to_s end @@ -23,46 +31,33 @@ module FormattingHelper end def status_content_format(status) - html_aware_format(status.text, status.local?, markdown: status.markdown, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) - end + MastodonOTELTracer.in_span('HtmlAwareFormatter rendering') do |span| + span.add_attributes( + 'app.formatter.content.type' => 'status', + 'app.formatter.content.origin' => status.local? ? 'local' : 'remote' + ) - def emoji_name_format(emoji_reaction, status) - html_aware_format(emoji_reaction['url'].present? ? ":#{emoji_reaction['name']}:" : emoji_reaction['name'], status.local?, markdown: status.markdown) + html_aware_format(status.text, status.local?, markdown: status.markdown, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) + end end def rss_status_content_format(status) - html = status_content_format(status) - - before_html = if status.spoiler_text? - tag.p do - tag.strong do - I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale) - end - - status.spoiler_text - end + tag.hr - end - - after_html = if status.preloadable_poll - tag.p do - safe_join( - status.preloadable_poll.options.map do |o| - tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', o, disabled: true) - end, - tag.br - ) - end - end - prerender_custom_emojis( - safe_join([before_html, html, after_html]), + wrapped_status_content_format(status), status.emojis, - style: 'min-width: 1.1em; height: 1.1em; object-fit: contain; vertical-align: middle; margin: -.2ex .15em .2ex' + style: SYNDICATED_EMOJI_STYLES ).to_str end def account_bio_format(account) - html_aware_format(account.note, account.local?, markdown: account.user&.setting_bio_markdown) + MastodonOTELTracer.in_span('HtmlAwareFormatter rendering') do |span| + span.add_attributes( + 'app.formatter.content.type' => 'account_bio', + 'app.formatter.content.origin' => account.local? ? 'local' : 'remote' + ) + + html_aware_format(account.note, account.local?, markdown: account.user&.setting_bio_markdown) + end end def account_field_value_format(field, with_rel_me: true) @@ -72,4 +67,51 @@ module FormattingHelper html_aware_format(field.value, field.account.local?, markdown: false, with_rel_me: with_rel_me, with_domains: true, multiline: false) end end + + def markdown(text) + Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true).render(text).html_safe # rubocop:disable Rails/OutputSafety + end + + private + + def wrapped_status_content_format(status) + safe_join [ + rss_content_preroll(status), + status_content_format(status), + rss_content_postroll(status), + ] + end + + def rss_content_preroll(status) + if status.spoiler_text? + safe_join [ + tag.p { spoiler_with_warning(status) }, + tag.hr, + ] + end + end + + def spoiler_with_warning(status) + safe_join [ + tag.strong { I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale) }, + status.spoiler_text, + ] + end + + def rss_content_postroll(status) + if status.preloadable_poll + tag.p do + poll_option_tags(status) + end + end + end + + def poll_option_tags(status) + safe_join( + status.preloadable_poll.options.map do |option| + tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', option, disabled: true) + end, + tag.br + ) + end end diff --git a/app/helpers/high_load_helper.rb b/app/helpers/high_load_helper.rb new file mode 100644 index 0000000000..b4606c039f --- /dev/null +++ b/app/helpers/high_load_helper.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module HighLoadHelper + def allow_high_load? + ENV.fetch('ALLOW_HIGH_LOAD', 'true') == 'true' + end + module_function :allow_high_load? +end diff --git a/app/helpers/instance_helper.rb b/app/helpers/instance_helper.rb index 893afdd51f..018c69e620 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/json_ld_helper.rb similarity index 78% rename from app/helpers/jsonld_helper.rb rename to app/helpers/json_ld_helper.rb index df6e0acbd9..693cdf730f 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -149,7 +149,7 @@ module JsonLdHelper def safe_for_forwarding?(original, compacted) original.without('@context', 'signature').all? do |key, value| compacted_value = compacted[key] - return false unless value.class == compacted_value.class + return false unless value.instance_of?(compacted_value.class) if value.is_a?(Hash) safe_for_forwarding?(value, compacted_value) @@ -163,26 +163,63 @@ module JsonLdHelper end end - def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {}) + # Fetch the resource given by uri. + # @param uri [String] + # @param id_is_known [Boolean] + # @param on_behalf_of [nil, Account] + # @param raise_on_error [Symbol<:all, :temporary, :none>] See {#fetch_resource_without_id_validation} for possible values + def fetch_resource(uri, id_is_known, on_behalf_of = nil, raise_on_error: :none, request_options: {}) unless id_is_known - json = fetch_resource_without_id_validation(uri, on_behalf_of) + json = fetch_resource_without_id_validation(uri, on_behalf_of, raise_on_error: raise_on_error) return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id']) uri = json['id'] end - json = fetch_resource_without_id_validation(uri, on_behalf_of, request_options: request_options) + json = fetch_resource_without_id_validation(uri, on_behalf_of, raise_on_error: raise_on_error, request_options: request_options) json.present? && json['id'] == uri ? json : nil end - def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false, request_options: {}) + # Fetch the resource given by uri + # + # If an error is raised, it contains the response and can be captured for handling like + # + # begin + # fetch_resource_without_id_validation(uri, nil, true) + # rescue Mastodon::UnexpectedResponseError => e + # e.response + # end + # + # @param uri [String] + # @param on_behalf_of [nil, Account] + # @param raise_on_error [Symbol<:all, :temporary, :none>] + # - +:all+ - raise if response code is not in the 2xx range + # - +:temporary+ - raise if the response code is not an "unsalvageable error" like a 404 + # (see {#response_error_unsalvageable} ) + # - +:none+ - do not raise, return +nil+ + def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_error: :none, request_options: {}) on_behalf_of ||= Account.representative build_request(uri, on_behalf_of, options: request_options).perform do |response| - raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error + raise Mastodon::UnexpectedResponseError, response if !response_successful?(response) && ( + raise_on_error == :all || + (!response_error_unsalvageable?(response) && raise_on_error == :temporary) + ) - body_to_json(response.body_with_limit) if response.code == 200 + body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response) + end + end + + def valid_activitypub_content_type?(response) + return true if response.mime_type == 'application/activity+json' + + # When the mime type is `application/ld+json`, we need to check the profile, + # but `http.rb` does not parse it for us. + return false unless response.mime_type == 'application/ld+json' + + response.headers[HTTP::Headers::CONTENT_TYPE]&.split(';')&.map(&:strip)&.any? do |str| + str.start_with?('profile="') && str[9...-1].split.include?('https://www.w3.org/ns/activitystreams') end end @@ -196,14 +233,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/kmyblue_capabilities_helper.rb b/app/helpers/kmyblue_capabilities_helper.rb index ef6799faeb..279505bec8 100644 --- a/app/helpers/kmyblue_capabilities_helper.rb +++ b/app/helpers/kmyblue_capabilities_helper.rb @@ -19,6 +19,9 @@ module KmyblueCapabilitiesHelper kmyblue_searchability_limited kmyblue_circle_history kmyblue_list_notification + kmyblue_server_features + favourite_list + kmyblue_favourite_antenna ) capabilities << :full_text_search if Chewy.enabled? @@ -28,6 +31,7 @@ module KmyblueCapabilitiesHelper end capabilities << :kmyblue_visibility_public_unlisted if Setting.enable_public_unlisted_visibility capabilities << :kmyblue_searchability_public_unlisted if Setting.enable_public_unlisted_visibility + capabilities << :kmyblue_no_public_visibility unless Setting.enable_public_visibility capabilities << :timeline_no_local unless Setting.enable_local_timeline capabilities diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 0d4edceb7b..0a8ebcde54 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -109,6 +109,7 @@ module LanguagesHelper mn: ['Mongolian', 'Монгол хэл'].freeze, mr: ['Marathi', 'ΰ€ΰ€°ΰ€Ύΰ€ ΰ₯€'].freeze, ms: ['Malay', 'Bahasa Melayu'].freeze, + 'ms-Arab': ['Jawi Malay', 'Ψ¨Ω‡Ψ§Ψ³ Ω…Ω„Ψ§ΩŠΩˆ'].freeze, mt: ['Maltese', 'Malti'].freeze, my: ['Burmese', 'ဗမာစာ'].freeze, na: ['Nauru', 'EkakairΕ© Naoero'].freeze, @@ -127,7 +128,7 @@ module LanguagesHelper om: ['Oromo', 'Afaan Oromoo'].freeze, or: ['Oriya', 'ଓ଑଼ିଆ'].freeze, os: ['Ossetian', 'ΠΈΡ€ΠΎΠ½ Γ¦Π²Π·Π°Π³'].freeze, - pa: ['Panjabi', 'ΰ¨ͺΰ©°ΰ¨œΰ¨Ύΰ¨¬ΰ©€'].freeze, + pa: ['Punjabi', 'ΰ¨ͺΰ©°ΰ¨œΰ¨Ύΰ¨¬ΰ©€'].freeze, pi: ['Pāli', 'ΰ€ͺΰ€Ύΰ€΄ΰ€Ώ'].freeze, pl: ['Polish', 'Polski'].freeze, ps: ['Pashto', 'پښΨͺو'].freeze, @@ -161,7 +162,7 @@ module LanguagesHelper th: ['Thai', 'ΰΉ„ΰΈ—ΰΈ’'].freeze, ti: ['Tigrinya', 'α‰΅αŒαˆ­αŠ›'].freeze, tk: ['Turkmen', 'TΓΌrkmen'].freeze, - tl: ['Tagalog', 'Wikang Tagalog'].freeze, + tl: ['Tagalog', 'Tagalog'].freeze, tn: ['Tswana', 'Setswana'].freeze, to: ['Tonga', 'faka Tonga'].freeze, tr: ['Turkish', 'TΓΌrkΓ§e'].freeze, @@ -191,15 +192,21 @@ module LanguagesHelper chr: ['Cherokee', 'ᏣᎳᎩ αŽ¦α¬α‚αŽ―αα—'].freeze, ckb: ['Sorani (Kurdish)', 'Ψ³Ϋ†Ψ±Ψ§Ω†ΫŒ'].freeze, cnr: ['Montenegrin', 'crnogorski'].freeze, + csb: ['Kashubian', 'KaszΓ«bsczi'].freeze, + gsw: ['Swiss German', 'SchwiizertΓΌtsch'].freeze, jbo: ['Lojban', 'la .lojban.'].freeze, kab: ['Kabyle', 'Taqbaylit'].freeze, ldn: ['LΓ‘adan', 'LΓ‘adan'].freeze, lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze, + moh: ['Mohawk', 'KanienΚΌkΓ©ha'].freeze, + nds: ['Low German', 'PlattdΓΌΓΌtsch'].freeze, + pdc: ['Pennsylvania Dutch', 'Pennsilfaani-Deitsch'].freeze, sco: ['Scots', 'Scots'].freeze, sma: ['Southern Sami', 'Γ…arjelsaemien GΓ―ele'].freeze, smj: ['Lule Sami', 'JulevsΓ‘megiella'].freeze, szl: ['Silesian', 'Ε›lΕ―nsko godka'].freeze, tok: ['Toki Pona', 'toki pona'].freeze, + vai: ['Vai', 'ꕙꔀ'].freeze, xal: ['Kalmyk', 'Π₯альмг ΠΊΠ΅Π»Π½'].freeze, zba: ['Balaibalan', 'Ψ¨Ψ§Ω„ΩŠΨ¨Ω„Ω†'].freeze, zgh: ['Standard Moroccan Tamazight', 'β΅œβ΄°β΅Žβ΄°β΅£β΅‰β΅–β΅œ'].freeze, @@ -224,7 +231,7 @@ module LanguagesHelper 'en-GB': 'English (British)', 'es-AR': 'EspaΓ±ol (Argentina)', 'es-MX': 'EspaΓ±ol (MΓ©xico)', - 'fr-QC': 'FranΓ§ais (Canadien)', + 'fr-CA': 'FranΓ§ais (Canadien)', 'pt-BR': 'PortuguΓͺs (Brasil)', 'pt-PT': 'PortuguΓͺs (Portugal)', 'sr-Latn': 'Srpski (latinica)', @@ -232,9 +239,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/mascot_helper.rb b/app/helpers/mascot_helper.rb deleted file mode 100644 index 8ee04383ec..0000000000 --- a/app/helpers/mascot_helper.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module MascotHelper - def mascot_url - full_asset_url(instance_presenter.mascot&.file&.url || asset_pack_path('media/images/elephant_ui_plane.svg')) - end - - def instance_presenter - @instance_presenter ||= InstancePresenter.new - end -end diff --git a/app/helpers/media_component_helper.rb b/app/helpers/media_component_helper.rb index fa8f34fb4d..269566528a 100644 --- a/app/helpers/media_component_helper.rb +++ b/app/helpers/media_component_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module MediaComponentHelper - def render_video_component(status, **options) + def render_video_component(status, **) video = status.ordered_media_attachments.first meta = video.file.meta || {} @@ -18,14 +18,14 @@ module MediaComponentHelper media: [ serialize_media_attachment(video), ].as_json, - }.merge(**options) + }.merge(**) react_component :video, component_params do render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } end end - def render_audio_component(status, **options) + def render_audio_component(status, **) audio = status.ordered_media_attachments.first meta = audio.file.meta || {} @@ -38,45 +38,25 @@ module MediaComponentHelper foregroundColor: meta.dig('colors', 'foreground'), accentColor: meta.dig('colors', 'accent'), duration: meta.dig('original', 'duration'), - }.merge(**options) + }.merge(**) react_component :audio, component_params do render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } end end - def render_media_gallery_component(status, **options) + def render_media_gallery_component(status, **) component_params = { sensitive: sensitive_viewer?(status, current_account), autoplay: prefers_autoplay?, media: status.ordered_media_attachments.map { |a| serialize_media_attachment(a).as_json }, - }.merge(**options) + }.merge(**) react_component :media_gallery, component_params do render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } 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/ng_rule_helper.rb b/app/helpers/ng_rule_helper.rb new file mode 100644 index 0000000000..104442b117 --- /dev/null +++ b/app/helpers/ng_rule_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module NgRuleHelper + def check_invalid_status_for_ng_rule!(account, **options) + (check_for_ng_rule!(account, **options) { |rule| !rule.check_status_or_record! }).none? + end + + def check_invalid_reaction_for_ng_rule!(account, **options) + (check_for_ng_rule!(account, **options) { |rule| !rule.check_reaction_or_record! }).none? + end + + private + + def check_for_ng_rule!(account, **options, &block) + NgRule.cached_rules + .map { |raw_rule| Admin::NgRule.new(raw_rule, account, **options) } + .filter(&block) + end + + def do_account_action_for_rule!(account, action) + case action + when :silence + account.silence! + when :suspend + account.suspend! + end + end +end diff --git a/app/helpers/react_component_helper.rb b/app/helpers/react_component_helper.rb index ce616e8306..821a6f1e2d 100644 --- a/app/helpers/react_component_helper.rb +++ b/app/helpers/react_component_helper.rb @@ -15,9 +15,20 @@ module ReactComponentHelper div_tag_with_data(data) end + def serialized_media_attachments(media_attachments) + media_attachments.map { |attachment| serialized_attachment(attachment) } + end + private def div_tag_with_data(data) content_tag(:div, nil, data: data) end + + def serialized_attachment(attachment) + ActiveModelSerializers::SerializableResource.new( + attachment, + serializer: REST::MediaAttachmentSerializer + ).as_json + end end diff --git a/app/helpers/registration_helper.rb b/app/helpers/registration_helper.rb index ef5462ac88..f387a48309 100644 --- a/app/helpers/registration_helper.rb +++ b/app/helpers/registration_helper.rb @@ -3,8 +3,10 @@ module RegistrationHelper extend ActiveSupport::Concern + include RegistrationLimitationHelper + def allowed_registration?(remote_ip, invite) - !Rails.configuration.x.single_user_mode && !omniauth_only? && (registrations_open? || invite&.valid_for_use?) && !ip_blocked?(remote_ip) + !Rails.configuration.x.single_user_mode && !omniauth_only? && ((registrations_open? && !reach_registrations_limit?) || invite&.valid_for_use?) && !ip_blocked?(remote_ip) end def registrations_open? @@ -16,6 +18,6 @@ module RegistrationHelper end def ip_blocked?(remote_ip) - IpBlock.where(severity: :sign_up_block).exists?(['ip >>= ?', remote_ip.to_s]) + IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists? end end diff --git a/app/helpers/registration_limitation_helper.rb b/app/helpers/registration_limitation_helper.rb new file mode 100644 index 0000000000..c295dc0746 --- /dev/null +++ b/app/helpers/registration_limitation_helper.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module RegistrationLimitationHelper + def reach_registrations_limit? + ((Setting.registrations_limit.presence || 0).positive? && Setting.registrations_limit <= user_count_for_registration) || + ((Setting.registrations_limit_per_day.presence || 0).positive? && Setting.registrations_limit_per_day <= today_increase_user_count) + end + + def user_count_for_registration + Rails.cache.fetch('registrations:user_count') { User.confirmed.enabled.joins(:account).merge(Account.without_suspended).count } + end + + def today_increase_user_count + today_date = Time.now.utc.beginning_of_day.to_i + count = 0 + + if Rails.cache.fetch('registrations:today_date') { today_date } == today_date + count = Rails.cache.fetch('registrations:today_increase_user_count') { today_increase_user_count_value } + else + count = today_increase_user_count_value + Rails.cache.write('registrations:today_date', today_date) + Rails.cache.write('registrations:today_increase_user_count', count) + end + + count + end + + def today_increase_user_count_value + User.confirmed.enabled.where(users: { created_at: Time.now.utc.beginning_of_day.. }).joins(:account).merge(Account.without_suspended).count + end + + def registrations_in_time? + start_hour = Setting.registrations_start_hour + end_hour = Setting.registrations_end_hour + secondary_start_hour = Setting.registrations_secondary_start_hour + secondary_end_hour = Setting.registrations_secondary_end_hour + + start_hour = 0 unless start_hour.is_a?(Integer) + end_hour = 0 unless end_hour.is_a?(Integer) + secondary_start_hour = 0 unless secondary_start_hour.is_a?(Integer) + secondary_end_hour = 0 unless secondary_end_hour.is_a?(Integer) + + return true if start_hour >= end_hour && secondary_start_hour >= secondary_end_hour + + current_hour = Time.now.utc.hour + + (start_hour < end_hour && end_hour.positive? && current_hour.between?(start_hour, end_hour - 1)) || + (secondary_start_hour < secondary_end_hour && secondary_end_hour.positive? && current_hour.between?(secondary_start_hour, secondary_end_hour - 1)) + end + + def reset_registration_limit_caches! + Rails.cache.delete('registrations:user_count') + Rails.cache.delete('registrations:today_increase_user_count') + end +end diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index 2fb9ce72cb..22efc5f092 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -14,8 +14,8 @@ module RoutingHelper end end - def full_asset_url(source, **options) - source = ActionController::Base.helpers.asset_url(source, **options) unless use_storage? + def full_asset_url(source, **) + source = ActionController::Base.helpers.asset_url(source, **) unless use_storage? URI.join(asset_host, source).to_s end @@ -24,8 +24,12 @@ module RoutingHelper Rails.configuration.action_controller.asset_host || root_url end - def full_pack_url(source, **options) - full_asset_url(asset_pack_path(source, **options)) + def frontend_asset_path(source, **) + asset_pack_path("media/#{source}", **) + end + + def frontend_asset_url(source, **) + full_asset_url(frontend_asset_path(source, **)) end def use_storage? diff --git a/app/helpers/self_destruct_helper.rb b/app/helpers/self_destruct_helper.rb index 78557c25e5..f1927b1e04 100644 --- a/app/helpers/self_destruct_helper.rb +++ b/app/helpers/self_destruct_helper.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true module SelfDestructHelper + VERIFY_PURPOSE = 'self-destruct' + def self.self_destruct? - value = ENV.fetch('SELF_DESTRUCT', nil) - value.present? && Rails.application.message_verifier('self-destruct').verify(value) == ENV['LOCAL_DOMAIN'] + value = Rails.configuration.x.mastodon.self_destruct_value + value.present? && Rails.application.message_verifier(VERIFY_PURPOSE).verify(value) == ENV['LOCAL_DOMAIN'] rescue ActiveSupport::MessageVerifier::InvalidSignature false end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 3c72b22c66..fd631ce92e 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -9,15 +9,29 @@ module SettingsHelper LanguagesHelper.sorted_locale_keys(I18n.available_locales) end + def featured_tags_hint(recently_used_tags) + recently_used_tags.present? && + safe_join( + [ + t('simple_form.hints.featured_tag.name'), + safe_join( + links_for_featured_tags(recently_used_tags), + ', ' + ), + ], + ' ' + ) + end + def session_device_icon(session) device = session.detection.device if device.mobile? - 'mobile' + 'smartphone' elsif device.tablet? 'tablet' else - 'desktop' + 'desktop_mac' end end @@ -28,4 +42,18 @@ module SettingsHelper safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: '', class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ') end end + + private + + def links_for_featured_tags(tags) + tags.map { |tag| post_link_to_featured_tag(tag) } + end + + def post_link_to_featured_tag(tag) + link_to( + "##{tag.display_name}", + settings_featured_tags_path(featured_tag: { name: tag.name }), + method: :post + ) + end end diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 01102645de..7e42dd4623 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -1,19 +1,18 @@ # frozen_string_literal: true module StatusesHelper - EMBEDDED_CONTROLLER = 'statuses' - EMBEDDED_ACTION = 'embed' - - def link_to_newer(url) - link_to t('statuses.show_newer'), url, class: 'load-more load-gap' - end - - def link_to_older(url) - link_to t('statuses.show_older'), url, class: 'load-more load-gap' - end + VISIBLITY_ICONS = { + public: 'globe', + unlisted: 'lock_open', + private: 'lock', + direct: 'alternate_email', + public_unlisted: 'cloud', + login: 'key', + limited: 'shield', + }.freeze def nothing_here(extra_classes = '') - content_tag(:div, class: "nothing-here #{extra_classes}") do + tag.div(class: ['nothing-here', extra_classes]) do t('accounts.nothing_here') end end @@ -61,31 +60,8 @@ module StatusesHelper components.compact_blank.join("\n\n") end - def stream_link_target - 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 'public_unlisted' - fa_icon 'cloud fw' - when 'login' - fa_icon 'key fw' - when 'private' - fa_icon 'lock fw' - when 'limited' - fa_icon 'get-pocket fw' - when 'direct' - fa_icon 'at fw' - end - end - - def embedded_view? - params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION + def visibility_icon(status) + VISIBLITY_ICONS[status.visibility.to_sym] end def prefers_autoplay? diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb new file mode 100644 index 0000000000..f4d88a1ef0 --- /dev/null +++ b/app/helpers/theme_helper.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module ThemeHelper + def theme_style_tags(theme) + if theme == 'system' + ''.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 + end + + def theme_color_tags(theme) + if theme == 'system' + ''.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 + end + + def custom_stylesheet + if active_custom_stylesheet.present? + stylesheet_link_tag( + custom_css_path(active_custom_stylesheet), + host: root_url, + media: :all, + skip_pipeline: true + ) + end + end + + def system_stylesheet + stylesheet_link_tag( + system_css_path, + host: root_url, + media: :all, + skip_pipeline: true + ) + end + + def user_custom_stylesheet + stylesheet_link_tag( + user_custom_css_path({ version: user_custom_css_version }), + host: root_url, + media: :all, + skip_pipeline: true + ) + end + + private + + def active_custom_stylesheet + if cached_custom_css_digest.present? + [:custom, cached_custom_css_digest.to_s.first(8)] + .compact_blank + .join('-') + end + end + + def cached_custom_css_digest + Rails.cache.fetch(:setting_digest_custom_css) do + Setting.custom_css&.then { |content| Digest::SHA256.hexdigest(content) } + end + end + + def theme_color_for(theme) + theme == 'mastodon-light' ? Themes::THEME_COLORS[:light] : Themes::THEME_COLORS[:dark] + end +end diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb deleted file mode 100644 index 482f4e19ea..0000000000 --- a/app/helpers/webfinger_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module WebfingerHelper - def webfinger!(uri) - Webfinger.new(uri).perform - end -end diff --git a/app/inputs/date_of_birth_input.rb b/app/inputs/date_of_birth_input.rb new file mode 100644 index 0000000000..131234b02e --- /dev/null +++ b/app/inputs/date_of_birth_input.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class DateOfBirthInput < SimpleForm::Inputs::Base + OPTIONS = [ + { autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze, + { autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze, + { autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze, + ].freeze + + def input(wrapper_options = nil) + merged_input_options = merge_wrapper_options(input_html_options, wrapper_options) + merged_input_options[:inputmode] = 'numeric' + + values = (object.public_send(attribute_name) || '').split('.') + + safe_join(Array.new(3) do |index| + options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index] + @builder.text_field("#{attribute_name}(#{index + 1}i)", options) + end) + end + + def label_target + "#{attribute_name}_1i" + end + + private + + def generate_id(index) + "#{object_name}_#{attribute_name}_#{index + 1}i" + end +end diff --git a/app/javascript/__mocks__/svg.js b/app/javascript/__mocks__/svg.js index e725dc2da6..762bc165d0 100644 --- a/app/javascript/__mocks__/svg.js +++ b/app/javascript/__mocks__/svg.js @@ -1,3 +1,3 @@ -// eslint-disable-next-line import/no-anonymous-default-export -export default 'SvgrURL'; -export const ReactComponent = 'div'; +const ReactComponent = 'div'; + +export default ReactComponent; diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx new file mode 100644 index 0000000000..64192f54ad --- /dev/null +++ b/app/javascript/entrypoints/admin.tsx @@ -0,0 +1,440 @@ +import './public-path'; +import { createRoot } from 'react-dom/client'; + +import Rails from '@rails/ujs'; + +import ready from '../mastodon/ready'; + +const setAnnouncementEndsAttributes = (target: HTMLInputElement) => { + const valid = target.value && target.validity.valid; + const element = document.querySelector( + 'input[type="datetime-local"]#announcement_ends_at', + ); + + if (!element) return; + + if (valid) { + element.classList.remove('optional'); + element.required = true; + element.min = target.value; + } else { + element.classList.add('optional'); + element.removeAttribute('required'); + element.removeAttribute('min'); + } +}; + +Rails.delegate( + document, + 'input[type="datetime-local"]#announcement_starts_at', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + setAnnouncementEndsAttributes(target); + }, +); + +const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; + +const showSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + selectAllMatchingElement?.classList.add('active'); +}; + +const hideSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + const hiddenField = document.querySelector( + 'input#select_all_matching', + ); + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + selectAllMatchingElement?.classList.remove('active'); + selectedMsg?.classList.remove('active'); + notSelectedMsg?.classList.add('active'); + if (hiddenField) hiddenField.value = '0'; +}; + +Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + document + .querySelectorAll(batchCheckboxClassName) + .forEach((content) => { + content.checked = target.checked; + }); + + if (selectAllMatchingElement) { + if (target.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } +}); + +Rails.delegate(document, '.batch-table__select-all button', 'click', () => { + const hiddenField = document.querySelector( + '#select_all_matching', + ); + + if (!hiddenField) return; + + const active = hiddenField.value === '1'; + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + if (!selectedMsg || !notSelectedMsg) return; + + if (active) { + hiddenField.value = '0'; + selectedMsg.classList.remove('active'); + notSelectedMsg.classList.add('active'); + } else { + hiddenField.value = '1'; + notSelectedMsg.classList.remove('active'); + selectedMsg.classList.add('active'); + } +}); + +Rails.delegate(document, batchCheckboxClassName, 'change', () => { + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + + if (selectAllMatchingElement) { + if (checkAllElement.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } + } +}); + +Rails.delegate( + document, + '.filter-subset--with-select select', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) target.form?.submit(); + }, +); + +const onDomainBlockSeverityChange = (target: HTMLSelectElement) => { + const rejectMediaDiv = document.querySelector( + '.input.with_label.domain_block_reject_media', + ); + const rejectReportsDiv = document.querySelector( + '.input.with_label.domain_block_reject_reports', + ); + + if (rejectMediaDiv && rejectMediaDiv instanceof HTMLElement) { + rejectMediaDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } + + if (rejectReportsDiv && rejectReportsDiv instanceof HTMLElement) { + rejectReportsDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } +}; + +Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => { + if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target); +}); + +const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => { + const bootstrapTimelineAccountsField = + document.querySelector( + '#form_admin_settings_bootstrap_timeline_accounts', + ); + + if (bootstrapTimelineAccountsField) { + bootstrapTimelineAccountsField.disabled = !target.checked; + if (target.checked) { + bootstrapTimelineAccountsField.parentElement?.classList.remove( + 'disabled', + ); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.remove( + 'disabled', + ); + } else { + bootstrapTimelineAccountsField.parentElement?.classList.add('disabled'); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.add( + 'disabled', + ); + } + } +}; + +Rails.delegate( + document, + '#form_admin_settings_enable_bootstrap_timeline_accounts', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + onEnableBootstrapTimelineAccountsChange(target); + }, +); + +const onChangeRegistrationMode = (target: HTMLSelectElement) => { + const enabled = target.value === 'approved'; + + document + .querySelectorAll( + '.form_admin_settings_registrations_mode .warning-hint', + ) + .forEach((warning_hint) => { + warning_hint.style.display = target.value === 'open' ? 'inline' : 'none'; + }); + + const toggleEnabled = (input: HTMLInputElement, value: boolean) => { + input.disabled = !value; + if (value) { + let element: HTMLElement | null = input; + do { + element.classList.remove('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } else { + let element: HTMLElement | null = input; + do { + element.classList.add('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } + }; + + document + .querySelectorAll( + 'input#form_admin_settings_require_invite_text', + ) + .forEach((input) => { + toggleEnabled(input, enabled); + }); + + document + .querySelectorAll( + '#form_admin_settings_registrations_start_hour, #form_admin_settings_registrations_end_hour, #form_admin_settings_registrations_secondary_start_hour, #form_admin_settings_registrations_secondary_end_hour', + ) + .forEach((input) => { + toggleEnabled(input, target.value === 'open'); + }); +}; + +const convertUTCDateTimeToLocal = (value: string) => { + const date = new Date(value + 'Z'); + const twoChars = (x: number) => x.toString().padStart(2, '0'); + return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`; +}; + +function convertLocalDatetimeToUTC(value: string) { + const date = new Date(value); + const fullISO8601 = date.toISOString(); + return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); +} + +Rails.delegate( + document, + '#form_admin_settings_registrations_mode', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target); + }, +); + +const addTableRow = (tableId: string) => { + const templateElement = document.querySelector(`#${tableId} .template-row`)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion + const tableElement = document.querySelector(`#${tableId} tbody`)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion + + if ( + typeof templateElement === 'undefined' || + typeof tableElement === 'undefined' + ) + return; + + let temporaryId = 0; + tableElement + .querySelectorAll('.temporary_id') + .forEach((input) => { + if (parseInt(input.value) + 1 > temporaryId) { + temporaryId = parseInt(input.value) + 1; + } + }); + + const cloned = templateElement.cloneNode(true) as HTMLTableRowElement; + cloned.className = ''; + cloned.querySelector('.temporary_id')!.value = // eslint-disable-line @typescript-eslint/no-non-null-assertion + temporaryId.toString(); + cloned + .querySelectorAll('input[type=checkbox]') + .forEach((input) => { + input.value = temporaryId.toString(); + }); + tableElement.appendChild(cloned); +}; + +const removeTableRow = (target: EventTarget | null, tableId: string) => { + const tableRowElement = (target as HTMLElement).closest('tr') as Node; + const tableElement = document.querySelector(`#${tableId} tbody`)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion + + if ( + typeof tableRowElement === 'undefined' || + typeof tableElement === 'undefined' + ) + return; + + tableElement.removeChild(tableRowElement); +}; + +const setupTableList = (id: string) => { + Rails.delegate(document, `#${id} .add-row-button`, 'click', (ev) => { + ev.preventDefault(); + addTableRow(id); + }); + + Rails.delegate(document, `#${id} .delete-row-button`, 'click', (ev) => { + ev.preventDefault(); + removeTableRow(ev.target, id); + }); +}; + +setupTableList('sensitive-words-table'); +setupTableList('ng-words-table'); +setupTableList('white-list-table'); + +async function mountReactComponent(element: Element) { + const componentName = element.getAttribute('data-admin-component'); + const stringProps = element.getAttribute('data-props'); + + if (!stringProps) return; + + const componentProps = JSON.parse(stringProps) as object; + + const { default: AdminComponent } = await import( + '@/mastodon/containers/admin_component' + ); + + const { default: Component } = (await import( + `@/mastodon/components/admin/${componentName}` + )) as { default: React.ComponentType }; + + const root = createRoot(element); + + root.render( + + + , + ); +} + +ready(() => { + const domainBlockSeveritySelect = document.querySelector( + 'select#domain_block_severity', + ); + if (domainBlockSeveritySelect) + onDomainBlockSeverityChange(domainBlockSeveritySelect); + + const enableBootstrapTimelineAccounts = + document.querySelector( + 'input#form_admin_settings_enable_bootstrap_timeline_accounts', + ); + if (enableBootstrapTimelineAccounts) + onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); + + const registrationMode = document.querySelector( + 'select#form_admin_settings_registrations_mode', + ); + if (registrationMode) onChangeRegistrationMode(registrationMode); + + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + } + + document + .querySelector('a#add-instance-button') + ?.addEventListener('click', (e) => { + const domain = document.querySelector( + 'input[type="text"]#by_domain', + )?.value; + + if (domain && e.target instanceof HTMLAnchorElement) { + const url = new URL(e.target.href); + url.searchParams.set('_domain', domain); + e.target.href = url.toString(); + } + }); + + document + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value) { + element.value = convertUTCDateTimeToLocal(element.value); + } + if (element.placeholder) { + element.placeholder = convertUTCDateTimeToLocal(element.placeholder); + } + }); + + Rails.delegate(document, 'form', 'submit', ({ target }) => { + if (target instanceof HTMLFormElement) + target + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value && element.validity.valid) { + element.value = convertLocalDatetimeToUTC(element.value); + } + }); + }); + + const announcementStartsAt = document.querySelector( + 'input[type="datetime-local"]#announcement_starts_at', + ); + if (announcementStartsAt) { + setAnnouncementEndsAttributes(announcementStartsAt); + } + + document.querySelectorAll('[data-admin-component]').forEach((element) => { + void mountReactComponent(element); + }); +}).catch((reason: unknown) => { + throw reason; +}); diff --git a/app/javascript/packs/application.js b/app/javascript/entrypoints/application.ts similarity index 81% rename from app/javascript/packs/application.js rename to app/javascript/entrypoints/application.ts index d13388b479..1087b1c4cb 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/entrypoints/application.ts @@ -1,5 +1,5 @@ import './public-path'; -import main from "mastodon/main"; +import main from 'mastodon/main'; import { start } from '../mastodon/common'; import { loadLocale } from '../mastodon/locales'; @@ -10,6 +10,6 @@ start(); loadPolyfills() .then(loadLocale) .then(main) - .catch(e => { + .catch((e: unknown) => { console.error(e); }); diff --git a/app/javascript/entrypoints/embed.tsx b/app/javascript/entrypoints/embed.tsx new file mode 100644 index 0000000000..6c091e4d07 --- /dev/null +++ b/app/javascript/entrypoints/embed.tsx @@ -0,0 +1,78 @@ +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; + + // Only set overflow to `hidden` once we got the expected `message` so the post can still be scrolled if + // embedded without parent Javascript support + document.body.style.overflow = 'hidden'; + + // 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/packs/error.js b/app/javascript/entrypoints/error.ts similarity index 64% rename from app/javascript/packs/error.js rename to app/javascript/entrypoints/error.ts index 6376dc2f5d..db68484f3a 100644 --- a/app/javascript/packs/error.js +++ b/app/javascript/entrypoints/error.ts @@ -2,7 +2,9 @@ import './public-path'; import ready from '../mastodon/ready'; ready(() => { - const image = document.querySelector('img'); + const image = document.querySelector('img'); + + if (!image) return; image.addEventListener('mouseenter', () => { image.src = '/oops.gif'; @@ -11,4 +13,6 @@ ready(() => { image.addEventListener('mouseleave', () => { image.src = '/oops.png'; }); +}).catch((e: unknown) => { + console.error(e); }); diff --git a/app/javascript/entrypoints/inert.ts b/app/javascript/entrypoints/inert.ts new file mode 100644 index 0000000000..7c04a97faf --- /dev/null +++ b/app/javascript/entrypoints/inert.ts @@ -0,0 +1,4 @@ +/* Placeholder file to have `inert.scss` compiled by Webpack + This is used by the `wicg-inert` polyfill */ + +import '../styles/inert.scss'; diff --git a/app/javascript/packs/mailer.js b/app/javascript/entrypoints/mailer.ts similarity index 100% rename from app/javascript/packs/mailer.js rename to app/javascript/entrypoints/mailer.ts diff --git a/app/javascript/packs/public-path.js b/app/javascript/entrypoints/public-path.ts similarity index 69% rename from app/javascript/packs/public-path.js rename to app/javascript/entrypoints/public-path.ts index f4d166a771..ac4b9355b9 100644 --- a/app/javascript/packs/public-path.js +++ b/app/javascript/entrypoints/public-path.ts @@ -2,7 +2,7 @@ // to share the same assets regardless of instance configuration. // See https://webpack.js.org/guides/public-path/#on-the-fly -function removeOuterSlashes(string) { +function removeOuterSlashes(string: string) { return string.replace(/^\/*/, '').replace(/\/*$/, ''); } @@ -15,7 +15,9 @@ function formatPublicPath(host = '', path = '') { return `${formattedHost}/${formattedPath}/`; } -const cdnHost = document.querySelector('meta[name=cdn-host]'); +const cdnHost = document.querySelector('meta[name=cdn-host]'); -// eslint-disable-next-line no-undef -__webpack_public_path__ = formatPublicPath(cdnHost ? cdnHost.content : '', process.env.PUBLIC_OUTPUT_PATH); +__webpack_public_path__ = formatPublicPath( + cdnHost ? cdnHost.content : '', + process.env.PUBLIC_OUTPUT_PATH, +); diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx new file mode 100644 index 0000000000..9374d6b2d1 --- /dev/null +++ b/app/javascript/entrypoints/public.tsx @@ -0,0 +1,420 @@ +import { createRoot } from 'react-dom/client'; + +import './public-path'; + +import { IntlMessageFormat } from 'intl-messageformat'; +import type { MessageDescriptor, PrimitiveType } from 'react-intl'; +import { defineMessages } from 'react-intl'; + +import Rails from '@rails/ujs'; +import axios from 'axios'; +import { throttle } from 'lodash'; + +import { start } from '../mastodon/common'; +import { timeAgoString } from '../mastodon/components/relative_timestamp'; +import emojify from '../mastodon/features/emoji/emoji'; +import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; +import { loadLocale, getLocale } from '../mastodon/locales'; +import { loadPolyfills } from '../mastodon/polyfills'; +import ready from '../mastodon/ready'; + +import 'cocoon-js-vanilla'; + +start(); + +const messages = defineMessages({ + usernameTaken: { + id: 'username.taken', + defaultMessage: 'That username is taken. Try another', + }, + passwordExceedsLength: { + id: 'password_confirmation.exceeds_maxlength', + defaultMessage: 'Password confirmation exceeds the maximum password length', + }, + passwordDoesNotMatch: { + id: 'password_confirmation.mismatching', + defaultMessage: 'Password confirmation does not match', + }, +}); + +function loaded() { + const { messages: localeData } = getLocale(); + + const locale = document.documentElement.lang; + + const dateTimeFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }); + + const dateFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + + const timeFormat = new Intl.DateTimeFormat(locale, { + timeStyle: 'short', + }); + + const formatMessage = ( + { id, defaultMessage }: MessageDescriptor, + values?: Record, + ) => { + let message: string | undefined = undefined; + + if (id) message = localeData[id]; + + message ??= defaultMessage as string; + + const messageFormat = new IntlMessageFormat(message, locale); + return messageFormat.format(values) as string; + }; + + document.querySelectorAll('.emojify').forEach((content) => { + content.innerHTML = emojify(content.innerHTML); + }); + + document + .querySelectorAll('time.formatted') + .forEach((content) => { + const datetime = new Date(content.dateTime); + const formattedDate = dateTimeFormat.format(datetime); + + content.title = formattedDate; + content.textContent = formattedDate; + }); + + const isToday = (date: Date) => { + const today = new Date(); + + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); + }; + const todayFormat = new IntlMessageFormat( + localeData['relative_format.today'] ?? 'Today at {time}', + locale, + ); + + document + .querySelectorAll('time.relative-formatted') + .forEach((content) => { + const datetime = new Date(content.dateTime); + + let formattedContent: string; + + if (isToday(datetime)) { + const formattedTime = timeFormat.format(datetime); + + formattedContent = todayFormat.format({ + time: formattedTime, + }) as string; + } else { + formattedContent = dateFormat.format(datetime); + } + + const timeGiven = content.dateTime.includes('T'); + content.title = timeGiven + ? dateTimeFormat.format(datetime) + : dateFormat.format(datetime); + + content.textContent = formattedContent; + }); + + document + .querySelectorAll('time.time-ago') + .forEach((content) => { + const datetime = new Date(content.dateTime); + const now = new Date(); + + const timeGiven = content.dateTime.includes('T'); + content.title = timeGiven + ? dateTimeFormat.format(datetime) + : dateFormat.format(datetime); + content.textContent = timeAgoString( + { + formatMessage, + formatDate: (date: Date, options) => + new Intl.DateTimeFormat(locale, options).format(date), + }, + datetime, + now.getTime(), + now.getFullYear(), + timeGiven, + ); + }); + + const reactComponents = document.querySelectorAll('[data-component]'); + + if (reactComponents.length > 0) { + import( + /* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container' + ) + .then(({ default: MediaContainer }) => { + reactComponents.forEach((component) => { + Array.from(component.children).forEach((child) => { + component.removeChild(child); + }); + }); + + const content = document.createElement('div'); + + const root = createRoot(content); + root.render( + , + ); + document.body.appendChild(content); + + return true; + }) + .catch((error: unknown) => { + console.error(error); + }); + } + + Rails.delegate( + document, + 'input#user_account_attributes_username', + 'input', + throttle( + ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + if (target.value && target.value.length > 0) { + axios + .get('/api/v1/accounts/lookup', { params: { acct: target.value } }) + .then(() => { + target.setCustomValidity(formatMessage(messages.usernameTaken)); + return true; + }) + .catch(() => { + target.setCustomValidity(''); + }); + } else { + target.setCustomValidity(''); + } + }, + 500, + { leading: false, trailing: true }, + ), + ); + + Rails.delegate( + document, + '#user_password,#user_password_confirmation', + 'input', + () => { + const password = document.querySelector( + 'input#user_password', + ); + const confirmation = document.querySelector( + 'input#user_password_confirmation', + ); + if (!confirmation || !password) return; + + if ( + confirmation.value && + confirmation.value.length > password.maxLength + ) { + confirmation.setCustomValidity( + formatMessage(messages.passwordExceedsLength), + ); + } else if (password.value && password.value !== confirmation.value) { + confirmation.setCustomValidity( + formatMessage(messages.passwordDoesNotMatch), + ); + } else { + confirmation.setCustomValidity(''); + } + }, + ); +} + +Rails.delegate( + document, + '#edit_profile input[type=file]', + 'change', + ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const avatar = document.querySelector( + `img#${target.id}-preview`, + ); + + if (!avatar) return; + + let file: File | undefined; + if (target.files) file = target.files[0]; + + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + + if (url) avatar.src = url; + }, +); + +Rails.delegate(document, '.input-copy input', 'click', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + target.focus(); + target.select(); + target.setSelectionRange(0, target.value.length); +}); + +Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { + if (!(target instanceof HTMLButtonElement)) return; + + const input = target.parentNode?.querySelector( + '.input-copy__wrapper input', + ); + + if (!input) return; + + navigator.clipboard + .writeText(input.value) + .then(() => { + const parent = target.parentElement; + + if (parent) { + parent.classList.add('copied'); + + setTimeout(() => { + parent.classList.remove('copied'); + }, 700); + } + + return true; + }) + .catch((error: unknown) => { + console.error(error); + }); +}); + +const toggleSidebar = () => { + const sidebar = document.querySelector('.sidebar ul'); + const toggleButton = document.querySelector( + 'a.sidebar__toggle__icon', + ); + + if (!sidebar || !toggleButton) return; + + if (sidebar.classList.contains('visible')) { + document.body.style.overflow = ''; + toggleButton.setAttribute('aria-expanded', 'false'); + } else { + document.body.style.overflow = 'hidden'; + toggleButton.setAttribute('aria-expanded', 'true'); + } + + toggleButton.classList.toggle('active'); + sidebar.classList.toggle('visible'); +}; + +Rails.delegate(document, '.sidebar__toggle__icon', 'click', () => { + toggleSidebar(); +}); + +Rails.delegate(document, '.sidebar__toggle__icon', 'keydown', (e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + toggleSidebar(); + } +}); + +Rails.delegate(document, 'img.custom-emoji', 'mouseover', ({ target }) => { + if (target instanceof HTMLImageElement && target.dataset.original) + target.src = target.dataset.original; +}); +Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => { + if (target instanceof HTMLImageElement && target.dataset.static) + 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', () => { + [ + 'user_website', + 'user_confirm_password', + 'registration_user_website', + 'registration_user_confirm_password', + ].forEach((id) => { + const field = document.querySelector(`input#${id}`); + if (field) { + field.value = ''; + } + }); +}); + +Rails.delegate(document, '.rules-list button', 'click', ({ target }) => { + if (!(target instanceof HTMLElement)) { + return; + } + + const button = target.closest('button'); + + if (!button) { + return; + } + + if (button.ariaExpanded === 'true') { + button.ariaExpanded = 'false'; + } else { + button.ariaExpanded = 'true'; + } +}); + +function main() { + ready(loaded).catch((error: unknown) => { + console.error(error); + }); +} + +loadPolyfills() + .then(loadLocale) + .then(main) + .then(loadKeyboardExtensions) + .catch((error: unknown) => { + console.error(error); + }); diff --git a/app/javascript/packs/remote_interaction_helper.ts b/app/javascript/entrypoints/remote_interaction_helper.ts similarity index 96% rename from app/javascript/packs/remote_interaction_helper.ts rename to app/javascript/entrypoints/remote_interaction_helper.ts index d5834c6c3d..419571c896 100644 --- a/app/javascript/packs/remote_interaction_helper.ts +++ b/app/javascript/entrypoints/remote_interaction_helper.ts @@ -67,7 +67,9 @@ const fetchInteractionURLFailure = () => { ); }; -const isValidDomain = (value: string) => { +const isValidDomain = (value: unknown) => { + if (typeof value !== 'string') return false; + const url = new URL('https:///path'); url.hostname = value; return url.hostname === value; @@ -124,6 +126,11 @@ const fromAcct = (acct: string) => { const domain = segments[1]; const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + if (!domain) { + fetchInteractionURLFailure(); + return; + } + axios .get(`https://${domain}/.well-known/webfinger`, { params: { resource: `acct:${acct}` }, diff --git a/app/javascript/packs/share.jsx b/app/javascript/entrypoints/share.tsx similarity index 61% rename from app/javascript/packs/share.jsx rename to app/javascript/entrypoints/share.tsx index 0f3b84549d..7926250851 100644 --- a/app/javascript/packs/share.jsx +++ b/app/javascript/entrypoints/share.tsx @@ -2,7 +2,7 @@ import './public-path'; import { createRoot } from 'react-dom/client'; import { start } from '../mastodon/common'; -import ComposeContainer from '../mastodon/containers/compose_container'; +import ComposeContainer from '../mastodon/containers/compose_container'; import { loadPolyfills } from '../mastodon/polyfills'; import ready from '../mastodon/ready'; @@ -13,18 +13,24 @@ function loaded() { if (mountNode) { const attr = mountNode.getAttribute('data-props'); - if(!attr) return; - const props = JSON.parse(attr); + if (!attr) return; + + const props = JSON.parse(attr) as object; const root = createRoot(mountNode); + root.render(); } } function main() { - ready(loaded); + ready(loaded).catch((error: unknown) => { + console.error(error); + }); } -loadPolyfills().then(main).catch(error => { - console.error(error); -}); +loadPolyfills() + .then(main) + .catch((error: unknown) => { + console.error(error); + }); diff --git a/app/javascript/entrypoints/sign_up.ts b/app/javascript/entrypoints/sign_up.ts new file mode 100644 index 0000000000..880738fcb7 --- /dev/null +++ b/app/javascript/entrypoints/sign_up.ts @@ -0,0 +1,48 @@ +import './public-path'; +import axios from 'axios'; + +import ready from '../mastodon/ready'; + +async function checkConfirmation() { + const response = await axios.get('/api/v1/emails/check_confirmation'); + + if (response.data) { + window.location.href = '/start'; + } +} + +ready(() => { + setInterval(() => { + void checkConfirmation(); + }, 5000); + + document + .querySelectorAll('button.timer-button') + .forEach((button) => { + let counter = 30; + + const container = document.createElement('span'); + + const updateCounter = () => { + container.innerText = ` (${counter})`; + }; + + updateCounter(); + + const countdown = setInterval(() => { + counter--; + + if (counter === 0) { + button.disabled = false; + button.removeChild(container); + clearInterval(countdown); + } else { + updateCounter(); + } + }, 1000); + + button.appendChild(container); + }); +}).catch((e: unknown) => { + throw e; +}); diff --git a/app/javascript/entrypoints/two_factor_authentication.ts b/app/javascript/entrypoints/two_factor_authentication.ts new file mode 100644 index 0000000000..981481694b --- /dev/null +++ b/app/javascript/entrypoints/two_factor_authentication.ts @@ -0,0 +1,197 @@ +import * as WebAuthnJSON from '@github/webauthn-json'; +import axios, { AxiosError } from 'axios'; + +import ready from '../mastodon/ready'; + +import 'regenerator-runtime/runtime'; + +type PublicKeyCredentialCreationOptionsJSON = + WebAuthnJSON.CredentialCreationOptionsJSON['publicKey']; + +function exceptionHasAxiosError( + error: unknown, +): error is AxiosError<{ error: unknown }> { + return ( + error instanceof AxiosError && + typeof error.response?.data === 'object' && + 'error' in error.response.data + ); +} + +function logAxiosResponseError(error: unknown) { + if (exceptionHasAxiosError(error)) console.error(error); +} + +function getCSRFToken() { + return document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute('content'); +} + +function hideFlashMessages() { + document.querySelectorAll('.flash-message').forEach((flashMessage) => { + flashMessage.classList.add('hidden'); + }); +} + +async function callback( + url: string, + body: + | { + credential: WebAuthnJSON.PublicKeyCredentialWithAttestationJSON; + nickname: string; + } + | { + user: { credential: WebAuthnJSON.PublicKeyCredentialWithAssertionJSON }; + }, +) { + try { + const response = await axios.post<{ redirect_path: string }>( + url, + JSON.stringify(body), + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-Token': getCSRFToken(), + }, + }, + ); + + window.location.replace(response.data.redirect_path); + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 422) { + const errorMessage = document.getElementById( + 'security-key-error-message', + ); + errorMessage?.classList.remove('hidden'); + + logAxiosResponseError(error); + } else { + console.error(error); + } + } +} + +async function handleWebauthnCredentialRegistration(nickname: string) { + try { + const response = await axios.get( + '/settings/security_keys/options', + ); + + const credentialOptions = response.data; + + try { + const credential = await WebAuthnJSON.create({ + publicKey: credentialOptions, + }); + + const params = { + credential: credential, + nickname: nickname, + }; + + await callback('/settings/security_keys', params); + } catch (error) { + const errorMessage = document.getElementById( + 'security-key-error-message', + ); + errorMessage?.classList.remove('hidden'); + console.error(error); + } + } catch (error) { + logAxiosResponseError(error); + } +} + +async function handleWebauthnCredentialAuthentication() { + try { + const response = await axios.get( + 'sessions/security_key_options', + ); + + const credentialOptions = response.data; + + try { + const credential = await WebAuthnJSON.get({ + publicKey: credentialOptions, + }); + + const params = { user: { credential: credential } }; + void callback('sign_in', params); + } catch (error) { + const errorMessage = document.getElementById( + 'security-key-error-message', + ); + errorMessage?.classList.remove('hidden'); + console.error(error); + } + } catch (error) { + logAxiosResponseError(error); + } +} + +ready(() => { + if (!WebAuthnJSON.supported()) { + const unsupported_browser_message = document.getElementById( + 'unsupported-browser-message', + ); + if (unsupported_browser_message) { + unsupported_browser_message.classList.remove('hidden'); + const button = document.querySelector( + 'button.btn.js-webauthn', + ); + if (button) button.disabled = true; + } + } + + const webAuthnCredentialRegistrationForm = + document.querySelector('form#new_webauthn_credential'); + if (webAuthnCredentialRegistrationForm) { + webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + if (!(event.target instanceof HTMLFormElement)) return; + + const nickname = event.target.querySelector( + 'input[name="new_webauthn_credential[nickname]"]', + ); + + if (nickname?.value) { + void handleWebauthnCredentialRegistration(nickname.value); + } else { + nickname?.focus(); + } + }); + } + + const webAuthnCredentialAuthenticationForm = + document.getElementById('webauthn-form'); + if (webAuthnCredentialAuthenticationForm) { + webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => { + event.preventDefault(); + void handleWebauthnCredentialAuthentication(); + }); + + const otpAuthenticationForm = document.getElementById( + 'otp-authentication-form', + ); + + const linkToOtp = document.getElementById('link-to-otp'); + + linkToOtp?.addEventListener('click', () => { + webAuthnCredentialAuthenticationForm.classList.add('hidden'); + otpAuthenticationForm?.classList.remove('hidden'); + hideFlashMessages(); + }); + + const linkToWebAuthn = document.getElementById('link-to-webauthn'); + linkToWebAuthn?.addEventListener('click', () => { + otpAuthenticationForm?.classList.add('hidden'); + webAuthnCredentialAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + } +}).catch((e: unknown) => { + throw e; +}); diff --git a/app/javascript/fonts/inter/inter-variable-font-slnt-wght.woff2 b/app/javascript/fonts/inter/inter-variable-font-slnt-wght.woff2 new file mode 100644 index 0000000000..e6345f2e3d Binary files /dev/null and b/app/javascript/fonts/inter/inter-variable-font-slnt-wght.woff2 differ diff --git a/app/javascript/icons/android-chrome-144x144.png b/app/javascript/icons/android-chrome-144x144.png old mode 100755 new mode 100644 index d636e94c43..698fb4a260 Binary files a/app/javascript/icons/android-chrome-144x144.png and b/app/javascript/icons/android-chrome-144x144.png differ diff --git a/app/javascript/icons/android-chrome-192x192.png b/app/javascript/icons/android-chrome-192x192.png old mode 100755 new mode 100644 index 4a2681ffb9..2b6b632648 Binary files a/app/javascript/icons/android-chrome-192x192.png and b/app/javascript/icons/android-chrome-192x192.png differ diff --git a/app/javascript/icons/android-chrome-256x256.png b/app/javascript/icons/android-chrome-256x256.png old mode 100755 new mode 100644 index 8fab493ede..51e3849a26 Binary files a/app/javascript/icons/android-chrome-256x256.png and b/app/javascript/icons/android-chrome-256x256.png differ diff --git a/app/javascript/icons/android-chrome-36x36.png b/app/javascript/icons/android-chrome-36x36.png old mode 100755 new mode 100644 index 335d012db1..925f69c4fc Binary files a/app/javascript/icons/android-chrome-36x36.png and b/app/javascript/icons/android-chrome-36x36.png differ diff --git a/app/javascript/icons/android-chrome-384x384.png b/app/javascript/icons/android-chrome-384x384.png old mode 100755 new mode 100644 index 02b1e6fced..9d256a83cb Binary files a/app/javascript/icons/android-chrome-384x384.png and b/app/javascript/icons/android-chrome-384x384.png differ diff --git a/app/javascript/icons/android-chrome-48x48.png b/app/javascript/icons/android-chrome-48x48.png old mode 100755 new mode 100644 index 43cf411b8c..bcfe7475d0 Binary files a/app/javascript/icons/android-chrome-48x48.png and b/app/javascript/icons/android-chrome-48x48.png differ diff --git a/app/javascript/icons/android-chrome-512x512.png b/app/javascript/icons/android-chrome-512x512.png old mode 100755 new mode 100644 index 1856b80c7c..bffacfb699 Binary files a/app/javascript/icons/android-chrome-512x512.png and b/app/javascript/icons/android-chrome-512x512.png differ diff --git a/app/javascript/icons/android-chrome-72x72.png b/app/javascript/icons/android-chrome-72x72.png old mode 100755 new mode 100644 index 335008bf85..16679d5731 Binary files a/app/javascript/icons/android-chrome-72x72.png and b/app/javascript/icons/android-chrome-72x72.png differ diff --git a/app/javascript/icons/android-chrome-96x96.png b/app/javascript/icons/android-chrome-96x96.png old mode 100755 new mode 100644 index d1cb095822..9ade87cf32 Binary files a/app/javascript/icons/android-chrome-96x96.png and b/app/javascript/icons/android-chrome-96x96.png differ diff --git a/app/javascript/icons/apple-touch-icon-1024x1024.png b/app/javascript/icons/apple-touch-icon-1024x1024.png old mode 100755 new mode 100644 index c2a2d516ef..8ec371eb27 Binary files a/app/javascript/icons/apple-touch-icon-1024x1024.png and b/app/javascript/icons/apple-touch-icon-1024x1024.png differ diff --git a/app/javascript/icons/apple-touch-icon-114x114.png b/app/javascript/icons/apple-touch-icon-114x114.png old mode 100755 new mode 100644 index 218b415439..e1563f51e5 Binary files a/app/javascript/icons/apple-touch-icon-114x114.png and b/app/javascript/icons/apple-touch-icon-114x114.png differ diff --git a/app/javascript/icons/apple-touch-icon-120x120.png b/app/javascript/icons/apple-touch-icon-120x120.png old mode 100755 new mode 100644 index be53bc7c10..e9a5f5b0e5 Binary files a/app/javascript/icons/apple-touch-icon-120x120.png and b/app/javascript/icons/apple-touch-icon-120x120.png differ diff --git a/app/javascript/icons/apple-touch-icon-144x144.png b/app/javascript/icons/apple-touch-icon-144x144.png old mode 100755 new mode 100644 index cbb055732f..698fb4a260 Binary files a/app/javascript/icons/apple-touch-icon-144x144.png and b/app/javascript/icons/apple-touch-icon-144x144.png differ diff --git a/app/javascript/icons/apple-touch-icon-152x152.png b/app/javascript/icons/apple-touch-icon-152x152.png old mode 100755 new mode 100644 index 3a7975c054..0cc93cc288 Binary files a/app/javascript/icons/apple-touch-icon-152x152.png and b/app/javascript/icons/apple-touch-icon-152x152.png differ diff --git a/app/javascript/icons/apple-touch-icon-167x167.png b/app/javascript/icons/apple-touch-icon-167x167.png old mode 100755 new mode 100644 index 25be4eb5f5..9bbbf53120 Binary files a/app/javascript/icons/apple-touch-icon-167x167.png and b/app/javascript/icons/apple-touch-icon-167x167.png differ diff --git a/app/javascript/icons/apple-touch-icon-180x180.png b/app/javascript/icons/apple-touch-icon-180x180.png old mode 100755 new mode 100644 index dc0e9bc20b..329b803b91 Binary files a/app/javascript/icons/apple-touch-icon-180x180.png and b/app/javascript/icons/apple-touch-icon-180x180.png differ diff --git a/app/javascript/icons/apple-touch-icon-192x192.png b/app/javascript/icons/apple-touch-icon-192x192.png new file mode 100644 index 0000000000..2b6b632648 Binary files /dev/null and b/app/javascript/icons/apple-touch-icon-192x192.png differ diff --git a/app/javascript/icons/apple-touch-icon-256x256.png b/app/javascript/icons/apple-touch-icon-256x256.png new file mode 100644 index 0000000000..51e3849a26 Binary files /dev/null and b/app/javascript/icons/apple-touch-icon-256x256.png differ diff --git a/app/javascript/icons/apple-touch-icon-36x36.png b/app/javascript/icons/apple-touch-icon-36x36.png new file mode 100644 index 0000000000..925f69c4fc Binary files /dev/null and b/app/javascript/icons/apple-touch-icon-36x36.png differ diff --git a/app/javascript/icons/apple-touch-icon-384x384.png b/app/javascript/icons/apple-touch-icon-384x384.png new file mode 100644 index 0000000000..9d256a83cb Binary files /dev/null and b/app/javascript/icons/apple-touch-icon-384x384.png differ diff --git a/app/javascript/icons/apple-touch-icon-48x48.png b/app/javascript/icons/apple-touch-icon-48x48.png new file mode 100644 index 0000000000..bcfe7475d0 Binary files /dev/null and b/app/javascript/icons/apple-touch-icon-48x48.png differ diff --git a/app/javascript/icons/apple-touch-icon-512x512.png b/app/javascript/icons/apple-touch-icon-512x512.png new file mode 100644 index 0000000000..bffacfb699 Binary files /dev/null and b/app/javascript/icons/apple-touch-icon-512x512.png differ diff --git a/app/javascript/icons/apple-touch-icon-57x57.png b/app/javascript/icons/apple-touch-icon-57x57.png old mode 100755 new mode 100644 index bb0dc957cd..e00e142c64 Binary files a/app/javascript/icons/apple-touch-icon-57x57.png and b/app/javascript/icons/apple-touch-icon-57x57.png differ diff --git a/app/javascript/icons/apple-touch-icon-60x60.png b/app/javascript/icons/apple-touch-icon-60x60.png old mode 100755 new mode 100644 index 9143a0bf07..011285b564 Binary files a/app/javascript/icons/apple-touch-icon-60x60.png and b/app/javascript/icons/apple-touch-icon-60x60.png differ diff --git a/app/javascript/icons/apple-touch-icon-72x72.png b/app/javascript/icons/apple-touch-icon-72x72.png old mode 100755 new mode 100644 index 2b7d19484c..16679d5731 Binary files a/app/javascript/icons/apple-touch-icon-72x72.png and b/app/javascript/icons/apple-touch-icon-72x72.png differ diff --git a/app/javascript/icons/apple-touch-icon-76x76.png b/app/javascript/icons/apple-touch-icon-76x76.png old mode 100755 new mode 100644 index 0985e33bcb..83c8748876 Binary files a/app/javascript/icons/apple-touch-icon-76x76.png and b/app/javascript/icons/apple-touch-icon-76x76.png differ diff --git a/app/javascript/icons/apple-touch-icon-96x96.png b/app/javascript/icons/apple-touch-icon-96x96.png new file mode 100644 index 0000000000..9ade87cf32 Binary files /dev/null and b/app/javascript/icons/apple-touch-icon-96x96.png differ diff --git a/app/javascript/icons/favicon-16x16.png b/app/javascript/icons/favicon-16x16.png old mode 100755 new mode 100644 index 1326ba0462..7f865cfe96 Binary files a/app/javascript/icons/favicon-16x16.png and b/app/javascript/icons/favicon-16x16.png differ diff --git a/app/javascript/icons/favicon-32x32.png b/app/javascript/icons/favicon-32x32.png old mode 100755 new mode 100644 index f5058cb0a5..7f865cfe96 Binary files a/app/javascript/icons/favicon-32x32.png and b/app/javascript/icons/favicon-32x32.png differ diff --git a/app/javascript/icons/favicon-48x48.png b/app/javascript/icons/favicon-48x48.png old mode 100755 new mode 100644 index 6253d054c7..7f865cfe96 Binary files a/app/javascript/icons/favicon-48x48.png and b/app/javascript/icons/favicon-48x48.png differ diff --git a/app/javascript/images/archetypes/booster.png b/app/javascript/images/archetypes/booster.png new file mode 100755 index 0000000000..df2a0226f8 Binary files /dev/null and b/app/javascript/images/archetypes/booster.png differ diff --git a/app/javascript/images/archetypes/lurker.png b/app/javascript/images/archetypes/lurker.png new file mode 100755 index 0000000000..e37f98aab2 Binary files /dev/null and b/app/javascript/images/archetypes/lurker.png differ diff --git a/app/javascript/images/archetypes/oracle.png b/app/javascript/images/archetypes/oracle.png new file mode 100755 index 0000000000..9d4e2177c5 Binary files /dev/null and b/app/javascript/images/archetypes/oracle.png differ diff --git a/app/javascript/images/archetypes/pollster.png b/app/javascript/images/archetypes/pollster.png new file mode 100755 index 0000000000..9fe6281af0 Binary files /dev/null and b/app/javascript/images/archetypes/pollster.png differ diff --git a/app/javascript/images/archetypes/replier.png b/app/javascript/images/archetypes/replier.png new file mode 100755 index 0000000000..6c6325b9f1 Binary files /dev/null and b/app/javascript/images/archetypes/replier.png differ diff --git a/app/javascript/images/check.svg b/app/javascript/images/check.svg new file mode 100644 index 0000000000..8a0ebe878d --- /dev/null +++ b/app/javascript/images/check.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/javascript/images/filter-stripes.svg b/app/javascript/images/filter-stripes.svg new file mode 100755 index 0000000000..4c1b58cb74 --- /dev/null +++ b/app/javascript/images/filter-stripes.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/images/logo_full.svg b/app/javascript/images/logo_full.svg deleted file mode 100644 index 03bcf93e39..0000000000 --- a/app/javascript/images/logo_full.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/javascript/images/logo_transparent.svg b/app/javascript/images/logo_transparent.svg deleted file mode 100644 index a1e7b403e0..0000000000 --- a/app/javascript/images/logo_transparent.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/javascript/images/mailer-new/common/header-bg-end.png b/app/javascript/images/mailer-new/common/header-bg-end.png new file mode 100644 index 0000000000..900196678a Binary files /dev/null and b/app/javascript/images/mailer-new/common/header-bg-end.png differ diff --git a/app/javascript/images/mailer-new/common/header-bg-start.png b/app/javascript/images/mailer-new/common/header-bg-start.png new file mode 100644 index 0000000000..0037c1ad93 Binary files /dev/null and b/app/javascript/images/mailer-new/common/header-bg-start.png differ diff --git a/app/javascript/images/mailer-new/common/logo-footer.png b/app/javascript/images/mailer-new/common/logo-footer.png new file mode 100644 index 0000000000..2baafd8d7f Binary files /dev/null and b/app/javascript/images/mailer-new/common/logo-footer.png differ diff --git a/app/javascript/images/mailer-new/common/logo-header.png b/app/javascript/images/mailer-new/common/logo-header.png new file mode 100644 index 0000000000..46a6bddaa1 Binary files /dev/null and b/app/javascript/images/mailer-new/common/logo-header.png differ diff --git a/app/javascript/images/mailer-new/heading/2fa-disabled.png b/app/javascript/images/mailer-new/heading/2fa-disabled.png new file mode 100644 index 0000000000..b1e342a87c Binary files /dev/null and b/app/javascript/images/mailer-new/heading/2fa-disabled.png differ diff --git a/app/javascript/images/mailer-new/heading/2fa-enabled.png b/app/javascript/images/mailer-new/heading/2fa-enabled.png new file mode 100644 index 0000000000..3ce3e04f84 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/2fa-enabled.png differ diff --git a/app/javascript/images/mailer-new/heading/2fa-recovery.png b/app/javascript/images/mailer-new/heading/2fa-recovery.png new file mode 100644 index 0000000000..cefb21e1eb Binary files /dev/null and b/app/javascript/images/mailer-new/heading/2fa-recovery.png differ diff --git a/app/javascript/images/mailer-new/heading/LICENSE b/app/javascript/images/mailer-new/heading/LICENSE new file mode 100644 index 0000000000..974db1ac4b --- /dev/null +++ b/app/javascript/images/mailer-new/heading/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2024 PaweΕ‚ Kuna + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/javascript/images/mailer-new/heading/README.md b/app/javascript/images/mailer-new/heading/README.md new file mode 100644 index 0000000000..ecd4b949e7 --- /dev/null +++ b/app/javascript/images/mailer-new/heading/README.md @@ -0,0 +1 @@ +Images in this folder are based on [Tabler.io icons](https://tabler.io/icons). diff --git a/app/javascript/images/mailer-new/heading/appeal-approved.png b/app/javascript/images/mailer-new/heading/appeal-approved.png new file mode 100755 index 0000000000..b2476ec346 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/appeal-approved.png differ diff --git a/app/javascript/images/mailer-new/heading/appeal-rejected.png b/app/javascript/images/mailer-new/heading/appeal-rejected.png new file mode 100644 index 0000000000..7ae38ad0c1 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/appeal-rejected.png differ diff --git a/app/javascript/images/mailer-new/heading/archive.png b/app/javascript/images/mailer-new/heading/archive.png new file mode 100644 index 0000000000..b0c7fad84d Binary files /dev/null and b/app/javascript/images/mailer-new/heading/archive.png differ diff --git a/app/javascript/images/mailer-new/heading/boost.png b/app/javascript/images/mailer-new/heading/boost.png new file mode 100644 index 0000000000..e33b759976 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/boost.png differ diff --git a/app/javascript/images/mailer-new/heading/email.png b/app/javascript/images/mailer-new/heading/email.png new file mode 100644 index 0000000000..c922c5239e Binary files /dev/null and b/app/javascript/images/mailer-new/heading/email.png differ diff --git a/app/javascript/images/mailer-new/heading/favorite.png b/app/javascript/images/mailer-new/heading/favorite.png new file mode 100644 index 0000000000..0e483ee9b2 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/favorite.png differ diff --git a/app/javascript/images/mailer-new/heading/follow.png b/app/javascript/images/mailer-new/heading/follow.png new file mode 100644 index 0000000000..ff5b7e0042 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/follow.png differ diff --git a/app/javascript/images/mailer-new/heading/key-added.png b/app/javascript/images/mailer-new/heading/key-added.png new file mode 100755 index 0000000000..82dcd464bf Binary files /dev/null and b/app/javascript/images/mailer-new/heading/key-added.png differ diff --git a/app/javascript/images/mailer-new/heading/key-deleted.png b/app/javascript/images/mailer-new/heading/key-deleted.png new file mode 100755 index 0000000000..2930f591a0 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/key-deleted.png differ diff --git a/app/javascript/images/mailer-new/heading/key-disabled.png b/app/javascript/images/mailer-new/heading/key-disabled.png new file mode 100755 index 0000000000..e0f259359a Binary files /dev/null and b/app/javascript/images/mailer-new/heading/key-disabled.png differ diff --git a/app/javascript/images/mailer-new/heading/key-enabled.png b/app/javascript/images/mailer-new/heading/key-enabled.png new file mode 100644 index 0000000000..b2476ec346 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/key-enabled.png differ diff --git a/app/javascript/images/mailer-new/heading/login.png b/app/javascript/images/mailer-new/heading/login.png new file mode 100644 index 0000000000..89a6e9ee33 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/login.png differ diff --git a/app/javascript/images/mailer-new/heading/mention.png b/app/javascript/images/mailer-new/heading/mention.png new file mode 100644 index 0000000000..c4dccff8ef Binary files /dev/null and b/app/javascript/images/mailer-new/heading/mention.png differ diff --git a/app/javascript/images/mailer-new/heading/password.png b/app/javascript/images/mailer-new/heading/password.png new file mode 100755 index 0000000000..552c7c0687 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/password.png differ diff --git a/app/javascript/images/mailer-new/heading/user.png b/app/javascript/images/mailer-new/heading/user.png new file mode 100644 index 0000000000..f1dd58a18d Binary files /dev/null and b/app/javascript/images/mailer-new/heading/user.png differ diff --git a/app/javascript/images/mailer-new/heading/warning.png b/app/javascript/images/mailer-new/heading/warning.png new file mode 100755 index 0000000000..7764837abe Binary files /dev/null and b/app/javascript/images/mailer-new/heading/warning.png differ diff --git a/app/javascript/images/mailer-new/store-icons/btn-app-store.png b/app/javascript/images/mailer-new/store-icons/btn-app-store.png new file mode 100644 index 0000000000..ee3bd9385c Binary files /dev/null and b/app/javascript/images/mailer-new/store-icons/btn-app-store.png differ diff --git a/app/javascript/images/mailer-new/store-icons/btn-google-play.png b/app/javascript/images/mailer-new/store-icons/btn-google-play.png new file mode 100644 index 0000000000..ed43ff29aa Binary files /dev/null and b/app/javascript/images/mailer-new/store-icons/btn-google-play.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/LICENSE b/app/javascript/images/mailer-new/welcome-icons/LICENSE new file mode 100644 index 0000000000..974db1ac4b --- /dev/null +++ b/app/javascript/images/mailer-new/welcome-icons/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2024 PaweΕ‚ Kuna + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/javascript/images/mailer-new/welcome-icons/README.md b/app/javascript/images/mailer-new/welcome-icons/README.md new file mode 100644 index 0000000000..ecd4b949e7 --- /dev/null +++ b/app/javascript/images/mailer-new/welcome-icons/README.md @@ -0,0 +1 @@ +Images in this folder are based on [Tabler.io icons](https://tabler.io/icons). diff --git a/app/javascript/images/mailer-new/welcome-icons/apps_step-off.png b/app/javascript/images/mailer-new/welcome-icons/apps_step-off.png new file mode 100755 index 0000000000..ca270f5478 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/apps_step-off.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/apps_step-on.png b/app/javascript/images/mailer-new/welcome-icons/apps_step-on.png new file mode 100644 index 0000000000..fd631bf97e Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/apps_step-on.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-off.png b/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-off.png new file mode 100644 index 0000000000..dfcdd04e16 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-off.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-on.png b/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-on.png new file mode 100644 index 0000000000..c3776d17df Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-on.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/follow_step-off.png b/app/javascript/images/mailer-new/welcome-icons/follow_step-off.png new file mode 100755 index 0000000000..a262454d2d Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/follow_step-off.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/follow_step-on.png b/app/javascript/images/mailer-new/welcome-icons/follow_step-on.png new file mode 100644 index 0000000000..3ac011539b Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/follow_step-on.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/post_step-off.png b/app/javascript/images/mailer-new/welcome-icons/post_step-off.png new file mode 100755 index 0000000000..972de65a56 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/post_step-off.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/post_step-on.png b/app/javascript/images/mailer-new/welcome-icons/post_step-on.png new file mode 100644 index 0000000000..aa318e66c8 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/post_step-on.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/share_step-off.png b/app/javascript/images/mailer-new/welcome-icons/share_step-off.png new file mode 100755 index 0000000000..f45e9a2c9a Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/share_step-off.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/share_step-on.png b/app/javascript/images/mailer-new/welcome-icons/share_step-on.png new file mode 100644 index 0000000000..98782d9317 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/share_step-on.png differ diff --git a/app/javascript/images/mailer-new/welcome/checkbox-off.png b/app/javascript/images/mailer-new/welcome/checkbox-off.png new file mode 100644 index 0000000000..51c190efe6 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/checkbox-off.png differ diff --git a/app/javascript/images/mailer-new/welcome/checkbox-on.png b/app/javascript/images/mailer-new/welcome/checkbox-on.png new file mode 100644 index 0000000000..162095e7df Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/checkbox-on.png differ diff --git a/app/javascript/images/mailer-new/welcome/feature_audience.png b/app/javascript/images/mailer-new/welcome/feature_audience.png new file mode 100644 index 0000000000..902de133b4 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/feature_audience.png differ diff --git a/app/javascript/images/mailer-new/welcome/feature_control.png b/app/javascript/images/mailer-new/welcome/feature_control.png new file mode 100644 index 0000000000..1afb6c238c Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/feature_control.png differ diff --git a/app/javascript/images/mailer-new/welcome/feature_creativity.png b/app/javascript/images/mailer-new/welcome/feature_creativity.png new file mode 100644 index 0000000000..3365856699 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/feature_creativity.png differ diff --git a/app/javascript/images/mailer-new/welcome/feature_moderation.png b/app/javascript/images/mailer-new/welcome/feature_moderation.png new file mode 100644 index 0000000000..7cee9b29b8 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/feature_moderation.png differ diff --git a/app/javascript/images/mailer-new/welcome/purple-extra-soft-spacer.png b/app/javascript/images/mailer-new/welcome/purple-extra-soft-spacer.png new file mode 100644 index 0000000000..ec1ad5c957 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/purple-extra-soft-spacer.png differ diff --git a/app/javascript/images/mailer-new/welcome/purple-extra-soft-wave.png b/app/javascript/images/mailer-new/welcome/purple-extra-soft-wave.png new file mode 100644 index 0000000000..ba8f6dd3d9 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/purple-extra-soft-wave.png differ diff --git a/app/javascript/images/quote-stripes.svg b/app/javascript/images/quote-stripes.svg new file mode 100755 index 0000000000..1234d4d0a6 --- /dev/null +++ b/app/javascript/images/quote-stripes.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/javascript/images/quote.svg b/app/javascript/images/quote.svg new file mode 100644 index 0000000000..ae6fbbe04a --- /dev/null +++ b/app/javascript/images/quote.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/images/reticle.png b/app/javascript/images/reticle.png deleted file mode 100644 index a724ac0bcd..0000000000 Binary files a/app/javascript/images/reticle.png and /dev/null differ diff --git a/app/javascript/images/warning-stripes.svg b/app/javascript/images/warning-stripes.svg new file mode 100755 index 0000000000..9d68acdada --- /dev/null +++ b/app/javascript/images/warning-stripes.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/mastodon/actions/account_notes.ts b/app/javascript/mastodon/actions/account_notes.ts index e524e5235b..9e7d199dc9 100644 --- a/app/javascript/mastodon/actions/account_notes.ts +++ b/app/javascript/mastodon/actions/account_notes.ts @@ -1,18 +1,9 @@ -import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships'; -import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; +import { apiSubmitAccountNote } from 'mastodon/api/accounts'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; -import api from '../api'; - -export const submitAccountNote = createAppAsyncThunk( +export const submitAccountNote = createDataLoadingThunk( 'account_note/submit', - async (args: { id: string; value: string }, { getState }) => { - const response = await api(getState).post( - `/api/v1/accounts/${args.id}/note`, - { - comment: args.value, - }, - ); - - return { relationship: response.data }; - }, + ({ accountId, note }: { accountId: string; note: string }) => + apiSubmitAccountNote(accountId, note), + (relationship) => ({ relationship }), ); diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 9f3bbba033..d821381ce0 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 { @@ -76,11 +79,11 @@ export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL'; export * from './accounts_typed'; export function fetchAccount(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchRelationships([id])); dispatch(fetchAccountRequest(id)); - api(getState).get(`/api/v1/accounts/${id}`).then(response => { + api().get(`/api/v1/accounts/${id}`).then(response => { dispatch(importFetchedAccount(response.data)); dispatch(fetchAccountSuccess()); }).catch(error => { @@ -89,10 +92,10 @@ export function fetchAccount(id) { }; } -export const lookupAccount = acct => (dispatch, getState) => { +export const lookupAccount = acct => (dispatch) => { dispatch(lookupAccountRequest(acct)); - api(getState).get('/api/v1/accounts/lookup', { params: { acct } }).then(response => { + api().get('/api/v1/accounts/lookup', { params: { acct } }).then(response => { dispatch(fetchRelationships([response.data.id])); dispatch(importFetchedAccount(response.data)); dispatch(lookupAccountSuccess()); @@ -139,6 +142,13 @@ export function fetchAccountFail(id, error) { }; } +/** + * @param {string} id + * @param {Object} options + * @param {boolean} [options.reblogs] + * @param {boolean} [options.notify] + * @returns {function(): void} + */ export function followAccount(id, options = { reblogs: true }) { return (dispatch, getState) => { const alreadyFollowing = getState().getIn(['relationships', id, 'following']); @@ -146,7 +156,7 @@ export function followAccount(id, options = { reblogs: true }) { dispatch(followAccountRequest({ id, locked })); - api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => { + api().post(`/api/v1/accounts/${id}/follow`, options).then(response => { dispatch(followAccountSuccess({relationship: response.data, alreadyFollowing})); }).catch(error => { dispatch(followAccountFail({ id, error, locked })); @@ -158,7 +168,7 @@ export function unfollowAccount(id) { return (dispatch, getState) => { dispatch(unfollowAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { + api().post(`/api/v1/accounts/${id}/unfollow`).then(response => { dispatch(unfollowAccountSuccess({relationship: response.data, statuses: getState().get('statuses')})); }).catch(error => { dispatch(unfollowAccountFail({ id, error })); @@ -170,7 +180,7 @@ export function blockAccount(id) { return (dispatch, getState) => { dispatch(blockAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { + api().post(`/api/v1/accounts/${id}/block`).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(blockAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') })); }).catch(error => { @@ -180,10 +190,10 @@ export function blockAccount(id) { } export function unblockAccount(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(unblockAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => { + api().post(`/api/v1/accounts/${id}/unblock`).then(response => { dispatch(unblockAccountSuccess({ relationship: response.data })); }).catch(error => { dispatch(unblockAccountFail({ id, error })); @@ -223,7 +233,7 @@ export function muteAccount(id, notifications, duration=0) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => { + api().post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(muteAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') })); }).catch(error => { @@ -233,10 +243,10 @@ export function muteAccount(id, notifications, duration=0) { } export function unmuteAccount(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(unmuteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { + api().post(`/api/v1/accounts/${id}/unmute`).then(response => { dispatch(unmuteAccountSuccess({ relationship: response.data })); }).catch(error => { dispatch(unmuteAccountFail({ id, error })); @@ -274,10 +284,10 @@ export function unmuteAccountFail(error) { export function fetchFollowers(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchFollowersRequest(id)); - api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { + api().get(`/api/v1/accounts/${id}/followers`).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); @@ -324,7 +334,7 @@ export function expandFollowers(id) { dispatch(expandFollowersRequest(id)); - api(getState).get(url).then(response => { + api().get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); @@ -361,10 +371,10 @@ export function expandFollowersFail(id, error) { } export function fetchFollowing(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchFollowingRequest(id)); - api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { + api().get(`/api/v1/accounts/${id}/following`).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); @@ -411,7 +421,7 @@ export function expandFollowing(id) { dispatch(expandFollowingRequest(id)); - api(getState).get(url).then(response => { + api().get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); @@ -447,6 +457,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 +482,7 @@ export function fetchRelationships(accountIds) { return; } - dispatch(fetchRelationshipsRequest(newAccountIds)); - - api(getState).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); }; } @@ -486,10 +504,10 @@ export function fetchRelationshipsFail(error) { } export function fetchFollowRequests() { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchFollowRequestsRequest()); - api(getState).get('/api/v1/follow_requests').then(response => { + api().get('/api/v1/follow_requests').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); @@ -528,7 +546,7 @@ export function expandFollowRequests() { dispatch(expandFollowRequestsRequest()); - api(getState).get(url).then(response => { + api().get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); @@ -558,10 +576,10 @@ export function expandFollowRequestsFail(error) { } export function authorizeFollowRequest(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(authorizeFollowRequestRequest(id)); - api(getState) + api() .post(`/api/v1/follow_requests/${id}/authorize`) .then(() => dispatch(authorizeFollowRequestSuccess({ id }))) .catch(error => dispatch(authorizeFollowRequestFail(id, error))); @@ -585,10 +603,10 @@ export function authorizeFollowRequestFail(id, error) { export function rejectFollowRequest(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(rejectFollowRequestRequest(id)); - api(getState) + api() .post(`/api/v1/follow_requests/${id}/reject`) .then(() => dispatch(rejectFollowRequestSuccess({ id }))) .catch(error => dispatch(rejectFollowRequestFail(id, error))); @@ -611,10 +629,10 @@ export function rejectFollowRequestFail(id, error) { } export function pinAccount(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(pinAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { + api().post(`/api/v1/accounts/${id}/pin`).then(response => { dispatch(pinAccountSuccess({ relationship: response.data })); }).catch(error => { dispatch(pinAccountFail(error)); @@ -623,10 +641,10 @@ export function pinAccount(id) { } export function unpinAccount(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(unpinAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { + api().post(`/api/v1/accounts/${id}/unpin`).then(response => { dispatch(unpinAccountSuccess({ relationship: response.data })); }).catch(error => { dispatch(unpinAccountFail(error)); @@ -662,7 +680,7 @@ export function unpinAccountFail(error) { }; } -export const updateAccount = ({ displayName, note, avatar, header, discoverable, indexable }) => (dispatch, getState) => { +export const updateAccount = ({ displayName, note, avatar, header, discoverable, indexable }) => (dispatch) => { const data = new FormData(); data.append('display_name', displayName); @@ -672,7 +690,17 @@ export const updateAccount = ({ displayName, note, avatar, header, discoverable, data.append('discoverable', discoverable); data.append('indexable', indexable); - return api(getState).patch('/api/v1/accounts/update_credentials', data).then(response => { + return api().patch('/api/v1/accounts/update_credentials', data).then(response => { 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 deleted file mode 100644 index 42834146bf..0000000000 --- a/app/javascript/mastodon/actions/alerts.js +++ /dev/null @@ -1,59 +0,0 @@ -import { defineMessages } from 'react-intl'; - -const messages = defineMessages({ - unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, - unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, - rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' }, - rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' }, -}); - -export const ALERT_SHOW = 'ALERT_SHOW'; -export const ALERT_DISMISS = 'ALERT_DISMISS'; -export const ALERT_CLEAR = 'ALERT_CLEAR'; -export const ALERT_NOOP = 'ALERT_NOOP'; - -export const dismissAlert = alert => ({ - type: ALERT_DISMISS, - alert, -}); - -export const clearAlert = () => ({ - type: ALERT_CLEAR, -}); - -export const showAlert = alert => ({ - type: ALERT_SHOW, - alert, -}); - -export const showAlertForError = (error, skipNotFound = false) => { - if (error.response) { - const { data, status, statusText, headers } = error.response; - - // Skip these errors as they are reflected in the UI - if (skipNotFound && (status === 404 || status === 410)) { - return { type: ALERT_NOOP }; - } - - // Rate limit errors - if (status === 429 && headers['x-ratelimit-reset']) { - return showAlert({ - title: messages.rateLimitedTitle, - message: messages.rateLimitedMessage, - values: { 'retry_time': new Date(headers['x-ratelimit-reset']) }, - }); - } - - return showAlert({ - title: `${status}`, - message: data.error || statusText, - }); - } - - console.error(error); - - return showAlert({ - title: messages.unexpectedTitle, - message: messages.unexpectedMessage, - }); -}; diff --git a/app/javascript/mastodon/actions/alerts.ts b/app/javascript/mastodon/actions/alerts.ts new file mode 100644 index 0000000000..4fd293e252 --- /dev/null +++ b/app/javascript/mastodon/actions/alerts.ts @@ -0,0 +1,76 @@ +import { defineMessages } from 'react-intl'; + +import { createAction } from '@reduxjs/toolkit'; + +import { AxiosError } from 'axios'; +import type { AxiosResponse } from 'axios'; + +import type { Alert } from 'mastodon/models/alert'; + +interface ApiErrorResponse { + error?: string; +} + +const messages = defineMessages({ + unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, + unexpectedMessage: { + id: 'alert.unexpected.message', + defaultMessage: 'An unexpected error occurred.', + }, + rateLimitedTitle: { + id: 'alert.rate_limited.title', + defaultMessage: 'Rate limited', + }, + rateLimitedMessage: { + id: 'alert.rate_limited.message', + defaultMessage: 'Please retry after {retry_time, time, medium}.', + }, +}); + +export const dismissAlert = createAction<{ key: number }>('alerts/dismiss'); + +export const clearAlerts = createAction('alerts/clear'); + +export const showAlert = createAction>('alerts/show'); + +const ignoreAlert = createAction('alerts/ignore'); + +export const showAlertForError = (error: unknown, skipNotFound = false) => { + if (error instanceof AxiosError && error.response) { + const { status, statusText, headers } = error.response; + const { data } = error.response as AxiosResponse; + + // Skip these errors as they are reflected in the UI + if (skipNotFound && (status === 404 || status === 410)) { + return ignoreAlert(); + } + + // Rate limit errors + if (status === 429 && headers['x-ratelimit-reset']) { + return showAlert({ + title: messages.rateLimitedTitle, + message: messages.rateLimitedMessage, + values: { + retry_time: new Date(headers['x-ratelimit-reset'] as string), + }, + }); + } + + return showAlert({ + title: `${status}`, + message: data.error ?? statusText, + }); + } + + // An aborted request, e.g. due to reloading the browser window, is not really an error + if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) { + return ignoreAlert(); + } + + console.error(error); + + return showAlert({ + title: messages.unexpectedTitle, + message: messages.unexpectedMessage, + }); +}; diff --git a/app/javascript/mastodon/actions/announcements.js b/app/javascript/mastodon/actions/announcements.js index 339c5f3adc..7657b05dc4 100644 --- a/app/javascript/mastodon/actions/announcements.js +++ b/app/javascript/mastodon/actions/announcements.js @@ -26,10 +26,10 @@ export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW'; const noOp = () => {}; -export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => { +export const fetchAnnouncements = (done = noOp) => (dispatch) => { dispatch(fetchAnnouncementsRequest()); - api(getState).get('/api/v1/announcements').then(response => { + api().get('/api/v1/announcements').then(response => { dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x)))); }).catch(error => { dispatch(fetchAnnouncementsFail(error)); @@ -61,10 +61,10 @@ export const updateAnnouncements = announcement => ({ announcement: normalizeAnnouncement(announcement), }); -export const dismissAnnouncement = announcementId => (dispatch, getState) => { +export const dismissAnnouncement = announcementId => (dispatch) => { dispatch(dismissAnnouncementRequest(announcementId)); - api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { + api().post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { dispatch(dismissAnnouncementSuccess(announcementId)); }).catch(error => { dispatch(dismissAnnouncementFail(announcementId, error)); @@ -103,7 +103,7 @@ export const addReaction = (announcementId, name) => (dispatch, getState) => { dispatch(addReactionRequest(announcementId, name, alreadyAdded)); } - api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => { + api().put(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => { dispatch(addReactionSuccess(announcementId, name, alreadyAdded)); }).catch(err => { if (!alreadyAdded) { @@ -134,10 +134,10 @@ export const addReactionFail = (announcementId, name, error) => ({ skipLoading: true, }); -export const removeReaction = (announcementId, name) => (dispatch, getState) => { +export const removeReaction = (announcementId, name) => (dispatch) => { dispatch(removeReactionRequest(announcementId, name)); - api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => { + api().delete(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => { dispatch(removeReactionSuccess(announcementId, name)); }).catch(err => { dispatch(removeReactionFail(announcementId, name, err)); diff --git a/app/javascript/mastodon/actions/antennas.js b/app/javascript/mastodon/actions/antennas.js index 4716897586..30dc2d42c6 100644 --- a/app/javascript/mastodon/actions/antennas.js +++ b/app/javascript/mastodon/actions/antennas.js @@ -1,8 +1,5 @@ import api from '../api'; -import { showAlertForError } from './alerts'; -import { importFetchedAccounts } from './importer'; - export const ANTENNA_FETCH_REQUEST = 'ANTENNA_FETCH_REQUEST'; export const ANTENNA_FETCH_SUCCESS = 'ANTENNA_FETCH_SUCCESS'; export const ANTENNA_FETCH_FAIL = 'ANTENNA_FETCH_FAIL'; @@ -11,121 +8,10 @@ export const ANTENNAS_FETCH_REQUEST = 'ANTENNAS_FETCH_REQUEST'; export const ANTENNAS_FETCH_SUCCESS = 'ANTENNAS_FETCH_SUCCESS'; export const ANTENNAS_FETCH_FAIL = 'ANTENNAS_FETCH_FAIL'; -export const ANTENNA_EDITOR_TITLE_CHANGE = 'ANTENNA_EDITOR_TITLE_CHANGE'; -export const ANTENNA_EDITOR_RESET = 'ANTENNA_EDITOR_RESET'; -export const ANTENNA_EDITOR_SETUP = 'ANTENNA_EDITOR_SETUP'; - -export const ANTENNA_CREATE_REQUEST = 'ANTENNA_CREATE_REQUEST'; -export const ANTENNA_CREATE_SUCCESS = 'ANTENNA_CREATE_SUCCESS'; -export const ANTENNA_CREATE_FAIL = 'ANTENNA_CREATE_FAIL'; - -export const ANTENNA_UPDATE_REQUEST = 'ANTENNA_UPDATE_REQUEST'; -export const ANTENNA_UPDATE_SUCCESS = 'ANTENNA_UPDATE_SUCCESS'; -export const ANTENNA_UPDATE_FAIL = 'ANTENNA_UPDATE_FAIL'; - export const ANTENNA_DELETE_REQUEST = 'ANTENNA_DELETE_REQUEST'; export const ANTENNA_DELETE_SUCCESS = 'ANTENNA_DELETE_SUCCESS'; export const ANTENNA_DELETE_FAIL = 'ANTENNA_DELETE_FAIL'; -export const ANTENNA_ACCOUNTS_FETCH_REQUEST = 'ANTENNA_ACCOUNTS_FETCH_REQUEST'; -export const ANTENNA_ACCOUNTS_FETCH_SUCCESS = 'ANTENNA_ACCOUNTS_FETCH_SUCCESS'; -export const ANTENNA_ACCOUNTS_FETCH_FAIL = 'ANTENNA_ACCOUNTS_FETCH_FAIL'; - -export const ANTENNA_EDITOR_SUGGESTIONS_CHANGE = 'ANTENNA_EDITOR_SUGGESTIONS_CHANGE'; -export const ANTENNA_EDITOR_SUGGESTIONS_READY = 'ANTENNA_EDITOR_SUGGESTIONS_READY'; -export const ANTENNA_EDITOR_SUGGESTIONS_CLEAR = 'ANTENNA_EDITOR_SUGGESTIONS_CLEAR'; - -export const ANTENNA_EDITOR_ADD_REQUEST = 'ANTENNA_EDITOR_ADD_REQUEST'; -export const ANTENNA_EDITOR_ADD_SUCCESS = 'ANTENNA_EDITOR_ADD_SUCCESS'; -export const ANTENNA_EDITOR_ADD_FAIL = 'ANTENNA_EDITOR_ADD_FAIL'; - -export const ANTENNA_EDITOR_REMOVE_REQUEST = 'ANTENNA_EDITOR_REMOVE_REQUEST'; -export const ANTENNA_EDITOR_REMOVE_SUCCESS = 'ANTENNA_EDITOR_REMOVE_SUCCESS'; -export const ANTENNA_EDITOR_REMOVE_FAIL = 'ANTENNA_EDITOR_REMOVE_FAIL'; - -export const ANTENNA_EXCLUDE_ACCOUNTS_FETCH_REQUEST = 'ANTENNA_EXCLUDE_ACCOUNTS_FETCH_REQUEST'; -export const ANTENNA_EXCLUDE_ACCOUNTS_FETCH_SUCCESS = 'ANTENNA_EXCLUDE_ACCOUNTS_FETCH_SUCCESS'; -export const ANTENNA_EXCLUDE_ACCOUNTS_FETCH_FAIL = 'ANTENNA_EXCLUDE_ACCOUNTS_FETCH_FAIL'; - -export const ANTENNA_EDITOR_ADD_EXCLUDE_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDE_REQUEST'; -export const ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS'; -export const ANTENNA_EDITOR_ADD_EXCLUDE_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_FAIL'; - -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_REQUEST'; -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS'; -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_FAIL'; - -export const ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST = 'ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST'; -export const ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS = 'ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS'; -export const ANTENNA_EDITOR_FETCH_DOMAINS_FAIL = 'ANTENNA_EDITOR_FETCH_DOMAINS_FAIL'; - -export const ANTENNA_EDITOR_ADD_DOMAIN_REQUEST = 'ANTENNA_EDITOR_ADD_DOMAIN_REQUEST'; -export const ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS'; -export const ANTENNA_EDITOR_ADD_DOMAIN_FAIL = 'ANTENNA_EDITOR_ADD_DOMAIN_FAIL'; - -export const ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDEDOMAIN_REQUEST'; -export const ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_SUCCESS'; -export const ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_FAIL'; - -export const ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST = 'ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST'; -export const ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS'; -export const ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL = 'ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL'; - -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_REQUEST'; -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_SUCCESS'; -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_FAIL'; - -export const ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST = 'ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST'; -export const ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS = 'ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS'; -export const ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL = 'ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL'; - -export const ANTENNA_EDITOR_ADD_KEYWORD_REQUEST = 'ANTENNA_EDITOR_ADD_KEYWORD_REQUEST'; -export const ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS'; -export const ANTENNA_EDITOR_ADD_KEYWORD_FAIL = 'ANTENNA_EDITOR_ADD_KEYWORD_FAIL'; - -export const ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_REQUEST'; -export const ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_SUCCESS'; -export const ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_FAIL'; - -export const ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST'; -export const ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS'; -export const ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL = 'ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL'; - -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_REQUEST'; -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS'; -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL'; - -export const ANTENNA_EDITOR_FETCH_TAGS_REQUEST = 'ANTENNA_EDITOR_FETCH_TAGS_REQUEST'; -export const ANTENNA_EDITOR_FETCH_TAGS_SUCCESS = 'ANTENNA_EDITOR_FETCH_TAGS_SUCCESS'; -export const ANTENNA_EDITOR_FETCH_TAGS_FAIL = 'ANTENNA_EDITOR_FETCH_TAGS_FAIL'; - -export const ANTENNA_EDITOR_ADD_TAG_REQUEST = 'ANTENNA_EDITOR_ADD_TAG_REQUEST'; -export const ANTENNA_EDITOR_ADD_TAG_SUCCESS = 'ANTENNA_EDITOR_ADD_TAG_SUCCESS'; -export const ANTENNA_EDITOR_ADD_TAG_FAIL = 'ANTENNA_EDITOR_ADD_TAG_FAIL'; - -export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST'; -export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS'; -export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL'; - -export const ANTENNA_EDITOR_REMOVE_TAG_REQUEST = 'ANTENNA_EDITOR_REMOVE_TAG_REQUEST'; -export const ANTENNA_EDITOR_REMOVE_TAG_SUCCESS = 'ANTENNA_EDITOR_REMOVE_TAG_SUCCESS'; -export const ANTENNA_EDITOR_REMOVE_TAG_FAIL = 'ANTENNA_EDITOR_REMOVE_TAG_FAIL'; - -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST'; -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS'; -export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL'; - -export const ANTENNA_ADDER_RESET = 'ANTENNA_ADDER_RESET'; -export const ANTENNA_ADDER_SETUP = 'ANTENNA_ADDER_SETUP'; - -export const ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST'; -export const ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS'; -export const ANTENNA_ADDER_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_ANTENNAS_FETCH_FAIL'; - -export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST'; -export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS'; -export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL'; - export const fetchAntenna = id => (dispatch, getState) => { if (getState().getIn(['antennas', id])) { return; @@ -176,98 +62,6 @@ export const fetchAntennasFail = error => ({ error, }); -export const submitAntennaEditor = shouldReset => (dispatch, getState) => { - const antennaId = getState().getIn(['antennaEditor', 'antennaId']); - const title = getState().getIn(['antennaEditor', 'title']); - - if (antennaId === null) { - dispatch(createAntenna(title, shouldReset)); - } else { - dispatch(updateAntenna(antennaId, title, shouldReset)); - } -}; - -export const setupAntennaEditor = antennaId => (dispatch, getState) => { - dispatch({ - type: ANTENNA_EDITOR_SETUP, - antenna: getState().getIn(['antennas', antennaId]), - }); - - dispatch(fetchAntennaAccounts(antennaId)); -}; - -export const setupExcludeAntennaEditor = antennaId => (dispatch, getState) => { - dispatch({ - type: ANTENNA_EDITOR_SETUP, - antenna: getState().getIn(['antennas', antennaId]), - }); - - dispatch(fetchAntennaExcludeAccounts(antennaId)); -}; - -export const changeAntennaEditorTitle = value => ({ - type: ANTENNA_EDITOR_TITLE_CHANGE, - value, -}); - -export const createAntenna = (title, shouldReset) => (dispatch, getState) => { - dispatch(createAntennaRequest()); - - api(getState).post('/api/v1/antennas', { title }).then(({ data }) => { - dispatch(createAntennaSuccess(data)); - - if (shouldReset) { - dispatch(resetAntennaEditor()); - } - }).catch(err => dispatch(createAntennaFail(err))); -}; - -export const createAntennaRequest = () => ({ - type: ANTENNA_CREATE_REQUEST, -}); - -export const createAntennaSuccess = antenna => ({ - type: ANTENNA_CREATE_SUCCESS, - antenna, -}); - -export const createAntennaFail = error => ({ - type: ANTENNA_CREATE_FAIL, - error, -}); - -export const updateAntenna = (id, title, shouldReset, list_id, stl, ltl, with_media_only, ignore_reblog, insert_feeds) => (dispatch, getState) => { - dispatch(updateAntennaRequest(id)); - - api(getState).put(`/api/v1/antennas/${id}`, { title, list_id, stl, ltl, with_media_only, ignore_reblog, insert_feeds }).then(({ data }) => { - dispatch(updateAntennaSuccess(data)); - - if (shouldReset) { - dispatch(resetAntennaEditor()); - } - }).catch(err => dispatch(updateAntennaFail(id, err))); -}; - -export const updateAntennaRequest = id => ({ - type: ANTENNA_UPDATE_REQUEST, - id, -}); - -export const updateAntennaSuccess = antenna => ({ - type: ANTENNA_UPDATE_SUCCESS, - antenna, -}); - -export const updateAntennaFail = (id, error) => ({ - type: ANTENNA_UPDATE_FAIL, - id, - error, -}); - -export const resetAntennaEditor = () => ({ - type: ANTENNA_EDITOR_RESET, -}); - export const deleteAntenna = id => (dispatch, getState) => { dispatch(deleteAntennaRequest(id)); @@ -291,696 +85,3 @@ export const deleteAntennaFail = (id, error) => ({ id, error, }); - -export const fetchAntennaAccounts = antennaId => (dispatch, getState) => { - dispatch(fetchAntennaAccountsRequest(antennaId)); - - api(getState).get(`/api/v1/antennas/${antennaId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchAntennaAccountsSuccess(antennaId, data)); - }).catch(err => dispatch(fetchAntennaAccountsFail(antennaId, err))); -}; - -export const fetchAntennaAccountsRequest = id => ({ - type: ANTENNA_ACCOUNTS_FETCH_REQUEST, - id, -}); - -export const fetchAntennaAccountsSuccess = (id, accounts, next) => ({ - type: ANTENNA_ACCOUNTS_FETCH_SUCCESS, - id, - accounts, - next, -}); - -export const fetchAntennaAccountsFail = (id, error) => ({ - type: ANTENNA_ACCOUNTS_FETCH_FAIL, - id, - error, -}); - -export const fetchAntennaExcludeAccounts = antennaId => (dispatch, getState) => { - dispatch(fetchAntennaExcludeAccountsRequest(antennaId)); - - api(getState).get(`/api/v1/antennas/${antennaId}/exclude_accounts`, { params: { limit: 0 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchAntennaExcludeAccountsSuccess(antennaId, data)); - }).catch(err => dispatch(fetchAntennaExcludeAccountsFail(antennaId, err))); -}; - -export const fetchAntennaExcludeAccountsRequest = id => ({ - type: ANTENNA_EXCLUDE_ACCOUNTS_FETCH_REQUEST, - id, -}); - -export const fetchAntennaExcludeAccountsSuccess = (id, accounts, next) => ({ - type: ANTENNA_EXCLUDE_ACCOUNTS_FETCH_SUCCESS, - id, - accounts, - next, -}); - -export const fetchAntennaExcludeAccountsFail = (id, error) => ({ - type: ANTENNA_EXCLUDE_ACCOUNTS_FETCH_FAIL, - id, - error, -}); - -export const fetchAntennaSuggestions = q => (dispatch, getState) => { - const params = { - q, - resolve: false, - }; - - api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchAntennaSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); -}; - -export const fetchAntennaSuggestionsReady = (query, accounts) => ({ - type: ANTENNA_EDITOR_SUGGESTIONS_READY, - query, - accounts, -}); - -export const clearAntennaSuggestions = () => ({ - type: ANTENNA_EDITOR_SUGGESTIONS_CLEAR, -}); - -export const changeAntennaSuggestions = value => ({ - type: ANTENNA_EDITOR_SUGGESTIONS_CHANGE, - value, -}); - -export const addToAntennaEditor = accountId => (dispatch, getState) => { - dispatch(addToAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); -}; - -export const addToAntenna = (antennaId, accountId) => (dispatch, getState) => { - dispatch(addToAntennaRequest(antennaId, accountId)); - - api(getState).post(`/api/v1/antennas/${antennaId}/accounts`, { account_ids: [accountId] }) - .then(() => dispatch(addToAntennaSuccess(antennaId, accountId))) - .catch(err => dispatch(addToAntennaFail(antennaId, accountId, err))); -}; - -export const addToAntennaRequest = (antennaId, accountId) => ({ - type: ANTENNA_EDITOR_ADD_REQUEST, - antennaId, - accountId, -}); - -export const addToAntennaSuccess = (antennaId, accountId) => ({ - type: ANTENNA_EDITOR_ADD_SUCCESS, - antennaId, - accountId, -}); - -export const addToAntennaFail = (antennaId, accountId, error) => ({ - type: ANTENNA_EDITOR_ADD_FAIL, - antennaId, - accountId, - error, -}); - -export const addExcludeToAntennaEditor = accountId => (dispatch, getState) => { - dispatch(addExcludeToAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); -}; - -export const addExcludeToAntenna = (antennaId, accountId) => (dispatch, getState) => { - dispatch(addExcludeToAntennaRequest(antennaId, accountId)); - - api(getState).post(`/api/v1/antennas/${antennaId}/exclude_accounts`, { account_ids: [accountId] }) - .then(() => dispatch(addExcludeToAntennaSuccess(antennaId, accountId))) - .catch(err => dispatch(addExcludeToAntennaFail(antennaId, accountId, err))); -}; - -export const addExcludeToAntennaRequest = (antennaId, accountId) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_REQUEST, - antennaId, - accountId, -}); - -export const addExcludeToAntennaSuccess = (antennaId, accountId) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS, - antennaId, - accountId, -}); - -export const addExcludeToAntennaFail = (antennaId, accountId, error) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_FAIL, - antennaId, - accountId, - error, -}); - -export const removeFromAntennaEditor = accountId => (dispatch, getState) => { - dispatch(removeFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); -}; - -export const removeFromAntenna = (antennaId, accountId) => (dispatch, getState) => { - dispatch(removeFromAntennaRequest(antennaId, accountId)); - - api(getState).delete(`/api/v1/antennas/${antennaId}/accounts`, { params: { account_ids: [accountId] } }) - .then(() => dispatch(removeFromAntennaSuccess(antennaId, accountId))) - .catch(err => dispatch(removeFromAntennaFail(antennaId, accountId, err))); -}; - -export const removeFromAntennaRequest = (antennaId, accountId) => ({ - type: ANTENNA_EDITOR_REMOVE_REQUEST, - antennaId, - accountId, -}); - -export const removeFromAntennaSuccess = (antennaId, accountId) => ({ - type: ANTENNA_EDITOR_REMOVE_SUCCESS, - antennaId, - accountId, -}); - -export const removeFromAntennaFail = (antennaId, accountId, error) => ({ - type: ANTENNA_EDITOR_REMOVE_FAIL, - antennaId, - accountId, - error, -}); - -export const removeExcludeFromAntennaEditor = accountId => (dispatch, getState) => { - dispatch(removeExcludeFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); -}; - -export const removeExcludeFromAntenna = (antennaId, accountId) => (dispatch, getState) => { - dispatch(removeExcludeFromAntennaRequest(antennaId, accountId)); - - api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_accounts`, { params: { account_ids: [accountId] } }) - .then(() => dispatch(removeExcludeFromAntennaSuccess(antennaId, accountId))) - .catch(err => dispatch(removeExcludeFromAntennaFail(antennaId, accountId, err))); -}; - -export const removeExcludeFromAntennaRequest = (antennaId, accountId) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_REQUEST, - antennaId, - accountId, -}); - -export const removeExcludeFromAntennaSuccess = (antennaId, accountId) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS, - antennaId, - accountId, -}); - -export const removeExcludeFromAntennaFail = (antennaId, accountId, error) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_FAIL, - antennaId, - accountId, - error, -}); - -export const fetchAntennaDomains = antennaId => (dispatch, getState) => { - dispatch(fetchAntennaDomainsRequest(antennaId)); - - api(getState).get(`/api/v1/antennas/${antennaId}/domains`, { params: { limit: 0 } }).then(({ data }) => { - dispatch(fetchAntennaDomainsSuccess(antennaId, data)); - }).catch(err => dispatch(fetchAntennaDomainsFail(antennaId, err))); -}; - -export const fetchAntennaDomainsRequest = id => ({ - type: ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST, - id, -}); - -export const fetchAntennaDomainsSuccess = (id, domains) => ({ - type: ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS, - id, - domains, -}); - -export const fetchAntennaDomainsFail = (id, error) => ({ - type: ANTENNA_EDITOR_FETCH_DOMAINS_FAIL, - id, - error, -}); - -export const addDomainToAntenna = (antennaId, domain) => (dispatch, getState) => { - dispatch(addDomainToAntennaRequest(antennaId, domain)); - - api(getState).post(`/api/v1/antennas/${antennaId}/domains`, { domains: [domain] }) - .then(() => dispatch(addDomainToAntennaSuccess(antennaId, domain))) - .catch(err => dispatch(addDomainToAntennaFail(antennaId, domain, err))); -}; - -export const addDomainToAntennaRequest = (antennaId, domain) => ({ - type: ANTENNA_EDITOR_ADD_DOMAIN_REQUEST, - antennaId, - domain, -}); - -export const addDomainToAntennaSuccess = (antennaId, domain) => ({ - type: ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS, - antennaId, - domain, -}); - -export const addDomainToAntennaFail = (antennaId, domain, error) => ({ - type: ANTENNA_EDITOR_ADD_DOMAIN_FAIL, - antennaId, - domain, - error, -}); - -export const removeDomainFromAntenna = (antennaId, domain) => (dispatch, getState) => { - dispatch(removeDomainFromAntennaRequest(antennaId, domain)); - - api(getState).delete(`/api/v1/antennas/${antennaId}/domains`, { params: { domains: [domain] } }) - .then(() => dispatch(removeDomainFromAntennaSuccess(antennaId, domain))) - .catch(err => dispatch(removeDomainFromAntennaFail(antennaId, domain, err))); -}; - -export const removeDomainFromAntennaRequest = (antennaId, domain) => ({ - type: ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST, - antennaId, - domain, -}); - -export const removeDomainFromAntennaSuccess = (antennaId, domain) => ({ - type: ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS, - antennaId, - domain, -}); - -export const removeDomainFromAntennaFail = (antennaId, domain, error) => ({ - type: ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL, - antennaId, - domain, - error, -}); - -export const addExcludeDomainToAntenna = (antennaId, domain) => (dispatch, getState) => { - dispatch(addExcludeDomainToAntennaRequest(antennaId, domain)); - - api(getState).post(`/api/v1/antennas/${antennaId}/exclude_domains`, { domains: [domain] }) - .then(() => dispatch(addExcludeDomainToAntennaSuccess(antennaId, domain))) - .catch(err => dispatch(addExcludeDomainToAntennaFail(antennaId, domain, err))); -}; - -export const addExcludeDomainToAntennaRequest = (antennaId, domain) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_REQUEST, - antennaId, - domain, -}); - -export const addExcludeDomainToAntennaSuccess = (antennaId, domain) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_SUCCESS, - antennaId, - domain, -}); - -export const addExcludeDomainToAntennaFail = (antennaId, domain, error) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_FAIL, - antennaId, - domain, - error, -}); - -export const removeExcludeDomainFromAntenna = (antennaId, domain) => (dispatch, getState) => { - dispatch(removeExcludeDomainFromAntennaRequest(antennaId, domain)); - - api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_domains`, { params: { domains: [domain] } }) - .then(() => dispatch(removeExcludeDomainFromAntennaSuccess(antennaId, domain))) - .catch(err => dispatch(removeExcludeDomainFromAntennaFail(antennaId, domain, err))); -}; - -export const removeExcludeDomainFromAntennaRequest = (antennaId, domain) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_REQUEST, - antennaId, - domain, -}); - -export const removeExcludeDomainFromAntennaSuccess = (antennaId, domain) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_SUCCESS, - antennaId, - domain, -}); - -export const removeExcludeDomainFromAntennaFail = (antennaId, domain, error) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_FAIL, - antennaId, - domain, - error, -}); - -export const fetchAntennaKeywords = antennaId => (dispatch, getState) => { - dispatch(fetchAntennaKeywordsRequest(antennaId)); - - api(getState).get(`/api/v1/antennas/${antennaId}/keywords`, { params: { limit: 0 } }).then(({ data }) => { - dispatch(fetchAntennaKeywordsSuccess(antennaId, data)); - }).catch(err => dispatch(fetchAntennaKeywordsFail(antennaId, err))); -}; - -export const fetchAntennaKeywordsRequest = id => ({ - type: ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST, - id, -}); - -export const fetchAntennaKeywordsSuccess = (id, keywords) => ({ - type: ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS, - id, - keywords, -}); - -export const fetchAntennaKeywordsFail = (id, error) => ({ - type: ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL, - id, - error, -}); - -export const addKeywordToAntenna = (antennaId, keyword) => (dispatch, getState) => { - dispatch(addKeywordToAntennaRequest(antennaId, keyword)); - - api(getState).post(`/api/v1/antennas/${antennaId}/keywords`, { keywords: [keyword] }) - .then(() => dispatch(addKeywordToAntennaSuccess(antennaId, keyword))) - .catch(err => dispatch(addKeywordToAntennaFail(antennaId, keyword, err))); -}; - -export const addKeywordToAntennaRequest = (antennaId, keyword) => ({ - type: ANTENNA_EDITOR_ADD_KEYWORD_REQUEST, - antennaId, - keyword, -}); - -export const addKeywordToAntennaSuccess = (antennaId, keyword) => ({ - type: ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS, - antennaId, - keyword, -}); - -export const addKeywordToAntennaFail = (antennaId, keyword, error) => ({ - type: ANTENNA_EDITOR_ADD_KEYWORD_FAIL, - antennaId, - keyword, - error, -}); - -export const removeKeywordFromAntenna = (antennaId, keyword) => (dispatch, getState) => { - dispatch(removeKeywordFromAntennaRequest(antennaId, keyword)); - - api(getState).delete(`/api/v1/antennas/${antennaId}/keywords`, { params: { keywords: [keyword] } }) - .then(() => dispatch(removeKeywordFromAntennaSuccess(antennaId, keyword))) - .catch(err => dispatch(removeKeywordFromAntennaFail(antennaId, keyword, err))); -}; - -export const removeKeywordFromAntennaRequest = (antennaId, keyword) => ({ - type: ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST, - antennaId, - keyword, -}); - -export const removeKeywordFromAntennaSuccess = (antennaId, keyword) => ({ - type: ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS, - antennaId, - keyword, -}); - -export const removeKeywordFromAntennaFail = (antennaId, keyword, error) => ({ - type: ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL, - antennaId, - keyword, - error, -}); - -export const addExcludeKeywordToAntenna = (antennaId, keyword) => (dispatch, getState) => { - dispatch(addExcludeKeywordToAntennaRequest(antennaId, keyword)); - - api(getState).post(`/api/v1/antennas/${antennaId}/exclude_keywords`, { keywords: [keyword] }) - .then(() => dispatch(addExcludeKeywordToAntennaSuccess(antennaId, keyword))) - .catch(err => dispatch(addExcludeKeywordToAntennaFail(antennaId, keyword, err))); -}; - -export const addExcludeKeywordToAntennaRequest = (antennaId, keyword) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_REQUEST, - antennaId, - keyword, -}); - -export const addExcludeKeywordToAntennaSuccess = (antennaId, keyword) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_SUCCESS, - antennaId, - keyword, -}); - -export const addExcludeKeywordToAntennaFail = (antennaId, keyword, error) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_FAIL, - antennaId, - keyword, - error, -}); - -export const removeExcludeKeywordFromAntenna = (antennaId, keyword) => (dispatch, getState) => { - dispatch(removeExcludeKeywordFromAntennaRequest(antennaId, keyword)); - - api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_keywords`, { params: { keywords: [keyword] } }) - .then(() => dispatch(removeExcludeKeywordFromAntennaSuccess(antennaId, keyword))) - .catch(err => dispatch(removeExcludeKeywordFromAntennaFail(antennaId, keyword, err))); -}; - -export const removeExcludeKeywordFromAntennaRequest = (antennaId, keyword) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_REQUEST, - antennaId, - keyword, -}); - -export const removeExcludeKeywordFromAntennaSuccess = (antennaId, keyword) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS, - antennaId, - keyword, -}); - -export const removeExcludeKeywordFromAntennaFail = (antennaId, keyword, error) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL, - antennaId, - keyword, - error, -}); - -export const fetchAntennaTags = antennaId => (dispatch, getState) => { - dispatch(fetchAntennaTagsRequest(antennaId)); - - api(getState).get(`/api/v1/antennas/${antennaId}/tags`, { params: { limit: 0 } }).then(({ data }) => { - dispatch(fetchAntennaTagsSuccess(antennaId, data)); - }).catch(err => dispatch(fetchAntennaTagsFail(antennaId, err))); -}; - -export const fetchAntennaTagsRequest = id => ({ - type: ANTENNA_EDITOR_FETCH_TAGS_REQUEST, - id, -}); - -export const fetchAntennaTagsSuccess = (id, tags) => ({ - type: ANTENNA_EDITOR_FETCH_TAGS_SUCCESS, - id, - tags, -}); - -export const fetchAntennaTagsFail = (id, error) => ({ - type: ANTENNA_EDITOR_FETCH_TAGS_FAIL, - id, - error, -}); - -export const addTagToAntenna = (antennaId, tag) => (dispatch, getState) => { - dispatch(addTagToAntennaRequest(antennaId, tag)); - - api(getState).post(`/api/v1/antennas/${antennaId}/tags`, { tags: [tag] }) - .then(() => dispatch(addTagToAntennaSuccess(antennaId, tag))) - .catch(err => dispatch(addTagToAntennaFail(antennaId, tag, err))); -}; - -export const addTagToAntennaRequest = (antennaId, tag) => ({ - type: ANTENNA_EDITOR_ADD_TAG_REQUEST, - antennaId, - tag, -}); - -export const addTagToAntennaSuccess = (antennaId, tag) => ({ - type: ANTENNA_EDITOR_ADD_TAG_SUCCESS, - antennaId, - tag, -}); - -export const addTagToAntennaFail = (antennaId, tag, error) => ({ - type: ANTENNA_EDITOR_ADD_TAG_FAIL, - antennaId, - tag, - error, -}); - -export const removeTagFromAntenna = (antennaId, tag) => (dispatch, getState) => { - dispatch(removeTagFromAntennaRequest(antennaId, tag)); - - api(getState).delete(`/api/v1/antennas/${antennaId}/tags`, { params: { tags: [tag] } }) - .then(() => dispatch(removeTagFromAntennaSuccess(antennaId, tag))) - .catch(err => dispatch(removeTagFromAntennaFail(antennaId, tag, err))); -}; - -export const removeTagFromAntennaRequest = (antennaId, tag) => ({ - type: ANTENNA_EDITOR_REMOVE_TAG_REQUEST, - antennaId, - tag, -}); - -export const removeTagFromAntennaSuccess = (antennaId, tag) => ({ - type: ANTENNA_EDITOR_REMOVE_TAG_SUCCESS, - antennaId, - tag, -}); - -export const removeTagFromAntennaFail = (antennaId, tag, error) => ({ - type: ANTENNA_EDITOR_REMOVE_TAG_FAIL, - antennaId, - tag, - error, -}); - -export const addExcludeTagToAntenna = (antennaId, tag) => (dispatch, getState) => { - dispatch(addExcludeTagToAntennaRequest(antennaId, tag)); - - api(getState).post(`/api/v1/antennas/${antennaId}/exclude_tags`, { tags: [tag] }) - .then(() => dispatch(addExcludeTagToAntennaSuccess(antennaId, tag))) - .catch(err => dispatch(addExcludeTagToAntennaFail(antennaId, tag, err))); -}; - -export const addExcludeTagToAntennaRequest = (antennaId, tag) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST, - antennaId, - tag, -}); - -export const addExcludeTagToAntennaSuccess = (antennaId, tag) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS, - antennaId, - tag, -}); - -export const addExcludeTagToAntennaFail = (antennaId, tag, error) => ({ - type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL, - antennaId, - tag, - error, -}); - -export const removeExcludeTagFromAntenna = (antennaId, tag) => (dispatch, getState) => { - dispatch(removeExcludeTagFromAntennaRequest(antennaId, tag)); - - api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_tags`, { params: { tags: [tag] } }) - .then(() => dispatch(removeExcludeTagFromAntennaSuccess(antennaId, tag))) - .catch(err => dispatch(removeExcludeTagFromAntennaFail(antennaId, tag, err))); -}; - -export const removeExcludeTagFromAntennaRequest = (antennaId, tag) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST, - antennaId, - tag, -}); - -export const removeExcludeTagFromAntennaSuccess = (antennaId, tag) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS, - antennaId, - tag, -}); - -export const removeExcludeTagFromAntennaFail = (antennaId, tag, error) => ({ - type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL, - antennaId, - tag, - error, -}); - -export const resetAntennaAdder = () => ({ - type: ANTENNA_ADDER_RESET, -}); - -export const setupAntennaAdder = accountId => (dispatch, getState) => { - dispatch({ - type: ANTENNA_ADDER_SETUP, - account: getState().getIn(['accounts', accountId]), - }); - dispatch(fetchAntennas()); - dispatch(fetchAccountAntennas(accountId)); -}; - -export const setupExcludeAntennaAdder = accountId => (dispatch, getState) => { - dispatch({ - type: ANTENNA_ADDER_SETUP, - account: getState().getIn(['accounts', accountId]), - }); - dispatch(fetchAntennas()); - dispatch(fetchExcludeAccountAntennas(accountId)); -}; - -export const fetchAccountAntennas = accountId => (dispatch, getState) => { - dispatch(fetchAccountAntennasRequest(accountId)); - - api(getState).get(`/api/v1/accounts/${accountId}/antennas`) - .then(({ data }) => dispatch(fetchAccountAntennasSuccess(accountId, data))) - .catch(err => dispatch(fetchAccountAntennasFail(accountId, err))); -}; - -export const fetchAccountAntennasRequest = id => ({ - type:ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST, - id, -}); - -export const fetchAccountAntennasSuccess = (id, antennas) => ({ - type: ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS, - id, - antennas, -}); - -export const fetchAccountAntennasFail = (id, err) => ({ - type: ANTENNA_ADDER_ANTENNAS_FETCH_FAIL, - id, - err, -}); - -export const fetchExcludeAccountAntennas = accountId => (dispatch, getState) => { - dispatch(fetchExcludeAccountAntennasRequest(accountId)); - - api(getState).get(`/api/v1/accounts/${accountId}/exclude_antennas`) - .then(({ data }) => dispatch(fetchExcludeAccountAntennasSuccess(accountId, data))) - .catch(err => dispatch(fetchExcludeAccountAntennasFail(accountId, err))); -}; - -export const fetchExcludeAccountAntennasRequest = id => ({ - type:ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST, - id, -}); - -export const fetchExcludeAccountAntennasSuccess = (id, antennas) => ({ - type: ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS, - id, - antennas, -}); - -export const fetchExcludeAccountAntennasFail = (id, err) => ({ - type: ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL, - id, - err, -}); - -export const addToAntennaAdder = antennaId => (dispatch, getState) => { - dispatch(addToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); -}; - -export const removeFromAntennaAdder = antennaId => (dispatch, getState) => { - dispatch(removeFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); -}; - -export const addExcludeToAntennaAdder = antennaId => (dispatch, getState) => { - dispatch(addExcludeToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); -}; - -export const removeExcludeFromAntennaAdder = antennaId => (dispatch, getState) => { - dispatch(removeExcludeFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); -}; - diff --git a/app/javascript/mastodon/actions/antennas_typed.ts b/app/javascript/mastodon/actions/antennas_typed.ts new file mode 100644 index 0000000000..f385b37d6e --- /dev/null +++ b/app/javascript/mastodon/actions/antennas_typed.ts @@ -0,0 +1,13 @@ +import { apiCreate, apiUpdate } from 'mastodon/api/antennas'; +import type { Antenna } from 'mastodon/models/antenna'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const createAntenna = createDataLoadingThunk( + 'antenna/create', + (antenna: Partial) => apiCreate(antenna), +); + +export const updateAntenna = createDataLoadingThunk( + 'antenna/update', + (antenna: Partial) => apiUpdate(antenna), +); diff --git a/app/javascript/mastodon/actions/blocks.js b/app/javascript/mastodon/actions/blocks.js index e293657ad3..5c66e27bec 100644 --- a/app/javascript/mastodon/actions/blocks.js +++ b/app/javascript/mastodon/actions/blocks.js @@ -12,13 +12,11 @@ export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; -export const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL'; - export function fetchBlocks() { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchBlocksRequest()); - api(getState).get('/api/v1/blocks').then(response => { + api().get('/api/v1/blocks').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); @@ -58,7 +56,7 @@ export function expandBlocks() { dispatch(expandBlocksRequest()); - api(getState).get(url).then(response => { + api().get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); @@ -90,11 +88,12 @@ export function expandBlocksFail(error) { export function initBlockModal(account) { return dispatch => { - dispatch({ - type: BLOCKS_INIT_MODAL, - account, - }); - - dispatch(openModal({ modalType: 'BLOCK' })); + dispatch(openModal({ + modalType: 'BLOCK', + modalProps: { + accountId: account.get('id'), + acct: account.get('acct'), + }, + })); }; } diff --git a/app/javascript/mastodon/actions/bookmark_categories.js b/app/javascript/mastodon/actions/bookmark_categories.js index 7d458b85ec..313d5de8f2 100644 --- a/app/javascript/mastodon/actions/bookmark_categories.js +++ b/app/javascript/mastodon/actions/bookmark_categories.js @@ -1,10 +1,6 @@ -import { bookmarkCategoryNeeded } from 'mastodon/initial_state'; -import { makeGetStatus } from 'mastodon/selectors'; - import api, { getLinks } from '../api'; import { importFetchedStatuses } from './importer'; -import { unbookmark } from './interactions'; export const BOOKMARK_CATEGORY_FETCH_REQUEST = 'BOOKMARK_CATEGORY_FETCH_REQUEST'; export const BOOKMARK_CATEGORY_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_FETCH_SUCCESS'; @@ -14,18 +10,6 @@ export const BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORIES_FETCH_REQU export const BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORIES_FETCH_SUCCESS'; export const BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORIES_FETCH_FAIL'; -export const BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE = 'BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE'; -export const BOOKMARK_CATEGORY_EDITOR_RESET = 'BOOKMARK_CATEGORY_EDITOR_RESET'; -export const BOOKMARK_CATEGORY_EDITOR_SETUP = 'BOOKMARK_CATEGORY_EDITOR_SETUP'; - -export const BOOKMARK_CATEGORY_CREATE_REQUEST = 'BOOKMARK_CATEGORY_CREATE_REQUEST'; -export const BOOKMARK_CATEGORY_CREATE_SUCCESS = 'BOOKMARK_CATEGORY_CREATE_SUCCESS'; -export const BOOKMARK_CATEGORY_CREATE_FAIL = 'BOOKMARK_CATEGORY_CREATE_FAIL'; - -export const BOOKMARK_CATEGORY_UPDATE_REQUEST = 'BOOKMARK_CATEGORY_UPDATE_REQUEST'; -export const BOOKMARK_CATEGORY_UPDATE_SUCCESS = 'BOOKMARK_CATEGORY_UPDATE_SUCCESS'; -export const BOOKMARK_CATEGORY_UPDATE_FAIL = 'BOOKMARK_CATEGORY_UPDATE_FAIL'; - export const BOOKMARK_CATEGORY_DELETE_REQUEST = 'BOOKMARK_CATEGORY_DELETE_REQUEST'; export const BOOKMARK_CATEGORY_DELETE_SUCCESS = 'BOOKMARK_CATEGORY_DELETE_SUCCESS'; export const BOOKMARK_CATEGORY_DELETE_FAIL = 'BOOKMARK_CATEGORY_DELETE_FAIL'; @@ -34,25 +18,13 @@ export const BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_STATU export const BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS'; export const BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL = 'BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL'; -export const BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST'; -export const BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS'; -export const BOOKMARK_CATEGORY_EDITOR_ADD_FAIL = 'BOOKMARK_CATEGORY_EDITOR_ADD_FAIL'; - -export const BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST'; -export const BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS'; -export const BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL'; - -export const BOOKMARK_CATEGORY_ADDER_RESET = 'BOOKMARK_CATEGORY_ADDER_RESET'; -export const BOOKMARK_CATEGORY_ADDER_SETUP = 'BOOKMARK_CATEGORY_ADDER_SETUP'; - -export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST'; -export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS'; -export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL'; - export const BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST'; export const BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS'; export const BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL'; +export const BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS'; +export const BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS'; + export const fetchBookmarkCategory = id => (dispatch, getState) => { if (getState().getIn(['bookmark_categories', id])) { return; @@ -103,89 +75,6 @@ export const fetchBookmarkCategoriesFail = error => ({ error, }); -export const submitBookmarkCategoryEditor = shouldReset => (dispatch, getState) => { - const bookmarkCategoryId = getState().getIn(['bookmarkCategoryEditor', 'bookmarkCategoryId']); - const title = getState().getIn(['bookmarkCategoryEditor', 'title']); - - if (bookmarkCategoryId === null) { - dispatch(createBookmarkCategory(title, shouldReset)); - } else { - dispatch(updateBookmarkCategory(bookmarkCategoryId, title, shouldReset)); - } -}; - -export const setupBookmarkCategoryEditor = bookmarkCategoryId => (dispatch, getState) => { - dispatch({ - type: BOOKMARK_CATEGORY_EDITOR_SETUP, - bookmarkCategory: getState().getIn(['bookmark_categories', bookmarkCategoryId]), - }); - - dispatch(fetchBookmarkCategoryStatuses(bookmarkCategoryId)); -}; - -export const changeBookmarkCategoryEditorTitle = value => ({ - type: BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE, - value, -}); - -export const createBookmarkCategory = (title, shouldReset) => (dispatch, getState) => { - dispatch(createBookmarkCategoryRequest()); - - api(getState).post('/api/v1/bookmark_categories', { title }).then(({ data }) => { - dispatch(createBookmarkCategorySuccess(data)); - - if (shouldReset) { - dispatch(resetBookmarkCategoryEditor()); - } - }).catch(err => dispatch(createBookmarkCategoryFail(err))); -}; - -export const createBookmarkCategoryRequest = () => ({ - type: BOOKMARK_CATEGORY_CREATE_REQUEST, -}); - -export const createBookmarkCategorySuccess = bookmarkCategory => ({ - type: BOOKMARK_CATEGORY_CREATE_SUCCESS, - bookmarkCategory, -}); - -export const createBookmarkCategoryFail = error => ({ - type: BOOKMARK_CATEGORY_CREATE_FAIL, - error, -}); - -export const updateBookmarkCategory = (id, title, shouldReset) => (dispatch, getState) => { - dispatch(updateBookmarkCategoryRequest(id)); - - api(getState).put(`/api/v1/bookmark_categories/${id}`, { title }).then(({ data }) => { - dispatch(updateBookmarkCategorySuccess(data)); - - if (shouldReset) { - dispatch(resetBookmarkCategoryEditor()); - } - }).catch(err => dispatch(updateBookmarkCategoryFail(id, err))); -}; - -export const updateBookmarkCategoryRequest = id => ({ - type: BOOKMARK_CATEGORY_UPDATE_REQUEST, - id, -}); - -export const updateBookmarkCategorySuccess = bookmarkCategory => ({ - type: BOOKMARK_CATEGORY_UPDATE_SUCCESS, - bookmarkCategory, -}); - -export const updateBookmarkCategoryFail = (id, error) => ({ - type: BOOKMARK_CATEGORY_UPDATE_FAIL, - id, - error, -}); - -export const resetBookmarkCategoryEditor = () => ({ - type: BOOKMARK_CATEGORY_EDITOR_RESET, -}); - export const deleteBookmarkCategory = id => (dispatch, getState) => { dispatch(deleteBookmarkCategoryRequest(id)); @@ -238,121 +127,11 @@ export const fetchBookmarkCategoryStatusesFail = (id, error) => ({ error, }); -export const addToBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => { - dispatch(addToBookmarkCategoryRequest(bookmarkCategoryId, statusId)); - - api(getState).post(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { status_ids: [statusId] }) - .then(() => dispatch(addToBookmarkCategorySuccess(bookmarkCategoryId, statusId))) - .catch(err => dispatch(addToBookmarkCategoryFail(bookmarkCategoryId, statusId, err))); -}; - -export const addToBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({ - type: BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST, - bookmarkCategoryId, - statusId, -}); - -export const addToBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({ - type: BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS, - bookmarkCategoryId, - statusId, -}); - -export const addToBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({ - type: BOOKMARK_CATEGORY_EDITOR_ADD_FAIL, - bookmarkCategoryId, - statusId, - error, -}); - -export const removeFromBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => { - dispatch(removeFromBookmarkCategoryRequest(bookmarkCategoryId, statusId)); - - api(getState).delete(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { params: { status_ids: [statusId] } }) - .then(() => dispatch(removeFromBookmarkCategorySuccess(bookmarkCategoryId, statusId))) - .catch(err => dispatch(removeFromBookmarkCategoryFail(bookmarkCategoryId, statusId, err))); -}; - -export const removeFromBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({ - type: BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST, - bookmarkCategoryId, - statusId, -}); - -export const removeFromBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({ - type: BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS, - bookmarkCategoryId, - statusId, -}); - -export const removeFromBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({ - type: BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL, - bookmarkCategoryId, - statusId, - error, -}); - -export const resetBookmarkCategoryAdder = () => ({ - type: BOOKMARK_CATEGORY_ADDER_RESET, -}); - -export const setupBookmarkCategoryAdder = statusId => (dispatch, getState) => { - dispatch({ - type: BOOKMARK_CATEGORY_ADDER_SETUP, - status: getState().getIn(['statuses', statusId]), - }); - dispatch(fetchBookmarkCategories()); - dispatch(fetchStatusBookmarkCategories(statusId)); -}; - -export const fetchStatusBookmarkCategories = statusId => (dispatch, getState) => { - dispatch(fetchStatusBookmarkCategoriesRequest(statusId)); - - api(getState).get(`/api/v1/statuses/${statusId}/bookmark_categories`) - .then(({ data }) => dispatch(fetchStatusBookmarkCategoriesSuccess(statusId, data))) - .catch(err => dispatch(fetchStatusBookmarkCategoriesFail(statusId, err))); -}; - -export const fetchStatusBookmarkCategoriesRequest = id => ({ - type:BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST, - id, -}); - -export const fetchStatusBookmarkCategoriesSuccess = (id, bookmarkCategories) => ({ - type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS, - id, - bookmarkCategories, -}); - -export const fetchStatusBookmarkCategoriesFail = (id, err) => ({ - type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL, - id, - err, -}); - -export const addToBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => { - dispatch(addToBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId']))); -}; - -export const removeFromBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => { - if (bookmarkCategoryNeeded) { - const categories = getState().getIn(['bookmarkCategoryAdder', 'bookmarkCategories', 'items']); - if (categories && categories.count() <= 1) { - const status = makeGetStatus()(getState(), { id: getState().getIn(['bookmarkCategoryAdder', 'statusId']) }); - dispatch(unbookmark(status)); - } else { - dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId']))); - } - } else { - dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId']))); - } -}; - export function expandBookmarkCategoryStatuses(bookmarkCategoryId) { return (dispatch, getState) => { - const url = getState().getIn(['bookmark_categories', bookmarkCategoryId, 'next'], null); + const url = getState().getIn(['status_lists', 'bookmark_category_statuses', bookmarkCategoryId, 'next'], null); - if (url === null || getState().getIn(['bookmark_categories', bookmarkCategoryId, 'isLoading'])) { + if (url === null || getState().getIn(['status_lists', 'bookmark_category_statuses', bookmarkCategoryId, 'isLoading'])) { return; } @@ -392,3 +171,19 @@ export function expandBookmarkCategoryStatusesFail(id, error) { }; } +export function bookmarkCategoryEditorAddSuccess(id, statusId) { + return { + type: BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS, + id, + statusId, + }; +} + +export function bookmarkCategoryEditorRemoveSuccess(id, statusId) { + return { + type: BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS, + id, + statusId, + }; +} + diff --git a/app/javascript/mastodon/actions/bookmark_categories_typed.ts b/app/javascript/mastodon/actions/bookmark_categories_typed.ts new file mode 100644 index 0000000000..3570ce5991 --- /dev/null +++ b/app/javascript/mastodon/actions/bookmark_categories_typed.ts @@ -0,0 +1,13 @@ +import { apiCreate, apiUpdate } from 'mastodon/api/bookmark_categories'; +import type { BookmarkCategory } from 'mastodon/models/bookmark_category'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const createBookmarkCategory = createDataLoadingThunk( + 'bookmark_category/create', + (bookmarkCategory: Partial) => apiCreate(bookmarkCategory), +); + +export const updateBookmarkCategory = createDataLoadingThunk( + 'bookmark_category/update', + (bookmarkCategory: Partial) => apiUpdate(bookmarkCategory), +); diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js index 91c1d61e10..133e49e839 100644 --- a/app/javascript/mastodon/actions/bookmarks.js +++ b/app/javascript/mastodon/actions/bookmarks.js @@ -20,7 +20,7 @@ export function fetchBookmarkedStatuses() { dispatch(fetchBookmarkedStatusesRequest()); - api(getState).get('/api/v1/bookmarks').then(response => { + api().get('/api/v1/bookmarks').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); @@ -61,7 +61,7 @@ export function expandBookmarkedStatuses() { dispatch(expandBookmarkedStatusesRequest()); - api(getState).get(url).then(response => { + api().get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); diff --git a/app/javascript/mastodon/actions/boosts.js b/app/javascript/mastodon/actions/boosts.js deleted file mode 100644 index f219ca3ef3..0000000000 --- a/app/javascript/mastodon/actions/boosts.js +++ /dev/null @@ -1,32 +0,0 @@ -import { openModal } from './modal'; - -export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL'; -export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY'; - -export function initBoostModal(props) { - return (dispatch, getState) => { - const default_privacy = getState().getIn(['compose', 'default_privacy']); - - const privacy = props.status.get('visibility_ex') === 'private' ? 'private' : default_privacy; - - dispatch({ - type: BOOSTS_INIT_MODAL, - privacy, - }); - - dispatch(openModal({ - modalType: 'BOOST', - modalProps: props, - })); - }; -} - - -export function changeBoostPrivacy(privacy) { - return dispatch => { - dispatch({ - type: BOOSTS_CHANGE_PRIVACY, - privacy, - }); - }; -} diff --git a/app/javascript/mastodon/actions/circles.js b/app/javascript/mastodon/actions/circles.js index 7ed41b4045..221c0c683a 100644 --- a/app/javascript/mastodon/actions/circles.js +++ b/app/javascript/mastodon/actions/circles.js @@ -1,7 +1,6 @@ import api, { getLinks } from '../api'; -import { showAlertForError } from './alerts'; -import { importFetchedAccounts, importFetchedStatuses } from './importer'; +import { importFetchedStatuses } from './importer'; export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST'; export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS'; @@ -11,10 +10,6 @@ export const CIRCLES_FETCH_REQUEST = 'CIRCLES_FETCH_REQUEST'; export const CIRCLES_FETCH_SUCCESS = 'CIRCLES_FETCH_SUCCESS'; export const CIRCLES_FETCH_FAIL = 'CIRCLES_FETCH_FAIL'; -export const CIRCLE_EDITOR_TITLE_CHANGE = 'CIRCLE_EDITOR_TITLE_CHANGE'; -export const CIRCLE_EDITOR_RESET = 'CIRCLE_EDITOR_RESET'; -export const CIRCLE_EDITOR_SETUP = 'CIRCLE_EDITOR_SETUP'; - export const CIRCLE_CREATE_REQUEST = 'CIRCLE_CREATE_REQUEST'; export const CIRCLE_CREATE_SUCCESS = 'CIRCLE_CREATE_SUCCESS'; export const CIRCLE_CREATE_FAIL = 'CIRCLE_CREATE_FAIL'; @@ -27,29 +22,6 @@ export const CIRCLE_DELETE_REQUEST = 'CIRCLE_DELETE_REQUEST'; export const CIRCLE_DELETE_SUCCESS = 'CIRCLE_DELETE_SUCCESS'; export const CIRCLE_DELETE_FAIL = 'CIRCLE_DELETE_FAIL'; -export const CIRCLE_ACCOUNTS_FETCH_REQUEST = 'CIRCLE_ACCOUNTS_FETCH_REQUEST'; -export const CIRCLE_ACCOUNTS_FETCH_SUCCESS = 'CIRCLE_ACCOUNTS_FETCH_SUCCESS'; -export const CIRCLE_ACCOUNTS_FETCH_FAIL = 'CIRCLE_ACCOUNTS_FETCH_FAIL'; - -export const CIRCLE_EDITOR_SUGGESTIONS_CHANGE = 'CIRCLE_EDITOR_SUGGESTIONS_CHANGE'; -export const CIRCLE_EDITOR_SUGGESTIONS_READY = 'CIRCLE_EDITOR_SUGGESTIONS_READY'; -export const CIRCLE_EDITOR_SUGGESTIONS_CLEAR = 'CIRCLE_EDITOR_SUGGESTIONS_CLEAR'; - -export const CIRCLE_EDITOR_ADD_REQUEST = 'CIRCLE_EDITOR_ADD_REQUEST'; -export const CIRCLE_EDITOR_ADD_SUCCESS = 'CIRCLE_EDITOR_ADD_SUCCESS'; -export const CIRCLE_EDITOR_ADD_FAIL = 'CIRCLE_EDITOR_ADD_FAIL'; - -export const CIRCLE_EDITOR_REMOVE_REQUEST = 'CIRCLE_EDITOR_REMOVE_REQUEST'; -export const CIRCLE_EDITOR_REMOVE_SUCCESS = 'CIRCLE_EDITOR_REMOVE_SUCCESS'; -export const CIRCLE_EDITOR_REMOVE_FAIL = 'CIRCLE_EDITOR_REMOVE_FAIL'; - -export const CIRCLE_ADDER_RESET = 'CIRCLE_ADDER_RESET'; -export const CIRCLE_ADDER_SETUP = 'CIRCLE_ADDER_SETUP'; - -export const CIRCLE_ADDER_CIRCLES_FETCH_REQUEST = 'CIRCLE_ADDER_CIRCLES_FETCH_REQUEST'; -export const CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS = 'CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS'; -export const CIRCLE_ADDER_CIRCLES_FETCH_FAIL = 'CIRCLE_ADDER_CIRCLES_FETCH_FAIL'; - export const CIRCLE_STATUSES_FETCH_REQUEST = 'CIRCLE_STATUSES_FETCH_REQUEST'; export const CIRCLE_STATUSES_FETCH_SUCCESS = 'CIRCLE_STATUSES_FETCH_SUCCESS'; export const CIRCLE_STATUSES_FETCH_FAIL = 'CIRCLE_STATUSES_FETCH_FAIL'; @@ -108,89 +80,6 @@ export const fetchCirclesFail = error => ({ error, }); -export const submitCircleEditor = shouldReset => (dispatch, getState) => { - const circleId = getState().getIn(['circleEditor', 'circleId']); - const title = getState().getIn(['circleEditor', 'title']); - - if (circleId === null) { - dispatch(createCircle(title, shouldReset)); - } else { - dispatch(updateCircle(circleId, title, shouldReset)); - } -}; - -export const setupCircleEditor = circleId => (dispatch, getState) => { - dispatch({ - type: CIRCLE_EDITOR_SETUP, - circle: getState().getIn(['circles', circleId]), - }); - - dispatch(fetchCircleAccounts(circleId)); -}; - -export const changeCircleEditorTitle = value => ({ - type: CIRCLE_EDITOR_TITLE_CHANGE, - value, -}); - -export const createCircle = (title, shouldReset) => (dispatch, getState) => { - dispatch(createCircleRequest()); - - api(getState).post('/api/v1/circles', { title }).then(({ data }) => { - dispatch(createCircleSuccess(data)); - - if (shouldReset) { - dispatch(resetCircleEditor()); - } - }).catch(err => dispatch(createCircleFail(err))); -}; - -export const createCircleRequest = () => ({ - type: CIRCLE_CREATE_REQUEST, -}); - -export const createCircleSuccess = circle => ({ - type: CIRCLE_CREATE_SUCCESS, - circle, -}); - -export const createCircleFail = error => ({ - type: CIRCLE_CREATE_FAIL, - error, -}); - -export const updateCircle = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => { - dispatch(updateCircleRequest(id)); - - api(getState).put(`/api/v1/circles/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => { - dispatch(updateCircleSuccess(data)); - - if (shouldReset) { - dispatch(resetCircleEditor()); - } - }).catch(err => dispatch(updateCircleFail(id, err))); -}; - -export const updateCircleRequest = id => ({ - type: CIRCLE_UPDATE_REQUEST, - id, -}); - -export const updateCircleSuccess = circle => ({ - type: CIRCLE_UPDATE_SUCCESS, - circle, -}); - -export const updateCircleFail = (id, error) => ({ - type: CIRCLE_UPDATE_FAIL, - id, - error, -}); - -export const resetCircleEditor = () => ({ - type: CIRCLE_EDITOR_RESET, -}); - export const deleteCircle = id => (dispatch, getState) => { dispatch(deleteCircleRequest(id)); @@ -215,169 +104,6 @@ export const deleteCircleFail = (id, error) => ({ error, }); -export const fetchCircleAccounts = circleId => (dispatch, getState) => { - dispatch(fetchCircleAccountsRequest(circleId)); - - api(getState).get(`/api/v1/circles/${circleId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchCircleAccountsSuccess(circleId, data)); - }).catch(err => dispatch(fetchCircleAccountsFail(circleId, err))); -}; - -export const fetchCircleAccountsRequest = id => ({ - type: CIRCLE_ACCOUNTS_FETCH_REQUEST, - id, -}); - -export const fetchCircleAccountsSuccess = (id, accounts, next) => ({ - type: CIRCLE_ACCOUNTS_FETCH_SUCCESS, - id, - accounts, - next, -}); - -export const fetchCircleAccountsFail = (id, error) => ({ - type: CIRCLE_ACCOUNTS_FETCH_FAIL, - id, - error, -}); - -export const fetchCircleSuggestions = q => (dispatch, getState) => { - const params = { - q, - resolve: false, - follower: true, - }; - - api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchCircleSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); -}; - -export const fetchCircleSuggestionsReady = (query, accounts) => ({ - type: CIRCLE_EDITOR_SUGGESTIONS_READY, - query, - accounts, -}); - -export const clearCircleSuggestions = () => ({ - type: CIRCLE_EDITOR_SUGGESTIONS_CLEAR, -}); - -export const changeCircleSuggestions = value => ({ - type: CIRCLE_EDITOR_SUGGESTIONS_CHANGE, - value, -}); - -export const addToCircleEditor = accountId => (dispatch, getState) => { - dispatch(addToCircle(getState().getIn(['circleEditor', 'circleId']), accountId)); -}; - -export const addToCircle = (circleId, accountId) => (dispatch, getState) => { - dispatch(addToCircleRequest(circleId, accountId)); - - api(getState).post(`/api/v1/circles/${circleId}/accounts`, { account_ids: [accountId] }) - .then(() => dispatch(addToCircleSuccess(circleId, accountId))) - .catch(err => dispatch(addToCircleFail(circleId, accountId, err))); -}; - -export const addToCircleRequest = (circleId, accountId) => ({ - type: CIRCLE_EDITOR_ADD_REQUEST, - circleId, - accountId, -}); - -export const addToCircleSuccess = (circleId, accountId) => ({ - type: CIRCLE_EDITOR_ADD_SUCCESS, - circleId, - accountId, -}); - -export const addToCircleFail = (circleId, accountId, error) => ({ - type: CIRCLE_EDITOR_ADD_FAIL, - circleId, - accountId, - error, -}); - -export const removeFromCircleEditor = accountId => (dispatch, getState) => { - dispatch(removeFromCircle(getState().getIn(['circleEditor', 'circleId']), accountId)); -}; - -export const removeFromCircle = (circleId, accountId) => (dispatch, getState) => { - dispatch(removeFromCircleRequest(circleId, accountId)); - - api(getState).delete(`/api/v1/circles/${circleId}/accounts`, { params: { account_ids: [accountId] } }) - .then(() => dispatch(removeFromCircleSuccess(circleId, accountId))) - .catch(err => dispatch(removeFromCircleFail(circleId, accountId, err))); -}; - -export const removeFromCircleRequest = (circleId, accountId) => ({ - type: CIRCLE_EDITOR_REMOVE_REQUEST, - circleId, - accountId, -}); - -export const removeFromCircleSuccess = (circleId, accountId) => ({ - type: CIRCLE_EDITOR_REMOVE_SUCCESS, - circleId, - accountId, -}); - -export const removeFromCircleFail = (circleId, accountId, error) => ({ - type: CIRCLE_EDITOR_REMOVE_FAIL, - circleId, - accountId, - error, -}); - -export const resetCircleAdder = () => ({ - type: CIRCLE_ADDER_RESET, -}); - -export const setupCircleAdder = accountId => (dispatch, getState) => { - dispatch({ - type: CIRCLE_ADDER_SETUP, - account: getState().getIn(['accounts', accountId]), - }); - dispatch(fetchCircles()); - dispatch(fetchAccountCircles(accountId)); -}; - -export const fetchAccountCircles = accountId => (dispatch, getState) => { - dispatch(fetchAccountCirclesRequest(accountId)); - - api(getState).get(`/api/v1/accounts/${accountId}/circles`) - .then(({ data }) => dispatch(fetchAccountCirclesSuccess(accountId, data))) - .catch(err => dispatch(fetchAccountCirclesFail(accountId, err))); -}; - -export const fetchAccountCirclesRequest = id => ({ - type:CIRCLE_ADDER_CIRCLES_FETCH_REQUEST, - id, -}); - -export const fetchAccountCirclesSuccess = (id, circles) => ({ - type: CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS, - id, - circles, -}); - -export const fetchAccountCirclesFail = (id, err) => ({ - type: CIRCLE_ADDER_CIRCLES_FETCH_FAIL, - id, - err, -}); - -export const addToCircleAdder = circleId => (dispatch, getState) => { - dispatch(addToCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); -}; - -export const removeFromCircleAdder = circleId => (dispatch, getState) => { - dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); -}; - export function fetchCircleStatuses(circleId) { return (dispatch, getState) => { if (getState().getIn(['circles', circleId, 'isLoading'])) { @@ -426,9 +152,9 @@ export function fetchCircleStatusesFail(id, error) { export function expandCircleStatuses(circleId) { return (dispatch, getState) => { - const url = getState().getIn(['circles', circleId, 'next'], null); + const url = getState().getIn(['status_lists', 'circle_statuses', circleId, 'next'], null); - if (url === null || getState().getIn(['circles', circleId, 'isLoading'])) { + if (url === null || getState().getIn(['status_lists', 'circle_statuses', circleId, 'isLoading'])) { return; } diff --git a/app/javascript/mastodon/actions/circles_typed.ts b/app/javascript/mastodon/actions/circles_typed.ts new file mode 100644 index 0000000000..6f4117ff81 --- /dev/null +++ b/app/javascript/mastodon/actions/circles_typed.ts @@ -0,0 +1,13 @@ +import { apiCreate, apiUpdate } from 'mastodon/api/circles'; +import type { Circle } from 'mastodon/models/circle'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const createCircle = createDataLoadingThunk( + 'circle/create', + (circle: Partial) => apiCreate(circle), +); + +export const updateCircle = createDataLoadingThunk( + 'circle/update', + (circle: Partial) => apiUpdate(circle), +); diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index d8c521dc22..9a92528f3a 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'; @@ -64,6 +65,7 @@ export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; export const COMPOSE_EXPIRATION_INSERT = 'COMPOSE_EXPIRATION_INSERT'; +export const COMPOSE_FEATURED_TAG_INSERT = 'COMPOSE_FEATURED_TAG_INSERT'; export const COMPOSE_REFERENCE_INSERT = 'COMPOSE_REFERENCE_INSERT'; export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; @@ -83,6 +85,7 @@ export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL'; export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION'; export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; +export const COMPOSE_CHANGE_MEDIA_ORDER = 'COMPOSE_CHANGE_MEDIA_ORDER'; export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; @@ -95,9 +98,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'); } }; @@ -117,14 +120,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))); + } }; } @@ -140,38 +155,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']); @@ -205,7 +226,7 @@ export function submitCompose(routerHistory) { }); } - api(getState).request({ + api().request({ url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, method: statusId === null ? 'post' : 'put', data: { @@ -213,7 +234,7 @@ export function submitCompose(routerHistory) { in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: media.map(item => item.get('id')), media_attributes, - sensitive: media.size > 0 ? getState().getIn(['compose', 'sensitive']) : false, + sensitive: media.size > 0 ? getState().getIn(['compose', 'spoiler']) : false, spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', markdown: getState().getIn(['compose', 'markdown']), visibility: getState().getIn(['compose', 'privacy']), @@ -226,8 +247,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)); @@ -251,21 +272,21 @@ export function submitCompose(routerHistory) { insertIfOnline('home'); } - if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility_ex === 'public') { + if (statusId === null && response.data.in_reply_to_id === null && ['public', 'public_unlisted', 'login'].includes(response.data.visibility_ex)) { insertIfOnline('community'); insertIfOnline('public'); insertIfOnline(`account:${response.data.account.id}`); } if (statusId === null && privacy === 'circle' && circleId !== null && circleId !== 0) { - dispatch(submitComposeWithCircleSuccess({ ...response.data }, circleId)); + dispatch(submitComposeWithCircleSuccess({ ...response.data }, `${circleId}`)); } dispatch(showAlert({ 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)); @@ -289,7 +310,7 @@ export function submitComposeSuccess(status) { export function submitComposeWithCircleSuccess(status, circleId) { return { type: COMPOSE_WITH_CIRCLE_SUCCESS, - status, + statusId: status.id, circleId, }; } @@ -303,9 +324,11 @@ 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 defaultSensitive = getState().getIn(['compose', 'default_sensitive']); + const spoiler = getState().getIn(['compose', 'spoiler']); const progress = new Array(files.length).fill(0); let total = Array.from(files).reduce((a, v) => a + v.size, 0); @@ -318,12 +341,12 @@ export function uploadCompose(files) { dispatch(uploadComposeRequest()); for (const [i, file] of Array.from(files).entries()) { - if (media.size + i >= 4) break; + if (media.size + i > (uploadLimit - 1)) break; const data = new FormData(); data.append('file', file); - api(getState).post('/api/v2/media', data, { + api().post('/api/v2/media', data, { onUploadProgress: function({ loaded }){ progress[i] = loaded; dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); @@ -334,13 +357,17 @@ export function uploadCompose(files) { if (status === 200) { dispatch(uploadComposeSuccess(data, file)); + + if (defaultSensitive && !spoiler && (media.size + i) === 0) { + dispatch(changeComposeSpoilerness()); + } } else if (status === 202) { dispatch(uploadComposeProcessing()); let tryCount = 1; const poll = () => { - api(getState).get(`/api/v1/media/${data.id}`).then(response => { + api().get(`/api/v1/media/${data.id}`).then(response => { if (response.status === 200) { dispatch(uploadComposeSuccess(response.data, file)); } else if (response.status === 206) { @@ -362,7 +389,7 @@ export const uploadComposeProcessing = () => ({ type: COMPOSE_UPLOAD_PROCESSING, }); -export const uploadThumbnail = (id, file) => (dispatch, getState) => { +export const uploadThumbnail = (id, file) => (dispatch) => { dispatch(uploadThumbnailRequest()); const total = file.size; @@ -370,7 +397,7 @@ export const uploadThumbnail = (id, file) => (dispatch, getState) => { data.append('thumbnail', file); - api(getState).put(`/api/v1/media/${id}`, data, { + api().put(`/api/v1/media/${id}`, data, { onUploadProgress: ({ loaded }) => { dispatch(uploadThumbnailProgress(loaded, total)); }, @@ -414,7 +441,7 @@ export function initMediaEditModal(id) { dispatch(openModal({ modalType: 'FOCAL_POINT', - modalProps: { id }, + modalProps: { mediaId: id }, })); }; } @@ -453,7 +480,7 @@ export function changeUploadCompose(id, params) { dispatch(changeUploadComposeSuccess(data, true)); } else { - api(getState).put(`/api/v1/media/${id}`, params).then(response => { + api().put(`/api/v1/media/${id}`, params).then(response => { dispatch(changeUploadComposeSuccess(response.data, false)); }).catch(error => { dispatch(changeUploadComposeFail(id, error)); @@ -541,7 +568,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => fetchComposeSuggestionsAccountsController = new AbortController(); - api(getState).get('/api/v1/accounts/search', { + api().get('/api/v1/accounts/search', { signal: fetchComposeSuggestionsAccountsController.signal, params: { @@ -575,7 +602,7 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => { fetchComposeSuggestionsTagsController = new AbortController(); - api(getState).get('/api/v2/search', { + api().get('/api/v2/search', { signal: fetchComposeSuggestionsTagsController.signal, params: { @@ -801,12 +828,24 @@ export function insertExpirationCompose(position, data) { }; } -export function insertReferenceCompose(position, url, attributeType) { +export function insertFeaturedTagCompose(position, data) { return { - type: COMPOSE_REFERENCE_INSERT, + type: COMPOSE_FEATURED_TAG_INSERT, position, - url, - attributeType, + data, + }; +} + +export function insertReferenceCompose(position, url, attributeType, routerHistory) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_REFERENCE_INSERT, + position, + url, + attributeType, + }); + + ensureComposeIsVisible(getState, routerHistory); }; } @@ -836,11 +875,12 @@ export function addPollOption(title) { }; } -export function changePollOption(index, title) { +export function changePollOption(index, title, maxOptions) { return { type: COMPOSE_POLL_OPTION_CHANGE, index, title, + maxOptions, }; } @@ -859,6 +899,12 @@ export function changePollSettings(expiresIn, isMultiple) { }; } +export const changeMediaOrder = (a, b) => ({ + type: COMPOSE_CHANGE_MEDIA_ORDER, + a, + b, +}); + export function changeCircle(circleId) { return { type: COMPOSE_CIRCLE_CHANGE, diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts new file mode 100644 index 0000000000..97f0d68c51 --- /dev/null +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -0,0 +1,70 @@ +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; + +import { apiUpdateMedia } from 'mastodon/api/compose'; +import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments'; +import type { MediaAttachment } from 'mastodon/models/media_attachment'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & { + unattached?: boolean; +}; + +const simulateModifiedApiResponse = ( + media: MediaAttachment, + params: { description?: string; focus?: string }, +): SimulatedMediaAttachmentJSON => { + const [x, y] = (params.focus ?? '').split(','); + + const data = { + ...media.toJS(), + ...params, + meta: { + focus: { + x: parseFloat(x ?? '0'), + y: parseFloat(y ?? '0'), + }, + }, + } as unknown as SimulatedMediaAttachmentJSON; + + return data; +}; + +export const changeUploadCompose = createDataLoadingThunk( + 'compose/changeUpload', + async ( + { + id, + ...params + }: { + id: string; + description: string; + focus: string; + }, + { getState }, + ) => { + const media = ( + (getState().compose as ImmutableMap).get( + 'media_attachments', + ) as ImmutableList + ).find((item) => item.get('id') === id); + + // Editing already-attached media is deferred to editing the post itself. + // For simplicity's sake, fake an API reply. + if (media && !media.get('unattached')) { + return new Promise((resolve) => { + resolve(simulateModifiedApiResponse(media, params)); + }); + } + + return apiUpdateMedia(id, params); + }, + (media: SimulatedMediaAttachmentJSON) => { + return { + media, + attached: typeof media.unattached !== 'undefined' && !media.unattached, + }; + }, + { + useLoadingBar: false, + }, +); diff --git a/app/javascript/mastodon/actions/conversations.js b/app/javascript/mastodon/actions/conversations.js index 8c4c4529fb..03174c485d 100644 --- a/app/javascript/mastodon/actions/conversations.js +++ b/app/javascript/mastodon/actions/conversations.js @@ -28,13 +28,13 @@ export const unmountConversations = () => ({ type: CONVERSATIONS_UNMOUNT, }); -export const markConversationRead = conversationId => (dispatch, getState) => { +export const markConversationRead = conversationId => (dispatch) => { dispatch({ type: CONVERSATIONS_READ, id: conversationId, }); - api(getState).post(`/api/v1/conversations/${conversationId}/read`); + api().post(`/api/v1/conversations/${conversationId}/read`); }; export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => { @@ -48,7 +48,7 @@ export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => { const isLoadingRecent = !!params.since_id; - api(getState).get('/api/v1/conversations', { params }) + api().get('/api/v1/conversations', { params }) .then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); @@ -88,10 +88,10 @@ export const updateConversations = conversation => dispatch => { }); }; -export const deleteConversation = conversationId => (dispatch, getState) => { +export const deleteConversation = conversationId => (dispatch) => { dispatch(deleteConversationRequest(conversationId)); - api(getState).delete(`/api/v1/conversations/${conversationId}`) + api().delete(`/api/v1/conversations/${conversationId}`) .then(() => dispatch(deleteConversationSuccess(conversationId))) .catch(error => dispatch(deleteConversationFail(conversationId, error))); }; diff --git a/app/javascript/mastodon/actions/custom_emojis.js b/app/javascript/mastodon/actions/custom_emojis.js index 9ec8156b17..fb65f072dc 100644 --- a/app/javascript/mastodon/actions/custom_emojis.js +++ b/app/javascript/mastodon/actions/custom_emojis.js @@ -5,10 +5,10 @@ export const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS'; export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL'; export function fetchCustomEmojis() { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchCustomEmojisRequest()); - api(getState).get('/api/v1/custom_emojis').then(response => { + api().get('/api/v1/custom_emojis').then(response => { dispatch(fetchCustomEmojisSuccess(response.data)); }).catch(error => { dispatch(fetchCustomEmojisFail(error)); diff --git a/app/javascript/mastodon/actions/directory.js b/app/javascript/mastodon/actions/directory.js deleted file mode 100644 index cda63f2b5a..0000000000 --- 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, getState) => { - dispatch(fetchDirectoryRequest()); - - api(getState).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(getState).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 0000000000..34ac309c66 --- /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/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js index 718002613f..279ec1bef7 100644 --- a/app/javascript/mastodon/actions/domain_blocks.js +++ b/app/javascript/mastodon/actions/domain_blocks.js @@ -1,6 +1,8 @@ import api, { getLinks } from '../api'; import { blockDomainSuccess, unblockDomainSuccess } from "./domain_blocks_typed"; +import { openModal } from './modal'; + export * from "./domain_blocks_typed"; @@ -10,19 +12,11 @@ export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; -export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; -export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS'; -export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL'; - -export const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST'; -export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS'; -export const DOMAIN_BLOCKS_EXPAND_FAIL = 'DOMAIN_BLOCKS_EXPAND_FAIL'; - export function blockDomain(domain) { return (dispatch, getState) => { dispatch(blockDomainRequest(domain)); - api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { + api().post('/api/v1/domain_blocks', { domain }).then(() => { const at_domain = '@' + domain; const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); @@ -52,7 +46,7 @@ export function unblockDomain(domain) { return (dispatch, getState) => { dispatch(unblockDomainRequest(domain)); - api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { + api().delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { const at_domain = '@' + domain; const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); dispatch(unblockDomainSuccess({ domain, accounts })); @@ -77,76 +71,11 @@ export function unblockDomainFail(domain, error) { }; } -export function fetchDomainBlocks() { - return (dispatch, getState) => { - dispatch(fetchDomainBlocksRequest()); - - api(getState).get('/api/v1/domain_blocks').then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null)); - }).catch(err => { - dispatch(fetchDomainBlocksFail(err)); - }); - }; -} - -export function fetchDomainBlocksRequest() { - return { - type: DOMAIN_BLOCKS_FETCH_REQUEST, - }; -} - -export function fetchDomainBlocksSuccess(domains, next) { - return { - type: DOMAIN_BLOCKS_FETCH_SUCCESS, - domains, - next, - }; -} - -export function fetchDomainBlocksFail(error) { - return { - type: DOMAIN_BLOCKS_FETCH_FAIL, - error, - }; -} - -export function expandDomainBlocks() { - return (dispatch, getState) => { - const url = getState().getIn(['domain_lists', 'blocks', 'next']); - - if (!url) { - return; - } - - dispatch(expandDomainBlocksRequest()); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null)); - }).catch(err => { - dispatch(expandDomainBlocksFail(err)); - }); - }; -} - -export function expandDomainBlocksRequest() { - return { - type: DOMAIN_BLOCKS_EXPAND_REQUEST, - }; -} - -export function expandDomainBlocksSuccess(domains, next) { - return { - type: DOMAIN_BLOCKS_EXPAND_SUCCESS, - domains, - next, - }; -} - -export function expandDomainBlocksFail(error) { - return { - type: DOMAIN_BLOCKS_EXPAND_FAIL, - error, - }; -} +export const initDomainBlockModal = account => dispatch => dispatch(openModal({ + modalType: 'DOMAIN_BLOCK', + modalProps: { + domain: account.get('acct').split('@')[1], + acct: account.get('acct'), + accountId: account.get('id'), + }, +})); diff --git a/app/javascript/mastodon/actions/dropdown_menu.ts b/app/javascript/mastodon/actions/dropdown_menu.ts index 3694df1ae0..d9d395ba33 100644 --- a/app/javascript/mastodon/actions/dropdown_menu.ts +++ b/app/javascript/mastodon/actions/dropdown_menu.ts @@ -1,11 +1,11 @@ import { createAction } from '@reduxjs/toolkit'; export const openDropdownMenu = createAction<{ - id: string; + id: number; keyboard: boolean; - scrollKey: string; + scrollKey?: string; }>('dropdownMenu/open'); -export const closeDropdownMenu = createAction<{ id: string }>( +export const closeDropdownMenu = createAction<{ id: number }>( 'dropdownMenu/close', ); diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js index e5e2c481aa..6270f3473a 100644 --- a/app/javascript/mastodon/actions/favourites.js +++ b/app/javascript/mastodon/actions/favourites.js @@ -20,7 +20,7 @@ export function fetchFavouritedStatuses() { dispatch(fetchFavouritedStatusesRequest()); - api(getState).get('/api/v1/favourites').then(response => { + api().get('/api/v1/favourites').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); @@ -64,7 +64,7 @@ export function expandFavouritedStatuses() { dispatch(expandFavouritedStatusesRequest()); - api(getState).get(url).then(response => { + api().get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); diff --git a/app/javascript/mastodon/actions/featured_tags.js b/app/javascript/mastodon/actions/featured_tags.js index 18bb615394..6ee4dee2bc 100644 --- a/app/javascript/mastodon/actions/featured_tags.js +++ b/app/javascript/mastodon/actions/featured_tags.js @@ -11,7 +11,7 @@ export const fetchFeaturedTags = (id) => (dispatch, getState) => { dispatch(fetchFeaturedTagsRequest(id)); - api(getState).get(`/api/v1/accounts/${id}/featured_tags`) + api().get(`/api/v1/accounts/${id}/featured_tags`) .then(({ data }) => dispatch(fetchFeaturedTagsSuccess(id, data))) .catch(err => dispatch(fetchFeaturedTagsFail(id, err))); }; diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js index a11956ac56..588e390f0a 100644 --- a/app/javascript/mastodon/actions/filters.js +++ b/app/javascript/mastodon/actions/filters.js @@ -23,13 +23,13 @@ export const initAddFilter = (status, { contextType }) => dispatch => }, })); -export const fetchFilters = () => (dispatch, getState) => { +export const fetchFilters = () => (dispatch) => { dispatch({ type: FILTERS_FETCH_REQUEST, skipLoading: true, }); - api(getState) + api() .get('/api/v2/filters') .then(({ data }) => dispatch({ type: FILTERS_FETCH_SUCCESS, @@ -44,10 +44,10 @@ export const fetchFilters = () => (dispatch, getState) => { })); }; -export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => { +export const createFilterStatus = (params, onSuccess, onFail) => (dispatch) => { dispatch(createFilterStatusRequest()); - api(getState).post(`/api/v2/filters/${params.filter_id}/statuses`, params).then(response => { + api().post(`/api/v2/filters/${params.filter_id}/statuses`, params).then(response => { dispatch(createFilterStatusSuccess(response.data)); if (onSuccess) onSuccess(); }).catch(error => { @@ -70,10 +70,10 @@ export const createFilterStatusFail = error => ({ error, }); -export const createFilter = (params, onSuccess, onFail) => (dispatch, getState) => { +export const createFilter = (params, onSuccess, onFail) => (dispatch) => { dispatch(createFilterRequest()); - api(getState).post('/api/v2/filters', params).then(response => { + api().post('/api/v2/filters', params).then(response => { dispatch(createFilterSuccess(response.data)); if (onSuccess) onSuccess(response.data); }).catch(error => { diff --git a/app/javascript/mastodon/actions/history.js b/app/javascript/mastodon/actions/history.js index 52401b7dce..07732ea187 100644 --- a/app/javascript/mastodon/actions/history.js +++ b/app/javascript/mastodon/actions/history.js @@ -15,7 +15,7 @@ export const fetchHistory = statusId => (dispatch, getState) => { dispatch(fetchHistoryRequest(statusId)); - api(getState).get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => { + api().get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => { dispatch(importFetchedAccounts(data.map(x => x.account))); dispatch(fetchHistorySuccess(statusId, data)); }).catch(error => dispatch(fetchHistoryFail(error))); diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 4edccd908f..fc165b1a1f 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -1,10 +1,12 @@ +import { createPollFromServerJSON } from 'mastodon/models/poll'; + import { importAccounts } from '../accounts_typed'; -import { normalizeStatus, normalizePoll } from './normalizer'; +import { normalizeStatus } from './normalizer'; +import { importPolls } from './polls'; export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; -export const POLLS_IMPORT = 'POLLS_IMPORT'; export const FILTERS_IMPORT = 'FILTERS_IMPORT'; function pushUnique(array, object) { @@ -25,10 +27,6 @@ export function importFilters(filters) { return { type: FILTERS_IMPORT, filters }; } -export function importPolls(polls) { - return { type: POLLS_IMPORT, polls }; -} - export function importFetchedAccount(account) { return importFetchedAccounts([account]); } @@ -68,30 +66,28 @@ export function importFetchedStatuses(statuses) { status.filtered.forEach(result => pushUnique(filters, result.filter)); } - if (status.reblog && status.reblog.id) { + if (status.reblog?.id) { processStatus(status.reblog); } - if (status.quote && status.quote.id) { + if (status.quote?.id && !getState().getIn(['statuses', status.id])) { processStatus(status.quote); } - if (status.poll && status.poll.id) { - pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); + if (status.poll?.id) { + pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id])); + } + + if (status.card) { + status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account)); } } statuses.forEach(processStatus); - dispatch(importPolls(polls)); + dispatch(importPolls({ polls })); dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); dispatch(importFilters(filters)); }; } - -export function importFetchedPoll(poll) { - return (dispatch, getState) => { - dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))])); - }; -} diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 05b6250761..b643cf5613 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -1,15 +1,12 @@ import escapeTextContentForBrowser from 'escape-html'; +import { makeEmojiMap } from 'mastodon/models/custom_emoji'; + import emojify from '../../features/emoji/emoji'; import { expandSpoilers, me } from '../../initial_state'; const domParser = new DOMParser(); -const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => { - obj[`:${emoji.shortcode}:`] = emoji; - return obj; -}, {}); - export function searchTextFromRawStatus (status) { const spoilerText = status.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); @@ -36,6 +33,17 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.poll = status.poll.id; } + if (status.card) { + normalStatus.card = { + ...status.card, + authors: status.card.authors.map(author => ({ + ...author, + accountId: author.account?.id, + account: undefined, + })), + }; + } + if (status.filtered) { normalStatus.filtered = status.filtered.map(normalizeFilterResult); } @@ -129,38 +137,6 @@ export function normalizeStatusTranslation(translation, status) { return normalTranslation; } -export function normalizePoll(poll, normalOldPoll) { - const normalPoll = { ...poll }; - const emojiMap = makeEmojiMap(poll.emojis); - - normalPoll.options = poll.options.map((option, index) => { - const normalOption = { - ...option, - voted: poll.own_votes && poll.own_votes.includes(index), - titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap), - }; - - if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) { - normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']); - } - - return normalOption; - }); - - return normalPoll; -} - -export function normalizePollOptionTranslation(translation, poll) { - const emojiMap = makeEmojiMap(poll.get('emojis').toJS()); - - const normalTranslation = { - ...translation, - titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap), - }; - - return normalTranslation; -} - export function normalizeAnnouncement(announcement) { const normalAnnouncement = { ...announcement }; const emojiMap = makeEmojiMap(normalAnnouncement.emojis); diff --git a/app/javascript/mastodon/actions/importer/polls.ts b/app/javascript/mastodon/actions/importer/polls.ts new file mode 100644 index 0000000000..5bbe7d57d6 --- /dev/null +++ b/app/javascript/mastodon/actions/importer/polls.ts @@ -0,0 +1,7 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { Poll } from 'mastodon/models/poll'; + +export const importPolls = createAction<{ polls: Poll[] }>( + 'poll/importMultiple', +); diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 640c5c3128..1392d3a667 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -1,11 +1,11 @@ +import { boostModal } from 'mastodon/initial_state'; + import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer'; - -export const REBLOG_REQUEST = 'REBLOG_REQUEST'; -export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; -export const REBLOG_FAIL = 'REBLOG_FAIL'; +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'; @@ -19,10 +19,6 @@ export const EMOJIREACT_REQUEST = 'EMOJIREACT_REQUEST'; export const EMOJIREACT_SUCCESS = 'EMOJIREACT_SUCCESS'; export const EMOJIREACT_FAIL = 'EMOJIREACT_FAIL'; -export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; -export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; -export const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; - export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; @@ -79,89 +75,13 @@ export const MENTIONED_USERS_EXPAND_REQUEST = 'MENTIONED_USERS_EXPAND_REQUEST'; export const MENTIONED_USERS_EXPAND_SUCCESS = 'MENTIONED_USERS_EXPAND_SUCCESS'; export const MENTIONED_USERS_EXPAND_FAIL = 'MENTIONED_USERS_EXPAND_FAIL'; -export function reblog(status, visibility) { - return function (dispatch, getState) { - dispatch(reblogRequest(status)); - - api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`, { visibility }).then(function (response) { - // The reblog API method returns a new status wrapped around the original. In this case we are only - // interested in how the original is modified, hence passing it skipping the wrapper - dispatch(importFetchedStatus(response.data.reblog)); - dispatch(reblogSuccess(status)); - }).catch(function (error) { - dispatch(reblogFail(status, error)); - }); - }; -} - -export function unreblog(status) { - return (dispatch, getState) => { - dispatch(unreblogRequest(status)); - - api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { - dispatch(importFetchedStatus(response.data)); - dispatch(unreblogSuccess(status)); - }).catch(error => { - dispatch(unreblogFail(status, error)); - }); - }; -} - -export function reblogRequest(status) { - return { - type: REBLOG_REQUEST, - status: status, - skipLoading: true, - }; -} - -export function reblogSuccess(status) { - return { - type: REBLOG_SUCCESS, - status: status, - skipLoading: true, - }; -} - -export function reblogFail(status, error) { - return { - type: REBLOG_FAIL, - status: status, - error: error, - skipLoading: true, - }; -} - -export function unreblogRequest(status) { - return { - type: UNREBLOG_REQUEST, - status: status, - skipLoading: true, - }; -} - -export function unreblogSuccess(status) { - return { - type: UNREBLOG_SUCCESS, - status: status, - skipLoading: true, - }; -} - -export function unreblogFail(status, error) { - return { - type: UNREBLOG_FAIL, - status: status, - error: error, - skipLoading: true, - }; -} +export * from "./interactions_typed"; export function favourite(status) { - return function (dispatch, getState) { + return function (dispatch) { dispatch(favouriteRequest(status)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { + api().post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { dispatch(importFetchedStatus(response.data)); dispatch(favouriteSuccess(status)); }).catch(function (error) { @@ -171,10 +91,10 @@ export function favourite(status) { } export function unfavourite(status) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(unfavouriteRequest(status)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { + api().post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unfavouriteSuccess(status)); }).catch(error => { @@ -319,10 +239,10 @@ export function unEmojiReactFail(status, emoji, error) { } export function bookmark(status) { - return function (dispatch, getState) { + return function (dispatch) { dispatch(bookmarkRequest(status)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) { + api().post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) { dispatch(importFetchedStatus(response.data)); dispatch(bookmarkSuccess(status, response.data)); }).catch(function (error) { @@ -332,10 +252,10 @@ export function bookmark(status) { } export function unbookmark(status) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(unbookmarkRequest(status)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { + api().post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unbookmarkSuccess(status, response.data)); }).catch(error => { @@ -391,10 +311,10 @@ export function unbookmarkFail(status, error) { } export function fetchReblogs(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchReblogsRequest(id)); - api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { + api().get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null)); @@ -438,7 +358,7 @@ export function expandReblogs(id) { dispatch(expandReblogsRequest(id)); - api(getState).get(url).then(response => { + api().get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); @@ -473,10 +393,10 @@ export function expandReblogsFail(id, error) { } export function fetchFavourites(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchFavouritesRequest(id)); - api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { + api().get(`/api/v1/statuses/${id}/favourited_by`).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null)); @@ -520,7 +440,7 @@ export function expandFavourites(id) { dispatch(expandFavouritesRequest(id)); - api(getState).get(url).then(response => { + api().get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); @@ -669,10 +589,10 @@ export function fetchStatusReferencesFail(id, error) { } export function pin(status) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(pinRequest(status)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { + api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(pinSuccess(status)); }).catch(error => { @@ -707,10 +627,10 @@ export function pinFail(status, error) { } export function unpin (status) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(unpinRequest(status)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { + api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unpinSuccess(status)); }).catch(error => { @@ -825,3 +745,49 @@ export function expandMentionedUsersFail(id, error) { error, }; } + +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, forceModal = 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) || (forceModal && !status.get('reblogged'))) { + 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/interactions_typed.ts b/app/javascript/mastodon/actions/interactions_typed.ts new file mode 100644 index 0000000000..f58faffa86 --- /dev/null +++ b/app/javascript/mastodon/actions/interactions_typed.ts @@ -0,0 +1,35 @@ +import { apiReblog, apiUnreblog } from 'mastodon/api/interactions'; +import type { StatusVisibility } from 'mastodon/models/status'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +import { importFetchedStatus } from './importer'; + +export const reblog = createDataLoadingThunk( + 'status/reblog', + ({ + statusId, + visibility, + }: { + statusId: string; + visibility: StatusVisibility; + }) => apiReblog(statusId, visibility), + (data, { dispatch, discardLoadData }) => { + // The reblog API method returns a new status wrapped around the original. In this case we are only + // interested in how the original is modified, hence passing it skipping the wrapper + dispatch(importFetchedStatus(data.reblog)); + + // The payload is not used in any actions + return discardLoadData; + }, +); + +export const unreblog = createDataLoadingThunk( + 'status/unreblog', + ({ statusId }: { statusId: string }) => apiUnreblog(statusId), + (data, { dispatch, discardLoadData }) => { + dispatch(importFetchedStatus(data)); + + // The payload is not used in any actions + return discardLoadData; + }, +); diff --git a/app/javascript/mastodon/actions/languages.js b/app/javascript/mastodon/actions/languages.js deleted file mode 100644 index ad186ba0cc..0000000000 --- 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/lists.js b/app/javascript/mastodon/actions/lists.js index e494a40a5d..7b6dd22041 100644 --- a/app/javascript/mastodon/actions/lists.js +++ b/app/javascript/mastodon/actions/lists.js @@ -2,9 +2,6 @@ import api from '../api'; -import { showAlertForError } from './alerts'; -import { importFetchedAccounts } from './importer'; - export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL'; @@ -13,45 +10,10 @@ export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST'; export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS'; export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL'; -export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE'; -export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET'; -export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP'; - -export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST'; -export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS'; -export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL'; - -export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST'; -export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS'; -export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL'; - export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST'; export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS'; export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL'; -export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; -export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; -export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; - -export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; -export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; -export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; - -export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST'; -export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS'; -export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL'; - -export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; -export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; -export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; - -export const LIST_ADDER_RESET = 'LIST_ADDER_RESET'; -export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP'; - -export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST'; -export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS'; -export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL'; - export const fetchList = id => (dispatch, getState) => { if (getState().getIn(['lists', id])) { return; @@ -59,7 +21,7 @@ export const fetchList = id => (dispatch, getState) => { dispatch(fetchListRequest(id)); - api(getState).get(`/api/v1/lists/${id}`) + api().get(`/api/v1/lists/${id}`) .then(({ data }) => dispatch(fetchListSuccess(data))) .catch(err => dispatch(fetchListFail(id, err))); }; @@ -80,10 +42,10 @@ export const fetchListFail = (id, error) => ({ error, }); -export const fetchLists = () => (dispatch, getState) => { +export const fetchLists = () => (dispatch) => { dispatch(fetchListsRequest()); - api(getState).get('/api/v1/lists') + api().get('/api/v1/lists') .then(({ data }) => dispatch(fetchListsSuccess(data))) .catch(err => dispatch(fetchListsFail(err))); }; @@ -102,98 +64,10 @@ export const fetchListsFail = error => ({ error, }); -export const submitListEditor = shouldReset => (dispatch, getState) => { - const listId = getState().getIn(['listEditor', 'listId']); - const title = getState().getIn(['listEditor', 'title']); - - if (listId === null) { - dispatch(createList(title, shouldReset)); - } else { - dispatch(updateList(listId, title, shouldReset)); - } -}; - -export const setupListEditor = listId => (dispatch, getState) => { - dispatch({ - type: LIST_EDITOR_SETUP, - list: getState().getIn(['lists', listId]), - }); - - dispatch(fetchListAccounts(listId)); -}; - -export const changeListEditorTitle = value => ({ - type: LIST_EDITOR_TITLE_CHANGE, - value, -}); - -export const createList = (title, shouldReset) => (dispatch, getState) => { - dispatch(createListRequest()); - - api(getState).post('/api/v1/lists', { title }).then(({ data }) => { - dispatch(createListSuccess(data)); - - if (shouldReset) { - dispatch(resetListEditor()); - } - }).catch(err => dispatch(createListFail(err))); -}; - -export const createListRequest = () => ({ - type: LIST_CREATE_REQUEST, -}); - -export const createListSuccess = list => ({ - type: LIST_CREATE_SUCCESS, - list, -}); - -export const createListFail = error => ({ - type: LIST_CREATE_FAIL, - error, -}); - -export const updateList = (id, title, shouldReset, isExclusive, replies_policy, notify) => (dispatch, getState) => { - dispatch(updateListRequest(id)); - - api(getState).put(`/api/v1/lists/${id}`, { - title, - replies_policy, - exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive, - notify: typeof notify === 'undefined' ? undefined : !!notify, - }).then(({ data }) => { - dispatch(updateListSuccess(data)); - - if (shouldReset) { - dispatch(resetListEditor()); - } - }).catch(err => dispatch(updateListFail(id, err))); -}; - -export const updateListRequest = id => ({ - type: LIST_UPDATE_REQUEST, - id, -}); - -export const updateListSuccess = list => ({ - type: LIST_UPDATE_SUCCESS, - list, -}); - -export const updateListFail = (id, error) => ({ - type: LIST_UPDATE_FAIL, - id, - error, -}); - -export const resetListEditor = () => ({ - type: LIST_EDITOR_RESET, -}); - -export const deleteList = id => (dispatch, getState) => { +export const deleteList = id => (dispatch) => { dispatch(deleteListRequest(id)); - api(getState).delete(`/api/v1/lists/${id}`) + api().delete(`/api/v1/lists/${id}`) .then(() => dispatch(deleteListSuccess(id))) .catch(err => dispatch(deleteListFail(id, err))); }; @@ -213,167 +87,3 @@ export const deleteListFail = (id, error) => ({ id, error, }); - -export const fetchListAccounts = listId => (dispatch, getState) => { - dispatch(fetchListAccountsRequest(listId)); - - api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchListAccountsSuccess(listId, data)); - }).catch(err => dispatch(fetchListAccountsFail(listId, err))); -}; - -export const fetchListAccountsRequest = id => ({ - type: LIST_ACCOUNTS_FETCH_REQUEST, - id, -}); - -export const fetchListAccountsSuccess = (id, accounts, next) => ({ - type: LIST_ACCOUNTS_FETCH_SUCCESS, - id, - accounts, - next, -}); - -export const fetchListAccountsFail = (id, error) => ({ - type: LIST_ACCOUNTS_FETCH_FAIL, - id, - error, -}); - -export const fetchListSuggestions = q => (dispatch, getState) => { - const params = { - q, - resolve: false, - following: true, - }; - - api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchListSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); -}; - -export const fetchListSuggestionsReady = (query, accounts) => ({ - type: LIST_EDITOR_SUGGESTIONS_READY, - query, - accounts, -}); - -export const clearListSuggestions = () => ({ - type: LIST_EDITOR_SUGGESTIONS_CLEAR, -}); - -export const changeListSuggestions = value => ({ - type: LIST_EDITOR_SUGGESTIONS_CHANGE, - value, -}); - -export const addToListEditor = accountId => (dispatch, getState) => { - dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId)); -}; - -export const addToList = (listId, accountId) => (dispatch, getState) => { - dispatch(addToListRequest(listId, accountId)); - - api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) - .then(() => dispatch(addToListSuccess(listId, accountId))) - .catch(err => dispatch(addToListFail(listId, accountId, err))); -}; - -export const addToListRequest = (listId, accountId) => ({ - type: LIST_EDITOR_ADD_REQUEST, - listId, - accountId, -}); - -export const addToListSuccess = (listId, accountId) => ({ - type: LIST_EDITOR_ADD_SUCCESS, - listId, - accountId, -}); - -export const addToListFail = (listId, accountId, error) => ({ - type: LIST_EDITOR_ADD_FAIL, - listId, - accountId, - error, -}); - -export const removeFromListEditor = accountId => (dispatch, getState) => { - dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId)); -}; - -export const removeFromList = (listId, accountId) => (dispatch, getState) => { - dispatch(removeFromListRequest(listId, accountId)); - - api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) - .then(() => dispatch(removeFromListSuccess(listId, accountId))) - .catch(err => dispatch(removeFromListFail(listId, accountId, err))); -}; - -export const removeFromListRequest = (listId, accountId) => ({ - type: LIST_EDITOR_REMOVE_REQUEST, - listId, - accountId, -}); - -export const removeFromListSuccess = (listId, accountId) => ({ - type: LIST_EDITOR_REMOVE_SUCCESS, - listId, - accountId, -}); - -export const removeFromListFail = (listId, accountId, error) => ({ - type: LIST_EDITOR_REMOVE_FAIL, - listId, - accountId, - error, -}); - -export const resetListAdder = () => ({ - type: LIST_ADDER_RESET, -}); - -export const setupListAdder = accountId => (dispatch, getState) => { - dispatch({ - type: LIST_ADDER_SETUP, - account: getState().getIn(['accounts', accountId]), - }); - dispatch(fetchLists()); - dispatch(fetchAccountLists(accountId)); -}; - -export const fetchAccountLists = accountId => (dispatch, getState) => { - dispatch(fetchAccountListsRequest(accountId)); - - api(getState).get(`/api/v1/accounts/${accountId}/lists`) - .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data))) - .catch(err => dispatch(fetchAccountListsFail(accountId, err))); -}; - -export const fetchAccountListsRequest = id => ({ - type:LIST_ADDER_LISTS_FETCH_REQUEST, - id, -}); - -export const fetchAccountListsSuccess = (id, lists) => ({ - type: LIST_ADDER_LISTS_FETCH_SUCCESS, - id, - lists, -}); - -export const fetchAccountListsFail = (id, err) => ({ - type: LIST_ADDER_LISTS_FETCH_FAIL, - id, - err, -}); - -export const addToListAdder = listId => (dispatch, getState) => { - dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId']))); -}; - -export const removeFromListAdder = listId => (dispatch, getState) => { - dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId']))); -}; - diff --git a/app/javascript/mastodon/actions/lists_typed.ts b/app/javascript/mastodon/actions/lists_typed.ts new file mode 100644 index 0000000000..eca051f52c --- /dev/null +++ b/app/javascript/mastodon/actions/lists_typed.ts @@ -0,0 +1,17 @@ +import { apiCreate, apiUpdate } from 'mastodon/api/lists'; +import type { List } from 'mastodon/models/list'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const createList = createDataLoadingThunk( + 'list/create', + (list: Partial) => apiCreate(list), +); + +// Kmyblue tracking marker: copied antenna, circle, bookmark_category + +export const updateList = createDataLoadingThunk( + 'list/update', + (list: Partial) => apiUpdate(list), +); + +// Kmyblue tracking marker: copied antenna, circle, bookmark_category diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js deleted file mode 100644 index cfc329a8b7..0000000000 --- a/app/javascript/mastodon/actions/markers.js +++ /dev/null @@ -1,152 +0,0 @@ -import { List as ImmutableList } from 'immutable'; - -import { debounce } from 'lodash'; - -import api from '../api'; -import { compareId } from '../compare_id'; - -export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; -export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; -export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL'; -export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS'; - -export const synchronouslySubmitMarkers = () => (dispatch, getState) => { - const accessToken = getState().getIn(['meta', 'access_token'], ''); - const params = _buildParams(getState()); - - if (Object.keys(params).length === 0 || accessToken === '') { - return; - } - - // The Fetch API allows us to perform requests that will be carried out - // after the page closes. But that only works if the `keepalive` attribute - // is supported. - if (window.fetch && 'keepalive' in new Request('')) { - fetch('/api/v1/markers', { - keepalive: true, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, - }, - body: JSON.stringify(params), - }); - - return; - } else if (navigator && navigator.sendBeacon) { - // Failing that, we can use sendBeacon, but we have to encode the data as - // FormData for DoorKeeper to recognize the token. - const formData = new FormData(); - - formData.append('bearer_token', accessToken); - - for (const [id, value] of Object.entries(params)) { - formData.append(`${id}[last_read_id]`, value.last_read_id); - } - - if (navigator.sendBeacon('/api/v1/markers', formData)) { - return; - } - } - - // If neither Fetch nor sendBeacon worked, try to perform a synchronous - // request. - try { - const client = new XMLHttpRequest(); - - client.open('POST', '/api/v1/markers', false); - client.setRequestHeader('Content-Type', 'application/json'); - client.setRequestHeader('Authorization', `Bearer ${accessToken}`); - client.send(JSON.stringify(params)); - } catch (e) { - // Do not make the BeforeUnload handler error out - } -}; - -const _buildParams = (state) => { - const params = {}; - - const lastHomeId = state.getIn(['timelines', 'home', 'items'], ImmutableList()).find(item => item !== null); - const lastNotificationId = state.getIn(['notifications', 'lastReadId']); - - if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { - params.home = { - last_read_id: lastHomeId, - }; - } - - if (lastNotificationId && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) { - params.notifications = { - last_read_id: lastNotificationId, - }; - } - - return params; -}; - -const debouncedSubmitMarkers = debounce((dispatch, getState) => { - const accessToken = getState().getIn(['meta', 'access_token'], ''); - const params = _buildParams(getState()); - - if (Object.keys(params).length === 0 || accessToken === '') { - return; - } - - api(getState).post('/api/v1/markers', params).then(() => { - dispatch(submitMarkersSuccess(params)); - }).catch(() => {}); -}, 300000, { leading: true, trailing: true }); - -export function submitMarkersSuccess({ home, notifications }) { - return { - type: MARKERS_SUBMIT_SUCCESS, - home: (home || {}).last_read_id, - notifications: (notifications || {}).last_read_id, - }; -} - -export function submitMarkers(params = {}) { - const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); - - if (params.immediate === true) { - debouncedSubmitMarkers.flush(); - } - - return result; -} - -export const fetchMarkers = () => (dispatch, getState) => { - const params = { timeline: ['notifications'] }; - - dispatch(fetchMarkersRequest()); - - api(getState).get('/api/v1/markers', { params }).then(response => { - dispatch(fetchMarkersSuccess(response.data)); - }).catch(error => { - dispatch(fetchMarkersFail(error)); - }); -}; - -export function fetchMarkersRequest() { - return { - type: MARKERS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchMarkersSuccess(markers) { - return { - type: MARKERS_FETCH_SUCCESS, - markers, - skipLoading: true, - }; -} - -export function fetchMarkersFail(error) { - return { - type: MARKERS_FETCH_FAIL, - error, - skipLoading: true, - skipAlert: true, - }; -} diff --git a/app/javascript/mastodon/actions/markers.ts b/app/javascript/mastodon/actions/markers.ts new file mode 100644 index 0000000000..251546cb9a --- /dev/null +++ b/app/javascript/mastodon/actions/markers.ts @@ -0,0 +1,145 @@ +import { debounce } from 'lodash'; + +import type { MarkerJSON } from 'mastodon/api_types/markers'; +import { getAccessToken } from 'mastodon/initial_state'; +import type { AppDispatch, RootState } from 'mastodon/store'; +import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; + +import api from '../api'; +import { compareId } from '../compare_id'; + +export const synchronouslySubmitMarkers = createAppAsyncThunk( + 'markers/submit', + async (_args, { getState }) => { + const accessToken = getAccessToken(); + const params = buildPostMarkersParams(getState()); + + if ( + Object.keys(params).length === 0 || + !accessToken || + accessToken === '' + ) { + return; + } + + // The Fetch API allows us to perform requests that will be carried out + // after the page closes. But that only works if the `keepalive` attribute + // is supported. + if ('fetch' in window && 'keepalive' in new Request('')) { + await fetch('/api/v1/markers', { + keepalive: true, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(params), + }); + + return; + } else if ('sendBeacon' in navigator) { + // Failing that, we can use sendBeacon, but we have to encode the data as + // FormData for DoorKeeper to recognize the token. + const formData = new FormData(); + + formData.append('bearer_token', accessToken); + + for (const [id, value] of Object.entries(params)) { + if (value.last_read_id) + formData.append(`${id}[last_read_id]`, value.last_read_id); + } + + if (navigator.sendBeacon('/api/v1/markers', formData)) { + return; + } + } + + // If neither Fetch nor sendBeacon worked, try to perform a synchronous + // request. + try { + const client = new XMLHttpRequest(); + + client.open('POST', '/api/v1/markers', false); + client.setRequestHeader('Content-Type', 'application/json'); + client.setRequestHeader('Authorization', `Bearer ${accessToken}`); + client.send(JSON.stringify(params)); + } catch { + // Do not make the BeforeUnload handler error out + } + }, +); + +interface MarkerParam { + last_read_id?: string; +} + +function getLastNotificationId(state: RootState): string | undefined { + return state.notificationGroups.lastReadId; +} + +const buildPostMarkersParams = (state: RootState) => { + const params = {} as { home?: MarkerParam; notifications?: MarkerParam }; + + const lastNotificationId = getLastNotificationId(state); + + if ( + lastNotificationId && + compareId(lastNotificationId, state.markers.notifications) > 0 + ) { + params.notifications = { + last_read_id: lastNotificationId, + }; + } + + return params; +}; + +export const submitMarkersAction = createAppAsyncThunk<{ + home: string | undefined; + notifications: string | undefined; +}>('markers/submitAction', async (_args, { getState }) => { + const accessToken = getAccessToken(); + const params = buildPostMarkersParams(getState()); + + if (Object.keys(params).length === 0 || !accessToken || accessToken === '') { + return { home: undefined, notifications: undefined }; + } + + await api().post('/api/v1/markers', params); + + return { + home: params.home?.last_read_id, + notifications: params.notifications?.last_read_id, + }; +}); + +const debouncedSubmitMarkers = debounce( + (dispatch: AppDispatch) => { + void dispatch(submitMarkersAction()); + }, + 300000, + { + leading: true, + trailing: true, + }, +); + +export const submitMarkers = createAppAsyncThunk( + 'markers/submit', + (params: { immediate?: boolean }, { dispatch }) => { + debouncedSubmitMarkers(dispatch); + + if (params.immediate) { + debouncedSubmitMarkers.flush(); + } + }, +); + +export const fetchMarkers = createAppAsyncThunk('markers/fetch', async () => { + const response = await api().get>( + `/api/v1/markers`, + { params: { timeline: ['notifications'] } }, + ); + + return { markers: response.data }; +}); diff --git a/app/javascript/mastodon/actions/modal.ts b/app/javascript/mastodon/actions/modal.ts index ab03e46765..49af176a11 100644 --- a/app/javascript/mastodon/actions/modal.ts +++ b/app/javascript/mastodon/actions/modal.ts @@ -9,6 +9,7 @@ export type ModalType = keyof typeof MODAL_COMPONENTS; interface OpenModalPayload { modalType: ModalType; modalProps: ModalProps; + previousModalProps?: ModalProps; } export const openModal = createAction('MODAL_OPEN'); diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js index fb041078b8..3676748cf3 100644 --- a/app/javascript/mastodon/actions/mutes.js +++ b/app/javascript/mastodon/actions/mutes.js @@ -12,15 +12,11 @@ export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; -export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; -export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; -export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; - export function fetchMutes() { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchMutesRequest()); - api(getState).get('/api/v1/mutes').then(response => { + api().get('/api/v1/mutes').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); @@ -60,7 +56,7 @@ export function expandMutes() { dispatch(expandMutesRequest()); - api(getState).get(url).then(response => { + api().get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data)); dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); @@ -92,26 +88,12 @@ export function expandMutesFail(error) { export function initMuteModal(account) { return dispatch => { - dispatch({ - type: MUTES_INIT_MODAL, - account, - }); - - dispatch(openModal({ modalType: 'MUTE' })); - }; -} - -export function toggleHideNotifications() { - return dispatch => { - dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); - }; -} - -export function changeMuteDuration(duration) { - return dispatch => { - dispatch({ - type: MUTES_CHANGE_DURATION, - duration, - }); + dispatch(openModal({ + modalType: 'MUTE', + modalProps: { + accountId: account.get('id'), + acct: account.get('acct'), + }, + })); }; } diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts new file mode 100644 index 0000000000..c7b192accc --- /dev/null +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -0,0 +1,259 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { + apiClearNotifications, + apiFetchNotificationGroups, +} from 'mastodon/api/notifications'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import type { + ApiNotificationGroupJSON, + ApiNotificationJSON, + NotificationType, +} from 'mastodon/api_types/notifications'; +import { allNotificationTypes } from 'mastodon/api_types/notifications'; +import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; +import { enableEmojiReaction, usePendingItems } from 'mastodon/initial_state'; +import type { NotificationGap } from 'mastodon/reducers/notification_groups'; +import { + selectSettingsNotificationsExcludedTypes, + selectSettingsNotificationsGroupFollows, + 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); + + const types = + activeFilter === 'all' + ? selectSettingsNotificationsExcludedTypes(state) + : excludeAllTypesExcept(activeFilter); + if (!enableEmojiReaction && !types.includes('emoji_reaction')) { + types.push('emoji_reaction'); + } + + return types; +} + +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)); +} + +function selectNotificationGroupedTypes(state: RootState) { + const types: NotificationType[] = ['favourite', 'reblog', 'emoji_reaction']; + + if (selectSettingsNotificationsGroupFollows(state)) types.push('follow'); + + return types; +} + +export const fetchNotifications = createDataLoadingThunk( + 'notificationGroups/fetch', + async (_params, { getState }) => + apiFetchNotificationGroups({ + grouped_types: selectNotificationGroupedTypes(getState()), + 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: selectNotificationGroupedTypes(getState()), + 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: selectNotificationGroupedTypes(getState()), + 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 }; + }, + { + useLoadingBar: false, + }, +); + +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] !== false + : 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, + groupedTypes: selectNotificationGroupedTypes(state), + }; + }, +); + +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 new file mode 100644 index 0000000000..fd798eaad7 --- /dev/null +++ b/app/javascript/mastodon/actions/notification_policies.ts @@ -0,0 +1,22 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { + apiGetNotificationPolicy, + apiUpdateNotificationsPolicy, +} from 'mastodon/api/notification_policies'; +import type { NotificationPolicy } from 'mastodon/models/notification_policy'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const fetchNotificationPolicy = createDataLoadingThunk( + 'notificationPolicy/fetch', + () => apiGetNotificationPolicy(), +); + +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 0000000000..8352ff2aad --- /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 14a58e3613..87b842e51f 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -1,80 +1,52 @@ import { IntlMessageFormat } from 'intl-messageformat'; import { defineMessages } from 'react-intl'; -import { List as ImmutableList } from 'immutable'; - -import { compareId } from 'mastodon/compare_id'; -import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; - -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, - importFetchedStatus, - importFetchedStatuses, } from './importer'; import { submitMarkers } from './markers'; import { notificationsUpdate } from "./notifications_typed"; import { register as registerPushNotifications } from './push_notifications'; -import { saveSettings } from './settings'; import { STATUS_EMOJI_REACTION_UPDATE } from './statuses'; export * from "./notifications_typed"; -export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; - -export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; -export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; -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'; - -export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; -export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; - -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'; -defineMessages({ - mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, +const messages = defineMessages({ + // mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, + 'message_admin.report': { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' }, + 'message_admin.sign_up': { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' }, + message_emoji_reaction: { id: 'notification.emoji_reaction', defaultMessage: '{name} reacted your post with emoji' }, + message_favourite: { id: 'notification.favourite', defaultMessage: '{name} favorited your post' }, + message_follow: { id: 'notification.follow', defaultMessage: '{name} followed you' }, + message_list_status: { id: 'notification.list_status', defaultMessage: '{name} post is added to {listName}' }, + message_mention: { id: 'notification.mention', defaultMessage: 'Mention' }, + message_poll: { id: 'notification.poll', defaultMessage: 'A poll you voted in has ended' }, + message_reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' }, + message_status: { id: 'notification.status', defaultMessage: '{name} just posted' }, + message_status_reference: { id: 'notification.status_reference', defaultMessage: '{name} quoted your post' }, + message_update: { id: 'notification.update', defaultMessage: '{name} edited a post' }, }); -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, -}); - -export function updateEmojiReactions(emoji_reaction, accountId) { +export function updateEmojiReactions(emoji_reaction) { return (dispatch) => dispatch({ type: STATUS_EMOJI_REACTION_UPDATE, emoji_reaction, - accountId, }); } export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { - const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); - const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type; const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); @@ -83,7 +55,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { if (['mention', 'status'].includes(notification.type) && notification.status.filtered) { const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications')); - if (filters.some(result => result.filter.filter_action_ex === 'hide')) { + if (filters.some(result => result.filter.filter_action === 'hide')) { return; } @@ -96,31 +68,17 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch(submitMarkers()); - if (showInColumn) { - dispatch(importFetchedAccount(notification.account)); - - if (notification.status) { - dispatch(importFetchedStatus(notification.status)); - } - - if (notification.report) { - dispatch(importFetchedAccount(notification.report.target_account)); - } - - - dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered})); - - fetchRelatedRelationships(dispatch, [notification]); - } else if (playSound && !filtered) { - dispatch({ - type: NOTIFICATIONS_UPDATE_NOOP, - meta: { sound: 'boop' }, - }); - } + // `notificationsUpdate` is still used in `user_lists` and `relationships` reducers + dispatch(importFetchedAccount(notification.account)); + dispatch(notificationsUpdate({ notification, playSound: playSound && !filtered})); // Desktop notifications if (typeof window.Notification !== 'undefined' && showAlert && !filtered) { - const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); + const messageTemplate = intlMessages[`notification.${notification.type}`] || messages[`message_${notification.type}`] || '[NO MESSAGE DEFINITION]'; + const title = new IntlMessageFormat(messageTemplate, intlLocale).format({ + name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username, + listName: notification.list && notification.list.title, + }); const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); @@ -133,157 +91,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) { }; } -const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); - -const excludeTypesFromFilter = filter => { - const allTypes = ImmutableList([ - 'follow', - 'follow_request', - 'favourite', - 'emoji_reaction', - 'reblog', - 'status_reference', - 'mention', - 'poll', - 'status', - 'list_status', - 'update', - 'admin.sign_up', - 'admin.report', - ]); - - return allTypes.filterNot(item => item === filter).toJS(); -}; - const noOp = () => {}; -let expandNotificationsController = new AbortController(); - -export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { - return (dispatch, getState) => { - const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); - const notifications = getState().get('notifications'); - const isLoadingMore = !!maxId; - - if (notifications.get('isLoading')) { - if (forceLoad) { - expandNotificationsController.abort(); - expandNotificationsController = new AbortController(); - } else { - done(); - return; - } - } - - const params = { - max_id: maxId, - exclude_types: activeFilter === 'all' - ? excludeTypesFromSettings(getState()) - : excludeTypesFromFilter(activeFilter), - }; - - if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) { - const a = notifications.getIn(['pendingItems', 0, 'id']); - const b = notifications.getIn(['items', 0, 'id']); - - if (a && b && compareId(a, b) > 0) { - params.since_id = a; - } else { - params.since_id = b || a; - } - } - - const isLoadingRecent = !!params.since_id; - - dispatch(expandNotificationsRequest(isLoadingMore)); - - api(getState).get('/api/v1/notifications', { params, signal: expandNotificationsController.signal }).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(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); - fetchRelatedRelationships(dispatch, response.data); - dispatch(submitMarkers()); - }).catch(error => { - dispatch(expandNotificationsFail(error, isLoadingMore)); - }).finally(() => { - done(); - }); - }; -} - -export function expandNotificationsRequest(isLoadingMore) { - return { - type: NOTIFICATIONS_EXPAND_REQUEST, - skipLoading: !isLoadingMore, - }; -} - -export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) { - return { - type: NOTIFICATIONS_EXPAND_SUCCESS, - notifications, - next, - isLoadingRecent: isLoadingRecent, - usePendingItems, - skipLoading: !isLoadingMore, - }; -} - -export function expandNotificationsFail(error, isLoadingMore) { - return { - type: NOTIFICATIONS_EXPAND_FAIL, - error, - skipLoading: !isLoadingMore, - skipAlert: !isLoadingMore || error.name === 'AbortError', - }; -} - -export function clearNotifications() { - return (dispatch, getState) => { - dispatch({ - type: NOTIFICATIONS_CLEAR, - }); - - api(getState).post('/api/v1/notifications/clear'); - }; -} - -export function scrollTopNotifications(top) { - return { - type: NOTIFICATIONS_SCROLL_TOP, - top, - }; -} - -export function setFilter (filterType) { - return dispatch => { - dispatch({ - type: NOTIFICATIONS_FILTER_SET, - path: ['notifications', 'quickFilter', 'active'], - value: filterType, - }); - dispatch(expandNotifications({ forceLoad: true })); - dispatch(saveSettings()); - }; -} - -export const mountNotifications = () => ({ - type: NOTIFICATIONS_MOUNT, -}); - -export const unmountNotifications = () => ({ - type: NOTIFICATIONS_UNMOUNT, -}); - - -export const markNotificationsAsRead = () => ({ - type: NOTIFICATIONS_MARK_AS_READ, -}); - // Browser support export function setupBrowserNotifications() { return dispatch => { diff --git a/app/javascript/mastodon/actions/notifications_typed.ts b/app/javascript/mastodon/actions/notifications_typed.ts index 176362f4b1..3eb1230666 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,8 +8,7 @@ export const notificationsUpdate = createAction( playSound, ...args }: { - notification: FakeApiNotificationJSON; - usePendingItems: boolean; + notification: ApiNotificationJSON; playSound: boolean; }) => ({ payload: args, diff --git a/app/javascript/mastodon/actions/picture_in_picture.js b/app/javascript/mastodon/actions/picture_in_picture.js deleted file mode 100644 index 898375abeb..0000000000 --- a/app/javascript/mastodon/actions/picture_in_picture.js +++ /dev/null @@ -1,46 +0,0 @@ -// @ts-check - -export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; -export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; - -/** - * @typedef MediaProps - * @property {string} src - * @property {boolean} muted - * @property {number} volume - * @property {number} currentTime - * @property {string} poster - * @property {string} backgroundColor - * @property {string} foregroundColor - * @property {string} accentColor - */ - -/** - * @param {string} statusId - * @param {string} accountId - * @param {string} playerType - * @param {MediaProps} props - * @returns {object} - */ -export const deployPictureInPicture = (statusId, accountId, playerType, props) => { - // @ts-expect-error - return (dispatch, getState) => { - // Do not open a player for a toot that does not exist - if (getState().hasIn(['statuses', statusId])) { - dispatch({ - type: PICTURE_IN_PICTURE_DEPLOY, - statusId, - accountId, - playerType, - props, - }); - } - }; -}; - -/* - * @return {object} - */ -export const removePictureInPicture = () => ({ - type: PICTURE_IN_PICTURE_REMOVE, -}); diff --git a/app/javascript/mastodon/actions/picture_in_picture.ts b/app/javascript/mastodon/actions/picture_in_picture.ts new file mode 100644 index 0000000000..d34b508a33 --- /dev/null +++ b/app/javascript/mastodon/actions/picture_in_picture.ts @@ -0,0 +1,31 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { PIPMediaProps } from 'mastodon/reducers/picture_in_picture'; +import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; + +interface DeployParams { + statusId: string; + accountId: string; + playerType: 'audio' | 'video'; + props: PIPMediaProps; +} + +export const removePictureInPicture = createAction('pip/remove'); + +export const deployPictureInPictureAction = + createAction('pip/deploy'); + +export const deployPictureInPicture = createAppAsyncThunk( + 'pip/deploy', + (args: DeployParams, { dispatch, getState }) => { + const { statusId } = args; + + // Do not open a player for a toot that does not exist + + // @ts-expect-error state.statuses is not yet typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + if (getState().hasIn(['statuses', statusId])) { + dispatch(deployPictureInPictureAction(args)); + } + }, +); diff --git a/app/javascript/mastodon/actions/pin_statuses.js b/app/javascript/mastodon/actions/pin_statuses.js index baa10d1562..d583eab573 100644 --- a/app/javascript/mastodon/actions/pin_statuses.js +++ b/app/javascript/mastodon/actions/pin_statuses.js @@ -8,10 +8,10 @@ export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; export function fetchPinnedStatuses() { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchPinnedStatusesRequest()); - api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { + api().get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { dispatch(importFetchedStatuses(response.data)); dispatch(fetchPinnedStatusesSuccess(response.data, null)); }).catch(error => { diff --git a/app/javascript/mastodon/actions/polls.js b/app/javascript/mastodon/actions/polls.js deleted file mode 100644 index a37410dc90..0000000000 --- a/app/javascript/mastodon/actions/polls.js +++ /dev/null @@ -1,61 +0,0 @@ -import api from '../api'; - -import { importFetchedPoll } from './importer'; - -export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; -export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; -export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; - -export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; -export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; -export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; - -export const vote = (pollId, choices) => (dispatch, getState) => { - dispatch(voteRequest()); - - api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) - .then(({ data }) => { - dispatch(importFetchedPoll(data)); - dispatch(voteSuccess(data)); - }) - .catch(err => dispatch(voteFail(err))); -}; - -export const fetchPoll = pollId => (dispatch, getState) => { - dispatch(fetchPollRequest()); - - api(getState).get(`/api/v1/polls/${pollId}`) - .then(({ data }) => { - dispatch(importFetchedPoll(data)); - dispatch(fetchPollSuccess(data)); - }) - .catch(err => dispatch(fetchPollFail(err))); -}; - -export const voteRequest = () => ({ - type: POLL_VOTE_REQUEST, -}); - -export const voteSuccess = poll => ({ - type: POLL_VOTE_SUCCESS, - poll, -}); - -export const voteFail = error => ({ - type: POLL_VOTE_FAIL, - error, -}); - -export const fetchPollRequest = () => ({ - type: POLL_FETCH_REQUEST, -}); - -export const fetchPollSuccess = poll => ({ - type: POLL_FETCH_SUCCESS, - poll, -}); - -export const fetchPollFail = error => ({ - type: POLL_FETCH_FAIL, - error, -}); diff --git a/app/javascript/mastodon/actions/polls.ts b/app/javascript/mastodon/actions/polls.ts new file mode 100644 index 0000000000..65a96e8f62 --- /dev/null +++ b/app/javascript/mastodon/actions/polls.ts @@ -0,0 +1,40 @@ +import { apiGetPoll, apiPollVote } from 'mastodon/api/polls'; +import type { ApiPollJSON } from 'mastodon/api_types/polls'; +import { createPollFromServerJSON } from 'mastodon/models/poll'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from 'mastodon/store/typed_functions'; + +import { importPolls } from './importer/polls'; + +export const importFetchedPoll = createAppAsyncThunk( + 'poll/importFetched', + (args: { poll: ApiPollJSON }, { dispatch, getState }) => { + const { poll } = args; + + dispatch( + importPolls({ + polls: [createPollFromServerJSON(poll, getState().polls[poll.id])], + }), + ); + }, +); + +export const vote = createDataLoadingThunk( + 'poll/vote', + ({ pollId, choices }: { pollId: string; choices: string[] }) => + apiPollVote(pollId, choices), + async (poll, { dispatch, discardLoadData }) => { + await dispatch(importFetchedPoll({ poll })); + return discardLoadData; + }, +); + +export const fetchPoll = createDataLoadingThunk( + 'poll/fetch', + ({ pollId }: { pollId: string }) => apiGetPoll(pollId), + async (poll, { dispatch }) => { + await dispatch(importFetchedPoll({ poll })); + }, +); diff --git a/app/javascript/mastodon/actions/push_notifications/registerer.js b/app/javascript/mastodon/actions/push_notifications/registerer.js index b3d3850e31..647a6bd9fb 100644 --- a/app/javascript/mastodon/actions/push_notifications/registerer.js +++ b/app/javascript/mastodon/actions/push_notifications/registerer.js @@ -33,7 +33,7 @@ const unsubscribe = ({ registration, subscription }) => subscription ? subscription.unsubscribe().then(() => registration) : registration; const sendSubscriptionToBackend = (subscription) => { - const params = { subscription }; + const params = { subscription: { ...subscription.toJSON(), standard: true } }; if (me) { const data = pushNotificationsSetting.get(me); diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js index 756b8cd05e..49b89b0d13 100644 --- a/app/javascript/mastodon/actions/reports.js +++ b/app/javascript/mastodon/actions/reports.js @@ -15,10 +15,10 @@ export const initReport = (account, status) => dispatch => }, })); -export const submitReport = (params, onSuccess, onFail) => (dispatch, getState) => { +export const submitReport = (params, onSuccess, onFail) => (dispatch) => { dispatch(submitReportRequest()); - api(getState).post('/api/v1/reports', params).then(response => { + api().post('/api/v1/reports', params).then(response => { dispatch(submitReportSuccess(response.data)); if (onSuccess) onSuccess(); }).catch(error => { diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js deleted file mode 100644 index 38a089b486..0000000000 --- a/app/javascript/mastodon/actions/search.js +++ /dev/null @@ -1,210 +0,0 @@ -import { fromJS } from 'immutable'; - -import { searchHistory } from 'mastodon/settings'; - -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts, importFetchedStatuses } from './importer'; - -export const SEARCH_CHANGE = 'SEARCH_CHANGE'; -export const SEARCH_CLEAR = 'SEARCH_CLEAR'; -export const SEARCH_SHOW = 'SEARCH_SHOW'; - -export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; -export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; -export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; - -export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; -export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; -export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; - -export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE'; - -export function changeSearch(value) { - return { - type: SEARCH_CHANGE, - value, - }; -} - -export function clearSearch() { - return { - type: SEARCH_CLEAR, - }; -} - -export function submitSearch(type) { - return (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const signedIn = !!getState().getIn(['meta', 'me']); - - if (value.length === 0) { - dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type)); - return; - } - - dispatch(fetchSearchRequest(type)); - - api(getState).get('/api/v2/search', { - params: { - q: value, - resolve: signedIn, - limit: 11, - type, - }, - }).then(response => { - if (response.data.accounts) { - dispatch(importFetchedAccounts(response.data.accounts)); - } - - if (response.data.statuses) { - dispatch(importFetchedStatuses(response.data.statuses)); - } - - dispatch(fetchSearchSuccess(response.data, value, type)); - dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(fetchSearchFail(error)); - }); - }; -} - -export function fetchSearchRequest(searchType) { - return { - type: SEARCH_FETCH_REQUEST, - searchType, - }; -} - -export function fetchSearchSuccess(results, searchTerm, searchType) { - return { - type: SEARCH_FETCH_SUCCESS, - results, - searchType, - searchTerm, - }; -} - -export function fetchSearchFail(error) { - return { - type: SEARCH_FETCH_FAIL, - error, - }; -} - -export const expandSearch = type => (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const offset = getState().getIn(['search', 'results', type]).size - 1; - - dispatch(expandSearchRequest(type)); - - api(getState).get('/api/v2/search', { - params: { - q: value, - type, - offset, - limit: 11, - }, - }).then(({ data }) => { - if (data.accounts) { - dispatch(importFetchedAccounts(data.accounts)); - } - - if (data.statuses) { - dispatch(importFetchedStatuses(data.statuses)); - } - - dispatch(expandSearchSuccess(data, value, type)); - dispatch(fetchRelationships(data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(expandSearchFail(error)); - }); -}; - -export const expandSearchRequest = (searchType) => ({ - type: SEARCH_EXPAND_REQUEST, - searchType, -}); - -export const expandSearchSuccess = (results, searchTerm, searchType) => ({ - type: SEARCH_EXPAND_SUCCESS, - results, - searchTerm, - searchType, -}); - -export const expandSearchFail = error => ({ - type: SEARCH_EXPAND_FAIL, - error, -}); - -export const showSearch = () => ({ - type: SEARCH_SHOW, -}); - -export const openURL = (value, history, onFailure) => (dispatch, getState) => { - const signedIn = !!getState().getIn(['meta', 'me']); - - if (!signedIn) { - if (onFailure) { - onFailure(); - } - - return; - } - - dispatch(fetchSearchRequest()); - - api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => { - if (response.data.accounts?.length > 0) { - dispatch(importFetchedAccounts(response.data.accounts)); - history.push(`/@${response.data.accounts[0].acct}`); - } else if (response.data.statuses?.length > 0) { - dispatch(importFetchedStatuses(response.data.statuses)); - history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); - } else if (onFailure) { - onFailure(); - } - - dispatch(fetchSearchSuccess(response.data, value)); - }).catch(err => { - dispatch(fetchSearchFail(err)); - - if (onFailure) { - onFailure(); - } - }); -}; - -export const clickSearchResult = (q, type) => (dispatch, getState) => { - const previous = getState().getIn(['search', 'recent']); - const me = getState().getIn(['meta', 'me']); - const current = previous.add(fromJS({ type, q })).takeLast(4); - - searchHistory.set(me, current.toJS()); - dispatch(updateSearchHistory(current)); -}; - -export const forgetSearchResult = q => (dispatch, getState) => { - const previous = getState().getIn(['search', 'recent']); - const me = getState().getIn(['meta', 'me']); - const current = previous.filterNot(result => result.get('q') === q); - - searchHistory.set(me, current.toJS()); - dispatch(updateSearchHistory(current)); -}; - -export const updateSearchHistory = recent => ({ - type: SEARCH_HISTORY_UPDATE, - recent, -}); - -export const hydrateSearch = () => (dispatch, getState) => { - const me = getState().getIn(['meta', 'me']); - const history = searchHistory.get(me); - - if (history !== null) { - dispatch(updateSearchHistory(history)); - } -}; \ No newline at end of file diff --git a/app/javascript/mastodon/actions/search.ts b/app/javascript/mastodon/actions/search.ts new file mode 100644 index 0000000000..13a4ee4432 --- /dev/null +++ b/app/javascript/mastodon/actions/search.ts @@ -0,0 +1,148 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { apiGetSearch } from 'mastodon/api/search'; +import type { ApiSearchType } from 'mastodon/api_types/search'; +import type { + RecentSearch, + SearchType as RecentSearchType, +} from 'mastodon/models/search'; +import { searchHistory } from 'mastodon/settings'; +import { + createDataLoadingThunk, + createAppAsyncThunk, +} from 'mastodon/store/typed_functions'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; + +export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE'; + +export const submitSearch = createDataLoadingThunk( + 'search/submit', + async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => { + const signedIn = !!getState().meta.get('me'); + + return apiGetSearch({ + q, + type, + resolve: signedIn, + limit: 11, + }); + }, + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + dispatch(fetchRelationships(data.accounts.map((account) => account.id))); + } + + if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: false, + }, +); + +export const expandSearch = createDataLoadingThunk( + 'search/expand', + async ({ type }: { type: ApiSearchType }, { getState }) => { + const q = getState().search.q; + const results = getState().search.results; + const offset = results?.[type].length; + + return apiGetSearch({ + q, + type, + limit: 10, + offset, + }); + }, + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + dispatch(fetchRelationships(data.accounts.map((account) => account.id))); + } + + if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: true, + }, +); + +export const openURL = createDataLoadingThunk( + 'search/openURL', + ({ url }: { url: string }) => + apiGetSearch({ + q: url, + resolve: true, + limit: 1, + }), + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + } else if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: true, + }, +); + +export const clickSearchResult = createAppAsyncThunk( + 'search/clickResult', + ( + { q, type }: { q: string; type?: RecentSearchType }, + { dispatch, getState }, + ) => { + const previous = getState().search.recent; + + if (previous.some((x) => x.q === q && x.type === type)) { + return; + } + + const me = getState().meta.get('me') as string; + const current = [{ type, q }, ...previous].slice(0, 4); + + searchHistory.set(me, current); + dispatch(updateSearchHistory(current)); + }, +); + +export const forgetSearchResult = createAppAsyncThunk( + 'search/forgetResult', + (q: string, { dispatch, getState }) => { + const previous = getState().search.recent; + const me = getState().meta.get('me') as string; + const current = previous.filter((result) => result.q !== q); + + searchHistory.set(me, current); + dispatch(updateSearchHistory(current)); + }, +); + +export const updateSearchHistory = createAction( + 'search/updateHistory', +); + +export const hydrateSearch = createAppAsyncThunk( + 'search/hydrate', + (_args, { dispatch, getState }) => { + const me = getState().meta.get('me') as string; + const history = searchHistory.get(me) as RecentSearch[] | null; + + if (history !== null) { + dispatch(updateSearchHistory(history)); + } + }, +); diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js index 65f3efc3a7..32ee093afa 100644 --- a/app/javascript/mastodon/actions/server.js +++ b/app/javascript/mastodon/actions/server.js @@ -25,7 +25,7 @@ export const fetchServer = () => (dispatch, getState) => { dispatch(fetchServerRequest()); - api(getState) + api() .get('/api/v2/instance').then(({ data }) => { if (data.contact.account) dispatch(importFetchedAccount(data.contact.account)); dispatch(fetchServerSuccess(data)); @@ -46,10 +46,10 @@ const fetchServerFail = error => ({ error, }); -export const fetchServerTranslationLanguages = () => (dispatch, getState) => { +export const fetchServerTranslationLanguages = () => (dispatch) => { dispatch(fetchServerTranslationLanguagesRequest()); - api(getState) + api() .get('/api/v1/instance/translation_languages').then(({ data }) => { dispatch(fetchServerTranslationLanguagesSuccess(data)); }).catch(err => dispatch(fetchServerTranslationLanguagesFail(err))); @@ -76,7 +76,7 @@ export const fetchExtendedDescription = () => (dispatch, getState) => { dispatch(fetchExtendedDescriptionRequest()); - api(getState) + api() .get('/api/v1/instance/extended_description') .then(({ data }) => dispatch(fetchExtendedDescriptionSuccess(data))) .catch(err => dispatch(fetchExtendedDescriptionFail(err))); @@ -103,7 +103,7 @@ export const fetchDomainBlocks = () => (dispatch, getState) => { dispatch(fetchDomainBlocksRequest()); - api(getState) + api() .get('/api/v1/instance/domain_blocks') .then(({ data }) => dispatch(fetchDomainBlocksSuccess(true, data))) .catch(err => { diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js index 3685b0684e..7659fb5f98 100644 --- a/app/javascript/mastodon/actions/settings.js +++ b/app/javascript/mastodon/actions/settings.js @@ -20,7 +20,7 @@ export function changeSetting(path, value) { } const debouncedSave = debounce((dispatch, getState) => { - if (getState().getIn(['settings', 'saved'])) { + if (getState().getIn(['settings', 'saved']) || !getState().getIn(['meta', 'me'])) { return; } @@ -29,7 +29,7 @@ const debouncedSave = debounce((dispatch, getState) => { api().put('/api/web/settings', { data }) .then(() => dispatch({ type: SETTING_SAVE })) .catch(error => dispatch(showAlertForError(error))); -}, 5000, { trailing: true }); +}, 2000, { leading: true, trailing: true }); export function saveSettings() { return (dispatch, getState) => debouncedSave(dispatch, getState); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 9807e6f67c..5064e65e7b 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -1,5 +1,9 @@ +import { browserHistory } from 'mastodon/components/router'; +import { me } from 'mastodon/initial_state'; + import api from '../api'; +import { fetchRelationships } from './accounts'; import { ensureComposeIsVisible, setComposeToStatus } from './compose'; import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; import { deleteFromTimelines } from './timelines'; @@ -49,11 +53,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; @@ -61,8 +67,14 @@ export function fetchStatus(id, forceFetch = false) { dispatch(fetchStatusRequest(id, skipLoading)); - api(getState).get(`/api/v1/statuses/${id}`).then(response => { + api().get(`/api/v1/statuses/${id}`).then(response => { dispatch(importFetchedStatus(response.data)); + + const accountId = response.data.account.id; + if (me && !getState().getIn(['relationships', accountId])) { + dispatch(fetchRelationships([accountId])); + } + dispatch(fetchStatusSuccess(skipLoading)); }).catch(error => { dispatch(fetchStatusFail(id, error, skipLoading)); @@ -95,7 +107,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,9 +116,9 @@ export const editStatus = (id, routerHistory) => (dispatch, getState) => { dispatch(fetchStatusSourceRequest()); - api(getState).get(`/api/v1/statuses/${id}/source`).then(response => { + 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)); @@ -126,7 +138,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]); @@ -136,14 +148,14 @@ export function deleteStatus(id, routerHistory, withRedraft = false) { dispatch(deleteStatusRequest(id)); - api(getState).delete(`/api/v1/statuses/${id}`).then(response => { + api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => { dispatch(deleteStatusSuccess(id)); dispatch(deleteFromTimelines(id)); dispatch(importFetchedAccount(response.data.account)); if (withRedraft) { dispatch(redraft(status, response.data.text)); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); } }).catch(error => { dispatch(deleteStatusFail(id, error)); @@ -177,10 +189,10 @@ export const updateStatus = status => dispatch => dispatch(importFetchedStatus(status)); export function fetchContext(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(fetchContextRequest(id)); - api(getState).get(`/api/v1/statuses/${id}/context?with_reference=1`).then(response => { + api().get(`/api/v1/statuses/${id}/context?with_reference=1`).then(response => { dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants).concat(response.data.references))); dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants, response.data.references)); @@ -222,10 +234,10 @@ export function fetchContextFail(id, error) { } export function muteStatus(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(muteStatusRequest(id)); - api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { + api().post(`/api/v1/statuses/${id}/mute`).then(() => { dispatch(muteStatusSuccess(id)); }).catch(error => { dispatch(muteStatusFail(id, error)); @@ -256,10 +268,10 @@ export function muteStatusFail(id, error) { } export function unmuteStatus(id) { - return (dispatch, getState) => { + return (dispatch) => { dispatch(unmuteStatusRequest(id)); - api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => { + api().post(`/api/v1/statuses/${id}/unmute`).then(() => { dispatch(unmuteStatusSuccess(id)); }).catch(error => { dispatch(unmuteStatusFail(id, error)); @@ -311,6 +323,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, @@ -319,10 +346,10 @@ export function toggleStatusCollapse(id, isCollapsed) { }; } -export const translateStatus = id => (dispatch, getState) => { +export const translateStatus = id => (dispatch) => { dispatch(translateStatusRequest(id)); - api(getState).post(`/api/v1/statuses/${id}/translate`).then(response => { + api().post(`/api/v1/statuses/${id}/translate`).then(response => { dispatch(translateStatusSuccess(id, response.data)); }).catch(error => { dispatch(translateStatusFail(id, error)); @@ -356,3 +383,15 @@ export const updateEmojiReaction = (emoji_reaction) => ({ type: STATUS_EMOJI_REACTION_UPDATE, emoji_reaction, }); + +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/store.js b/app/javascript/mastodon/actions/store.js index 8ab75cdc44..e8fec13453 100644 --- a/app/javascript/mastodon/actions/store.js +++ b/app/javascript/mastodon/actions/store.js @@ -1,4 +1,4 @@ -import { Iterable, fromJS } from 'immutable'; +import { fromJS, isIndexed } from 'immutable'; import { hydrateCompose } from './compose'; import { importFetchedAccounts } from './importer'; @@ -9,8 +9,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; const convertState = rawState => fromJS(rawState, (k, v) => - Iterable.isIndexed(v) ? v.toList() : v.toMap()); - + isIndexed(v) ? v.toList() : v.toMap()); export function hydrateStore(rawState) { return dispatch => { diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index d430ae7acf..f9d784c2b4 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -10,7 +10,8 @@ import { deleteAnnouncement, } from './announcements'; import { updateConversations } from './conversations'; -import { updateNotifications, expandNotifications, updateEmojiReactions } from './notifications'; +import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups'; +import { updateNotifications, updateEmojiReactions } from './notifications'; import { updateStatus } from './statuses'; import { updateTimeline, @@ -37,7 +38,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} @@ -52,14 +53,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 { @@ -78,7 +78,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti }, onDisconnect() { - dispatch(disconnectTimeline(timelineId)); + dispatch(disconnectTimeline({ timeline: timelineId })); if (options.fallback) { // @ts-expect-error @@ -99,14 +99,22 @@ 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 'emoji_reaction': // @ts-expect-error - dispatch(updateEmojiReactions(JSON.parse(data.payload), getState().getIn(['meta', 'me']))); + dispatch(updateEmojiReactions(JSON.parse(data.payload))); break; + case 'notifications_merged': { + dispatch(refreshStaleNotificationGroups()); + break; + } case 'conversation': // @ts-expect-error dispatch(updateConversations(JSON.parse(data.payload))); @@ -130,21 +138,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/suggestions.js b/app/javascript/mastodon/actions/suggestions.js deleted file mode 100644 index 870a311024..0000000000 --- a/app/javascript/mastodon/actions/suggestions.js +++ /dev/null @@ -1,65 +0,0 @@ -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; - -export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; -export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; -export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; - -export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; - -export function fetchSuggestions(withRelationships = false) { - return (dispatch, getState) => { - dispatch(fetchSuggestionsRequest()); - - api(getState).get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => { - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(fetchSuggestionsSuccess(response.data)); - - if (withRelationships) { - dispatch(fetchRelationships(response.data.map(item => item.account.id))); - } - }).catch(error => dispatch(fetchSuggestionsFail(error))); - }; -} - -export function fetchSuggestionsRequest() { - return { - type: SUGGESTIONS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchSuggestionsSuccess(suggestions) { - return { - type: SUGGESTIONS_FETCH_SUCCESS, - suggestions, - skipLoading: true, - }; -} - -export function fetchSuggestionsFail(error) { - return { - type: SUGGESTIONS_FETCH_FAIL, - error, - skipLoading: true, - skipAlert: true, - }; -} - -export const dismissSuggestion = accountId => (dispatch, getState) => { - dispatch({ - type: SUGGESTIONS_DISMISS, - id: accountId, - }); - - api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => { - dispatch(fetchSuggestionsRequest()); - - api(getState).get('/api/v2/suggestions').then(response => { - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(fetchSuggestionsSuccess(response.data)); - }).catch(error => dispatch(fetchSuggestionsFail(error))); - }).catch(() => {}); -}; diff --git a/app/javascript/mastodon/actions/suggestions.ts b/app/javascript/mastodon/actions/suggestions.ts new file mode 100644 index 0000000000..0eadfa6b47 --- /dev/null +++ b/app/javascript/mastodon/actions/suggestions.ts @@ -0,0 +1,24 @@ +import { + apiGetSuggestions, + apiDeleteSuggestion, +} from 'mastodon/api/suggestions'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +export const fetchSuggestions = createDataLoadingThunk( + 'suggestions/fetch', + () => apiGetSuggestions(20), + (data, { dispatch }) => { + dispatch(importFetchedAccounts(data.map((x) => x.account))); + dispatch(fetchRelationships(data.map((x) => x.account.id))); + + return data; + }, +); + +export const dismissSuggestion = createDataLoadingThunk( + 'suggestions/dismiss', + ({ accountId }: { accountId: string }) => apiDeleteSuggestion(accountId), +); diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js deleted file mode 100644 index dda8c924bb..0000000000 --- a/app/javascript/mastodon/actions/tags.js +++ /dev/null @@ -1,172 +0,0 @@ -import api, { getLinks } from '../api'; - -export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; -export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; -export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; - -export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST'; -export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS'; -export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL'; - -export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST'; -export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS'; -export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL'; - -export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; -export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; -export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; - -export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; -export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; -export const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; - -export const fetchHashtag = name => (dispatch, getState) => { - dispatch(fetchHashtagRequest()); - - api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => { - dispatch(fetchHashtagSuccess(name, data)); - }).catch(err => { - dispatch(fetchHashtagFail(err)); - }); -}; - -export const fetchHashtagRequest = () => ({ - type: HASHTAG_FETCH_REQUEST, -}); - -export const fetchHashtagSuccess = (name, tag) => ({ - type: HASHTAG_FETCH_SUCCESS, - name, - tag, -}); - -export const fetchHashtagFail = error => ({ - type: HASHTAG_FETCH_FAIL, - error, -}); - -export const fetchFollowedHashtags = () => (dispatch, getState) => { - dispatch(fetchFollowedHashtagsRequest()); - - api(getState).get('/api/v1/followed_tags').then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null)); - }).catch(err => { - dispatch(fetchFollowedHashtagsFail(err)); - }); -}; - -export function fetchFollowedHashtagsRequest() { - return { - type: FOLLOWED_HASHTAGS_FETCH_REQUEST, - }; -} - -export function fetchFollowedHashtagsSuccess(followed_tags, next) { - return { - type: FOLLOWED_HASHTAGS_FETCH_SUCCESS, - followed_tags, - next, - }; -} - -export function fetchFollowedHashtagsFail(error) { - return { - type: FOLLOWED_HASHTAGS_FETCH_FAIL, - error, - }; -} - -export function expandFollowedHashtags() { - return (dispatch, getState) => { - const url = getState().getIn(['followed_tags', 'next']); - - if (url === null) { - return; - } - - dispatch(expandFollowedHashtagsRequest()); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(expandFollowedHashtagsFail(error)); - }); - }; -} - -export function expandFollowedHashtagsRequest() { - return { - type: FOLLOWED_HASHTAGS_EXPAND_REQUEST, - }; -} - -export function expandFollowedHashtagsSuccess(followed_tags, next) { - return { - type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS, - followed_tags, - next, - }; -} - -export function expandFollowedHashtagsFail(error) { - return { - type: FOLLOWED_HASHTAGS_EXPAND_FAIL, - error, - }; -} - -export const followHashtag = name => (dispatch, getState) => { - dispatch(followHashtagRequest(name)); - - api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => { - dispatch(followHashtagSuccess(name, data)); - }).catch(err => { - dispatch(followHashtagFail(name, err)); - }); -}; - -export const followHashtagRequest = name => ({ - type: HASHTAG_FOLLOW_REQUEST, - name, -}); - -export const followHashtagSuccess = (name, tag) => ({ - type: HASHTAG_FOLLOW_SUCCESS, - name, - tag, -}); - -export const followHashtagFail = (name, error) => ({ - type: HASHTAG_FOLLOW_FAIL, - name, - error, -}); - -export const unfollowHashtag = name => (dispatch, getState) => { - dispatch(unfollowHashtagRequest(name)); - - api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { - dispatch(unfollowHashtagSuccess(name, data)); - }).catch(err => { - dispatch(unfollowHashtagFail(name, err)); - }); -}; - -export const unfollowHashtagRequest = name => ({ - type: HASHTAG_UNFOLLOW_REQUEST, - name, -}); - -export const unfollowHashtagSuccess = (name, tag) => ({ - type: HASHTAG_UNFOLLOW_SUCCESS, - name, - tag, -}); - -export const unfollowHashtagFail = (name, error) => ({ - type: HASHTAG_UNFOLLOW_FAIL, - name, - error, -}); diff --git a/app/javascript/mastodon/actions/tags_typed.ts b/app/javascript/mastodon/actions/tags_typed.ts new file mode 100644 index 0000000000..6dca32fd84 --- /dev/null +++ b/app/javascript/mastodon/actions/tags_typed.ts @@ -0,0 +1,17 @@ +import { apiGetTag, apiFollowTag, apiUnfollowTag } from 'mastodon/api/tags'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const fetchHashtag = createDataLoadingThunk( + 'tags/fetch', + ({ tagId }: { tagId: string }) => apiGetTag(tagId), +); + +export const followHashtag = createDataLoadingThunk( + 'tags/follow', + ({ tagId }: { tagId: string }) => apiFollowTag(tagId), +); + +export const unfollowHashtag = createDataLoadingThunk( + 'tags/unfollow', + ({ tagId }: { tagId: string }) => apiUnfollowTag(tagId), +); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 4e246a39dc..740e893a57 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -6,9 +6,11 @@ import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import { importFetchedStatus, importFetchedStatuses } from './importer'; import { submitMarkers } from './markers'; +import {timelineDelete} from './timelines_typed'; + +export { disconnectTimeline } from './timelines_typed'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; -export const TIMELINE_DELETE = 'TIMELINE_DELETE'; export const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; @@ -17,10 +19,13 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING'; -export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL'; +export const TIMELINE_INSERT = 'TIMELINE_INSERT'; + +export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions'; +export const TIMELINE_GAP = null; export const loadPending = timeline => ({ type: TIMELINE_LOAD_PENDING, @@ -58,16 +63,10 @@ export function updateTimeline(timeline, status, accept) { export function deleteFromTimelines(id) { return (dispatch, getState) => { const accountId = getState().getIn(['statuses', id, 'account']); - const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id')); + const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id')).valueSeq().toJSON(); const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); - dispatch({ - type: TIMELINE_DELETE, - id, - accountId, - references, - reblogOf, - }); + dispatch(timelineDelete({ statusId: id, accountId, references, reblogOf })); }; } @@ -77,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; } @@ -110,61 +106,69 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { dispatch(expandTimelineRequest(timelineId, isLoadingMore)); - api(getState).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)); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); + if (timelineId === 'home' && !isLoadingMore && !isLoadingRecent) { + const now = new Date(); + const fittingIndex = response.data.findIndex(status => now - (new Date(status.created_at)) > 4 * 3600 * 1000); + + if (fittingIndex !== -1) { + dispatch(insertIntoTimeline(timelineId, TIMELINE_SUGGESTIONS, Math.max(1, fittingIndex))); + } + } + 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 expandAntennaTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`antenna:${id}`, `/api/v1/timelines/antenna/${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 expandAntennaTimeline = (id, { maxId } = {}) => expandTimeline(`antenna:${id}`, `/api/v1/timelines/antenna/${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 fillAntennaTimelineGaps = (id, done = noOp) => fillTimelineGaps(`antenna:${id}`, `/api/v1/timelines/antenna/${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 const fillAntennaTimelineGaps = (id) => fillTimelineGaps(`antenna:${id}`, `/api/v1/timelines/antenna/${id}`, {}); export function expandTimelineRequest(timeline, isLoadingMore) { return { @@ -213,13 +217,14 @@ export function connectTimeline(timeline) { }; } -export const disconnectTimeline = timeline => ({ - type: TIMELINE_DISCONNECT, - timeline, - usePendingItems: preferPendingItems, -}); - export const markAsPartial = timeline => ({ type: TIMELINE_MARK_AS_PARTIAL, timeline, }); + +export const insertIntoTimeline = (timeline, key, index) => ({ + type: TIMELINE_INSERT, + timeline, + index, + key, +}); diff --git a/app/javascript/mastodon/actions/timelines_typed.ts b/app/javascript/mastodon/actions/timelines_typed.ts new file mode 100644 index 0000000000..07d82b2f01 --- /dev/null +++ b/app/javascript/mastodon/actions/timelines_typed.ts @@ -0,0 +1,20 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; + +export const disconnectTimeline = createAction( + 'timeline/disconnect', + ({ timeline }: { timeline: string }) => ({ + payload: { + timeline, + usePendingItems: preferPendingItems, + }, + }), +); + +export const timelineDelete = createAction<{ + statusId: string; + accountId: string; + references: string[]; + reblogOf: string | null; +}>('timelines/delete'); diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js index d314423884..0bdf17a5d2 100644 --- a/app/javascript/mastodon/actions/trends.js +++ b/app/javascript/mastodon/actions/trends.js @@ -1,6 +1,6 @@ import api, { getLinks } from '../api'; -import { importFetchedStatuses } from './importer'; +import { importFetchedStatuses, importFetchedAccounts } from './importer'; export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST'; export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS'; @@ -18,10 +18,10 @@ export const TRENDS_STATUSES_EXPAND_REQUEST = 'TRENDS_STATUSES_EXPAND_REQUEST'; export const TRENDS_STATUSES_EXPAND_SUCCESS = 'TRENDS_STATUSES_EXPAND_SUCCESS'; export const TRENDS_STATUSES_EXPAND_FAIL = 'TRENDS_STATUSES_EXPAND_FAIL'; -export const fetchTrendingHashtags = () => (dispatch, getState) => { +export const fetchTrendingHashtags = () => (dispatch) => { dispatch(fetchTrendingHashtagsRequest()); - api(getState) + api() .get('/api/v1/trends/tags') .then(({ data }) => dispatch(fetchTrendingHashtagsSuccess(data))) .catch(err => dispatch(fetchTrendingHashtagsFail(err))); @@ -45,12 +45,15 @@ export const fetchTrendingHashtagsFail = error => ({ skipAlert: true, }); -export const fetchTrendingLinks = () => (dispatch, getState) => { +export const fetchTrendingLinks = () => (dispatch) => { dispatch(fetchTrendingLinksRequest()); - api(getState) - .get('/api/v1/trends/links') - .then(({ data }) => dispatch(fetchTrendingLinksSuccess(data))) + api() + .get('/api/v1/trends/links', { params: { limit: 20 } }) + .then(({ data }) => { + dispatch(importFetchedAccounts(data.flatMap(link => link.authors.map(author => author.account)).filter(account => !!account))); + dispatch(fetchTrendingLinksSuccess(data)); + }) .catch(err => dispatch(fetchTrendingLinksFail(err))); }; @@ -79,7 +82,7 @@ export const fetchTrendingStatuses = () => (dispatch, getState) => { dispatch(fetchTrendingStatusesRequest()); - api(getState).get('/api/v1/trends/statuses').then(response => { + api().get('/api/v1/trends/statuses').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); dispatch(fetchTrendingStatusesSuccess(response.data, next ? next.uri : null)); @@ -115,7 +118,7 @@ export const expandTrendingStatuses = () => (dispatch, getState) => { dispatch(expandTrendingStatusesRequest()); - api(getState).get(url).then(response => { + api().get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); dispatch(expandTrendingStatusesSuccess(response.data, next ? next.uri : null)); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index f262fd8570..a41b058d2c 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -1,9 +1,14 @@ -import type { AxiosResponse, RawAxiosRequestHeaders } from 'axios'; +import type { + AxiosError, + AxiosResponse, + Method, + RawAxiosRequestHeaders, +} from 'axios'; import axios from 'axios'; import LinkHeader from 'http-link-header'; +import { getAccessToken } from './initial_state'; import ready from './ready'; -import type { GetState } from './store'; export const getLinks = (response: AxiosResponse) => { const value = response.headers.link as string | undefined; @@ -29,25 +34,25 @@ const setCSRFHeader = () => { void ready(setCSRFHeader); -const authorizationHeaderFromState = (getState?: GetState) => { - const accessToken = - getState && (getState().meta.get('access_token', '') as string); +const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => { + const accessToken = getAccessToken(); - if (!accessToken) { - return {}; - } + if (!accessToken) return {}; return { Authorization: `Bearer ${accessToken}`, - } as RawAxiosRequestHeaders; + }; }; // eslint-disable-next-line import/no-default-export -export default function api(getState: GetState) { - return axios.create({ +export default function api(withAuthorization = true) { + const instance = axios.create({ + transitional: { + clarifyTimeoutError: true, + }, headers: { ...csrfHeader, - ...authorizationHeaderFromState(getState), + ...(withAuthorization ? authorizationTokenFromInitialState() : {}), }, transformResponse: [ @@ -60,4 +65,69 @@ export default function api(getState: GetState) { }, ], }); + + instance.interceptors.response.use( + (response: AxiosResponse) => { + if (response.headers.deprecation) { + console.warn( + `Deprecated request: ${response.config.method} ${response.config.url}`, + ); + } + return response; + }, + (error: AxiosError) => { + return Promise.reject(error); + }, + ); + + return instance; +} + +type RequestParamsOrData = Record; + +export async function apiRequest( + method: Method, + url: string, + args: { + signal?: AbortSignal; + params?: RequestParamsOrData; + data?: RequestParamsOrData; + timeout?: number; + } = {}, +) { + const { data } = await api().request({ + method, + url: '/api/' + url, + ...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 new file mode 100644 index 0000000000..717010ba74 --- /dev/null +++ b/app/javascript/mastodon/api/accounts.ts @@ -0,0 +1,20 @@ +import { apiRequestPost } from 'mastodon/api'; +import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships'; + +export const apiSubmitAccountNote = (id: string, value: string) => + apiRequestPost(`v1/accounts/${id}/note`, { + comment: value, + }); + +export const apiFollowAccount = ( + id: string, + params?: { + reblogs: boolean; + }, +) => + apiRequestPost(`v1/accounts/${id}/follow`, { + ...params, + }); + +export const apiUnfollowAccount = (id: string) => + apiRequestPost(`v1/accounts/${id}/unfollow`); diff --git a/app/javascript/mastodon/api/antennas.ts b/app/javascript/mastodon/api/antennas.ts new file mode 100644 index 0000000000..61fd84185d --- /dev/null +++ b/app/javascript/mastodon/api/antennas.ts @@ -0,0 +1,143 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'mastodon/api'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import type { ApiAntennaJSON } from 'mastodon/api_types/antennas'; + +export const apiCreate = (antenna: Partial) => + apiRequestPost('v1/antennas', antenna); + +export const apiUpdate = (antenna: Partial) => + apiRequestPut(`v1/antennas/${antenna.id}`, antenna); + +export const apiGetAccounts = (antennaId: string) => + apiRequestGet(`v1/antennas/${antennaId}/accounts`, { + limit: 0, + }); + +export const apiGetExcludeAccounts = (antennaId: string) => + apiRequestGet(`v1/antennas/${antennaId}/exclude_accounts`, { + limit: 0, + }); + +export const apiGetDomains = (antennaId: string) => + apiRequestGet<{ domains: string[]; exclude_domains: string[] }>( + `v1/antennas/${antennaId}/domains`, + { + limit: 0, + }, + ); + +export const apiAddDomain = (antennaId: string, domain: string) => + apiRequestPost(`v1/antennas/${antennaId}/domains`, { + domains: [domain], + }); + +export const apiRemoveDomain = (antennaId: string, domain: string) => + apiRequestDelete(`v1/antennas/${antennaId}/domains`, { + domains: [domain], + }); + +export const apiAddExcludeDomain = (antennaId: string, domain: string) => + apiRequestPost(`v1/antennas/${antennaId}/exclude_domains`, { + domains: [domain], + }); + +export const apiRemoveExcludeDomain = (antennaId: string, domain: string) => + apiRequestDelete(`v1/antennas/${antennaId}/exclude_domains`, { + domains: [domain], + }); + +export const apiGetTags = (antennaId: string) => + apiRequestGet<{ tags: string[]; exclude_tags: string[] }>( + `v1/antennas/${antennaId}/tags`, + { + limit: 0, + }, + ); + +export const apiAddTag = (antennaId: string, tag: string) => + apiRequestPost(`v1/antennas/${antennaId}/tags`, { + tags: [tag], + }); + +export const apiRemoveTag = (antennaId: string, tag: string) => + apiRequestDelete(`v1/antennas/${antennaId}/tags`, { + tags: [tag], + }); + +export const apiAddExcludeTag = (antennaId: string, tag: string) => + apiRequestPost(`v1/antennas/${antennaId}/exclude_tags`, { + tags: [tag], + }); + +export const apiRemoveExcludeTag = (antennaId: string, tag: string) => + apiRequestDelete(`v1/antennas/${antennaId}/exclude_tags`, { + tags: [tag], + }); + +export const apiGetKeywords = (antennaId: string) => + apiRequestGet<{ keywords: string[]; exclude_keywords: string[] }>( + `v1/antennas/${antennaId}/keywords`, + { + limit: 0, + }, + ); + +export const apiAddKeyword = (antennaId: string, keyword: string) => + apiRequestPost(`v1/antennas/${antennaId}/keywords`, { + keywords: [keyword], + }); + +export const apiRemoveKeyword = (antennaId: string, keyword: string) => + apiRequestDelete(`v1/antennas/${antennaId}/keywords`, { + keywords: [keyword], + }); + +export const apiAddExcludeKeyword = (antennaId: string, keyword: string) => + apiRequestPost(`v1/antennas/${antennaId}/exclude_keywords`, { + keywords: [keyword], + }); + +export const apiRemoveExcludeKeyword = (antennaId: string, keyword: string) => + apiRequestDelete(`v1/antennas/${antennaId}/exclude_keywords`, { + keywords: [keyword], + }); + +export const apiGetAccountAntennas = (accountId: string) => + apiRequestGet(`v1/accounts/${accountId}/antennas`); + +export const apiAddAccountToAntenna = (antennaId: string, accountId: string) => + apiRequestPost(`v1/antennas/${antennaId}/accounts`, { + account_ids: [accountId], + }); + +export const apiRemoveAccountFromAntenna = ( + antennaId: string, + accountId: string, +) => + apiRequestDelete(`v1/antennas/${antennaId}/accounts`, { + account_ids: [accountId], + }); + +export const apiGetExcludeAccountAntennas = (accountId: string) => + apiRequestGet(`v1/accounts/${accountId}/exclude_antennas`); + +export const apiAddExcludeAccountToAntenna = ( + antennaId: string, + accountId: string, +) => + apiRequestPost(`v1/antennas/${antennaId}/exclude_accounts`, { + account_ids: [accountId], + }); + +export const apiRemoveExcludeAccountFromAntenna = ( + antennaId: string, + accountId: string, +) => + apiRequestDelete(`v1/antennas/${antennaId}/exclude_accounts`, { + account_ids: [accountId], + }); diff --git a/app/javascript/mastodon/api/bookmark_categories.ts b/app/javascript/mastodon/api/bookmark_categories.ts new file mode 100644 index 0000000000..d6d3394b3a --- /dev/null +++ b/app/javascript/mastodon/api/bookmark_categories.ts @@ -0,0 +1,49 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'mastodon/api'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import type { ApiBookmarkCategoryJSON } from 'mastodon/api_types/bookmark_categories'; + +export const apiCreate = (bookmarkCategory: Partial) => + apiRequestPost( + 'v1/bookmark_categories', + bookmarkCategory, + ); + +export const apiUpdate = (bookmarkCategory: Partial) => + apiRequestPut( + `v1/bookmark_categories/${bookmarkCategory.id}`, + bookmarkCategory, + ); + +export const apiGetStatuses = (bookmarkCategoryId: string) => + apiRequestGet( + `v1/bookmark_categories/${bookmarkCategoryId}/statuses`, + { + limit: 0, + }, + ); + +export const apiGetStatusBookmarkCategories = (accountId: string) => + apiRequestGet( + `v1/statuses/${accountId}/bookmark_categories`, + ); + +export const apiAddStatusToBookmarkCategory = ( + bookmarkCategoryId: string, + statusId: string, +) => + apiRequestPost(`v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { + status_ids: [statusId], + }); + +export const apiRemoveStatusFromBookmarkCategory = ( + bookmarkCategoryId: string, + statusId: string, +) => + apiRequestDelete(`v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { + status_ids: [statusId], + }); diff --git a/app/javascript/mastodon/api/circles.ts b/app/javascript/mastodon/api/circles.ts new file mode 100644 index 0000000000..04971e1e6b --- /dev/null +++ b/app/javascript/mastodon/api/circles.ts @@ -0,0 +1,35 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'mastodon/api'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import type { ApiCircleJSON } from 'mastodon/api_types/circles'; + +export const apiCreate = (circle: Partial) => + apiRequestPost('v1/circles', circle); + +export const apiUpdate = (circle: Partial) => + apiRequestPut(`v1/circles/${circle.id}`, circle); + +export const apiGetAccounts = (circleId: string) => + apiRequestGet(`v1/circles/${circleId}/accounts`, { + limit: 0, + }); + +export const apiGetAccountCircles = (accountId: string) => + apiRequestGet(`v1/accounts/${accountId}/circles`); + +export const apiAddAccountToCircle = (circleId: string, accountId: string) => + apiRequestPost(`v1/circles/${circleId}/accounts`, { + account_ids: [accountId], + }); + +export const apiRemoveAccountFromCircle = ( + circleId: string, + accountId: string, +) => + apiRequestDelete(`v1/circles/${circleId}/accounts`, { + account_ids: [accountId], + }); diff --git a/app/javascript/mastodon/api/compose.ts b/app/javascript/mastodon/api/compose.ts new file mode 100644 index 0000000000..757e9961c9 --- /dev/null +++ b/app/javascript/mastodon/api/compose.ts @@ -0,0 +1,7 @@ +import { apiRequestPut } from 'mastodon/api'; +import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments'; + +export const apiUpdateMedia = ( + id: string, + params?: { description?: string; focus?: string }, +) => apiRequestPut(`v1/media/${id}`, params); diff --git a/app/javascript/mastodon/api/directory.ts b/app/javascript/mastodon/api/directory.ts new file mode 100644 index 0000000000..cd39f8f269 --- /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/domain_blocks.ts b/app/javascript/mastodon/api/domain_blocks.ts new file mode 100644 index 0000000000..4e153b0ee9 --- /dev/null +++ b/app/javascript/mastodon/api/domain_blocks.ts @@ -0,0 +1,13 @@ +import api, { getLinks } from 'mastodon/api'; + +export const apiGetDomainBlocks = async (url?: string) => { + const response = await api().request({ + method: 'GET', + url: url ?? '/api/v1/domain_blocks', + }); + + return { + domains: response.data, + links: getLinks(response), + }; +}; diff --git a/app/javascript/mastodon/api/instance.ts b/app/javascript/mastodon/api/instance.ts new file mode 100644 index 0000000000..764e8daab2 --- /dev/null +++ b/app/javascript/mastodon/api/instance.ts @@ -0,0 +1,15 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { + ApiTermsOfServiceJSON, + ApiPrivacyPolicyJSON, +} from 'mastodon/api_types/instance'; + +export const apiGetTermsOfService = (version?: string) => + apiRequestGet( + version + ? `v1/instance/terms_of_service/${version}` + : 'v1/instance/terms_of_service', + ); + +export const apiGetPrivacyPolicy = () => + apiRequestGet('v1/instance/privacy_policy'); diff --git a/app/javascript/mastodon/api/interactions.ts b/app/javascript/mastodon/api/interactions.ts new file mode 100644 index 0000000000..118b5f06d2 --- /dev/null +++ b/app/javascript/mastodon/api/interactions.ts @@ -0,0 +1,10 @@ +import { apiRequestPost } from 'mastodon/api'; +import type { Status, StatusVisibility } from 'mastodon/models/status'; + +export const apiReblog = (statusId: string, visibility: StatusVisibility) => + apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, { + visibility, + }); + +export const apiUnreblog = (statusId: string) => + apiRequestPost(`v1/statuses/${statusId}/unreblog`); diff --git a/app/javascript/mastodon/api/lists.ts b/app/javascript/mastodon/api/lists.ts new file mode 100644 index 0000000000..a5586eb6d4 --- /dev/null +++ b/app/javascript/mastodon/api/lists.ts @@ -0,0 +1,32 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'mastodon/api'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import type { ApiListJSON } from 'mastodon/api_types/lists'; + +export const apiCreate = (list: Partial) => + apiRequestPost('v1/lists', list); + +export const apiUpdate = (list: Partial) => + apiRequestPut(`v1/lists/${list.id}`, list); + +export const apiGetAccounts = (listId: string) => + apiRequestGet(`v1/lists/${listId}/accounts`, { + limit: 0, + }); + +export const apiGetAccountLists = (accountId: string) => + apiRequestGet(`v1/accounts/${accountId}/lists`); + +export const apiAddAccountToList = (listId: string, accountId: string) => + apiRequestPost(`v1/lists/${listId}/accounts`, { + account_ids: [accountId], + }); + +export const apiRemoveAccountFromList = (listId: string, accountId: string) => + apiRequestDelete(`v1/lists/${listId}/accounts`, { + account_ids: [accountId], + }); diff --git a/app/javascript/mastodon/api/notification_policies.ts b/app/javascript/mastodon/api/notification_policies.ts new file mode 100644 index 0000000000..3bc8174139 --- /dev/null +++ b/app/javascript/mastodon/api/notification_policies.ts @@ -0,0 +1,9 @@ +import { apiRequestGet, apiRequestPut } from 'mastodon/api'; +import type { NotificationPolicyJSON } from 'mastodon/api_types/notification_policies'; + +export const apiGetNotificationPolicy = () => + apiRequestGet('v2/notifications/policy'); + +export const apiUpdateNotificationsPolicy = ( + policy: Partial, +) => 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 0000000000..5c31baab8e --- /dev/null +++ b/app/javascript/mastodon/api/notifications.ts @@ -0,0 +1,109 @@ +import api, { + apiRequest, + getLinks, + apiRequestGet, + apiRequestPost, +} from 'mastodon/api'; +import type { + ApiNotificationGroupsResultJSON, + ApiNotificationGroupJSON, + ApiNotificationRequestJSON, + ApiNotificationJSON, +} from 'mastodon/api_types/notifications'; +import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; + +const exceptInvalidNotifications = ( + notifications: ApiNotificationGroupJSON[], +) => { + return notifications.filter((n) => { + if ('status' in n) { + return (n.status as ApiStatusJSON | null) !== null; + } + return true; + }); +}; + +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: exceptInvalidNotifications(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/polls.ts b/app/javascript/mastodon/api/polls.ts new file mode 100644 index 0000000000..cb659986f5 --- /dev/null +++ b/app/javascript/mastodon/api/polls.ts @@ -0,0 +1,10 @@ +import { apiRequestGet, apiRequestPost } from 'mastodon/api'; +import type { ApiPollJSON } from 'mastodon/api_types/polls'; + +export const apiGetPoll = (pollId: string) => + apiRequestGet(`/v1/polls/${pollId}`); + +export const apiPollVote = (pollId: string, choices: string[]) => + apiRequestPost(`/v1/polls/${pollId}/votes`, { + choices, + }); diff --git a/app/javascript/mastodon/api/search.ts b/app/javascript/mastodon/api/search.ts new file mode 100644 index 0000000000..79b0385fe8 --- /dev/null +++ b/app/javascript/mastodon/api/search.ts @@ -0,0 +1,16 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { + ApiSearchType, + ApiSearchResultsJSON, +} from 'mastodon/api_types/search'; + +export const apiGetSearch = (params: { + q: string; + resolve?: boolean; + type?: ApiSearchType; + limit?: number; + offset?: number; +}) => + apiRequestGet('v2/search', { + ...params, + }); diff --git a/app/javascript/mastodon/api/suggestions.ts b/app/javascript/mastodon/api/suggestions.ts new file mode 100644 index 0000000000..d4817698cc --- /dev/null +++ b/app/javascript/mastodon/api/suggestions.ts @@ -0,0 +1,8 @@ +import { apiRequestGet, apiRequestDelete } from 'mastodon/api'; +import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions'; + +export const apiGetSuggestions = (limit: number) => + apiRequestGet('v2/suggestions', { limit }); + +export const apiDeleteSuggestion = (accountId: string) => + apiRequestDelete(`v1/suggestions/${accountId}`); diff --git a/app/javascript/mastodon/api/tags.ts b/app/javascript/mastodon/api/tags.ts new file mode 100644 index 0000000000..4b111def81 --- /dev/null +++ b/app/javascript/mastodon/api/tags.ts @@ -0,0 +1,23 @@ +import api, { getLinks, apiRequestPost, apiRequestGet } from 'mastodon/api'; +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; + +export const apiGetTag = (tagId: string) => + apiRequestGet(`v1/tags/${tagId}`); + +export const apiFollowTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/follow`); + +export const apiUnfollowTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/unfollow`); + +export const apiGetFollowedTags = async (url?: string) => { + const response = await api().request({ + method: 'GET', + url: url ?? '/api/v1/followed_tags', + }); + + return { + tags: response.data, + links: getLinks(response), + }; +}; diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index ead54bdd36..9d7974eda0 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -31,17 +31,23 @@ export interface ApiAccountOtherSettingsJSON { subscription_policy: 'allow' | 'followers_only' | 'block'; } +export interface ApiServerFeaturesJSON { + circle: boolean; + emoji_reaction: boolean; + quote: boolean; + status_reference: boolean; +} + // See app/serializers/rest/account_serializer.rb -export interface ApiAccountJSON { +export interface BaseApiAccountJSON { acct: string; avatar: string; avatar_static: string; bot: boolean; created_at: string; - discoverable: boolean; + discoverable?: boolean; indexable: boolean; display_name: string; - emoji_reaction_available_server: boolean; emojis: ApiCustomEmojiJSON[]; fields: ApiAccountFieldJSON[]; followers_count: number; @@ -56,6 +62,7 @@ export interface ApiAccountJSON { note: string; other_settings: ApiAccountOtherSettingsJSON; roles?: ApiAccountJSON[]; + server_features: ApiServerFeaturesJSON; subscribable: boolean; statuses_count: number; uri: string; @@ -67,3 +74,12 @@ export interface ApiAccountJSON { memorial?: boolean; hide_collections: boolean; } + +// See app/serializers/rest/muted_account_serializer.rb +export interface ApiMutedAccountJSON extends BaseApiAccountJSON { + mute_expires_at?: string | null; +} + +// For now, we have the same type representing both `Account` and `MutedAccount` +// objects, but we should refactor this in the future. +export type ApiAccountJSON = ApiMutedAccountJSON; diff --git a/app/javascript/mastodon/api_types/antennas.ts b/app/javascript/mastodon/api_types/antennas.ts new file mode 100644 index 0000000000..a2a8a997ba --- /dev/null +++ b/app/javascript/mastodon/api_types/antennas.ts @@ -0,0 +1,17 @@ +// See app/serializers/rest/antenna_serializer.rb + +import type { ApiListJSON } from './lists'; + +export interface ApiAntennaJSON { + id: string; + title: string; + stl: boolean; + ltl: boolean; + insert_feeds: boolean; + with_media_only: boolean; + ignore_reblog: boolean; + favourite: boolean; + list: ApiListJSON | null; + + list_id: string | undefined; +} diff --git a/app/javascript/mastodon/api_types/bookmark_categories.ts b/app/javascript/mastodon/api_types/bookmark_categories.ts new file mode 100644 index 0000000000..5407b6b125 --- /dev/null +++ b/app/javascript/mastodon/api_types/bookmark_categories.ts @@ -0,0 +1,6 @@ +// See app/serializers/rest/bookmark_category_serializer.rb + +export interface ApiBookmarkCategoryJSON { + id: string; + title: string; +} diff --git a/app/javascript/mastodon/api_types/circles.ts b/app/javascript/mastodon/api_types/circles.ts new file mode 100644 index 0000000000..9905d480b8 --- /dev/null +++ b/app/javascript/mastodon/api_types/circles.ts @@ -0,0 +1,6 @@ +// See app/serializers/rest/circle_serializer.rb + +export interface ApiCircleJSON { + id: string; + title: string; +} diff --git a/app/javascript/mastodon/api_types/instance.ts b/app/javascript/mastodon/api_types/instance.ts new file mode 100644 index 0000000000..3a29684b70 --- /dev/null +++ b/app/javascript/mastodon/api_types/instance.ts @@ -0,0 +1,11 @@ +export interface ApiTermsOfServiceJSON { + effective_date: string; + effective: boolean; + succeeded_by: string | null; + content: string; +} + +export interface ApiPrivacyPolicyJSON { + updated_at: string; + content: string; +} diff --git a/app/javascript/mastodon/api_types/lists.ts b/app/javascript/mastodon/api_types/lists.ts new file mode 100644 index 0000000000..bc32b33883 --- /dev/null +++ b/app/javascript/mastodon/api_types/lists.ts @@ -0,0 +1,15 @@ +// See app/serializers/rest/list_serializer.rb + +import type { ApiAntennaJSON } from './antennas'; + +export type RepliesPolicyType = 'list' | 'followed' | 'none'; + +export interface ApiListJSON { + id: string; + title: string; + exclusive: boolean; + replies_policy: RepliesPolicyType; + notify: boolean; + favourite: boolean; + antennas?: ApiAntennaJSON[]; +} diff --git a/app/javascript/mastodon/api_types/markers.ts b/app/javascript/mastodon/api_types/markers.ts new file mode 100644 index 0000000000..f7664fd7c1 --- /dev/null +++ b/app/javascript/mastodon/api_types/markers.ts @@ -0,0 +1,7 @@ +// See app/serializers/rest/account_serializer.rb + +export interface MarkerJSON { + last_read_id: string; + version: string; + updated_at: string; +} diff --git a/app/javascript/mastodon/api_types/media_attachments.ts b/app/javascript/mastodon/api_types/media_attachments.ts new file mode 100644 index 0000000000..fc027ccd2a --- /dev/null +++ b/app/javascript/mastodon/api_types/media_attachments.ts @@ -0,0 +1,22 @@ +// See app/serializers/rest/media_attachment_serializer.rb + +export type MediaAttachmentType = + | 'image' + | 'gifv' + | 'video' + | 'unknown' + | 'audio'; + +export interface ApiMediaAttachmentJSON { + id: string; + type: MediaAttachmentType; + url: string; + preview_url: string; + remoteUrl: string; + preview_remote_url: string; + text_url: string; + // TODO: how to define this? + meta: unknown; + description?: string; + blurhash: string; +} diff --git a/app/javascript/mastodon/api_types/notification_policies.ts b/app/javascript/mastodon/api_types/notification_policies.ts new file mode 100644 index 0000000000..1c3970782c --- /dev/null +++ b/app/javascript/mastodon/api_types/notification_policies.ts @@ -0,0 +1,15 @@ +// See app/serializers/rest/notification_policy_serializer.rb + +export type NotificationPolicyValue = 'accept' | 'filter' | 'drop'; + +export interface NotificationPolicyJSON { + 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 0000000000..41daed25ad --- /dev/null +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -0,0 +1,200 @@ +// See app/serializers/rest/notification_group_serializer.rb + +import type { AccountWarningAction } from 'mastodon/models/notification_group'; + +import type { ApiAccountJSON } from './accounts'; +import type { ApiListJSON } from './lists'; +import type { ApiReportJSON } from './reports'; +import type { ApiStatusJSON } from './statuses'; + +// See app/model/notification.rb +export const allNotificationTypes = [ + 'follow', + 'follow_request', + 'favourite', + 'emoji_reaction', + 'reblog', + 'mention', + 'status_reference', + 'poll', + 'status', + 'list_status', + 'update', + 'admin.sign_up', + 'admin.report', + 'moderation_warning', + 'severed_relationships', + 'annual_report', +]; + +export type NotificationWithStatusType = + | 'favourite' + | 'emoji_reaction' + | 'reblog' + | 'status' + | 'list_status' + | 'mention' + | 'status_reference' + | 'poll' + | 'update'; + +export type NotificationType = + | NotificationWithStatusType + | 'follow' + | 'follow_request' + | 'moderation_warning' + | 'severed_relationships' + | 'admin.sign_up' + | 'admin.report' + | 'annual_report'; + +export interface NotifyEmojiReactionJSON { + name: string; + count: number; + me: boolean; + url?: string; + static_url?: string; + domain?: string; + width?: number; + height?: number; +} + +export interface NotificationEmojiReactionGroupJSON { + emoji_reaction: NotifyEmojiReactionJSON; + sample_account_ids: string[]; +} + +export interface BaseNotificationJSON { + id: string; + type: NotificationType; + created_at: string; + group_key: string; + account: ApiAccountJSON; + emoji_reaction?: NotifyEmojiReactionJSON; + list?: ApiListJSON; +} + +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; + emoji_reaction_groups?: NotificationEmojiReactionGroupJSON[]; + list?: ApiListJSON; +} + +interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { + type: NotificationWithStatusType; + status_id: string | null; +} + +interface NotificationWithStatusJSON extends BaseNotificationJSON { + type: NotificationWithStatusType; + status: ApiStatusJSON | null; + emoji_reaction?: NotifyEmojiReactionJSON; +} + +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 interface ApiAnnualReportEventJSON { + year: string; +} + +interface AnnualReportNotificationGroupJSON extends BaseNotificationGroupJSON { + type: 'annual_report'; + annual_report: ApiAnnualReportEventJSON; +} + +export type ApiNotificationJSON = + | SimpleNotificationJSON + | ReportNotificationJSON + | AccountRelationshipSeveranceNotificationJSON + | NotificationWithStatusJSON + | ModerationWarningNotificationJSON; + +export type ApiNotificationGroupJSON = + | SimpleNotificationGroupJSON + | ReportNotificationGroupJSON + | AccountRelationshipSeveranceNotificationGroupJSON + | NotificationGroupWithStatusJSON + | ModerationWarningNotificationGroupJSON + | AnnualReportNotificationGroupJSON; + +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/polls.ts b/app/javascript/mastodon/api_types/polls.ts new file mode 100644 index 0000000000..891a2faba7 --- /dev/null +++ b/app/javascript/mastodon/api_types/polls.ts @@ -0,0 +1,23 @@ +import type { ApiCustomEmojiJSON } from './custom_emoji'; + +// See app/serializers/rest/poll_serializer.rb + +export interface ApiPollOptionJSON { + title: string; + votes_count: number; +} + +export interface ApiPollJSON { + id: string; + expires_at: string; + expired: boolean; + multiple: boolean; + votes_count: number; + voters_count: number | null; + + options: ApiPollOptionJSON[]; + emojis: ApiCustomEmojiJSON[]; + + voted?: boolean; + own_votes?: number[]; +} diff --git a/app/javascript/mastodon/api_types/reports.ts b/app/javascript/mastodon/api_types/reports.ts new file mode 100644 index 0000000000..b11cfdd2eb --- /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/search.ts b/app/javascript/mastodon/api_types/search.ts new file mode 100644 index 0000000000..795cbb2b41 --- /dev/null +++ b/app/javascript/mastodon/api_types/search.ts @@ -0,0 +1,11 @@ +import type { ApiAccountJSON } from './accounts'; +import type { ApiStatusJSON } from './statuses'; +import type { ApiHashtagJSON } from './tags'; + +export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags'; + +export interface ApiSearchResultsJSON { + accounts: ApiAccountJSON[]; + statuses: ApiStatusJSON[]; + hashtags: ApiHashtagJSON[]; +} diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts new file mode 100644 index 0000000000..a83c99351e --- /dev/null +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -0,0 +1,127 @@ +// See app/serializers/rest/status_serializer.rb + +import type { ApiAccountJSON } from './accounts'; +import type { ApiCustomEmojiJSON } from './custom_emoji'; +import type { ApiMediaAttachmentJSON } from './media_attachments'; +import type { ApiPollJSON } from './polls'; + +// See app/modals/status.rb visibility+limited_scope +export type StatusVisibility = + | 'public' + | 'unlisted' + | 'private' + | 'direct' + | 'public_unlisted' + | 'login' + | 'mutual' + | 'circle' + | 'personal' + | 'reply' + | 'limited'; + +export interface ApiStatusApplicationJSON { + name: string; + website: string; +} + +export interface ApiTagJSON { + name: string; + url: string; +} + +export interface ApiMentionJSON { + id: string; + username: string; + url: string; + acct: string; +} + +export interface ApiPreviewCardAuthorJSON { + name: string; + url: string; + account?: ApiAccountJSON; +} + +export interface ApiPreviewCardJSON { + url: string; + title: string; + description: string; + language: string; + type: string; + author_name: string; + author_url: string; + author_account?: ApiAccountJSON; + provider_name: string; + provider_url: string; + html: string; + width: number; + height: number; + image: string; + image_description: string; + 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 { + id: string; + created_at: string; + in_reply_to_id?: string; + in_reply_to_account_id?: string; + sensitive: boolean; + spoiler_text?: string; + visibility: StatusVisibility; + language: string; + uri: string; + url: string; + replies_count: number; + reblogs_count: number; + favorites_count: number; + edited_at?: string; + + favorited?: boolean; + reblogged?: boolean; + muted?: boolean; + bookmarked?: boolean; + pinned?: boolean; + + filtered?: ApiFilterResultJSON[]; + content?: string; + text?: string; + + reblog?: ApiStatusJSON; + application?: ApiStatusApplicationJSON; + account: ApiAccountJSON; + media_attachments: ApiMediaAttachmentJSON[]; + mentions: ApiMentionJSON[]; + + tags: ApiTagJSON[]; + emojis: ApiCustomEmojiJSON[]; + + card?: ApiPreviewCardJSON; + poll?: ApiPollJSON; +} diff --git a/app/javascript/mastodon/api_types/suggestions.ts b/app/javascript/mastodon/api_types/suggestions.ts new file mode 100644 index 0000000000..7d91daf901 --- /dev/null +++ b/app/javascript/mastodon/api_types/suggestions.ts @@ -0,0 +1,13 @@ +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; + +export type ApiSuggestionSourceJSON = + | 'featured' + | 'most_followed' + | 'most_interactions' + | 'similar_to_recently_followed' + | 'friends_of_friends'; + +export interface ApiSuggestionJSON { + sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]]; + account: ApiAccountJSON; +} diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts new file mode 100644 index 0000000000..0c16c8bd28 --- /dev/null +++ b/app/javascript/mastodon/api_types/tags.ts @@ -0,0 +1,13 @@ +interface ApiHistoryJSON { + day: string; + accounts: string; + uses: string; +} + +export interface ApiHashtagJSON { + id: string; + name: string; + url: string; + history: [ApiHistoryJSON, ...ApiHistoryJSON[]]; + following?: boolean; +} diff --git a/app/javascript/mastodon/common.js b/app/javascript/mastodon/common.js index 0ec8449343..c61e02250c 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); + require.context('../images/', true, /\.(jpg|png|svg)$/); try { Rails.start(); - } catch (e) { + } catch { // If called twice } } diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap index 1c37278483..dc955b7abe 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap @@ -9,7 +9,11 @@ exports[` renders emoji with custom url 1`] = ` className="emojione" src="http://example.com/emoji.png" /> - :foobar: +

+ :foobar: +
`; @@ -22,6 +26,10 @@ exports[` renders native emoji 1`] = ` className="emojione" src="/emoji/1f499.svg" /> - :foobar: +
+ :foobar: +
`; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap index 2f0a2de324..124b50d8c7 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap @@ -2,7 +2,7 @@ exports[` Autoplay renders a animated avatar 1`] = `
Autoplay renders a animated avatar 1`] = ` >
@@ -21,7 +23,7 @@ exports[` Autoplay renders a animated avatar 1`] = ` exports[` Still renders a still avatar 1`] = `
Still renders a still avatar 1`] = ` >
diff --git a/app/javascript/mastodon/components/__tests__/hashtag_bar.tsx b/app/javascript/mastodon/components/__tests__/hashtag_bar.tsx index b7225fc92e..f86c1a2a6b 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 deleted file mode 100644 index 5146bf5a5b..0000000000 --- a/app/javascript/mastodon/components/account.jsx +++ /dev/null @@ -1,187 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; -import { Link } from 'react-router-dom'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; - -import { EmptyAccount } from 'mastodon/components/empty_account'; -import { ShortNumber } from 'mastodon/components/short_number'; -import { VerifiedBadge } from 'mastodon/components/verified_badge'; - -import { me } from '../initial_state'; - -import { Avatar } from './avatar'; -import { Button } from './button'; -import { FollowersCounter } from './counters'; -import { DisplayName } from './display_name'; -import { RelativeTimestamp } from './relative_timestamp'; - -const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' }, - unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, - unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, - mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' }, - unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' }, - mute: { id: 'account.mute_short', defaultMessage: 'Mute' }, - block: { id: 'account.block_short', defaultMessage: 'Block' }, -}); - -class Account extends ImmutablePureComponent { - - static propTypes = { - size: PropTypes.number, - account: ImmutablePropTypes.record, - onFollow: PropTypes.func.isRequired, - onBlock: PropTypes.func.isRequired, - onMute: PropTypes.func.isRequired, - onMuteNotifications: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - hidden: PropTypes.bool, - hideButtons: PropTypes.bool, - minimal: PropTypes.bool, - defaultAction: PropTypes.string, - children: PropTypes.object, - withBio: PropTypes.bool, - }; - - static defaultProps = { - size: 46, - }; - - handleFollow = () => { - this.props.onFollow(this.props.account); - }; - - handleBlock = () => { - this.props.onBlock(this.props.account); - }; - - handleMute = () => { - this.props.onMute(this.props.account); - }; - - handleMuteNotifications = () => { - this.props.onMuteNotifications(this.props.account, true); - }; - - handleUnmuteNotifications = () => { - this.props.onMuteNotifications(this.props.account, false); - }; - - render () { - const { account, intl, hidden, hideButtons, withBio, defaultAction, size, minimal, children } = this.props; - - if (!account) { - return ; - } - - if (hidden) { - return ( - <> - {account.get('display_name')} - {account.get('username')} - - ); - } - - let buttons; - - if (!hideButtons && account.get('id') !== me && account.get('relationship', null) !== null) { - const following = account.getIn(['relationship', 'following']); - const requested = account.getIn(['relationship', 'requested']); - const blocking = account.getIn(['relationship', 'blocking']); - const muting = account.getIn(['relationship', 'muting']); - - if (requested) { - buttons = + )} + + + ); +}; + +export const AlertsController: React.FC = () => { + const alerts = useAppSelector((state) => state.alerts); + + if (alerts.length === 0) { + return null; + } + + return ( +
+ {alerts.map((alert, idx) => ( + + ))} +
+ ); +}; diff --git a/app/javascript/mastodon/components/alt_text_badge.tsx b/app/javascript/mastodon/components/alt_text_badge.tsx new file mode 100644 index 0000000000..701cfbe8b4 --- /dev/null +++ b/app/javascript/mastodon/components/alt_text_badge.tsx @@ -0,0 +1,77 @@ +import { useState, useCallback, useRef, useId } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import Overlay from 'react-overlays/Overlay'; +import type { + OffsetValue, + UsePopperOptions, +} from 'react-overlays/esm/usePopper'; + +import { useSelectableClick } from 'mastodon/hooks/useSelectableClick'; + +const offset = [0, 4] as OffsetValue; +const popperConfig = { strategy: 'fixed' } as UsePopperOptions; + +export const AltTextBadge: React.FC<{ + description: string; +}> = ({ description }) => { + const accessibilityId = useId(); + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleClick = useCallback(() => { + setOpen((v) => !v); + }, [setOpen]); + + const handleClose = useCallback(() => { + setOpen(false); + }, [setOpen]); + + const [handleMouseDown, handleMouseUp] = useSelectableClick(handleClose); + + return ( + <> + + + + {({ props }) => ( +
+
+

+ +

+

{description}

+
+
+ )} +
+ + ); +}; diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx index e98e30b242..db422f47ce 100644 --- a/app/javascript/mastodon/components/animated_number.tsx +++ b/app/javascript/mastodon/components/animated_number.tsx @@ -1,6 +1,6 @@ -import { useCallback, useState } from 'react'; +import { useEffect, useState } from 'react'; -import { TransitionMotion, spring } from 'react-motion'; +import { animated, useSpring, config } from '@react-spring/web'; import { reduceMotion } from '../initial_state'; @@ -11,52 +11,49 @@ interface Props { } export const AnimatedNumber: React.FC = ({ value }) => { const [previousValue, setPreviousValue] = useState(value); - const [direction, setDirection] = useState<1 | -1>(1); + const direction = value > previousValue ? -1 : 1; - if (previousValue !== value) { - setPreviousValue(value); - setDirection(value > previousValue ? 1 : -1); - } - - const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]); - const willLeave = useCallback( - () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }), - [direction], + const [styles, api] = useSpring( + () => ({ + from: { transform: `translateY(${100 * direction}%)` }, + to: { transform: 'translateY(0%)' }, + onRest() { + setPreviousValue(value); + }, + config: { ...config.gentle, duration: 200 }, + immediate: true, // This ensures that the animation is not played when the component is first rendered + }), + [value, previousValue], ); + // When the value changes, start the animation + useEffect(() => { + if (value !== previousValue) { + void api.start({ reset: true }); + } + }, [api, previousValue, value]); + if (reduceMotion) { return ; } - const styles = [ - { - key: `${value}`, - data: value, - style: { y: spring(0, { damping: 35, stiffness: 400 }) }, - }, - ]; - return ( - - {(items) => ( - - {items.map(({ key, data, style }) => ( - 0 ? 'absolute' : 'static', - transform: `translateY(${style.y * 100}%)`, - }} - > - - - ))} - + + + + + {value !== previousValue && ( + + + )} - + ); }; diff --git a/app/javascript/mastodon/components/attachment_list.jsx b/app/javascript/mastodon/components/attachment_list.jsx index ebf092b83d..f97e22f2d4 100644 --- a/app/javascript/mastodon/components/attachment_list.jsx +++ b/app/javascript/mastodon/components/attachment_list.jsx @@ -7,8 +7,7 @@ import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { ReactComponent as LinkIcon } from '@material-symbols/svg-600/outlined/link.svg'; - +import LinkIcon from '@/material-icons/400-24px/link.svg?react'; import { Icon } from 'mastodon/components/icon'; const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; @@ -37,7 +36,7 @@ export default class AttachmentList extends ImmutablePureComponent { return (
  • - + {compact && } {compact && ' ' } {displayUrl ? filename(displayUrl) : } diff --git a/app/javascript/mastodon/components/autosuggest_emoji.jsx b/app/javascript/mastodon/components/autosuggest_emoji.jsx index 28f628b4ac..adea1d9f4b 100644 --- a/app/javascript/mastodon/components/autosuggest_emoji.jsx +++ b/app/javascript/mastodon/components/autosuggest_emoji.jsx @@ -35,7 +35,7 @@ export default class AutosuggestEmoji extends PureComponent { alt={emoji.native || emoji.colons} /> - {emoji.colons} +
    {emoji.colons}
    ); } diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.tsx b/app/javascript/mastodon/components/autosuggest_hashtag.tsx index e83d493c2c..036946b0f1 100644 --- a/app/javascript/mastodon/components/autosuggest_hashtag.tsx +++ b/app/javascript/mastodon/components/autosuggest_hashtag.tsx @@ -1,5 +1,3 @@ -import { FormattedMessage } from 'react-intl'; - import { ShortNumber } from 'mastodon/components/short_number'; interface Props { @@ -16,27 +14,18 @@ interface Props { }; } -export const AutosuggestHashtag: React.FC = ({ tag }) => { - const weeklyUses = tag.history && ( - total + day.uses * 1, 0)} - /> - ); - - return ( -
    -
    - #{tag.name} -
    - {tag.history !== undefined && ( -
    - -
    - )} +export const AutosuggestHashtag: React.FC = ({ tag }) => ( +
    +
    + #{tag.name}
    - ); -}; + + {tag.history !== undefined && ( +
    + total + day.uses * 1, 0)} + /> +
    + )} +
    +); diff --git a/app/javascript/mastodon/components/autosuggest_input.jsx b/app/javascript/mastodon/components/autosuggest_input.jsx index 06cbb5d75b..f707a18e1d 100644 --- a/app/javascript/mastodon/components/autosuggest_input.jsx +++ b/app/javascript/mastodon/components/autosuggest_input.jsx @@ -5,6 +5,8 @@ import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import Overlay from 'react-overlays/Overlay'; + import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestEmoji from './autosuggest_emoji'; @@ -195,34 +197,37 @@ export default class AutosuggestInput extends ImmutablePureComponent { return (
    - - -
    - {suggestions.map(this.renderSuggestion)} -
    + + {({ props }) => ( +
    +
    + {suggestions.map(this.renderSuggestion)} +
    +
    + )} +
    ); } diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx index 4d173af59d..07fbccacc9 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.jsx +++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx @@ -5,6 +5,7 @@ import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import Overlay from 'react-overlays/Overlay'; import Textarea from 'react-textarea-autosize'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; @@ -52,7 +53,6 @@ const AutosuggestTextarea = forwardRef(({ onFocus, autoFocus = true, lang, - children, }, textareaRef) => { const [suggestionsHidden, setSuggestionsHidden] = useState(true); @@ -183,40 +183,38 @@ const AutosuggestTextarea = forwardRef(({ ); }; - return [ -
    -
    -