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 480b274fad..0000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,367 +0,0 @@ -// @ts-check -const { defineConfig } = require('eslint-define-config'); - -module.exports = defineConfig({ - root: true, - - extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:jsx-a11y/recommended', - 'plugin:import/recommended', - 'plugin:promise/recommended', - 'plugin:jsdoc/recommended', - ], - - env: { - browser: true, - node: true, - es6: true, - }, - - parser: '@typescript-eslint/parser', - - plugins: [ - 'react', - 'jsx-a11y', - 'import', - 'promise', - '@typescript-eslint', - 'formatjs', - ], - - parserOptions: { - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - ecmaVersion: 2021, - requireConfigFile: false, - babelOptions: { - configFile: false, - presets: ['@babel/react', '@babel/env'], - }, - }, - - settings: { - react: { - version: 'detect', - }, - 'import/ignore': [ - 'node_modules', - '\\.(css|scss|json)$', - ], - 'import/resolver': { - typescript: {}, - }, - }, - - rules: { - 'consistent-return': 'error', - 'dot-notation': 'error', - eqeqeq: ['error', 'always', { 'null': 'ignore' }], - 'indent': ['error', 2], - 'jsx-quotes': ['error', 'prefer-single'], - 'semi': ['error', 'always'], - 'no-catch-shadow': 'error', - 'no-console': [ - 'warn', - { - allow: [ - 'error', - 'warn', - ], - }, - ], - 'no-empty': ['error', { "allowEmptyCatch": true }], - 'no-restricted-properties': [ - 'error', - { property: 'substring', message: 'Use .slice instead of .substring.' }, - { property: 'substr', message: 'Use .slice instead of .substr.' }, - ], - 'no-restricted-syntax': [ - 'error', - { - // eslint-disable-next-line no-restricted-syntax - selector: 'Literal[value=/•/], JSXText[value=/•/]', - // eslint-disable-next-line no-restricted-syntax - message: "Use '·' (middle dot) instead of '•' (bullet)", - }, - ], - 'no-unused-expressions': 'error', - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': [ - 'error', - { - vars: 'all', - args: 'after-used', - destructuredArrayIgnorePattern: '^_', - ignoreRestSiblings: true, - }, - ], - 'valid-typeof': 'error', - - 'react/jsx-filename-extension': ['error', { extensions: ['.jsx', 'tsx'] }], - 'react/jsx-boolean-value': 'error', - 'react/display-name': 'off', - 'react/jsx-fragments': ['error', 'syntax'], - 'react/jsx-equals-spacing': 'error', - 'react/jsx-no-bind': 'error', - 'react/jsx-no-useless-fragment': 'error', - 'react/jsx-no-target-blank': ['error', { allowReferrer: true }], - 'react/jsx-tag-spacing': 'error', - 'react/jsx-uses-react': 'off', // not needed with new JSX transform - 'react/jsx-wrap-multilines': 'error', - 'react/react-in-jsx-scope': 'off', // not needed with new JSX transform - 'react/self-closing-comp': 'error', - - // recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46 - 'jsx-a11y/click-events-have-key-events': 'off', - 'jsx-a11y/label-has-associated-control': 'off', - 'jsx-a11y/media-has-caption': 'off', - 'jsx-a11y/no-autofocus': 'off', - // recommended rule is: - // 'jsx-a11y/no-interactive-element-to-noninteractive-role': [ - // 'error', - // { - // tr: ['none', 'presentation'], - // canvas: ['img'], - // }, - // ], - 'jsx-a11y/no-interactive-element-to-noninteractive-role': 'off', - // recommended rule is: - // 'jsx-a11y/no-noninteractive-tabindex': [ - // 'error', - // { - // tags: [], - // roles: ['tabpanel'], - // allowExpressionValues: true, - // }, - // ], - 'jsx-a11y/no-noninteractive-tabindex': 'off', - // recommended is full 'error' - 'jsx-a11y/no-static-element-interactions': [ - 'warn', - { - handlers: [ - 'onClick', - ], - }, - ], - - // See https://github.com/import-js/eslint-plugin-import/blob/v2.29.1/config/recommended.js - 'import/extensions': [ - 'error', - 'always', - { - js: 'never', - jsx: 'never', - mjs: 'never', - ts: 'never', - tsx: 'never', - }, - ], - 'import/first': 'error', - 'import/newline-after-import': 'error', - 'import/no-anonymous-default-export': 'error', - 'import/no-extraneous-dependencies': [ - 'error', - { - devDependencies: [ - '.eslintrc.js', - 'config/webpack/**', - 'app/javascript/mastodon/performance.js', - 'app/javascript/mastodon/test_setup.js', - 'app/javascript/**/__tests__/**', - ], - }, - ], - 'import/no-amd': 'error', - 'import/no-commonjs': 'error', - 'import/no-import-module-exports': 'error', - 'import/no-relative-packages': 'error', - 'import/no-self-import': 'error', - 'import/no-useless-path-segments': 'error', - 'import/no-webpack-loader-syntax': 'error', - - 'import/order': [ - 'error', - { - alphabetize: { order: 'asc' }, - 'newlines-between': 'always', - groups: [ - 'builtin', - 'external', - 'internal', - 'parent', - ['index', 'sibling'], - 'object', - ], - pathGroups: [ - // React core packages - { - pattern: '{react,react-dom,react-dom/client,prop-types}', - group: 'builtin', - position: 'after', - }, - // I18n - { - pattern: '{react-intl,intl-messageformat}', - group: 'builtin', - position: 'after', - }, - // Common React utilities - { - pattern: '{classnames,react-helmet,react-router,react-router-dom}', - group: 'external', - position: 'before', - }, - // Immutable / Redux / data store - { - pattern: '{immutable,@reduxjs/toolkit,react-redux,react-immutable-proptypes,react-immutable-pure-component}', - group: 'external', - position: 'before', - }, - // Internal packages - { - pattern: '{mastodon/**}', - group: 'internal', - position: 'after', - }, - ], - pathGroupsExcludedImportTypes: [], - }, - ], - - 'promise/always-return': 'off', - 'promise/catch-or-return': [ - 'error', - { - allowFinally: true, - }, - ], - 'promise/no-callback-in-promise': 'off', - 'promise/no-nesting': 'off', - 'promise/no-promise-in-callback': 'off', - - 'formatjs/blocklist-elements': 'error', - 'formatjs/enforce-default-message': ['error', 'literal'], - 'formatjs/enforce-description': 'off', // description values not currently used - 'formatjs/enforce-id': 'off', // Explicit IDs are used in the project - 'formatjs/enforce-placeholders': 'off', // Issues in short_number.jsx - 'formatjs/enforce-plural-rules': 'error', - 'formatjs/no-camel-case': 'off', // disabledAccount is only non-conforming - 'formatjs/no-complex-selectors': 'error', - 'formatjs/no-emoji': 'error', - 'formatjs/no-id': 'off', // IDs are used for translation keys - 'formatjs/no-invalid-icu': 'error', - 'formatjs/no-literal-string-in-jsx': 'off', // Should be looked at, but mainly flagging punctuation outside of strings - 'formatjs/no-multiple-whitespaces': 'error', - 'formatjs/no-offset': 'error', - 'formatjs/no-useless-message': 'error', - 'formatjs/prefer-formatted-message': 'error', - 'formatjs/prefer-pound-in-plural': 'error', - - 'jsdoc/check-types': 'off', - 'jsdoc/no-undefined-types': 'off', - 'jsdoc/require-jsdoc': 'off', - 'jsdoc/require-param-description': 'off', - 'jsdoc/require-property-description': 'off', - 'jsdoc/require-returns-description': 'off', - 'jsdoc/require-returns': 'off', - }, - - overrides: [ - { - files: [ - '.eslintrc.js', - '*.config.js', - '.*rc.js', - 'ide-helper.js', - 'config/webpack/**/*', - 'config/formatjs-formatter.js', - ], - - env: { - commonjs: true, - }, - - parserOptions: { - sourceType: 'script', - }, - - rules: { - 'import/no-commonjs': 'off', - }, - }, - { - files: [ - '**/*.ts', - '**/*.tsx', - ], - - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/strict-type-checked', - 'plugin:@typescript-eslint/stylistic-type-checked', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:jsx-a11y/recommended', - 'plugin:import/recommended', - 'plugin:import/typescript', - 'plugin:promise/recommended', - 'plugin:jsdoc/recommended-typescript', - ], - - parserOptions: { - projectService: true, - tsconfigRootDir: __dirname, - }, - - rules: { - // Disable formatting rules that have been enabled in the base config - 'indent': 'off', - - // This is not needed as we use noImplicitReturns, which handles this in addition to understanding types - 'consistent-return': 'off', - - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - - '@typescript-eslint/consistent-type-definitions': ['warn', 'interface'], - '@typescript-eslint/consistent-type-exports': 'error', - '@typescript-eslint/consistent-type-imports': 'error', - "@typescript-eslint/prefer-nullish-coalescing": ['error', { ignorePrimitives: { boolean: true } }], - "@typescript-eslint/no-restricted-imports": [ - "warn", - { - "name": "react-redux", - "importNames": ["useSelector", "useDispatch"], - "message": "Use typed hooks `useAppDispatch` and `useAppSelector` instead." - } - ], - "@typescript-eslint/restrict-template-expressions": ['warn', { allowNumber: true }], - 'jsdoc/require-jsdoc': 'off', - - // Those rules set stricter rules for TS files - // to enforce better practices when converting from JS - 'import/no-default-export': 'warn', - 'react/prefer-stateless-function': 'warn', - 'react/function-component-definition': ['error', { namedComponents: 'arrow-function' }], - 'react/jsx-uses-react': 'off', // not needed with new JSX transform - 'react/react-in-jsx-scope': 'off', // not needed with new JSX transform - 'react/prop-types': 'off', - }, - }, - { - files: [ - '**/__tests__/*.js', - '**/__tests__/*.jsx', - ], - - env: { - jest: true, - }, - } - ], -}); diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 8a10676283..e638b9c548 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -15,6 +15,8 @@ // to `null` after any other rule set it to something. dependencyDashboardHeader: 'This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. Before approving any upgrade: read the description and comments in the [`renovate.json5` file](https://github.com/mastodon/mastodon/blob/main/.github/renovate.json5).', postUpdateOptions: ['yarnDedupeHighest'], + // The types are now included in recent versions,we ignore them here until we upgrade and remove the dependency + ignoreDeps: ['@types/emoji-mart'], packageRules: [ { // Require Dependency Dashboard Approval for major version bumps of these node packages @@ -97,7 +99,13 @@ { // Group all eslint-related packages with `eslint` in the same PR matchManagers: ['npm'], - matchPackageNames: ['eslint', 'eslint-*', '@typescript-eslint/*'], + matchPackageNames: [ + 'eslint', + 'eslint-*', + 'typescript-eslint', + '@eslint/*', + 'globals', + ], matchUpdateTypes: ['patch', 'minor'], groupName: 'eslint (non-major)', }, diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 621a662387..13468e7799 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -14,7 +14,7 @@ on: - 'tsconfig.json' - '.nvmrc' - '.prettier*' - - '.eslint*' + - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' - '**/*.ts' @@ -28,7 +28,7 @@ on: - 'tsconfig.json' - '.nvmrc' - '.prettier*' - - '.eslint*' + - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' - '**/*.ts' @@ -47,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/.prettierrc.js b/.prettierrc.js index af39b253f6..65ec869c33 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,4 +1,4 @@ module.exports = { singleQuote: true, jsxSingleQuote: true -} +}; diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index f589f5f03d..8232ec8ec3 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.75.0. +# using RuboCop version 1.75.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -68,11 +68,6 @@ Style/HashTransformValues: - 'app/serializers/rest/web_push_subscription_serializer.rb' - 'app/services/import_service.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/MapToHash: - Exclude: - - 'app/models/status.rb' - # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? Style/OptionalBooleanParameter: diff --git a/Gemfile b/Gemfile index b64a1dbe91..9e5955e0b8 100644 --- a/Gemfile +++ b/Gemfile @@ -62,6 +62,7 @@ gem 'inline_svg' gem 'irb', '~> 1.8' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' +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' diff --git a/Gemfile.lock b/Gemfile.lock index 1ad5429d4b..80049a7dc2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -395,10 +395,16 @@ GEM rexml link_header (0.0.8) lint_roller (1.1.0) + linzer (0.6.3) + 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.6.6) + logger (1.7.0) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) @@ -440,7 +446,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.6) + nokogiri (1.18.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.10) @@ -563,7 +569,7 @@ GEM opentelemetry-instrumentation-redis (0.26.1) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-sidekiq (0.26.0) + opentelemetry-instrumentation-sidekiq (0.26.1) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-registry (0.4.0) @@ -580,7 +586,7 @@ GEM ox (2.14.22) bigdecimal (>= 3.0) parallel (1.26.3) - parser (3.3.7.3) + parser (3.3.7.4) ast (~> 2.4.1) racc parslet (2.0.0) @@ -754,15 +760,15 @@ GEM rubocop-i18n (3.2.3) lint_roller (~> 1.1) rubocop (>= 1.72.1) - rubocop-performance (1.24.0) + rubocop-performance (1.25.0) lint_roller (~> 1.1) - rubocop (>= 1.72.1, < 2.0) + rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.30.3) + rubocop-rails (2.31.0) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.72.1, < 2.0) + rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) rubocop-rspec (3.5.0) lint_roller (~> 1.1) @@ -829,9 +835,11 @@ GEM simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) stackprof (0.2.27) + starry (0.2.0) + base64 stoplight (4.1.1) redlock (~> 1.0) - stringio (3.1.5) + stringio (3.1.6) strong_migrations (2.2.1) activerecord (>= 7) swd (2.0.3) @@ -980,6 +988,7 @@ DEPENDENCIES letter_opener (~> 1.8) letter_opener_web (~> 3.0) link_header (~> 0.0) + linzer (~> 0.6.1) lograge (~> 0.12) mail (~> 2.8) mario-redis-lock (~> 1.2) 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/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/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 5f7ef8dd63..ffe612f468 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -10,8 +10,6 @@ module SignatureVerification EXPIRATION_WINDOW_LIMIT = 12.hours CLOCK_SKEW_MARGIN = 1.hour - class SignatureVerificationError < StandardError; end - def require_account_signature! render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account end @@ -34,7 +32,7 @@ module SignatureVerification def signature_key_id signature_params['keyId'] - rescue SignatureVerificationError + rescue Mastodon::SignatureVerificationError nil end @@ -45,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) @@ -68,7 +66,7 @@ module SignatureVerification 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? @@ -78,7 +76,7 @@ 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 *Mastodon::HTTP_CONNECTION_ERRORS => e fail_with! "Failed to fetch remote data: #{e.message}" @@ -104,7 +102,7 @@ module SignatureVerification def signature_params @signature_params ||= SignatureParser.parse(request.headers['Signature']) rescue SignatureParser::ParsingError - raise SignatureVerificationError, 'Error parsing signature parameters' + raise Mastodon::SignatureVerificationError, 'Error parsing signature parameters' end def signature_algorithm @@ -116,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?(HttpSignatureDraft::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) @@ -165,13 +163,13 @@ module SignatureVerification "#{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 @@ -193,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? @@ -233,9 +231,9 @@ module SignatureVerification 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_wrapper @@ -251,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/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 0560e76628..9374d6b2d1 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -68,7 +68,7 @@ function loaded() { if (id) message = localeData[id]; - if (!message) message = defaultMessage as string; + message ??= defaultMessage as string; const messageFormat = new IntlMessageFormat(message, locale); return messageFormat.format(values) as string; diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js index 727f800af3..279ec1bef7 100644 --- a/app/javascript/mastodon/actions/domain_blocks.js +++ b/app/javascript/mastodon/actions/domain_blocks.js @@ -12,14 +12,6 @@ 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)); @@ -79,80 +71,6 @@ export function unblockDomainFail(domain, error) { }; } -export function fetchDomainBlocks() { - return (dispatch) => { - dispatch(fetchDomainBlocksRequest()); - - api().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().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: { diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 380190a910..fc165b1a1f 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -75,7 +75,7 @@ export function importFetchedStatuses(statuses) { } if (status.poll?.id) { - pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id))); + pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id])); } if (status.card) { diff --git a/app/javascript/mastodon/actions/polls.ts b/app/javascript/mastodon/actions/polls.ts index 28f729394b..65a96e8f62 100644 --- a/app/javascript/mastodon/actions/polls.ts +++ b/app/javascript/mastodon/actions/polls.ts @@ -15,7 +15,7 @@ export const importFetchedPoll = createAppAsyncThunk( dispatch( importPolls({ - polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))], + polls: [createPollFromServerJSON(poll, getState().polls[poll.id])], }), ); }, 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_types/polls.ts b/app/javascript/mastodon/api_types/polls.ts index 275ca29fd7..891a2faba7 100644 --- a/app/javascript/mastodon/api_types/polls.ts +++ b/app/javascript/mastodon/api_types/polls.ts @@ -13,7 +13,7 @@ export interface ApiPollJSON { expired: boolean; multiple: boolean; votes_count: number; - voters_count: number; + voters_count: number | null; options: ApiPollOptionJSON[]; emojis: ApiCustomEmojiJSON[]; diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx index 6c1e0aaec1..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,53 +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 ?? 0) * 100}%)`, - }} - > - - - ))} - + + + + + {value !== previousValue && ( + + + )} - + ); }; diff --git a/app/javascript/mastodon/components/copy_icon_button.jsx b/app/javascript/mastodon/components/copy_icon_button.tsx similarity index 62% rename from app/javascript/mastodon/components/copy_icon_button.jsx rename to app/javascript/mastodon/components/copy_icon_button.tsx index 0c3c6c290b..29f5f34430 100644 --- a/app/javascript/mastodon/components/copy_icon_button.jsx +++ b/app/javascript/mastodon/components/copy_icon_button.tsx @@ -1,29 +1,36 @@ -import PropTypes from 'prop-types'; import { useState, useCallback } from 'react'; import { defineMessages } from 'react-intl'; import classNames from 'classnames'; -import { useDispatch } from 'react-redux'; - import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; import { showAlert } from 'mastodon/actions/alerts'; import { IconButton } from 'mastodon/components/icon_button'; +import { useAppDispatch } from 'mastodon/store'; const messages = defineMessages({ - copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' }, + copied: { + id: 'copy_icon_button.copied', + defaultMessage: 'Copied to clipboard', + }, }); -export const CopyIconButton = ({ title, value, className }) => { +export const CopyIconButton: React.FC<{ + title: string; + value: string; + className: string; +}> = ({ title, value, className }) => { const [copied, setCopied] = useState(false); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const handleClick = useCallback(() => { - navigator.clipboard.writeText(value); + void navigator.clipboard.writeText(value); setCopied(true); dispatch(showAlert({ message: messages.copied })); - setTimeout(() => setCopied(false), 700); + setTimeout(() => { + setCopied(false); + }, 700); }, [setCopied, value, dispatch]); return ( @@ -31,13 +38,8 @@ export const CopyIconButton = ({ title, value, className }) => { className={classNames(className, copied ? 'copied' : 'copyable')} title={title} onClick={handleClick} + icon='' iconComponent={ContentCopyIcon} /> ); }; - -CopyIconButton.propTypes = { - title: PropTypes.string, - value: PropTypes.string, - className: PropTypes.string, -}; diff --git a/app/javascript/mastodon/components/counters.tsx b/app/javascript/mastodon/components/counters.tsx index 35b0ad8d60..151b25a3f7 100644 --- a/app/javascript/mastodon/components/counters.tsx +++ b/app/javascript/mastodon/components/counters.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import type React from 'react'; import { FormattedMessage } from 'react-intl'; diff --git a/app/javascript/mastodon/components/domain.tsx b/app/javascript/mastodon/components/domain.tsx index aa64f0f8c3..0ccffac482 100644 --- a/app/javascript/mastodon/components/domain.tsx +++ b/app/javascript/mastodon/components/domain.tsx @@ -1,24 +1,15 @@ import { useCallback } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; -import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react'; import { unblockDomain } from 'mastodon/actions/domain_blocks'; import { useAppDispatch } from 'mastodon/store'; -import { IconButton } from './icon_button'; - -const messages = defineMessages({ - unblockDomain: { - id: 'account.unblock_domain', - defaultMessage: 'Unblock domain {domain}', - }, -}); +import { Button } from './button'; export const Domain: React.FC<{ domain: string; }> = ({ domain }) => { - const intl = useIntl(); const dispatch = useAppDispatch(); const handleDomainUnblock = useCallback(() => { @@ -27,20 +18,17 @@ export const Domain: React.FC<{ return (
-
- - {domain} - +
+ {domain} +
-
- +
+
); diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx deleted file mode 100644 index 1326131009..0000000000 --- a/app/javascript/mastodon/components/poll.jsx +++ /dev/null @@ -1,248 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; - -import escapeTextContentForBrowser from 'escape-html'; -import spring from 'react-motion/lib/spring'; - -import CheckIcon from '@/material-icons/400-24px/check.svg?react'; -import { Icon } from 'mastodon/components/icon'; -import emojify from 'mastodon/features/emoji/emoji'; -import Motion from 'mastodon/features/ui/util/optional_motion'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; - -import { RelativeTimestamp } from './relative_timestamp'; - -const messages = defineMessages({ - closed: { - id: 'poll.closed', - defaultMessage: 'Closed', - }, - voted: { - id: 'poll.voted', - defaultMessage: 'You voted for this answer', - }, - votes: { - id: 'poll.votes', - defaultMessage: '{votes, plural, one {# vote} other {# votes}}', - }, -}); - -class Poll extends ImmutablePureComponent { - static propTypes = { - identity: identityContextPropShape, - poll: ImmutablePropTypes.record.isRequired, - status: ImmutablePropTypes.map.isRequired, - lang: PropTypes.string, - intl: PropTypes.object.isRequired, - disabled: PropTypes.bool, - refresh: PropTypes.func, - onVote: PropTypes.func, - onInteractionModal: PropTypes.func, - }; - - state = { - selected: {}, - expired: null, - }; - - static getDerivedStateFromProps (props, state) { - const { poll } = props; - const expires_at = poll.get('expires_at'); - const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < Date.now(); - return (expired === state.expired) ? null : { expired }; - } - - componentDidMount () { - this._setupTimer(); - } - - componentDidUpdate () { - this._setupTimer(); - } - - componentWillUnmount () { - clearTimeout(this._timer); - } - - _setupTimer () { - const { poll } = this.props; - clearTimeout(this._timer); - if (!this.state.expired) { - const delay = (new Date(poll.get('expires_at'))).getTime() - Date.now(); - this._timer = setTimeout(() => { - this.setState({ expired: true }); - }, delay); - } - } - - _toggleOption = value => { - if (this.props.poll.get('multiple')) { - const tmp = { ...this.state.selected }; - if (tmp[value]) { - delete tmp[value]; - } else { - tmp[value] = true; - } - this.setState({ selected: tmp }); - } else { - const tmp = {}; - tmp[value] = true; - this.setState({ selected: tmp }); - } - }; - - handleOptionChange = ({ target: { value } }) => { - this._toggleOption(value); - }; - - handleOptionKeyPress = (e) => { - if (e.key === 'Enter' || e.key === ' ') { - this._toggleOption(e.target.getAttribute('data-index')); - e.stopPropagation(); - e.preventDefault(); - } - }; - - handleVote = () => { - if (this.props.disabled) { - return; - } - - if (this.props.identity.signedIn) { - this.props.onVote(Object.keys(this.state.selected)); - } else { - this.props.onInteractionModal('vote', this.props.status); - } - }; - - handleRefresh = () => { - if (this.props.disabled) { - return; - } - - this.props.refresh(); - }; - - handleReveal = () => { - this.setState({ revealed: true }); - }; - - renderOption (option, optionIndex, showResults) { - const { poll, lang, disabled, intl } = this.props; - const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); - const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100; - const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count')); - const active = !!this.state.selected[`${optionIndex}`]; - const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); - - const title = option.getIn(['translation', 'title']) || option.get('title'); - let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml'); - - if (!titleHtml) { - const emojiMap = emojiMap(poll); - titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); - } - - return ( -
  • - - - {showResults && ( - - {({ width }) => - - } - - )} -
  • - ); - } - - render () { - const { poll, intl } = this.props; - const { revealed, expired } = this.state; - - if (!poll) { - return null; - } - - const timeRemaining = expired ? intl.formatMessage(messages.closed) : ; - const showResults = poll.get('voted') || revealed || expired; - const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); - - let votesCount = null; - - if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) { - votesCount = ; - } else { - votesCount = ; - } - - return ( -
    -
      - {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))} -
    - -
    - {!showResults && } - {!showResults && <> · } - {showResults && !this.props.disabled && <> · } - {votesCount} - {poll.get('expires_at') && <> · {timeRemaining}} -
    -
    - ); - } - -} - -export default injectIntl(withIdentity(Poll)); diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx new file mode 100644 index 0000000000..6692f674d4 --- /dev/null +++ b/app/javascript/mastodon/components/poll.tsx @@ -0,0 +1,337 @@ +import type { KeyboardEventHandler } from 'react'; +import { useCallback, useMemo, useState } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { animated, useSpring } from '@react-spring/web'; +import escapeTextContentForBrowser from 'escape-html'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import { openModal } from 'mastodon/actions/modal'; +import { fetchPoll, vote } from 'mastodon/actions/polls'; +import { Icon } from 'mastodon/components/icon'; +import emojify from 'mastodon/features/emoji/emoji'; +import { useIdentity } from 'mastodon/identity_context'; +import { reduceMotion } from 'mastodon/initial_state'; +import { makeEmojiMap } from 'mastodon/models/custom_emoji'; +import type * as Model from 'mastodon/models/poll'; +import type { Status } from 'mastodon/models/status'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +import { RelativeTimestamp } from './relative_timestamp'; + +const messages = defineMessages({ + closed: { + id: 'poll.closed', + defaultMessage: 'Closed', + }, + voted: { + id: 'poll.voted', + defaultMessage: 'You voted for this answer', + }, + votes: { + id: 'poll.votes', + defaultMessage: '{votes, plural, one {# vote} other {# votes}}', + }, +}); + +interface PollProps { + pollId: string; + status: Status; + lang?: string; + disabled?: boolean; +} + +export const Poll: React.FC = ({ pollId, disabled, status }) => { + // Third party hooks + const poll = useAppSelector((state) => state.polls[pollId]); + const identity = useIdentity(); + const intl = useIntl(); + const dispatch = useAppDispatch(); + + // State + const [revealed, setRevealed] = useState(false); + const [selected, setSelected] = useState>({}); + + // Derived values + const expired = useMemo(() => { + if (!poll) { + return false; + } + const expiresAt = poll.expires_at; + return poll.expired || new Date(expiresAt).getTime() < Date.now(); + }, [poll]); + const timeRemaining = useMemo(() => { + if (!poll) { + return null; + } + if (expired) { + return intl.formatMessage(messages.closed); + } + return ; + }, [expired, intl, poll]); + const votesCount = useMemo(() => { + if (!poll) { + return null; + } + if (poll.voters_count) { + return ( + + ); + } + return ( + + ); + }, [poll]); + + const voteDisabled = + disabled || Object.values(selected).every((item) => !item); + + // Event handlers + const handleVote = useCallback(() => { + if (voteDisabled) { + return; + } + + if (identity.signedIn) { + void dispatch(vote({ pollId, choices: Object.keys(selected) })); + } else { + dispatch( + openModal({ + modalType: 'INTERACTION', + modalProps: { + type: 'vote', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + }), + ); + } + }, [voteDisabled, dispatch, identity, pollId, selected, status]); + + const handleReveal = useCallback(() => { + setRevealed(true); + }, []); + + const handleRefresh = useCallback(() => { + if (disabled) { + return; + } + void dispatch(fetchPoll({ pollId })); + }, [disabled, dispatch, pollId]); + + const handleOptionChange = useCallback( + (choiceIndex: number) => { + if (!poll) { + return; + } + if (poll.multiple) { + setSelected((prev) => ({ + ...prev, + [choiceIndex]: !prev[choiceIndex], + })); + } else { + setSelected({ [choiceIndex]: true }); + } + }, + [poll], + ); + + if (!poll) { + return null; + } + const showResults = poll.voted || revealed || expired; + + return ( +
    +
      + {poll.options.map((option, i) => ( + + ))} +
    + +
    + {!showResults && ( + + )} + {!showResults && ( + <> + {' '} + ·{' '} + + )} + {showResults && !disabled && ( + <> + {' '} + ·{' '} + + )} + {votesCount} + {poll.expires_at && <> · {timeRemaining}} +
    +
    + ); +}; + +type PollOptionProps = Pick & { + active: boolean; + onChange: (index: number) => void; + poll: Model.Poll; + option: Model.PollOption; + index: number; + showResults?: boolean; +}; + +const PollOption: React.FC = (props) => { + const { active, lang, disabled, poll, option, index, showResults, onChange } = + props; + const voted = option.voted || poll.own_votes?.includes(index); + const title = option.translation?.title ?? option.title; + + const intl = useIntl(); + + // Derived values + const percent = useMemo(() => { + const pollVotesCount = poll.voters_count ?? poll.votes_count; + return pollVotesCount === 0 + ? 0 + : (option.votes_count / pollVotesCount) * 100; + }, [option, poll]); + const isLeading = useMemo( + () => + poll.options + .filter((other) => other.title !== option.title) + .every((other) => option.votes_count >= other.votes_count), + [poll, option], + ); + const titleHtml = useMemo(() => { + let titleHtml = option.translation?.titleHtml ?? option.titleHtml; + + if (!titleHtml) { + const emojiMap = makeEmojiMap(poll.emojis); + titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); + } + + return titleHtml; + }, [option, poll, title]); + + // Handlers + const handleOptionChange = useCallback(() => { + onChange(index); + }, [index, onChange]); + const handleOptionKeyPress: KeyboardEventHandler = useCallback( + (event) => { + if (event.key === 'Enter' || event.key === ' ') { + onChange(index); + event.stopPropagation(); + event.preventDefault(); + } + }, + [index, onChange], + ); + + const widthSpring = useSpring({ + from: { + width: '0%', + }, + to: { + width: `${percent}%`, + }, + immediate: reduceMotion, + }); + + return ( +
  • + + + {showResults && ( + + )} +
  • + ); +}; diff --git a/app/javascript/mastodon/components/router.tsx b/app/javascript/mastodon/components/router.tsx index 558d0307e7..815b4b59ab 100644 --- a/app/javascript/mastodon/components/router.tsx +++ b/app/javascript/mastodon/components/router.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren } from 'react'; -import React from 'react'; +import type React from 'react'; import { Router as OriginalRouter, useHistory } from 'react-router'; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 0c51d8cb4c..383fab2d87 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -11,7 +11,7 @@ import { connect } from 'react-redux'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import { Icon } from 'mastodon/components/icon'; -import PollContainer from 'mastodon/containers/poll_container'; +import { Poll } from 'mastodon/components/poll'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; @@ -250,7 +250,7 @@ class StatusContent extends PureComponent { ); const poll = !!status.get('poll') && ( - + ); if (this.props.onClick) { diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx index d18602e3b5..9c07341faa 100644 --- a/app/javascript/mastodon/containers/media_container.jsx +++ b/app/javascript/mastodon/containers/media_container.jsx @@ -7,12 +7,13 @@ import { fromJS } from 'immutable'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; import MediaGallery from 'mastodon/components/media_gallery'; import ModalRoot from 'mastodon/components/modal_root'; -import Poll from 'mastodon/components/poll'; +import { Poll } from 'mastodon/components/poll'; import Audio from 'mastodon/features/audio'; import Card from 'mastodon/features/status/components/card'; import MediaModal from 'mastodon/features/ui/components/media_modal'; -import Video from 'mastodon/features/video'; +import { Video } from 'mastodon/features/video'; import { IntlProvider } from 'mastodon/locales'; +import { createPollFromServerJSON } from 'mastodon/models/poll'; import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; @@ -88,7 +89,7 @@ export default class MediaContainer extends PureComponent { Object.assign(props, { ...(media ? { media: fromJS(media) } : {}), ...(card ? { card: fromJS(card) } : {}), - ...(poll ? { poll: fromJS(poll) } : {}), + ...(poll ? { poll: createPollFromServerJSON(poll) } : {}), ...(hashtag ? { hashtag: fromJS(hashtag) } : {}), ...(componentName === 'Video' ? { diff --git a/app/javascript/mastodon/containers/poll_container.js b/app/javascript/mastodon/containers/poll_container.js deleted file mode 100644 index 7ca840138d..0000000000 --- a/app/javascript/mastodon/containers/poll_container.js +++ /dev/null @@ -1,38 +0,0 @@ -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import { openModal } from 'mastodon/actions/modal'; -import { fetchPoll, vote } from 'mastodon/actions/polls'; -import Poll from 'mastodon/components/poll'; - -const mapDispatchToProps = (dispatch, { pollId }) => ({ - refresh: debounce( - () => { - dispatch(fetchPoll({ pollId })); - }, - 1000, - { leading: true }, - ), - - onVote (choices) { - dispatch(vote({ pollId, choices })); - }, - - onInteractionModal (type, status) { - dispatch(openModal({ - modalType: 'INTERACTION', - modalProps: { - type, - accountId: status.getIn(['account', 'id']), - url: status.get('uri'), - }, - })); - } -}); - -const mapStateToProps = (state, { pollId }) => ({ - poll: state.polls.get(pollId), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Poll); diff --git a/app/javascript/mastodon/features/alt_text_modal/index.tsx b/app/javascript/mastodon/features/alt_text_modal/index.tsx index 80c4f36105..8c5e552eb8 100644 --- a/app/javascript/mastodon/features/alt_text_modal/index.tsx +++ b/app/javascript/mastodon/features/alt_text_modal/index.tsx @@ -30,7 +30,7 @@ import { Skeleton } from 'mastodon/components/skeleton'; import Audio from 'mastodon/features/audio'; import { CharacterCounter } from 'mastodon/features/compose/components/character_counter'; import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components'; -import Video, { getPointerPosition } from 'mastodon/features/video'; +import { Video, getPointerPosition } from 'mastodon/features/video'; import { me } from 'mastodon/initial_state'; import type { MediaAttachment } from 'mastodon/models/media_attachment'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; @@ -134,17 +134,7 @@ const Preview: React.FC<{ return; } - const { x, y } = getPointerPosition(nodeRef.current, e); - setDragging(true); - draggingRef.current = true; - onPositionChange([x, y]); - }, - [setDragging, onPositionChange], - ); - - const handleTouchStart = useCallback( - (e: React.TouchEvent) => { - const { x, y } = getPointerPosition(nodeRef.current, e); + const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent); setDragging(true); draggingRef.current = true; onPositionChange([x, y]); @@ -165,28 +155,12 @@ const Preview: React.FC<{ } }; - const handleTouchEnd = () => { - setDragging(false); - draggingRef.current = false; - }; - - const handleTouchMove = (e: TouchEvent) => { - if (draggingRef.current) { - const { x, y } = getPointerPosition(nodeRef.current, e); - onPositionChange([x, y]); - } - }; - document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('touchend', handleTouchEnd); - document.addEventListener('touchmove', handleTouchMove); return () => { document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('touchend', handleTouchEnd); - document.removeEventListener('touchmove', handleTouchMove); }; }, [setDragging, onPositionChange]); @@ -204,7 +178,6 @@ const Preview: React.FC<{ alt='' role='presentation' onMouseDown={handleMouseDown} - onTouchStart={handleTouchStart} />
    ); diff --git a/app/javascript/mastodon/features/audio/index.jsx b/app/javascript/mastodon/features/audio/index.jsx index dc48756906..53ce3f0bdb 100644 --- a/app/javascript/mastodon/features/audio/index.jsx +++ b/app/javascript/mastodon/features/audio/index.jsx @@ -27,8 +27,8 @@ import Visualizer from './visualizer'; const messages = defineMessages({ play: { id: 'video.play', defaultMessage: 'Play' }, pause: { id: 'video.pause', defaultMessage: 'Pause' }, - mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, - unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, + mute: { id: 'video.mute', defaultMessage: 'Mute' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute' }, download: { id: 'video.download', defaultMessage: 'Download file' }, hide: { id: 'audio.hide', defaultMessage: 'Hide audio' }, }); diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 84659e23e1..889e7951d8 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -25,7 +25,6 @@ import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import SearchabilityDropdownContainer from '../containers/searchability_dropdown_container'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import UploadButtonContainer from '../containers/upload_button_container'; -import WarningContainer from '../containers/warning_container'; import { countableText } from '../util/counter'; import { CharacterCounter } from './character_counter'; @@ -35,6 +34,7 @@ import { NavigationBar } from './navigation_bar'; import { PollForm } from "./poll_form"; import { ReplyIndicator } from './reply_indicator'; import { UploadForm } from './upload_form'; +import { Warning } from './warning'; const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; @@ -254,7 +254,7 @@ class ComposeForm extends ImmutablePureComponent {
    {!withoutNavigation && } - +
    diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.jsx b/app/javascript/mastodon/features/compose/components/upload_progress.jsx deleted file mode 100644 index fd0c8f4530..0000000000 --- a/app/javascript/mastodon/features/compose/components/upload_progress.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import spring from 'react-motion/lib/spring'; - -import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react'; -import { Icon } from 'mastodon/components/icon'; - -import Motion from '../../ui/util/optional_motion'; - -export const UploadProgress = ({ active, progress, isProcessing }) => { - if (!active) { - return null; - } - - let message; - - if (isProcessing) { - message = ; - } else { - message = ; - } - - return ( -
    - - -
    - {message} - -
    - - {({ width }) => -
    - } - -
    -
    -
    - ); -}; - -UploadProgress.propTypes = { - active: PropTypes.bool, - progress: PropTypes.number, - isProcessing: PropTypes.bool, -}; diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.tsx b/app/javascript/mastodon/features/compose/components/upload_progress.tsx new file mode 100644 index 0000000000..be15917784 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload_progress.tsx @@ -0,0 +1,52 @@ +import { FormattedMessage } from 'react-intl'; + +import { animated, useSpring } from '@react-spring/web'; + +import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react'; +import { Icon } from 'mastodon/components/icon'; +import { reduceMotion } from 'mastodon/initial_state'; + +interface UploadProgressProps { + active: boolean; + progress: number; + isProcessing?: boolean; +} + +export const UploadProgress: React.FC = ({ + active, + progress, + isProcessing = false, +}) => { + const styles = useSpring({ + from: { width: '0%' }, + to: { width: `${progress}%` }, + immediate: reduceMotion || !active, // If this is not active, update the UI immediately. + }); + if (!active) { + return null; + } + + return ( +
    + + +
    + {isProcessing ? ( + + ) : ( + + )} + +
    + +
    +
    +
    + ); +}; diff --git a/app/javascript/mastodon/features/compose/components/warning.jsx b/app/javascript/mastodon/features/compose/components/warning.jsx deleted file mode 100644 index c5babc30a5..0000000000 --- a/app/javascript/mastodon/features/compose/components/warning.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import spring from 'react-motion/lib/spring'; - -import Motion from '../../ui/util/optional_motion'; - -export default class Warning extends PureComponent { - - static propTypes = { - message: PropTypes.node.isRequired, - }; - - render () { - const { message } = this.props; - - return ( - - {({ opacity, scaleX, scaleY }) => ( -
    - {message} -
    - )} -
    - ); - } - -} diff --git a/app/javascript/mastodon/features/compose/components/warning.tsx b/app/javascript/mastodon/features/compose/components/warning.tsx new file mode 100644 index 0000000000..36b971b044 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/warning.tsx @@ -0,0 +1,147 @@ +import { FormattedMessage } from 'react-intl'; + +import { createSelector } from '@reduxjs/toolkit'; + +import { animated, useSpring } from '@react-spring/web'; + +import { me } from 'mastodon/initial_state'; +import { useAppSelector } from 'mastodon/store'; +import type { RootState } from 'mastodon/store'; +import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags'; +import { MENTION_PATTERN_REGEX } from 'mastodon/utils/mentions'; + +const selector = createSelector( + (state: RootState) => state.compose.get('privacy') as string, + (state: RootState) => !!state.accounts.getIn([me, 'locked']), + (state: RootState) => state.compose.get('text') as string, + (state: RootState) => state.compose.get('searchability') as string, + (state: RootState) => state.compose.get('limited_scope') as string, + (privacy, locked, text, searchability, limited_scope) => ({ + needsLockWarning: privacy === 'private' && !locked, + hashtagWarning: + !['public', 'public_unlisted', 'login'].includes(privacy) && + (privacy !== 'unlisted' || searchability !== 'public') && + HASHTAG_PATTERN_REGEX.test(text), + directMessageWarning: privacy === 'direct', + searchabilityWarning: searchability === 'limited', + mentionWarning: + ['mutual', 'circle', 'limited'].includes(privacy) && + MENTION_PATTERN_REGEX.test(text), + limitedPostWarning: + ['mutual', 'circle'].includes(privacy) && !limited_scope, + }), +); + +export const Warning = () => { + const { + needsLockWarning, + hashtagWarning, + directMessageWarning, + searchabilityWarning, + mentionWarning, + limitedPostWarning, + } = useAppSelector(selector); + if (needsLockWarning) { + return ( + + + + + ), + }} + /> + + ); + } + + if (hashtagWarning) { + return ( + + + + ); + } + + if (directMessageWarning) { + return ( + + {' '} + + + + + ); + } + + if (searchabilityWarning) { + return ( + + + + ); + } + + if (mentionWarning) { + return ( + + + + ); + } + + if (limitedPostWarning) { + return ( + + + + ); + } + + return null; +}; + +export const WarningMessage: React.FC = ({ + children, +}) => { + const styles = useSpring({ + from: { + opacity: 0, + transform: 'scale(0.85, 0.75)', + }, + to: { + opacity: 1, + transform: 'scale(1, 1)', + }, + }); + return ( + + {children} + + ); +}; diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.jsx b/app/javascript/mastodon/features/compose/containers/warning_container.jsx deleted file mode 100644 index bc74d1209b..0000000000 --- a/app/javascript/mastodon/features/compose/containers/warning_container.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import { connect } from 'react-redux'; - -import { me } from 'mastodon/initial_state'; -import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags'; -import { MENTION_PATTERN_REGEX } from 'mastodon/utils/mentions'; - -import Warning from '../components/warning'; - -const mapStateToProps = state => ({ - needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), - hashtagWarning: !['public', 'public_unlisted', 'login'].includes(state.getIn(['compose', 'privacy'])) && state.getIn(['compose', 'searchability']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])), - directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', - searchabilityWarning: state.getIn(['compose', 'searchability']) === 'limited', - mentionWarning: ['mutual', 'circle', 'limited'].includes(state.getIn(['compose', 'privacy'])) && MENTION_PATTERN_REGEX.test(state.getIn(['compose', 'text'])), - limitedPostWarning: ['mutual', 'circle'].includes(state.getIn(['compose', 'privacy'])) && !state.getIn(['compose', 'limited_scope']), -}); - -const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, searchabilityWarning, mentionWarning, limitedPostWarning }) => { - if (needsLockWarning) { - return }} />} />; - } - - if (hashtagWarning) { - return } />; - } - - if (directMessageWarning) { - const message = ( - - - - ); - - return ; - } - - if (searchabilityWarning) { - return } />; - } - - if (mentionWarning) { - return } />; - } - - if (limitedPostWarning) { - return } />; - } - - return null; -}; - -WarningWrapper.propTypes = { - needsLockWarning: PropTypes.bool, - hashtagWarning: PropTypes.bool, - directMessageWarning: PropTypes.bool, - searchabilityWarning: PropTypes.bool, - mentionWarning: PropTypes.bool, - limitedPostWarning: PropTypes.bool, -}; - -export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/mastodon/features/domain_blocks/index.jsx b/app/javascript/mastodon/features/domain_blocks/index.jsx deleted file mode 100644 index 3656596806..0000000000 --- a/app/javascript/mastodon/features/domain_blocks/index.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react'; -import { Domain } from 'mastodon/components/domain'; - -import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks'; -import { LoadingIndicator } from '../../components/loading_indicator'; -import ScrollableList from '../../components/scrollable_list'; -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' }, -}); - -const mapStateToProps = state => ({ - domains: state.getIn(['domain_lists', 'blocks', 'items']), - hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']), -}); - -class Blocks extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - hasMore: PropTypes.bool, - domains: ImmutablePropTypes.orderedSet, - intl: PropTypes.object.isRequired, - multiColumn: PropTypes.bool, - }; - - UNSAFE_componentWillMount () { - this.props.dispatch(fetchDomainBlocks()); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandDomainBlocks()); - }, 300, { leading: true }); - - render () { - const { intl, domains, hasMore, multiColumn } = this.props; - - if (!domains) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - - - {domains.map(domain => - , - )} - - - - - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(Blocks)); diff --git a/app/javascript/mastodon/features/domain_blocks/index.tsx b/app/javascript/mastodon/features/domain_blocks/index.tsx new file mode 100644 index 0000000000..900aba4745 --- /dev/null +++ b/app/javascript/mastodon/features/domain_blocks/index.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react'; +import { apiGetDomainBlocks } from 'mastodon/api/domain_blocks'; +import { Column } from 'mastodon/components/column'; +import type { ColumnRef } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { Domain } from 'mastodon/components/domain'; +import ScrollableList from 'mastodon/components/scrollable_list'; + +const messages = defineMessages({ + heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' }, +}); + +const Blocks: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + const intl = useIntl(); + const [domains, setDomains] = useState([]); + const [loading, setLoading] = useState(false); + const [next, setNext] = useState(); + const hasMore = !!next; + const columnRef = useRef(null); + + useEffect(() => { + setLoading(true); + + void apiGetDomainBlocks() + .then(({ domains, links }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + setLoading(false); + setDomains(domains); + setNext(next?.uri); + + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, [setLoading, setDomains, setNext]); + + const handleLoadMore = useCallback(() => { + setLoading(true); + + void apiGetDomainBlocks(next) + .then(({ domains, links }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + setLoading(false); + setDomains((previousDomains) => [...previousDomains, ...domains]); + setNext(next?.uri); + + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, [setLoading, setDomains, setNext, next]); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + const emptyMessage = ( + + ); + + return ( + + + + + {domains.map((domain) => ( + + ))} + + + + {intl.formatMessage(messages.heading)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Blocks; diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js index 1a14ab6e82..1bc08fb368 100644 --- a/app/javascript/mastodon/features/emoji/emoji_compressed.js +++ b/app/javascript/mastodon/features/emoji/emoji_compressed.js @@ -1,5 +1,3 @@ -/* eslint-disable import/no-commonjs -- - We need to use CommonJS here due to preval */ // @preval // http://www.unicode.org/Public/emoji/5.0/emoji-test.txt // This file contains the compressed version of the emoji data from @@ -22,8 +20,8 @@ const emojiMap = require('./emoji_map.json'); // This json file is downloaded from https://github.com/iamcal/emoji-data/ // and is used to correct the sheet coordinates since we're using that repo's sheet const emojiSheetData = require('./emoji_sheet.json'); -const { unicodeToFilename } = require('./unicode_to_filename_s'); -const { unicodeToUnifiedName } = require('./unicode_to_unified_name_s'); +const unicodeToFilename = require('./unicode_to_filename_s'); +const unicodeToUnifiedName = require('./unicode_to_unified_name_s'); // Grabbed from `emoji_utils` to avoid circular dependency function unifiedToNative(unified) { diff --git a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.ts b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.ts index adf4e2bb7b..0a5a4c1d76 100644 --- a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.ts +++ b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.ts @@ -33,11 +33,8 @@ function processEmojiMapData( shortCode?: ShortCodesToEmojiDataKey, ) { const [native, _filename] = emojiMapData; - let filename = emojiMapData[1]; - if (!filename) { - // filename name can be derived from unicodeToFilename - filename = unicodeToFilename(native); - } + // filename name can be derived from unicodeToFilename + const filename = emojiMapData[1] ?? unicodeToFilename(native); unicodeMapping[native] = { shortCode, filename, diff --git a/app/javascript/mastodon/features/emoji/unicode_to_filename_s.js b/app/javascript/mastodon/features/emoji/unicode_to_filename_s.js index 3395c77174..ed65ef2227 100644 --- a/app/javascript/mastodon/features/emoji/unicode_to_filename_s.js +++ b/app/javascript/mastodon/features/emoji/unicode_to_filename_s.js @@ -1,9 +1,6 @@ -/* eslint-disable import/no-commonjs -- - We need to use CommonJS here as its imported into a preval file (`emoji_compressed.js`) */ - // taken from: // https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866 -exports.unicodeToFilename = (str) => { +const unicodeToFilename = (str) => { let result = ''; let charCode = 0; let p = 0; @@ -27,3 +24,5 @@ exports.unicodeToFilename = (str) => { } return result; }; + +export default unicodeToFilename; diff --git a/app/javascript/mastodon/features/emoji/unicode_to_unified_name_s.js b/app/javascript/mastodon/features/emoji/unicode_to_unified_name_s.js index 108b911222..afe532dcfe 100644 --- a/app/javascript/mastodon/features/emoji/unicode_to_unified_name_s.js +++ b/app/javascript/mastodon/features/emoji/unicode_to_unified_name_s.js @@ -1,6 +1,3 @@ -/* eslint-disable import/no-commonjs -- - We need to use CommonJS here as its imported into a preval file (`emoji_compressed.js`) */ - function padLeft(str, num) { while (str.length < num) { str = '0' + str; @@ -9,7 +6,7 @@ function padLeft(str, num) { return str; } -exports.unicodeToUnifiedName = (str) => { +const unicodeToUnifiedName = (str) => { let output = ''; for (let i = 0; i < str.length; i += 2) { @@ -22,3 +19,5 @@ exports.unicodeToUnifiedName = (str) => { return output; }; + +export default unicodeToUnifiedName; diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.jsx b/app/javascript/mastodon/features/getting_started/components/announcements.jsx index ad66d2e5fa..f5f593860f 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.jsx +++ b/app/javascript/mastodon/features/getting_started/components/announcements.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; +import { PureComponent, useCallback, useMemo } from 'react'; import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; @@ -9,8 +9,7 @@ import { withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import TransitionMotion from 'react-motion/lib/TransitionMotion'; -import spring from 'react-motion/lib/spring'; +import { animated, useTransition } from '@react-spring/web'; import ReactSwipeableViews from 'react-swipeable-views'; import elephantUIPlane from '@/images/elephant_ui_plane.svg'; @@ -239,72 +238,76 @@ class Reaction extends ImmutablePureComponent { } return ( - + ); } } -class ReactionsBar extends ImmutablePureComponent { +const ReactionsBar = ({ + announcementId, + reactions, + emojiMap, + addReaction, + removeReaction, +}) => { + const visibleReactions = useMemo(() => reactions.filter(x => x.get('count') > 0).toArray(), [reactions]); - static propTypes = { - announcementId: PropTypes.string.isRequired, - reactions: ImmutablePropTypes.list.isRequired, - addReaction: PropTypes.func.isRequired, - removeReaction: PropTypes.func.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, - }; + const handleEmojiPick = useCallback((emoji) => { + addReaction(announcementId, emoji.native.replaceAll(/:/g, '')); + }, [addReaction, announcementId]); - handleEmojiPick = data => { - const { addReaction, announcementId } = this.props; - addReaction(announcementId, data.native.replace(/:/g, '')); - }; + const transitions = useTransition(visibleReactions, { + from: { + scale: 0, + }, + enter: { + scale: 1, + }, + leave: { + scale: 0, + }, + immediate: reduceMotion, + keys: visibleReactions.map(x => x.get('name')), + }); - willEnter () { - return { scale: reduceMotion ? 1 : 0 }; - } + return ( +
    + {transitions(({ scale }, reaction) => ( + `scale(${s})`) }} + addReaction={addReaction} + removeReaction={removeReaction} + announcementId={announcementId} + emojiMap={emojiMap} + /> + ))} - willLeave () { - return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }; - } - - render () { - const { reactions } = this.props; - const visibleReactions = reactions.filter(x => x.get('count') > 0); - - const styles = visibleReactions.map(reaction => ({ - key: reaction.get('name'), - data: reaction, - style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, - })).toArray(); - - return ( - - {items => ( -
    - {items.map(({ key, data, style }) => ( - - ))} - - {visibleReactions.size < 8 && } />} -
    - )} -
    - ); - } - -} + {visibleReactions.length < 8 && ( + } + /> + )} +
    + ); +}; +ReactionsBar.propTypes = { + announcementId: PropTypes.string.isRequired, + reactions: ImmutablePropTypes.list.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, +}; class Announcement extends ImmutablePureComponent { diff --git a/app/javascript/mastodon/features/picture_in_picture/index.tsx b/app/javascript/mastodon/features/picture_in_picture/index.tsx index 51b72f9725..9bae1b5545 100644 --- a/app/javascript/mastodon/features/picture_in_picture/index.tsx +++ b/app/javascript/mastodon/features/picture_in_picture/index.tsx @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { removePictureInPicture } from 'mastodon/actions/picture_in_picture'; import Audio from 'mastodon/features/audio'; -import Video from 'mastodon/features/video'; +import { Video } from 'mastodon/features/video'; import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions'; import Footer from './components/footer'; @@ -35,6 +35,10 @@ export const PictureInPicture: React.FC = () => { accentColor, } = pipState; + if (!src) { + return null; + } + let player; switch (type) { @@ -42,11 +46,10 @@ export const PictureInPicture: React.FC = () => { player = (