diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index 5da1ec3a24..4d5ed0f25f 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -21,13 +21,12 @@ 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: - - '3000:3000' - - '3035:3035' - - '4000:4000' + - '127.0.0.1:3000:3000' + - '127.0.0.1:3035:3035' + - '127.0.0.1:4000:4000' networks: - external_network - internal_network diff --git a/.dockerignore b/.dockerignore index 9d990ab9ce..41da718049 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,9 +20,3 @@ postgres14 redis elasticsearch chart -.yarn/ -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions diff --git a/.env.production.sample b/.env.production.sample index a311ad5f8d..1faaf5b57c 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -79,9 +79,6 @@ 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 @@ -89,27 +86,3 @@ 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/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..d4930e1f52 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,13 @@ +/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 new file mode 100644 index 0000000000..480b274fad --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,367 @@ +// @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 e638b9c548..8a10676283 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -15,8 +15,6 @@ // to `null` after any other rule set it to something. dependencyDashboardHeader: 'This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. Before approving any upgrade: read the description and comments in the [`renovate.json5` file](https://github.com/mastodon/mastodon/blob/main/.github/renovate.json5).', postUpdateOptions: ['yarnDedupeHighest'], - // 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 @@ -99,13 +97,7 @@ { // Group all eslint-related packages with `eslint` in the same PR matchManagers: ['npm'], - matchPackageNames: [ - 'eslint', - 'eslint-*', - 'typescript-eslint', - '@eslint/*', - 'globals', - ], + matchPackageNames: ['eslint', 'eslint-*', '@typescript-eslint/*'], matchUpdateTypes: ['patch', 'minor'], groupName: 'eslint (non-major)', }, diff --git a/.github/workflows/build-security.yml b/.github/workflows/build-security.yml index d3cb4e5e0a..1e2455d3d9 100644 --- a/.github/workflows/build-security.yml +++ b/.github/workflows/build-security.yml @@ -24,6 +24,8 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon @@ -44,6 +46,8 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon-streaming diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index c596261eb0..1dc0800dbb 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -46,4 +46,4 @@ jobs: - name: Run haml-lint run: | echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" - bin/haml-lint --reporter github + bin/haml-lint --parallel --reporter github diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 13468e7799..621a662387 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -14,7 +14,7 @@ on: - 'tsconfig.json' - '.nvmrc' - '.prettier*' - - 'eslint.config.mjs' + - '.eslint*' - '**/*.js' - '**/*.jsx' - '**/*.ts' @@ -28,7 +28,7 @@ on: - 'tsconfig.json' - '.nvmrc' - '.prettier*' - - 'eslint.config.mjs' + - '.eslint*' - '**/*.js' - '**/*.jsx' - '**/*.ts' @@ -47,7 +47,7 @@ jobs: uses: ./.github/actions/setup-javascript - name: ESLint - run: yarn workspaces foreach --all --parallel run lint:js --max-warnings 0 + run: yarn lint:js --max-warnings 0 - name: Typecheck run: yarn typecheck diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index c4a716e8f9..2e7123cd7e 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -67,6 +67,7 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres + DISABLE_SIMPLECOV: true RAILS_ENV: test BUNDLE_CLEAN: true BUNDLE_FROZEN: true @@ -80,18 +81,6 @@ jobs: - name: Set up Ruby environment uses: ./.github/actions/setup-ruby - - name: Ensure no errors with `db:prepare` - run: | - bin/rails db:drop - bin/rails db:prepare - bin/rails db:migrate - - - name: Ensure no errors with `db:prepare` and SKIP_POST_DEPLOYMENT_MIGRATIONS - run: | - bin/rails db:drop - SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:prepare - bin/rails db:migrate - - name: Test "one step migration" flow run: | bin/rails db:drop diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index fd4c666059..d52896f13c 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -110,7 +110,7 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} + DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }} RAILS_ENV: test ALLOW_NOPAM: true PAM_ENABLED: true @@ -212,7 +212,7 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} + DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }} RAILS_ENV: test ALLOW_NOPAM: true PAM_ENABLED: true @@ -299,6 +299,7 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres + DISABLE_SIMPLECOV: true RAILS_ENV: test BUNDLE_WITH: test ES_ENABLED: false @@ -415,6 +416,7 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres + DISABLE_SIMPLECOV: true RAILS_ENV: test BUNDLE_WITH: test ES_ENABLED: true diff --git a/.nvmrc b/.nvmrc index 744ca17ec0..fb0a135541 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.14 +22.13 diff --git a/.prettierignore b/.prettierignore index 80b4c0159e..6b2f0c1889 100644 --- a/.prettierignore +++ b/.prettierignore @@ -63,7 +63,6 @@ docker-compose.override.yml # 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 diff --git a/.prettierrc.js b/.prettierrc.js index 65ec869c33..af39b253f6 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,4 +1,4 @@ module.exports = { singleQuote: true, jsxSingleQuote: true -}; +} diff --git a/.rubocop.yml b/.rubocop.yml index 1bbba515af..342cf1dcb5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,7 +18,6 @@ inherit_from: - .rubocop/rspec_rails.yml - .rubocop/rspec.yml - .rubocop/style.yml - - .rubocop/i18n.yml - .rubocop/custom.yml - .rubocop_todo.yml - .rubocop/strict.yml @@ -27,10 +26,10 @@ inherit_mode: merge: - Exclude -plugins: - - rubocop-capybara - - rubocop-i18n - - rubocop-performance +require: - rubocop-rails - rubocop-rspec - rubocop-rspec_rails + - rubocop-performance + - rubocop-capybara + - ./lib/linter/rubocop_middle_dot diff --git a/.rubocop/i18n.yml b/.rubocop/i18n.yml deleted file mode 100644 index de395d3a79..0000000000 --- a/.rubocop/i18n.yml +++ /dev/null @@ -1,12 +0,0 @@ -I18n/RailsI18n: - Enabled: true - Exclude: - - 'config/**/*' - - 'db/**/*' - - 'lib/**/*' - - 'spec/**/*' -I18n/GetText: - Enabled: false - -I18n/RailsI18n/DecorateStringFormattingUsingInterpolation: - Enabled: false diff --git a/.rubocop/rails.yml b/.rubocop/rails.yml index bbd172e656..ae31c1f266 100644 --- a/.rubocop/rails.yml +++ b/.rubocop/rails.yml @@ -2,9 +2,6 @@ Rails/BulkChangeTable: Enabled: false # Conflicts with strong_migrations features -Rails/Delegate: - Enabled: false - Rails/FilePath: EnforcedStyle: arguments diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 13fb25d333..14774acde0 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.2. +# using RuboCop version 1.70.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -49,7 +49,7 @@ Style/FetchEnvVar: - 'lib/tasks/repo.rake' # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, Mode, AllowedMethods, AllowedPatterns. +# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns. # SupportedStyles: annotated, template, unannotated # AllowedMethods: redirect Style/FormatStringToken: @@ -62,10 +62,22 @@ Style/FormatStringToken: Style/GuardClause: Enabled: false +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/HashTransformValues: + Exclude: + - 'app/serializers/rest/web_push_subscription_serializer.rb' + - 'app/services/import_service.rb' + +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/MapToHash: + Exclude: + - 'app/models/status.rb' + # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? Style/OptionalBooleanParameter: Exclude: + - 'app/helpers/json_ld_helper.rb' - 'app/lib/admin/system_check/message.rb' - 'app/lib/request.rb' - 'app/lib/webfinger.rb' diff --git a/.ruby-version b/.ruby-version index 6cb9d3dd0d..47b322c971 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.3 +3.4.1 diff --git a/Dockerfile b/Dockerfile index 6620f4c096..588b100107 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,13 +13,13 @@ ARG BASE_REGISTRY="docker.io" # 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"] +ARG RUBY_VERSION="3.4.1" +# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # renovate: datasource=node-version depName=node ARG NODE_MAJOR_VERSION="22" # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] ARG DEBIAN_VERSION="bookworm" -# Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim) +# Node 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 @@ -61,7 +61,7 @@ ENV \ ENV \ # Configure the IP to bind Mastodon to when serving traffic BIND="0.0.0.0" \ - # Use production settings for Yarn, Node.js and related tools + # Use production settings for Yarn, Node and related nodejs based tools NODE_ENV="production" \ # Use production settings for Ruby on Rails RAILS_ENV="production" \ @@ -128,6 +128,13 @@ RUN \ # 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 + ARG TARGETPLATFORM # hadolint ignore=DL3008 @@ -181,12 +188,18 @@ RUN \ libx265-dev \ ; +RUN \ + # Configure Corepack + rm /usr/local/bin/yarn*; \ + corepack enable; \ + corepack prepare --activate; + # 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 +ARG VIPS_VERSION=8.16.0 # 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 @@ -271,37 +284,38 @@ RUN \ # Download and install required Gems bundle install -j"$(nproc)"; -# Create temporary assets build layer from build layer -FROM build AS precompiler +# Create temporary node specific build layer from build layer +FROM build AS yarn ARG TARGETPLATFORM -# 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; +# 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 # 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.js packages + # Install Node packages yarn workspaces focus --production @mastodon/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 +# 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 --from=bundler /opt/mastodon /opt/mastodon/ COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/ +# Copy libvips components to layer for precompiler +COPY --from=libvips /usr/local/libvips/bin /usr/local/bin +COPY --from=libvips /usr/local/libvips/lib /usr/local/lib + +ARG TARGETPLATFORM RUN \ ldconfig; \ diff --git a/Gemfile b/Gemfile index 9e5955e0b8..89648e8cac 100644 --- a/Gemfile +++ b/Gemfile @@ -14,7 +14,6 @@ gem 'haml-rails', '~>2.0' gem 'pg', '~> 1.5' gem 'pghero' -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 'blurhash', '~> 0.1' gem 'fog-core', '<= 2.6.0' @@ -40,7 +39,7 @@ gem 'net-ldap', '~> 0.18' gem 'omniauth', '~> 2.0' gem 'omniauth-cas', '~> 3.0.0.beta.1' -gem 'omniauth_openid_connect', '~> 0.8.0' +gem 'omniauth_openid_connect', '~> 0.6.1' gem 'omniauth-rails_csrf_protection', '~> 1.0' gem 'omniauth-saml', '~> 2.0' @@ -62,7 +61,6 @@ 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' @@ -104,10 +102,10 @@ gem 'rdf-normalize', '~> 0.5' gem 'prometheus_exporter', '~> 2.2', require: false -gem 'opentelemetry-api', '~> 1.5.0' +gem 'opentelemetry-api', '~> 1.4.0' group :opentelemetry do - gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false + gem 'opentelemetry-exporter-otlp', '~> 0.29.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 @@ -118,7 +116,7 @@ group :opentelemetry do 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-rails', '~> 0.35.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 @@ -147,6 +145,9 @@ group :test do # Used to mock environment variables gem 'climate_control' + # Add back helpers functions removed in Rails 5.1 + gem 'rails-controller-testing', '~> 1.0' + # Validate schemas in specs gem 'json-schema', '~> 5.0' @@ -155,7 +156,7 @@ group :test do gem 'shoulda-matchers' - # Coverage formatter for RSpec + # Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false gem 'simplecov', '~> 0.22', require: false gem 'simplecov-lcov', '~> 0.8', require: false @@ -167,14 +168,13 @@ 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 'annotaterb', '~> 4.13', require: false + gem 'annotaterb', '~> 4.13' # Enhanced error message pages for development gem 'better_errors', '~> 2.9' @@ -197,7 +197,7 @@ end group :development, :test do # Interactive Debugging tools - gem 'debug', '~> 1.8', require: false + gem 'debug', '~> 1.8' # Generate fake data values gem 'faker', '~> 3.2' @@ -209,7 +209,7 @@ group :development, :test do gem 'memory_profiler', require: false gem 'ruby-prof', require: false gem 'stackprof', require: false - gem 'test-prof', require: false + gem 'test-prof' # RSpec runner for rails gem 'rspec-rails', '~> 7.0' diff --git a/Gemfile.lock b/Gemfile.lock index f13df0c43f..c11a970bc2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,29 +10,29 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + actioncable (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actionmailbox (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) mail (>= 2.8.0) - actionmailer (8.0.2) - actionpack (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activesupport (= 8.0.2) + actionmailer (8.0.1) + actionpack (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activesupport (= 8.0.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2) - actionview (= 8.0.2) - activesupport (= 8.0.2) + actionpack (8.0.1) + actionview (= 8.0.1) + activesupport (= 8.0.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -40,15 +40,15 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2) - actionpack (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actiontext (8.0.1) + actionpack (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2) - activesupport (= 8.0.2) + actionview (8.0.1) + activesupport (= 8.0.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -58,22 +58,22 @@ GEM activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (8.0.2) - activesupport (= 8.0.2) + activejob (8.0.1) + activesupport (= 8.0.1) globalid (>= 0.3.6) - activemodel (8.0.2) - activesupport (= 8.0.2) - activerecord (8.0.2) - activemodel (= 8.0.2) - activesupport (= 8.0.2) + activemodel (8.0.1) + activesupport (= 8.0.1) + activerecord (8.0.1) + activemodel (= 8.0.1) + activesupport (= 8.0.1) timeout (>= 0.4.0) - activestorage (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activesupport (= 8.0.2) + activestorage (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activesupport (= 8.0.1) marcel (~> 1.0) - activesupport (8.0.2) + activesupport (8.0.1) base64 benchmark (>= 0.3) bigdecimal @@ -90,12 +90,12 @@ GEM public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) - annotaterb (4.14.0) - ast (2.4.3) + annotaterb (4.13.0) + ast (2.4.2) attr_required (1.0.2) - aws-eventstream (1.3.2) - aws-partitions (1.1087.0) - aws-sdk-core (3.215.1) + aws-eventstream (1.3.0) + aws-partitions (1.1032.0) + aws-sdk-core (3.214.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -107,9 +107,9 @@ GEM aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.10.1) aws-eventstream (~> 1, >= 1.0.2) - azure-blob (0.5.7) + azure-blob (0.5.4) rexml base64 (0.2.0) bcp47_spec (0.2.1) @@ -120,13 +120,13 @@ GEM rack (>= 0.9.0) rouge (>= 1.0.0) bigdecimal (3.1.9) - bindata (2.5.1) + bindata (2.5.0) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) blurhash (0.1.8) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (7.0.2) + brakeman (7.0.0) racc browser (6.2.0) brpoplpush-redis_script (0.1.3) @@ -168,9 +168,9 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (1.21.1) + css_parser (1.21.0) addressable - csv (3.3.4) + csv (3.3.2) database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) @@ -194,14 +194,14 @@ GEM devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.0) rpam2 (~> 4.0) - diff-lcs (1.6.1) + diff-lcs (1.5.1) discard (1.4.0) activerecord (>= 4.2, < 9.0) docile (1.4.1) domain_name (0.6.20240107) - doorkeeper (5.8.2) + doorkeeper (5.8.1) railties (>= 5) - dotenv (3.1.8) + dotenv (3.1.7) drb (2.2.1) elasticsearch (7.17.11) elasticsearch-api (= 7.17.11) @@ -217,29 +217,24 @@ GEM htmlentities (~> 4.3.3) launchy (>= 2.1, < 4.0) mail (~> 2.7) - email_validator (2.2.4) - activemodel erubi (1.13.1) et-orbi (1.2.11) tzinfo - excon (1.2.5) - logger + excon (0.112.0) fabrication (2.31.0) faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.13.0) + faraday (2.12.2) 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.4.0) - ffi (1.17.2) + ffi (1.17.1) ffi-compiler (1.3.2) ffi (>= 1.15.5) rake @@ -249,15 +244,15 @@ GEM flatware-rspec (2.3.4) flatware (= 2.3.4) rspec (>= 3.6) - fog-core (2.6.0) + fog-core (2.5.0) builder - excon (~> 1.0) + excon (~> 0.71) formatador (>= 0.2, < 2.0) mime-types fog-json (1.2.0) fog-core multi_json (~> 1.10) - fog-openstack (1.1.5) + fog-openstack (1.1.3) fog-core (~> 2.1) fog-json (>= 1.0) formatador (1.1.0) @@ -266,10 +261,8 @@ GEM raabro (~> 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.25.5) + googleapis-common-protos-types (1.15.0) google-protobuf (>= 3.18, < 5.a) haml (6.3.0) temple (>= 0.8.2) @@ -280,7 +273,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.62.0) + haml_lint (0.59.0) haml (>= 5.0) parallel (~> 1.10) rainbow @@ -305,14 +298,13 @@ GEM domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) - httpclient (2.9.0) - mutex_m + httpclient (2.8.3) httplog (1.7.0) rack (>= 2.0) rainbow (>= 2.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.15) + i18n-tasks (1.0.14) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi @@ -321,14 +313,13 @@ GEM 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) inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.8.0) - irb (1.15.2) + irb (1.15.1) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) @@ -337,15 +328,13 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.10.2) + json (2.9.1) json-canonicalization (1.0.0) - json-jwt (1.16.7) + json-jwt (1.15.3.1) activesupport (>= 4.2) aes_key_wrap - base64 bindata - faraday (~> 2.0) - faraday-follow_redirects + httpclient json-ld (3.3.2) htmlentities (~> 4.3) json-canonicalization (~> 1.0) @@ -361,7 +350,7 @@ GEM addressable (~> 2.8) bigdecimal (~> 3.1) jsonapi-renderer (0.2.2) - jwt (2.10.1) + jwt (2.9.3) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -382,10 +371,9 @@ GEM mime-types terrapin (>= 0.6.0, < 2.0) language_server-protocol (3.17.0.4) - launchy (3.1.1) + launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) - logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) @@ -394,17 +382,10 @@ GEM railties (>= 6.1) rexml link_header (0.0.8) - 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) + llhttp-ffi (0.5.0) ffi-compiler (~> 1.0) rake (~> 13.0) - logger (1.7.0) + logger (1.6.5) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) @@ -423,14 +404,14 @@ GEM redis (>= 3.0.5) matrix (0.4.2) memory_profiler (1.1.0) - mime-types (3.6.2) + mime-types (3.6.0) logger mime-types-data (~> 3.2015) - mime-types-data (3.2025.0408) + mime-types-data (3.2025.0107) mini_mime (1.1.5) mini_portile2 (2.8.8) - minitest (5.25.5) - msgpack (1.8.0) + minitest (5.25.4) + msgpack (1.7.5) multi_json (1.15.0) mutex_m (0.3.0) net-http (0.6.0) @@ -446,17 +427,17 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.7) + nokogiri (1.18.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.10) + oj (3.16.9) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.3) + omniauth (2.1.2) hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection - omniauth-cas (3.0.1) + omniauth-cas (3.0.0) addressable (~> 2.8) nokogiri (~> 1.12) omniauth (~> 2.1) @@ -466,29 +447,27 @@ GEM omniauth-saml (2.2.3) omniauth (~> 2.1) ruby-saml (~> 1.18) - omniauth_openid_connect (0.8.0) + omniauth_openid_connect (0.6.1) omniauth (>= 1.9, < 3) - openid_connect (~> 2.2) - openid_connect (2.3.1) + openid_connect (~> 1.1) + openid_connect (1.4.2) activemodel attr_required (>= 1.0.0) - email_validator - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.16) - mail - rack-oauth2 (~> 2.2) - swd (~> 2.0) + json-jwt (>= 1.15.0) + net-smtp + rack-oauth2 (~> 1.21) + swd (~> 1.3) tzinfo + validate_email validate_url - webfinger (~> 2.0) - openssl (3.3.0) + webfinger (~> 1.2) + openssl (3.2.1) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.5.0) - opentelemetry-common (0.22.0) + opentelemetry-api (1.4.0) + opentelemetry-common (0.21.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.30.0) + opentelemetry-exporter-otlp (0.29.1) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) @@ -501,7 +480,7 @@ GEM opentelemetry-api (~> 1.0) opentelemetry-instrumentation-active_support (~> 0.7) opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-action_pack (0.12.0) + opentelemetry-instrumentation-action_pack (0.11.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-rack (~> 0.21) @@ -519,10 +498,6 @@ GEM 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) @@ -555,45 +530,44 @@ GEM opentelemetry-instrumentation-rack (0.26.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-rails (0.36.0) + opentelemetry-instrumentation-rails (0.35.1) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-action_mailer (~> 0.4.0) - opentelemetry-instrumentation-action_pack (~> 0.12.0) + opentelemetry-instrumentation-action_pack (~> 0.11.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-instrumentation-redis (0.26.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-sidekiq (0.26.1) + opentelemetry-instrumentation-sidekiq (0.26.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-registry (0.4.0) + opentelemetry-registry (0.3.1) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.8.0) + opentelemetry-sdk (1.6.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.11.0) + opentelemetry-semantic_conventions (1.10.1) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) ostruct (0.6.1) - ox (2.14.22) + ox (2.14.21) bigdecimal (>= 3.0) - parallel (1.27.0) - parser (3.3.8.0) + parallel (1.26.3) + parser (3.3.7.0) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) pg (1.5.9) - pghero (3.6.2) + pghero (3.6.1) activerecord (>= 6.1) pp (0.6.2) prettyprint @@ -606,7 +580,6 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - prism (1.4.0) prometheus_exporter (2.2.0) webrick propshaft (1.1.0) @@ -618,9 +591,9 @@ GEM date stringio public_suffix (6.0.1) - puma (6.6.0) + puma (6.5.0) nio4r (~> 2.0) - pundit (2.5.0) + pundit (2.4.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) @@ -629,11 +602,10 @@ GEM rack (>= 1.0, < 4) rack-cors (2.0.2) rack (>= 2.0.0) - rack-oauth2 (2.2.1) + rack-oauth2 (1.21.3) activesupport attr_required - faraday (~> 2.0) - faraday-follow_redirects + httpclient json-jwt (>= 1.11.0) rack (>= 2.1.0) rack-protection (3.2.0) @@ -648,20 +620,24 @@ GEM rackup (1.0.1) rack (< 3) webrick - 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) + rails (8.0.1) + actioncable (= 8.0.1) + actionmailbox (= 8.0.1) + actionmailer (= 8.0.1) + actionpack (= 8.0.1) + actiontext (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activemodel (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) bundler (>= 1.15.0) - railties (= 8.0.2) + railties (= 8.0.1) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -672,9 +648,9 @@ GEM rails-i18n (8.0.1) i18n (>= 0.7, < 2) railties (>= 8.0.0, < 9) - railties (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + railties (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -688,23 +664,23 @@ GEM link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.13.1) + rdoc (6.11.0) psych (>= 4.0.0) - redcarpet (3.6.1) + redcarpet (3.6.0) redis (4.8.1) redis-namespace (1.11.0) redis (>= 4) redlock (1.3.2) redis (>= 3.0.0, < 6.0) regexp_parser (2.10.0) - reline (0.6.1) + reline (0.6.0) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.4.1) + rexml (3.4.0) rotp (6.3.0) rouge (4.5.1) rpam2 (4.0.2) @@ -716,7 +692,7 @@ GEM rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.3) + rspec-core (3.13.2) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) @@ -726,7 +702,7 @@ GEM rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (7.1.1) + rspec-rails (7.1.0) actionpack (>= 7.0) activesupport (>= 7.0) railties (>= 7.0) @@ -734,49 +710,39 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-sidekiq (5.1.0) + rspec-sidekiq (5.0.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) - sidekiq (>= 5, < 9) + sidekiq (>= 5, < 8) rspec-support (3.13.2) - rubocop (1.75.2) + rubocop (1.71.0) json (~> 2.3) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.1.0) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.44.0, < 2.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) 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) + rubocop-ast (1.38.0) + parser (>= 3.3.1.0) + rubocop-capybara (2.21.0) + rubocop (~> 1.41) + rubocop-performance (1.23.1) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.29.1) activesupport (>= 4.2.0) - lint_roller (~> 1.1) rack (>= 1.1) - 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) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rspec (3.4.0) + rubocop (~> 1.61) + rubocop-rspec_rails (2.30.0) + rubocop (~> 1.61) + rubocop-rspec (~> 3, >= 3.0.1) ruby-prof (1.7.1) ruby-progressbar (1.13.0) ruby-saml (1.18.0) @@ -797,7 +763,7 @@ GEM activerecord (>= 4.0.0) railties (>= 4.0.0) securerandom (0.4.1) - selenium-webdriver (4.31.0) + selenium-webdriver (4.28.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -835,29 +801,26 @@ 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) + stoplight (4.1.0) redlock (~> 1.0) - stringio (3.1.6) - strong_migrations (2.3.0) - activerecord (>= 7) - swd (2.0.3) + stringio (3.1.2) + strong_migrations (2.1.0) + activerecord (>= 6.1) + swd (1.3.0) activesupport (>= 3) attr_required (>= 0.0.5) - faraday (~> 2.0) - faraday-follow_redirects + httpclient (>= 2.4) sysexits (1.2.0) temple (0.10.3) - terminal-table (4.0.0) - unicode-display_width (>= 1.1.1, < 4) - terrapin (1.1.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + terrapin (1.0.1) climate_control test-prof (1.4.4) thor (1.3.2) - tilt (2.6.0) + tilt (2.5.0) timeout (0.4.3) - tpm-key_attestation (0.14.0) + tpm-key_attestation (0.12.1) bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) @@ -876,34 +839,34 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.2) + tzinfo-data (1.2025.1) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext unf_ext (0.0.9.1) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-display_width (2.6.0) uri (1.0.3) useragent (0.16.11) + validate_email (0.1.6) + activemodel (>= 3.0) + mail (>= 2.2.5) validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix warden (1.2.9) rack (>= 2.0.9) - webauthn (3.4.0) + webauthn (3.2.2) android_key_attestation (~> 0.3.0) bindata (~> 2.4) cbor (~> 0.5.9) cose (~> 1.1) openssl (>= 2.2) safety_net_attestation (~> 0.4.0) - tpm-key_attestation (~> 0.14.0) - webfinger (2.1.3) + tpm-key_attestation (~> 0.12.0) + webfinger (1.2.0) activesupport - faraday (~> 2.0) - faraday-follow_redirects - webmock (3.25.1) + httpclient (>= 2.4) + webmock (3.24.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -922,7 +885,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.2) + zeitwerk (2.7.1) PLATFORMS ruby @@ -931,7 +894,6 @@ DEPENDENCIES active_model_serializers (~> 0.10) addressable (~> 2.8) annotaterb (~> 4.13) - aws-sdk-core (< 3.216.0) aws-sdk-s3 (~> 1.123) better_errors (~> 2.9) binding_of_caller (~> 1.0) @@ -988,7 +950,6 @@ 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) @@ -1003,9 +964,9 @@ DEPENDENCIES omniauth-cas (~> 3.0.0.beta.1) omniauth-rails_csrf_protection (~> 1.0) omniauth-saml (~> 2.0) - omniauth_openid_connect (~> 0.8.0) - opentelemetry-api (~> 1.5.0) - opentelemetry-exporter-otlp (~> 0.30.0) + omniauth_openid_connect (~> 0.6.1) + opentelemetry-api (~> 1.4.0) + opentelemetry-exporter-otlp (~> 0.29.0) opentelemetry-instrumentation-active_job (~> 0.8.0) opentelemetry-instrumentation-active_model_serializers (~> 0.22.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) @@ -1016,7 +977,7 @@ DEPENDENCIES 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-rails (~> 0.35.0) opentelemetry-instrumentation-redis (~> 0.26.0) opentelemetry-instrumentation-sidekiq (~> 0.26.0) opentelemetry-sdk (~> 1.4) @@ -1035,6 +996,7 @@ DEPENDENCIES rack-cors (~> 2.0) rack-test (~> 2.1) rails (~> 8.0) + rails-controller-testing (~> 1.0) rails-i18n (~> 8.0) rdf-normalize (~> 0.5) redcarpet (~> 3.6) @@ -1046,7 +1008,6 @@ DEPENDENCIES rspec-sidekiq (~> 5.0) rubocop rubocop-capybara - rubocop-i18n rubocop-performance rubocop-rails rubocop-rspec @@ -1085,4 +1046,4 @@ RUBY VERSION ruby 3.4.1p0 BUNDLED WITH - 2.6.8 + 2.6.3 diff --git a/README.md b/README.md index 854e8ac3d9..200d58d8c4 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,123 @@ -NAS is an KMY & Mastodon Fork +# ![kmyblue icon](https://raw.githubusercontent.com/kmycode/mastodon/kb_development/app/javascript/icons/favicon-32x32.png) kmyblue -The following are just a few of the most common features. There are many other minor changes to the specifications. +[![Ruby Testing](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml) -Emoji reactions +! FOR ENGLISH USER ! We do not provide English documentation for kmyblue; we assume that you will use automatic translation software, such as Google, to translate the site. -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は、ActivityPubに接続するSNSの1つである[Mastodon](https://github.com/mastodon/mastodon)のフォークです。創作作家のためのMastodonを目指して開発しました。 -Bookmark classification +kmyblueはフォーク名であり、同時に[サーバー名](https://kmy.blue)でもあります。以下は特に記述がない限り、フォークとしてのkmyblueをさします。 -Set who can search your posts for each post (Searchability) +kmyblueは AGPL ライセンスで公開されているため、どなたでも自由にフォークし、このソースコードを元に自分でサーバーを立てて公開することができます。確かにサーバーkmyblueは創作作家向けの利用規約が設定されていますが、フォークとしてのkmyblueのルールは全くの別物です。いかなるコミュニティにも平等にお使いいただけます。 +kmyblueは、閉鎖的なコミュニティ、あまり目立ちたくないコミュニティには特に強力な機能を提供します。kmyblueはプライバシーを考慮したうえで強力な独自機能を提供するため、汎用サーバーとして利用するにもある程度十分な機能が揃っています。 -Quote posts, modest quotes (references) +テストコード、Lint どちらも動いています。 -Record posts that meet certain conditions such as domains, accounts, and keywords (Subscriptions/Antennas) +### アジェンダ -Send posts to a designated set of followers (Circles) (different from direct messages) +- 利用方法 +- kmyblueの開発方針 +- kmyblueは何でないか +- kmyblueの独自機能 +- 英語のサポートについて -Notification of new posts on lists +## 利用方法 -Exclude posts from people you follow when filtering posts +### インストール方法 -Hide number of followers and followings +[Wiki](https://github.com/kmycode/mastodon/wiki/Installation)を参照してください。 -Automatically delete posts after a specified time has passed +### 開発への参加方法 -Expanding moderation functions +CONTRIBUTING.mdを参照してください。 + +### テスト + +``` +# デバッグ実行(以下のいずれか) +foreman start +DB_USER=postgres DB_PASS=password foreman start + +# 一部を除く全てのテストを行う +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の開発方針 + +### 本家Mastodonへの積極的追従 + +kmyblueは、追加機能を控えめにする代わりに本家Mastodonに積極的に追従を行います。kmyblueの追加機能そのままに、Mastodonの新機能も利用できるよう調整を行います。 + +### ゆるやかな内輪での運用 + +kmyblueは同人向けサーバーとして出発したため、同人作家に需要のある「内輪ノリを外部にできるだけもらさない」という部分に特化しています。 + +「ローカル公開」は、投稿を見せたくない人に見つかりにくくする効果があります。「サークル」は、フォロワーの中でも特に見せたい人だけに見せる効果があります。 +「検索許可」という独自の検索オプションを利用することで、公開投稿の一部だけを検索されにくくするだけでなく、非収載投稿が誰でも自由に検索できるようになります。 + +内輪とは自分のサーバーに限ったものではありません。内輪同士で複数のサーバーを運営するとき、お互いが深く繋がれる「フレンドサーバー」というシステムも用意しています。 + +### 少人数サーバーでの運用 + +kmyblueは、人の少ないサーバーでの運用を考慮して設計しています。そのため、Fedibirdにあるような、人の多いサーバー向けの機能はあまり作っていません。 + +サーバーの負荷については一部度外視している部分があります。たとえば絵文字リアクション機能はサーバーへ著しい負荷をかける場合があります。ただしkmyblueでは、絵文字リアクション機能そのものを無効にしたり、負荷の高いストリーミング処理を無効にする管理者オプションも存在します。 + +もちろん人の多いサーバーでの運用が不便になるような修正は行っていません。人数にかかわらず、そのままお使いいただけます。 + +### 比較的高い防御力 + +kmyblueでは、「Fediverseは将来的に荒むのではないか」「Fediverseは将来的にスパムに溢れるのではないか」を念頭に設計している部分があります。投稿だけでなく絵文字リアクションも対象にした防衛策があります。 + +管理者は「NGワード」「NGルール」機能の利用が可能です。設定を変更することで、一部のモデレーターもこの機能を利用できます。 +利用者は、独自拡張されたフィルター機能、絵文字リアクションのブロックなどを利用できます。 + +ただし防御力の高さは自由を犠牲にします。例えばNGワードが多すぎると、他のサーバーからの投稿が制限され、かつそれに気づきにくくなります。 + +## kmyblueは何でないか + +kmyblueは、企業・政府機関向けに開発されたものではありません。開発者はセキュリティに関する専門知識を有しておらず、高度なセキュリティを求められる機関向けのソフトウェアを制作する能力はありません。また、kmyblueのメンテナは現在1人のみであり、そのメンテナが飽きたら開発がストップするリスクも高いです。Mastodonのような高い信頼性・安全性を保証することはできないので、導入の際はご自身で安全を十分に確認してからお使いになることを強くおすすめします。 +個人サーバーであっても、安定性を強く求める方にはおすすめできません。glitch-socがよりよい選択肢になるでしょう。 + +kmyblueは、Misskeyではありません。Misskeyは「楽しむ」をコンセプトにしていますが、kmyblueはMastodonの思想を受け継ぎ、炎上や喧騒を避けることのできる落ち着いた場所を目指しています。そのため、思想に合わない機能は実装しないか、大幅に弱体化しています。 + +kmyblueは、Fedibirdではありません。Fedibirdは大規模サーバー向けに設定していると思われる機能があり、例えば購読機能がその代表例です。Fedibirdの購読は擬似的なフォロー体験を与えるものですが、本物のフォローではないため、購読対象の投稿が配送されることを確約したものではありません。小規模サーバーだとかえって不便になる機能を、kmyblueは避けています。 + +## kmyblueの独自機能 + +以下に列挙したものはあくまで代表的なものです。これ以外にも、細かい仕様変更などが多数含まれます。 + +- 絵文字リアクション +- ローカル公開(Local Public)(リモートサーバーの連合タイムラインには流れませんが、フォロワーのホームタイムラインには流れます。**ローカル限定とは異なります**) +- ブックマークの分類 +- 自分の投稿を検索できる人を投稿ごとに設定(検索許可・Searchability) +- 投稿の引用、ひかえめな引用(参照) +- ドメイン・アカウント・キーワードなど特定条件を満たした投稿を記録する機能(購読・アンテナ) +- フォロワーの一部を指名して投稿を送る機能(サークル)(ダイレクトメッセージとは異なります) +- リスト新着投稿の通知 +- 投稿のフィルタリングにおいて、自分がフォローしている相手の投稿を除外 +- フォロー・フォロワー数を隠す機能 +- 指定した時間が経過したあとに投稿を自動削除する機能 +- モデレーション機能の拡張 + +## 英語のサポートについて + +kmyblueのメイン開発者である[雪あすか](https://kmy.blue/@askyq)は、英語の読み書きがほとんどできません。そのため、ドキュメントの英語化、海外向け公式アカウントの新設などを行う予定はありません。 + +要望やバグ報告はIssueに書いて構いませんが、Issue画面内の説明やテンプレートはすべて日本語になっています。投稿が難しければ、Discussionに投稿してください。こちらで必要と判断したものは、改めてIssueとして起票します。 + +そのほか開発者へ質問があれば、[@askyq@kmy.blue](https://kmy.blue/@askyq)へ英語のまま送ってください。 + +ただしkmyblueのドキュメント、[@askyq@kmy.blue](https://kmy.blue/@askyq)内のkmyblueフォークに関係する投稿を、許可なく翻訳して公開することは問題ありません。 + +## 開発者のアカウントについて + +kmyblueのメイン開発者である[雪あすか](https://kmy.blue/@askyq)は、用途別にアカウントを分けるようなことはせず、すべての発言を1つのアカウントで行っています。そのため、kmyblueの開発だけでなく、成人向け同人作品の話も混ざっています。 + +このうち、公開範囲「公開」「ローカル公開」「非収載」であるkmyblueフォークの開発に関係する投稿に限り抽出し、翻訳の有無に関係なく公開することを許可します。これはkmyblueフォークの利用者にとって公共性の高いコンテンツであると思われます。これは、日本と欧米では一般的に考えられている児童ポルノの基準が異なり、欧米のサーバーの中にはこのアカウントをフォローしづらいものもあるという懸念を考慮したものです。 diff --git a/app/controllers/admin/announcements/distributions_controller.rb b/app/controllers/admin/announcements/distributions_controller.rb deleted file mode 100644 index 4bd8769834..0000000000 --- a/app/controllers/admin/announcements/distributions_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index d77f931a7f..0000000000 --- a/app/controllers/admin/announcements/previews_controller.rb +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index f2457eb23a..0000000000 --- a/app/controllers/admin/announcements/tests_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -# 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/base_controller.rb b/app/controllers/admin/base_controller.rb index 14338dd293..3dca3a9614 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -7,12 +7,17 @@ module Admin layout 'admin' + before_action :set_cache_headers before_action :set_referrer_policy_header after_action :verify_authorized private + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end + def set_referrer_policy_header response.headers['Referrer-Policy'] = 'same-origin' end diff --git a/app/controllers/admin/fasp/debug/callbacks_controller.rb b/app/controllers/admin/fasp/debug/callbacks_controller.rb deleted file mode 100644 index 28aba5e489..0000000000 --- a/app/controllers/admin/fasp/debug/callbacks_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -# 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 deleted file mode 100644 index 1e1b6dbf3c..0000000000 --- a/app/controllers/admin/fasp/debug_calls_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index 4f1f1271bf..0000000000 --- a/app/controllers/admin/fasp/providers_controller.rb +++ /dev/null @@ -1,47 +0,0 @@ -# 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 deleted file mode 100644 index 52c46c2eb6..0000000000 --- a/app/controllers/admin/fasp/registrations_controller.rb +++ /dev/null @@ -1,23 +0,0 @@ -# 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/software_updates_controller.rb b/app/controllers/admin/software_updates_controller.rb index c9be97eb71..d7b114a100 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.by_version.filter(&:pending?) + @software_updates = SoftwareUpdate.by_version end private diff --git a/app/controllers/admin/terms_of_service/drafts_controller.rb b/app/controllers/admin/terms_of_service/drafts_controller.rb index 0c67eb9df8..02cb05946f 100644 --- a/app/controllers/admin/terms_of_service/drafts_controller.rb +++ b/app/controllers/admin/terms_of_service/drafts_controller.rb @@ -23,7 +23,7 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController 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) + @terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text) end def current_terms_of_service @@ -32,6 +32,6 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController def resource_params params - .expect(terms_of_service: [:text, :changelog, :effective_date]) + .expect(terms_of_service: [:text, :changelog]) end end diff --git a/app/controllers/admin/terms_of_service_controller.rb b/app/controllers/admin/terms_of_service_controller.rb index 10aa5c66ca..f70bfd2071 100644 --- a/app/controllers/admin/terms_of_service_controller.rb +++ b/app/controllers/admin/terms_of_service_controller.rb @@ -3,6 +3,6 @@ class Admin::TermsOfServiceController < Admin::BaseController def index authorize :terms_of_service, :index? - @terms_of_service = TermsOfService.published.first + @terms_of_service = TermsOfService.live.first end end diff --git a/app/controllers/api/fasp/base_controller.rb b/app/controllers/api/fasp/base_controller.rb deleted file mode 100644 index 690f7e419a..0000000000 --- a/app/controllers/api/fasp/base_controller.rb +++ /dev/null @@ -1,81 +0,0 @@ -# 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 deleted file mode 100644 index 794e53f095..0000000000 --- a/app/controllers/api/fasp/debug/v0/callback/responses_controller.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index fecc992fec..0000000000 --- a/app/controllers/api/fasp/registrations_controller.rb +++ /dev/null @@ -1,26 +0,0 @@ -# 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/v1/accounts/endorsements_controller.rb b/app/controllers/api/v1/accounts/endorsements_controller.rb deleted file mode 100644 index 1e21994a90..0000000000 --- a/app/controllers/api/v1/accounts/endorsements_controller.rb +++ /dev/null @@ -1,66 +0,0 @@ -# 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/featured_tags_controller.rb b/app/controllers/api/v1/accounts/featured_tags_controller.rb index f95846366c..0101fb469b 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.unavailable? ? [] : @account.featured_tags + @featured_tags = @account.suspended? ? [] : @account.featured_tags 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 02a45e8758..48f293f47a 100644 --- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb +++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb @@ -1,10 +1,6 @@ # 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 new file mode 100644 index 0000000000..0eb13c048c --- /dev/null +++ b/app/controllers/api/v1/accounts/pins_controller.rb @@ -0,0 +1,30 @@ +# 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_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 46838aeb66..6bef6a3768 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -124,7 +124,7 @@ class Api::V1::AccountsController < Api::BaseController end def account_params - params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code, :date_of_birth) + params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code) end def invite diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index f8d91c5f7f..c97e9720ad 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -1,10 +1,6 @@ # 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! diff --git a/app/controllers/api/v1/instances/terms_of_services_controller.rb b/app/controllers/api/v1/instances/terms_of_services_controller.rb index 0a861dd7bb..e9e8e8ef55 100644 --- a/app/controllers/api/v1/instances/terms_of_services_controller.rb +++ b/app/controllers/api/v1/instances/terms_of_services_controller.rb @@ -5,18 +5,12 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo def show cache_even_if_authenticated! - render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer + render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer 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 + @terms_of_service = TermsOfService.live.first! end end diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index e01267c000..49da75ed28 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -1,9 +1,15 @@ # frozen_string_literal: true -class Api::V1::InstancesController < Api::V2::InstancesController - include DeprecationConcern +class Api::V1::InstancesController < Api::BaseController + skip_before_action :require_authenticated_user!, unless: :limited_federation_mode? + skip_around_action :set_locale - deprecate_api '2022-11-14' + 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! diff --git a/app/controllers/api/v1/lists_controller.rb b/app/controllers/api/v1/lists_controller.rb index b019ab6018..2086bf116d 100644 --- a/app/controllers/api/v1/lists_controller.rb +++ b/app/controllers/api/v1/lists_controller.rb @@ -7,6 +7,10 @@ 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 diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index c427e055ea..5ea26d55bd 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, :destroy] - before_action :check_processing, except: [:create, :destroy] + before_action :set_media_attachment, except: [:create] + before_action :check_processing, except: [:create] def show render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment @@ -25,15 +25,6 @@ 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 @@ -63,8 +54,4 @@ 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/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 1217b70752..534347d019 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -67,8 +67,6 @@ 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 @@ -127,7 +125,7 @@ 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' => !truthy_param?(:delete_media) }) + RemovalWorker.perform_async(@status.id, { 'redraft' => true }) render json: json end diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb index df9346832f..9ba1cef63c 100644 --- a/app/controllers/api/v1/suggestions_controller.rb +++ b/app/controllers/api/v1/suggestions_controller.rb @@ -2,9 +2,6 @@ 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/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index ecac3579fc..10a3442344 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -1,15 +1,11 @@ # 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 = (ENV['MAX_TRENDING_TAGS'] || 10).to_i - - deprecate_api '2022-03-30', only: :index, if: -> { request.path == '/api/v1/trends' } + DEFAULT_TAGS_LIMIT = 10 def index cache_if_unauthenticated! diff --git a/app/controllers/api/v2/instances_controller.rb b/app/controllers/api/v2/instances_controller.rb index 62adf95260..8346e28830 100644 --- a/app/controllers/api/v2/instances_controller.rb +++ b/app/controllers/api/v2/instances_controller.rb @@ -1,16 +1,6 @@ # frozen_string_literal: true -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 - +class Api::V2::InstancesController < Api::V1::InstancesController def show cache_even_if_authenticated! render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance' diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 0b6f5b3af4..34c7599553 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -12,6 +12,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :set_sessions, only: [:edit, :update] before_action :set_strikes, only: [: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 @@ -62,7 +63,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, :date_of_birth) + user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password) end end @@ -138,6 +139,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController set_locale { render :rules } end + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end + def is_flashing_format? # rubocop:disable Naming/PredicateName if params[:action] == 'create' false # Disable flash messages for sign-up diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 5f9f133659..eb8ac38da9 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -174,7 +174,7 @@ class Auth::SessionsController < Devise::SessionsController end def disable_custom_css? - user_params[:disable_css].present? && user_params[:disable_css] == '1' + user_params[:disable_css].present? && user_params[:disable_css] != '0' end def disable_custom_css!(user) diff --git a/app/controllers/concerns/deprecation_concern.rb b/app/controllers/concerns/deprecation_concern.rb deleted file mode 100644 index ad8de724a1..0000000000 --- a/app/controllers/concerns/deprecation_concern.rb +++ /dev/null @@ -1,17 +0,0 @@ -# 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/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index ffe612f468..5f7ef8dd63 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -10,6 +10,8 @@ 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 @@ -32,7 +34,7 @@ module SignatureVerification def signature_key_id signature_params['keyId'] - rescue Mastodon::SignatureVerificationError + rescue SignatureVerificationError nil end @@ -43,17 +45,17 @@ module SignatureVerification def signed_request_actor return @signed_request_actor if defined?(@signed_request_actor) - 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? + 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? verify_signature_strength! verify_body_digest! actor = actor_from_key_id(signature_params['keyId']) - raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil? + raise 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) @@ -66,7 +68,7 @@ module SignatureVerification actor = stoplight_wrapper.run { actor_refresh_key!(actor) } - raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil? + raise 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? @@ -76,7 +78,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 Mastodon::SignatureVerificationError => e + rescue SignatureVerificationError => e fail_with! e.message rescue *Mastodon::HTTP_CONNECTION_ERRORS => e fail_with! "Failed to fetch remote data: #{e.message}" @@ -102,7 +104,7 @@ module SignatureVerification def signature_params @signature_params ||= SignatureParser.parse(request.headers['Signature']) rescue SignatureParser::ParsingError - raise Mastodon::SignatureVerificationError, 'Error parsing signature parameters' + raise SignatureVerificationError, 'Error parsing signature parameters' end def signature_algorithm @@ -114,31 +116,31 @@ module SignatureVerification end def verify_signature_strength! - 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') + 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') end def verify_body_digest! return unless signed_headers.include?('digest') - raise Mastodon::SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest') + raise 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 Mastodon::SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil? + raise 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 Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}" + raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}" end - 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. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32 - raise Mastodon::SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}" + raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}" end def verify_signature(actor, signature, compare_signed_string) @@ -163,13 +165,13 @@ module SignatureVerification "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" end when '(created)' - 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? + 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? "(created): #{signature_params['created']}" when '(expires)' - 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? + 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? "(expires): #{signature_params['expires']}" else @@ -191,7 +193,7 @@ module SignatureVerification expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present? rescue ArgumentError => e - raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}" + raise SignatureVerificationError, "Invalid Date header: #{e.message}" end expires_time ||= created_time + 5.minutes unless created_time.nil? @@ -231,9 +233,9 @@ module SignatureVerification account end rescue Mastodon::PrivateNetworkAddressError => e - raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" + raise 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 Mastodon::SignatureVerificationError, e.message + raise SignatureVerificationError, e.message end def stoplight_wrapper @@ -249,8 +251,8 @@ module SignatureVerification ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false) rescue Mastodon::PrivateNetworkAddressError => e - raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" + raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, Webfinger::Error => e - raise Mastodon::SignatureVerificationError, e.message + raise 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 ec2256aa9c..1d8ee43507 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -46,6 +46,6 @@ module WebAppControllerConcern protected def set_referer_header - response.set_header('Referrer-Policy', Setting.allow_referrer_origin ? 'strict-origin-when-cross-origin' : 'same-origin') + response.set_header('Referrer-Policy', Setting.allow_referrer_origin ? 'origin' : 'same-origin') end end diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb index 07677fd3f3..dd24a1b740 100644 --- a/app/controllers/disputes/base_controller.rb +++ b/app/controllers/disputes/base_controller.rb @@ -8,4 +8,11 @@ class Disputes::BaseController < ApplicationController skip_before_action :require_functional! before_action :authenticate_user! + before_action :set_cache_headers + + private + + 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 d85b017aaa..ca5205d042 100644 --- a/app/controllers/filters/statuses_controller.rb +++ b/app/controllers/filters/statuses_controller.rb @@ -6,6 +6,7 @@ class Filters::StatusesController < ApplicationController before_action :authenticate_user! before_action :set_filter before_action :set_status_filters + before_action :set_cache_headers PER_PAGE = 20 @@ -39,4 +40,8 @@ class Filters::StatusesController < ApplicationController def action_from_button 'remove' if params[:remove] 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 20b8135908..6390e1ef10 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -5,6 +5,7 @@ class FiltersController < ApplicationController before_action :authenticate_user! before_action :set_filter, only: [:edit, :update, :destroy] + before_action :set_cache_headers def index @filters = current_account.custom_filters.includes(:keywords, :statuses).order(:phrase) @@ -49,4 +50,8 @@ class FiltersController < ApplicationController def resource_params 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 + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index fc65333ac4..c4c52cce11 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -6,6 +6,7 @@ class InvitesController < ApplicationController layout 'admin' before_action :authenticate_user! + before_action :set_cache_headers def index authorize :invite, :create? @@ -44,4 +45,8 @@ class InvitesController < ApplicationController def resource_params params.expect(invite: [:max_uses, :expires_in, :autofollow, :comment]) end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index deafedeaef..66e774425d 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -5,6 +5,7 @@ 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) @@ -31,4 +32,8 @@ 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 8b11a519ea..9e541e5e3c 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -6,6 +6,7 @@ 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_cache_headers before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json } @@ -29,6 +30,10 @@ 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 = current_resource_owner.applications_last_used end diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index 7e793fc734..43105d70c8 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -6,6 +6,7 @@ class RelationshipsController < ApplicationController before_action :authenticate_user! before_action :set_accounts, only: :show before_action :set_relationships, only: :show + before_action :set_cache_headers helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship? @@ -65,4 +66,8 @@ class RelationshipsController < ApplicationController 'remove_domains_from_followers' end end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb index 8e39741f89..9785a1b90f 100644 --- a/app/controllers/settings/applications_controller.rb +++ b/app/controllers/settings/applications_controller.rb @@ -2,6 +2,7 @@ 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]) @@ -59,6 +60,12 @@ class Settings::ApplicationsController < Settings::BaseController end def application_params - params.expect(doorkeeper_application: [:name, :redirect_uri, :website, scopes: []]) + params + .expect(doorkeeper_application: [:name, :redirect_uri, :scopes, :website]) + end + + def prepare_scopes + scopes = application_params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil) + params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array end end diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index 7f2279aa8f..188334ac23 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -4,9 +4,14 @@ class Settings::BaseController < ApplicationController layout 'admin' before_action :authenticate_user! + before_action :set_cache_headers private + 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/privacy_extra_controller.rb b/app/controllers/settings/privacy_extra_controller.rb index f1292e644c..cb99c390dd 100644 --- a/app/controllers/settings/privacy_extra_controller.rb +++ b/app/controllers/settings/privacy_extra_controller.rb @@ -8,7 +8,7 @@ class Settings::PrivacyExtraController < 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_extra_path, notice: I18n.t('generic.changes_saved_msg') else render :show diff --git a/app/controllers/severed_relationships_controller.rb b/app/controllers/severed_relationships_controller.rb index 817abebf62..965753a26f 100644 --- a/app/controllers/severed_relationships_controller.rb +++ b/app/controllers/severed_relationships_controller.rb @@ -4,6 +4,7 @@ class SeveredRelationshipsController < ApplicationController layout 'admin' before_action :authenticate_user! + before_action :set_cache_headers before_action :set_event, only: [:following, :followers] @@ -48,4 +49,8 @@ class SeveredRelationshipsController < ApplicationController def acct(account) account.local? ? account.local_username_and_domain : account.acct end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb index a25e544392..583254ec27 100644 --- a/app/controllers/statuses_cleanup_controller.rb +++ b/app/controllers/statuses_cleanup_controller.rb @@ -5,6 +5,7 @@ class StatusesCleanupController < ApplicationController before_action :authenticate_user! before_action :set_policy + before_action :set_cache_headers def show; end @@ -29,4 +30,8 @@ class StatusesCleanupController < ApplicationController def resource_params 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 + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb index 693cdf730f..2a5c2d8826 100644 --- a/app/helpers/json_ld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -163,49 +163,24 @@ module JsonLdHelper end end - # 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: {}) + def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {}) unless id_is_known - json = fetch_resource_without_id_validation(uri, on_behalf_of, raise_on_error: raise_on_error) + json = fetch_resource_without_id_validation(uri, on_behalf_of) 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, raise_on_error: raise_on_error, request_options: request_options) + json = fetch_resource_without_id_validation(uri, on_behalf_of, request_options: request_options) json.present? && json['id'] == uri ? json : nil end - # 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: {}) + def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false, request_options: {}) on_behalf_of ||= Account.representative build_request(uri, on_behalf_of, options: request_options).perform do |response| - raise Mastodon::UnexpectedResponseError, response if !response_successful?(response) && ( - raise_on_error == :all || - (!response_error_unsalvageable?(response) && raise_on_error == :temporary) - ) + raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response) end diff --git a/app/inputs/date_of_birth_input.rb b/app/inputs/date_of_birth_input.rb deleted file mode 100644 index 131234b02e..0000000000 --- a/app/inputs/date_of_birth_input.rb +++ /dev/null @@ -1,31 +0,0 @@ -# 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/entrypoints/embed.tsx b/app/javascript/entrypoints/embed.tsx index 6c091e4d07..cb62727563 100644 --- a/app/javascript/entrypoints/embed.tsx +++ b/app/javascript/entrypoints/embed.tsx @@ -1,7 +1,7 @@ import './public-path'; import { createRoot } from 'react-dom/client'; -import { afterInitialRender } from 'mastodon/hooks/useRenderSignal'; +import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal'; import { start } from '../mastodon/common'; import { Status } from '../mastodon/features/standalone/status'; diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 9374d6b2d1..0560e76628 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -68,7 +68,7 @@ function loaded() { if (id) message = localeData[id]; - message ??= defaultMessage as string; + if (!message) message = defaultMessage as string; const messageFormat = new IntlMessageFormat(message, locale); return messageFormat.format(values) as string; diff --git a/app/javascript/mastodon/hooks/useHovering.ts b/app/javascript/hooks/useHovering.ts similarity index 100% rename from app/javascript/mastodon/hooks/useHovering.ts rename to app/javascript/hooks/useHovering.ts diff --git a/app/javascript/mastodon/hooks/useLinks.ts b/app/javascript/hooks/useLinks.ts similarity index 77% rename from app/javascript/mastodon/hooks/useLinks.ts rename to app/javascript/hooks/useLinks.ts index abaa108f6b..c99f3f4199 100644 --- a/app/javascript/mastodon/hooks/useLinks.ts +++ b/app/javascript/hooks/useLinks.ts @@ -14,9 +14,6 @@ const isHashtagClick = (element: HTMLAnchorElement) => element.textContent?.[0] === '#' || element.previousSibling?.textContent?.endsWith('#'); -const isFeaturedHashtagClick = (element: HTMLAnchorElement) => - isHashtagClick(element) && element.href.includes('/tagged/'); - export const useLinks = () => { const history = useHistory(); const dispatch = useAppDispatch(); @@ -32,19 +29,6 @@ export const useLinks = () => { [history], ); - const handleFeaturedHashtagClick = useCallback( - (element: HTMLAnchorElement) => { - const { textContent, href } = element; - - if (!textContent) return; - - const url = new URL(href); - - history.push(url.pathname); - }, - [history], - ); - const handleMentionClick = useCallback( async (element: HTMLAnchorElement) => { const result = await dispatch(openURL({ url: element.href })); @@ -77,15 +61,12 @@ export const useLinks = () => { if (isMentionClick(target)) { e.preventDefault(); void handleMentionClick(target); - } else if (isFeaturedHashtagClick(target)) { - e.preventDefault(); - handleFeaturedHashtagClick(target); } else if (isHashtagClick(target)) { e.preventDefault(); handleHashtagClick(target); } }, - [handleMentionClick, handleFeaturedHashtagClick, handleHashtagClick], + [handleMentionClick, handleHashtagClick], ); return handleClick; diff --git a/app/javascript/mastodon/hooks/useRenderSignal.ts b/app/javascript/hooks/useRenderSignal.ts similarity index 100% rename from app/javascript/mastodon/hooks/useRenderSignal.ts rename to app/javascript/hooks/useRenderSignal.ts diff --git a/app/javascript/mastodon/hooks/useSearchParam.ts b/app/javascript/hooks/useSearchParam.ts similarity index 100% rename from app/javascript/mastodon/hooks/useSearchParam.ts rename to app/javascript/hooks/useSearchParam.ts diff --git a/app/javascript/mastodon/hooks/useSelectableClick.ts b/app/javascript/hooks/useSelectableClick.ts similarity index 100% rename from app/javascript/mastodon/hooks/useSelectableClick.ts rename to app/javascript/hooks/useSelectableClick.ts diff --git a/app/javascript/mastodon/hooks/useTimeout.ts b/app/javascript/hooks/useTimeout.ts similarity index 100% rename from app/javascript/mastodon/hooks/useTimeout.ts rename to app/javascript/hooks/useTimeout.ts diff --git a/app/javascript/icons/android-chrome-144x144.png b/app/javascript/icons/android-chrome-144x144.png old mode 100644 new mode 100755 index 698fb4a260..d636e94c43 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 100644 new mode 100755 index 2b6b632648..4a2681ffb9 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 100644 new mode 100755 index 51e3849a26..8fab493ede 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 100644 new mode 100755 index 925f69c4fc..335d012db1 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 100644 new mode 100755 index 9d256a83cb..02b1e6fced 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 100644 new mode 100755 index bcfe7475d0..43cf411b8c 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 100644 new mode 100755 index bffacfb699..1856b80c7c 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 100644 new mode 100755 index 16679d5731..335008bf85 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 100644 new mode 100755 index 9ade87cf32..d1cb095822 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 100644 new mode 100755 index 8ec371eb27..c2a2d516ef 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 100644 new mode 100755 index e1563f51e5..218b415439 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 100644 new mode 100755 index e9a5f5b0e5..be53bc7c10 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 100644 new mode 100755 index 698fb4a260..cbb055732f 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 100644 new mode 100755 index 0cc93cc288..3a7975c054 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 100644 new mode 100755 index 9bbbf53120..25be4eb5f5 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 100644 new mode 100755 index 329b803b91..dc0e9bc20b 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 deleted file mode 100644 index 2b6b632648..0000000000 Binary files a/app/javascript/icons/apple-touch-icon-192x192.png and /dev/null differ diff --git a/app/javascript/icons/apple-touch-icon-256x256.png b/app/javascript/icons/apple-touch-icon-256x256.png deleted file mode 100644 index 51e3849a26..0000000000 Binary files a/app/javascript/icons/apple-touch-icon-256x256.png and /dev/null differ diff --git a/app/javascript/icons/apple-touch-icon-36x36.png b/app/javascript/icons/apple-touch-icon-36x36.png deleted file mode 100644 index 925f69c4fc..0000000000 Binary files a/app/javascript/icons/apple-touch-icon-36x36.png and /dev/null differ diff --git a/app/javascript/icons/apple-touch-icon-384x384.png b/app/javascript/icons/apple-touch-icon-384x384.png deleted file mode 100644 index 9d256a83cb..0000000000 Binary files a/app/javascript/icons/apple-touch-icon-384x384.png and /dev/null differ diff --git a/app/javascript/icons/apple-touch-icon-48x48.png b/app/javascript/icons/apple-touch-icon-48x48.png deleted file mode 100644 index bcfe7475d0..0000000000 Binary files a/app/javascript/icons/apple-touch-icon-48x48.png and /dev/null differ diff --git a/app/javascript/icons/apple-touch-icon-512x512.png b/app/javascript/icons/apple-touch-icon-512x512.png deleted file mode 100644 index bffacfb699..0000000000 Binary files a/app/javascript/icons/apple-touch-icon-512x512.png and /dev/null differ diff --git a/app/javascript/icons/apple-touch-icon-57x57.png b/app/javascript/icons/apple-touch-icon-57x57.png old mode 100644 new mode 100755 index e00e142c64..bb0dc957cd 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 100644 new mode 100755 index 011285b564..9143a0bf07 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 100644 new mode 100755 index 16679d5731..2b7d19484c 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 100644 new mode 100755 index 83c8748876..0985e33bcb 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 deleted file mode 100644 index 9ade87cf32..0000000000 Binary files a/app/javascript/icons/apple-touch-icon-96x96.png and /dev/null differ diff --git a/app/javascript/icons/favicon-16x16.png b/app/javascript/icons/favicon-16x16.png old mode 100644 new mode 100755 index 7f865cfe96..1326ba0462 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 100644 new mode 100755 index 7f865cfe96..f5058cb0a5 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 100644 new mode 100755 index 7f865cfe96..6253d054c7 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 index df2a0226f8..18c92dfb7d 100755 Binary files a/app/javascript/images/archetypes/booster.png 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 index e37f98aab2..8e1d6451b0 100755 Binary files a/app/javascript/images/archetypes/lurker.png 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 index 9d4e2177c5..2afd3c72e1 100755 Binary files a/app/javascript/images/archetypes/oracle.png 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 index 9fe6281af0..b838fccdd6 100755 Binary files a/app/javascript/images/archetypes/pollster.png 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 index 6c6325b9f1..b298d4221c 100755 Binary files a/app/javascript/images/archetypes/replier.png and b/app/javascript/images/archetypes/replier.png differ diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index d821381ce0..3d0e8b8c90 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -142,13 +142,6 @@ 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']); diff --git a/app/javascript/mastodon/actions/alerts.ts b/app/javascript/mastodon/actions/alerts.ts index 4fd293e252..a521f3ef35 100644 --- a/app/javascript/mastodon/actions/alerts.ts +++ b/app/javascript/mastodon/actions/alerts.ts @@ -1,11 +1,14 @@ import { defineMessages } from 'react-intl'; - -import { createAction } from '@reduxjs/toolkit'; +import type { MessageDescriptor } from 'react-intl'; import { AxiosError } from 'axios'; import type { AxiosResponse } from 'axios'; -import type { Alert } from 'mastodon/models/alert'; +interface Alert { + title: string | MessageDescriptor; + message: string | MessageDescriptor; + values?: Record; +} interface ApiErrorResponse { error?: string; @@ -27,13 +30,24 @@ const messages = defineMessages({ }, }); -export const dismissAlert = createAction<{ key: number }>('alerts/dismiss'); +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 clearAlerts = createAction('alerts/clear'); +export const dismissAlert = (alert: Alert) => ({ + type: ALERT_DISMISS, + alert, +}); -export const showAlert = createAction>('alerts/show'); +export const clearAlert = () => ({ + type: ALERT_CLEAR, +}); -const ignoreAlert = createAction('alerts/ignore'); +export const showAlert = (alert: Alert) => ({ + type: ALERT_SHOW, + alert, +}); export const showAlertForError = (error: unknown, skipNotFound = false) => { if (error instanceof AxiosError && error.response) { @@ -42,7 +56,7 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => { // Skip these errors as they are reflected in the UI if (skipNotFound && (status === 404 || status === 410)) { - return ignoreAlert(); + return { type: ALERT_NOOP }; } // Rate limit errors @@ -62,9 +76,9 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => { }); } - // An aborted request, e.g. due to reloading the browser window, is not really an error + // An aborted request, e.g. due to reloading the browser window, it not really error if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) { - return ignoreAlert(); + return { type: ALERT_NOOP }; } console.error(error); diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js index 279ec1bef7..727f800af3 100644 --- a/app/javascript/mastodon/actions/domain_blocks.js +++ b/app/javascript/mastodon/actions/domain_blocks.js @@ -12,6 +12,14 @@ 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)); @@ -71,6 +79,80 @@ 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/dropdown_menu.ts b/app/javascript/mastodon/actions/dropdown_menu.ts index d9d395ba33..3694df1ae0 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: number; + id: string; keyboard: boolean; - scrollKey?: string; + scrollKey: string; }>('dropdownMenu/open'); -export const closeDropdownMenu = createAction<{ id: number }>( +export const closeDropdownMenu = createAction<{ id: string }>( 'dropdownMenu/close', ); diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index fc165b1a1f..380190a910 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[status.poll.id])); + pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id))); } if (status.card) { diff --git a/app/javascript/mastodon/actions/polls.ts b/app/javascript/mastodon/actions/polls.ts index 65a96e8f62..28f729394b 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[poll.id])], + polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))], }), ); }, diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js index 7659fb5f98..fbd89f9d4b 100644 --- a/app/javascript/mastodon/actions/settings.js +++ b/app/javascript/mastodon/actions/settings.js @@ -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))); -}, 2000, { leading: true, trailing: true }); +}, 5000, { 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 5064e65e7b..40ead34782 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -148,7 +148,7 @@ export function deleteStatus(id, withRedraft = false) { dispatch(deleteStatusRequest(id)); - api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => { + api().delete(`/api/v1/statuses/${id}`).then(response => { dispatch(deleteStatusSuccess(id)); dispatch(deleteFromTimelines(id)); dispatch(importFetchedAccount(response.data.account)); diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js new file mode 100644 index 0000000000..6e0c95288a --- /dev/null +++ b/app/javascript/mastodon/actions/tags.js @@ -0,0 +1,81 @@ +import api, { getLinks } from '../api'; + +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 fetchFollowedHashtags = () => (dispatch) => { + dispatch(fetchFollowedHashtagsRequest()); + + api().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().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, + }; +} diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index a41b058d2c..f0663ded40 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -1,9 +1,4 @@ -import type { - AxiosError, - AxiosResponse, - Method, - RawAxiosRequestHeaders, -} from 'axios'; +import type { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios'; import axios from 'axios'; import LinkHeader from 'http-link-header'; @@ -46,7 +41,7 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => { // eslint-disable-next-line import/no-default-export export default function api(withAuthorization = true) { - const instance = axios.create({ + return axios.create({ transitional: { clarifyTimeoutError: true, }, @@ -65,22 +60,6 @@ export default function api(withAuthorization = true) { }, ], }); - - 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; diff --git a/app/javascript/mastodon/api/domain_blocks.ts b/app/javascript/mastodon/api/domain_blocks.ts deleted file mode 100644 index 4e153b0ee9..0000000000 --- a/app/javascript/mastodon/api/domain_blocks.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 index 764e8daab2..ec9146fb34 100644 --- a/app/javascript/mastodon/api/instance.ts +++ b/app/javascript/mastodon/api/instance.ts @@ -4,12 +4,8 @@ import type { 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 apiGetTermsOfService = () => + apiRequestGet('v1/instance/terms_of_service'); export const apiGetPrivacyPolicy = () => apiRequestGet('v1/instance/privacy_policy'); diff --git a/app/javascript/mastodon/api/tags.ts b/app/javascript/mastodon/api/tags.ts index 4b111def81..2cb802800c 100644 --- a/app/javascript/mastodon/api/tags.ts +++ b/app/javascript/mastodon/api/tags.ts @@ -1,4 +1,4 @@ -import api, { getLinks, apiRequestPost, apiRequestGet } from 'mastodon/api'; +import { apiRequestPost, apiRequestGet } from 'mastodon/api'; import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; export const apiGetTag = (tagId: string) => @@ -9,15 +9,3 @@ export const apiFollowTag = (tagId: string) => 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/instance.ts b/app/javascript/mastodon/api_types/instance.ts index 3a29684b70..ead9774515 100644 --- a/app/javascript/mastodon/api_types/instance.ts +++ b/app/javascript/mastodon/api_types/instance.ts @@ -1,7 +1,5 @@ export interface ApiTermsOfServiceJSON { - effective_date: string; - effective: boolean; - succeeded_by: string | null; + updated_at: string; content: string; } diff --git a/app/javascript/mastodon/api_types/polls.ts b/app/javascript/mastodon/api_types/polls.ts index 891a2faba7..275ca29fd7 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 | null; + voters_count: number; options: ApiPollOptionJSON[]; emojis: ApiCustomEmojiJSON[]; diff --git a/app/javascript/mastodon/components/account.tsx b/app/javascript/mastodon/components/account.tsx index c6c2204085..f5b28ecaaa 100644 --- a/app/javascript/mastodon/components/account.tsx +++ b/app/javascript/mastodon/components/account.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react'; -import type React from 'react'; -import { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; @@ -14,19 +13,18 @@ import { muteAccount, unmuteAccount, } from 'mastodon/actions/accounts'; -import { openModal } from 'mastodon/actions/modal'; import { initMuteModal } from 'mastodon/actions/mutes'; import { Avatar } from 'mastodon/components/avatar'; import { Button } from 'mastodon/components/button'; import { FollowersCounter } from 'mastodon/components/counters'; import { DisplayName } from 'mastodon/components/display_name'; -import { Dropdown } from 'mastodon/components/dropdown_menu'; import { FollowButton } from 'mastodon/components/follow_button'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { ShortNumber } from 'mastodon/components/short_number'; import { Skeleton } from 'mastodon/components/skeleton'; import { VerifiedBadge } from 'mastodon/components/verified_badge'; -import type { MenuItem } from 'mastodon/models/dropdown_menu'; +import DropdownMenu from 'mastodon/containers/dropdown_menu_container'; +import { me } from 'mastodon/initial_state'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; const messages = defineMessages({ @@ -49,14 +47,6 @@ const messages = defineMessages({ mute: { id: 'account.mute_short', defaultMessage: 'Mute' }, block: { id: 'account.block_short', defaultMessage: 'Block' }, more: { id: 'status.more', defaultMessage: 'More' }, - addToLists: { - id: 'account.add_or_remove_from_list', - defaultMessage: 'Add or Remove from lists', - }, - openOriginalPage: { - id: 'account.open_original_page', - defaultMessage: 'Open original page', - }, }); export const Account: React.FC<{ @@ -82,7 +72,6 @@ export const Account: React.FC<{ const account = useAppSelector((state) => state.accounts.get(id)); const relationship = useAppSelector((state) => state.relationships.get(id)); const dispatch = useAppDispatch(); - const accountUrl = account?.url; const handleBlock = useCallback(() => { if (relationship?.blocking) { @@ -100,62 +89,13 @@ export const Account: React.FC<{ } }, [dispatch, id, account, relationship]); - const menu = useMemo(() => { - let arr: MenuItem[] = []; + const handleMuteNotifications = useCallback(() => { + dispatch(muteAccount(id, true)); + }, [dispatch, id]); - if (defaultAction === 'mute') { - const handleMuteNotifications = () => { - dispatch(muteAccount(id, true)); - }; - - const handleUnmuteNotifications = () => { - dispatch(muteAccount(id, false)); - }; - - arr = [ - { - text: intl.formatMessage( - relationship?.muting_notifications - ? messages.unmute_notifications - : messages.mute_notifications, - ), - action: relationship?.muting_notifications - ? handleUnmuteNotifications - : handleMuteNotifications, - }, - ]; - } else if (defaultAction !== 'block') { - const handleAddToLists = () => { - dispatch( - openModal({ - modalType: 'LIST_ADDER', - modalProps: { - accountId: id, - }, - }), - ); - }; - - arr = [ - { - text: intl.formatMessage(messages.addToLists), - action: handleAddToLists, - }, - ]; - - if (accountUrl) { - arr.unshift( - { - text: intl.formatMessage(messages.openOriginalPage), - href: accountUrl, - }, - null, - ); - } - } - - return arr; - }, [dispatch, intl, id, accountUrl, relationship, defaultAction]); + const handleUnmuteNotifications = useCallback(() => { + dispatch(muteAccount(id, false)); + }, [dispatch, id]); if (hidden) { return ( @@ -166,46 +106,73 @@ export const Account: React.FC<{ ); } - let button: React.ReactNode, dropdown: React.ReactNode; + let buttons; - if (menu.length > 0) { - dropdown = ( - - ); - } + if (account && account.id !== me && relationship) { + const { requested, blocking, muting } = relationship; - if (defaultAction === 'block') { - button = ( - - )} - - - ); -}; - -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 index 701cfbe8b4..466c5cf1bc 100644 --- a/app/javascript/mastodon/components/alt_text_badge.tsx +++ b/app/javascript/mastodon/components/alt_text_badge.tsx @@ -8,7 +8,7 @@ import type { UsePopperOptions, } from 'react-overlays/esm/usePopper'; -import { useSelectableClick } from 'mastodon/hooks/useSelectableClick'; +import { useSelectableClick } from '@/hooks/useSelectableClick'; const offset = [0, 4] as OffsetValue; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx index db422f47ce..6c1e0aaec1 100644 --- a/app/javascript/mastodon/components/animated_number.tsx +++ b/app/javascript/mastodon/components/animated_number.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; -import { animated, useSpring, config } from '@react-spring/web'; +import { TransitionMotion, spring } from 'react-motion'; import { reduceMotion } from '../initial_state'; @@ -11,49 +11,53 @@ interface Props { } export const AnimatedNumber: React.FC = ({ value }) => { const [previousValue, setPreviousValue] = useState(value); - const direction = value > previousValue ? -1 : 1; + const [direction, setDirection] = useState<1 | -1>(1); - 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], + 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], ); - // 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 ( - - - - - {value !== previousValue && ( - - - + + {(items) => ( + + {items.map(({ key, data, style }) => ( + 0 ? 'absolute' : 'static', + transform: `translateY(${(style.y ?? 0) * 100}%)`, + }} + > + + + ))} + )} - + ); }; diff --git a/app/javascript/mastodon/components/avatar.tsx b/app/javascript/mastodon/components/avatar.tsx index a2dc0b782e..f61d9676de 100644 --- a/app/javascript/mastodon/components/avatar.tsx +++ b/app/javascript/mastodon/components/avatar.tsx @@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'; import classNames from 'classnames'; -import { useHovering } from 'mastodon/hooks/useHovering'; +import { useHovering } from 'mastodon/../hooks/useHovering'; import { autoPlayGif } from 'mastodon/initial_state'; import type { Account } from 'mastodon/models/account'; diff --git a/app/javascript/mastodon/components/avatar_overlay.tsx b/app/javascript/mastodon/components/avatar_overlay.tsx index 0bd33fea69..f98cfcc38b 100644 --- a/app/javascript/mastodon/components/avatar_overlay.tsx +++ b/app/javascript/mastodon/components/avatar_overlay.tsx @@ -1,7 +1,8 @@ -import { useHovering } from 'mastodon/hooks/useHovering'; -import { autoPlayGif } from 'mastodon/initial_state'; import type { Account } from 'mastodon/models/account'; +import { useHovering } from '../../hooks/useHovering'; +import { autoPlayGif } from '../initial_state'; + interface Props { account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there diff --git a/app/javascript/mastodon/components/copy_icon_button.tsx b/app/javascript/mastodon/components/copy_icon_button.jsx similarity index 62% rename from app/javascript/mastodon/components/copy_icon_button.tsx rename to app/javascript/mastodon/components/copy_icon_button.jsx index 29f5f34430..0c3c6c290b 100644 --- a/app/javascript/mastodon/components/copy_icon_button.tsx +++ b/app/javascript/mastodon/components/copy_icon_button.jsx @@ -1,36 +1,29 @@ +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: React.FC<{ - title: string; - value: string; - className: string; -}> = ({ title, value, className }) => { +export const CopyIconButton = ({ title, value, className }) => { const [copied, setCopied] = useState(false); - const dispatch = useAppDispatch(); + const dispatch = useDispatch(); const handleClick = useCallback(() => { - void navigator.clipboard.writeText(value); + navigator.clipboard.writeText(value); setCopied(true); dispatch(showAlert({ message: messages.copied })); - setTimeout(() => { - setCopied(false); - }, 700); + setTimeout(() => setCopied(false), 700); }, [setCopied, value, dispatch]); return ( @@ -38,8 +31,13 @@ export const CopyIconButton: React.FC<{ 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/copy_paste_text.tsx b/app/javascript/mastodon/components/copy_paste_text.tsx index e6eba765ab..f888acd0f7 100644 --- a/app/javascript/mastodon/components/copy_paste_text.tsx +++ b/app/javascript/mastodon/components/copy_paste_text.tsx @@ -5,8 +5,8 @@ import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; +import { useTimeout } from 'mastodon/../hooks/useTimeout'; import { Icon } from 'mastodon/components/icon'; -import { useTimeout } from 'mastodon/hooks/useTimeout'; export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => { const inputRef = useRef(null); diff --git a/app/javascript/mastodon/components/counters.tsx b/app/javascript/mastodon/components/counters.tsx index 151b25a3f7..35b0ad8d60 100644 --- a/app/javascript/mastodon/components/counters.tsx +++ b/app/javascript/mastodon/components/counters.tsx @@ -1,4 +1,4 @@ -import type React from 'react'; +import 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 0ccffac482..aa64f0f8c3 100644 --- a/app/javascript/mastodon/components/domain.tsx +++ b/app/javascript/mastodon/components/domain.tsx @@ -1,15 +1,24 @@ import { useCallback } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { defineMessages, useIntl } 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 { Button } from './button'; +import { IconButton } from './icon_button'; + +const messages = defineMessages({ + unblockDomain: { + id: 'account.unblock_domain', + defaultMessage: 'Unblock domain {domain}', + }, +}); export const Domain: React.FC<{ domain: string; }> = ({ domain }) => { + const intl = useIntl(); const dispatch = useAppDispatch(); const handleDomainUnblock = useCallback(() => { @@ -18,17 +27,20 @@ export const Domain: React.FC<{ return (
-
- {domain} -
+
+ + {domain} + -
- +
); diff --git a/app/javascript/mastodon/components/dropdown_menu.jsx b/app/javascript/mastodon/components/dropdown_menu.jsx new file mode 100644 index 0000000000..f2b3cf90a2 --- /dev/null +++ b/app/javascript/mastodon/components/dropdown_menu.jsx @@ -0,0 +1,345 @@ +import PropTypes from 'prop-types'; +import { PureComponent, cloneElement, Children } from 'react'; + +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { supportsPassiveEvents } from 'detect-passive-events'; +import Overlay from 'react-overlays/Overlay'; + +import { CircularProgress } from 'mastodon/components/circular_progress'; +import { WithRouterPropTypes } from 'mastodon/utils/react_router'; + +import { IconButton } from './icon_button'; + +const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; +let id = 0; + +class DropdownMenu extends PureComponent { + + static propTypes = { + items: PropTypes.array.isRequired, + loading: PropTypes.bool, + scrollable: PropTypes.bool, + onClose: PropTypes.func.isRequired, + style: PropTypes.object, + openedViaKeyboard: PropTypes.bool, + renderItem: PropTypes.func, + renderHeader: PropTypes.func, + onItemClick: PropTypes.func.isRequired, + }; + + static defaultProps = { + style: {}, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + e.stopPropagation(); + e.preventDefault(); + } + }; + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, { capture: true }); + document.addEventListener('keydown', this.handleKeyDown, { capture: true }); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + + if (this.focusedItem && this.props.openedViaKeyboard) { + this.focusedItem.focus({ preventScroll: true }); + } + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, { capture: true }); + document.removeEventListener('keydown', this.handleKeyDown, { capture: true }); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + }; + + setFocusRef = c => { + this.focusedItem = c; + }; + + handleKeyDown = e => { + const items = Array.from(this.node.querySelectorAll('a, button')); + const index = items.indexOf(document.activeElement); + let element = null; + + switch(e.key) { + case 'ArrowDown': + element = items[index+1] || items[0]; + break; + case 'ArrowUp': + element = items[index-1] || items[items.length-1]; + break; + case 'Tab': + if (e.shiftKey) { + element = items[index-1] || items[items.length-1]; + } else { + element = items[index+1] || items[0]; + } + break; + case 'Home': + element = items[0]; + break; + case 'End': + element = items[items.length-1]; + break; + case 'Escape': + this.props.onClose(); + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + }; + + handleItemKeyPress = e => { + if (e.key === 'Enter' || e.key === ' ') { + this.handleClick(e); + } + }; + + handleClick = e => { + const { onItemClick } = this.props; + onItemClick(e); + }; + + renderItem = (option, i) => { + if (option === null) { + return
  • ; + } + + const { text, href = '#', target = '_blank', method, dangerous } = option; + + return ( +
  • + + {text} + +
  • + ); + }; + + render () { + const { items, scrollable, renderHeader, loading } = this.props; + + let renderItem = this.props.renderItem || this.renderItem; + + return ( +
    + {loading && ( + + )} + + {!loading && renderHeader && ( +
    + {renderHeader(items)} +
    + )} + + {!loading && ( +
      + {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))} +
    + )} +
    + ); + } + +} + +class Dropdown extends PureComponent { + + static propTypes = { + children: PropTypes.node, + icon: PropTypes.string, + iconComponent: PropTypes.func, + items: PropTypes.array.isRequired, + loading: PropTypes.bool, + size: PropTypes.number, + title: PropTypes.string, + disabled: PropTypes.bool, + scrollable: PropTypes.bool, + active: PropTypes.bool, + status: ImmutablePropTypes.map, + isUserTouching: PropTypes.func, + onOpen: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + openDropdownId: PropTypes.number, + openedViaKeyboard: PropTypes.bool, + renderItem: PropTypes.func, + renderHeader: PropTypes.func, + onItemClick: PropTypes.func, + ...WithRouterPropTypes + }; + + static defaultProps = { + title: 'Menu', + }; + + state = { + id: id++, + }; + + handleClick = ({ type }) => { + if (this.state.id === this.props.openDropdownId) { + this.handleClose(); + } else { + this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click'); + } + }; + + handleClose = () => { + if (this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + this.activeElement = null; + } + this.props.onClose(this.state.id); + }; + + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + }; + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + }; + + handleKeyPress = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleClick(e); + e.stopPropagation(); + e.preventDefault(); + break; + } + }; + + handleItemClick = e => { + const { onItemClick } = this.props; + const i = Number(e.currentTarget.getAttribute('data-index')); + const item = this.props.items[i]; + + this.handleClose(); + + if (typeof onItemClick === 'function') { + e.preventDefault(); + onItemClick(item, i); + } else if (item && typeof item.action === 'function') { + e.preventDefault(); + item.action(); + } else if (item && item.to) { + e.preventDefault(); + this.props.history.push(item.to); + } + }; + + setTargetRef = c => { + this.target = c; + }; + + findTarget = () => { + return this.target?.buttonRef?.current ?? this.target; + }; + + componentWillUnmount = () => { + if (this.state.id === this.props.openDropdownId) { + this.handleClose(); + } + }; + + close = () => { + this.handleClose(); + }; + + render () { + const { + icon, + iconComponent, + items, + size, + title, + disabled, + loading, + scrollable, + openDropdownId, + openedViaKeyboard, + children, + renderItem, + renderHeader, + active, + } = this.props; + + const open = this.state.id === openDropdownId; + + const button = children ? cloneElement(Children.only(children), { + onClick: this.handleClick, + onMouseDown: this.handleMouseDown, + onKeyDown: this.handleButtonKeyDown, + onKeyPress: this.handleKeyPress, + ref: this.setTargetRef, + }) : ( + + ); + + return ( + <> + {button} + + + {({ props, arrowProps, placement }) => ( +
    +
    +
    + +
    +
    + )} + + + ); + } + +} + +export default withRouter(Dropdown); diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx deleted file mode 100644 index 0f9ab5b1cc..0000000000 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ /dev/null @@ -1,551 +0,0 @@ -import { - useState, - useEffect, - useRef, - useCallback, - cloneElement, - Children, -} from 'react'; - -import classNames from 'classnames'; -import { Link } from 'react-router-dom'; - -import type { Map as ImmutableMap } from 'immutable'; - -import Overlay from 'react-overlays/Overlay'; -import type { - OffsetValue, - UsePopperOptions, -} from 'react-overlays/esm/usePopper'; - -import { fetchRelationships } from 'mastodon/actions/accounts'; -import { - openDropdownMenu, - closeDropdownMenu, -} from 'mastodon/actions/dropdown_menu'; -import { openModal, closeModal } from 'mastodon/actions/modal'; -import { CircularProgress } from 'mastodon/components/circular_progress'; -import { isUserTouching } from 'mastodon/is_mobile'; -import type { - MenuItem, - ActionMenuItem, - ExternalLinkMenuItem, -} from 'mastodon/models/dropdown_menu'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; - -import type { IconProp } from './icon'; -import { IconButton } from './icon_button'; - -let id = 0; - -const isMenuItem = (item: unknown): item is MenuItem => { - if (item === null) { - return true; - } - - return typeof item === 'object' && 'text' in item; -}; - -const isActionItem = (item: unknown): item is ActionMenuItem => { - if (!item || !isMenuItem(item)) { - return false; - } - - return 'action' in item; -}; - -const isExternalLinkItem = (item: unknown): item is ExternalLinkMenuItem => { - if (!item || !isMenuItem(item)) { - return false; - } - - return 'href' in item; -}; - -type RenderItemFn = ( - item: Item, - index: number, - handlers: { - onClick: (e: React.MouseEvent) => void; - onKeyUp: (e: React.KeyboardEvent) => void; - }, -) => React.ReactNode; - -type ItemClickFn = (item: Item, index: number) => void; - -type RenderHeaderFn = (items: Item[]) => React.ReactNode; - -interface DropdownMenuProps { - items?: Item[]; - loading?: boolean; - scrollable?: boolean; - onClose: () => void; - openedViaKeyboard: boolean; - renderItem?: RenderItemFn; - renderHeader?: RenderHeaderFn; - onItemClick?: ItemClickFn; -} - -export const DropdownMenu = ({ - items, - loading, - scrollable, - onClose, - openedViaKeyboard, - renderItem, - renderHeader, - onItemClick, -}: DropdownMenuProps) => { - const nodeRef = useRef(null); - const focusedItemRef = useRef(null); - - useEffect(() => { - const handleDocumentClick = (e: MouseEvent) => { - if ( - e.target instanceof Node && - nodeRef.current && - !nodeRef.current.contains(e.target) - ) { - onClose(); - e.stopPropagation(); - e.preventDefault(); - } - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (!nodeRef.current) { - return; - } - - const items = Array.from(nodeRef.current.querySelectorAll('a, button')); - const index = document.activeElement - ? items.indexOf(document.activeElement) - : -1; - - let element: Element | undefined; - - switch (e.key) { - case 'ArrowDown': - element = items[index + 1] ?? items[0]; - break; - case 'ArrowUp': - element = items[index - 1] ?? items[items.length - 1]; - break; - case 'Tab': - if (e.shiftKey) { - element = items[index - 1] ?? items[items.length - 1]; - } else { - element = items[index + 1] ?? items[0]; - } - break; - case 'Home': - element = items[0]; - break; - case 'End': - element = items[items.length - 1]; - break; - case 'Escape': - onClose(); - break; - } - - if (element && element instanceof HTMLElement) { - element.focus(); - e.preventDefault(); - e.stopPropagation(); - } - }; - - document.addEventListener('click', handleDocumentClick, { capture: true }); - document.addEventListener('keydown', handleKeyDown, { capture: true }); - - if (focusedItemRef.current && openedViaKeyboard) { - focusedItemRef.current.focus({ preventScroll: true }); - } - - return () => { - document.removeEventListener('click', handleDocumentClick, { - capture: true, - }); - document.removeEventListener('keydown', handleKeyDown, { capture: true }); - }; - }, [onClose, openedViaKeyboard]); - - const handleFocusedItemRef = useCallback( - (c: HTMLAnchorElement | HTMLButtonElement | null) => { - focusedItemRef.current = c as HTMLElement; - }, - [], - ); - - const handleItemClick = useCallback( - (e: React.MouseEvent | React.KeyboardEvent) => { - const i = Number(e.currentTarget.getAttribute('data-index')); - const item = items?.[i]; - - onClose(); - - if (!item) { - return; - } - - if (typeof onItemClick === 'function') { - e.preventDefault(); - onItemClick(item, i); - } else if (isActionItem(item)) { - e.preventDefault(); - item.action(); - } - }, - [onClose, onItemClick, items], - ); - - const handleItemKeyUp = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - handleItemClick(e); - } - }, - [handleItemClick], - ); - - const nativeRenderItem = (option: Item, i: number) => { - if (!isMenuItem(option)) { - return null; - } - - if (option === null) { - return
  • ; - } - - const { text, dangerous } = option; - - let element: React.ReactElement; - - if (isActionItem(option)) { - element = ( - - ); - } else if (isExternalLinkItem(option)) { - element = ( - - {text} - - ); - } else { - element = ( - - {text} - - ); - } - - return ( -
  • - {element} -
  • - ); - }; - - const renderItemMethod = renderItem ?? nativeRenderItem; - - return ( -
    - {(loading || !items) && } - - {!loading && renderHeader && items && ( -
    - {renderHeader(items)} -
    - )} - - {!loading && items && ( -
      - {items.map((option, i) => - renderItemMethod(option, i, { - onClick: handleItemClick, - onKeyUp: handleItemKeyUp, - }), - )} -
    - )} -
    - ); -}; - -interface DropdownProps { - children?: React.ReactElement; - icon?: string; - iconComponent?: IconProp; - items?: Item[]; - loading?: boolean; - title?: string; - disabled?: boolean; - scrollable?: boolean; - active?: boolean; - scrollKey?: string; - status?: ImmutableMap; - renderItem?: RenderItemFn; - renderHeader?: RenderHeaderFn; - onOpen?: () => void; - onItemClick?: ItemClickFn; -} - -const offset = [5, 5] as OffsetValue; -const popperConfig = { strategy: 'fixed' } as UsePopperOptions; - -export const Dropdown = ({ - children, - icon, - iconComponent, - items, - loading, - title = 'Menu', - disabled, - scrollable, - active, - status, - renderItem, - renderHeader, - onOpen, - onItemClick, - scrollKey, -}: DropdownProps) => { - const dispatch = useAppDispatch(); - const openDropdownId = useAppSelector((state) => state.dropdownMenu.openId); - const openedViaKeyboard = useAppSelector( - (state) => state.dropdownMenu.keyboard, - ); - const [currentId] = useState(id++); - const open = currentId === openDropdownId; - const activeElement = useRef(null); - const targetRef = useRef(null); - - const handleClose = useCallback(() => { - if (activeElement.current) { - activeElement.current.focus({ preventScroll: true }); - activeElement.current = null; - } - - dispatch( - closeModal({ - modalType: 'ACTIONS', - ignoreFocus: false, - }), - ); - - dispatch(closeDropdownMenu({ id: currentId })); - }, [dispatch, currentId]); - - const handleItemClick = useCallback( - (e: React.MouseEvent | React.KeyboardEvent) => { - const i = Number(e.currentTarget.getAttribute('data-index')); - const item = items?.[i]; - - handleClose(); - - if (!item) { - return; - } - - if (typeof onItemClick === 'function') { - e.preventDefault(); - onItemClick(item, i); - } else if (isActionItem(item)) { - e.preventDefault(); - item.action(); - } - }, - [handleClose, onItemClick, items], - ); - - const handleClick = useCallback( - (e: React.MouseEvent | React.KeyboardEvent) => { - const { type } = e; - - if (open) { - handleClose(); - } else { - onOpen?.(); - - if (status) { - dispatch(fetchRelationships([status.getIn(['account', 'id'])])); - } - - if (isUserTouching()) { - dispatch( - openModal({ - modalType: 'ACTIONS', - modalProps: { - status, - actions: items, - onClick: handleItemClick, - }, - }), - ); - } else { - dispatch( - openDropdownMenu({ - id: currentId, - keyboard: type !== 'click', - scrollKey, - }), - ); - } - } - }, - [ - dispatch, - currentId, - scrollKey, - onOpen, - handleItemClick, - open, - status, - items, - handleClose, - ], - ); - - const handleMouseDown = useCallback(() => { - if (!open && document.activeElement instanceof HTMLElement) { - activeElement.current = document.activeElement; - } - }, [open]); - - const handleButtonKeyDown = useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case ' ': - case 'Enter': - handleMouseDown(); - break; - } - }, - [handleMouseDown], - ); - - const handleKeyPress = useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case ' ': - case 'Enter': - handleClick(e); - e.stopPropagation(); - e.preventDefault(); - break; - } - }, - [handleClick], - ); - - useEffect(() => { - return () => { - if (currentId === openDropdownId) { - handleClose(); - } - }; - }, [currentId, openDropdownId, handleClose]); - - let button: React.ReactElement; - - if (children) { - button = cloneElement(Children.only(children), { - onClick: handleClick, - onMouseDown: handleMouseDown, - onKeyDown: handleButtonKeyDown, - onKeyPress: handleKeyPress, - ref: targetRef, - }); - } else if (icon && iconComponent) { - button = ( - - ); - } else { - return null; - } - - return ( - <> - {button} - - - {({ props, arrowProps, placement }) => ( -
    -
    -
    - - -
    -
    - )} - - - ); -}; diff --git a/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js b/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js new file mode 100644 index 0000000000..726fee9076 --- /dev/null +++ b/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; + +import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_menu'; +import { fetchHistory } from 'mastodon/actions/history'; +import DropdownMenu from 'mastodon/components/dropdown_menu'; + +/** + * + * @param {import('mastodon/store').RootState} state + * @param {*} props + */ +const mapStateToProps = (state, { statusId }) => ({ + openDropdownId: state.dropdownMenu.openId, + openedViaKeyboard: state.dropdownMenu.keyboard, + items: state.getIn(['history', statusId, 'items']), + loading: state.getIn(['history', statusId, 'loading']), +}); + +const mapDispatchToProps = (dispatch, { statusId }) => ({ + + onOpen (id, onItemClick, keyboard) { + dispatch(fetchHistory(statusId)); + dispatch(openDropdownMenu({ id, keyboard })); + }, + + onClose (id) { + dispatch(closeDropdownMenu({ id })); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/javascript/mastodon/components/edited_timestamp/index.jsx b/app/javascript/mastodon/components/edited_timestamp/index.jsx new file mode 100644 index 0000000000..fbf14ec4bd --- /dev/null +++ b/app/javascript/mastodon/components/edited_timestamp/index.jsx @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { openModal } from 'mastodon/actions/modal'; +import InlineAccount from 'mastodon/components/inline_account'; +import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; + +import DropdownMenu from './containers/dropdown_menu_container'; + +const mapDispatchToProps = (dispatch, { statusId }) => ({ + + onItemClick (index) { + dispatch(openModal({ + modalType: 'COMPARE_HISTORY', + modalProps: { index, statusId }, + })); + }, + +}); + +class EditedTimestamp extends PureComponent { + + static propTypes = { + statusId: PropTypes.string.isRequired, + timestamp: PropTypes.string.isRequired, + intl: PropTypes.object.isRequired, + onItemClick: PropTypes.func.isRequired, + }; + + handleItemClick = (item, i) => { + const { onItemClick } = this.props; + onItemClick(i); + }; + + renderHeader = items => { + return ( + + ); + }; + + renderItem = (item, index, { onClick, onKeyPress }) => { + const formattedDate = ; + const formattedName = ; + + const label = item.get('original') ? ( + + ) : ( + + ); + + return ( +
  • + +
  • + ); + }; + + render () { + const { timestamp, intl, statusId } = this.props; + + return ( + + + + ); + } + +} + +export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp)); diff --git a/app/javascript/mastodon/components/edited_timestamp/index.tsx b/app/javascript/mastodon/components/edited_timestamp/index.tsx deleted file mode 100644 index 4a33210199..0000000000 --- a/app/javascript/mastodon/components/edited_timestamp/index.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useCallback } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import type { Map as ImmutableMap, List as ImmutableList } from 'immutable'; - -import { fetchHistory } from 'mastodon/actions/history'; -import { openModal } from 'mastodon/actions/modal'; -import { Dropdown } from 'mastodon/components/dropdown_menu'; -import { FormattedDateWrapper } from 'mastodon/components/formatted_date'; -import InlineAccount from 'mastodon/components/inline_account'; -import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; - -type HistoryItem = ImmutableMap; - -export const EditedTimestamp: React.FC<{ - statusId: string; - timestamp: string; -}> = ({ statusId, timestamp }) => { - const dispatch = useAppDispatch(); - const items = useAppSelector( - (state) => - ( - state.history.getIn([statusId, 'items']) as - | ImmutableList - | undefined - )?.toArray() as HistoryItem[], - ); - const loading = useAppSelector( - (state) => state.history.getIn([statusId, 'loading']) as boolean, - ); - - const handleOpen = useCallback(() => { - dispatch(fetchHistory(statusId)); - }, [dispatch, statusId]); - - const handleItemClick = useCallback( - (_item: HistoryItem, index: number) => { - dispatch( - openModal({ - modalType: 'COMPARE_HISTORY', - modalProps: { index, statusId }, - }), - ); - }, - [dispatch, statusId], - ); - - const renderHeader = useCallback((items: HistoryItem[]) => { - return ( - - ); - }, []); - - const renderItem = useCallback( - ( - item: HistoryItem, - index: number, - { - onClick, - onKeyUp, - }: { - onClick: React.MouseEventHandler; - onKeyUp: React.KeyboardEventHandler; - }, - ) => { - const formattedDate = ( - - ); - const formattedName = ( - - ); - - const label = (item.get('original') as boolean) ? ( - - ) : ( - - ); - - return ( -
  • - -
  • - ); - }, - [], - ); - - return ( - - items={items} - loading={loading} - renderItem={renderItem} - scrollable - renderHeader={renderHeader} - onOpen={handleOpen} - onItemClick={handleItemClick} - > - - - ); -}; diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index f21ad60240..083736d7fb 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -16,7 +16,8 @@ const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, - editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, + mutual: { id: 'account.mutual', defaultMessage: 'Mutual' }, + edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, }); export const FollowButton: React.FC<{ @@ -54,7 +55,7 @@ export const FollowButton: React.FC<{ ); } - if (!relationship || !accountId) return; + if (!relationship) return; if (accountId === me) { return; @@ -72,9 +73,15 @@ export const FollowButton: React.FC<{ if (!signedIn) { label = intl.formatMessage(messages.follow); } else if (accountId === me) { - label = intl.formatMessage(messages.editProfile); + label = intl.formatMessage(messages.edit_profile); } else if (!relationship) { label = ; + } else if ( + relationship.following && + isShowItem('relationships') && + relationship.followed_by + ) { + label = intl.formatMessage(messages.mutual); } else if (relationship.following || relationship.requested) { label = intl.formatMessage(messages.unfollow); } else if (relationship.followed_by && isShowItem('relationships')) { diff --git a/app/javascript/mastodon/components/formatted_date.tsx b/app/javascript/mastodon/components/formatted_date.tsx deleted file mode 100644 index cc927b0873..0000000000 --- a/app/javascript/mastodon/components/formatted_date.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { ComponentProps } from 'react'; - -import { FormattedDate } from 'react-intl'; - -export const FormattedDateWrapper = ( - props: ComponentProps & { className?: string }, -) => ( - - {(date) => ( - - )} - -); - -const tryIsoString = (date?: string | number | Date): string => { - if (!date) { - return ''; - } - try { - return new Date(date).toISOString(); - } catch { - return date.toString(); - } -}; diff --git a/app/javascript/mastodon/components/gif.tsx b/app/javascript/mastodon/components/gif.tsx index 1cc0881a5a..8fbcb8c76b 100644 --- a/app/javascript/mastodon/components/gif.tsx +++ b/app/javascript/mastodon/components/gif.tsx @@ -1,4 +1,4 @@ -import { useHovering } from 'mastodon/hooks/useHovering'; +import { useHovering } from '@/hooks/useHovering'; import { autoPlayGif } from 'mastodon/initial_state'; export const GIF: React.FC<{ diff --git a/app/javascript/mastodon/components/hashtag.tsx b/app/javascript/mastodon/components/hashtag.tsx index 346c95183f..f3d5cc1f2e 100644 --- a/app/javascript/mastodon/components/hashtag.tsx +++ b/app/javascript/mastodon/components/hashtag.tsx @@ -102,11 +102,10 @@ export interface HashtagProps { description?: React.ReactNode; history?: number[]; name: string; - people?: number; + people: number; to: string; uses?: number; withGraph?: boolean; - children?: React.ReactNode; } export const Hashtag: React.FC = ({ @@ -118,7 +117,6 @@ export const Hashtag: React.FC = ({ className, description, withGraph = true, - children, }) => (
    @@ -153,14 +151,12 @@ export const Hashtag: React.FC = ({ 0)} + data={history ? history : Array.from(Array(7)).map(() => 0)} >
    )} - - {children &&
    {children}
    }
    ); diff --git a/app/javascript/mastodon/components/hashtag_bar.tsx b/app/javascript/mastodon/components/hashtag_bar.tsx index ce8f17ddb9..9e1d74bb74 100644 --- a/app/javascript/mastodon/components/hashtag_bar.tsx +++ b/app/javascript/mastodon/components/hashtag_bar.tsx @@ -20,7 +20,6 @@ export type StatusLike = Record<{ contentHTML: string; media_attachments: List; spoiler_text?: string; - account: Record<{ id: string }>; }>; function normalizeHashtag(hashtag: string) { @@ -196,36 +195,19 @@ export function getHashtagBarForStatus(status: StatusLike) { return { statusContentProps, - hashtagBar: ( - - ), + hashtagBar: , }; } -export function getFeaturedHashtagBar( - accountId: string, - acct: string, - tags: string[], -) { - return ( - - ); +export function getFeaturedHashtagBar(acct: string, tags: string[]) { + return ; } const HashtagBar: React.FC<{ hashtags: string[]; - accountId: string; acct?: string; defaultExpanded?: boolean; -}> = ({ hashtags, accountId, acct, defaultExpanded }) => { +}> = ({ hashtags, acct, defaultExpanded }) => { const [expanded, setExpanded] = useState(false); const handleClick = useCallback(() => { setExpanded(true); @@ -246,7 +228,6 @@ const HashtagBar: React.FC<{ #{hashtag} diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx index 38c3306f30..057ef1aaed 100644 --- a/app/javascript/mastodon/components/hover_card_controller.tsx +++ b/app/javascript/mastodon/components/hover_card_controller.tsx @@ -8,8 +8,8 @@ import type { UsePopperOptions, } from 'react-overlays/esm/usePopper'; +import { useTimeout } from 'mastodon/../hooks/useTimeout'; import { HoverCardAccount } from 'mastodon/components/hover_card_account'; -import { useTimeout } from 'mastodon/hooks/useTimeout'; const offset = [-12, 4] as OffsetValue; const enterDelay = 750; diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx index 7e0b3e7a22..b7cac35960 100644 --- a/app/javascript/mastodon/components/icon_button.tsx +++ b/app/javascript/mastodon/components/icon_button.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, forwardRef } from 'react'; +import { PureComponent, createRef } from 'react'; import classNames from 'classnames'; @@ -15,110 +15,101 @@ interface Props { onMouseDown?: React.MouseEventHandler; onKeyDown?: React.KeyboardEventHandler; onKeyPress?: React.KeyboardEventHandler; - active?: boolean; + active: boolean; expanded?: boolean; style?: React.CSSProperties; activeStyle?: React.CSSProperties; - disabled?: boolean; + disabled: boolean; inverted?: boolean; - animate?: boolean; - overlay?: boolean; - tabIndex?: number; + animate: boolean; + overlay: boolean; + tabIndex: number; counter?: number; href?: string; - ariaHidden?: boolean; + ariaHidden: boolean; data_id?: string; } +interface States { + activate: boolean; + deactivate: boolean; +} +export class IconButton extends PureComponent { + buttonRef = createRef(); -export const IconButton = forwardRef( - ( - { + static defaultProps = { + active: false, + disabled: false, + animate: false, + overlay: false, + tabIndex: 0, + ariaHidden: false, + }; + + state = { + activate: false, + deactivate: false, + }; + + UNSAFE_componentWillReceiveProps(nextProps: Props) { + if (!nextProps.animate) return; + + if (this.props.active && !nextProps.active) { + this.setState({ activate: false, deactivate: true }); + } else if (!this.props.active && nextProps.active) { + this.setState({ activate: true, deactivate: false }); + } + } + + handleClick: React.MouseEventHandler = (e) => { + e.preventDefault(); + + if (!this.props.disabled && this.props.onClick != null) { + this.props.onClick(e); + } + }; + + handleKeyPress: React.KeyboardEventHandler = (e) => { + if (this.props.onKeyPress && !this.props.disabled) { + this.props.onKeyPress(e); + } + }; + + handleMouseDown: React.MouseEventHandler = (e) => { + if (!this.props.disabled && this.props.onMouseDown) { + this.props.onMouseDown(e); + } + }; + + handleKeyDown: React.KeyboardEventHandler = (e) => { + if (!this.props.disabled && this.props.onKeyDown) { + this.props.onKeyDown(e); + } + }; + + render() { + const style = { + ...this.props.style, + ...(this.props.active ? this.props.activeStyle : {}), + }; + + const { + active, className, + disabled, expanded, icon, iconComponent, inverted, + overlay, + tabIndex, title, counter, href, - style, - activeStyle, - onClick, - onKeyDown, - onKeyPress, - onMouseDown, - active = false, - disabled = false, - animate = false, - overlay = false, - tabIndex = 0, - ariaHidden = false, - data_id = undefined, - }, - buttonRef, - ) => { - const [activate, setActivate] = useState(false); - const [deactivate, setDeactivate] = useState(false); + ariaHidden, + data_id, + } = this.props; - useEffect(() => { - if (!animate) { - return; - } - - if (activate && !active) { - setActivate(false); - setDeactivate(true); - } else if (!activate && active) { - setActivate(true); - setDeactivate(false); - } - }, [setActivate, setDeactivate, animate, active, activate]); - - const handleClick: React.MouseEventHandler = useCallback( - (e) => { - e.preventDefault(); - - if (!disabled) { - onClick?.(e); - } - }, - [disabled, onClick], - ); - - const handleKeyPress: React.KeyboardEventHandler = - useCallback( - (e) => { - if (!disabled) { - onKeyPress?.(e); - } - }, - [disabled, onKeyPress], - ); - - const handleMouseDown: React.MouseEventHandler = - useCallback( - (e) => { - if (!disabled) { - onMouseDown?.(e); - } - }, - [disabled, onMouseDown], - ); - - const handleKeyDown: React.KeyboardEventHandler = - useCallback( - (e) => { - if (!disabled) { - onKeyDown?.(e); - } - }, - [disabled, onKeyDown], - ); - - const buttonStyle = { - ...style, - ...(active ? activeStyle : {}), - }; + const { activate, deactivate } = this.state; const classes = classNames(className, 'icon-button', { active, @@ -157,20 +148,18 @@ export const IconButton = forwardRef( aria-hidden={ariaHidden} title={title} className={classes} - onClick={handleClick} - onMouseDown={handleMouseDown} - onKeyDown={handleKeyDown} - onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated - style={buttonStyle} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} + style={style} tabIndex={tabIndex} disabled={disabled} data-id={data_id} - ref={buttonRef} + ref={this.buttonRef} > {contents} ); - }, -); - -IconButton.displayName = 'IconButton'; + } +} diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx index 12cf381e5e..fd8aa59b01 100644 --- a/app/javascript/mastodon/components/media_gallery.jsx +++ b/app/javascript/mastodon/components/media_gallery.jsx @@ -12,7 +12,6 @@ import { debounce } from 'lodash'; import { AltTextBadge } from 'mastodon/components/alt_text_badge'; import { Blurhash } from 'mastodon/components/blurhash'; -import { SpoilerButton } from 'mastodon/components/spoiler_button'; import { formatTime } from 'mastodon/features/video'; import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state'; @@ -39,7 +38,6 @@ class Item extends PureComponent { state = { loaded: false, - error: false, }; handleMouseEnter = (e) => { @@ -83,10 +81,6 @@ class Item extends PureComponent { this.setState({ loaded: true }); }; - handleImageError = () => { - this.setState({ error: true }); - }; - render () { const { attachment, lang, index, size, standalone, displayWidth, visible } = this.props; @@ -170,7 +164,6 @@ class Item extends PureComponent { lang={lang} style={{ objectPosition: `${x}% ${y}%` }} onLoad={this.handleImageLoad} - onError={this.handleImageError} /> ); @@ -206,7 +199,7 @@ class Item extends PureComponent { } return ( -
    +
    ); } + if (uncached) { + spoilerButton = ( + + ); + } else if (!visible) { + spoilerButton = ( + + ); + } + const rowClass = (size === 5 || size === 6 || size === 9 || size === 10 || size === 11 || size === 12) ? 'media-gallery--row3' : (size === 7 || size === 8 || size === 13 || size === 14 || size === 15 || size === 16) ? 'media-gallery--row4' : 'media-gallery--row2'; @@ -354,7 +366,11 @@ class MediaGallery extends PureComponent {
    {children} - {(!visible || uncached) && } + {(!visible || uncached) && ( +
    + {spoilerButton} +
    + )} {(visible && !uncached) && (
    diff --git a/app/javascript/mastodon/components/navigation_portal.tsx b/app/javascript/mastodon/components/navigation_portal.tsx index d3ac8baa6e..08f91ce18a 100644 --- a/app/javascript/mastodon/components/navigation_portal.tsx +++ b/app/javascript/mastodon/components/navigation_portal.tsx @@ -1,6 +1,25 @@ +import { Switch, Route } from 'react-router-dom'; + +import AccountNavigation from 'mastodon/features/account/navigation'; import Trends from 'mastodon/features/getting_started/containers/trends_container'; import { showTrends } from 'mastodon/initial_state'; +const DefaultNavigation: React.FC = () => (showTrends ? : null); + export const NavigationPortal: React.FC = () => ( -
    {showTrends && }
    +
    + + + + + + + + + +
    ); diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx new file mode 100644 index 0000000000..1326131009 --- /dev/null +++ b/app/javascript/mastodon/components/poll.jsx @@ -0,0 +1,248 @@ +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 deleted file mode 100644 index 6692f674d4..0000000000 --- a/app/javascript/mastodon/components/poll.tsx +++ /dev/null @@ -1,337 +0,0 @@ -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/remote_hint.tsx b/app/javascript/mastodon/components/remote_hint.tsx deleted file mode 100644 index 772aa805db..0000000000 --- a/app/javascript/mastodon/components/remote_hint.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { FormattedMessage } from 'react-intl'; - -import { useAppSelector } from 'mastodon/store'; - -import { TimelineHint } from './timeline_hint'; - -interface RemoteHintProps { - accountId?: string; -} - -export const RemoteHint: React.FC = ({ accountId }) => { - const account = useAppSelector((state) => - accountId ? state.accounts.get(accountId) : undefined, - ); - const domain = account?.acct ? account.acct.split('@')[1] : undefined; - if ( - !account || - !account.url || - account.acct !== account.username || - !domain - ) { - return null; - } - - return ( - - } - label={ - {domain} }} - /> - } - /> - ); -}; diff --git a/app/javascript/mastodon/components/router.tsx b/app/javascript/mastodon/components/router.tsx index 815b4b59ab..558d0307e7 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 type React from 'react'; +import React from 'react'; import { Router as OriginalRouter, useHistory } from 'react-router'; diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 93ed201a07..d463245233 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -81,7 +81,6 @@ class ScrollableList extends PureComponent { bindToDocument: PropTypes.bool, preventScroll: PropTypes.bool, footer: PropTypes.node, - className: PropTypes.string, }; static defaultProps = { @@ -326,7 +325,7 @@ class ScrollableList extends PureComponent { }; render () { - const { children, scrollKey, className, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = Children.count(children); @@ -337,9 +336,9 @@ class ScrollableList extends PureComponent { if (showLoading) { scrollableArea = (
    - {prepend} - -
    +
    + {prepend} +
    @@ -351,9 +350,9 @@ class ScrollableList extends PureComponent { } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) { scrollableArea = (
    - {prepend} +
    + {prepend} -
    {loadPending} {Children.map(this.props.children, (child, index) => ( diff --git a/app/javascript/mastodon/components/spoiler_button.tsx b/app/javascript/mastodon/components/spoiler_button.tsx deleted file mode 100644 index bf84ffd04d..0000000000 --- a/app/javascript/mastodon/components/spoiler_button.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -interface Props { - hidden?: boolean; - sensitive: boolean; - uncached?: boolean; - matchedFilters?: string[]; - onClick: React.MouseEventHandler; -} - -export const SpoilerButton: React.FC = ({ - hidden = false, - sensitive, - uncached = false, - matchedFilters, - onClick, -}) => { - let warning; - let action; - - if (uncached) { - warning = ( - - ); - action = ( - - ); - } else if (matchedFilters) { - warning = ( - {chunks}, - }} - /> - ); - action = ( - - ); - } else if (sensitive) { - warning = ( - - ); - action = ( - - ); - } else { - warning = ( - - ); - action = ( - - ); - } - - return ( -
    - -
    - ); -}; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 0efea48f87..5c95cf46e1 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -77,7 +77,7 @@ export const defaultMediaVisibility = (status) => { status = status.get('reblog'); } - return !status.get('matched_media_filters') && (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); + return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); }; const messages = defineMessages({ @@ -496,7 +496,6 @@ class Status extends ImmutablePureComponent { defaultWidth={this.props.cachedMediaWidth} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} - matchedFilters={status.get('matched_media_filters')} /> )} @@ -525,7 +524,6 @@ class Status extends ImmutablePureComponent { blurhash={attachment.get('blurhash')} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} - matchedFilters={status.get('matched_media_filters')} /> )} @@ -550,7 +548,6 @@ class Status extends ImmutablePureComponent { deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} - matchedFilters={status.get('matched_media_filters')} /> )} diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 4721e9da93..5e4593160c 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -25,8 +25,9 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; + +import DropdownMenuContainer from '../containers/dropdown_menu_container'; import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container'; -import { Dropdown } from 'mastodon/components/dropdown_menu'; import { enableEmojiReaction , bookmarkCategoryNeeded, simpleTimelineMenu, me, isHideItem, boostMenu, boostModal } from '../initial_state'; import { IconButton } from './icon_button'; @@ -348,9 +349,10 @@ class StatusActionBar extends ImmutablePureComponent { if (writtenByMe && pinnableStatus) { menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); - menu.push(null); } + menu.push(null); + if (writtenByMe || withDismiss) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); @@ -496,7 +498,7 @@ class StatusActionBar extends ImmutablePureComponent {
    {reblogMenu.length === 0 ? reblogButton : ( - + > + {reblogButton} + )}
    @@ -518,7 +522,7 @@ class StatusActionBar extends ImmutablePureComponent {
    {emojiPickerDropdown}
    - + ); if (this.props.onClick) { diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js new file mode 100644 index 0000000000..dc2a9648ff --- /dev/null +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -0,0 +1,50 @@ +import { connect } from 'react-redux'; + +import { fetchRelationships } from 'mastodon/actions/accounts'; + +import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu'; +import { openModal, closeModal } from '../actions/modal'; +import DropdownMenu from '../components/dropdown_menu'; +import { isUserTouching } from '../is_mobile'; + +/** + * @param {import('mastodon/store').RootState} state + */ +const mapStateToProps = state => ({ + openDropdownId: state.dropdownMenu.openId, + openedViaKeyboard: state.dropdownMenu.keyboard, +}); + +/** + * @param {any} dispatch + * @param {Object} root0 + * @param {any} [root0.status] + * @param {any} root0.items + * @param {any} [root0.scrollKey] + */ +const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ + onOpen(id, onItemClick, keyboard) { + if (status) { + dispatch(fetchRelationships([status.getIn(['account', 'id'])])); + } + + dispatch(isUserTouching() ? openModal({ + modalType: 'ACTIONS', + modalProps: { + status, + actions: items, + onClick: onItemClick, + }, + }) : openDropdownMenu({ id, keyboard, scrollKey })); + }, + + onClose(id) { + dispatch(closeModal({ + modalType: 'ACTIONS', + ignoreFocus: false, + })); + dispatch(closeDropdownMenu({ id })); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx index 9c07341faa..d18602e3b5 100644 --- a/app/javascript/mastodon/containers/media_container.jsx +++ b/app/javascript/mastodon/containers/media_container.jsx @@ -7,13 +7,12 @@ 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 }; @@ -89,7 +88,7 @@ export default class MediaContainer extends PureComponent { Object.assign(props, { ...(media ? { media: fromJS(media) } : {}), ...(card ? { card: fromJS(card) } : {}), - ...(poll ? { poll: createPollFromServerJSON(poll) } : {}), + ...(poll ? { poll: fromJS(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 new file mode 100644 index 0000000000..7ca840138d --- /dev/null +++ b/app/javascript/mastodon/containers/poll_container.js @@ -0,0 +1,38 @@ +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/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 8158f47c11..359fb2ff7a 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -173,7 +173,7 @@ class About extends PureComponent {
    `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />

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

    -

    +

    Mastodon }} />

    diff --git a/app/javascript/mastodon/features/account/components/account_note.jsx b/app/javascript/mastodon/features/account/components/account_note.jsx index df7312eafc..e736e7ad64 100644 --- a/app/javascript/mastodon/features/account/components/account_note.jsx +++ b/app/javascript/mastodon/features/account/components/account_note.jsx @@ -4,6 +4,7 @@ import { PureComponent } from 'react'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { is } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; @@ -48,7 +49,7 @@ class InlineAlert extends PureComponent { class AccountNote extends ImmutablePureComponent { static propTypes = { - accountId: PropTypes.string.isRequired, + account: ImmutablePropTypes.record.isRequired, value: PropTypes.string, onSave: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, @@ -65,7 +66,7 @@ class AccountNote extends ImmutablePureComponent { } UNSAFE_componentWillReceiveProps (nextProps) { - const accountWillChange = !is(this.props.accountId, nextProps.accountId); + const accountWillChange = !is(this.props.account, nextProps.account); const newState = {}; if (accountWillChange && this._isDirty()) { @@ -101,10 +102,10 @@ class AccountNote extends ImmutablePureComponent { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { e.preventDefault(); + this._save(); + if (this.textarea) { this.textarea.blur(); - } else { - this._save(); } } else if (e.keyCode === 27) { e.preventDefault(); @@ -140,21 +141,21 @@ class AccountNote extends ImmutablePureComponent { } render () { - const { accountId, intl } = this.props; + const { account, intl } = this.props; const { value, saved } = this.state; - if (!accountId) { + if (!account) { return null; } return (
    -