diff --git a/.browserslistrc b/.browserslistrc index 54dd3aaf34..0376af4bcc 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,7 +1,9 @@ [production] defaults -not IE 11 +> 0.2% +ios >= 15.6 not dead +not OperaMini all [development] supports es6-module diff --git a/.bundler-audit.yml b/.bundler-audit.yml new file mode 100644 index 0000000000..9c4d4511f1 --- /dev/null +++ b/.bundler-audit.yml @@ -0,0 +1,8 @@ +--- +ignore: + # devise-two-factor advisory about brute-forcing TOTP + # We have rate-limits on authentication endpoints in place (including second + # factor verification) since Mastodon v3.2.0 + - CVE-2024-0227 + - CVE-2024-27456 + - CVE-2023-51774 diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 21ee078d60..97331f74ea 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -70,7 +70,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.5.3 + image: libretranslate/libretranslate:v1.5.7 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.env.development b/.env.development new file mode 100644 index 0000000000..0330da8377 --- /dev/null +++ b/.env.development @@ -0,0 +1,4 @@ +# Required by ActiveRecord encryption feature +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=fkSxKD2bF396kdQbrP1EJ7WbU7ZgNokR +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=r0hvVmzBVsjxC7AMlwhOzmtc36ZCOS1E +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=PhdFyyfy5xJ7WVd2lWBpcPScRQHzRTNr diff --git a/.env.test b/.env.test index 2f8c1afd6e..539bdeb795 100644 --- a/.env.test +++ b/.env.test @@ -3,3 +3,10 @@ NODE_ENV=production # Federation LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_HTTPS=true +# Elasticsearch +ES_PREFIX=test + +# Required by ActiveRecord encryption feature +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=fkSxKD2bF396kdQbrP1EJ7WbU7ZgNokR +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=r0hvVmzBVsjxC7AMlwhOzmtc36ZCOS1E +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=PhdFyyfy5xJ7WVd2lWBpcPScRQHzRTNr diff --git a/.eslintrc.js b/.eslintrc.js index 1b36bcee25..759003b55e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -123,7 +123,7 @@ module.exports = defineConfig({ 'react/react-in-jsx-scope': 'off', // not needed with new JSX transform 'react/self-closing-comp': 'error', - // recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/index.js + // recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46 'jsx-a11y/accessible-emoji': 'warn', 'jsx-a11y/click-events-have-key-events': 'off', 'jsx-a11y/label-has-associated-control': 'off', @@ -165,7 +165,7 @@ module.exports = defineConfig({ // }, // ], 'jsx-a11y/no-noninteractive-tabindex': 'off', - 'jsx-a11y/no-onchange': 'warn', + 'jsx-a11y/no-onchange': 'off', // recommended is full 'error' 'jsx-a11y/no-static-element-interactions': [ 'warn', @@ -176,7 +176,7 @@ module.exports = defineConfig({ }, ], - // See https://github.com/import-js/eslint-plugin-import/blob/main/config/recommended.js + // See https://github.com/import-js/eslint-plugin-import/blob/v2.29.1/config/recommended.js 'import/extensions': [ 'error', 'always', @@ -338,7 +338,6 @@ module.exports = defineConfig({ 'plugin:import/typescript', 'plugin:promise/recommended', 'plugin:jsdoc/recommended-typescript', - 'plugin:prettier/recommended', ], parserOptions: { @@ -347,6 +346,9 @@ module.exports = defineConfig({ }, rules: { + // Disable formatting rules that have been enabled in the base config + 'indent': 'off', + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], '@typescript-eslint/consistent-type-definitions': ['warn', 'interface'], @@ -361,6 +363,7 @@ module.exports = defineConfig({ "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 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..fa7a0c5353 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://fantia.jp/fanclubs/484677 diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml new file mode 100644 index 0000000000..10421eed7b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -0,0 +1,74 @@ +name: バグ報告 +description: kmyblueのバグ報告(ただし情報改竄、秘密情報の漏洩、システムの破損などが発生するバグは、こちらではなく「Security」タブよりセキュリティインシデントとして報告してください) +labels: [bug] +body: + - type: textarea + attributes: + label: バグの再現手順 + description: どのように操作したらバグが発生したのか、バグが発生する直前までの手順を順番に詳しく教えてください + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: textarea + attributes: + label: 期待する動作 + description: どのように動いてほしかったですか? + validations: + required: true + - type: textarea + attributes: + label: 実際の動作 + description: どのようなバグが発生しましたか? + validations: + required: true + - type: textarea + attributes: + label: 詳しい情報 + validations: + required: false + - type: input + attributes: + label: バグが発生したkmyblueサーバーのドメイン + description: サーバー固有の問題の可能性もありますので、プライバシー上可能な範囲内で、できるだけ書いてください + placeholder: kmy.blue + validations: + required: false + - type: input + attributes: + label: バグが発生したkmyblueのバージョン + description: | + Mastodonではなくkmyblueのバージョンを記述してください。例えばバージョン表記が `v4.2.0+kmyblue.5.1-LTS` の場合、バージョンは `5.1`になります + + バージョンは、PCだと画面左下、スマホだと概要画面の一番下に書いてあります + placeholder: '5.1' + validations: + required: true + - type: input + attributes: + label: ブラウザの名前 + description: | + ブラウザの名前を書いてください。可能であればバージョンも併記してください + placeholder: Firefox 105.0.3 + validations: + required: false + - type: input + attributes: + label: OS + description: | + あなたのOSと、できればバージョンも教えてください。スマホの場合は、「Android」「iPhone」にバージョンをつけてください + placeholder: Windows11 + validations: + required: false + - type: textarea + attributes: + label: その他の詳細情報 + description: | + あなたの環境が特殊な場合、詳しいことを教えてください(例: VPS、tor、学内LANなど) + + サーバー管理者の場合は、Ruby、Node.jsのバージョン、Cloudflareの使用可否なども可能なら書いてください + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml deleted file mode 100644 index 20e27d103c..0000000000 --- a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Bug Report (Web Interface) -description: If you are using Mastodon's web interface and something is not working as expected -labels: [bug, 'status/to triage', 'area/web interface'] -body: - - type: markdown - attributes: - value: | - Make sure that you are submitting a new bug that was not previously reported or already fixed. - - Please use a concise and distinct title for the issue. - - type: textarea - attributes: - label: Steps to reproduce the problem - description: What were you trying to do? - value: | - 1. - 2. - 3. - ... - validations: - required: true - - type: input - attributes: - label: Expected behaviour - description: What should have happened? - validations: - required: true - - type: input - attributes: - label: Actual behaviour - description: What happened? - validations: - required: true - - type: textarea - attributes: - label: Detailed description - validations: - required: false - - type: input - attributes: - label: Mastodon instance - description: The address of the Mastodon instance where you experienced the issue - placeholder: mastodon.social - validations: - required: true - - type: input - attributes: - label: Mastodon version - description: | - This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` - placeholder: v4.1.2 - validations: - required: true - - type: input - attributes: - label: Browser name and version - description: | - What browser are you using when getting this bug? Please specify the version as well. - placeholder: Firefox 105.0.3 - validations: - required: true - - type: input - attributes: - label: Operating system - description: | - What OS are you running? Please specify the version as well. - placeholder: macOS 13.4.1 - validations: - required: true - - type: textarea - attributes: - label: Technical details - description: | - Any additional technical details you may have. This can include the full error log, inspector's output… - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/2.feature_request.yml b/.github/ISSUE_TEMPLATE/2.feature_request.yml new file mode 100644 index 0000000000..10fb4bb23b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2.feature_request.yml @@ -0,0 +1,16 @@ +name: 機能要望 +description: 機能の提案 +labels: [enhancement] +body: + - type: textarea + attributes: + label: 欲しい機能 + description: 欲しい機能の詳細を書いてください + validations: + required: true + - type: textarea + attributes: + label: 必要性 + description: この機能はあなたにとってなぜ必要でしょうか?どういった状況で使われるものですか? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml deleted file mode 100644 index 49d5f57209..0000000000 --- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Bug Report (server / API) -description: | - If something is not working as expected, but is not from using the web interface. -labels: [bug, 'status/to triage'] -body: - - type: markdown - attributes: - value: | - Make sure that you are submitting a new bug that was not previously reported or already fixed. - - Please use a concise and distinct title for the issue. - - type: textarea - attributes: - label: Steps to reproduce the problem - description: What were you trying to do? - value: | - 1. - 2. - 3. - ... - validations: - required: true - - type: input - attributes: - label: Expected behaviour - description: What should have happened? - validations: - required: true - - type: input - attributes: - label: Actual behaviour - description: What happened? - validations: - required: true - - type: textarea - attributes: - label: Detailed description - validations: - required: false - - type: input - attributes: - label: Mastodon instance - description: The address of the Mastodon instance where you experienced the issue - placeholder: mastodon.social - validations: - required: false - - type: input - attributes: - label: Mastodon version - description: | - This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` - placeholder: v4.1.2 - validations: - required: false - - type: textarea - attributes: - label: Technical details - description: | - Any additional technical details you may have, like logs or error traces - value: | - If this is happening on your own Mastodon server, please fill out those: - - Ruby version: (from `ruby --version`, eg. v3.1.2) - - Node.js version: (from `node --version`, eg. v18.16.0) - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/3.feature_request.yml b/.github/ISSUE_TEMPLATE/3.feature_request.yml deleted file mode 100644 index 2cabcf61e0..0000000000 --- a/.github/ISSUE_TEMPLATE/3.feature_request.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Feature Request -description: I have a suggestion -labels: [suggestion] -body: - - type: markdown - attributes: - value: | - Please use a concise and distinct title for the issue. - - Consider: Could it be implemented as a 3rd party app using the REST API instead? - - type: textarea - attributes: - label: Pitch - description: Describe your idea for a feature. Make sure it has not already been suggested/implemented/turned down before. - validations: - required: true - - type: textarea - attributes: - label: Motivation - description: Why do you think this feature is needed? Who would benefit from it? - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/3.spec_change_request.yml b/.github/ISSUE_TEMPLATE/3.spec_change_request.yml new file mode 100644 index 0000000000..e71befe859 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3.spec_change_request.yml @@ -0,0 +1,28 @@ +name: 仕様変更・改善要望 +description: 既存の仕様や挙動変更の要望 +labels: [specchange] +body: + - type: markdown + attributes: + value: 意図したものとは明らかに異なる挙動をしているものはバグとして、もともと仕様として決められた動きをしているものを変更したいときはこちらでお願いします + - type: textarea + attributes: + label: 挙動を変更してほしい機能や動作 + validations: + required: true + - type: textarea + attributes: + label: 現在の挙動 + validations: + required: true + - type: textarea + attributes: + label: 変更してほしい新しい挙動 + validations: + required: true + - type: textarea + attributes: + label: 必要性 + description: この変更はあなたにとってなぜ必要でしょうか?どういった状況で使われるものですか? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index f5d3196528..0086358db1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1 @@ -blank_issues_enabled: false -contact_links: - - name: GitHub Discussions - url: https://github.com/mastodon/mastodon/discussions - about: Please ask and answer questions here. +blank_issues_enabled: true diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 07fd4d08d3..808adc7de6 100644 --- a/.github/actions/setup-javascript/action.yml +++ b/.github/actions/setup-javascript/action.yml @@ -23,7 +23,7 @@ runs: shell: bash run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} diff --git a/.github/codecov.yml b/.github/codecov.yml index 5532c49618..9d6413a106 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -1,3 +1,4 @@ +comment: false # Do not leave PR comments coverage: status: project: @@ -8,6 +9,3 @@ coverage: default: # Github status check is not blocking informational: true -comment: - # Only write a comment in PR if there are changes - require_changes: true diff --git a/.github/renovate.json5 b/.github/renovate.json5 index dab99829a1..378d4fc83c 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -125,6 +125,29 @@ ], groupName: null, // We dont want them to belong to any group }, + { + // Group all RuboCop packages with `rubocop` in the same PR + matchManagers: ['bundler'], + matchPackageNames: ['rubocop'], + matchPackagePrefixes: ['rubocop-'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'RuboCop (non-major)', + }, + { + // Group all RSpec packages with `rspec` in the same PR + matchManagers: ['bundler'], + matchPackageNames: ['rspec'], + matchPackagePrefixes: ['rspec-'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'RSpec (non-major)', + }, + { + // Group all opentelemetry-ruby packages in the same PR + matchManagers: ['bundler'], + matchPackagePrefixes: ['opentelemetry-'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'opentelemetry-ruby (non-major)', + }, // Add labels depending on package manager { matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] }, { matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] }, diff --git a/.github/stylelint-matcher.json b/.github/stylelint-matcher.json deleted file mode 100644 index cdfd4086bd..0000000000 --- a/.github/stylelint-matcher.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "stylelint", - "pattern": [ - { - "regexp": "^([^\\s].*)$", - "file": 1 - }, - { - "regexp": "^\\s+((\\d+):(\\d+))?\\s+(✖|×)\\s+(.*)\\s{2,}(.*)$", - "line": 2, - "column": 3, - "message": 5, - "code": 6, - "loop": true - } - ] - } - ] -} diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml deleted file mode 100644 index e100e15821..0000000000 --- a/.github/workflows/build-container-image.yml +++ /dev/null @@ -1,102 +0,0 @@ -on: - workflow_call: - inputs: - platforms: - required: true - type: string - cache: - type: boolean - default: true - use_native_arm64_builder: - type: boolean - push_to_images: - type: string - version_prerelease: - type: string - version_metadata: - type: string - flavor: - type: string - tags: - type: string - labels: - type: string - file_to_build: - type: string - -jobs: - build-image: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: docker/setup-qemu-action@v3 - if: contains(inputs.platforms, 'linux/arm64') && !inputs.use_native_arm64_builder - - - uses: docker/setup-buildx-action@v3 - id: buildx - if: ${{ !(inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')) }} - - - name: Start a local Docker Builder - if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64') - run: | - docker run --rm -d --name buildkitd -p 1234:1234 --privileged moby/buildkit:latest --addr tcp://0.0.0.0:1234 - - - uses: docker/setup-buildx-action@v3 - id: buildx-native - if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64') - with: - driver: remote - endpoint: tcp://localhost:1234 - platforms: linux/amd64 - append: | - - endpoint: tcp://${{ vars.DOCKER_BUILDER_HETZNER_ARM64_01_HOST }}:13865 - platforms: linux/arm64 - name: mastodon-docker-builder-arm64-01 - driver-opts: - - servername=mastodon-docker-builder-arm64-01 - env: - BUILDER_NODE_1_AUTH_TLS_CACERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CACERT }} - BUILDER_NODE_1_AUTH_TLS_CERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CERT }} - BUILDER_NODE_1_AUTH_TLS_KEY: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_KEY }} - - - name: Log in to Docker Hub - if: contains(inputs.push_to_images, 'tootsuite') - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Log in to the Github Container registry - if: contains(inputs.push_to_images, 'ghcr.io') - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/metadata-action@v5 - id: meta - if: ${{ inputs.push_to_images != '' }} - with: - images: ${{ inputs.push_to_images }} - flavor: ${{ inputs.flavor }} - tags: ${{ inputs.tags }} - labels: ${{ inputs.labels }} - - - uses: docker/build-push-action@v5 - with: - context: . - file: ${{ inputs.file_to_build }} - build-args: | - MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }} - MASTODON_VERSION_METADATA=${{ inputs.version_metadata }} - platforms: ${{ inputs.platforms }} - provenance: false - builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }} - push: ${{ inputs.push_to_images != '' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: ${{ inputs.cache && 'type=gha' || '' }} - cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }} diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml deleted file mode 100644 index 72baed5121..0000000000 --- a/.github/workflows/build-push-pr.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Build container image for PR -on: - pull_request: - types: [labeled, synchronize, reopened, ready_for_review, opened] - -permissions: - contents: read - packages: write - -jobs: - compute-suffix: - runs-on: ubuntu-latest - # This is only allowed to run if: - # - the PR branch is in the `mastodon/mastodon` repository - # - the PR is not a draft - # - the PR has the "build-image" label - if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'build-image') }} - steps: - # Repository needs to be cloned so `git rev-parse` below works - - name: Clone repository - uses: actions/checkout@v4 - - id: version_vars - run: | - echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT - outputs: - metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }} - - build-image: - needs: compute-suffix - uses: ./.github/workflows/build-container-image.yml - with: - file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true - push_to_images: | - ghcr.io/mastodon/mastodon - version_metadata: ${{ needs.compute-suffix.outputs.metadata }} - flavor: | - latest=auto - tags: | - type=ref,event=pr - secrets: inherit - - build-image-streaming: - needs: compute-suffix - uses: ./.github/workflows/build-container-image.yml - with: - file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true - push_to_images: | - ghcr.io/mastodon/mastodon-streaming - version_metadata: ${{ needs.compute-suffix.outputs.metadata }} - flavor: | - latest=auto - tags: | - type=ref,event=pr - secrets: inherit diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml deleted file mode 100644 index 3f0bef32ac..0000000000 --- a/.github/workflows/build-releases.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Build container release images -on: - push: - tags: - - '*' - -permissions: - contents: read - packages: write - -jobs: - build-image: - uses: ./.github/workflows/build-container-image.yml - with: - file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true - push_to_images: | - tootsuite/mastodon - ghcr.io/mastodon/mastodon - # Do not use cache when building releases, so apt update is always ran and the release always contain the latest packages - cache: false - # Only tag with latest when ran against the latest stable branch - # This needs to be updated after each minor version release - flavor: | - latest=${{ startsWith(github.ref, 'refs/tags/v4.2.') }} - tags: | - type=pep440,pattern={{raw}} - type=pep440,pattern=v{{major}}.{{minor}} - secrets: inherit - - build-image-streaming: - if: startsWith(github.ref, 'refs/tags/v4.3.') - uses: ./.github/workflows/build-container-image.yml - with: - file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true - push_to_images: | - tootsuite/mastodon-streaming - ghcr.io/mastodon/mastodon-streaming - # Do not use cache when building releases, so apt update is always ran and the release always contain the latest packages - cache: false - # Only tag with latest when ran against the latest stable branch - # This needs to be updated after each minor version release - flavor: | - latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }} - tags: | - type=pep440,pattern={{raw}} - type=pep440,pattern=v{{major}}.{{minor}} - secrets: inherit diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-security.yml similarity index 83% rename from .github/workflows/build-nightly.yml rename to .github/workflows/build-security.yml index 7c6f74b457..1e2455d3d9 100644 --- a/.github/workflows/build-nightly.yml +++ b/.github/workflows/build-security.yml @@ -1,8 +1,6 @@ -name: Build nightly container image +name: Build security nightly container image on: workflow_dispatch: - schedule: - - cron: '0 2 * * *' # run at 2 AM UTC permissions: contents: read @@ -17,7 +15,7 @@ jobs: env: TZ: Etc/UTC run: | - echo mastodon_version_prerelease=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT + echo mastodon_version_prerelease=nightly.$(date --date='next day' +'%Y-%m-%d')-security>> $GITHUB_OUTPUT outputs: prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }} @@ -40,7 +38,7 @@ jobs: tags: | type=raw,value=edge type=raw,value=nightly - type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }} + type=raw,value=${{ needs.compute-suffix.outputs.prerelease }} secrets: inherit build-image-streaming: @@ -62,5 +60,5 @@ jobs: tags: | type=raw,value=edge type=raw,value=nightly - type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }} + type=raw,value=${{ needs.compute-suffix.outputs.prerelease }} secrets: inherit diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml deleted file mode 100644 index d3988d2f1a..0000000000 --- a/.github/workflows/crowdin-download.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Crowdin / Download translations -on: - schedule: - - cron: '17 4 * * *' # Every day - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - download-translations: - runs-on: ubuntu-latest - if: github.repository == 'mastodon/mastodon' - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Increase Git http.postBuffer - # This is needed due to a bug in Ubuntu's cURL version? - # See https://github.com/orgs/community/discussions/55820 - run: | - git config --global http.version HTTP/1.1 - git config --global http.postBuffer 157286400 - - # Download the translation files from Crowdin - - name: crowdin action - uses: crowdin/github-action@v1 - with: - upload_sources: false - upload_translations: false - download_translations: true - crowdin_branch_name: main - push_translations: false - create_pull_request: false - env: - CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} - - # As the files are extracted from a Docker container, they belong to root:root - # We need to fix this before the next steps - - name: Fix file permissions - run: sudo chown -R runner:docker . - - # This is needed to run the normalize step - - name: Set up Ruby environment - uses: ./.github/actions/setup-ruby - - - name: Run i18n normalize task - run: bundle exec i18n-tasks normalize - - # Create or update the pull request - - name: Create Pull Request - uses: peter-evans/create-pull-request@v5.0.2 - with: - commit-message: 'New Crowdin translations' - title: 'New Crowdin Translations (automated)' - author: 'GitHub Actions ' - body: | - New Crowdin translations, automated with Github Actions - - See `.github/workflows/crowdin-download.yml` - - This PR will be updated every day with new translations. - - Due to a limitation in Github Actions, checks are not running on this PR without manual action. - If you want to run the checks, then close and re-open it. - branch: i18n/crowdin/translations - base: main - labels: i18n diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml deleted file mode 100644 index 705af12c02..0000000000 --- a/.github/workflows/crowdin-upload.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Crowdin / Upload translations - -on: - push: - branches: - - main - paths: - - crowdin.yml - - app/javascript/mastodon/locales/en.json - - config/locales/en.yml - - config/locales/simple_form.en.yml - - config/locales/activerecord.en.yml - - config/locales/devise.en.yml - - config/locales/doorkeeper.en.yml - - .github/workflows/crowdin-upload.yml - -jobs: - upload-translations: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: crowdin action - uses: crowdin/github-action@v1 - with: - upload_sources: true - upload_translations: false - download_translations: false - crowdin_branch_name: main - - env: - CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml new file mode 100644 index 0000000000..2d483b5022 --- /dev/null +++ b/.github/workflows/format-check.yml @@ -0,0 +1,18 @@ +name: Check formatting +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Check formatting with Prettier + run: yarn format:check diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 7229bec582..d3b8035cd8 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -38,9 +38,5 @@ jobs: - name: Set up Javascript environment uses: ./.github/actions/setup-javascript - - uses: xt0rted/stylelint-problem-matcher@v1 - - - run: echo "::add-matcher::.github/stylelint-matcher.json" - - name: Stylelint - run: yarn lint:sass + run: yarn lint:css -f github diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index 8dcab845ee..25615b720d 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -36,4 +36,4 @@ jobs: - name: Run haml-lint run: | echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" - bundle exec haml-lint + bundle exec haml-lint --reporter github diff --git a/.github/workflows/lint-json.yml b/.github/workflows/lint-json.yml deleted file mode 100644 index 7796bf92c4..0000000000 --- a/.github/workflows/lint-json.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: JSON Linting -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.json' - - '.github/workflows/lint-json.yml' - - '!app/javascript/mastodon/locales/*.json' - - pull_request: - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.json' - - '.github/workflows/lint-json.yml' - - '!app/javascript/mastodon/locales/*.json' - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Prettier - run: yarn lint:json diff --git a/.github/workflows/lint-md.yml b/.github/workflows/lint-md.yml deleted file mode 100644 index 51c59937a3..0000000000 --- a/.github/workflows/lint-md.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Markdown Linting -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - paths: - - '.github/workflows/lint-md.yml' - - '.nvmrc' - - '.prettier*' - - '**/*.md' - - '!AUTHORS.md' - - 'package.json' - - 'yarn.lock' - - pull_request: - paths: - - '.github/workflows/lint-md.yml' - - '.nvmrc' - - '.prettier*' - - '**/*.md' - - '!AUTHORS.md' - - 'package.json' - - 'yarn.lock' - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Prettier - run: yarn lint:md diff --git a/.github/workflows/lint-yml.yml b/.github/workflows/lint-yml.yml deleted file mode 100644 index 908bdef5cc..0000000000 --- a/.github/workflows/lint-yml.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: YML Linting -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.yaml' - - '**/*.yml' - - '.github/workflows/lint-yml.yml' - - '!config/locales/*.yml' - - pull_request: - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.yaml' - - '**/*.yml' - - '.github/workflows/lint-yml.yml' - - '!config/locales/*.yml' - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Prettier - run: yarn lint:yml diff --git a/.github/workflows/test-image-build.yml b/.github/workflows/test-image-build.yml deleted file mode 100644 index 980e071897..0000000000 --- a/.github/workflows/test-image-build.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Test container image build -on: - pull_request: - paths: - - .github/workflows/build-nightly.yml - - .github/workflows/build-push-pr.yml - - .github/workflows/build-releases.yml - - .github/workflows/test-image-build.yml - - Dockerfile - - streaming/Dockerfile -permissions: - contents: read - -jobs: - build-image: - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - uses: ./.github/workflows/build-container-image.yml - with: - file_to_build: Dockerfile - platforms: linux/amd64 # Testing only on native platform so it is performant - cache: true - - build-image-streaming: - concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-streaming - cancel-in-progress: true - - uses: ./.github/workflows/build-container-image.yml - with: - file_to_build: streaming/Dockerfile - platforms: linux/amd64 # Testing only on native platform so it is performant - cache: true diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 79622b6c1f..481afdba30 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -38,5 +38,5 @@ jobs: - name: Set up Javascript environment uses: ./.github/actions/setup-javascript - - name: Jest testing + - name: JavaScript testing run: yarn jest --reporters github-actions summary diff --git a/.github/workflows/test-migrations-one-step.yml b/.github/workflows/test-migrations-one-step.yml index 5dca8e376d..1ff5cc06b9 100644 --- a/.github/workflows/test-migrations-one-step.yml +++ b/.github/workflows/test-migrations-one-step.yml @@ -78,23 +78,8 @@ jobs: - name: Create database run: './bin/rails db:create' - - name: Run migrations up to v2.0.0 - run: './bin/rails db:migrate VERSION=20171010025614' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2' - - - name: Run migrations up to v2.4.0 - run: './bin/rails db:migrate VERSION=20180514140000' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4' - - - name: Run migrations up to v2.4.3 - run: './bin/rails db:migrate VERSION=20180707154237' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4_3' + - name: Run historical migrations with data population + run: './bin/rails tests:migrations:prepare_database' - name: Run all remaining migrations run: './bin/rails db:migrate' diff --git a/.github/workflows/test-migrations-two-step.yml b/.github/workflows/test-migrations-two-step.yml index 59485d285d..6698847315 100644 --- a/.github/workflows/test-migrations-two-step.yml +++ b/.github/workflows/test-migrations-two-step.yml @@ -45,6 +45,7 @@ jobs: --health-retries 5 ports: - 5432:5432 + redis: image: redis:7-alpine options: >- @@ -77,28 +78,11 @@ jobs: - name: Create database run: './bin/rails db:create' - - name: Run migrations up to v2.0.0 - run: './bin/rails db:migrate VERSION=20171010025614' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2' - - - name: Run pre-deployment migrations up to v2.4.0 - run: './bin/rails db:migrate VERSION=20180514140000' + - name: Run historical migrations with data population + run: './bin/rails tests:migrations:prepare_database' env: SKIP_POST_DEPLOYMENT_MIGRATIONS: true - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4' - - - name: Run migrations up to v2.4.3 - run: './bin/rails db:migrate VERSION=20180707154237' - env: - SKIP_POST_DEPLOYMENT_MIGRATIONS: true - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4_3' - - name: Run all remaining pre-deployment migrations run: './bin/rails db:migrate' env: diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index ae25648a0b..5892c59066 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -28,6 +28,9 @@ jobs: env: RAILS_ENV: ${{ matrix.mode }} BUNDLE_WITH: ${{ matrix.mode }} + ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: precompile_placeholder + ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: precompile_placeholder + ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: precompile_placeholder OTP_SECRET: precompile_placeholder SECRET_KEY_BASE: precompile_placeholder @@ -52,7 +55,7 @@ jobs: run: | tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: matrix.mode == 'test' with: path: |- @@ -106,18 +109,19 @@ jobs: CAS_ENABLED: true BUNDLE_WITH: 'pam_authentication test' GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} + ES_ENABLED: false strategy: fail-fast: false matrix: ruby-version: - - '3.0' - '3.1' + - '3.2' - '.ruby-version' steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: path: './' name: ${{ github.sha }} @@ -139,9 +143,11 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: files: coverage/lcov/mastodon.lcov + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} test-e2e: name: End to End testing @@ -181,19 +187,22 @@ jobs: DISABLE_SIMPLECOV: true RAILS_ENV: test BUNDLE_WITH: test + ES_ENABLED: false + LOCAL_DOMAIN: localhost:3000 + LOCAL_HTTPS: false strategy: fail-fast: false matrix: ruby-version: - - '3.0' - '3.1' + - '3.2' - '.ruby-version' steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: path: './public' name: ${{ github.sha }} @@ -210,21 +219,21 @@ jobs: - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' - - run: bundle exec rake spec:system + - run: bin/rspec spec/system --tag streaming --tag js - name: Archive logs - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: e2e-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: e2e-screenshots - path: tmp/screenshots/ + path: tmp/capybara/ test-search: name: Elastic Search integration testing @@ -257,8 +266,8 @@ jobs: ports: - 6379:6379 - search: - image: ${{ matrix.search-image }} + elasticsearch: + image: ${{ contains(matrix.search-image, 'elasticsearch') && matrix.search-image || '' }} env: discovery.type: single-node xpack.security.enabled: false @@ -270,6 +279,20 @@ jobs: ports: - 9200:9200 + opensearch: + image: ${{ contains(matrix.search-image, 'opensearch') && matrix.search-image || '' }} + env: + discovery.type: single-node + DISABLE_INSTALL_DEMO_CONFIG: true + DISABLE_SECURITY_PLUGIN: true + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + ports: + - 9200:9200 + env: DB_HOST: localhost DB_USER: postgres @@ -285,19 +308,21 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.0' - '3.1' + - '3.2' - '.ruby-version' search-image: - docker.elastic.co/elasticsearch/elasticsearch:7.17.13 include: - ruby-version: '.ruby-version' search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2 + - ruby-version: '.ruby-version' + search-image: opensearchproject/opensearch:2 steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: path: './public' name: ${{ github.sha }} @@ -317,15 +342,105 @@ jobs: - run: bin/rspec --tag search - name: Archive logs - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: test-search-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: test-search-screenshots - path: tmp/screenshots/ + path: tmp/capybara/ + + test-back-and-return: + name: Back to original and return test + runs-on: ubuntu-latest + + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }} + RAILS_ENV: test + ALLOW_NOPAM: true + PAM_ENABLED: true + PAM_DEFAULT_SERVICE: pam_test + PAM_CONTROLLED_SERVICE: pam_test_controlled + OIDC_ENABLED: true + OIDC_SCOPE: read + SAML_ENABLED: true + CAS_ENABLED: true + BUNDLE_WITH: 'pam_authentication test' + GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} + ES_ENABLED: false + BACK_UPSTREAM_FORCE: true + + strategy: + fail-fast: false + matrix: + ruby-version: + - '.ruby-version' + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: './' + name: ${{ github.sha }} + + - name: Expand archived asset artifacts + run: | + tar xvzf artifacts.tar.gz + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version}} + additional-system-dependencies: ffmpeg imagemagick libpam-dev + + - name: Load database schema + run: './bin/rails db:create db:schema:load db:seed' + + - name: Back to upstream schema + run: 'bundle exec rake dangerous:back_upstream' + + - name: Return to kmyblue + run: './bin/rails db:migrate' + + - run: bin/rspec + + - name: Upload coverage reports to Codecov + if: matrix.ruby-version == '.ruby-version' + uses: codecov/codecov-action@v3 + with: + files: coverage/lcov/mastodon-back-ret.lcov diff --git a/.gitignore b/.gitignore index c5af8eb67f..4106e3f7f8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,10 +24,12 @@ /public/packs-test .env .env.production -.env.development /node_modules/ /build/ +# Ignore elasticsearch config +/.elasticsearch.yml + # Ignore Vagrant files .vagrant/ @@ -69,3 +71,6 @@ yarn-debug.log # Ignore Docker option files docker-compose.override.yml + +# Ignore dotenv .local files +.env*.local diff --git a/.haml-lint.yml b/.haml-lint.yml index 8cfcaec8d9..7dbc88e9db 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -2,7 +2,6 @@ inherits_from: .haml-lint_todo.yml exclude: - 'vendor/**/*' - - lib/templates/haml/scaffold/_form.html.haml require: - ./lib/linter/haml_middle_dot.rb @@ -13,4 +12,6 @@ linters: MiddleDot: enabled: true LineLength: - max: 320 + max: 300 + ViewLength: + max: 200 # Override default value of 100 inherited from rubocop diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml index af2d2e8f4e..841561291f 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -10,4 +10,27 @@ linters: # Offense count: 1 LineLength: exclude: + - 'app/views/admin/ng_rules/_ng_rule_fields.html.haml' - 'app/views/admin/roles/_form.html.haml' + + # Offense count: 9 + RuboCop: + exclude: + - 'app/views/home/index.html.haml' + + ViewLength: + exclude: + - 'app/views/admin/accounts/index.html.haml' + - 'app/views/admin/instances/show.html.haml' + - 'app/views/admin/ng_rules/_ng_rule_fields.html.haml' + - 'app/views/admin/settings/discovery/show.html.haml' + - 'app/views/settings/preferences/appearance/show.html.haml' + - 'app/views/settings/preferences/other/show.html.haml' + + InstanceVariables: + exclude: + - 'app/views/application/_sidebar.html.haml' + - 'app/views/admin/ng_rules/_ng_rule_fields.html.haml' + - 'app/views/admin/ng_words/keywords/_ng_word.html.haml' + - 'app/views/admin/ng_words/white_list/_specified_domain.html.haml' + - 'app/views/admin/sensitive_words/_sensitive_word.html.haml' diff --git a/.husky/pre-commit b/.husky/pre-commit index d2ae35e84b..3723623171 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - yarn lint-staged diff --git a/.nvmrc b/.nvmrc index a3597ecbd1..973f49d55c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.11 +20.13 diff --git a/.prettierignore b/.prettierignore index 51850b2b28..6b2f0c1889 100644 --- a/.prettierignore +++ b/.prettierignore @@ -54,6 +54,13 @@ # Ignore Docker option files docker-compose.override.yml +# Ignore public +/public/assets +/public/emoji +/public/packs +/public/packs-test +/public/system + # Ignore emoji map file /app/javascript/mastodon/features/emoji/emoji_map.json @@ -74,4 +81,5 @@ app/javascript/styles/mastodon/reset.scss # Ignore the generated AUTHORS.md AUTHORS.md +# Process a few selected JS files !lint-staged.config.js diff --git a/.rubocop.yml b/.rubocop.yml index a06621d660..5596727890 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -9,12 +9,13 @@ inherit_mode: require: - rubocop-rails - rubocop-rspec + - rubocop-rspec_rails - rubocop-performance - rubocop-capybara - ./lib/linter/rubocop_middle_dot AllCops: - TargetRubyVersion: 3.0 # Set to minimum supported version of CI + TargetRubyVersion: 3.1 # Set to minimum supported version of CI DisplayCopNames: true DisplayStyleGuide: true ExtraDetails: true @@ -22,7 +23,7 @@ AllCops: CacheRootDirectory: tmp NewCops: enable # Opt-in to newly added rules Exclude: - - db/schema.rb + - 'db/schema.rb' - 'bin/*' - 'node_modules/**/*' - 'Vagrantfile' @@ -39,13 +40,7 @@ Layout/FirstHashElementIndentation: # Reason: Currently disabled in .rubocop_todo.yml # https://docs.rubocop.org/rubocop/cops_layout.html#layoutlinelength Layout/LineLength: - Max: 320 # Default of 120 causes a duplicate entry in generated todo file - -# Reason: -# https://docs.rubocop.org/rubocop/cops_lint.html#lintuselessaccessmodifier -Lint/UselessAccessModifier: - ContextCreatingMethods: - - class_methods + Max: 300 # Default of 120 causes a duplicate entry in generated todo file ## Disable most Metrics/*Length cops # Reason: those are often triggered and force significant refactors when this happend @@ -73,12 +68,18 @@ Metrics/ModuleLength: # https://docs.rubocop.org/rubocop/cops_metrics.html#metricsabcsize Metrics/AbcSize: Exclude: + - 'app/serializers/initial_state_serializer.rb' - 'lib/mastodon/cli/*.rb' # Reason: Currently disabled in .rubocop_todo.yml # https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity Metrics/CyclomaticComplexity: Exclude: + - 'app/lib/feed_manager.rb' + - 'app/policies/status_policy.rb' + - 'app/services/activitypub/process_account_service.rb' + - 'app/services/delivery_antenna_service.rb' + - 'app/services/post_status_service.rb' - lib/mastodon/cli/*.rb # Reason: @@ -86,6 +87,17 @@ Metrics/CyclomaticComplexity: Metrics/ParameterLists: CountKeywordArgs: false +Metrics/PerceivedComplexity: + Exclude: + - 'app/policies/status_policy.rb' + - 'app/services/delivery_antenna_service.rb' + - 'app/services/post_status_service.rb' + +# Reason: Prefer seeing a variable name +# https://docs.rubocop.org/rubocop/cops_naming.html#namingblockforwarding +Naming/BlockForwarding: + EnforcedStyle: explicit + # Reason: Prevailing style is argument file paths # https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsfilepath Rails/FilePath: @@ -96,22 +108,26 @@ Rails/FilePath: Rails/HttpStatus: EnforcedStyle: numeric -# Reason: Allowed in `tootctl` CLI code and in boot ENV checker -# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsexit -Rails/Exit: - Exclude: - - 'config/boot.rb' - - 'lib/mastodon/cli/*.rb' - # Reason: Conflicts with `Lint/UselessMethodDefinition` for inherited controller actions # https://docs.rubocop.org/rubocop-rails/cops_rails.html#railslexicallyscopedactionfilter Rails/LexicallyScopedActionFilter: Exclude: - 'app/controllers/auth/*' -Rails/SkipsModelValidations: +# Reason: These tasks are doing local work which do not need full env loaded +# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsrakeenvironment +Rails/RakeEnvironment: Exclude: - - 'db/*migrate/**/*' + - 'lib/tasks/auto_annotate_models.rake' + - 'lib/tasks/emojis.rake' + - 'lib/tasks/mastodon.rake' + - 'lib/tasks/repo.rake' + - 'lib/tasks/statistics.rake' + +# Reason: There are appropriate times to use these features +# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsskipsmodelvalidations +Rails/SkipsModelValidations: + Enabled: false # Reason: We want to preserve the ability to migrate from arbitrary old versions, # and cannot guarantee that every installation has run every migration as they upgrade. @@ -124,6 +140,11 @@ Rails/UnusedIgnoredColumns: Rails/NegateInclude: Enabled: false +# Reason: Enforce default limit, but allow some elements to span lines +# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecexamplelength +RSpec/ExampleLength: + CountAsOne: ['array', 'heredoc', 'method_call'] + # Reason: Deprecated cop, will be removed in 3.0, replaced by SpecFilePathFormat # https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecfilepath RSpec/FilePath: @@ -139,11 +160,6 @@ RSpec/NamedSubject: RSpec/NotToNot: EnforcedStyle: to_not -# Reason: Prevailing style uses numeric status codes, matches Rails/HttpStatus -# https://docs.rubocop.org/rubocop-rspec/cops_rspec_rails.html#rspecrailshttpstatus -RSpec/Rails/HttpStatus: - EnforcedStyle: numeric - # Reason: Match overrides from Rspec/FilePath rule above # https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecspecfilepathformat RSpec/SpecFilePathFormat: @@ -154,6 +170,11 @@ RSpec/SpecFilePathFormat: OEmbedController: oembed_controller OStatus: ostatus +# Reason: Prevailing style uses numeric status codes, matches Rails/HttpStatus +# https://docs.rubocop.org/rubocop-rspec/cops_rspec_rails.html#rspecrailshttpstatus +RSpecRails/HttpStatus: + EnforcedStyle: numeric + # Reason: # https://docs.rubocop.org/rubocop/cops_style.html#styleclassandmodulechildren Style/ClassAndModuleChildren: @@ -164,10 +185,25 @@ Style/ClassAndModuleChildren: Style/Documentation: Enabled: false +# Reason: Route redirects are not token-formatted and must be skipped +# https://docs.rubocop.org/rubocop/cops_style.html#styleformatstringtoken +Style/FormatStringToken: + inherit_mode: + merge: + - AllowedMethods # The rubocop-rails config adds `redirect` + AllowedMethods: + - redirect_with_vary + +# Reason: Prevailing style choice +# https://docs.rubocop.org/rubocop/cops_style.html#stylehashaslastarrayitem +Style/HashAsLastArrayItem: + Enabled: false + # Reason: Enforce modern Ruby style # https://docs.rubocop.org/rubocop/cops_style.html#stylehashsyntax Style/HashSyntax: EnforcedStyle: ruby19_no_mixed_keys + EnforcedShorthandSyntax: either # Reason: # https://docs.rubocop.org/rubocop/cops_style.html#stylenumericliterals @@ -187,16 +223,16 @@ Style/PercentLiteralDelimiters: Style/RedundantBegin: Enabled: false +# Reason: Prevailing style choice +# https://docs.rubocop.org/rubocop/cops_style.html#styleredundantfetchblock +Style/RedundantFetchBlock: + Enabled: false + # Reason: Overridden to reduce implicit StandardError rescues # https://docs.rubocop.org/rubocop/cops_style.html#stylerescuestandarderror Style/RescueStandardError: EnforcedStyle: implicit -# Reason: Simplify some spec layouts -# https://docs.rubocop.org/rubocop/cops_style.html#stylesemicolon -Style/Semicolon: - AllowAsExpressionSeparator: true - # Reason: Originally disabled for CodeClimate, and no config consensus has been found # https://docs.rubocop.org/rubocop/cops_style.html#stylesymbolarray Style/SymbolArray: diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 602d99c9f0..119df96090 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,25 +1,11 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.59.0. +# using RuboCop version 1.63.5. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. -# Include: **/*.gemfile, **/Gemfile, **/gems.rb -Bundler/OrderedGems: - Exclude: - - 'Gemfile' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. -# URISchemes: http, https -Layout/LineLength: - Exclude: - - 'app/models/account.rb' - Lint/NonLocalExitFromIterator: Exclude: - 'app/helpers/jsonld_helper.rb' @@ -43,119 +29,27 @@ Metrics/PerceivedComplexity: # Configuration parameters: CountAsOne. RSpec/ExampleLength: - Max: 22 + Max: 18 RSpec/MultipleExpectations: - Max: 8 + Max: 7 # Configuration parameters: AllowSubject. RSpec/MultipleMemoizedHelpers: Max: 17 + Exclude: + - 'spec/lib/activitypub/activity/create_spec.rb' + - 'spec/services/delete_account_service_spec.rb' + - 'spec/services/fan_out_on_write_service_spec.rb' # Configuration parameters: AllowedGroups. RSpec/NestedGroups: Max: 6 -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/HasAndBelongsToMany: - Exclude: - - 'app/models/concerns/account/associations.rb' - - 'app/models/status.rb' - - 'app/models/tag.rb' - Rails/OutputSafety: Exclude: - 'config/initializers/simple_form.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: Include. -# Include: **/Rakefile, **/*.rake -Rails/RakeEnvironment: - Exclude: - - 'lib/tasks/auto_annotate_models.rake' - - 'lib/tasks/db.rake' - - 'lib/tasks/emojis.rake' - - 'lib/tasks/mastodon.rake' - - 'lib/tasks/repo.rake' - - 'lib/tasks/statistics.rake' - -# Configuration parameters: ForbiddenMethods, AllowedMethods. -# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all -Rails/SkipsModelValidations: - Exclude: - - 'app/controllers/admin/invites_controller.rb' - - 'app/controllers/concerns/session_tracking_concern.rb' - - 'app/models/concerns/account/merging.rb' - - 'app/models/concerns/expireable.rb' - - 'app/models/status.rb' - - 'app/models/trends/links.rb' - - 'app/models/trends/preview_card_batch.rb' - - 'app/models/trends/preview_card_provider_batch.rb' - - 'app/models/trends/status_batch.rb' - - 'app/models/trends/statuses.rb' - - 'app/models/trends/tag_batch.rb' - - 'app/models/trends/tags.rb' - - 'app/models/user.rb' - - 'app/services/activitypub/process_status_update_service.rb' - - 'app/services/approve_appeal_service.rb' - - 'app/services/block_domain_service.rb' - - 'app/services/delete_account_service.rb' - - 'app/services/process_mentions_service.rb' - - 'app/services/unallow_domain_service.rb' - - 'app/services/unblock_domain_service.rb' - - 'app/services/update_status_service.rb' - - 'app/workers/activitypub/post_upgrade_worker.rb' - - 'app/workers/move_worker.rb' - - 'app/workers/scheduler/ip_cleanup_scheduler.rb' - - 'app/workers/scheduler/scheduled_statuses_scheduler.rb' - - 'lib/mastodon/cli/accounts.rb' - - 'lib/mastodon/cli/maintenance.rb' - - 'spec/lib/activitypub/activity/follow_spec.rb' - - 'spec/services/follow_service_spec.rb' - - 'spec/services/update_account_service_spec.rb' - -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/UniqueValidationWithoutIndex: - Exclude: - - 'app/models/account_alias.rb' - - 'app/models/custom_filter_status.rb' - - 'app/models/identity.rb' - - 'app/models/webauthn_credential.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: exists, where -Rails/WhereExists: - Exclude: - - 'app/controllers/activitypub/inboxes_controller.rb' - - 'app/controllers/admin/email_domain_blocks_controller.rb' - - 'app/lib/activitypub/activity/create.rb' - - 'app/lib/delivery_failure_tracker.rb' - - 'app/lib/feed_manager.rb' - - 'app/lib/status_cache_hydrator.rb' - - 'app/lib/suspicious_sign_in_detector.rb' - - 'app/models/concerns/account/interactions.rb' - - 'app/models/featured_tag.rb' - - 'app/models/poll.rb' - - 'app/models/session_activation.rb' - - 'app/models/status.rb' - - 'app/models/user.rb' - - 'app/policies/status_policy.rb' - - 'app/serializers/rest/announcement_serializer.rb' - - 'app/serializers/rest/tag_serializer.rb' - - 'app/services/activitypub/fetch_remote_status_service.rb' - - 'app/services/vote_service.rb' - - 'app/validators/reaction_validator.rb' - - 'app/validators/vote_validator.rb' - - 'app/workers/move_worker.rb' - - 'lib/tasks/tests.rake' - - 'spec/models/account_spec.rb' - - 'spec/services/activitypub/process_collection_service_spec.rb' - - 'spec/services/purge_domain_service_spec.rb' - - 'spec/services/unallow_domain_service_spec.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: AllowedMethods, AllowedPatterns. # AllowedMethods: ==, equal?, eql? @@ -164,17 +58,12 @@ Style/ClassEqualityComparison: - 'app/helpers/jsonld_helper.rb' - 'app/serializers/activitypub/outbox_serializer.rb' -Style/ClassVars: - Exclude: - - 'config/initializers/devise.rb' - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowedVars. Style/FetchEnvVar: Exclude: - 'app/lib/redis_configuration.rb' - 'app/lib/translation_service.rb' - - 'config/environments/development.rb' - 'config/environments/production.rb' - 'config/initializers/2_limited_federation_mode.rb' - 'config/initializers/3_omniauth.rb' @@ -184,9 +73,8 @@ Style/FetchEnvVar: - 'config/initializers/paperclip.rb' - 'config/initializers/vapid.rb' - 'lib/mastodon/redis_config.rb' - - 'lib/premailer_webpack_strategy.rb' - 'lib/tasks/repo.rake' - - 'spec/features/profile_spec.rb' + - 'spec/system/profile_spec.rb' # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns. @@ -194,7 +82,6 @@ Style/FetchEnvVar: # AllowedMethods: redirect Style/FormatStringToken: Exclude: - - 'app/models/privacy_policy.rb' - 'config/initializers/devise.rb' - 'lib/paperclip/color_extractor.rb' @@ -208,10 +95,6 @@ Style/GlobalStdStream: # Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. Style/GuardClause: Exclude: - - 'app/controllers/admin/confirmations_controller.rb' - - 'app/controllers/auth/confirmations_controller.rb' - - 'app/controllers/auth/passwords_controller.rb' - - 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb' - 'app/lib/activitypub/activity/block.rb' - 'app/lib/request.rb' - 'app/lib/request_pool.rb' @@ -235,35 +118,14 @@ Style/GuardClause: - 'lib/mastodon/cli/accounts.rb' - 'lib/mastodon/cli/maintenance.rb' - 'lib/mastodon/cli/media.rb' - - 'lib/paperclip/attachment_extensions.rb' - 'lib/tasks/repo.rake' -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: braces, no_braces -Style/HashAsLastArrayItem: - Exclude: - - 'app/controllers/admin/statuses_controller.rb' - - 'app/controllers/api/v1/statuses_controller.rb' - - 'app/models/concerns/account/counters.rb' - - 'app/models/concerns/status/threading_concern.rb' - - 'app/models/status.rb' - - 'app/services/batched_remove_status_service.rb' - - 'app/services/notify_service.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). Style/HashTransformValues: Exclude: - 'app/serializers/rest/web_push_subscription_serializer.rb' - 'app/services/import_service.rb' -# This cop supports safe autocorrection (--autocorrect). -Style/IfUnlessModifier: - Exclude: - - 'config/environments/production.rb' - - 'config/initializers/devise.rb' - - 'config/initializers/ffmpeg.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). Style/MapToHash: Exclude: @@ -298,13 +160,6 @@ Style/OptionalBooleanParameter: - 'app/workers/unfollow_follow_worker.rb' - 'lib/mastodon/redis_config.rb' -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: PreferredDelimiters. -Style/PercentLiteralDelimiters: - Exclude: - - 'config/deploy.rb' - - 'config/initializers/doorkeeper.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. # SupportedStyles: short, verbose @@ -318,16 +173,6 @@ Style/RedundantConstantBase: - 'config/environments/production.rb' - 'config/initializers/sidekiq.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: SafeForConstants. -Style/RedundantFetchBlock: - Exclude: - - 'config/initializers/1_hosts.rb' - - 'config/initializers/chewy.rb' - - 'config/initializers/devise.rb' - - 'config/initializers/paperclip.rb' - - 'config/puma.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. # AllowedMethods: present?, blank?, presence, try, try! @@ -335,59 +180,12 @@ Style/SafeNavigation: Exclude: - 'app/models/concerns/account/finder_concern.rb' -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: only_raise, only_fail, semantic -Style/SignalException: - Exclude: - - 'lib/devise/strategies/two_factor_ldap_authenticatable.rb' - - 'lib/devise/strategies/two_factor_pam_authenticatable.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/SingleArgumentDig: - Exclude: - - 'lib/webpacker/manifest_extensions.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: Mode. Style/StringConcatenation: Exclude: - 'config/initializers/paperclip.rb' -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. -# SupportedStyles: single_quotes, double_quotes -Style/StringLiterals: - Exclude: - - 'config/environments/production.rb' - - 'config/initializers/backtrace_silencers.rb' - - 'config/initializers/http_client_proxy.rb' - - 'config/initializers/rack_attack.rb' - - 'config/initializers/webauthn.rb' - - 'config/routes.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, AllowSafeAssignment. -# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex -Style/TernaryParentheses: - Exclude: - - 'config/environments/development.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyleForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma -Style/TrailingCommaInArguments: - Exclude: - - 'config/initializers/paperclip.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyleForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma -Style/TrailingCommaInHashLiteral: - Exclude: - - 'config/environments/production.rb' - - 'config/environments/test.rb' - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: WordRegex. # SupportedStyles: percent, brackets diff --git a/.ruby-version b/.ruby-version index be94e6f53d..bea438e9ad 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +3.3.1 diff --git a/.simplecov b/.simplecov deleted file mode 100644 index fbd0207bec..0000000000 --- a/.simplecov +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -if ENV['CI'] - require 'simplecov-lcov' - SimpleCov::Formatter::LcovFormatter.config.report_with_single_file = true - SimpleCov.formatter = SimpleCov::Formatter::LcovFormatter -else - SimpleCov.formatter = SimpleCov::Formatter::HTMLFormatter -end - -SimpleCov.start 'rails' do - enable_coverage :branch - - add_filter 'lib/linter' - - add_group 'Libraries', 'lib' - add_group 'Policies', 'app/policies' - add_group 'Presenters', 'app/presenters' - add_group 'Serializers', 'app/serializers' - add_group 'Services', 'app/services' - add_group 'Validators', 'app/validators' -end diff --git a/AUTHORS_KB.md b/AUTHORS_KB.md new file mode 100644 index 0000000000..2cda5fd31d --- /dev/null +++ b/AUTHORS_KB.md @@ -0,0 +1,18 @@ +# Authors for kmyblue fork + +## 貢献者 + +kmyblueフォークは、以下の方の貢献によって成り立っています。 +本家Mastodonの貢献者については、`AUTHORS.md`をご覧ください。 + +- [aoisensi](https://github.com/aoisensi) +- [KMY](https://github.com/kmycode) +- [S-H-GAMELINKS](https://github.com/S-H-GAMELINKS) +- [Yuicho](https://github.com/yuicho) + +## 特記 + +kmyblueフォークの開発にあたって、API・Activity仕様の設計(一部機能については内部仕様)策定の過程で下記リポジトリのコードを参考にしました。 +kmyblueフォークに直接貢献したわけではありませんが、以下のリポジトリにある絵文字リアクション機能・検索範囲機能のコードのうち一部にkmyblueへ転写した箇所がございますため、お名前記載させていただきます。 + +- [Fedibird](https://github.com/fedibird/mastodon) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f775fcfa8..a53790afaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,101 @@ All notable changes to this project will be documented in this file. +## [4.2.7] - 2024-02-16 + +### Fixed + +- Fix OmniAuth tests and edge cases in error handling ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29201), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/29207)) +- Fix new installs by upgrading to the latest release of the `nsa` gem, instead of a no longer existing commit ([mjankowski](https://github.com/mastodon/mastodon/pull/29065)) + +### Security + +- Fix insufficient checking of remote posts ([GHSA-jhrq-qvrm-qr36](https://github.com/mastodon/mastodon/security/advisories/GHSA-jhrq-qvrm-qr36)) + +## [4.2.6] - 2024-02-14 + +### Security + +- Update the `sidekiq-unique-jobs` dependency (see [GHSA-cmh9-rx85-xj38](https://github.com/mhenrixon/sidekiq-unique-jobs/security/advisories/GHSA-cmh9-rx85-xj38)) + In addition, we have disabled the web interface for `sidekiq-unique-jobs` out of caution. + If you need it, you can re-enable it by setting `ENABLE_SIDEKIQ_UNIQUE_JOBS_UI=true`. + If you only need to clear all locks, you can now use `bundle exec rake sidekiq_unique_jobs:delete_all_locks`. +- Update the `nokogiri` dependency (see [GHSA-xc9x-jj77-9p9j](https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-xc9x-jj77-9p9j)) +- Disable administrative Doorkeeper routes ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/29187)) +- Fix ongoing streaming sessions not being invalidated when applications get deleted in some cases ([GHSA-7w3c-p9j8-mq3x](https://github.com/mastodon/mastodon/security/advisories/GHSA-7w3c-p9j8-mq3x)) + In some rare cases, the streaming server was not notified of access tokens revocation on application deletion. +- Change external authentication behavior to never reattach a new identity to an existing user by default ([GHSA-vm39-j3vx-pch3](https://github.com/mastodon/mastodon/security/advisories/GHSA-vm39-j3vx-pch3)) + Up until now, Mastodon has allowed new identities from external authentication providers to attach to an existing local user based on their verified e-mail address. + This allowed upgrading users from a database-stored password to an external authentication provider, or move from one authentication provider to another. + However, this behavior may be unexpected, and means that when multiple authentication providers are configured, the overall security would be that of the least secure authentication provider. + For these reasons, this behavior is now locked under the `ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH` environment variable. + In addition, regardless of this environment variable, Mastodon will refuse to attach two identities from the same authentication provider to the same account. + +## [4.2.5] - 2024-02-01 + +### Security + +- Fix insufficient origin validation (CVE-2024-23832, [GHSA-3fjr-858r-92rw](https://github.com/mastodon/mastodon/security/advisories/GHSA-3fjr-858r-92rw)) + +## [4.2.4] - 2024-01-24 + +### Fixed + +- Fix error when processing remote files with unusually long names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28823)) +- Fix processing of compacted single-item JSON-LD collections ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28816)) +- Retry 401 errors on replies fetching ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/28788)) +- Fix `RecordNotUnique` errors in LinkCrawlWorker ([tribela](https://github.com/mastodon/mastodon/pull/28748)) +- Fix Mastodon not correctly processing HTTP Signatures with query strings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28443), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28476)) +- Fix potential redirection loop of streaming endpoint ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28665)) +- Fix streaming API redirection ignoring the port of `streaming_api_base_url` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28558)) +- Fix error when processing link preview with an array as `inLanguage` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28252)) +- Fix unsupported time zone or locale preventing sign-up ([Gargron](https://github.com/mastodon/mastodon/pull/28035)) +- Fix "Hide these posts from home" list setting not refreshing when switching lists ([brianholley](https://github.com/mastodon/mastodon/pull/27763)) +- Fix missing background behind dismissable banner in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/27479)) +- Fix line wrapping of language selection button with long locale codes ([gunchleoc](https://github.com/mastodon/mastodon/pull/27100), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27127)) +- Fix `Undo Announce` activity not being sent to non-follower authors ([MitarashiDango](https://github.com/mastodon/mastodon/pull/18482)) +- Fix N+1s because of association preloaders not actually getting called ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28339)) +- Fix empty column explainer getting cropped under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28337)) +- Fix `LinkCrawlWorker` error when encountering empty OEmbed response ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28268)) +- Fix call to inefficient `delete_matched` cache method in domain blocks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28367)) + +### Security + +- Add rate-limit of TOTP authentication attempts at controller level ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28801)) + +## [4.2.3] - 2023-12-05 + +### Fixed + +- Fix dependency on `json-canonicalization` version that has been made unavailable since last release + +## [4.2.2] - 2023-12-04 + +### Changed + +- Change dismissed banners to be stored server-side ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27055)) +- Change GIF max matrix size error to explicitly mention GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27927)) +- Change `Follow` activities delivery to bypass availability check ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/27586)) +- Change single-column navigation notice to be displayed outside of the logo container ([renchap](https://github.com/mastodon/mastodon/pull/27462), [renchap](https://github.com/mastodon/mastodon/pull/27476)) +- Change Content-Security-Policy to be tighter on media paths ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26889)) +- Change post language code to include country code when relevant ([gunchleoc](https://github.com/mastodon/mastodon/pull/27099), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27207)) + +### Fixed + +- Fix upper border radius of onboarding columns ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27890)) +- Fix incoming status creation date not being restricted to standard ISO8601 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27655), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28081)) +- Fix some posts from threads received out-of-order sometimes not being inserted into timelines ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27653)) +- Fix posts from force-sensitized accounts being able to trend ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27620)) +- Fix error when trying to delete already-deleted file with OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27569)) +- Fix batch attachment deletion when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27554)) +- Fix processing LDSigned activities from actors with unknown public keys ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27474)) +- Fix error and incorrect URLs in `/api/v1/accounts/:id/featured_tags` for remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27459)) +- Fix report processing notice not mentioning the report number when performing a custom action ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27442)) +- Fix handling of `inLanguage` attribute in preview card processing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27423)) +- Fix own posts being removed from home timeline when unfollowing a used hashtag ([kmycode](https://github.com/mastodon/mastodon/pull/27391)) +- Fix some link anchors being recognized as hashtags ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27271), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27584)) +- Fix format-dependent redirects being cached regardless of requested format ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27634)) + ## [4.2.1] - 2023-10-10 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b68a9bde3e..43f8a79249 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,50 +1,35 @@ -# Contributing +# CONTRIBUTING -Thank you for considering contributing to Mastodon 🐘 +kmyblueは、コミュニティの意見も聞くには聞きますが導入する・しないは管理人が決定します。 -You can contribute in the following ways: +## バグ報告 -- Finding and reporting bugs -- Translating the Mastodon interface into various languages -- Contributing code to Mastodon by fixing bugs or implementing features -- Improving the documentation +バグについて、L最新よりも過去のバージョンへの対応は、LTSや特別な場合以外は行いません。 -If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). +以下のいずれかの方法で報告してください。 -## API Changes and Additions +- [GitHub Issues](https://github.com/kmycode/mastodon/issues) (セキュリティインシデントはここの一番下から) +- [kmyblue開発者への連絡](https://kmy.blue/@askyq) +- [kmyblue開発者へのメール](https://kmy.blue/about) -Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation). +## 翻訳、プルリクエスト -## Bug reports +新しい機能や既存機能の修正については、プルリクエストのためにコードを作成する前に、まずGitHub Issuesで機能の提案を行いkmyblue開発者の考えを聞くことをおすすめします。バグ修正、翻訳、テストコードなどは基本受け入れますが、依存モジュールのバージョンアップについては特別な事情がなければ本家Mastodonよりも先に行かないようにしてください。 -Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/mastodon/mastodon/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected. +プルリクエストのタイトルには、プルリクエストの内容が明確になるようなものを設定してください。 -## Translations +### kmyblueの開発方針 -You can submit translations via [Crowdin](https://crowdin.com/project/mastodon). They are periodically merged into the codebase. +下記のものに矛盾がなければ、あとは管理人の意向次第です。 -[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)](https://crowdin.com/project/mastodon) +- **自分の投稿を見せたくない人に見せない** +- **他人の見たくない投稿を見ない** +- ただし本家Mastodonで上記原則に矛盾した機能が追加された場合は従う +- 画面を騒がしくするような機能(絵文字を大きく表示するなど)は追加しないか、控えめにする。ただし他のソフトウェアにも導入され利用者が多くいる場合などは別途判断して、オプトアウト可能な設定項目とともに追加する +- 負荷を著しく上げるような機能はできるだけ追加しない -## Pull requests +kmyblueが意図的に実装していない機能は、例えば以下のものがあります。詳しい理由が知りたい場合は[この記事を参照するか](https://note.com/kmycode/n/n463410b5e03c)、別途お問い合わせください。もちろん明確な根拠がある場合、あなたはこれに抗議する権利を有しますが、あなたがこのkmyblueをフォークして新しいリポジトリを作るほうがより自由でしょう。 -**Please use clean, concise titles for your pull requests.** Unless the pull request is about refactoring code, updating dependencies or other internal tasks, assume that the person reading the pull request title is not a programmer or Mastodon developer, but instead a Mastodon user or server administrator, and **try to describe your change or fix from their perspective**. We use commit squashing, so the final commit in the main branch will carry the title of the pull request, and commits from the main branch are fed into the changelog. The changelog is separated into [keepachangelog.com categories](https://keepachangelog.com/en/1.0.0/), and while that spec does not prescribe how the entries ought to be named, for easier sorting, start your pull request titles using one of the verbs "Add", "Change", "Deprecate", "Remove", or "Fix" (present tense). - -Example: - -| Not ideal | Better | -| ------------------------------------ | ------------------------------------------------------------- | -| Fixed NoMethodError in RemovalWorker | Fix nil error when removing statuses caused by race condition | - -It is not always possible to phrase every change in such a manner, but it is desired. - -**The smaller the set of changes in the pull request is, the quicker it can be reviewed and merged.** Splitting tasks into multiple smaller pull requests is often preferable. - -**Pull requests that do not pass automated checks may not be reviewed**. In particular, you need to keep in mind: - -- Unit and integration tests (rspec, jest) -- Code style rules (rubocop, eslint) -- Normalization of locale files (i18n-tasks) - -## Documentation - -The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to mastodon/documentation](https://github.com/mastodon/documentation). +- お気に入り一覧の公開 +- ブックマーク分類の公開 +- Fedibird、Misskeyにあるような詳細な画面表示オプション diff --git a/Dockerfile b/Dockerfile index 96f8b5cd27..4278242bc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.4 +# syntax=docker/dockerfile:1.7 # Please see https://docs.docker.com/engine/reference/builder for information about # the extended buildx capabilities used in this file. @@ -7,20 +7,20 @@ ARG TARGETPLATFORM=${TARGETPLATFORM} ARG BUILDPLATFORM=${BUILDPLATFORM} -# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.2.2"] -ARG RUBY_VERSION="3.2.2" +# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.1"] +ARG RUBY_VERSION="3.3.1" # # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] ARG NODE_MAJOR_VERSION="20" # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] ARG DEBIAN_VERSION="bookworm" # Node image to use for base image based on combined variables (ex: 20-bookworm-slim) FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim as node -# Ruby image to use for base image based on combined variables (ex: 3.2.2-slim-bookworm) +# Ruby image to use for base image based on combined variables (ex: 3.3.1-slim-bookworm) FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA # Example: v4.2.0-nightly.2023.11.09+something -# Overwrite existance of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] +# Overwrite existence of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] ARG MASTODON_VERSION_PRERELEASE="" # Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="something"] ARG MASTODON_VERSION_METADATA="" @@ -29,7 +29,7 @@ ARG MASTODON_VERSION_METADATA="" # See: https://docs.joinmastodon.org/admin/config/#rails_serve_static_files ARG RAILS_SERVE_STATIC_FILES="true" # Allow to use YJIT compiler -# See: https://github.com/ruby/ruby/blob/master/doc/yjit/yjit.md +# See: https://github.com/ruby/ruby/blob/v3_2_4/doc/yjit/yjit.md ARG RUBY_YJIT_ENABLE="1" # Timezone used by the Docker container and runtime, change with [--build-arg TZ=Europe/Berlin] ARG TZ="Etc/UTC" @@ -205,7 +205,12 @@ ARG TARGETPLATFORM RUN \ # Use Ruby on Rails to create Mastodon assets - OTP_SECRET=precompile_placeholder SECRET_KEY_BASE=precompile_placeholder bundle exec rails assets:precompile; \ + ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=precompile_placeholder \ + ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=precompile_placeholder \ + ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=precompile_placeholder \ + OTP_SECRET=precompile_placeholder \ + SECRET_KEY_BASE=precompile_placeholder \ + bundle exec rails assets:precompile; \ # Cleanup temporary files rm -fr /opt/mastodon/tmp; @@ -257,4 +262,4 @@ USER mastodon # Expose default Puma ports EXPOSE 3000 # Set container tini as default entry point -ENTRYPOINT ["/usr/bin/tini", "--"] \ No newline at end of file +ENTRYPOINT ["/usr/bin/tini", "--"] diff --git a/FEDERATION.md b/FEDERATION.md index e3721d7241..2819fa935a 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -1,19 +1,35 @@ -## ActivityPub federation in Mastodon +# Federation + +## Supported federation protocols and standards + +- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server) +- [WebFinger](https://webfinger.net/) +- [Http Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) +- [NodeInfo](https://nodeinfo.diaspora.software/) + +## Supported FEPs + +- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) +- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md) +- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) +- [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) + +## ActivityPub in Mastodon Mastodon largely follows the ActivityPub server-to-server specification but it makes uses of some non-standard extensions, some of which are required for interacting with Mastodon at all. -Supported vocabulary: https://docs.joinmastodon.org/spec/activitypub/ +- [Supported ActivityPub vocabulary](https://docs.joinmastodon.org/spec/activitypub/) ### Required extensions -#### Webfinger +#### WebFinger In Mastodon, users are identified by a `username` and `domain` pair (e.g., `Gargron@mastodon.social`). This is used both for discovery and for unambiguously mentioning users across the fediverse. Furthermore, this is part of Mastodon's database design from its very beginnings. As a result, Mastodon requires that each ActivityPub actor uniquely maps back to an `acct:` URI that can be resolved via WebFinger. -More information and examples are available at: https://docs.joinmastodon.org/spec/webfinger/ +- [WebFinger information and examples](https://docs.joinmastodon.org/spec/webfinger/) #### HTTP Signatures @@ -21,11 +37,13 @@ In order to authenticate activities, Mastodon relies on HTTP Signatures, signing Mastodon requires all `POST` requests to be signed, and MAY require `GET` requests to be signed, depending on the configuration of the Mastodon server. -More information on HTTP Signatures, as well as examples, can be found here: https://docs.joinmastodon.org/spec/security/#http +- [HTTP Signatures information and examples](https://docs.joinmastodon.org/spec/security/#http) ### Optional extensions -- Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld -- Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/ -- Followers collection synchronization: https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md -- Search indexing consent for actors: https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md +- [Linked-Data Signatures](https://docs.joinmastodon.org/spec/security/#ld) +- [Bearcaps](https://docs.joinmastodon.org/spec/bearcaps/) + +### Additional documentation + +- [Mastodon documentation](https://docs.joinmastodon.org/) diff --git a/Gemfile b/Gemfile index 2d4e504ac7..240dcce95a 100644 --- a/Gemfile +++ b/Gemfile @@ -1,37 +1,37 @@ # frozen_string_literal: true source 'https://rubygems.org' -ruby '>= 3.0.0' +ruby '>= 3.1.0' -gem 'puma', '~> 6.3' -gem 'rails', '~> 7.1.1' gem 'propshaft' -gem 'thor', '~> 1.2' +gem 'puma', '~> 6.3' gem 'rack', '~> 2.2.7' +gem 'rails', '~> 7.1.1' +gem 'thor', '~> 1.2' # For why irb is in the Gemfile, see: https://ruby.social/@st0012/111444685161478182 gem 'irb', '~> 1.8' +gem 'dotenv' gem 'haml-rails', '~>2.0' gem 'pg', '~> 1.5' gem 'pghero' -gem 'dotenv-rails', '~> 2.8' gem 'aws-sdk-s3', '~> 1.123', require: false +gem 'blurhash', '~> 0.1' gem 'fog-core', '<= 2.4.0' gem 'fog-openstack', '~> 1.0', require: false gem 'kt-paperclip', '~> 7.2' gem 'md-paperclip-azure', '~> 2.2', require: false -gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' -gem 'bootsnap', '~> 1.17.0', require: false +gem 'bootsnap', '~> 1.18.0', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' gem 'devise', '~> 4.9' -gem 'devise-two-factor', '~> 4.1' +gem 'devise-two-factor' group :pam_authentication, optional: true do gem 'devise_pam_authenticatable2', '~> 9.2' @@ -39,11 +39,11 @@ end gem 'net-ldap', '~> 0.18' -gem 'omniauth-cas', '~> 3.0.0.beta.1' -gem 'omniauth-saml', '~> 2.0' -gem 'omniauth_openid_connect', '~> 0.6.1' gem 'omniauth', '~> 2.0' +gem 'omniauth-cas', '~> 3.0.0.beta.1' +gem 'omniauth_openid_connect', '~> 0.6.1' gem 'omniauth-rails_csrf_protection', '~> 1.0' +gem 'omniauth-saml', '~> 2.0' gem 'color_diff', '~> 0.1' gem 'csv', '~> 3.2' @@ -53,48 +53,49 @@ gem 'ed25519', '~> 1.3' gem 'fast_blank', '~> 1.0' gem 'fastimage' gem 'hiredis', '~> 0.6' -gem 'redis-namespace', '~> 1.10' gem 'htmlentities', '~> 4.3' -gem 'http', '~> 5.1' +gem 'http', '~> 5.2.0' gem 'http_accept_language', '~> 2.1' gem 'httplog', '~> 1.6.2' +gem 'i18n' gem 'idn-ruby', require: 'idn' +gem 'inline_svg' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' +gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' gem 'nokogiri', '~> 1.15' -gem 'nsa', github: 'jhawthorn/nsa', ref: 'e020fcc3a54d993ab45b7194d89ab720296c111b' +gem 'nsa' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' -gem 'posix-spawn' +gem 'premailer-rails' gem 'public_suffix', '~> 5.0' gem 'pundit', '~> 2.3' -gem 'premailer-rails' gem 'rack-attack', '~> 6.6' gem 'rack-cors', '~> 2.0', require: 'rack/cors' gem 'rails-i18n', '~> 7.0' gem 'redcarpet', '~> 3.6' gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] -gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' +gem 'redis-namespace', '~> 1.10' gem 'rqrcode', '~> 2.2' gem 'ruby-progressbar', '~> 1.13' gem 'sanitize', '~> 6.0' gem 'scenic', '~> 1.7' gem 'sidekiq', '~> 6.5' +gem 'sidekiq-bulk', '~> 0.2.0' gem 'sidekiq-scheduler', '~> 5.0' gem 'sidekiq-unique-jobs', '~> 7.1' -gem 'sidekiq-bulk', '~> 0.2.0' -gem 'simple-navigation', '~> 4.4' gem 'simple_form', '~> 5.2' -gem 'stoplight', '~> 3.0.1' -gem 'strong_migrations', '1.7.0' +gem 'simple-navigation', '~> 4.4' +gem 'stoplight', '~> 4.1' +gem 'strong_migrations', '1.8.0' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' gem 'tzinfo-data', '~> 1.2023' +gem 'webauthn', '~> 3.0' gem 'webpacker', '~> 5.4' gem 'webpush', github: 'ClearlyClaire/webpush', ref: 'f14a4d52e201128b1b00245d11b6de80d6cfdcd9' -gem 'webauthn', '~> 3.0' gem 'json-ld' gem 'json-ld-preloaded', '~> 3.2' @@ -102,6 +103,24 @@ gem 'rdf-normalize', '~> 0.5' gem 'private_address_check', '~> 0.5' +group :opentelemetry do + gem 'opentelemetry-exporter-otlp', '~> 0.26.3', require: false + gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false + gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false + gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.22.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.24.1', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.23.2', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.22.3', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.27.1', require: false + gem 'opentelemetry-instrumentation-rack', '~> 0.24.1', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.30.0', require: false + gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false + gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false + gem 'opentelemetry-sdk', '~> 1.4', require: false +end + group :test do # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab gem 'rspec-github', '~> 2.4', require: false @@ -112,8 +131,8 @@ group :test do # RSpec helpers for email specs gem 'email_spec' - # Extra RSpec extenion methods and helpers for sidekiq - gem 'rspec-sidekiq', '~> 4.0' + # Extra RSpec extension methods and helpers for sidekiq + gem 'rspec-sidekiq', '~> 5.0' # Browser integration testing gem 'capybara', '~> 3.39' @@ -123,13 +142,7 @@ group :test do gem 'database_cleaner-active_record' # Used to mock environment variables - gem 'climate_control', '~> 0.2' - - # Generating fake data for specs - gem 'faker', '~> 3.2' - - # Generate test objects for specs - gem 'fabrication', '~> 2.30' + gem 'climate_control' # Add back helpers functions removed in Rails 5.1 gem 'rails-controller-testing', '~> 1.0' @@ -165,7 +178,7 @@ group :development do # Preview mail in the browser gem 'letter_opener', '~> 1.8' - gem 'letter_opener_web', '~> 2.0' + gem 'letter_opener_web', '~> 3.0' # Security analysis CLI tools gem 'brakeman', '~> 6.0', require: false @@ -182,6 +195,12 @@ group :development, :test do # Interactive Debugging tools gem 'debug', '~> 1.8' + # Generate fake data values + gem 'faker', '~> 3.2' + + # Generate factory objects + gem 'fabrication', '~> 2.30' + # Profiling tools gem 'memory_profiler', require: false gem 'ruby-prof', require: false @@ -196,12 +215,14 @@ group :production do gem 'lograge', '~> 0.12' end +gem 'cocoon', '~> 1.2' gem 'concurrent-ruby', require: false gem 'connection_pool', require: false gem 'xorcist', '~> 1.1' -gem 'cocoon', '~> 1.2' gem 'net-http', '~> 0.4.0' gem 'rubyzip', '~> 2.3' gem 'hcaptcha', '~> 7.1' + +gem 'mail', '~> 2.8' diff --git a/Gemfile.lock b/Gemfile.lock index e9c62eb115..6001c73db4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,49 +7,38 @@ GIT hkdf (~> 0.2) jwt (~> 2.0) -GIT - remote: https://github.com/jhawthorn/nsa.git - revision: e020fcc3a54d993ab45b7194d89ab720296c111b - ref: e020fcc3a54d993ab45b7194d89ab720296c111b - specs: - nsa (0.2.8) - activesupport (>= 4.2, < 7.2) - concurrent-ruby (~> 1.0, >= 1.0.2) - sidekiq (>= 3.5) - statsd-ruby (~> 1.4, >= 1.4.0) - GEM remote: https://rubygems.org/ specs: - actioncable (7.1.2) - actionpack (= 7.1.2) - activesupport (= 7.1.2) + actioncable (7.1.3.2) + actionpack (= 7.1.3.2) + activesupport (= 7.1.3.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.2) - actionpack (= 7.1.2) - activejob (= 7.1.2) - activerecord (= 7.1.2) - activestorage (= 7.1.2) - activesupport (= 7.1.2) + actionmailbox (7.1.3.2) + actionpack (= 7.1.3.2) + activejob (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.2) - actionpack (= 7.1.2) - actionview (= 7.1.2) - activejob (= 7.1.2) - activesupport (= 7.1.2) + actionmailer (7.1.3.2) + actionpack (= 7.1.3.2) + actionview (= 7.1.3.2) + activejob (= 7.1.3.2) + activesupport (= 7.1.3.2) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.2) - actionview (= 7.1.2) - activesupport (= 7.1.2) + actionpack (7.1.3.2) + actionview (= 7.1.3.2) + activesupport (= 7.1.3.2) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -57,15 +46,15 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.2) - actionpack (= 7.1.2) - activerecord (= 7.1.2) - activestorage (= 7.1.2) - activesupport (= 7.1.2) + actiontext (7.1.3.2) + actionpack (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.2) - activesupport (= 7.1.2) + actionview (7.1.3.2) + activesupport (= 7.1.3.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -75,22 +64,22 @@ GEM activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (7.1.2) - activesupport (= 7.1.2) + activejob (7.1.3.2) + activesupport (= 7.1.3.2) globalid (>= 0.3.6) - activemodel (7.1.2) - activesupport (= 7.1.2) - activerecord (7.1.2) - activemodel (= 7.1.2) - activesupport (= 7.1.2) + activemodel (7.1.3.2) + activesupport (= 7.1.3.2) + activerecord (7.1.3.2) + activemodel (= 7.1.3.2) + activesupport (= 7.1.3.2) timeout (>= 0.4.0) - activestorage (7.1.2) - actionpack (= 7.1.2) - activejob (= 7.1.2) - activerecord (= 7.1.2) - activesupport (= 7.1.2) + activestorage (7.1.3.2) + actionpack (= 7.1.3.2) + activejob (= 7.1.3.2) + activerecord (= 7.1.3.2) + activesupport (= 7.1.3.2) marcel (~> 1.0) - activesupport (7.1.2) + activesupport (7.1.3.2) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -108,22 +97,20 @@ GEM activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) ast (2.4.2) - attr_encrypted (4.0.0) - encryptor (~> 3.0.0) - attr_required (1.0.1) + attr_required (1.0.2) awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.873.0) - aws-sdk-core (3.190.1) + aws-partitions (1.929.0) + aws-sdk-core (3.196.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.75.0) - aws-sdk-core (~> 3, >= 3.188.0) + aws-sdk-kms (1.81.0) + aws-sdk-core (~> 3, >= 3.193.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.142.0) - aws-sdk-core (~> 3, >= 3.189.0) + aws-sdk-s3 (1.151.0) + aws-sdk-core (~> 3, >= 3.194.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.8) aws-sigv4 (1.8.0) @@ -143,21 +130,14 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - better_html (2.0.2) - actionview (>= 6.0) - activesupport (>= 6.0) - ast (~> 2.0) - erubi (~> 1.4) - parser (>= 2.4) - smart_properties - bigdecimal (3.1.5) - bindata (2.4.15) - binding_of_caller (1.0.0) - debug_inspector (>= 0.0.1) + bigdecimal (3.1.8) + bindata (2.5.0) + binding_of_caller (1.0.1) + debug_inspector (>= 1.2.0) blurhash (0.1.7) - bootsnap (1.17.0) + bootsnap (1.18.3) msgpack (~> 1.2) - brakeman (6.1.1) + brakeman (6.1.2) racc browser (5.3.1) brpoplpush-redis_script (0.1.3) @@ -167,97 +147,91 @@ GEM bundler-audit (0.9.1) bundler (>= 1.2.0, < 3) thor (~> 1.0) - capybara (3.39.2) + capybara (3.40.0) addressable matrix mini_mime (>= 0.1.3) - nokogiri (~> 1.8) + nokogiri (~> 1.11) rack (>= 1.6.0) rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) case_transform (0.2) activesupport - cbor (0.5.9.6) + cbor (0.5.9.8) charlock_holmes (0.7.7) - chewy (7.4.0) + chewy (7.6.0) activesupport (>= 5.2) - elasticsearch (>= 7.12.0, < 7.14.0) + elasticsearch (>= 7.14.0, < 8) elasticsearch-dsl chunky_png (1.4.0) - climate_control (0.2.0) + climate_control (1.2.0) cocoon (1.2.15) color_diff (0.1) - concurrent-ruby (1.2.2) + concurrent-ruby (1.2.3) connection_pool (2.4.1) cose (1.3.0) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) - crack (0.4.5) + crack (1.0.0) + bigdecimal rexml crass (1.0.6) - css_parser (1.14.0) + css_parser (1.17.1) addressable - csv (3.2.8) + csv (3.3.0) database_cleaner-active_record (2.1.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) date (3.3.4) - debug (1.9.1) + debug (1.9.2) irb (~> 1.10) reline (>= 0.3.8) - debug_inspector (1.1.0) - devise (4.9.3) + debug_inspector (1.2.0) + devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (4.1.1) + devise-two-factor (5.0.0) activesupport (~> 7.0) - attr_encrypted (>= 1.3, < 5, != 2) devise (~> 4.0) railties (~> 7.0) rotp (~> 6.0) devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.0) rpam2 (~> 4.0) - diff-lcs (1.5.0) + diff-lcs (1.5.1) discard (1.3.0) activerecord (>= 4.2, < 8) docile (1.4.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.6.8) + domain_name (0.6.20240107) + doorkeeper (5.6.9) railties (>= 5) - dotenv (2.8.1) - dotenv-rails (2.8.1) - dotenv (= 2.8.1) - railties (>= 3.2) - drb (2.2.0) - ruby2_keywords + dotenv (3.1.2) + drb (2.2.1) ed25519 (1.3.0) - elasticsearch (7.13.3) - elasticsearch-api (= 7.13.3) - elasticsearch-transport (= 7.13.3) - elasticsearch-api (7.13.3) + elasticsearch (7.17.10) + elasticsearch-api (= 7.17.10) + elasticsearch-transport (= 7.17.10) + elasticsearch-api (7.17.10) multi_json elasticsearch-dsl (0.1.10) - elasticsearch-transport (7.13.3) - faraday (~> 1) + elasticsearch-transport (7.17.10) + faraday (>= 1, < 3) multi_json email_spec (2.2.2) htmlentities (~> 4.3.3) launchy (~> 2.1) mail (~> 2.7) - encryptor (3.0.0) erubi (1.12.0) - et-orbi (1.2.7) + et-orbi (1.2.11) tzinfo - excon (0.109.0) + excon (0.110.0) fabrication (2.31.0) - faker (3.2.2) + faker (3.3.1) i18n (>= 1.8.11, < 2) faraday (1.10.3) faraday-em_http (~> 1.0) @@ -285,10 +259,10 @@ GEM faraday_middleware (1.2.0) faraday (~> 1.0) fast_blank (1.0.1) - fastimage (2.3.0) - ffi (1.15.5) - ffi-compiler (1.0.1) - ffi (>= 1.0.0) + fastimage (2.3.1) + ffi (1.16.3) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) rake fog-core (2.4.0) builder @@ -298,11 +272,11 @@ GEM fog-json (1.2.0) fog-core multi_json (~> 1.10) - fog-openstack (1.1.0) + fog-openstack (1.1.1) fog-core (~> 2.1) fog-json (>= 1.0) formatador (1.1.0) - fugit (1.8.1) + fugit (1.10.1) et-orbi (~> 1, >= 1.2.7) raabro (~> 1.4) fuubar (2.5.1) @@ -310,6 +284,9 @@ GEM ruby-progressbar (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) + google-protobuf (3.25.3) + googleapis-common-protos-types (1.14.0) + google-protobuf (~> 3.18) haml (6.3.0) temple (>= 0.8.2) thor @@ -319,39 +296,39 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.53.0) + haml_lint (0.58.0) haml (>= 5.0) parallel (~> 1.10) rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hashdiff (1.0.1) + hashdiff (1.1.0) hashie (5.0.0) hcaptcha (7.1.0) json - highline (2.1.0) + highline (3.0.1) hiredis (0.6.3) hkdf (0.3.0) htmlentities (4.3.4) - http (5.1.1) + http (5.2.0) addressable (~> 2.8) + base64 (~> 0.1) http-cookie (~> 1.0) http-form_data (~> 2.2) - llhttp-ffi (~> 0.4.0) + llhttp-ffi (~> 0.5.0) http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) httpclient (2.8.3) - httplog (1.6.2) + httplog (1.6.3) rack (>= 2.0) rainbow (>= 2.0.0) - i18n (1.14.1) + i18n (1.14.5) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.13) + i18n-tasks (1.0.14) activesupport (>= 4.0.2) ast (>= 2.1.0) - better_html (>= 1.0, < 3.0) erubi highline (>= 2.0.0) i18n @@ -360,14 +337,17 @@ GEM rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) idn-ruby (0.1.5) - io-console (0.7.1) - irb (1.11.1) - rdoc + inline_svg (1.9.0) + activesupport (>= 3.0) + nokogiri (>= 1.6) + io-console (0.7.2) + irb (1.13.1) + rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.7.1) + json (2.7.2) json-canonicalization (1.0.0) - json-jwt (1.15.3) + json-jwt (1.15.3.1) activesupport (>= 4.2) aes_key_wrap bindata @@ -382,7 +362,7 @@ GEM json-ld-preloaded (3.3.0) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (4.1.1) + json-schema (4.3.0) addressable (>= 2.8) jsonapi-renderer (0.2.2) jwt (2.7.1) @@ -398,24 +378,24 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - kt-paperclip (7.2.1) + kt-paperclip (7.2.2) activemodel (>= 4.2.0) activesupport (>= 4.2.0) marcel (~> 1.0.1) mime-types - terrapin (~> 0.6.0) + terrapin (>= 0.6.0, < 2.0) language_server-protocol (3.17.0.3) launchy (2.5.2) addressable (~> 2.8) - letter_opener (1.8.1) - launchy (>= 2.2, < 3) - letter_opener_web (2.0.0) - actionmailer (>= 5.2) - letter_opener (~> 1.7) - railties (>= 5.2) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + letter_opener_web (3.0.0) + actionmailer (>= 6.1) + letter_opener (~> 1.9) + railties (>= 6.1) rexml link_header (0.0.8) - llhttp-ffi (0.4.0) + llhttp-ffi (0.5.0) ffi-compiler (~> 1.0) rake (~> 13.0) lograge (0.14.0) @@ -431,7 +411,7 @@ GEM net-imap net-pop net-smtp - marcel (1.0.2) + marcel (1.0.4) mario-redis-lock (1.2.1) redis (>= 3.0.5) matrix (0.4.2) @@ -442,19 +422,19 @@ GEM memory_profiler (1.0.1) mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2023.1205) + mime-types-data (3.2024.0507) mini_mime (1.1.5) - mini_portile2 (2.8.5) - minitest (5.20.0) + mini_portile2 (2.8.6) + minitest (5.22.3) msgpack (1.7.2) multi_json (1.15.0) - multipart-post (2.3.0) + multipart-post (2.4.0) mutex_m (0.2.0) net-http (0.4.1) uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.4) + net-imap (0.4.11) date net-protocol net-ldap (0.19.0) @@ -462,19 +442,24 @@ GEM net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0) + net-smtp (0.5.0) net-protocol - nio4r (2.5.9) - nokogiri (1.16.0) + nio4r (2.7.1) + nokogiri (1.16.5) mini_portile2 (~> 2.8.2) racc (~> 1.4) + nsa (0.3.0) + activesupport (>= 4.2, < 7.2) + concurrent-ruby (~> 1.0, >= 1.0.2) + sidekiq (>= 3.5) + statsd-ruby (~> 1.4, >= 1.4.0) oj (3.16.3) bigdecimal (>= 3.0) - omniauth (2.1.1) + omniauth (2.1.2) hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection - omniauth-cas (3.0.0.beta.1) + omniauth-cas (3.0.0) addressable (~> 2.8) nokogiri (~> 1.12) omniauth (~> 2.1) @@ -501,20 +486,109 @@ GEM openssl (3.2.0) openssl-signature_algorithm (1.3.0) openssl (> 2.0) + opentelemetry-api (1.2.5) + opentelemetry-common (0.20.1) + opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp (0.26.3) + google-protobuf (~> 3.14) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-sdk (~> 1.2) + opentelemetry-semantic_conventions + opentelemetry-helpers-sql-obfuscation (0.1.0) + opentelemetry-common (~> 0.20) + opentelemetry-instrumentation-action_pack (0.9.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-rack (~> 0.21) + opentelemetry-instrumentation-action_view (0.7.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (~> 0.1) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-active_job (0.7.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-active_model_serializers (0.20.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-active_record (0.7.2) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-active_support (0.5.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-base (0.22.3) + opentelemetry-api (~> 1.0) + opentelemetry-registry (~> 0.1) + opentelemetry-instrumentation-concurrent_ruby (0.21.3) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-excon (0.22.1) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.20.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-faraday (0.24.2) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.20.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-http (0.23.3) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-http_client (0.22.4) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.20.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-net_http (0.22.4) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.20.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-pg (0.27.3) + opentelemetry-api (~> 1.0) + opentelemetry-helpers-sql-obfuscation + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-rack (0.24.3) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.20.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-rails (0.30.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-action_pack (~> 0.9.0) + opentelemetry-instrumentation-action_view (~> 0.7.0) + opentelemetry-instrumentation-active_job (~> 0.7.0) + opentelemetry-instrumentation-active_record (~> 0.7.0) + opentelemetry-instrumentation-active_support (~> 0.5.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-redis (0.25.4) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.20.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-sidekiq (0.25.3) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.20.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-registry (0.3.1) + opentelemetry-api (~> 1.1) + opentelemetry-sdk (1.4.1) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-registry (~> 0.2) + opentelemetry-semantic_conventions + opentelemetry-semantic_conventions (1.10.0) + opentelemetry-api (~> 1.0) orm_adapter (0.5.0) - ox (2.14.17) + ox (2.14.18) parallel (1.24.0) - parser (3.2.2.4) + parser (3.3.1.0) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.4) - pghero (3.4.0) + pg (1.5.6) + pghero (3.4.1) activerecord (>= 6) - posix-spawn (0.3.15) - premailer (1.21.0) + premailer (1.23.0) addressable css_parser (>= 1.12.0) htmlentities (>= 4.0.0) @@ -530,17 +604,17 @@ GEM railties (>= 7.0.0) psych (5.1.2) stringio - public_suffix (5.0.4) + public_suffix (5.0.5) puma (6.4.2) nio4r (~> 2.0) - pundit (2.3.1) + pundit (2.3.2) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.7.3) - rack (2.2.8) + rack (2.2.9) rack-attack (6.7.0) rack (>= 1.0, < 4) - rack-cors (2.0.1) + rack-cors (2.0.2) rack (>= 2.0.0) rack-oauth2 (1.21.3) activesupport @@ -548,31 +622,32 @@ GEM httpclient json-jwt (>= 1.11.0) rack (>= 2.1.0) - rack-protection (3.0.5) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rack-proxy (0.7.7) rack - rack-proxy (0.7.6) - rack - rack-session (1.0.1) + rack-session (1.0.2) rack (< 3) rack-test (2.1.0) rack (>= 1.3) rackup (1.0.0) rack (< 3) webrick - rails (7.1.2) - actioncable (= 7.1.2) - actionmailbox (= 7.1.2) - actionmailer (= 7.1.2) - actionpack (= 7.1.2) - actiontext (= 7.1.2) - actionview (= 7.1.2) - activejob (= 7.1.2) - activemodel (= 7.1.2) - activerecord (= 7.1.2) - activestorage (= 7.1.2) - activesupport (= 7.1.2) + rails (7.1.3.2) + actioncable (= 7.1.3.2) + actionmailbox (= 7.1.3.2) + actionmailer (= 7.1.3.2) + actionpack (= 7.1.3.2) + actiontext (= 7.1.3.2) + actionview (= 7.1.3.2) + activejob (= 7.1.3.2) + activemodel (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) bundler (>= 1.15.0) - railties (= 7.1.2) + railties (= 7.1.3.2) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -584,25 +659,25 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - rails-i18n (7.0.8) + rails-i18n (7.0.9) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.2) - actionpack (= 7.1.2) - activesupport (= 7.1.2) + railties (7.1.3.2) + actionpack (= 7.1.3.2) + activesupport (= 7.1.3.2) irb rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.1.0) + rake (13.2.1) rdf (3.3.1) bcp47_spec (~> 0.2) link_header (~> 0.0, >= 0.0.8) - rdf-normalize (0.6.1) - rdf (~> 3.2) - rdoc (6.6.2) + rdf-normalize (0.7.0) + rdf (~> 3.3) + rdoc (6.6.3.1) psych (>= 4.0.0) redcarpet (3.6.0) redis (4.8.1) @@ -610,78 +685,82 @@ GEM redis (>= 4) redlock (1.3.2) redis (>= 3.0.0, < 6.0) - regexp_parser (2.8.3) - reline (0.4.2) + regexp_parser (2.9.0) + reline (0.5.7) io-console (~> 0.5) - request_store (1.5.1) + request_store (1.6.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.6) + rexml (3.2.8) + strscan (>= 3.0.9) rotp (6.3.0) - rouge (4.1.2) + rouge (4.2.1) rpam2 (4.0.2) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) + rspec-support (~> 3.13.0) rspec-github (2.4.0) rspec-core (~> 3.0) - rspec-mocks (3.12.6) + rspec-mocks (3.13.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-rails (6.1.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.2) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) - rspec-sidekiq (4.1.0) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-sidekiq (5.0.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 8) - rspec-support (3.12.1) - rubocop (1.59.0) + rspec-support (3.13.1) + rubocop (1.63.5) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.2.4) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.30.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) + rubocop-ast (1.31.3) + parser (>= 3.3.1.0) rubocop-capybara (2.20.0) rubocop (~> 1.41) - rubocop-factory_bot (2.25.0) - rubocop (~> 1.33) - rubocop-performance (1.20.2) + rubocop-factory_bot (2.25.1) + rubocop (~> 1.41) + rubocop-performance (1.21.0) rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.30.0, < 2.0) - rubocop-rails (2.23.1) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.24.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.30.0, < 2.0) - rubocop-rspec (2.26.1) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rspec (2.29.2) rubocop (~> 1.40) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) + rubocop-rspec_rails (~> 2.28) + rubocop-rspec_rails (2.28.3) + rubocop (~> 1.40) ruby-prof (1.7.0) ruby-progressbar (1.13.0) - ruby-saml (1.15.0) + ruby-saml (1.16.0) nokogiri (>= 1.13.10) rexml ruby2_keywords (0.0.5) @@ -693,10 +772,11 @@ GEM sanitize (6.1.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) - scenic (1.7.0) + scenic (1.8.0) activerecord (>= 4.0.0) railties (>= 4.0.0) - selenium-webdriver (4.16.0) + selenium-webdriver (4.21.0) + base64 (~> 0.2) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) @@ -711,7 +791,7 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 6, < 8) tilt (>= 1.4.0) - sidekiq-unique-jobs (7.1.31) + sidekiq-unique-jobs (7.1.33) brpoplpush-redis_script (> 0.1.1, <= 2.0.0) concurrent-ruby (~> 1.0, >= 1.0.5) redis (< 5.0) @@ -729,14 +809,14 @@ GEM simplecov-html (0.12.3) simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) - smart_properties (1.17.0) - stackprof (0.2.25) + stackprof (0.2.26) statsd-ruby (1.5.0) - stoplight (3.0.2) + stoplight (4.1.0) redlock (~> 1.0) stringio (3.1.0) - strong_migrations (1.7.0) + strong_migrations (1.8.0) activerecord (>= 5.2) + strscan (3.1.0) swd (1.3.0) activesupport (>= 3) attr_required (>= 0.0.5) @@ -745,10 +825,10 @@ GEM temple (0.10.3) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - terrapin (0.6.0) - climate_control (>= 0.0.3, < 1.0) - test-prof (1.3.1) - thor (1.3.0) + terrapin (1.0.1) + climate_control + test-prof (1.3.3) + thor (1.3.1) tilt (2.3.0) timeout (0.4.1) tpm-key_attestation (0.12.0) @@ -764,19 +844,19 @@ GEM tty-cursor (~> 0.7) tty-screen (~> 0.8) wisper (~> 2.0) - tty-screen (0.8.1) + tty-screen (0.8.2) twitter-text (3.1.0) idn-ruby unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2023.4) + tzinfo-data (1.2024.1) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext - unf_ext (0.0.8.2) + unf_ext (0.0.9.1) unicode-display_width (2.5.0) - uri (0.12.2) + uri (0.13.0) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -797,7 +877,7 @@ GEM webfinger (1.2.0) activesupport httpclient (>= 2.4) - webmock (3.19.1) + webmock (3.23.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -815,7 +895,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.12) + zeitwerk (2.6.14) PLATFORMS ruby @@ -828,14 +908,14 @@ DEPENDENCIES better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.17.0) + bootsnap (~> 1.18.0) brakeman (~> 6.0) browser bundler-audit (~> 0.9) capybara (~> 3.39) charlock_holmes (~> 0.7.7) chewy (~> 7.3) - climate_control (~> 0.2) + climate_control cocoon (~> 1.2) color_diff (~> 0.1) concurrent-ruby @@ -844,11 +924,11 @@ DEPENDENCIES database_cleaner-active_record debug (~> 1.8) devise (~> 4.9) - devise-two-factor (~> 4.1) + devise-two-factor devise_pam_authenticatable2 (~> 9.2) discard (~> 1.2) doorkeeper (~> 5.6) - dotenv-rails (~> 2.8) + dotenv ed25519 (~> 1.3) email_spec fabrication (~> 2.30) @@ -863,11 +943,13 @@ DEPENDENCIES hcaptcha (~> 7.1) hiredis (~> 0.6) htmlentities (~> 4.3) - http (~> 5.1) + http (~> 5.2.0) http_accept_language (~> 2.1) httplog (~> 1.6.2) + i18n i18n-tasks (~> 1.0) idn-ruby + inline_svg irb (~> 1.8) json-ld json-ld-preloaded (~> 3.2) @@ -875,9 +957,10 @@ DEPENDENCIES kaminari (~> 1.2) kt-paperclip (~> 7.2) letter_opener (~> 1.8) - letter_opener_web (~> 2.0) + letter_opener_web (~> 3.0) link_header (~> 0.0) lograge (~> 0.12) + mail (~> 2.8) mario-redis-lock (~> 1.2) md-paperclip-azure (~> 2.2) memory_profiler @@ -885,18 +968,32 @@ DEPENDENCIES net-http (~> 0.4.0) net-ldap (~> 0.18) nokogiri (~> 1.15) - nsa! + nsa oj (~> 3.14) omniauth (~> 2.0) omniauth-cas (~> 3.0.0.beta.1) omniauth-rails_csrf_protection (~> 1.0) omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.6.1) + opentelemetry-exporter-otlp (~> 0.26.3) + opentelemetry-instrumentation-active_job (~> 0.7.1) + opentelemetry-instrumentation-active_model_serializers (~> 0.20.1) + opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2) + opentelemetry-instrumentation-excon (~> 0.22.0) + opentelemetry-instrumentation-faraday (~> 0.24.1) + opentelemetry-instrumentation-http (~> 0.23.2) + opentelemetry-instrumentation-http_client (~> 0.22.3) + opentelemetry-instrumentation-net_http (~> 0.22.4) + opentelemetry-instrumentation-pg (~> 0.27.1) + opentelemetry-instrumentation-rack (~> 0.24.1) + opentelemetry-instrumentation-rails (~> 0.30.0) + opentelemetry-instrumentation-redis (~> 0.25.3) + opentelemetry-instrumentation-sidekiq (~> 0.25.2) + opentelemetry-sdk (~> 1.4) ox (~> 2.14) parslet pg (~> 1.5) pghero - posix-spawn premailer-rails private_address_check (~> 0.5) propshaft @@ -917,7 +1014,7 @@ DEPENDENCIES rqrcode (~> 2.2) rspec-github (~> 2.4) rspec-rails (~> 6.0) - rspec-sidekiq (~> 4.0) + rspec-sidekiq (~> 5.0) rubocop rubocop-capybara rubocop-performance @@ -938,8 +1035,8 @@ DEPENDENCIES simplecov (~> 0.22) simplecov-lcov (~> 0.8) stackprof - stoplight (~> 3.0.1) - strong_migrations (= 1.7.0) + stoplight (~> 4.1) + strong_migrations (= 1.8.0) test-prof thor (~> 1.2) tty-prompt (~> 0.23) @@ -952,7 +1049,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.2.2p53 + ruby 3.3.1p55 BUNDLED WITH - 2.4.20 + 2.5.9 diff --git a/README.md b/README.md index 267f0ed295..e438d0bd7b 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,112 @@ -

- - - Mastodon -

+# ![kmyblue icon](https://raw.githubusercontent.com/kmycode/mastodon/kb_development/app/javascript/icons/favicon-32x32.png) kmyblue -[![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases] -[![Ruby Testing](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml) -[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] +[![Ruby Testing](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml) -[releases]: https://github.com/mastodon/mastodon/releases -[crowdin]: https://crowdin.com/project/mastodon +kmyblueは[Mastodon](https://github.com/mastodon/mastodon)のフォークです。創作作家のためのMastodonを目指して開発しました。 -Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!) +kmyblueはフォーク名であり、同時に[サーバー名](https://kmy.blue)でもあります。以下は特に記述がない限り、フォークとしてのkmyblueをさします。 -Click below to **learn more** in a video: +kmyblueは AGPL ライセンスで公開されているため、どなたでも自由にフォークし、このソースコードを元に自分でサーバーを立てて公開することができます。確かにサーバーkmyblueは創作作家向けのものですが、フォークとしてのkmyblueはAGPLでライセンスつけられており、ルールは全くの別物です。創作活動の一部(エロ関係含む)または全体を否定するコミュニティなどにも平等にお使いいただけます。サーバーkmyblueのルールを適用する必要もなく、「Anyone But Kmyblue」なルールを設定することすら許容されます。 +kmyblueは、特に非収載投稿の検索が強化されているため、ローカルタイムラインに掲載されていない投稿も検索・購読することが可能な場合があります。閉鎖的なコミュニティ、あまり目立ちたくないコミュニティには特に強力な機能を提供します。それ以外のコミュニティに対しても、kmyblueはプライバシーを考慮したうえで強力な検索・購読機能を提供するため、汎用サーバーとして利用するにもある程度十分な機能が揃っています。 -[![Screenshot](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/ezgif-2-60f1b00403.gif)][youtube_demo] +ただしkmyblueにおいて**テストコードは飾り**でしかありません。これはkmyblueを利用する人が本家Mastodonより圧倒的に少なく、バグやセキュリティインシデントを発見するだけの人数が足りないことを意味します。kmyblueは対策として自動テストを拡充しています。独自機能のテストを記述するだけでなく、本家のテストコードの補強も行っておりますが、確認漏れは必ず発生するものです。不具合が発生しても自己責任になります。既知のバグもいくつかありますし、直す予定のないものも含まれます。 -[youtube_demo]: https://www.youtube.com/watch?v=IPSbNdBmWKE +テストコード、Lint どちらも動いています。 -## Navigation +## インストール方法 -- [Project homepage 🐘](https://joinmastodon.org) -- [Support the development via Patreon][patreon] -- [View sponsors](https://joinmastodon.org/sponsors) -- [Blog](https://blog.joinmastodon.org) -- [Documentation](https://docs.joinmastodon.org) -- [Roadmap](https://joinmastodon.org/roadmap) -- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon) -- [Browse Mastodon servers](https://joinmastodon.org/communities) -- [Browse Mastodon apps](https://joinmastodon.org/apps) +[Wiki](https://github.com/kmycode/mastodon/wiki/Installation)を参照してください。 -[patreon]: https://www.patreon.com/mastodon +## 開発への参加方法 -## Features +CONTRIBUTING.mdを参照してください。 - +## テスト -### No vendor lock-in: Fully interoperable with any conforming platform +``` +# デバッグ実行(以下のいずれか) +foreman start +DB_USER=postgres DB_PASS=password foreman start -It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/) +# 一部を除く全てのテストを行う +RAILS_ENV=test bundle exec rspec spec -### Real-time, chronological timeline updates +# 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 +``` -Updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well! +## kmyblueのブランチ -### Media attachments like images and short videos +- **main** - 管理者が本家MastodonにPRするときに使うことがあります +- **kb_development** - 開発中の最新のソースコードです。メジャーバージョンアップデートは通常このブランチから公開されます +- **kb_lts** - LTSの管理に使います。LTSはこのブランチから公開されます +- **kb_patch** - 修正パッチの管理に使います。マイナーバージョンアップデートは通常このブランチから公開されます -Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously! +## kmyblueの強み -### Safety and moderation tools +追加の詳細は下記記事もご覧ください。 -Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/) +https://note.com/kmycode/n/n5fd5e823ed40 -### OAuth2 and a straightforward REST API +以下に書いているもの以外にも多数の機能が存在します。 -Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices! +### 本家Mastodonへの積極的追従 -## Deployment +kmyblueは、追加機能を控えめにする代わりに本家Mastodonに積極的に追従を行います。kmyblueの追加機能そのままに、Mastodonの新機能も利用できるよう調整を行います。 -### Tech stack +### ゆるやかな内輪での運用 -- **Ruby on Rails** powers the REST API and other web pages -- **React.js** and Redux are used for the dynamic parts of the interface -- **Node.js** powers the streaming API +kmyblueは同人向けサーバーとして出発したため、同人作家に需要のある「内輪ノリを外部にできるだけもらさない」という部分に特化しています。 -### Requirements +「ローカル公開」という機能によって、「ローカルタイムラインに流すが他のサーバーの連合タイムラインに流さない」投稿が可能です。ただしMisskeyのローカル限定とは異なり、他のサーバーのフォロワーのタイムラインにも投稿は流れます。自分のサーバーの中で内輪で盛り上がって、他のサーバーの連合タイムラインには外面だけの投稿を流すことも可能です。 -- **PostgreSQL** 12+ -- **Redis** 4+ -- **Ruby** 2.7+ -- **Node.js** 16+ +「サークル」という機能によって、特定のフォロワーにだけ見える投稿を行うことも可能です。その投稿に返信することで、相手サークルの会話に参加することも可能です。ただしサークル投稿を正常に処理できるソフトウェアは現在、kmyblue・Fedibirdに限ります。 -The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation. +また、通常のMastodonでは公開投稿を他のサーバーの人に自由に検索できるようにすることも可能ですが、kmyblueでは非収載投稿に対して同様の設定が可能です。つまり、ローカルタイムラインにも連合タイムラインにも流れない、誰かの目に自然に触れることはない、でも特定キーワードを使った検索では引っかかりたい、そのような需要に対応できます。 -## Development +内輪とは自分のサーバーに限ったものではありません。内輪同士で複数のサーバーを運営するとき、お互いが深く繋がれるフレンドサーバーというシステムも用意しています。 -### Vagrant +ただしkmyblueは、同時に連合も重視しています。ローカル限定投稿など、連合を大きく制限させるような機能は作る予定はありません。 -A **Vagrant** configuration is included for development purposes. To use it, complete the following steps: +### 少人数サーバーでの運用 -- Install Vagrant and Virtualbox -- Install the `vagrant-hostsupdater` plugin: `vagrant plugin install vagrant-hostsupdater` -- Run `vagrant up` -- Run `vagrant ssh -c "cd /vagrant && foreman start"` -- Open `http://mastodon.local` in your browser +kmyblueは、人の少ないサーバーでの運用を考慮して設計しています。そのため、他のサーバーのアカウントの購読機能はFedibirdほど発達していませんし、人の多いサーバー向けの独自改造もほとんど存在しません。 -### MacOS +ただしサーバーの負荷については一部度外視している部分があります。たとえば絵文字リアクション機能はサーバーへ著しい負荷をかける場合があります。絵文字リアクション機能そのものを無効にする管理者オプションも存在します。 -To set up **MacOS** for native development, complete the following steps: +もちろん人の多いサーバーでの運用が不便になるような修正は行っていません。人の多いサーバーでもそのままお使いいただけます。 -- Install the latest stable Ruby version (use a Ruby version manager for easy installation and management of Ruby versions) -- Run `brew install postgresql@14` -- Run `brew install redis` -- Run `brew install imagemagick` -- Run `brew install libidn` -- Install Foreman or a similar tool (such as [overmind](https://github.com/DarthSim/overmind)) to handle multiple process launching. -- Navigate to Mastodon's root directory and run `brew install nvm` then `nvm use` to use the version from .nvmrc -- Run `corepack enable && corepack prepare` -- Run `bundle exec rails db:setup` (optionally prepend `RAILS_ENV=development` to target the dev environment) -- Finally, run `overmind start -f Procfile.dev` +### 比較的高い防御力 -### Docker +kmyblueでは、「Fediverseは将来的に荒むのではないか」「Fediverseは将来的にスパムに溢れるのではないか」を念頭に設計している部分があります。 -For development with **Docker**, complete the following steps: +個別ユーザー向けの設定項目が複数あります。 -- Install Docker Desktop -- Run `docker compose -f .devcontainer/docker-compose.yml up -d` -- Run `docker compose -f .devcontainer/docker-compose.yml exec app .devcontainer/post-create.sh` -- Finally, run `docker compose -f .devcontainer/docker-compose.yml exec app foreman start -f Procfile.dev` +- Misskeyは、たとえMastodonの投稿であっても非収載投稿を自由に検索できますが、kmyblueではそれをブロックできるユーザー設定が存在します +- 他の人からの絵文字リアクションの受け入れを制限する設定も可能であり、例えば他のサーバーから好ましくない絵文字リアクションを受け取ることを防止できます +- 公開タイムラインの引用表示はデフォルトで無効になっています。不快な投稿を引用したものが公開タイムラインに流れても、ある程度は防止できます + - フィルター(ワードミュート)は、引用された投稿の内容にも適用されます。この場合、引用投稿そのものが表示されなくなります +- 自分のフォローしている相手の投稿をフィルターから除外する設定が存在します。防御を上げすぎると不便な箇所が出てくるので、そちらも緩和できるよう可能な限り配慮しています -If you are using an IDE with [support for the Development Container specification](https://containers.dev/supporting), it will run the above `docker compose` commands automatically. For **Visual Studio Code** this requires the [Dev Container extension](https://containers.dev/supporting#dev-containers). +管理者向けには、スパムへの利用を前提とした正規表現可能なNGワード設定、細かい指定が可能な拡張ドメインブロック機能を用意しています。 -### GitHub Codespaces +ただし防御力の高さは自由を犠牲にします。例えばkmyblueは、絵文字リアクションの表示サイズ調整機能など、MisskeyやFedibirdには当たり前のようにある表示設定は存在しません。騒がしくなるようなものはあまり作りたいとは考えていません。 -To get you coding in just a few minutes, GitHub Codespaces provides a web-based version of Visual Studio Code and a cloud-hosted development environment fully configured with the software needed for this project.. +### その他の主な機能 -- Click this button to create a new codespace:
- [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=52281283&devcontainer_path=.devcontainer%2Fcodespaces%2Fdevcontainer.json) -- Wait for the environment to build. This will take a few minutes. -- When the editor is ready, run `foreman start -f Procfile.dev` in the terminal. -- After a few seconds, a popup will appear with a button labeled _Open in Browser_. This will open Mastodon. -- On the _Ports_ tab, right click on the “stream” row and select _Port visibility_ → _Public_. +- 絵文字リアクションによる手軽な交流 +- 絵文字デッキによる頻繁に使用する絵文字の登録・選択 +- 検索機能の強化(検索許可) +- 投稿の引用 +- ブックマークの分類 -## Contributing +## kmyblueは何でないか -Mastodon is **free, open-source software** licensed under **AGPLv3**. +kmyblueは、Misskeyではありません。絵文字リアクションなどMisskeyと同様の機能はありますが、根本的にUIの使い勝手が違う他にも、例えばブックマークを分類できてもそれを公開する機能を作っていません。Misskeyは「楽しむ」をコンセプトにしていますが、kmyblueはMastodonの思想を受け継ぎ、炎上や喧騒を避けることのできる落ち着いた場所を目指しています。そのため、思想に合わない機能は実装しないか、大幅に弱体化しています。 -You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository or submit translations using Crowdin. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). +kmyblueは、Fedibirdではありません。確かにローカルタイムラインを無効にしFedibirdのような運営を可能にする設定は存在します。しかしkmyblueは本家追従を優先する観点からWebで対応する範囲をある程度絞り込んでいるため、Fedibirdにあるような豊富な表示設定は作っていません。絵文字の大きさすら調整することはできません。また、Fedibirdではアカウントの購読機能があります。kmyblueにも同様の機能はあるものの、Fedibirdのように一発ですぐできるようなUIではありません。購読機能は相手のフォローを伴わないため、特に利用者に擬似的なフォロー体験を与えるアカウント購読は、人の少ない小規模サーバーには向いていません。これは、小規模サーバーの運用を想定しているkmyblueがあえて作っていない部分です。 -**IRC channel**: #mastodon on irc.libera.chat - -## License - -Copyright (C) 2016-2024 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) - -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +kmyblueは、企業・政府機関向けに開発されたものではありません。そもそも管理者はセキュリティに関する資格や専門知識を有しておらず、高度なセキュリティの求められる機関向けのソフトウェアを制作する能力はありません。kmyblueは確かに本家Mastodonに対して大幅に機能を追加していますが、そもそも個人によるフォークは、開発者が飽きたらそこで終わりというリスクも伴います。高い信頼性・安全性を保証することはできないので、導入の際はご自身で安全を十分に確認してからお使いになることを強くおすすめします。 diff --git a/SECURITY.md b/SECURITY.md index 81472b01b4..d5b27adfac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,20 +1,25 @@ -# Security Policy +# セキュリティポリシー -If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can either: +kmyblueのプログラムにおいてセキュリティインシデントを発見した場合、kmyblueに報告してください。 -- open a [Github security issue on the Mastodon project](https://github.com/mastodon/mastodon/security/advisories/new) -- reach us at +kmyblueにセキュリティインシデントを報告する場合、以下の手順を踏んでください。 -You should _not_ report such issues on public GitHub issues or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk. +- [こちらのリンクから新規インシデントを起票してください](https://github.com/kmycode/mastodon/security/advisories/new) +- メール 、または[@askyq@kmy.blue](https://kmy.blue/@askyq)宛に、**セキュリティインシデントを起票したことだけ**を連絡してください。セキュリティインシデントの内容は、絶対に連絡に含めないでください(リンクくらいなら含めていいかな) -## Scope +他のkmyblueフォークの利用者の安全のために少しでも時間稼ぎをしなければいけないので、この問題をIssueを含む公開された場所で記述しないでください。 -A "vulnerability in Mastodon" is a vulnerability in the code distributed through our main source code repository on GitHub. Vulnerabilities that are specific to a given installation (e.g. misconfiguration) should be reported to the owner of that installation and not us. +## 範囲 -## Supported Versions +こちらが対応できる範囲は、当リポジトリで公開しているソースコードのみとなります。当リポジトリの依存パッケージ内に問題がある場合は、そちらに報告してください。 -| Version | Supported | -| ------- | --------- | -| 4.2.x | Yes | -| 4.1.x | Yes | -| < 4.1 | No | +もしあなたに専門知識があり、それが本家Mastodon由来の問題であると信じるに足る根拠がある場合、kmyblueではなくMastodonのほうに報告してください。kmyblueに報告されても、Mastodonより先に修正してしまうことでMastodonにセキュリティリスクを発生させる可能性がありますし、本家Mastodonの対応を待つにしてもkmyblueのほうに来てしまったセキュリティインシデントの対応に困ります(本家がなかなか対応してくれない可能性を考えると削除しづらい)。もし間違ってkmyblueに来た場合、kmyblue開発者の責任で振り分けを行います。 + +## サポートするバージョン + +下記以外のバージョンは、セキュリティインシデントを起票されても対応しません。 + +- 最新メジャーバージョン、かつ、最新マイナーバージョン + - 最新メジャーバージョンのサポートは、次のメジャーバージョンが出た時点で終了します +- LTS + - LTSのサポートは、次のLTSが出た時点で終了します(ただし移行期間があってもいいと思ってるので、1〜3ヶ月以内ならセキュリティインシデントの程度に応じて対応する可能性があります) diff --git a/Vagrantfile b/Vagrantfile index 6f0f511095..8a95e91f36 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -173,6 +173,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| # Otherwise, you can access the site at http://localhost:3000 and http://localhost:4000 , http://localhost:8080 config.vm.network :forwarded_port, guest: 3000, host: 3000 + config.vm.network :forwarded_port, guest: 3035, host: 3035 config.vm.network :forwarded_port, guest: 4000, host: 4000 config.vm.network :forwarded_port, guest: 8080, host: 8080 config.vm.network :forwarded_port, guest: 9200, host: 9200 @@ -188,7 +189,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.post_up_message = <(account) { account.public_following_count }) + field(:followers_count, type: 'long', value: ->(account) { account.public_followers_count }) field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties }) field(:last_status_at, type: 'date', value: ->(account) { clamp_date(account.last_status_at || account.created_at) }) - field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } - field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } - field(:text, type: 'text', analyzer: 'verbatim', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' } + field(:domain, type: 'keyword', value: ->(account) { account.domain || '' }) + field(:display_name, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('display_name', 'analyzer')) do + field :edge_ngram, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('display_name', 'edge_ngram', 'analyzer'), search_analyzer: ChewyConfig.instance.accounts_analyzers.dig('display_name', 'edge_ngram', 'search_analyzer') + end + field(:username, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('username', 'analyzer'), value: lambda { |account| + [account.username, account.domain].compact.join('@') + }) do + field :edge_ngram, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('username', 'edge_ngram', 'analyzer'), + search_analyzer: ChewyConfig.instance.accounts_analyzers.dig('username', 'edge_ngram', 'search_analyzer') + end + field(:text, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('text', 'analyzer'), value: ->(account) { account.searchable_text }) { field(:stemmed, type: 'text', analyzer: ChewyConfig.instance.accounts_analyzers.dig('text', 'stemmed', 'analyzer')) } end end diff --git a/app/chewy/public_statuses_index.rb b/app/chewy/public_statuses_index.rb index 09a4dfc093..b71406d3e3 100644 --- a/app/chewy/public_statuses_index.rb +++ b/app/chewy/public_statuses_index.rb @@ -3,66 +3,22 @@ class PublicStatusesIndex < Chewy::Index include DatetimeClampingConcern - settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: { - filter: { - english_stop: { - type: 'stop', - stopwords: '_english_', - }, - - english_stemmer: { - type: 'stemmer', - language: 'english', - }, - - english_possessive_stemmer: { - type: 'stemmer', - language: 'possessive_english', - }, - }, - - analyzer: { - verbatim: { - tokenizer: 'uax_url_email', - filter: %w(lowercase), - }, - - content: { - tokenizer: 'standard', - filter: %w( - lowercase - asciifolding - cjk_width - elision - english_possessive_stemmer - english_stop - english_stemmer - ), - }, - - hashtag: { - tokenizer: 'keyword', - filter: %w( - word_delimiter_graph - lowercase - asciifolding - cjk_width - ), - }, - }, - } + # ElasticSearch config is moved to "/config/elasticsearch.default.yml". + # Edit it when original Mastodon changed ElasticSearch config. + settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: ChewyConfig.instance.public_statuses index_scope ::Status.unscoped .kept .indexable - .includes(:media_attachments, :preloadable_poll, :tags, preview_cards_status: :preview_card) + .includes(:media_attachments, :preloadable_poll, :tags, :account, preview_cards_status: :preview_card) root date_detection: false do field(:id, type: 'long') field(:account_id, type: 'long') - field(:text, type: 'text', analyzer: 'verbatim', value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: 'content') } - field(:tags, type: 'text', analyzer: 'hashtag', value: ->(status) { status.tags.map(&:display_name) }) + field(:text, type: 'text', analyzer: ChewyConfig.instance.public_statuses_analyzers.dig('text', 'analyzer'), value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: ChewyConfig.instance.public_statuses_analyzers.dig('text', 'stemmed', 'analyzer')) } + field(:tags, type: 'text', analyzer: ChewyConfig.instance.public_statuses_analyzers.dig('tags', 'analyzer'), value: ->(status) { status.tags.map(&:display_name) }) field(:language, type: 'keyword') + field(:domain, type: 'keyword', value: ->(status) { status.account.domain || '' }) field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties }) field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) }) end diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index e739ccecb4..44cb86d755 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -3,64 +3,49 @@ class StatusesIndex < Chewy::Index include DatetimeClampingConcern - settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: { - filter: { - english_stop: { - type: 'stop', - stopwords: '_english_', - }, + # ElasticSearch config is moved to "/config/elasticsearch.default.yml". + # Edit it when original Mastodon changed ElasticSearch config. + settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: ChewyConfig.instance.statuses - english_stemmer: { - type: 'stemmer', - language: 'english', - }, - - english_possessive_stemmer: { - type: 'stemmer', - language: 'possessive_english', - }, - }, - - analyzer: { - verbatim: { - tokenizer: 'uax_url_email', - filter: %w(lowercase), - }, - - content: { - tokenizer: 'standard', - filter: %w( - lowercase - asciifolding - cjk_width - elision - english_possessive_stemmer - english_stop - english_stemmer - ), - }, - - hashtag: { - tokenizer: 'keyword', - filter: %w( - word_delimiter_graph - lowercase - asciifolding - cjk_width - ), - }, - }, - } - - index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :local_mentioned, :local_favorited, :local_reblogged, :local_bookmarked, :tags, preview_cards_status: :preview_card, preloadable_poll: :local_voters), delete_if: ->(status) { status.searchable_by.empty? } + index_scope ::Status.unscoped.kept.without_reblogs.includes( + :account, + :media_attachments, + :local_mentioned, + :local_favorited, + :local_reblogged, + :local_bookmarked, + :local_emoji_reacted, + :tags, + :local_referenced, + preview_cards_status: :preview_card, + preloadable_poll: :local_voters + ), + delete_if: lambda { |status| + if status.searchability == 'direct' + status.searchable_by.empty? + else + status.searchability == 'limited' ? !status.local? : false + end + } root date_detection: false do field(:id, type: 'long') field(:account_id, type: 'long') - field(:text, type: 'text', analyzer: 'verbatim', value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: 'content') } - field(:tags, type: 'text', analyzer: 'hashtag', value: ->(status) { status.tags.map(&:display_name) }) + field(:text, type: 'text', analyzer: ChewyConfig.instance.statuses_analyzers.dig('text', 'analyzer'), value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: ChewyConfig.instance.statuses_analyzers.dig('text', 'stemmed', 'analyzer')) } + field(:tags, type: 'text', analyzer: ChewyConfig.instance.statuses_analyzers.dig('tags', 'analyzer'), value: ->(status) { status.tags.map(&:display_name) }) field(:searchable_by, type: 'long', value: ->(status) { status.searchable_by }) + field(:mentioned_by, type: 'long', value: ->(status) { status.mentioned_by }) + field(:favourited_by, type: 'long', value: ->(status) { status.favourited_by }) + field(:reblogged_by, type: 'long', value: ->(status) { status.reblogged_by }) + field(:bookmarked_by, type: 'long', value: ->(status) { status.bookmarked_by }) + field(:bookmark_categoried_by, type: 'long', value: ->(status) { status.bookmark_categoried_by }) + field(:emoji_reacted_by, type: 'long', value: ->(status) { status.emoji_reacted_by }) + field(:referenced_by, type: 'long', value: ->(status) { status.referenced_by }) + field(:voted_by, type: 'long', value: ->(status) { status.voted_by }) + field(:searchability, type: 'keyword', value: ->(status) { status.compute_searchability }) + field(:visibility, type: 'keyword', value: ->(status) { status.searchable_visibility }) field(:language, type: 'keyword') + field(:domain, type: 'keyword', value: ->(status) { status.account.domain || '' }) field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties }) field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) }) end diff --git a/app/chewy/tags_index.rb b/app/chewy/tags_index.rb index c99218a47f..965718e83e 100644 --- a/app/chewy/tags_index.rb +++ b/app/chewy/tags_index.rb @@ -3,36 +3,9 @@ class TagsIndex < Chewy::Index include DatetimeClampingConcern - settings index: index_preset(refresh_interval: '30s'), analysis: { - analyzer: { - content: { - tokenizer: 'keyword', - filter: %w( - word_delimiter_graph - lowercase - asciifolding - cjk_width - ), - }, - - edge_ngram: { - tokenizer: 'edge_ngram', - filter: %w( - lowercase - asciifolding - cjk_width - ), - }, - }, - - tokenizer: { - edge_ngram: { - type: 'edge_ngram', - min_gram: 2, - max_gram: 15, - }, - }, - } + # ElasticSearch config is moved to "/config/elasticsearch.default.yml". + # Edit it when original Mastodon changed ElasticSearch config. + settings index: index_preset(refresh_interval: '30s'), analysis: ChewyConfig.instance.tags index_scope ::Tag.listable @@ -41,7 +14,9 @@ class TagsIndex < Chewy::Index end root date_detection: false do - field(:name, type: 'text', analyzer: 'content', value: :display_name) { field(:edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content') } + field(:name, type: 'text', analyzer: ChewyConfig.instance.tags_analyzers.dig('name', 'analyzer'), value: :display_name) do + field(:edge_ngram, type: 'text', analyzer: ChewyConfig.instance.tags_analyzers.dig('name', 'edge_ngram', 'analyzer'), search_analyzer: ChewyConfig.instance.tags_analyzers.dig('name', 'edge_ngram', 'search_analyzer')) + end field(:reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }) field(:usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts }) field(:last_status_at, type: 'date', value: ->(tag) { clamp_date(tag.last_status_at || tag.created_at) }) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 4e475fe782..98e68bd873 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -25,7 +25,7 @@ class AccountsController < ApplicationController limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE @statuses = filtered_statuses.without_reblogs.limit(limit) - @statuses = cache_collection(@statuses, Status) + @statuses = preload_collection(@statuses, Status) end format.json do @@ -46,7 +46,11 @@ class AccountsController < ApplicationController end def default_statuses - @account.statuses.where(visibility: [:public, :unlisted]) + if current_account.present? + @account.statuses.distributable_visibility + else + @account.statuses.distributable_visibility_for_anonymous + end end def only_media_scope diff --git a/app/controllers/activitypub/base_controller.rb b/app/controllers/activitypub/base_controller.rb index 388d4b9e1d..c2563c492e 100644 --- a/app/controllers/activitypub/base_controller.rb +++ b/app/controllers/activitypub/base_controller.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class ActivityPub::BaseController < Api::BaseController + include SignatureVerification + include AccountOwnedConcern + skip_before_action :require_authenticated_user! skip_before_action :require_not_suspended! skip_around_action :set_locale diff --git a/app/controllers/activitypub/claims_controller.rb b/app/controllers/activitypub/claims_controller.rb index 339333e462..480baaf2bc 100644 --- a/app/controllers/activitypub/claims_controller.rb +++ b/app/controllers/activitypub/claims_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class ActivityPub::ClaimsController < ActivityPub::BaseController - include SignatureVerification - include AccountOwnedConcern - skip_before_action :authenticate_user! before_action :require_account_signature! diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index 2188eb72a3..c25362c9bc 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class ActivityPub::CollectionsController < ActivityPub::BaseController - include SignatureVerification - include AccountOwnedConcern - vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature!, if: :authorized_fetch_mode? @@ -21,7 +18,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def set_items case params[:id] when 'featured' - @items = for_signed_account { cache_collection(@account.pinned_statuses, Status) } + @items = for_signed_account { preload_collection(@account.pinned_statuses, Status) } @items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) } when 'tags' @items = for_signed_account { @account.featured_tags } diff --git a/app/controllers/activitypub/contexts_controller.rb b/app/controllers/activitypub/contexts_controller.rb new file mode 100644 index 0000000000..a3263ed82e --- /dev/null +++ b/app/controllers/activitypub/contexts_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ActivityPub::ContextsController < ActivityPub::BaseController + include SignatureVerification + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :set_context + + def show + expires_in 3.minutes, public: true + render json: @context, + serializer: ActivityPub::ContextSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + + private + + def set_context + @context = Conversation.find(params[:id]) + end +end diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb index 976caa3445..392dd36bcd 100644 --- a/app/controllers/activitypub/followers_synchronizations_controller.rb +++ b/app/controllers/activitypub/followers_synchronizations_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController - include SignatureVerification - include AccountOwnedConcern - vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature! @@ -24,7 +21,7 @@ class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseContro end def set_items - @items = @account.followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(uri_prefix)}/%", false, true)).or(@account.followers.where(uri: uri_prefix)).pluck(:uri) + @items = @account.followers.matches_uri_prefix(uri_prefix).pluck(:uri) end def collection_presenter diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 5ee85474e7..49cfc8ad1c 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class ActivityPub::InboxesController < ActivityPub::BaseController - include SignatureVerification include JsonLdHelper - include AccountOwnedConcern before_action :skip_unknown_actor_activity before_action :require_actor_signature! @@ -24,7 +22,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController def unknown_affected_account? json = Oj.load(body, mode: :strict) - json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists? + json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.exists?(uri: json['actor']) rescue Oj::ParseError false end @@ -62,11 +60,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true' || signed_request_account.nil? # Re-using the syntax for signature parameters - tree = SignatureParamsParser.new.parse(raw_params) - params = SignatureParamsTransformer.new.apply(tree) + params = SignatureParser.parse(raw_params) ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params) - rescue Parslet::ParseFailed + rescue SignatureParser::ParsingError Rails.logger.warn 'Error parsing Collection-Synchronization header' end diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index bf10ba762a..82b1830941 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -3,9 +3,6 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController LIMIT = 20 - include SignatureVerification - include AccountOwnedConcern - vary_by -> { 'Signature' if authorized_fetch_mode? || page_requested? } before_action :require_account_signature!, if: :authorized_fetch_mode? @@ -37,7 +34,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController ActivityPub::CollectionPresenter.new( id: outbox_url, type: :ordered, - size: @account.statuses_count, + size: @account.user&.setting_hide_statuses_count ? 0 : @account.statuses_count, first: outbox_url(page: true), last: outbox_url(page: true, min_id: 0) ) @@ -63,7 +60,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController def set_statuses return unless page_requested? - @statuses = cache_collection_paginated_by_id( + @statuses = preload_collection_paginated_by_id( AccountStatusesFilter.new(@account, signed_request_account).results, Status, LIMIT, diff --git a/app/controllers/activitypub/references_controller.rb b/app/controllers/activitypub/references_controller.rb new file mode 100644 index 0000000000..8f41fd6922 --- /dev/null +++ b/app/controllers/activitypub/references_controller.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +class ActivityPub::ReferencesController < ActivityPub::BaseController + include SignatureVerification + include Authorization + include AccountOwnedConcern + + before_action :require_signature!, if: :authorized_fetch_mode? + before_action :set_status + + def index + expires_in 0, public: public_fetch_mode? + render json: references_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true + end + + private + + def pundit_user + signed_request_account + end + + def set_status + @status = @account.statuses.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def load_statuses + cached_references + end + + def cached_references + preload_collection(Status.where(id: results).reorder(:id), Status) + end + + def results + @results ||= begin + references = @status.reference_objects.order(target_status_id: :asc) + references = references.where('target_status_id > ?', page_params[:min_id]) if page_params[:min_id].present? + references = references.limit(limit_param(references_limit)) + references.pluck(:target_status_id) + end + end + + def references_limit + StatusReference::REFERENCES_LIMIT + end + + def pagination_min_id + results.last + end + + def records_continue? + results.size == limit_param(references_limit) + end + + def references_collection_presenter + page = ActivityPub::CollectionPresenter.new( + id: ActivityPub::TagManager.instance.references_uri_for(@status, page_params), + type: :unordered, + part_of: ActivityPub::TagManager.instance.references_uri_for(@status), + items: load_statuses.map(&:uri), + next: next_page + ) + + return page if page_requested? + + ActivityPub::CollectionPresenter.new( + type: :unordered, + id: ActivityPub::TagManager.instance.references_uri_for(@status), + first: page + ) + end + + def page_requested? + truthy_param?(:page) + end + + def next_page + return unless records_continue? + + ActivityPub::TagManager.instance.references_uri_for(@status, page_params.merge(min_id: pagination_min_id)) + end + + def page_params + params_slice(:min_id, :limit).merge(page: true) + end +end diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index c38ff89d1c..11aac48c9c 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class ActivityPub::RepliesController < ActivityPub::BaseController - include SignatureVerification include Authorization - include AccountOwnedConcern DESCENDANTS_LIMIT = 60 @@ -33,7 +31,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController def set_replies @replies = only_other_accounts? ? Status.where.not(account_id: @account.id).joins(:account).merge(Account.without_suspended) : @account.statuses - @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted]) + @replies = @replies.distributable_visibility.where(in_reply_to_id: @status.id) @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 9beb8fde6b..724af2360d 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -3,13 +3,13 @@ module Admin class AccountsController < BaseController before_action :set_account, except: [:index, :batch] - before_action :require_remote_account!, only: [:redownload] + before_action :require_remote_account!, only: [:redownload, :approve_remote, :reject_remote] before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] def index authorize :account, :index? - @accounts = filtered_accounts.page(params[:page]) + @accounts = filtered_accounts.page(params[:page]).without_count @form = Form::AccountBatch.new end @@ -66,6 +66,20 @@ module Admin redirect_to admin_accounts_path(status: 'pending'), notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct) end + def approve_remote + authorize @account, :approve_remote? + @account.approve_remote! + log_action :approve_remote, @account + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.approved_msg', username: @account.acct) + end + + def reject_remote + authorize @account, :reject_remote? + @account.reject_remote! + log_action :reject_remote, @account + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct) + end + def destroy authorize @account, :destroy? Admin::AccountDeletionWorker.perform_async(@account.id) @@ -128,7 +142,7 @@ module Admin def unblock_email authorize @account, :unblock_email? - CanonicalEmailBlock.where(reference_account: @account).delete_all + CanonicalEmailBlock.matching_account(@account).delete_all log_action :unblock_email, @account @@ -168,6 +182,12 @@ module Admin 'approve' elsif params[:reject] 'reject' + elsif params[:approve_remote] + 'approve_remote' + elsif params[:approve_remote_domain] + 'approve_remote_domain' + elsif params[:reject_remote] + 'reject_remote' end end end diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb index 37a00ad225..8b8e83fde7 100644 --- a/app/controllers/admin/action_logs_controller.rb +++ b/app/controllers/admin/action_logs_controller.rb @@ -6,7 +6,7 @@ module Admin def index authorize :audit_log, :index? - @auditable_accounts = Account.where(id: Admin::ActionLog.select('distinct account_id')).select(:id, :username) + @auditable_accounts = Account.auditable.select(:id, :username) end private diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb index 6f4e426797..702550eecc 100644 --- a/app/controllers/admin/confirmations_controller.rb +++ b/app/controllers/admin/confirmations_controller.rb @@ -3,11 +3,11 @@ module Admin class ConfirmationsController < BaseController before_action :set_user - before_action :check_confirmation, only: [:resend] + before_action :redirect_confirmed_user, only: [:resend], if: :user_confirmed? def create authorize @user, :confirm? - @user.confirm! + @user.mark_email_as_confirmed! log_action :confirm, @user redirect_to admin_accounts_path end @@ -25,11 +25,13 @@ module Admin private - def check_confirmation - if @user.confirmed? - flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') - redirect_to admin_accounts_path - end + def redirect_confirmed_user + flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') + redirect_to admin_accounts_path + end + + def user_confirmed? + @user.confirmed? end end end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index 00d069cdfb..34368f08a2 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -2,10 +2,12 @@ module Admin class CustomEmojisController < BaseController + before_action :set_custom_emoji, only: [:edit, :update] + def index authorize :custom_emoji, :index? - @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) + @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]).without_count @form = Form::CustomEmojiBatch.new end @@ -15,6 +17,10 @@ module Admin @custom_emoji = CustomEmoji.new end + def edit + authorize :custom_emoji, :create? + end + def create authorize :custom_emoji, :create? @@ -28,6 +34,19 @@ module Admin end end + def update + authorize :custom_emoji, :create? + + @custom_emoji.assign_attributes(update_params) + + if @custom_emoji.save + log_action :update, @custom_emoji + redirect_to admin_custom_emojis_path(filter_params), notice: I18n.t('admin.custom_emojis.updated_msg') + else + render :new + end + end + def batch authorize :custom_emoji, :index? @@ -43,8 +62,16 @@ module Admin private + def set_custom_emoji + @custom_emoji = CustomEmoji.find(params[:id]) + end + def resource_params - params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker) + params.require(:custom_emoji).permit(:shortcode, :image, :category_id, :visible_in_picker, :aliases_raw, :license) + end + + def update_params + params.require(:custom_emoji).permit(:category_id, :visible_in_picker, :aliases_raw, :license) end def filtered_custom_emojis diff --git a/app/controllers/admin/domain_allows_controller.rb b/app/controllers/admin/domain_allows_controller.rb index 31be1978bb..b0f139e3a8 100644 --- a/app/controllers/admin/domain_allows_controller.rb +++ b/app/controllers/admin/domain_allows_controller.rb @@ -25,6 +25,8 @@ class Admin::DomainAllowsController < Admin::BaseController def destroy authorize @domain_allow, :destroy? UnallowDomainService.new.call(@domain_allow) + log_action :destroy, @domain_allow + redirect_to admin_instances_path, notice: I18n.t('admin.domain_allows.destroyed_msg') end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 325b33df80..2a2faf9cce 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -88,15 +88,19 @@ module Admin end def update_params - params.require(:domain_block).permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) + params.require(:domain_block).permit(:severity, :reject_media, :reject_favourite, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, + :reject_straight_follow, :reject_new_follow, :reject_friend, :block_trends, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden) end def resource_params - params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) + params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, + :reject_straight_follow, :reject_new_follow, :reject_friend, :block_trends, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden) end def form_domain_block_batch_params - params.require(:form_domain_block_batch).permit(domain_blocks_attributes: [:enabled, :domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate]) + params.require(:form_domain_block_batch).permit(domain_blocks_attributes: [:enabled, :domain, :severity, :reject_media, :reject_favourite, :reject_reply_exclude_followers, + :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, :reject_new_follow, :reject_friend, :block_trends, :detect_invalid_subscription, + :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden]) end def action_from_button diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index ff754bc0b4..faa0a061a6 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -38,7 +38,7 @@ module Admin log_action :create, @email_domain_block (@email_domain_block.other_domains || []).uniq.each do |domain| - next if EmailDomainBlock.where(domain: domain).exists? + next if EmailDomainBlock.exists?(domain: domain) other_email_domain_block = EmailDomainBlock.create!(domain: domain, allow_with_approval: @email_domain_block.allow_with_approval, parent: @email_domain_block) log_action :create, other_email_domain_block diff --git a/app/controllers/admin/export_domain_blocks_controller.rb b/app/controllers/admin/export_domain_blocks_controller.rb index ffc4478172..8d7350c765 100644 --- a/app/controllers/admin/export_domain_blocks_controller.rb +++ b/app/controllers/admin/export_domain_blocks_controller.rb @@ -36,7 +36,17 @@ module Admin reject_reports: row.fetch('#reject_reports', false), private_comment: @global_private_comment, public_comment: row['#public_comment'], - obfuscate: row.fetch('#obfuscate', false)) + obfuscate: row.fetch('#obfuscate', false), + reject_favourite: row.fetch('#reject_favourite', false), + reject_send_sensitive: row.fetch('#reject_send_sensitive', false), + reject_hashtag: row.fetch('#reject_hashtag', false), + reject_straight_follow: row.fetch('#reject_straight_follow', false), + reject_new_follow: row.fetch('#reject_new_follow', false), + hidden: row.fetch('#hidden', false), + detect_invalid_subscription: row.fetch('#detect_invalid_subscription', false), + reject_reply_exclude_followers: row.fetch('#reject_reply_exclude_followers', false), + reject_friend: row.fetch('#reject_friend', false), + block_trends: row.fetch('#block_trends', false)) if domain_block.invalid? flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: domain_block.errors.full_messages.join(', ')) @@ -49,7 +59,7 @@ module Admin next end - @warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain) + @warning_domains = instances_from_imported_blocks.pluck(:domain) rescue ActionController::ParameterMissing flash.now[:alert] = I18n.t('admin.export_domain_blocks.no_file') set_dummy_import! @@ -58,18 +68,56 @@ module Admin private + def instances_from_imported_blocks + Instance.with_domain_follows(@domain_blocks.map(&:domain)) + end + def export_filename 'domain_blocks.csv' end def export_headers - %w(#domain #severity #reject_media #reject_reports #public_comment #obfuscate) + %w( + #domain + #severity + #reject_media + #reject_reports + #public_comment + #obfuscate + #reject_favourite + #reject_send_sensitive + #reject_hashtag + #reject_straight_follow + #reject_new_follow + #hidden + #detect_invalid_subscription + #reject_reply_exclude_followers + #reject_friend + #block_trends + ) end def export_data CSV.generate(headers: export_headers, write_headers: true) do |content| DomainBlock.with_limitations.order(id: :asc).each do |instance| - content << [instance.domain, instance.severity, instance.reject_media, instance.reject_reports, instance.public_comment, instance.obfuscate] + content << [ + instance.domain, + instance.severity, + instance.reject_media, + instance.reject_reports, + instance.public_comment, + instance.obfuscate, + instance.reject_favourite, + instance.reject_send_sensitive, + instance.reject_hashtag, + instance.reject_straight_follow, + instance.reject_new_follow, + instance.hidden, + instance.detect_invalid_subscription, + instance.reject_reply_exclude_followers, + instance.reject_friend, + instance.block_trends, + ] end end end diff --git a/app/controllers/admin/friend_servers_controller.rb b/app/controllers/admin/friend_servers_controller.rb new file mode 100644 index 0000000000..729d3b3912 --- /dev/null +++ b/app/controllers/admin/friend_servers_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Admin + class FriendServersController < BaseController + before_action :set_friend, except: [:index, :new, :create] + before_action :warn_signatures_not_enabled!, only: [:new, :edit, :create, :follow, :unfollow, :accept, :reject] + + def index + authorize :friend_server, :update? + @friends = FriendDomain.all + end + + def new + authorize :friend_server, :update? + @friend = FriendDomain.new + end + + def edit + authorize :friend_server, :update? + end + + def create + authorize :friend_server, :update? + + @friend = FriendDomain.new(resource_params) + + if @friend.save + @friend.follow! + redirect_to admin_friend_servers_path + else + render action: :new + end + end + + def update + authorize :friend_server, :update? + + if @friend.update(update_resource_params) + redirect_to admin_friend_servers_path + else + render action: :edit + end + end + + def destroy + authorize :friend_server, :update? + @friend.destroy + redirect_to admin_friend_servers_path + end + + def follow + authorize :friend_server, :update? + @friend.follow! + render action: :edit + end + + def unfollow + authorize :friend_server, :update? + @friend.unfollow! + render action: :edit + end + + def accept + authorize :friend_server, :update? + @friend.accept! + render action: :edit + end + + def reject + authorize :friend_server, :update? + @friend.reject! + render action: :edit + end + + private + + def set_friend + @friend = FriendDomain.find(params[:id]) + end + + def resource_params + params.require(:friend_domain).permit(:domain, :inbox_url, :available, :pseudo_relay, :delivery_local, :unlocked, :allow_all_posts) + end + + def update_resource_params + params.require(:friend_domain).permit(:inbox_url, :available, :pseudo_relay, :delivery_local, :unlocked, :allow_all_posts) + end + + def warn_signatures_not_enabled! + flash.now[:error] = I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode? + end + end +end diff --git a/app/controllers/admin/ng_rule_histories_controller.rb b/app/controllers/admin/ng_rule_histories_controller.rb new file mode 100644 index 0000000000..9dccefaf49 --- /dev/null +++ b/app/controllers/admin/ng_rule_histories_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Admin + class NgRuleHistoriesController < BaseController + before_action :set_ng_rule + before_action :set_histories + + PER_PAGE = 20 + + def show + authorize :ng_words, :show? + end + + private + + def set_ng_rule + @ng_rule = ::NgRule.find(params[:id]) + end + + def set_histories + @histories = NgRuleHistory.where(ng_rule_id: params[:id]).order(id: :desc).page(params[:page]).per(PER_PAGE) + end + end +end diff --git a/app/controllers/admin/ng_rules_controller.rb b/app/controllers/admin/ng_rules_controller.rb new file mode 100644 index 0000000000..f37424cced --- /dev/null +++ b/app/controllers/admin/ng_rules_controller.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Admin + class NgRulesController < BaseController + before_action :set_ng_rule, only: [:edit, :update, :destroy, :duplicate] + + def index + authorize :ng_words, :show? + + @ng_rules = ::NgRule.order(id: :asc) + end + + def new + authorize :ng_words, :show? + + @ng_rule = ::NgRule.build + end + + def edit + authorize :ng_words, :show? + end + + def create + authorize :ng_words, :create? + + begin + test_words! + rescue + flash[:alert] = I18n.t('admin.ng_rules.test_error') + redirect_to new_admin_ng_rule_path + return + end + + @ng_rule = ::NgRule.build(resource_params) + + if @ng_rule.save + redirect_to admin_ng_rules_path + else + render :new + end + end + + def update + authorize :ng_words, :create? + + begin + test_words! + rescue + flash[:alert] = I18n.t('admin.ng_rules.test_error') + redirect_to edit_admin_ng_rule_path(id: @ng_rule.id) + return + end + + if @ng_rule.update(resource_params) + redirect_to admin_ng_rules_path + else + render :edit + end + end + + def duplicate + authorize :ng_words, :create? + + @ng_rule = @ng_rule.copy! + + flash[:alert] = I18n.t('admin.ng_rules.copy_error') unless @ng_rule.save + + redirect_to admin_ng_rules_path + end + + def destroy + authorize :ng_words, :create? + + @ng_rule.destroy + redirect_to admin_ng_rules_path + end + + private + + def set_ng_rule + @ng_rule = ::NgRule.find(params[:id]) + end + + def resource_params + params.require(:ng_rule).permit(:title, :expires_in, :available, :account_domain, :account_username, :account_display_name, + :account_note, :account_field_name, :account_field_value, :account_avatar_state, + :account_header_state, :account_include_local, :status_spoiler_text, :status_text, :status_tag, + :status_sensitive_state, :status_cw_state, :status_media_state, :status_poll_state, + :status_mention_state, :status_reference_state, + :status_quote_state, :status_reply_state, :status_media_threshold, :status_poll_threshold, + :status_mention_threshold, :status_allow_follower_mention, + :reaction_allow_follower, :emoji_reaction_name, :emoji_reaction_origin_domain, + :status_reference_threshold, :account_allow_followed_by_local, :record_history_also_local, + status_visibility: [], status_searchability: [], reaction_type: []) + end + + def test_words! + arr = [ + resource_params[:account_domain], + resource_params[:account_username], + resource_params[:account_display_name], + resource_params[:account_note], + resource_params[:account_field_name], + resource_params[:account_field_value], + resource_params[:status_spoiler_text], + resource_params[:status_text], + resource_params[:status_tag], + resource_params[:emoji_reaction_name], + resource_params[:emoji_reaction_origin_domain], + ].compact_blank.join("\n") + + Admin::NgRule.extract_test!(arr) if arr.present? + end + end +end diff --git a/app/controllers/admin/ng_words/keywords_controller.rb b/app/controllers/admin/ng_words/keywords_controller.rb new file mode 100644 index 0000000000..9af38fab7b --- /dev/null +++ b/app/controllers/admin/ng_words/keywords_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Admin + class NgWords::KeywordsController < NgWordsController + def show + super + @ng_words = ::NgWord.caches.presence || [::NgWord.new] + end + + protected + + def validate + begin + ::NgWord.save_from_raws(settings_params_test) + return true + rescue + flash[:alert] = I18n.t('admin.ng_words.test_error') + redirect_to after_update_redirect_path + end + + false + end + + private + + def after_update_redirect_path + admin_ng_words_keywords_path + end + end +end diff --git a/app/controllers/admin/ng_words/settings_controller.rb b/app/controllers/admin/ng_words/settings_controller.rb new file mode 100644 index 0000000000..63edadfce5 --- /dev/null +++ b/app/controllers/admin/ng_words/settings_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Admin + class NgWords::SettingsController < NgWordsController + protected + + def after_update_redirect_path + admin_ng_words_settings_path + end + end +end diff --git a/app/controllers/admin/ng_words/white_list_controller.rb b/app/controllers/admin/ng_words/white_list_controller.rb new file mode 100644 index 0000000000..8fdb7df327 --- /dev/null +++ b/app/controllers/admin/ng_words/white_list_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Admin + class NgWords::WhiteListController < NgWordsController + def show + super + @white_list_domains = SpecifiedDomain.white_list_domain_caches.presence || [SpecifiedDomain.new] + end + + protected + + def validate + begin + SpecifiedDomain.save_from_raws_as_white_list(settings_params_list) + return true + rescue + flash[:alert] = I18n.t('admin.ng_words.save_error') + redirect_to after_update_redirect_path + end + + false + end + + def after_update_redirect_path + admin_ng_words_white_list_path + end + + private + + def settings_params_list + params.require(:form_admin_settings)[:specified_domains] + end + end +end diff --git a/app/controllers/admin/ng_words_controller.rb b/app/controllers/admin/ng_words_controller.rb new file mode 100644 index 0000000000..a70a435fa4 --- /dev/null +++ b/app/controllers/admin/ng_words_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Admin + class NgWordsController < BaseController + def show + authorize :ng_words, :show? + + @admin_settings = Form::AdminSettings.new + end + + def create + authorize :ng_words, :create? + + return unless validate + + @admin_settings = Form::AdminSettings.new(settings_params) + + if @admin_settings.save + flash[:notice] = I18n.t('generic.changes_saved_msg') + redirect_to after_update_redirect_path + else + render :show + end + end + + protected + + def validate + true + end + + def after_update_redirect_path + admin_ng_words_path + end + + private + + def settings_params + params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS) + end + + def settings_params_test + params.require(:form_admin_settings)[:ng_words_test] + end + end +end diff --git a/app/controllers/admin/ngword_histories_controller.rb b/app/controllers/admin/ngword_histories_controller.rb new file mode 100644 index 0000000000..90f13db2fe --- /dev/null +++ b/app/controllers/admin/ngword_histories_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Admin + class NgwordHistoriesController < BaseController + before_action :set_histories + + PER_PAGE = 20 + + def index + authorize :ng_words, :show? + end + + private + + def set_histories + @histories = NgwordHistory.order(id: :desc).page(params[:page]).per(PER_PAGE) + end + end +end diff --git a/app/controllers/admin/reports/actions_controller.rb b/app/controllers/admin/reports/actions_controller.rb index 554f7906f8..5572108d59 100644 --- a/app/controllers/admin/reports/actions_controller.rb +++ b/app/controllers/admin/reports/actions_controller.rb @@ -12,7 +12,7 @@ class Admin::Reports::ActionsController < Admin::BaseController authorize @report, :show? case action_from_button - when 'delete', 'mark_as_sensitive' + when 'delete', 'mark_as_sensitive', 'force_cw' status_batch_action = Admin::StatusBatchAction.new( type: action_from_button, status_ids: @report.status_ids, @@ -52,6 +52,8 @@ class Admin::Reports::ActionsController < Admin::BaseController 'delete' elsif params[:mark_as_sensitive] 'mark_as_sensitive' + elsif params[:force_cw] + 'force_cw' elsif params[:silence] 'silence' elsif params[:suspend] diff --git a/app/controllers/admin/rules_controller.rb b/app/controllers/admin/rules_controller.rb index d31aec6ea8..b8def22ba3 100644 --- a/app/controllers/admin/rules_controller.rb +++ b/app/controllers/admin/rules_controller.rb @@ -53,7 +53,7 @@ module Admin end def resource_params - params.require(:rule).permit(:text, :priority) + params.require(:rule).permit(:text, :hint, :priority) end end end diff --git a/app/controllers/admin/sensitive_words_controller.rb b/app/controllers/admin/sensitive_words_controller.rb new file mode 100644 index 0000000000..24cdd4efcb --- /dev/null +++ b/app/controllers/admin/sensitive_words_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Admin + class SensitiveWordsController < BaseController + def show + authorize :sensitive_words, :show? + + @admin_settings = Form::AdminSettings.new + @sensitive_words = ::SensitiveWord.caches.presence || [::SensitiveWord.new] + end + + def create + authorize :sensitive_words, :create? + + begin + ::SensitiveWord.save_from_raws(settings_params_test) + rescue + flash[:alert] = I18n.t('admin.ng_words.test_error') + redirect_to after_update_redirect_path + return + end + + @admin_settings = Form::AdminSettings.new(settings_params) + + if @admin_settings.save + flash[:notice] = I18n.t('generic.changes_saved_msg') + redirect_to after_update_redirect_path + else + render :index + end + end + + private + + def after_update_redirect_path + admin_sensitive_words_path + end + + def settings_params + params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS) + end + + def settings_params_test + params.require(:form_admin_settings)[:sensitive_words_test] + end + end +end diff --git a/app/controllers/admin/settings/registrations_controller.rb b/app/controllers/admin/settings/registrations_controller.rb index b4a74349c0..6dbc86df9a 100644 --- a/app/controllers/admin/settings/registrations_controller.rb +++ b/app/controllers/admin/settings/registrations_controller.rb @@ -1,9 +1,18 @@ # frozen_string_literal: true class Admin::Settings::RegistrationsController < Admin::SettingsController + include RegistrationLimitationHelper + + before_action :set_limitation_counts, only: :show # rubocop:disable Rails/LexicallyScopedActionFilter + private def after_update_redirect_path admin_settings_registrations_path end + + def set_limitation_counts + @current_users_count = user_count_for_registration + @current_users_count_today = today_increase_user_count + end end diff --git a/app/controllers/admin/site_uploads_controller.rb b/app/controllers/admin/site_uploads_controller.rb index a5d2cf41cf..96e61cf6bb 100644 --- a/app/controllers/admin/site_uploads_controller.rb +++ b/app/controllers/admin/site_uploads_controller.rb @@ -9,7 +9,7 @@ module Admin @site_upload.destroy! - redirect_to admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg') + redirect_back fallback_location: admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg') end private diff --git a/app/controllers/admin/special_domains_controller.rb b/app/controllers/admin/special_domains_controller.rb new file mode 100644 index 0000000000..0ddbf26786 --- /dev/null +++ b/app/controllers/admin/special_domains_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Admin + class SpecialDomainsController < BaseController + def show + authorize :instance, :show? + + @admin_settings = Form::AdminSettings.new + end + + def create + authorize :instance, :destroy? + + @admin_settings = Form::AdminSettings.new(settings_params) + + if @admin_settings.save + flash[:notice] = I18n.t('generic.changes_saved_msg') + redirect_to after_update_redirect_path + else + render :show + end + end + + private + + def after_update_redirect_path + admin_special_domains_path + end + + def settings_params + params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS) + end + end +end diff --git a/app/controllers/admin/special_instances_controller.rb b/app/controllers/admin/special_instances_controller.rb new file mode 100644 index 0000000000..3fd35d474e --- /dev/null +++ b/app/controllers/admin/special_instances_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Admin + class SpecialInstancesController < BaseController + def show + authorize :instance, :show? + + @admin_settings = Form::AdminSettings.new + end + + def create + authorize :instance, :destroy? + + @admin_settings = Form::AdminSettings.new(settings_params) + + if @admin_settings.save + flash[:notice] = I18n.t('generic.changes_saved_msg') + redirect_to after_update_redirect_path + else + render :show + end + end + + private + + def after_update_redirect_path + admin_special_instances_path + end + + def settings_params + params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS) + end + end +end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index e53b22dca3..2070a7c70c 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -4,7 +4,7 @@ module Admin class StatusesController < BaseController before_action :set_account before_action :set_statuses, except: :show - before_action :set_status, only: :show + before_action :set_status, only: [:show, :remove_history, :remove_media, :force_sensitive, :force_cw, :remove_status] PER_PAGE = 20 @@ -29,6 +29,65 @@ module Admin redirect_to after_create_redirect_path end + def remove_history + authorize [:admin, @status], :show? + UpdateStatusService.new.call( + @status, + edit_status_account_id, + no_history: true, + bypass_validation: true + ) + log_action(:remove_history, @status) + redirect_to admin_account_status_path + end + + def remove_media + authorize [:admin, @status], :show? + UpdateStatusService.new.call( + @status, + edit_status_account_id, + media_ids: [], + media_attributes: [], + bypass_validation: true + ) + log_action(:remove_media, @status) + redirect_to admin_account_status_path + end + + def force_sensitive + authorize [:admin, @status], :show? + UpdateStatusService.new.call( + @status, + edit_status_account_id, + sensitive: true, + bypass_validation: true + ) + log_action(:force_sensitive, @status) + redirect_to admin_account_status_path + end + + def force_cw + authorize [:admin, @status], :show? + UpdateStatusService.new.call( + @status, + edit_status_account_id, + spoiler_text: 'CW', + bypass_validation: true + ) + log_action(:force_cw, @status) + redirect_to admin_account_status_path + end + + def remove_status + authorize [:admin, @status], :show? + @status.discard_with_reblogs + StatusPin.find_by(status: @status)&.destroy + @status.account.statuses_count = @status.account.statuses_count - 1 + RemovalWorker.perform_async(@status.id, { 'redraft' => false }) + log_action(:remove_status, @status) + redirect_to admin_account_path + end + private def batched_ordered_status_edits @@ -62,6 +121,13 @@ module Admin @statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE) end + def edit_status_account_id + return @edit_account_id || @account.id if @edit_account_checked + + @edit_account_checked = true + @edit_account_id = Account.representative.id + end + def filter_params params.slice(*Admin::StatusFilter::KEYS).permit(*Admin::StatusFilter::KEYS) end diff --git a/app/controllers/antennas_controller.rb b/app/controllers/antennas_controller.rb new file mode 100644 index 0000000000..ca7ee5d2a2 --- /dev/null +++ b/app/controllers/antennas_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class AntennasController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :set_antenna, only: [:edit, :update, :destroy] + before_action :set_body_classes + before_action :set_cache_headers + + def index + @antennas = current_account.antennas.includes(:antenna_domains).includes(:antenna_tags).includes(:antenna_accounts) + end + + def edit; end + + def update + if @antenna.update(resource_params) + redirect_to antennas_path + else + render action: :edit + end + end + + def destroy + @antenna.destroy + redirect_to antennas_path + end + + private + + def set_antenna + @antenna = current_account.antennas.find(params[:id]) + end + + def resource_params + params.require(:antenna).permit(:title, :available, :expires_in) + end + + def thin_resource_params + params.require(:antenna).permit(:title) + end + + def set_body_classes + @body_classes = 'admin' + end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end +end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 98fa1897ef..cc4e22d27d 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true class Api::BaseController < ApplicationController - DEFAULT_STATUSES_LIMIT = 20 - DEFAULT_ACCOUNTS_LIMIT = 40 + DEFAULT_STATUSES_LIMIT = 20 + DEFAULT_ACCOUNTS_LIMIT = 40 include Api::RateLimitHeaders include Api::AccessTokenTrackingConcern include Api::CachingConcern include Api::ContentSecurityPolicy + include Api::ErrorHandling + include Api::Pagination skip_before_action :require_functional!, unless: :limited_federation_mode? @@ -18,51 +20,6 @@ class Api::BaseController < ApplicationController protect_from_forgery with: :null_session - rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| - render json: { error: e.to_s }, status: 422 - end - - rescue_from ActiveRecord::RecordNotUnique do - render json: { error: 'Duplicate record' }, status: 422 - end - - rescue_from Date::Error do - render json: { error: 'Invalid date supplied' }, status: 422 - end - - rescue_from ActiveRecord::RecordNotFound do - render json: { error: 'Record not found' }, status: 404 - end - - rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do - render json: { error: 'Remote data could not be fetched' }, status: 503 - end - - rescue_from OpenSSL::SSL::SSLError do - render json: { error: 'Remote SSL certificate could not be verified' }, status: 503 - end - - rescue_from Mastodon::NotPermittedError do - render json: { error: 'This action is not allowed' }, status: 403 - end - - rescue_from Seahorse::Client::NetworkingError do |e| - Rails.logger.warn "Storage server error: #{e}" - render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 - end - - rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do - render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 - end - - rescue_from Mastodon::RateLimitExceededError do - render json: { error: I18n.t('errors.429') }, status: 429 - end - - rescue_from ActionController::ParameterMissing, Mastodon::InvalidParameterError do |e| - render json: { error: e.to_s }, status: 400 - end - def doorkeeper_unauthorized_render_options(error: nil) { json: { error: error.try(:description) || 'Not authorized' } } end @@ -73,13 +30,6 @@ class Api::BaseController < ApplicationController protected - def set_pagination_headers(next_path = nil, prev_path = nil) - links = [] - links << [next_path, [%w(rel next)]] if next_path - links << [prev_path, [%w(rel prev)]] if prev_path - response.headers['Link'] = LinkHeader.new(links) unless links.empty? - end - def limit_param(default_limit) return default_limit unless params[:limit] @@ -108,10 +58,6 @@ class Api::BaseController < ApplicationController render json: { error: 'Your login is currently disabled' }, status: 403 if current_user&.account&.unavailable? end - def require_valid_pagination_options! - render json: { error: 'Pagination values for `offset` and `limit` must be positive' }, status: 400 if pagination_options_invalid? - end - def require_user! if !current_user render json: { error: 'This method requires an authenticated user' }, status: 422 @@ -140,10 +86,6 @@ class Api::BaseController < ApplicationController private - def pagination_options_invalid? - params.slice(:limit, :offset).values.map(&:to_i).any?(&:negative?) - end - def respond_with_error(code) render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code end diff --git a/app/controllers/api/v1/accounts/antennas_controller.rb b/app/controllers/api/v1/accounts/antennas_controller.rb new file mode 100644 index 0000000000..957a4fb555 --- /dev/null +++ b/app/controllers/api/v1/accounts/antennas_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::AntennasController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' } + before_action :require_user! + before_action :set_account + + def index + @antennas = @account.suspended? ? [] : @account.joined_antennas.where(account: current_account) + render json: @antennas, each_serializer: REST::AntennaSerializer + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end +end diff --git a/app/controllers/api/v1/accounts/circles_controller.rb b/app/controllers/api/v1/accounts/circles_controller.rb new file mode 100644 index 0000000000..1b21eb7ce4 --- /dev/null +++ b/app/controllers/api/v1/accounts/circles_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::CirclesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' } + before_action :require_user! + before_action :set_account + + def index + @circles = @account.suspended? ? [] : @account.joined_circles.where(account: current_account) + render json: @circles, each_serializer: REST::CircleSerializer + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end +end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 8f31336b9f..daa7f87364 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::Accounts::CredentialsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, except: [:update] + before_action -> { doorkeeper_authorize! :read, :'read:accounts', :'read:me' }, except: [:update] before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:update] before_action :require_user! @@ -31,6 +31,8 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController :locked, :bot, :discoverable, + :searchability, + :dissubscribable, :hide_collections, :indexable, fields_attributes: [:name, :value] @@ -45,6 +47,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController { settings_attributes: { default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy), + default_searchability: source_params.fetch(:searchability, @account.user.setting_default_searchability), default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive), default_language: source_params.fetch(:language, @account.user.setting_default_language), }, diff --git a/app/controllers/api/v1/accounts/exclude_antennas_controller.rb b/app/controllers/api/v1/accounts/exclude_antennas_controller.rb new file mode 100644 index 0000000000..c1f5c5981c --- /dev/null +++ b/app/controllers/api/v1/accounts/exclude_antennas_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::ExcludeAntennasController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' } + before_action :require_user! + before_action :set_account + + def index + @antennas = @account.suspended? ? [] : current_account.antennas.where('exclude_accounts @> \'[?]\'', @account.id) + render json: @antennas, each_serializer: REST::AntennaSerializer + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end +end diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index 21b1095f18..449866fa55 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -21,7 +21,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController return [] if hide_results? scope = default_accounts - scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? || current_account.id == @account.id + scope = scope.not_excluded_by_account(current_account) unless current_account.nil? || current_account.id == @account.id scope.merge(paginated_follows).to_a end @@ -30,7 +30,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController end def default_accounts - Account.includes(:active_relationships, :account_stat).references(:active_relationships) + Account.includes(:active_relationships, :account_stat, :user).references(:active_relationships) end def paginated_follows @@ -41,10 +41,6 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_account_followers_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 1db521f79c..c4f4313f8f 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -21,7 +21,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController return [] if hide_results? scope = default_accounts - scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? || current_account.id == @account.id + scope = scope.not_excluded_by_account(current_account) unless current_account.nil? || current_account.id == @account.id scope.merge(paginated_follows).to_a end @@ -30,7 +30,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController end def default_accounts - Account.includes(:passive_relationships, :account_stat).references(:passive_relationships) + Account.includes(:passive_relationships, :account_stat, :user).references(:passive_relationships) end def paginated_follows @@ -41,10 +41,6 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_account_following_index_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 3061fcb7e7..0ac7858e16 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -18,6 +18,7 @@ class Api::V1::Accounts::SearchController < Api::BaseController limit: limit_param(DEFAULT_ACCOUNTS_LIMIT), resolve: truthy_param?(:resolve), following: truthy_param?(:following), + follower: truthy_param?(:follower), offset: params[:offset] ) end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index fe4279302f..6213f6f9e1 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -4,12 +4,14 @@ class Api::V1::Accounts::StatusesController < Api::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :set_account - after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) } + after_action :insert_pagination_headers def index cache_if_unauthenticated! @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + emoji_reaction_permitted_account_ids: EmojiReactionAccountsPresenter.new(@statuses, current_user&.account_id) end private @@ -19,11 +21,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def load_statuses - @account.unavailable? ? [] : cached_account_statuses + @account.unavailable? ? [] : preloaded_account_statuses end - def cached_account_statuses - cache_collection_paginated_by_id( + def preloaded_account_statuses + preload_collection_paginated_by_id( AccountStatusesFilter.new(@account, current_account, params).results, Status, limit_param(DEFAULT_STATUSES_LIMIT), @@ -35,10 +37,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController params.slice(:limit, *AccountStatusesFilter::KEYS).permit(:limit, *AccountStatusesFilter::KEYS).merge(core_params) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_account_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -51,11 +49,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) end - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + def pagination_collection + @statuses end end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 23fc85b475..881aec13e2 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -9,16 +9,22 @@ class Api::V1::AccountsController < Api::BaseController before_action -> { doorkeeper_authorize! :follow, :write, :'write:blocks' }, only: [:block, :unblock] before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:create] - before_action :require_user!, except: [:show, :create] - before_action :set_account, except: [:create] - before_action :check_account_approval, except: [:create] - before_action :check_account_confirmation, except: [:create] + before_action :require_user!, except: [:index, :show, :create] + before_action :set_account, except: [:index, :create] + before_action :set_accounts, only: [:index] + before_action :check_account_approval, except: [:index, :create] + before_action :check_account_confirmation, except: [:index, :create] before_action :check_enabled_registrations, only: [:create] + before_action :check_accounts_limit, only: [:index] skip_before_action :require_authenticated_user!, only: :create override_rate_limit_headers :follow, family: :follows + def index + render json: @accounts, each_serializer: REST::AccountSerializer + end + def show cache_if_unauthenticated! render json: @account, serializer: REST::AccountSerializer @@ -38,7 +44,12 @@ class Api::V1::AccountsController < Api::BaseController def follow follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, languages: params.key?(:languages) ? params[:languages] : nil, with_rate_limit: true) - options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify?, languages: follow.languages } }, requested_map: { @account.id => false } } + options = if @account.locked? || current_user.account.silenced? || (current_user.account.bot? && @account.user&.setting_lock_follow_from_bot) + {} + else + { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify?, languages: follow.languages } }, + requested_map: { @account.id => false } } + end render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(**options) end @@ -79,6 +90,10 @@ class Api::V1::AccountsController < Api::BaseController @account = Account.find(params[:id]) end + def set_accounts + @accounts = Account.where(id: account_ids).without_unapproved + end + def check_account_approval raise(ActiveRecord::RecordNotFound) if @account.local? && @account.user_pending? end @@ -87,10 +102,22 @@ class Api::V1::AccountsController < Api::BaseController raise(ActiveRecord::RecordNotFound) if @account.local? && !@account.user_confirmed? end + def check_accounts_limit + raise(Mastodon::ValidationError) if account_ids.size > DEFAULT_ACCOUNTS_LIMIT + end + def relationships(**options) AccountRelationshipsPresenter.new([@account], current_user.account_id, **options) end + def account_ids + Array(accounts_params[:ids]).uniq.map(&:to_i) + end + + def accounts_params + params.permit(ids: []) + end + def account_params params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code) end diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index ff9cae6398..ff6f41e01d 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -125,10 +125,6 @@ class Api::V1::Admin::AccountsController < Api::BaseController translated_params end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -137,12 +133,8 @@ class Api::V1::Admin::AccountsController < Api::BaseController api_v1_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty? end - def pagination_max_id - @accounts.last.id - end - - def pagination_since_id - @accounts.first.id + def pagination_collection + @accounts end def records_continue? diff --git a/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb b/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb index 7b192b979f..701f668de6 100644 --- a/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb +++ b/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb @@ -65,10 +65,6 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController @canonical_email_block = CanonicalEmailBlock.find(params[:id]) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_canonical_email_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -77,12 +73,8 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController api_v1_admin_canonical_email_blocks_url(pagination_params(min_id: pagination_since_id)) unless @canonical_email_blocks.empty? end - def pagination_max_id - @canonical_email_blocks.last.id - end - - def pagination_since_id - @canonical_email_blocks.first.id + def pagination_collection + @canonical_email_blocks end def records_continue? diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb index dd54d67106..a7ae84e306 100644 --- a/app/controllers/api/v1/admin/domain_allows_controller.rb +++ b/app/controllers/api/v1/admin/domain_allows_controller.rb @@ -61,10 +61,6 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController DomainAllow.all end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -73,12 +69,8 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController api_v1_admin_domain_allows_url(pagination_params(min_id: pagination_since_id)) unless @domain_allows.empty? end - def pagination_max_id - @domain_allows.last.id - end - - def pagination_since_id - @domain_allows.first.id + def pagination_collection + @domain_allows end def records_continue? diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb index 2538c7c7c2..e225de1f2f 100644 --- a/app/controllers/api/v1/admin/domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb @@ -29,10 +29,11 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController def create authorize :domain_block, :create? + @domain_block = DomainBlock.new(resource_params) existing_domain_block = resource_params[:domain].present? ? DomainBlock.rule_for(resource_params[:domain]) : nil - return render json: existing_domain_block, serializer: REST::Admin::ExistingDomainBlockErrorSerializer, status: 422 if existing_domain_block.present? + return render json: existing_domain_block, serializer: REST::Admin::ExistingDomainBlockErrorSerializer, status: 422 if conflicts_with_existing_block?(@domain_block, existing_domain_block) - @domain_block = DomainBlock.create!(resource_params) + @domain_block.save! DomainBlockWorker.perform_async(@domain_block.id) log_action :create, @domain_block render json: @domain_block, serializer: REST::Admin::DomainBlockSerializer @@ -55,6 +56,10 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController private + def conflicts_with_existing_block?(domain_block, existing_domain_block) + existing_domain_block.present? && (existing_domain_block.domain == TagManager.instance.normalize_domain(domain_block.domain) || !domain_block.stricter_than?(existing_domain_block)) + end + def set_domain_blocks @domain_blocks = filtered_domain_blocks.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end @@ -69,11 +74,8 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController end def domain_block_params - params.permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) - end - - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) + params.permit(:severity, :reject_media, :reject_favourite, :reject_reply_exclude_followers, :reject_reports, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, + :reject_new_follow, :reject_friend, :block_trends, :detect_invalid_subscription, :private_comment, :public_comment, :obfuscate, :hidden) end def next_path @@ -84,12 +86,8 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController api_v1_admin_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @domain_blocks.empty? end - def pagination_max_id - @domain_blocks.last.id - end - - def pagination_since_id - @domain_blocks.first.id + def pagination_collection + @domain_blocks end def records_continue? @@ -101,6 +99,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController end def resource_params - params.permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) + params.permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply_exclude_followers, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, + :reject_new_follow, :reject_friend, :block_trends, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden) end end diff --git a/app/controllers/api/v1/admin/email_domain_blocks_controller.rb b/app/controllers/api/v1/admin/email_domain_blocks_controller.rb index df54b9f0a4..bdedb9d040 100644 --- a/app/controllers/api/v1/admin/email_domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/email_domain_blocks_controller.rb @@ -58,10 +58,6 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController params.permit(:domain, :allow_with_approval) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_email_domain_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -70,12 +66,8 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController api_v1_admin_email_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @email_domain_blocks.empty? end - def pagination_max_id - @email_domain_blocks.last.id - end - - def pagination_since_id - @email_domain_blocks.first.id + def pagination_collection + @email_domain_blocks end def records_continue? diff --git a/app/controllers/api/v1/admin/ip_blocks_controller.rb b/app/controllers/api/v1/admin/ip_blocks_controller.rb index 61c1912344..3625781149 100644 --- a/app/controllers/api/v1/admin/ip_blocks_controller.rb +++ b/app/controllers/api/v1/admin/ip_blocks_controller.rb @@ -63,10 +63,6 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController params.permit(:ip, :severity, :comment, :expires_in) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_ip_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -75,12 +71,8 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController api_v1_admin_ip_blocks_url(pagination_params(min_id: pagination_since_id)) unless @ip_blocks.empty? end - def pagination_max_id - @ip_blocks.last.id - end - - def pagination_since_id - @ip_blocks.first.id + def pagination_collection + @ip_blocks end def records_continue? diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb index 9dfb181a28..9b5beeab67 100644 --- a/app/controllers/api/v1/admin/reports_controller.rb +++ b/app/controllers/api/v1/admin/reports_controller.rb @@ -35,6 +35,7 @@ class Api::V1::Admin::ReportsController < Api::BaseController def update authorize @report, :update? @report.update!(report_params) + log_action :update, @report render json: @report, serializer: REST::Admin::ReportSerializer end @@ -88,10 +89,6 @@ class Api::V1::Admin::ReportsController < Api::BaseController params.permit(*FILTER_PARAMS) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_reports_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -100,12 +97,8 @@ class Api::V1::Admin::ReportsController < Api::BaseController api_v1_admin_reports_url(pagination_params(min_id: pagination_since_id)) unless @reports.empty? end - def pagination_max_id - @reports.last.id - end - - def pagination_since_id - @reports.first.id + def pagination_collection + @reports end def records_continue? diff --git a/app/controllers/api/v1/admin/tags_controller.rb b/app/controllers/api/v1/admin/tags_controller.rb index 6a7c9f5bf3..c754980720 100644 --- a/app/controllers/api/v1/admin/tags_controller.rb +++ b/app/controllers/api/v1/admin/tags_controller.rb @@ -44,10 +44,6 @@ class Api::V1::Admin::TagsController < Api::BaseController params.permit(:display_name, :trendable, :usable, :listable) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_tags_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -56,12 +52,8 @@ class Api::V1::Admin::TagsController < Api::BaseController api_v1_admin_tags_url(pagination_params(min_id: pagination_since_id)) unless @tags.empty? end - def pagination_max_id - @tags.last.id - end - - def pagination_since_id - @tags.first.id + def pagination_collection + @tags end def records_continue? diff --git a/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb index 5d9fcc82c0..8bb5e22716 100644 --- a/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb +++ b/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb @@ -42,10 +42,6 @@ class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseC @providers = PreviewCardProvider.all.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_trends_links_preview_card_providers_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -54,12 +50,8 @@ class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseC api_v1_admin_trends_links_preview_card_providers_url(pagination_params(min_id: pagination_since_id)) unless @providers.empty? end - def pagination_max_id - @providers.last.id - end - - def pagination_since_id - @providers.first.id + def pagination_collection + @providers end def records_continue? diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb new file mode 100644 index 0000000000..9bc8e68ac2 --- /dev/null +++ b/app/controllers/api/v1/annual_reports_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::AnnualReportsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index + before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index + before_action :require_user! + before_action :set_annual_report, except: :index + + def index + with_read_replica do + @presenter = AnnualReportsPresenter.new(GeneratedAnnualReport.where(account_id: current_account.id).pending) + @relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id) + end + + render json: @presenter, + serializer: REST::AnnualReportsSerializer, + relationships: @relationships + end + + def read + @annual_report.view! + render_empty + end + + private + + def set_annual_report + @annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id]) + end +end diff --git a/app/controllers/api/v1/antennas/accounts_controller.rb b/app/controllers/api/v1/antennas/accounts_controller.rb new file mode 100644 index 0000000000..c50cbcdf3f --- /dev/null +++ b/app/controllers/api/v1/antennas/accounts_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::AccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_antenna + + after_action :insert_pagination_headers, only: :show + + def show + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + def create + ApplicationRecord.transaction do + antenna_accounts.each do |account| + @antenna.antenna_accounts.create!(account: account, exclude: false) + @antenna.update!(any_accounts: false) if @antenna.any_accounts + end + end + + render_empty + end + + def destroy + AntennaAccount.where(antenna: @antenna, account_id: account_ids).destroy_all + @antenna.update!(any_accounts: true) unless @antenna.antenna_accounts.where(exclude: false).any? + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def load_accounts + if unlimited? + @antenna.accounts.without_suspended.includes(:account_stat).all + else + @antenna.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + end + end + + def antenna_accounts + Account.find(account_ids) + end + + def account_ids + Array(resource_params[:account_ids]) + end + + def resource_params + params.permit(account_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_list_accounts_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/antennas/domains_controller.rb b/app/controllers/api/v1/antennas/domains_controller.rb new file mode 100644 index 0000000000..554b8d613c --- /dev/null +++ b/app/controllers/api/v1/antennas/domains_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::DomainsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_antenna + + def show + @domains = load_domains + @exclude_domains = load_exclude_domains + render json: { domains: @domains, exclude_domains: @exclude_domains } + end + + def create + ApplicationRecord.transaction do + domains.each do |domain| + @antenna.antenna_domains.create!(name: domain, exclude: false) + @antenna.update!(any_domains: false) if @antenna.any_domains + end + end + + render_empty + end + + def destroy + AntennaDomain.where(antenna: @antenna, name: domains).destroy_all + @antenna.update!(any_domains: true) unless @antenna.antenna_domains.where(exclude: false).any? + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def load_domains + @antenna.antenna_domains.pluck(:name) + end + + def load_exclude_domains + @antenna.exclude_domains || [] + end + + def domains + Array(resource_params[:domains]) + end + + def resource_params + params.permit(domains: []) + end +end diff --git a/app/controllers/api/v1/antennas/exclude_accounts_controller.rb b/app/controllers/api/v1/antennas/exclude_accounts_controller.rb new file mode 100644 index 0000000000..cdb9173c11 --- /dev/null +++ b/app/controllers/api/v1/antennas/exclude_accounts_controller.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::ExcludeAccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_antenna + + after_action :insert_pagination_headers, only: :show + + def show + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + def create + new_accounts = @antenna.exclude_accounts || [] + antenna_accounts.each do |account| + raise Mastodon::ValidationError, I18n.t('antennas.errors.duplicate_account') if new_accounts.include?(account.id) + + new_accounts << account.id + end + + raise Mastodon::ValidationError, I18n.t('antennas.errors.limit.accounts') if new_accounts.size > Antenna::ACCOUNTS_PER_ANTENNA_LIMIT + + @antenna.update!(exclude_accounts: new_accounts) + + render_empty + end + + def destroy + new_accounts = @antenna.exclude_accounts || [] + new_accounts -= antenna_accounts.pluck(:id) + + @antenna.update!(exclude_accounts: new_accounts) + + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def load_accounts + return [] if @antenna.exclude_accounts.nil? + + if unlimited? + Account.where(id: @antenna.exclude_accounts).without_suspended.includes(:account_stat).all + else + Account.where(id: @antenna.exclude_accounts).without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + end + end + + def antenna_accounts + Account.find(account_ids) + end + + def account_ids + Array(resource_params[:account_ids]) + end + + def resource_params + params.permit(account_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_list_accounts_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/antennas/exclude_domains_controller.rb b/app/controllers/api/v1/antennas/exclude_domains_controller.rb new file mode 100644 index 0000000000..235a44d593 --- /dev/null +++ b/app/controllers/api/v1/antennas/exclude_domains_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::ExcludeDomainsController < Api::BaseController + before_action -> { doorkeeper_authorize! :write, :'write:lists' } + + before_action :require_user! + before_action :set_antenna + + def create + new_domains = @antenna.exclude_domains || [] + domains.each do |domain| + raise Mastodon::ValidationError, I18n.t('antennas.errors.duplicate_domain') if new_domains.include?(domain) + + new_domains << domain + end + + raise Mastodon::ValidationError, I18n.t('antennas.errors.limit.domains') if new_domains.size > Antenna::KEYWORDS_PER_ANTENNA_LIMIT + + @antenna.update!(exclude_domains: new_domains) + + render_empty + end + + def destroy + new_domains = @antenna.exclude_domains || [] + new_domains -= domains + + @antenna.update!(exclude_domains: new_domains) + + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def domains + Array(resource_params[:domains]) + end + + def resource_params + params.permit(domains: []) + end +end diff --git a/app/controllers/api/v1/antennas/exclude_keywords_controller.rb b/app/controllers/api/v1/antennas/exclude_keywords_controller.rb new file mode 100644 index 0000000000..171dac5f80 --- /dev/null +++ b/app/controllers/api/v1/antennas/exclude_keywords_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::ExcludeKeywordsController < Api::BaseController + before_action -> { doorkeeper_authorize! :write, :'write:lists' } + + before_action :require_user! + before_action :set_antenna + + def create + new_keywords = @antenna.exclude_keywords || [] + keywords.each do |keyword| + raise Mastodon::ValidationError, I18n.t('antennas.errors.duplicate_keyword') if new_keywords.include?(keyword) + + new_keywords << keyword + end + + raise Mastodon::ValidationError, I18n.t('antennas.errors.limit.keywords') if new_keywords.size > Antenna::KEYWORDS_PER_ANTENNA_LIMIT + + @antenna.update!(exclude_keywords: new_keywords) + + render_empty + end + + def destroy + new_keywords = @antenna.exclude_keywords || [] + new_keywords -= keywords + + @antenna.update!(exclude_keywords: new_keywords) + + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def keywords + Array(resource_params[:keywords]) + end + + def resource_params + params.permit(keywords: []) + end +end diff --git a/app/controllers/api/v1/antennas/exclude_tags_controller.rb b/app/controllers/api/v1/antennas/exclude_tags_controller.rb new file mode 100644 index 0000000000..bf9e087369 --- /dev/null +++ b/app/controllers/api/v1/antennas/exclude_tags_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::ExcludeTagsController < Api::BaseController + before_action -> { doorkeeper_authorize! :write, :'write:lists' } + + before_action :require_user! + before_action :set_antenna + + def create + new_tags = @antenna.exclude_tags || [] + tags.map(&:id).each do |tag| + raise Mastodon::ValidationError, I18n.t('antennas.errors.duplicate_tag') if new_tags.include?(tag) + + new_tags << tag + end + + raise Mastodon::ValidationError, I18n.t('antennas.errors.limit.tags') if new_tags.size > Antenna::TAGS_PER_ANTENNA_LIMIT + + @antenna.update!(exclude_tags: new_tags) + + render_empty + end + + def destroy + new_tags = @antenna.exclude_tags || [] + new_tags -= exist_tags.pluck(:id) + + @antenna.update!(exclude_tags: new_tags) + + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def tags + Tag.find_or_create_by_names(Array(resource_params[:tags])) + end + + def exist_tags + Tag.matching_name(Array(resource_params[:tags])) + end + + def resource_params + params.permit(tags: []) + end +end diff --git a/app/controllers/api/v1/antennas/keywords_controller.rb b/app/controllers/api/v1/antennas/keywords_controller.rb new file mode 100644 index 0000000000..5260a66bc0 --- /dev/null +++ b/app/controllers/api/v1/antennas/keywords_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::KeywordsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_antenna + + def show + @keywords = load_keywords + @exclude_keywords = load_exclude_keywords + render json: { keywords: @keywords, exclude_keywords: @exclude_keywords } + end + + def create + new_keywords = @antenna.keywords || [] + keywords.each do |keyword| + raise Mastodon::ValidationError, I18n.t('antennas.errors.duplicate_keyword') if new_keywords.include?(keyword) + raise Mastodon::ValidationError, I18n.t('antennas.errors.too_short_keyword') if keyword.length < 2 + + new_keywords << keyword + end + + raise Mastodon::ValidationError, I18n.t('antennas.errors.limit.keywords') if new_keywords.size > Antenna::KEYWORDS_PER_ANTENNA_LIMIT + + @antenna.update!(keywords: new_keywords, any_keywords: new_keywords.empty?) + + render_empty + end + + def destroy + new_keywords = @antenna.keywords || [] + new_keywords -= keywords + + @antenna.update!(keywords: new_keywords, any_keywords: new_keywords.empty?) + + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def load_keywords + @antenna.keywords || [] + end + + def load_exclude_keywords + @antenna.exclude_keywords || [] + end + + def keywords + Array(resource_params[:keywords]) + end + + def resource_params + params.permit(keywords: []) + end +end diff --git a/app/controllers/api/v1/antennas/tags_controller.rb b/app/controllers/api/v1/antennas/tags_controller.rb new file mode 100644 index 0000000000..fe0bb6b4eb --- /dev/null +++ b/app/controllers/api/v1/antennas/tags_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::TagsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_antenna + + def show + @tags = load_tags + @exclude_tags = load_exclude_tags + render json: { tags: @tags, exclude_tags: @exclude_tags.pluck(:name) } + end + + def create + ApplicationRecord.transaction do + tags.each do |tag| + @antenna.antenna_tags.create!(tag: tag, exclude: false) + @antenna.update!(any_tags: false) if @antenna.any_tags + end + end + + render_empty + end + + def destroy + AntennaTag.where(antenna: @antenna, tag: exist_tags).destroy_all + @antenna.update!(any_tags: true) unless @antenna.antenna_tags.where(exclude: false).any? + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def load_tags + @antenna.tags.pluck(:name) + end + + def load_exclude_tags + Tag.where(id: @antenna.exclude_tags || []) + end + + def tags + Tag.find_or_create_by_names(Array(resource_params[:tags])) + end + + def exist_tags + Tag.matching_name(Array(resource_params[:tags])) + end + + def resource_params + params.permit(tags: []) + end +end diff --git a/app/controllers/api/v1/antennas_controller.rb b/app/controllers/api/v1/antennas_controller.rb new file mode 100644 index 0000000000..37bfb7f552 --- /dev/null +++ b/app/controllers/api/v1/antennas_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Api::V1::AntennasController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show] + + before_action :require_user! + before_action :set_antenna, except: [:index, :create] + + rescue_from ArgumentError do |e| + render json: { error: e.to_s }, status: 422 + end + + def index + @antennas = Antenna.where(account: current_account).all + render json: @antennas, each_serializer: REST::AntennaSerializer + end + + def show + render json: @antenna, serializer: REST::AntennaSerializer + end + + def create + @antenna = Antenna.create!(antenna_params.merge(account: current_account, list_id: 0)) + render json: @antenna, serializer: REST::AntennaSerializer + end + + def update + @antenna.update!(antenna_params) + render json: @antenna, serializer: REST::AntennaSerializer + end + + def destroy + @antenna.destroy! + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:id]) + end + + def antenna_params + params.permit(:title, :list_id, :insert_feeds, :stl, :ltl, :with_media_only, :ignore_reblog) + end +end diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index 06a8bfa891..234ab2e82c 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -17,7 +17,7 @@ class Api::V1::BlocksController < Api::BaseController end def paginated_blocks - @paginated_blocks ||= Block.eager_load(target_account: :account_stat) + @paginated_blocks ||= Block.eager_load(target_account: [:account_stat, :user]) .joins(:target_account) .merge(Account.without_suspended) .where(account: current_account) @@ -28,10 +28,6 @@ class Api::V1::BlocksController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_blocks_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -40,12 +36,8 @@ class Api::V1::BlocksController < Api::BaseController api_v1_blocks_url pagination_params(since_id: pagination_since_id) unless paginated_blocks.empty? end - def pagination_max_id - paginated_blocks.last.id - end - - def pagination_since_id - paginated_blocks.first.id + def pagination_collection + paginated_blocks end def records_continue? diff --git a/app/controllers/api/v1/bookmark_categories/statuses_controller.rb b/app/controllers/api/v1/bookmark_categories/statuses_controller.rb new file mode 100644 index 0000000000..a195fce97d --- /dev/null +++ b/app/controllers/api/v1/bookmark_categories/statuses_controller.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +class Api::V1::BookmarkCategories::StatusesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_bookmark_category + + after_action :insert_pagination_headers, only: :show + + def show + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer + end + + def create + ApplicationRecord.transaction do + bookmark_category_statuses.each do |status| + Bookmark.find_or_create_by!(account: current_account, status: status) + @bookmark_category.statuses << status + end + end + + render_empty + end + + def destroy + BookmarkCategoryStatus.where(bookmark_category: @bookmark_category, status_id: status_ids).destroy_all + render_empty + end + + private + + def set_bookmark_category + @bookmark_category = current_account.bookmark_categories.find(params[:bookmark_category_id]) + end + + def load_statuses + if unlimited? + @bookmark_category.statuses.includes(:status_stat).all + else + @bookmark_category.statuses.includes(:status_stat).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) + end + end + + def bookmark_category_statuses + Status.find(status_ids) + end + + def status_ids + Array(resource_params[:status_ids]) + end + + def resource_params + params.permit(status_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_bookmark_category_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_bookmark_category_statuses_url pagination_params(since_id: pagination_since_id) unless @statuses.empty? + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end + + def records_continue? + @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/bookmark_categories_controller.rb b/app/controllers/api/v1/bookmark_categories_controller.rb new file mode 100644 index 0000000000..c32828630d --- /dev/null +++ b/app/controllers/api/v1/bookmark_categories_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Api::V1::BookmarkCategoriesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show] + + before_action :require_user! + before_action :set_bookmark_category, except: [:index, :create] + + rescue_from ArgumentError do |e| + render json: { error: e.to_s }, status: 422 + end + + def index + @bookmark_categories = BookmarkCategory.where(account: current_account).all + render json: @bookmark_categories, each_serializer: REST::BookmarkCategorySerializer + end + + def show + render json: @bookmark_category, serializer: REST::BookmarkCategorySerializer + end + + def create + @bookmark_category = BookmarkCategory.create!(bookmark_category_params.merge(account: current_account)) + render json: @bookmark_category, serializer: REST::BookmarkCategorySerializer + end + + def update + @bookmark_category.update!(bookmark_category_params) + render json: @bookmark_category, serializer: REST::BookmarkCategorySerializer + end + + def destroy + @bookmark_category.destroy! + render_empty + end + + private + + def set_bookmark_category + @bookmark_category = BookmarkCategory.where(account: current_account).find(params[:id]) + end + + def bookmark_category_params + params.permit(:title) + end +end diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb index 498eb16f44..a2209d812e 100644 --- a/app/controllers/api/v1/bookmarks_controller.rb +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -7,17 +7,19 @@ class Api::V1::BookmarksController < Api::BaseController def index @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + emoji_reaction_permitted_account_ids: EmojiReactionAccountsPresenter.new(@statuses, current_user&.account_id) end private def load_statuses - cached_bookmarks + preloaded_bookmarks end - def cached_bookmarks - cache_collection(results.map(&:status), Status) + def preloaded_bookmarks + preload_collection(results.map(&:status), Status) end def results @@ -31,10 +33,6 @@ class Api::V1::BookmarksController < Api::BaseController current_account.bookmarks end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -43,12 +41,8 @@ class Api::V1::BookmarksController < Api::BaseController api_v1_bookmarks_url pagination_params(min_id: pagination_since_id) unless results.empty? end - def pagination_max_id - results.last.id - end - - def pagination_since_id - results.first.id + def pagination_collection + results end def records_continue? diff --git a/app/controllers/api/v1/circles/accounts_controller.rb b/app/controllers/api/v1/circles/accounts_controller.rb new file mode 100644 index 0000000000..e0d43bd950 --- /dev/null +++ b/app/controllers/api/v1/circles/accounts_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Api::V1::Circles::AccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_circle + + after_action :insert_pagination_headers, only: :show + + def show + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + def create + ApplicationRecord.transaction do + circle_accounts.each do |account| + @circle.accounts << account + end + end + + render_empty + end + + def destroy + CircleAccount.where(circle: @circle, account_id: account_ids).destroy_all + render_empty + end + + private + + def set_circle + @circle = Circle.where(account: current_account).find(params[:circle_id]) + end + + def load_accounts + if unlimited? + @circle.accounts.without_suspended.includes(:account_stat).all + else + @circle.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + end + end + + def circle_accounts + Account.find(account_ids) + end + + def account_ids + Array(resource_params[:account_ids]) + end + + def resource_params + params.permit(account_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_circle_accounts_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_circle_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/circles/statuses_controller.rb b/app/controllers/api/v1/circles/statuses_controller.rb new file mode 100644 index 0000000000..705731936b --- /dev/null +++ b/app/controllers/api/v1/circles/statuses_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class Api::V1::Circles::StatusesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + + before_action :require_user! + before_action :set_circle + + after_action :insert_pagination_headers, only: :show + + def show + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer + end + + private + + def set_circle + @circle = current_account.circles.find(params[:circle_id]) + end + + def load_statuses + if unlimited? + @circle.statuses.includes(:status_stat).all + else + @circle.statuses.includes(:status_stat).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) + end + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_circle_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_circle_statuses_url pagination_params(since_id: pagination_since_id) unless @statuses.empty? + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end + + def records_continue? + @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/circles_controller.rb b/app/controllers/api/v1/circles_controller.rb new file mode 100644 index 0000000000..53c9adf14e --- /dev/null +++ b/app/controllers/api/v1/circles_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Api::V1::CirclesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show] + + before_action :require_user! + before_action :set_circle, except: [:index, :create] + + rescue_from ArgumentError do |e| + render json: { error: e.to_s }, status: 422 + end + + def index + @circles = Circle.where(account: current_account).all + render json: @circles, each_serializer: REST::CircleSerializer + end + + def show + render json: @circle, serializer: REST::CircleSerializer + end + + def create + @circle = Circle.create!(circle_params.merge(account: current_account)) + render json: @circle, serializer: REST::CircleSerializer + end + + def update + @circle.update!(circle_params) + render json: @circle, serializer: REST::CircleSerializer + end + + def destroy + @circle.destroy! + render_empty + end + + private + + def set_circle + @circle = Circle.where(account: current_account).find(params[:id]) + end + + def circle_params + params.permit(:title) + end +end diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb index 6a3567e624..a95c816e1c 100644 --- a/app/controllers/api/v1/conversations_controller.rb +++ b/app/controllers/api/v1/conversations_controller.rb @@ -53,10 +53,6 @@ class Api::V1::ConversationsController < Api::BaseController .to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_conversations_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb index 68cf4384f7..d3de220393 100644 --- a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb +++ b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb @@ -29,10 +29,6 @@ class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController @encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -41,12 +37,8 @@ class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty? end - def pagination_max_id - @encrypted_messages.last.id - end - - def pagination_since_id - @encrypted_messages.first.id + def pagination_collection + @encrypted_messages end def records_continue? diff --git a/app/controllers/api/v1/directories_controller.rb b/app/controllers/api/v1/directories_controller.rb index e79b20ce42..6c540404ea 100644 --- a/app/controllers/api/v1/directories_controller.rb +++ b/app/controllers/api/v1/directories_controller.rb @@ -27,7 +27,7 @@ class Api::V1::DirectoriesController < Api::BaseController scope.merge!(local_account_scope) if local_accounts? scope.merge!(account_exclusion_scope) if current_account scope.merge!(account_domain_block_scope) if current_account && !local_accounts? - end + end.includes(:account_stat, user: :role) end def local_accounts? diff --git a/app/controllers/api/v1/domain_blocks_controller.rb b/app/controllers/api/v1/domain_blocks_controller.rb index 34def3c44a..3dee2d176c 100644 --- a/app/controllers/api/v1/domain_blocks_controller.rb +++ b/app/controllers/api/v1/domain_blocks_controller.rb @@ -38,10 +38,6 @@ class Api::V1::DomainBlocksController < Api::BaseController current_account.domain_blocks end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_domain_blocks_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -50,12 +46,8 @@ class Api::V1::DomainBlocksController < Api::BaseController api_v1_domain_blocks_url pagination_params(since_id: pagination_since_id) unless @blocks.empty? end - def pagination_max_id - @blocks.last.id - end - - def pagination_since_id - @blocks.first.id + def pagination_collection + @blocks end def records_continue? diff --git a/app/controllers/api/v1/emoji_reactions_controller.rb b/app/controllers/api/v1/emoji_reactions_controller.rb new file mode 100644 index 0000000000..5e913eef2a --- /dev/null +++ b/app/controllers/api/v1/emoji_reactions_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class Api::V1::EmojiReactionsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:favourites' } + before_action :require_user! + after_action :insert_pagination_headers + + def index + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + emoji_reaction_permitted_account_ids: EmojiReactionAccountsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_emoji_reactions + end + + def cached_emoji_reactions + preload_collection(results.map(&:status), EmojiReaction) + end + + def results + @results ||= account_emoji_reactions.joins(:status).eager_load(:status).to_a_paginated_by_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) + end + + def account_emoji_reactions + current_account.emoji_reactions + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_emoji_reactions_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_emoji_reactions_url pagination_params(min_id: pagination_since_id) unless results.empty? + end + + def pagination_max_id + results.last.id + end + + def pagination_since_id + results.first.id + end + + def records_continue? + results.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/endorsements_controller.rb b/app/controllers/api/v1/endorsements_controller.rb index 46e3fcd647..9a723d89e4 100644 --- a/app/controllers/api/v1/endorsements_controller.rb +++ b/app/controllers/api/v1/endorsements_controller.rb @@ -25,11 +25,7 @@ class Api::V1::EndorsementsController < Api::BaseController end def endorsed_accounts - current_account.endorsed_accounts.includes(:account_stat).without_suspended - end - - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) + current_account.endorsed_accounts.includes(:account_stat, :user).without_suspended end def next_path @@ -44,12 +40,8 @@ class Api::V1::EndorsementsController < Api::BaseController api_v1_endorsements_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? end - def pagination_max_id - @accounts.last.id - end - - def pagination_since_id - @accounts.first.id + def pagination_collection + @accounts end def records_continue? diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index faf1bda96a..95c795468b 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -7,17 +7,19 @@ class Api::V1::FavouritesController < Api::BaseController def index @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + emoji_reaction_permitted_account_ids: EmojiReactionAccountsPresenter.new(@statuses, current_user&.account_id) end private def load_statuses - cached_favourites + preloaded_favourites end - def cached_favourites - cache_collection(results.map(&:status), Status) + def preloaded_favourites + preload_collection(results.map(&:status), Status) end def results @@ -31,10 +33,6 @@ class Api::V1::FavouritesController < Api::BaseController current_account.favourites end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_favourites_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -43,12 +41,8 @@ class Api::V1::FavouritesController < Api::BaseController api_v1_favourites_url pagination_params(min_id: pagination_since_id) unless results.empty? end - def pagination_max_id - results.last.id - end - - def pagination_since_id - results.first.id + def pagination_collection + results end def records_continue? diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb index 76633210a1..9c72e4380d 100644 --- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb +++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb @@ -12,6 +12,6 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController private def set_recently_used_tags - @recently_used_tags = Tag.recently_used(current_account).where.not(id: current_account.featured_tags).limit(10) + @recently_used_tags = Tag.suggestions_for_account(current_account).limit(10) end end diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index ed98acce30..4345b61ac7 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -52,11 +52,11 @@ class Api::V1::FiltersController < Api::BaseController end def resource_params - params.permit(:phrase, :expires_in, :irreversible, :whole_word, context: []) + params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :whole_word, context: []) end def filter_params - resource_params.slice(:phrase, :expires_in, :irreversible, :context) + resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :context) end def keyword_params diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index ee717ebbcc..7ffd7614bb 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -37,7 +37,7 @@ class Api::V1::FollowRequestsController < Api::BaseController end def default_accounts - Account.without_suspended.includes(:follow_requests, :account_stat).references(:follow_requests) + Account.without_suspended.includes(:follow_requests, :account_stat, :user).references(:follow_requests) end def paginated_follow_requests @@ -48,10 +48,6 @@ class Api::V1::FollowRequestsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_follow_requests_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/followed_tags_controller.rb b/app/controllers/api/v1/followed_tags_controller.rb index eae2bdc010..8888612b16 100644 --- a/app/controllers/api/v1/followed_tags_controller.rb +++ b/app/controllers/api/v1/followed_tags_controller.rb @@ -22,10 +22,6 @@ class Api::V1::FollowedTagsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_followed_tags_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -34,12 +30,8 @@ class Api::V1::FollowedTagsController < Api::BaseController api_v1_followed_tags_url pagination_params(since_id: pagination_since_id) unless @results.empty? end - def pagination_max_id - @results.last.id - end - - def pagination_since_id - @results.first.id + def pagination_collection + @results end def records_continue? diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb index 8e12cb7b65..aecf391049 100644 --- a/app/controllers/api/v1/lists/accounts_controller.rb +++ b/app/controllers/api/v1/lists/accounts_controller.rb @@ -37,9 +37,9 @@ class Api::V1::Lists::AccountsController < Api::BaseController def load_accounts if unlimited? - @list.accounts.without_suspended.includes(:account_stat).all + @list.accounts.without_suspended.includes(:account_stat, :user).all else - @list.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + @list.accounts.without_suspended.includes(:account_stat, :user).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) end end @@ -55,10 +55,6 @@ class Api::V1::Lists::AccountsController < Api::BaseController params.permit(account_ids: []) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path return if unlimited? @@ -71,12 +67,8 @@ class Api::V1::Lists::AccountsController < Api::BaseController api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? end - def pagination_max_id - @accounts.last.id - end - - def pagination_since_id - @accounts.first.id + def pagination_collection + @accounts end def records_continue? diff --git a/app/controllers/api/v1/lists_controller.rb b/app/controllers/api/v1/lists_controller.rb index 4bbbed2673..0bacd7fdb0 100644 --- a/app/controllers/api/v1/lists_controller.rb +++ b/app/controllers/api/v1/lists_controller.rb @@ -31,6 +31,9 @@ class Api::V1::ListsController < Api::BaseController end def destroy + antenna = Antenna.find_by(list_id: @list.id) + antenna.update!(list_id: 0) if antenna.present? + @list.destroy! render_empty end @@ -42,6 +45,6 @@ class Api::V1::ListsController < Api::BaseController end def list_params - params.permit(:title, :replies_policy, :exclusive) + params.permit(:title, :replies_policy, :exclusive, :notify) end end diff --git a/app/controllers/api/v1/markers_controller.rb b/app/controllers/api/v1/markers_controller.rb index f8dfba8a94..8eaf7767df 100644 --- a/app/controllers/api/v1/markers_controller.rb +++ b/app/controllers/api/v1/markers_controller.rb @@ -19,7 +19,7 @@ class Api::V1::MarkersController < Api::BaseController @markers = {} resource_params.each_pair do |timeline, timeline_params| - @markers[timeline] = current_user.markers.find_or_initialize_by(timeline: timeline) + @markers[timeline] = current_user.markers.find_or_create_by(timeline: timeline) @markers[timeline].update!(timeline_params) end end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index 555485823c..dbfd7e103a 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -17,7 +17,7 @@ class Api::V1::MutesController < Api::BaseController end def paginated_mutes - @paginated_mutes ||= Mute.eager_load(:target_account) + @paginated_mutes ||= Mute.eager_load(target_account: [:account_stat, :user]) .joins(:target_account) .merge(Account.without_suspended) .where(account: current_account) @@ -28,10 +28,6 @@ class Api::V1::MutesController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_mutes_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -40,12 +36,8 @@ class Api::V1::MutesController < Api::BaseController api_v1_mutes_url pagination_params(since_id: pagination_since_id) unless paginated_mutes.empty? end - def pagination_max_id - paginated_mutes.last.id - end - - def pagination_since_id - paginated_mutes.first.id + def pagination_collection + paginated_mutes end def records_continue? diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb new file mode 100644 index 0000000000..1ec336f9a5 --- /dev/null +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Api::V1::Notifications::PoliciesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update + + before_action :require_user! + before_action :set_policy + + def show + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + def update + @policy.update!(resource_params) + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + private + + def set_policy + @policy = NotificationPolicy.find_or_initialize_by(account: current_account) + + with_read_replica do + @policy.summarize! + end + end + + def resource_params + params.permit( + :filter_not_following, + :filter_not_followers, + :filter_new_accounts, + :filter_private_mentions + ) + end +end diff --git a/app/controllers/api/v1/notifications/requests_controller.rb b/app/controllers/api/v1/notifications/requests_controller.rb new file mode 100644 index 0000000000..0e58379a38 --- /dev/null +++ b/app/controllers/api/v1/notifications/requests_controller.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class Api::V1::Notifications::RequestsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :index + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index + + before_action :require_user! + before_action :set_request, except: :index + + after_action :insert_pagination_headers, only: :index + + def index + with_read_replica do + @requests = load_requests + @relationships = relationships + end + + render json: @requests, each_serializer: REST::NotificationRequestSerializer, relationships: @relationships + end + + def show + render json: @request, serializer: REST::NotificationRequestSerializer + end + + def accept + AcceptNotificationRequestService.new.call(@request) + render_empty + end + + def dismiss + @request.update!(dismissed: true) + render_empty + end + + private + + def load_requests + requests = NotificationRequest.where(account: current_account).where(dismissed: truthy_param?(:dismissed) || false).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) + + NotificationRequest.preload_cache_collection(requests) do |statuses| + preload_collection(statuses, Status) + end + end + + def relationships + StatusRelationshipsPresenter.new(@requests.map(&:last_status), current_user&.account_id) + end + + def set_request + @request = NotificationRequest.where(account: current_account).find(params[:id]) + end + + def next_path + api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) unless @requests.empty? + end + + def prev_path + api_v1_notifications_requests_url pagination_params(min_id: pagination_since_id) unless @requests.empty? + end + + def pagination_max_id + @requests.last.id + end + + def pagination_since_id + @requests.first.id + end + + def pagination_params(core_params) + params.slice(:dismissed).permit(:dismissed).merge(core_params) + end +end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 406ab97538..1d0aa10d2e 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -41,7 +41,7 @@ class Api::V1::NotificationsController < Api::BaseController ) Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| - cache_collection(target_statuses, Status) + preload_collection(target_statuses, Status) end end @@ -49,7 +49,8 @@ class Api::V1::NotificationsController < Api::BaseController current_account.notifications.without_suspended.browserable( types: Array(browserable_params[:types]), exclude_types: Array(browserable_params[:exclude_types]), - from_account_id: browserable_params[:account_id] + from_account_id: browserable_params[:account_id], + include_filtered: truthy_param?(:include_filtered) ) end @@ -57,10 +58,6 @@ class Api::V1::NotificationsController < Api::BaseController @notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? end @@ -69,19 +66,15 @@ class Api::V1::NotificationsController < Api::BaseController api_v1_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? end - def pagination_max_id - @notifications.last.id - end - - def pagination_since_id - @notifications.first.id + def pagination_collection + @notifications end def browserable_params - params.permit(:account_id, types: [], exclude_types: []) + params.permit(:account_id, :include_filtered, types: [], exclude_types: []) end def pagination_params(core_params) - params.slice(:limit, :account_id, :types, :exclude_types).permit(:limit, :account_id, types: [], exclude_types: []).merge(core_params) + params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered).permit(:limit, :account_id, :include_filtered, types: [], exclude_types: []).merge(core_params) end end diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb index 0c503d9bc5..1780554c5d 100644 --- a/app/controllers/api/v1/peers/search_controller.rb +++ b/app/controllers/api/v1/peers/search_controller.rb @@ -27,7 +27,7 @@ class Api::V1::Peers::SearchController < Api::BaseController @domains = InstancesIndex.query(function_score: { query: { prefix: { - domain: TagManager.instance.normalize_domain(params[:q].strip), + domain: normalized_domain, }, }, @@ -37,11 +37,18 @@ class Api::V1::Peers::SearchController < Api::BaseController }, }).limit(10).pluck(:domain) else - domain = params[:q].strip - domain = TagManager.instance.normalize_domain(domain) - @domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain) + domain = normalized_domain + @domains = Instance.searchable.domain_starts_with(domain).limit(10).pluck(:domain) end rescue Addressable::URI::InvalidURIError @domains = [] end + + def normalized_domain + TagManager.instance.normalize_domain(query_value) + end + + def query_value + params[:q].strip + end end diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 3634acf956..e1ad89ee3e 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -1,9 +1,12 @@ # frozen_string_literal: true class Api::V1::Push::SubscriptionsController < Api::BaseController + include Redisable + include Lockable + before_action -> { doorkeeper_authorize! :push } before_action :require_user! - before_action :set_push_subscription + before_action :set_push_subscription, only: [:show, :update] before_action :check_push_subscription, only: [:show, :update] def show @@ -11,16 +14,18 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController end def create - @push_subscription&.destroy! + with_redis_lock("push_subscription:#{current_user.id}") do + destroy_web_push_subscriptions! - @push_subscription = Web::PushSubscription.create!( - endpoint: subscription_params[:endpoint], - key_p256dh: subscription_params[:keys][:p256dh], - key_auth: subscription_params[:keys][:auth], - data: data_params, - user_id: current_user.id, - access_token_id: doorkeeper_token.id - ) + @push_subscription = Web::PushSubscription.create!( + endpoint: subscription_params[:endpoint], + key_p256dh: subscription_params[:keys][:p256dh], + key_auth: subscription_params[:keys][:auth], + data: data_params, + user_id: current_user.id, + access_token_id: doorkeeper_token.id + ) + end render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end @@ -31,14 +36,18 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController end def destroy - @push_subscription&.destroy! + destroy_web_push_subscriptions! render_empty end private + def destroy_web_push_subscriptions! + doorkeeper_token.web_push_subscriptions.destroy_all + end + def set_push_subscription - @push_subscription = Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id) + @push_subscription = doorkeeper_token.web_push_subscriptions.first end def check_push_subscription diff --git a/app/controllers/api/v1/reaction_deck_controller.rb b/app/controllers/api/v1/reaction_deck_controller.rb new file mode 100644 index 0000000000..6229eb89d4 --- /dev/null +++ b/app/controllers/api/v1/reaction_deck_controller.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +class Api::V1::ReactionDeckController < Api::BaseController + include RoutingHelper + + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, only: [:create] + + before_action :require_user! + before_action :set_deck, only: [:index, :create] + + rescue_from ArgumentError do |e| + render json: { error: e.to_s }, status: 422 + end + + def index + render json: remove_metas(@deck) + end + + def create + deck = [] + + shortcodes = [] + (deck_params['emojis'] || []).each do |shortcode| + shortcodes << shortcode.delete(':') + break if shortcodes.length >= User::REACTION_DECK_MAX + end + + custom_emojis = CustomEmoji.where(shortcode: shortcodes, domain: nil) + + shortcodes.each do |shortcode| + custom_emoji = custom_emojis.find { |em| em.shortcode == shortcode } + + emoji_data = {} + + if custom_emoji + emoji_data['name'] = custom_emoji.shortcode + emoji_data['url'] = full_asset_url(custom_emoji.image.url) + emoji_data['static_url'] = full_asset_url(custom_emoji.image.url(:static)) + emoji_data['width'] = custom_emoji.image_width + emoji_data['height'] = custom_emoji.image_height + emoji_data['custom_emoji_id'] = custom_emoji.id + else + emoji_data['name'] = shortcode + end + + deck << emoji_data + end + + current_user.settings['reaction_deck'] = deck.to_json + current_user.save! + + render json: remove_metas(deck) + end + + private + + def set_deck + deck = current_user.setting_reaction_deck ? JSON.parse(current_user.setting_reaction_deck) : [] + @deck = remove_unused_custom_emojis(deck) + end + + def remove_unused_custom_emojis(deck) + custom_ids = [] + deck.each do |item| + custom_ids << item['custom_emoji_id'].to_i if item.key?('custom_emoji_id') + end + custom_emojis = CustomEmoji.where(id: custom_ids) + + deck.each do |item| + next if item['custom_emoji_id'].nil? + + custom_emoji = custom_emojis.find { |em| em.id == item['custom_emoji_id'].to_i } + remove = custom_emoji.nil? || custom_emoji.disabled + item['remove'] = remove if remove + end + deck.filter { |item| !item.key?('remove') } + end + + def remove_metas(deck) + deck.tap do |d| + d.each do |item| + item.delete('custom_emoji_id') + # item.delete('id') if item.key?('id') + end + end + end + + def deck_params + params + end +end diff --git a/app/controllers/api/v1/scheduled_statuses_controller.rb b/app/controllers/api/v1/scheduled_statuses_controller.rb index 2220b6d22e..1217ed014e 100644 --- a/app/controllers/api/v1/scheduled_statuses_controller.rb +++ b/app/controllers/api/v1/scheduled_statuses_controller.rb @@ -47,10 +47,6 @@ class Api::V1::ScheduledStatusesController < Api::BaseController params.slice(:limit).permit(:limit).merge(core_params) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_scheduled_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -63,11 +59,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) end - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + def pagination_collection + @statuses end end diff --git a/app/controllers/api/v1/statuses/bookmark_categories_controller.rb b/app/controllers/api/v1/statuses/bookmark_categories_controller.rb new file mode 100644 index 0000000000..9d65b96296 --- /dev/null +++ b/app/controllers/api/v1/statuses/bookmark_categories_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::BookmarkCategoriesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' } + before_action :require_user! + before_action :set_status + + def index + @statuses = @status.deleted_at.present? ? [] : @status.joined_bookmark_categories.where(account: current_account) + render json: @statuses, each_serializer: REST::BookmarkCategorySerializer + end + + private + + def set_status + @status = Status.find(params[:status_id]) + end +end diff --git a/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb new file mode 100644 index 0000000000..9c2fb3d4a5 --- /dev/null +++ b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::EmojiReactionedByAccountsController < Api::BaseController + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:accounts' } + before_action :set_status + after_action :insert_pagination_headers + + def index + @accounts = load_accounts + render json: @accounts, each_serializer: REST::EmojiReactionAccountSerializer + end + + private + + def load_accounts + return [] unless Setting.enable_emoji_reaction + return [] if current_account.nil? && @status.account.emoji_reaction_policy != :allow + return [] if current_account.present? && !@status.account.show_emoji_reaction?(current_account) + + scope = default_accounts + scope = scope.where.not(account_id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope.merge(paginated_emoji_reactions).to_a + end + + def default_accounts + EmojiReaction + .where(status_id: @status.id) + .includes(:account) + .where(account: { suspended_at: nil }) + end + + def paginated_emoji_reactions + EmojiReaction.paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_status_emoji_reactioned_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_status_emoji_reactioned_by_index_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb new file mode 100644 index 0000000000..1f103beb71 --- /dev/null +++ b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::EmojiReactionsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:favourites' } + before_action :require_user! + before_action :set_status, only: %i(create update) + before_action :set_status_without_authorize, only: [:destroy] + + def create + create_private(params[:emoji] || params[:id]) + end + + # For compatible with Fedibird API + def update + create_private(params[:id]) + end + + def destroy + emoji = params[:emoji] || params[:id] + + if emoji + shortcode, domain = emoji.split('@') + emoji_reaction = EmojiReaction.where(account_id: current_account.id).where(status_id: @status.id).where(name: shortcode) + .find { |reaction| domain == '' ? reaction.custom_emoji.nil? : reaction.custom_emoji&.domain == domain } + + authorize @status, :show? if emoji_reaction.nil? + + UnEmojiReactService.new.call(current_account, @status, emoji_reaction) if emoji_reaction.present? + else + authorize @status, :show? + end + + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new( + [@status], current_account.id + ) + rescue Mastodon::NotPermittedError + not_found + end + + private + + def create_private(emoji) + count = EmojiReaction.where(account: current_account, status: @status).count + raise Mastodon::ValidationError, I18n.t('reactions.errors.limit_reached') if count >= EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT + raise Mastodon::ValidationError, I18n.t('reactions.errors.disabled') unless Setting.enable_emoji_reaction + + EmojiReactService.new.call(current_account, @status, emoji) + render json: @status, serializer: REST::StatusSerializer + end + + def set_status + set_status_without_authorize + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def set_status_without_authorize + @status = Status.find(params[:status_id]) + end +end diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb index 3cca246ce8..bbc8082e0c 100644 --- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb @@ -14,14 +14,14 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::V1::Statuses::Bas def load_accounts scope = default_accounts - scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope = scope.not_excluded_by_account(current_account) unless current_account.nil? scope.merge(paginated_favourites).to_a end def default_accounts Account .without_suspended - .includes(:favourites, :account_stat) + .includes(:favourites, :account_stat, :user) .references(:favourites) .where(favourites: { status_id: @status.id }) end @@ -34,10 +34,6 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::V1::Statuses::Bas ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_status_favourited_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb b/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb new file mode 100644 index 0000000000..4d905ef1a6 --- /dev/null +++ b/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::MentionedAccountsController < Api::BaseController + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:accounts' } + before_action :set_status + after_action :insert_pagination_headers + + def index + cache_if_unauthenticated! + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def load_accounts + scope = default_accounts + scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope.merge(paginated_mentioned_users).to_a + end + + def default_accounts + Account + .without_suspended + .includes(:mentions, :account_stat) + .references(:mentions) + .where(mentions: { status_id: @status.id }) + end + + def paginated_mentioned_users + Mention.paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_status_mentioned_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_status_mentioned_by_index_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.mentions.last.id + end + + def pagination_since_id + @accounts.first.mentions.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show_mentioned_users? + rescue Mastodon::NotPermittedError + not_found + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb index dd3e60846b..bac96b032b 100644 --- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb @@ -14,26 +14,22 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::V1::Statuses::Base def load_accounts scope = default_accounts - scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope = scope.not_excluded_by_account(current_account) unless current_account.nil? scope.merge(paginated_statuses).to_a end def default_accounts - Account.without_suspended.includes(:statuses, :account_stat).references(:statuses) + Account.without_suspended.includes(:statuses, :account_stat, :user).references(:statuses) end def paginated_statuses - Status.where(reblog_of_id: @status.id).where(visibility: [:public, :unlisted]).paginate_by_max_id( + Status.where(reblog_of_id: @status.id).distributable_visibility.paginate_by_max_id( limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id] ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_status_reblogged_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/statuses/referred_by_statuses_controller.rb b/app/controllers/api/v1/statuses/referred_by_statuses_controller.rb new file mode 100644 index 0000000000..c13c5ff0e8 --- /dev/null +++ b/app/controllers/api/v1/statuses/referred_by_statuses_controller.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::ReferredByStatusesController < Api::BaseController + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:accounts' } + before_action :set_status + after_action :insert_pagination_headers + + def index + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + emoji_reaction_permitted_account_ids: EmojiReactionAccountsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_references + end + + def cached_references + results + end + + def results + return @results if @results + + account = current_user&.account + statuses = Status.where(id: @status.referenced_by_status_objects.select(:status_id)) + account_ids = statuses.map(&:account_id).uniq + domains = statuses.filter_map(&:account_domain).uniq + relations = account&.relations_map(account_ids, domains) || {} + + statuses = preload_collection_paginated_by_id( + statuses, + Status, + limit_param(DEFAULT_STATUSES_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) + + @results = statuses.filter { |status| !StatusFilter.new(status, account, relations).filtered? } + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_status_referred_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_status_referred_by_index_url pagination_params(min_id: pagination_since_id) unless results.empty? + end + + def pagination_max_id + results.last.id + end + + def pagination_since_id + results.first.id + end + + def records_continue? + results.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 064e7632a8..e8636f2138 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -5,9 +5,11 @@ class Api::V1::StatusesController < Api::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy] - before_action :require_user!, except: [:show, :context] - before_action :set_status, only: [:show, :context] - before_action :set_thread, only: [:create] + before_action :require_user!, except: [:index, :show, :context] + before_action :set_statuses, only: [:index] + before_action :set_status, only: [:show, :context] + before_action :set_thread, only: [:create] + before_action :check_statuses_limit, only: [:index] override_rate_limit_headers :create, family: :statuses override_rate_limit_headers :update, family: :statuses @@ -23,9 +25,14 @@ class Api::V1::StatusesController < Api::BaseController DESCENDANTS_LIMIT = 60 DESCENDANTS_DEPTH_LIMIT = 20 + def index + @statuses = preload_collection(@statuses, Status) + render json: @statuses, each_serializer: REST::StatusSerializer + end + def show cache_if_unauthenticated! - @status = cache_collection([@status], Status).first + @status = preload_collection([@status], Status).first render json: @status, serializer: REST::StatusSerializer end @@ -44,11 +51,20 @@ class Api::V1::StatusesController < Api::BaseController ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit) - loaded_ancestors = cache_collection(ancestors_results, Status) - loaded_descendants = cache_collection(descendants_results, Status) + references_results = @status.readable_references(current_account) + loaded_ancestors = preload_collection(ancestors_results, Status) + loaded_descendants = preload_collection(descendants_results, Status) + loaded_references = preload_collection(references_results, Status) - @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) - statuses = [@status] + @context.ancestors + @context.descendants + if params[:with_reference] + loaded_references.reject! { |status| loaded_ancestors.any? { |ancestor| ancestor.id == status.id } } + else + loaded_ancestors = (loaded_ancestors + loaded_references).uniq(&:id) + loaded_references = [] + end + + @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants, references: loaded_references) + statuses = [@status] + @context.ancestors + @context.descendants + @context.references render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) end @@ -61,7 +77,11 @@ class Api::V1::StatusesController < Api::BaseController media_ids: status_params[:media_ids], sensitive: status_params[:sensitive], spoiler_text: status_params[:spoiler_text], + markdown: status_params[:markdown], visibility: status_params[:visibility], + force_visibility: status_params[:force_visibility], + searchability: status_params[:searchability], + circle_id: status_params[:circle_id], language: status_params[:language], scheduled_at: status_params[:scheduled_at], application: doorkeeper_token.application, @@ -71,13 +91,9 @@ class Api::V1::StatusesController < Api::BaseController with_rate_limit: true ) - render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer + render json: @status, serializer: serializer_for_status rescue PostStatusService::UnexpectedMentionsError => e - unexpected_accounts = ActiveModel::Serializer::CollectionSerializer.new( - e.accounts, - serializer: REST::AccountSerializer - ) - render json: { error: e.message, unexpected_accounts: unexpected_accounts }, status: 422 + render json: unexpected_accounts_error_json(e), status: 422 end def update @@ -93,6 +109,7 @@ class Api::V1::StatusesController < Api::BaseController sensitive: status_params[:sensitive], language: status_params[:language], spoiler_text: status_params[:spoiler_text], + markdown: status_params[:markdown], poll: status_params[:poll] ) @@ -115,6 +132,10 @@ class Api::V1::StatusesController < Api::BaseController private + def set_statuses + @statuses = Status.permitted_statuses_from_ids(status_ids, current_account) + end + def set_status @status = Status.find(params[:id]) authorize @status, :show? @@ -129,6 +150,18 @@ class Api::V1::StatusesController < Api::BaseController render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 end + def check_statuses_limit + raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT + end + + def status_ids + Array(statuses_params[:ids]).uniq.map(&:to_i) + end + + def statuses_params + params.permit(ids: []) + end + def status_params params.permit( :status, @@ -136,8 +169,13 @@ class Api::V1::StatusesController < Api::BaseController :sensitive, :spoiler_text, :visibility, + :force_visibility, + :searchability, + :circle_id, :language, + :markdown, :scheduled_at, + :status_reference_ids, allowed_mentions: [], media_ids: [], media_attributes: [ @@ -155,6 +193,21 @@ class Api::V1::StatusesController < Api::BaseController ) end + def serializer_for_status + @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer + end + + def unexpected_accounts_error_json(error) + { + error: error.message, + unexpected_accounts: serialized_accounts(error.accounts), + } + end + + def serialized_accounts(accounts) + ActiveModel::Serializer::CollectionSerializer.new(accounts, serializer: REST::AccountSerializer) + end + def pagination_params(core_params) params.slice(:limit).permit(:limit).merge(core_params) end diff --git a/app/controllers/api/v1/timelines/antenna_controller.rb b/app/controllers/api/v1/timelines/antenna_controller.rb new file mode 100644 index 0000000000..69554361be --- /dev/null +++ b/app/controllers/api/v1/timelines/antenna_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::AntennaController < Api::V1::Timelines::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' } + before_action :require_user! + before_action :set_antenna + before_action :set_statuses + + PERMITTED_PARAMS = %i(limit).freeze + + def show + render json: @statuses, + each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:id]) + end + + def set_statuses + @statuses = cached_list_statuses + end + + def cached_list_statuses + preload_collection list_statuses, Status + end + + def list_statuses + list_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id], + params[:min_id] + ) + end + + def list_feed + AntennaFeed.new(@antenna) + end + + def next_path + api_v1_timelines_antenna_url params[:id], next_path_params + end + + def prev_path + api_v1_timelines_antenna_url params[:id], prev_path_params + end +end diff --git a/app/controllers/api/v1/timelines/base_controller.rb b/app/controllers/api/v1/timelines/base_controller.rb index 173e173cc9..e79eba79ee 100644 --- a/app/controllers/api/v1/timelines/base_controller.rb +++ b/app/controllers/api/v1/timelines/base_controller.rb @@ -5,16 +5,8 @@ class Api::V1::Timelines::BaseController < Api::BaseController private - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + def pagination_collection + @statuses end def next_path_params diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index 36fdbea647..e48c5ae251 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -10,22 +10,24 @@ class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController with_read_replica do @statuses = load_statuses @relationships = StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @emoji_reactions = EmojiReactionAccountsPresenter.new(@statuses, current_user&.account_id) end render json: @statuses, each_serializer: REST::StatusSerializer, relationships: @relationships, + emoji_reaction_permitted_account_ids: @emoji_reactions, status: account_home_feed.regenerating? ? 206 : 200 end private def load_statuses - cached_home_statuses + preloaded_home_statuses end - def cached_home_statuses - cache_collection home_statuses, Status + def preloaded_home_statuses + preload_collection home_statuses, Status end def home_statuses diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb index 14b884ecd9..d8cdbdb74c 100644 --- a/app/controllers/api/v1/timelines/list_controller.rb +++ b/app/controllers/api/v1/timelines/list_controller.rb @@ -21,11 +21,11 @@ class Api::V1::Timelines::ListController < Api::V1::Timelines::BaseController end def set_statuses - @statuses = cached_list_statuses + @statuses = preloaded_list_statuses end - def cached_list_statuses - cache_collection list_statuses, Status + def preloaded_list_statuses + preload_collection list_statuses, Status end def list_statuses diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 35af8dc4b5..cb56d309d4 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -8,7 +8,9 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController def show cache_if_unauthenticated! @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + emoji_reaction_permitted_account_ids: EmojiReactionAccountsPresenter.new(@statuses, current_user&.account_id) end private @@ -18,11 +20,11 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController end def load_statuses - cached_public_statuses_page + preloaded_public_statuses_page end - def cached_public_statuses_page - cache_collection(public_statuses, Status) + def preloaded_public_statuses_page + preload_collection(public_statuses, Status) end def public_statuses diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 4ba439dbb2..761a4299b8 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -9,7 +9,9 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController def show cache_if_unauthenticated! @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + emoji_reaction_permitted_account_ids: EmojiReactionAccountsPresenter.new(@statuses, current_user&.account_id) end private @@ -23,11 +25,11 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController end def load_statuses - cached_tagged_statuses + preloaded_tagged_statuses end - def cached_tagged_statuses - @tag.nil? ? [] : cache_collection(tag_timeline_statuses, Status) + def preloaded_tagged_statuses + @tag.nil? ? [] : preload_collection(tag_timeline_statuses, Status) end def tag_timeline_statuses diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb index 57cfa0b7e4..8edf5bbcef 100644 --- a/app/controllers/api/v1/trends/links_controller.rb +++ b/app/controllers/api/v1/trends/links_controller.rb @@ -34,10 +34,6 @@ class Api::V1::Trends::LinksController < Api::BaseController scope end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def pagination_params(core_params) params.slice(:limit).permit(:limit).merge(core_params) end diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb index c186864c3b..c6fbbce167 100644 --- a/app/controllers/api/v1/trends/statuses_controller.rb +++ b/app/controllers/api/v1/trends/statuses_controller.rb @@ -20,7 +20,7 @@ class Api::V1::Trends::StatusesController < Api::BaseController def set_statuses @statuses = if enabled? - cache_collection(statuses_from_trends.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status) + preload_collection(statuses_from_trends.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status) else [] end @@ -32,10 +32,6 @@ class Api::V1::Trends::StatusesController < Api::BaseController scope end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def pagination_params(core_params) params.slice(:limit).permit(:limit).merge(core_params) end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index aca3dd7089..6d3855a90a 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -30,10 +30,6 @@ class Api::V1::Trends::TagsController < Api::BaseController Trends.tags.query.allowed end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def pagination_params(core_params) params.slice(:limit).permit(:limit).merge(core_params) end diff --git a/app/controllers/api/v2/filters_controller.rb b/app/controllers/api/v2/filters_controller.rb index 2fcdeeae45..0b63caa36a 100644 --- a/app/controllers/api/v2/filters_controller.rb +++ b/app/controllers/api/v2/filters_controller.rb @@ -35,7 +35,7 @@ class Api::V2::FiltersController < Api::BaseController private def set_filters - @filters = current_account.custom_filters.includes(:keywords) + @filters = current_account.custom_filters.includes(:keywords, :statuses) end def set_filter @@ -43,6 +43,6 @@ class Api::V2::FiltersController < Api::BaseController end def resource_params - params.permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end end diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb index 3cfc6e7919..a701cbe582 100644 --- a/app/controllers/api/v2/search_controller.rb +++ b/app/controllers/api/v2/search_controller.rb @@ -3,7 +3,7 @@ class Api::V2::SearchController < Api::BaseController include Authorization - RESULTS_LIMIT = 20 + RESULTS_LIMIT = 40 before_action -> { authorize_if_got_token! :read, :'read:search' } before_action :validate_search_params! @@ -63,6 +63,6 @@ class Api::V2::SearchController < Api::BaseController end def search_params - params.permit(:type, :offset, :min_id, :max_id, :account_id, :following) + params.permit(:type, :offset, :min_id, :max_id, :account_id, :following, :searchability) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5f8725f6fc..66e0f7e305 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base include UserTrackingConcern include SessionTrackingConcern include CacheConcern + include PreloadingConcern include DomainControlHelper include DatabaseHelper include AuthorizedFetchHelper @@ -129,7 +130,7 @@ class ApplicationController < ActionController::Base end def single_user_mode? - @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? + @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.without_internal.exists? end def use_seamless_external_login? @@ -178,7 +179,7 @@ class ApplicationController < ActionController::Base respond_to do |format| format.any { render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html] } - format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: code } + format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: 410 } end end diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index d9cd630905..a99dceeb25 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -2,12 +2,13 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController include Auth::CaptchaConcern + include RegistrationLimitationHelper layout 'auth' before_action :set_body_classes before_action :set_confirmation_user!, only: [:show, :confirm_captcha] - before_action :require_unconfirmed! + before_action :redirect_confirmed_user, if: :signed_in_confirmed_user? before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha] before_action :require_captcha_if_needed!, only: [:show] @@ -16,6 +17,11 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController skip_before_action :require_functional! def show + if reach_registrations_limit? && !current_user&.valid_invitation? + render :limitation_error + return + end + old_session_values = session.to_hash reset_session session.update old_session_values.except('session_id') @@ -65,10 +71,12 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController @confirmation_user.nil? || @confirmation_user.confirmed? end - def require_unconfirmed! - if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? - redirect_to(current_user.approved? ? root_path : edit_user_registration_path) - end + def redirect_confirmed_user + redirect_to(current_user.approved? ? root_path : edit_user_registration_path) + end + + def signed_in_confirmed_user? + user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? end def set_body_classes diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb index 707b50ef9e..9d496220a3 100644 --- a/app/controllers/auth/omniauth_callbacks_controller.rb +++ b/app/controllers/auth/omniauth_callbacks_controller.rb @@ -7,7 +7,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController def self.provides_callback_for(provider) define_method provider do @provider = provider - @user = User.find_for_oauth(request.env['omniauth.auth'], current_user) + @user = User.find_for_omniauth(request.env['omniauth.auth'], current_user) if @user.persisted? record_login_activity @@ -17,6 +17,9 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController session["devise.#{provider}_data"] = request.env['omniauth.auth'] redirect_to new_user_registration_url end + rescue ActiveRecord::RecordInvalid + flash[:alert] = I18n.t('devise.failure.omniauth_user_creation_failure') if is_navigational_format? + redirect_to new_user_session_url end end diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index a752194d5b..de001f062b 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -2,7 +2,7 @@ class Auth::PasswordsController < Devise::PasswordsController skip_before_action :check_self_destruct! - before_action :check_validity_of_reset_password_token, only: :edit + before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid? before_action :set_body_classes layout 'auth' @@ -19,11 +19,9 @@ class Auth::PasswordsController < Devise::PasswordsController private - def check_validity_of_reset_password_token - unless reset_password_token_is_valid? - flash[:error] = I18n.t('auth.invalid_reset_password_token') - redirect_to new_password_path(resource_name) - end + def redirect_invalid_reset_token + flash[:error] = I18n.t('auth.invalid_reset_password_token') + redirect_to new_password_path(resource_name) end def set_body_classes diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 148ad53755..6ed7b2baac 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Auth::SessionsController < Devise::SessionsController + include Redisable + + MAX_2FA_ATTEMPTS_PER_HOUR = 10 + layout 'auth' skip_before_action :check_self_destruct! @@ -130,9 +134,23 @@ class Auth::SessionsController < Devise::SessionsController session.delete(:attempt_user_updated_at) end + def clear_2fa_attempt_from_user(user) + redis.del(second_factor_attempts_key(user)) + end + + def check_second_factor_rate_limits(user) + attempts, = redis.multi do |multi| + multi.incr(second_factor_attempts_key(user)) + multi.expire(second_factor_attempts_key(user), 1.hour) + end + + attempts >= MAX_2FA_ATTEMPTS_PER_HOUR + end + def on_authentication_success(user, security_measure) @on_authentication_success_called = true + clear_2fa_attempt_from_user(user) clear_attempt_from_session user.update_sign_in!(new_sign_in: true) @@ -163,5 +181,16 @@ class Auth::SessionsController < Devise::SessionsController ip: request.remote_ip, user_agent: request.user_agent ) + + # Only send a notification email every hour at most + return if redis.get("2fa_failure_notification:#{user.id}").present? + + redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour) + + UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! + end + + def second_factor_attempts_key(user) + "2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}" end end diff --git a/app/controllers/concerns/api/error_handling.rb b/app/controllers/concerns/api/error_handling.rb new file mode 100644 index 0000000000..ad559fe2d7 --- /dev/null +++ b/app/controllers/concerns/api/error_handling.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Api::ErrorHandling + extend ActiveSupport::Concern + + included do + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| + render json: { error: e.to_s }, status: 422 + end + + rescue_from ActiveRecord::RecordNotUnique do + render json: { error: 'Duplicate record' }, status: 422 + end + + rescue_from Date::Error do + render json: { error: 'Invalid date supplied' }, status: 422 + end + + rescue_from ActiveRecord::RecordNotFound do + render json: { error: 'Record not found' }, status: 404 + end + + rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do + render json: { error: 'Remote data could not be fetched' }, status: 503 + end + + rescue_from OpenSSL::SSL::SSLError do + render json: { error: 'Remote SSL certificate could not be verified' }, status: 503 + end + + rescue_from Mastodon::NotPermittedError do + render json: { error: 'This action is not allowed' }, status: 403 + end + + rescue_from Seahorse::Client::NetworkingError do |e| + Rails.logger.warn "Storage server error: #{e}" + render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do + render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from Mastodon::RateLimitExceededError do + render json: { error: I18n.t('errors.429') }, status: 429 + end + + rescue_from ActionController::ParameterMissing, Mastodon::InvalidParameterError do |e| + render json: { error: e.to_s }, status: 400 + end + end +end diff --git a/app/controllers/concerns/api/pagination.rb b/app/controllers/concerns/api/pagination.rb new file mode 100644 index 0000000000..d84a1d99f7 --- /dev/null +++ b/app/controllers/concerns/api/pagination.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Api::Pagination + extend ActiveSupport::Concern + + protected + + def pagination_max_id + pagination_collection.last.id + end + + def pagination_since_id + pagination_collection.first.id + end + + def set_pagination_headers(next_path = nil, prev_path = nil) + links = [] + links << [next_path, [%w(rel next)]] if next_path + links << [prev_path, [%w(rel prev)]] if prev_path + response.headers['Link'] = LinkHeader.new(links) unless links.empty? + end + + def require_valid_pagination_options! + render json: { error: 'Pagination values for `offset` and `limit` must be positive' }, status: 400 if pagination_options_invalid? + end + + private + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_options_invalid? + params.slice(:limit, :offset).values.map(&:to_i).any?(&:negative?) + end +end diff --git a/app/controllers/concerns/auth/two_factor_authentication_concern.rb b/app/controllers/concerns/auth/two_factor_authentication_concern.rb index effdb8d21c..404164751a 100644 --- a/app/controllers/concerns/auth/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/auth/two_factor_authentication_concern.rb @@ -66,6 +66,11 @@ module Auth::TwoFactorAuthenticationConcern end def authenticate_with_two_factor_via_otp(user) + if check_second_factor_rate_limits(user) + flash.now[:alert] = I18n.t('users.rate_limited') + return prompt_for_two_factor(user) + end + if valid_otp_attempt?(user) on_authentication_success(user, :otp) else diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index 62f763fe2f..3dc0ea840a 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -28,6 +28,16 @@ module CacheConcern def render_with_cache(**options) raise ArgumentError, 'Only JSON render calls are supported' unless options.key?(:json) || block_given? + if options.delete(:cancel_cache) + if block_given? + options[:json] = yield + elsif options[:json].is_a?(Symbol) + options[:json] = send(options[:json]) + end + + return render(options) + end + key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':') expires_in = options.delete(:expires_in) || 3.minutes body = Rails.cache.read(key, raw: true) @@ -45,28 +55,4 @@ module CacheConcern Rails.cache.write(key, response.body, expires_in: expires_in, raw: true) end end - - def cache_collection(raw, klass) - return raw unless klass.respond_to?(:with_includes) - - raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation) - return [] if raw.empty? - - cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id) - - uncached_ids = raw.map(&:id) - cached_keys_with_value.keys - - klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!) - - unless uncached_ids.empty? - uncached = klass.where(id: uncached_ids).with_includes.index_by(&:id) - Rails.cache.write_multi(uncached.values.to_h { |i| [i, i] }) - end - - raw.filter_map { |item| cached_keys_with_value[item.id] || uncached[item.id] } - end - - def cache_collection_paginated_by_id(raw, klass, limit, options) - cache_collection raw.cache_ids.to_a_paginated_by_id(limit, options), klass - end end diff --git a/app/controllers/concerns/preloading_concern.rb b/app/controllers/concerns/preloading_concern.rb new file mode 100644 index 0000000000..61e2213649 --- /dev/null +++ b/app/controllers/concerns/preloading_concern.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module PreloadingConcern + extend ActiveSupport::Concern + + def preload_collection(scope, klass) + return scope unless klass.respond_to?(:preload_cacheable_associations) + + scope.to_a.tap do |records| + klass.preload_cacheable_associations(records) + end + end + + def preload_collection_paginated_by_id(scope, klass, limit, options) + preload_collection scope.to_a_paginated_by_id(limit, options), klass + end +end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 35391e64c4..68f09ee023 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -12,39 +12,6 @@ module SignatureVerification class SignatureVerificationError < StandardError; end - class SignatureParamsParser < Parslet::Parser - rule(:token) { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) } - rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') } - # qdtext and quoted_pair are not exactly according to spec but meh - rule(:qdtext) { match('[^\\\\"]') } - rule(:quoted_pair) { str('\\') >> any } - rule(:bws) { match('\s').repeat } - rule(:param) { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) } - rule(:comma) { bws >> str(',') >> bws } - # Old versions of node-http-signature add an incorrect "Signature " prefix to the header - rule(:buggy_prefix) { str('Signature ') } - rule(:params) { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) } - root(:params) - end - - class SignatureParamsTransformer < Parslet::Transform - rule(params: subtree(:param)) do - (param.is_a?(Array) ? param : [param]).each_with_object({}) { |(key, value), hash| hash[key] = value } - end - - rule(param: { key: simple(:key), value: simple(:val) }) do - [key, val] - end - - rule(quoted_string: simple(:string)) do - string.to_s - end - - rule(token: simple(:string)) do - string.to_s - end - end - def require_account_signature! render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account end @@ -99,7 +66,7 @@ module SignatureVerification compare_signed_string = build_signed_string(include_query_string: false) return actor unless verify_signature(actor, signature, compare_signed_string).nil? - actor = stoplight_wrap_request { actor_refresh_key!(actor) } + actor = stoplight_wrapper.run { actor_refresh_key!(actor) } raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil? @@ -135,12 +102,8 @@ module SignatureVerification end def signature_params - @signature_params ||= begin - raw_signature = request.headers['Signature'] - tree = SignatureParamsParser.new.parse(raw_signature) - SignatureParamsTransformer.new.apply(tree) - end - rescue Parslet::ParseFailed + @signature_params ||= SignatureParser.parse(request.headers['Signature']) + rescue SignatureParser::ParsingError raise SignatureVerificationError, 'Error parsing signature parameters' end @@ -263,10 +226,10 @@ module SignatureVerification end if key_id.start_with?('acct:') - stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) } + stoplight_wrapper.run { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) } elsif !ActivityPub::TagManager.instance.local_uri?(key_id) account = ActivityPub::TagManager.instance.uri_to_actor(key_id) - account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) } + account ||= stoplight_wrapper.run { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) } account end rescue Mastodon::PrivateNetworkAddressError => e @@ -275,12 +238,11 @@ module SignatureVerification raise SignatureVerificationError, e.message end - def stoplight_wrap_request(&block) - Stoplight("source:#{request.remote_ip}", &block) + def stoplight_wrapper + Stoplight("source:#{request.remote_ip}") .with_threshold(1) .with_cool_off_time(5.minutes.seconds) .with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) } - .run end def actor_refresh_key!(actor) diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb index 5687d6e5b6..b8c909877b 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -21,10 +21,19 @@ module WebAppControllerConcern def redirect_unauthenticated_to_permalinks! return if user_signed_in? && current_account.moved_to_account_id.nil? - redirect_path = PermalinkRedirector.new(request.path).redirect_path - return if redirect_path.blank? + permalink_redirector = PermalinkRedirector.new(request.path) + return if permalink_redirector.redirect_path.blank? expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? - redirect_to(redirect_path) + + respond_to do |format| + format.html do + redirect_to(permalink_redirector.redirect_confirmation_path, allow_other_host: false) + end + + format.json do + redirect_to(permalink_redirector.redirect_uri, allow_other_host: true) + end + end end end diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb index 62f8e0d772..eb6417698a 100644 --- a/app/controllers/custom_css_controller.rb +++ b/app/controllers/custom_css_controller.rb @@ -16,6 +16,6 @@ class CustomCssController < ActionController::Base # rubocop:disable Rails/Appli helper_method :custom_css_styles def set_user_roles - @user_roles = UserRole.where(highlighted: true).where.not(color: [nil, '']) + @user_roles = UserRole.providing_styles end end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index bd9964426b..0b7f9f9e68 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -49,7 +49,7 @@ class FiltersController < ApplicationController end def resource_params - params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + params.require(:custom_filter).permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :exclude_quote, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end def set_body_classes diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 5effd9495e..44d90ec671 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -62,7 +62,7 @@ class FollowerAccountsController < ApplicationController ActivityPub::CollectionPresenter.new( id: account_followers_url(@account, page: params.fetch(:page, 1)), type: :ordered, - size: @account.followers_count, + size: @account.public_followers_count, items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) }, part_of: account_followers_url(@account), next: next_page_url, @@ -72,7 +72,7 @@ class FollowerAccountsController < ApplicationController ActivityPub::CollectionPresenter.new( id: account_followers_url(@account), type: :ordered, - size: @account.followers_count, + size: @account.public_followers_count, first: page_url(1) ) end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 268fad96d0..87a702f711 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -65,7 +65,7 @@ class FollowingAccountsController < ApplicationController ActivityPub::CollectionPresenter.new( id: account_following_index_url(@account, page: params.fetch(:page, 1)), type: :ordered, - size: @account.following_count, + size: @account.public_following_count, items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) }, part_of: account_following_index_url(@account), next: next_page_url, @@ -75,7 +75,7 @@ class FollowingAccountsController < ApplicationController ActivityPub::CollectionPresenter.new( id: account_following_index_url(@account), type: :ordered, - size: @account.following_count, + size: @account.public_following_count, first: page_url(1) ) end diff --git a/app/controllers/instance_actors_controller.rb b/app/controllers/instance_actors_controller.rb index 8422d74bc3..f2b1eaa3e7 100644 --- a/app/controllers/instance_actors_controller.rb +++ b/app/controllers/instance_actors_controller.rb @@ -6,6 +6,8 @@ class InstanceActorsController < ActivityPub::BaseController serialization_scope nil before_action :set_account + + skip_before_action :authenticate_user! # From `AccountOwnedConcern` skip_before_action :require_functional! skip_before_action :update_user_sign_in @@ -16,6 +18,11 @@ class InstanceActorsController < ActivityPub::BaseController private + # Skips various `before_action` from `AccountOwnedConcern` + def account_required? + false + end + def set_account @account = Account.representative end diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb index ea024e30e6..65c315208d 100644 --- a/app/controllers/intents_controller.rb +++ b/app/controllers/intents_controller.rb @@ -1,27 +1,26 @@ # frozen_string_literal: true class IntentsController < ApplicationController - before_action :check_uri + EXPECTED_SCHEME = 'web+mastodon' + before_action :handle_invalid_uri, unless: :valid_uri? rescue_from Addressable::URI::InvalidURIError, with: :handle_invalid_uri def show - if uri.scheme == 'web+mastodon' - case uri.host - when 'follow' - return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:')) - when 'share' - return redirect_to share_path(text: uri.query_values['text']) - end + case uri.host + when 'follow' + redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:')) + when 'share' + redirect_to share_path(text: uri.query_values['text']) + else + handle_invalid_uri end - - not_found end private - def check_uri - not_found if uri.blank? + def valid_uri? + uri.present? && uri.scheme == EXPECTED_SCHEME end def handle_invalid_uri diff --git a/app/controllers/redirect/accounts_controller.rb b/app/controllers/redirect/accounts_controller.rb new file mode 100644 index 0000000000..713ccf2ca1 --- /dev/null +++ b/app/controllers/redirect/accounts_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Redirect::AccountsController < Redirect::BaseController + private + + def set_resource + @resource = Account.find(params[:id]) + not_found if @resource.local? + end +end diff --git a/app/controllers/redirect/base_controller.rb b/app/controllers/redirect/base_controller.rb new file mode 100644 index 0000000000..90894ec1ed --- /dev/null +++ b/app/controllers/redirect/base_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Redirect::BaseController < ApplicationController + vary_by 'Accept-Language' + + before_action :set_resource + before_action :set_app_body_class + + def show + @redirect_path = ActivityPub::TagManager.instance.url_for(@resource) + + render 'redirects/show', layout: 'application' + end + + private + + def set_app_body_class + @body_classes = 'app-body' + end + + def set_resource + raise NotImplementedError + end +end diff --git a/app/controllers/redirect/statuses_controller.rb b/app/controllers/redirect/statuses_controller.rb new file mode 100644 index 0000000000..37a938c651 --- /dev/null +++ b/app/controllers/redirect/statuses_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Redirect::StatusesController < Redirect::BaseController + private + + def set_resource + @resource = Status.find(params[:id]) + not_found if @resource.local? || !@resource.distributable? + end +end diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb index d4b7205681..6849979b11 100644 --- a/app/controllers/settings/applications_controller.rb +++ b/app/controllers/settings/applications_controller.rb @@ -13,7 +13,7 @@ class Settings::ApplicationsController < Settings::BaseController def new @application = Doorkeeper::Application.new( redirect_uri: Doorkeeper.configuration.native_redirect_uri, - scopes: 'read write follow' + scopes: 'read:me' ) end diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index c384402650..90c112e219 100644 --- a/app/controllers/settings/featured_tags_controller.rb +++ b/app/controllers/settings/featured_tags_controller.rb @@ -38,7 +38,7 @@ class Settings::FeaturedTagsController < Settings::BaseController end def set_recently_used_tags - @recently_used_tags = Tag.recently_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10) + @recently_used_tags = Tag.suggestions_for_account(current_account).limit(10) end def featured_tag_params diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb index 983caf22fa..569aa07c53 100644 --- a/app/controllers/settings/imports_controller.rb +++ b/app/controllers/settings/imports_controller.rb @@ -31,7 +31,7 @@ class Settings::ImportsController < Settings::BaseController def show; end def failures - @bulk_import = current_account.bulk_imports.where(state: :finished).find(params[:id]) + @bulk_import = current_account.bulk_imports.state_finished.find(params[:id]) respond_to do |format| format.csv do @@ -92,7 +92,7 @@ class Settings::ImportsController < Settings::BaseController end def set_bulk_import - @bulk_import = current_account.bulk_imports.where(state: :unconfirmed).find(params[:id]) + @bulk_import = current_account.bulk_imports.state_unconfirmed.find(params[:id]) end def set_recent_imports diff --git a/app/controllers/settings/preferences/base_controller.rb b/app/controllers/settings/preferences/base_controller.rb index c1f8b49898..ce6e2bba44 100644 --- a/app/controllers/settings/preferences/base_controller.rb +++ b/app/controllers/settings/preferences/base_controller.rb @@ -19,6 +19,16 @@ class Settings::Preferences::BaseController < Settings::BaseController end def user_params + original_user_params.tap do |params| + params[:settings_attributes]&.merge!(disabled_visibilities_params[:settings_attributes] || {}) + end + end + + def original_user_params params.require(:user).permit(:locale, :time_zone, chosen_languages: [], settings_attributes: UserSettings.keys) end + + def disabled_visibilities_params + params.require(:user).permit(settings_attributes: { enabled_visibilities: [] }) + end end diff --git a/app/controllers/settings/preferences/other_controller.rb b/app/controllers/settings/preferences/other_controller.rb index a19fbf5c48..02925fa6e5 100644 --- a/app/controllers/settings/preferences/other_controller.rb +++ b/app/controllers/settings/preferences/other_controller.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true class Settings::Preferences::OtherController < Settings::Preferences::BaseController + include DtlHelper + + def show + @dtl_enabled = dtl_enabled? + @dtl_tag = dtl_tag_name + end + private def after_update_redirect_path diff --git a/app/controllers/settings/preferences/reaching_controller.rb b/app/controllers/settings/preferences/reaching_controller.rb new file mode 100644 index 0000000000..bd3e6ae9b2 --- /dev/null +++ b/app/controllers/settings/preferences/reaching_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Settings::Preferences::ReachingController < Settings::Preferences::BaseController + private + + def after_update_redirect_path + settings_preferences_reaching_path + end +end diff --git a/app/controllers/settings/privacy_extra_controller.rb b/app/controllers/settings/privacy_extra_controller.rb new file mode 100644 index 0000000000..49c71d5071 --- /dev/null +++ b/app/controllers/settings/privacy_extra_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Settings::PrivacyExtraController < Settings::BaseController + before_action :set_account + + def show; end + + 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) + redirect_to settings_privacy_extra_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end + end + + private + + def account_params + params.require(:account).permit(:subscription_policy, settings: UserSettings.keys) + end + + def set_account + @account = current_account + end +end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 8ae69b7fe0..a023b073b3 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -20,7 +20,8 @@ class Settings::ProfilesController < Settings::BaseController private def account_params - params.require(:account).permit(:display_name, :note, :avatar, :header, :bot, fields_attributes: [:name, :value]) + # params.require(:account).permit(:display_name, :note, :bio_markdown, :avatar, :header, :locked, :my_actor_type, :searchability, :dissubscribable, :discoverable, :discoverable_local, :hide_collections, fields_attributes: [:name, :value]) + params.require(:account).permit(:display_name, :note, :bio_markdown, :avatar, :header, :bot, :my_actor_type, :dissubscribable, fields_attributes: [:name, :value]) end def set_account diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb index c86ede4f3a..9714d54f95 100644 --- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb +++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb @@ -6,8 +6,8 @@ module Settings skip_before_action :check_self_destruct! skip_before_action :require_functional! - before_action :require_otp_enabled - before_action :require_webauthn_enabled, only: [:index, :destroy] + before_action :redirect_invalid_otp, unless: -> { current_user.otp_enabled? } + before_action :redirect_invalid_webauthn, only: [:index, :destroy], unless: -> { current_user.webauthn_enabled? } def index; end def new; end @@ -85,18 +85,14 @@ module Settings private - def require_otp_enabled - unless current_user.otp_enabled? - flash[:error] = t('webauthn_credentials.otp_required') - redirect_to settings_two_factor_authentication_methods_path - end + def redirect_invalid_otp + flash[:error] = t('webauthn_credentials.otp_required') + redirect_to settings_two_factor_authentication_methods_path end - def require_webauthn_enabled - unless current_user.webauthn_enabled? - flash[:error] = t('webauthn_credentials.not_enabled') - redirect_to settings_two_factor_authentication_methods_path - end + def redirect_invalid_webauthn + flash[:error] = t('webauthn_credentials.not_enabled') + redirect_to settings_two_factor_authentication_methods_path end end end diff --git a/app/controllers/severed_relationships_controller.rb b/app/controllers/severed_relationships_controller.rb new file mode 100644 index 0000000000..168e85e3fe --- /dev/null +++ b/app/controllers/severed_relationships_controller.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class SeveredRelationshipsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :set_body_classes + before_action :set_cache_headers + + before_action :set_event, only: [:following, :followers] + + def index + @events = AccountRelationshipSeveranceEvent.where(account: current_account) + end + + def following + respond_to do |format| + format.csv { send_data following_data, filename: "following-#{@event.target_name}-#{@event.created_at.to_date.iso8601}.csv" } + end + end + + def followers + respond_to do |format| + format.csv { send_data followers_data, filename: "followers-#{@event.target_name}-#{@event.created_at.to_date.iso8601}.csv" } + end + end + + private + + def set_event + @event = AccountRelationshipSeveranceEvent.find(params[:id]) + end + + def following_data + CSV.generate(headers: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'], write_headers: true) do |csv| + @event.severed_relationships.active.about_local_account(current_account).includes(:remote_account).reorder(id: :desc).each do |follow| + csv << [acct(follow.target_account), follow.show_reblogs, follow.notify, follow.languages&.join(', ')] + end + end + end + + def followers_data + CSV.generate(headers: ['Account address'], write_headers: true) do |csv| + @event.severed_relationships.passive.about_local_account(current_account).includes(:remote_account).reorder(id: :desc).each do |follow| + csv << [acct(follow.account)] + end + end + end + + def acct(account) + account.local? ? account.local_username_and_domain : account.acct + end + + def set_body_classes + @body_classes = 'admin' + end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end +end diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb index 4a3fc10ca4..04c3c0e05c 100644 --- a/app/controllers/statuses_cleanup_controller.rb +++ b/app/controllers/statuses_cleanup_controller.rb @@ -31,7 +31,7 @@ class StatusesCleanupController < ApplicationController end def resource_params - params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs) + params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :keep_self_emoji, :min_favs, :min_reblogs, :min_emojis) end def set_body_classes diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index db7eddd78b..be87161cc8 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -29,15 +29,15 @@ class StatusesController < ApplicationController end format.json do - expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? - render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter + expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? && !misskey_software? && !@status.expires? + render_with_cache json: @status, content_type: 'application/activity+json', serializer: status_activity_serializer, adapter: ActivityPub::Adapter, cancel_cache: misskey_software? end end end def activity - expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? - render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter + expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? && !misskey_software? + render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status, for_misskey: misskey_software?), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, cancel_cache: misskey_software? end def embed @@ -61,11 +61,41 @@ class StatusesController < ApplicationController def set_status @status = @account.statuses.find(params[:id]) - authorize @status, :show? + + if request.authorization.present? && request.authorization.match(/^Bearer /i) + raise Mastodon::NotPermittedError unless @status.capability_tokens.find_by(token: request.authorization.gsub(/^Bearer /i, '')) + elsif request.format == :json && @status.expires? + raise Mastodon::NotPermittedError unless StatusPolicy.new(signed_request_account, @status).show_activity? + else + authorize @status, :show? + end rescue Mastodon::NotPermittedError not_found end + def misskey_software? + return @misskey_software if defined?(@misskey_software) + + @misskey_software = false + + return false if !@status.local? || signed_request_account&.domain.blank? || !@status.sending_maybe_compromised_privacy? + + return @misskey_software = true if DomainBlock.detect_invalid_subscription?(signed_request_account.domain) + + info = InstanceInfo.find_by(domain: signed_request_account.domain) + return false if info.nil? + + @misskey_software = %w(misskey calckey cherrypick sharkey).include?(info.software) + end + + def status_activity_serializer + if misskey_software? + ActivityPub::NoteForMisskeySerializer + else + ActivityPub::NoteSerializer + end + end + def redirect_to_original redirect_to(ActivityPub::TagManager.instance.url_for(@status.reblog), allow_other_host: true) if @status.reblog? end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index b0bdbde956..d6c0d872c8 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -45,7 +45,7 @@ class TagsController < ApplicationController end def set_statuses - @statuses = cache_collection(TagFeed.new(@tag, nil, local: @local).get(limit_param), Status) + @statuses = preload_collection(TagFeed.new(@tag, nil, local: @local).get(limit_param), Status) end def limit_param diff --git a/app/controllers/well_known/oauth_metadata_controller.rb b/app/controllers/well_known/oauth_metadata_controller.rb new file mode 100644 index 0000000000..c80be2d652 --- /dev/null +++ b/app/controllers/well_known/oauth_metadata_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module WellKnown + class OauthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController + include CacheConcern + + # Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user` + # and thus re-issuing session cookies + serialization_scope nil + + def show + # Due to this document potentially changing between Mastodon versions (as + # new OAuth scopes are added), we don't use expires_in to cache upstream, + # instead just caching in the rails cache: + render_with_cache( + json: ::OauthMetadataPresenter.new, + serializer: ::OauthMetadataSerializer, + content_type: 'application/json', + expires_in: 15.minutes + ) + end + end +end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 6301919a9e..6136c82ce7 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -27,21 +27,25 @@ module AccountsHelper end end + def account_formatted_stat(value) + number_to_human(value, precision: 3, strip_insignificant_zeros: true) + end + def account_description(account) prepend_str = [ [ - number_to_human(account.statuses_count, precision: 3, strip_insignificant_zeros: true), - I18n.t('accounts.posts', count: account.statuses_count), + account_formatted_stat(account.public_statuses_count), + I18n.t('accounts.posts', count: account.public_statuses_count), ].join(' '), [ - number_to_human(account.following_count, precision: 3, strip_insignificant_zeros: true), - I18n.t('accounts.following', count: account.following_count), + account_formatted_stat(account.public_following_count), + I18n.t('accounts.following', count: account.public_following_count), ].join(' '), [ - number_to_human(account.followers_count, precision: 3, strip_insignificant_zeros: true), - I18n.t('accounts.followers', count: account.followers_count), + account_formatted_stat(account.public_followers_count), + I18n.t('accounts.followers', count: account.public_followers_count), ].join(' '), ].join(', ') diff --git a/app/helpers/admin/accounts_helper.rb b/app/helpers/admin/accounts_helper.rb index a936797e88..a2d2f75308 100644 --- a/app/helpers/admin/accounts_helper.rb +++ b/app/helpers/admin/accounts_helper.rb @@ -7,6 +7,7 @@ module Admin::AccountsHelper [t('admin.accounts.moderation.silenced'), 'silenced'], [t('admin.accounts.moderation.disabled'), 'disabled'], [t('admin.accounts.moderation.suspended'), 'suspended'], + [t('admin.accounts.moderation.remote_pending'), 'remote_pending'], [safe_join([t('admin.accounts.moderation.pending'), "(#{pending_user_count_label})"], ' '), 'pending'], ] end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 4018ef6b1c..4355b5f787 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -23,7 +23,9 @@ module Admin::ActionLogsHelper link_to log.human_identifier, disputes_strike_path(log.target_id) when 'Announcement' link_to truncate(log.human_identifier), edit_admin_announcement_path(log.target_id) - when 'IpBlock', 'Instance', 'CustomEmoji' + when 'CustomEmoji' + link_to log.human_identifier, edit_admin_custom_emoji_path(log.target_id) + when 'IpBlock', 'Instance' log.human_identifier when 'CanonicalEmailBlock' content_tag(:samp, (log.human_identifier.presence || '')[0...7], title: log.human_identifier) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4f7f66985d..8dee31e18d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module ApplicationHelper + include RegistrationLimitationHelper + DANGEROUS_SCOPES = %w( read write @@ -28,20 +30,12 @@ module ApplicationHelper number_to_human(number, **options) end - def active_nav_class(*paths) - paths.any? { |path| current_page?(path) } ? 'active' : '' - end - - def show_landing_strip? - !user_signed_in? && !single_user_mode? - end - def open_registrations? - Setting.registrations_mode == 'open' + Setting.registrations_mode == 'open' && registrations_in_time? end def approved_registrations? - Setting.registrations_mode == 'approved' + Setting.registrations_mode == 'approved' || (Setting.registrations_mode == 'open' && !registrations_in_time?) end def closed_registrations? @@ -121,8 +115,16 @@ module ApplicationHelper content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) end + def material_symbol(icon, attributes = {}) + inline_svg_tag( + "400-24px/#{icon}.svg", + class: %w(icon).concat(attributes[:class].to_s.split), + role: :img + ) + end + def check_icon - content_tag(:svg, tag.path('fill-rule': 'evenodd', 'clip-rule': 'evenodd', d: 'M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'), xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 20 20', fill: 'currentColor') + inline_svg_tag 'check.svg' end def visibility_icon(status) @@ -130,6 +132,10 @@ module ApplicationHelper fa_icon('globe', title: I18n.t('statuses.visibilities.public')) elsif status.unlisted_visibility? fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted')) + elsif status.public_unlisted_visibility? + fa_icon('cloud', title: I18n.t('statuses.visibilities.public_unlisted')) + elsif status.login_visibility? + fa_icon('key', title: I18n.t('statuses.visibilities.login')) elsif status.private_visibility? || status.limited_visibility? fa_icon('lock', title: I18n.t('statuses.visibilities.private')) elsif status.direct_visibility? @@ -195,10 +201,14 @@ module ApplicationHelper text: [params[:title], params[:text], params[:url]].compact.join(' '), } - permit_visibilities = %w(public unlisted private direct) - default_privacy = current_account&.user&.setting_default_privacy + permit_visibilities = %w(public unlisted public_unlisted login private direct) + permit_searchabilities = %w(public unlisted public_unlisted login private direct) + default_privacy = current_account&.user&.setting_default_privacy permit_visibilities.shift(permit_visibilities.index(default_privacy) + 1) if default_privacy.present? state_params[:visibility] = params[:visibility] if permit_visibilities.include? params[:visibility] + default_searchability = current_account&.user&.setting_default_searchability + permit_searchabilities.shift(permit_searchabilities.index(default_privacy) + 1) if default_searchability.present? + state_params[:searchability] = params[:searchability] if permit_searchabilities.include? params[:searchability] if user_signed_in? && current_user.functional? state_params[:settings] = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {}) @@ -213,7 +223,7 @@ module ApplicationHelper state_params[:moved_to_account] = current_account.moved_to_account end - state_params[:owner] = Account.local.without_suspended.where('id > 0').first if single_user_mode? + state_params[:owner] = Account.local.without_suspended.without_internal.first if single_user_mode? json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json # rubocop:disable Rails/OutputSafety @@ -240,6 +250,22 @@ module ApplicationHelper EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s end + def prerender_custom_emojis_from_hash(html, custom_emojis_hash) + prerender_custom_emojis(html, JSON.parse([custom_emojis_hash].to_json, object_class: OpenStruct)) # rubocop:disable Style/OpenStructUse + end + + def instance_presenter + @instance_presenter ||= InstancePresenter.new + end + + def favicon_path(size = '48') + instance_presenter.favicon&.file&.url(size) + end + + def app_icon_path(size = '48') + instance_presenter.app_icon&.file&.url(size) + end + private def storage_host_var diff --git a/app/helpers/branding_helper.rb b/app/helpers/branding_helper.rb index 2b9c233c23..8201f36e3c 100644 --- a/app/helpers/branding_helper.rb +++ b/app/helpers/branding_helper.rb @@ -19,17 +19,6 @@ module BrandingHelper end def render_logo - image_pack_tag('logo.svg', alt: 'Mastodon', class: 'logo logo--icon') - end - - def render_symbol(version = :icon) - path = case version - when :icon - 'logo-symbol-icon.svg' - when :wordmark - 'logo-symbol-wordmark.svg' - end - - render(file: Rails.root.join('app', 'javascript', 'images', path)).html_safe # rubocop:disable Rails/OutputSafety + image_tag(frontend_asset_path('images/logo.svg'), alt: 'Mastodon', class: 'logo logo--icon') end end diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 945ef9b91a..804c48c7b0 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -23,13 +23,31 @@ module ContextHelper indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' }, memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, + emoji_reactions: { 'fedibird' => 'http://fedibird.com/ns#', 'emojiReactions' => { '@id' => 'fedibird:emojiReactions', '@type' => '@id' } }, + searchable_by: { 'fedibird' => 'http://fedibird.com/ns#', 'searchableBy' => { '@id' => 'fedibird:searchableBy', '@type' => '@id' } }, + subscribable_by: { 'kmyblue' => 'http://kmy.blue/ns#', 'subscribableBy' => { '@id' => 'kmyblue:subscribableBy', '@type' => '@id' } }, + limited_scope: { 'kmyblue' => 'http://kmy.blue/ns#', 'limitedScope' => 'kmyblue:limitedScope' }, + other_setting: { 'fedibird' => 'http://fedibird.com/ns#', 'otherSetting' => 'fedibird:otherSetting' }, + references: { 'fedibird' => 'http://fedibird.com/ns#', 'references' => { '@id' => 'fedibird:references', '@type' => '@id' } }, + quote_uri: { 'fedibird' => 'http://fedibird.com/ns#', 'quoteUri' => 'fedibird:quoteUri' }, + keywords: { 'schema' => 'http://schema.org#', 'keywords' => 'schema:keywords' }, + license: { 'schema' => 'http://schema.org#', 'license' => 'schema:license' }, olm: { - 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', + 'toot' => 'http://joinmastodon.org/ns#', + 'Device' => 'toot:Device', + 'Ed25519Signature' => 'toot:Ed25519Signature', + 'Ed25519Key' => 'toot:Ed25519Key', + 'Curve25519Key' => 'toot:Curve25519Key', + 'EncryptedMessage' => 'toot:EncryptedMessage', + 'publicKeyBase64' => 'toot:publicKeyBase64', + 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, - 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' + 'messageFranking' => 'toot:messageFranking', + 'messageType' => 'toot:messageType', + 'cipherText' => 'toot:cipherText', }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, }.freeze @@ -39,13 +57,11 @@ module ContextHelper end def serialized_context(named_contexts_map, context_extensions_map) - context_array = [] - named_contexts = named_contexts_map.keys context_extensions = context_extensions_map.keys - named_contexts.each do |key| - context_array << NAMED_CONTEXT_MAP[key] + context_array = named_contexts.map do |key| + NAMED_CONTEXT_MAP[key] end extensions = context_extensions.each_with_object({}) do |key, h| diff --git a/app/helpers/dtl_helper.rb b/app/helpers/dtl_helper.rb new file mode 100644 index 0000000000..aa2c414c5f --- /dev/null +++ b/app/helpers/dtl_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DtlHelper + def dtl_enabled? + ENV.fetch('DTL_ENABLED', 'false') == 'true' + end + + def dtl_tag_name + ENV.fetch('DTL_TAG', 'kmyblue') + end +end diff --git a/app/helpers/follow_helper.rb b/app/helpers/follow_helper.rb new file mode 100644 index 0000000000..d7d7ff35e9 --- /dev/null +++ b/app/helpers/follow_helper.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module FollowHelper + def request_pending_follow?(source_account, target_account) + target_account.locked? || source_account.silenced? || block_straight_follow?(source_account) || + ((source_account.bot? || proxy_account?(source_account)) && target_account.user&.setting_lock_follow_from_bot) + end + + def block_straight_follow?(account) + return false if account.local? + + DomainBlock.reject_straight_follow?(account.domain) + end + + def proxy_account?(account) + (account.username.downcase.include?('_proxy') || + account.username.downcase.end_with?('proxy') || + account.username.downcase.include?('_bot_') || + account.username.downcase.end_with?('bot') || + account.display_name&.downcase&.include?('proxy') || + account.display_name&.include?('プロキシ') || + account.note&.include?('プロキシ')) && + (account.following_count.zero? || account.following_count > account.followers_count) && + proxyable_software?(account) + end + + def proxyable_software?(account) + return false if account.local? + + info = InstanceInfo.find_by(domain: account.domain) + return false if info.nil? + + %w(misskey calckey firefish meisskey cherrypick sharkey).include?(info.software) + end +end diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index 7d1423e52d..f0faf69b43 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -18,8 +18,16 @@ module FormattingHelper end module_function :extract_status_plain_text + def extract_status_plain_text_with_spoiler_text(status) + PlainTextFormatter.new("#{status.spoiler_text}\n#{status.text}", status.local?).to_s + end + def status_content_format(status) - html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) + html_aware_format(status.text, status.local?, markdown: status.markdown, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) + end + + def emoji_name_format(emoji_reaction, status) + html_aware_format(emoji_reaction['url'].present? ? ":#{emoji_reaction['name']}:" : emoji_reaction['name'], status.local?, markdown: status.markdown) end def rss_status_content_format(status) @@ -49,19 +57,19 @@ module FormattingHelper prerender_custom_emojis( safe_join([before_html, html, after_html]), status.emojis, - style: 'width: 1.1em; height: 1.1em; object-fit: contain; vertical-align: middle; margin: -.2ex .15em .2ex' + style: 'min-width: 1.1em; height: 1.1em; object-fit: contain; vertical-align: middle; margin: -.2ex .15em .2ex' ).to_str end def account_bio_format(account) - html_aware_format(account.note, account.local?) + html_aware_format(account.note, account.local?, markdown: account.user&.setting_bio_markdown) end def account_field_value_format(field, with_rel_me: true) if field.verified? && !field.account.local? TextFormatter.shortened_link(field.value_for_verification) else - html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false) + html_aware_format(field.value, field.account.local?, markdown: false, with_rel_me: with_rel_me, with_domains: true, multiline: false) end end end diff --git a/app/helpers/high_load_helper.rb b/app/helpers/high_load_helper.rb new file mode 100644 index 0000000000..b4606c039f --- /dev/null +++ b/app/helpers/high_load_helper.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module HighLoadHelper + def allow_high_load? + ENV.fetch('ALLOW_HIGH_LOAD', 'true') == 'true' + end + module_function :allow_high_load? +end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index ce3ff094f6..75901e3ff1 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -51,6 +51,14 @@ module JsonLdHelper end end + def as_array_ex(value) + if value.is_a?(Hash) + [] + else + as_array(value) + end + end + def value_or_id(value) value.is_a?(String) || value.nil? ? value : value['id'] end @@ -111,7 +119,7 @@ module JsonLdHelper patch_for_forwarding!(value, compacted_value) elsif value.is_a?(Array) compacted_value = [compacted_value] unless compacted_value.is_a?(Array) - return if value.size != compacted_value.size + next if value.size != compacted_value.size compacted[key] = value.zip(compacted_value).map do |v, vc| if v.is_a?(Hash) && vc.is_a?(Hash) @@ -155,8 +163,8 @@ module JsonLdHelper end end - def fetch_resource(uri, id, on_behalf_of = nil) - unless id + 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) return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id']) @@ -164,17 +172,29 @@ module JsonLdHelper uri = json['id'] end - json = fetch_resource_without_id_validation(uri, on_behalf_of) + json = fetch_resource_without_id_validation(uri, on_behalf_of, request_options: request_options) json.present? && json['id'] == uri ? json : nil end - def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false) + 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).perform do |response| + build_request(uri, on_behalf_of, options: request_options).perform do |response| raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error - body_to_json(response.body_with_limit) if response.code == 200 + body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response) + end + end + + def valid_activitypub_content_type?(response) + return true if response.mime_type == 'application/activity+json' + + # When the mime type is `application/ld+json`, we need to check the profile, + # but `http.rb` does not parse it for us. + return false unless response.mime_type == 'application/ld+json' + + response.headers[HTTP::Headers::CONTENT_TYPE]&.split(';')&.map(&:strip)&.any? do |str| + str.start_with?('profile="') && str[9...-1].split.include?('https://www.w3.org/ns/activitystreams') end end @@ -204,8 +224,8 @@ module JsonLdHelper response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)) end - def build_request(uri, on_behalf_of = nil) - Request.new(:get, uri).tap do |request| + def build_request(uri, on_behalf_of = nil, options: {}) + Request.new(:get, uri, **options).tap do |request| request.on_behalf_of(on_behalf_of) if on_behalf_of request.add_headers('Accept' => 'application/activity+json, application/ld+json') end diff --git a/app/helpers/kmyblue_capabilities_helper.rb b/app/helpers/kmyblue_capabilities_helper.rb new file mode 100644 index 0000000000..075869ee60 --- /dev/null +++ b/app/helpers/kmyblue_capabilities_helper.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module KmyblueCapabilitiesHelper + def fedibird_capabilities + capabilities = %i( + enable_wide_emoji + kmyblue_searchability + searchability + kmyblue_markdown + kmyblue_reaction_deck + kmyblue_visibility_login + status_reference + visibility_mutual + visibility_limited + kmyblue_limited_scope + kmyblue_antenna + kmyblue_bookmark_category + kmyblue_quote + kmyblue_searchability_limited + kmyblue_circle_history + kmyblue_list_notification + kmyblue_server_features + ) + + capabilities << :full_text_search if Chewy.enabled? + if Setting.enable_emoji_reaction + capabilities << :emoji_reaction + capabilities << :enable_wide_emoji_reaction + end + capabilities << :kmyblue_visibility_public_unlisted if Setting.enable_public_unlisted_visibility + capabilities << :kmyblue_searchability_public_unlisted if Setting.enable_public_unlisted_visibility + capabilities << :kmyblue_no_public_visibility unless Setting.enable_public_visibility + capabilities << :timeline_no_local unless Setting.enable_local_timeline + + capabilities + end + + def capabilities_for_nodeinfo + capabilities = %i( + enable_wide_emoji + status_reference + quote + emoji_keywords + circle + ) + + if Setting.enable_emoji_reaction + capabilities << :emoji_reaction + capabilities << :enable_wide_emoji_reaction + end + + capabilities + end +end diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 87f0f288d3..9e1c0a7db1 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -109,6 +109,7 @@ module LanguagesHelper mn: ['Mongolian', 'Монгол хэл'].freeze, mr: ['Marathi', 'मराठी'].freeze, ms: ['Malay', 'Bahasa Melayu'].freeze, + 'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze, mt: ['Maltese', 'Malti'].freeze, my: ['Burmese', 'ဗမာစာ'].freeze, na: ['Nauru', 'Ekakairũ Naoero'].freeze, @@ -127,7 +128,7 @@ module LanguagesHelper om: ['Oromo', 'Afaan Oromoo'].freeze, or: ['Oriya', 'ଓଡ଼ିଆ'].freeze, os: ['Ossetian', 'ирон æвзаг'].freeze, - pa: ['Panjabi', 'ਪੰਜਾਬੀ'].freeze, + pa: ['Punjabi', 'ਪੰਜਾਬੀ'].freeze, pi: ['Pāli', 'पाऴि'].freeze, pl: ['Polish', 'Polski'].freeze, ps: ['Pashto', 'پښتو'].freeze, @@ -191,15 +192,20 @@ module LanguagesHelper chr: ['Cherokee', 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ'].freeze, ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze, cnr: ['Montenegrin', 'crnogorski'].freeze, + csb: ['Kashubian', 'Kaszëbsczi'].freeze, jbo: ['Lojban', 'la .lojban.'].freeze, kab: ['Kabyle', 'Taqbaylit'].freeze, ldn: ['Láadan', 'Láadan'].freeze, lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze, + moh: ['Mohawk', 'Kanienʼkéha'].freeze, + nds: ['Low German', 'Plattdüütsch'].freeze, + pdc: ['Pennsylvania Dutch', 'Pennsilfaani-Deitsch'].freeze, sco: ['Scots', 'Scots'].freeze, sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze, smj: ['Lule Sami', 'Julevsámegiella'].freeze, szl: ['Silesian', 'ślůnsko godka'].freeze, tok: ['Toki Pona', 'toki pona'].freeze, + vai: ['Vai', 'ꕙꔤ'].freeze, xal: ['Kalmyk', 'Хальмг келн'].freeze, zba: ['Balaibalan', 'باليبلن'].freeze, zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze, diff --git a/app/helpers/mascot_helper.rb b/app/helpers/mascot_helper.rb index 8ee04383ec..34b656411e 100644 --- a/app/helpers/mascot_helper.rb +++ b/app/helpers/mascot_helper.rb @@ -2,7 +2,7 @@ module MascotHelper def mascot_url - full_asset_url(instance_presenter.mascot&.file&.url || asset_pack_path('media/images/elephant_ui_plane.svg')) + full_asset_url(instance_presenter.mascot&.file&.url || frontend_asset_path('images/elephant_ui_plane.svg')) end def instance_presenter diff --git a/app/helpers/ng_rule_helper.rb b/app/helpers/ng_rule_helper.rb new file mode 100644 index 0000000000..104442b117 --- /dev/null +++ b/app/helpers/ng_rule_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module NgRuleHelper + def check_invalid_status_for_ng_rule!(account, **options) + (check_for_ng_rule!(account, **options) { |rule| !rule.check_status_or_record! }).none? + end + + def check_invalid_reaction_for_ng_rule!(account, **options) + (check_for_ng_rule!(account, **options) { |rule| !rule.check_reaction_or_record! }).none? + end + + private + + def check_for_ng_rule!(account, **options, &block) + NgRule.cached_rules + .map { |raw_rule| Admin::NgRule.new(raw_rule, account, **options) } + .filter(&block) + end + + def do_account_action_for_rule!(account, action) + case action + when :silence + account.silence! + when :suspend + account.suspend! + end + end +end diff --git a/app/helpers/react_component_helper.rb b/app/helpers/react_component_helper.rb index ce616e8306..821a6f1e2d 100644 --- a/app/helpers/react_component_helper.rb +++ b/app/helpers/react_component_helper.rb @@ -15,9 +15,20 @@ module ReactComponentHelper div_tag_with_data(data) end + def serialized_media_attachments(media_attachments) + media_attachments.map { |attachment| serialized_attachment(attachment) } + end + private def div_tag_with_data(data) content_tag(:div, nil, data: data) end + + def serialized_attachment(attachment) + ActiveModelSerializers::SerializableResource.new( + attachment, + serializer: REST::MediaAttachmentSerializer + ).as_json + end end diff --git a/app/helpers/registration_helper.rb b/app/helpers/registration_helper.rb index ef5462ac88..c3db46c027 100644 --- a/app/helpers/registration_helper.rb +++ b/app/helpers/registration_helper.rb @@ -3,8 +3,10 @@ module RegistrationHelper extend ActiveSupport::Concern + include RegistrationLimitationHelper + def allowed_registration?(remote_ip, invite) - !Rails.configuration.x.single_user_mode && !omniauth_only? && (registrations_open? || invite&.valid_for_use?) && !ip_blocked?(remote_ip) + !Rails.configuration.x.single_user_mode && !omniauth_only? && ((registrations_open? && !reach_registrations_limit?) || invite&.valid_for_use?) && !ip_blocked?(remote_ip) end def registrations_open? diff --git a/app/helpers/registration_limitation_helper.rb b/app/helpers/registration_limitation_helper.rb new file mode 100644 index 0000000000..56523b816c --- /dev/null +++ b/app/helpers/registration_limitation_helper.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module RegistrationLimitationHelper + def reach_registrations_limit? + ((Setting.registrations_limit.presence || 0).positive? && Setting.registrations_limit <= user_count_for_registration) || + ((Setting.registrations_limit_per_day.presence || 0).positive? && Setting.registrations_limit_per_day <= today_increase_user_count) + end + + def user_count_for_registration + Rails.cache.fetch('registrations:user_count') { User.confirmed.enabled.joins(:account).merge(Account.without_suspended).count } + end + + def today_increase_user_count + today_date = Time.now.utc.beginning_of_day.to_i + count = 0 + + if Rails.cache.fetch('registrations:today_date') { today_date } == today_date + count = Rails.cache.fetch('registrations:today_increase_user_count') { today_increase_user_count_value } + else + count = today_increase_user_count_value + Rails.cache.write('registrations:today_date', today_date) + Rails.cache.write('registrations:today_increase_user_count', count) + end + + count + end + + def today_increase_user_count_value + User.confirmed.enabled.where('users.created_at >= ?', Time.now.utc.beginning_of_day).joins(:account).merge(Account.without_suspended).count + end + + def registrations_in_time? + start_hour = Setting.registrations_start_hour + end_hour = Setting.registrations_end_hour + secondary_start_hour = Setting.registrations_secondary_start_hour + secondary_end_hour = Setting.registrations_secondary_end_hour + + start_hour = 0 unless start_hour.is_a?(Integer) + end_hour = 0 unless end_hour.is_a?(Integer) + secondary_start_hour = 0 unless secondary_start_hour.is_a?(Integer) + secondary_end_hour = 0 unless secondary_end_hour.is_a?(Integer) + + return true if start_hour >= end_hour && secondary_start_hour >= secondary_end_hour + + current_hour = Time.now.utc.hour + + (start_hour < end_hour && end_hour.positive? && current_hour.between?(start_hour, end_hour - 1)) || + (secondary_start_hour < secondary_end_hour && secondary_end_hour.positive? && current_hour.between?(secondary_start_hour, secondary_end_hour - 1)) + end + + def reset_registration_limit_caches! + Rails.cache.delete('registrations:user_count') + Rails.cache.delete('registrations:today_increase_user_count') + end +end diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index 2fb9ce72cb..15d988f64d 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -24,8 +24,12 @@ module RoutingHelper Rails.configuration.action_controller.asset_host || root_url end - def full_pack_url(source, **options) - full_asset_url(asset_pack_path(source, **options)) + def frontend_asset_path(source, **options) + asset_pack_path("media/#{source}", **options) + end + + def frontend_asset_url(source, **options) + full_asset_url(frontend_asset_path(source, **options)) end def use_storage? diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 286c53d834..8ded11e03d 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -4,14 +4,6 @@ module StatusesHelper EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' - def link_to_newer(url) - link_to t('statuses.show_newer'), url, class: 'load-more load-gap' - end - - def link_to_older(url) - link_to t('statuses.show_older'), url, class: 'load-more load-gap' - end - def nothing_here(extra_classes = '') content_tag(:div, class: "nothing-here #{extra_classes}") do t('accounts.nothing_here') @@ -71,8 +63,14 @@ module StatusesHelper fa_icon 'globe fw' when 'unlisted' fa_icon 'unlock fw' + when 'public_unlisted' + fa_icon 'cloud fw' + when 'login' + fa_icon 'key fw' when 'private' fa_icon 'lock fw' + when 'limited' + fa_icon 'get-pocket fw' when 'direct' fa_icon 'at fw' end diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb new file mode 100644 index 0000000000..d15259851c --- /dev/null +++ b/app/helpers/theme_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ThemeHelper + def theme_style_tags(theme) + if theme == 'system' + stylesheet_pack_tag('mastodon-light', media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') + + stylesheet_pack_tag('default', media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') + else + stylesheet_pack_tag theme, media: 'all', crossorigin: 'anonymous' + end + end + + def theme_color_tags(theme) + if theme == 'system' + tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)') + + tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)') + else + tag.meta name: 'theme-color', content: theme_color_for(theme) + end + end + + private + + def theme_color_for(theme) + theme == 'mastodon-light' ? Themes::THEME_COLORS[:light] : Themes::THEME_COLORS[:dark] + end +end diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx new file mode 100644 index 0000000000..64192f54ad --- /dev/null +++ b/app/javascript/entrypoints/admin.tsx @@ -0,0 +1,440 @@ +import './public-path'; +import { createRoot } from 'react-dom/client'; + +import Rails from '@rails/ujs'; + +import ready from '../mastodon/ready'; + +const setAnnouncementEndsAttributes = (target: HTMLInputElement) => { + const valid = target.value && target.validity.valid; + const element = document.querySelector( + 'input[type="datetime-local"]#announcement_ends_at', + ); + + if (!element) return; + + if (valid) { + element.classList.remove('optional'); + element.required = true; + element.min = target.value; + } else { + element.classList.add('optional'); + element.removeAttribute('required'); + element.removeAttribute('min'); + } +}; + +Rails.delegate( + document, + 'input[type="datetime-local"]#announcement_starts_at', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + setAnnouncementEndsAttributes(target); + }, +); + +const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; + +const showSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + selectAllMatchingElement?.classList.add('active'); +}; + +const hideSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + const hiddenField = document.querySelector( + 'input#select_all_matching', + ); + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + selectAllMatchingElement?.classList.remove('active'); + selectedMsg?.classList.remove('active'); + notSelectedMsg?.classList.add('active'); + if (hiddenField) hiddenField.value = '0'; +}; + +Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + document + .querySelectorAll(batchCheckboxClassName) + .forEach((content) => { + content.checked = target.checked; + }); + + if (selectAllMatchingElement) { + if (target.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } +}); + +Rails.delegate(document, '.batch-table__select-all button', 'click', () => { + const hiddenField = document.querySelector( + '#select_all_matching', + ); + + if (!hiddenField) return; + + const active = hiddenField.value === '1'; + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + if (!selectedMsg || !notSelectedMsg) return; + + if (active) { + hiddenField.value = '0'; + selectedMsg.classList.remove('active'); + notSelectedMsg.classList.add('active'); + } else { + hiddenField.value = '1'; + notSelectedMsg.classList.remove('active'); + selectedMsg.classList.add('active'); + } +}); + +Rails.delegate(document, batchCheckboxClassName, 'change', () => { + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + + if (selectAllMatchingElement) { + if (checkAllElement.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } + } +}); + +Rails.delegate( + document, + '.filter-subset--with-select select', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) target.form?.submit(); + }, +); + +const onDomainBlockSeverityChange = (target: HTMLSelectElement) => { + const rejectMediaDiv = document.querySelector( + '.input.with_label.domain_block_reject_media', + ); + const rejectReportsDiv = document.querySelector( + '.input.with_label.domain_block_reject_reports', + ); + + if (rejectMediaDiv && rejectMediaDiv instanceof HTMLElement) { + rejectMediaDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } + + if (rejectReportsDiv && rejectReportsDiv instanceof HTMLElement) { + rejectReportsDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } +}; + +Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => { + if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target); +}); + +const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => { + const bootstrapTimelineAccountsField = + document.querySelector( + '#form_admin_settings_bootstrap_timeline_accounts', + ); + + if (bootstrapTimelineAccountsField) { + bootstrapTimelineAccountsField.disabled = !target.checked; + if (target.checked) { + bootstrapTimelineAccountsField.parentElement?.classList.remove( + 'disabled', + ); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.remove( + 'disabled', + ); + } else { + bootstrapTimelineAccountsField.parentElement?.classList.add('disabled'); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.add( + 'disabled', + ); + } + } +}; + +Rails.delegate( + document, + '#form_admin_settings_enable_bootstrap_timeline_accounts', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + onEnableBootstrapTimelineAccountsChange(target); + }, +); + +const onChangeRegistrationMode = (target: HTMLSelectElement) => { + const enabled = target.value === 'approved'; + + document + .querySelectorAll( + '.form_admin_settings_registrations_mode .warning-hint', + ) + .forEach((warning_hint) => { + warning_hint.style.display = target.value === 'open' ? 'inline' : 'none'; + }); + + const toggleEnabled = (input: HTMLInputElement, value: boolean) => { + input.disabled = !value; + if (value) { + let element: HTMLElement | null = input; + do { + element.classList.remove('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } else { + let element: HTMLElement | null = input; + do { + element.classList.add('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } + }; + + document + .querySelectorAll( + 'input#form_admin_settings_require_invite_text', + ) + .forEach((input) => { + toggleEnabled(input, enabled); + }); + + document + .querySelectorAll( + '#form_admin_settings_registrations_start_hour, #form_admin_settings_registrations_end_hour, #form_admin_settings_registrations_secondary_start_hour, #form_admin_settings_registrations_secondary_end_hour', + ) + .forEach((input) => { + toggleEnabled(input, target.value === 'open'); + }); +}; + +const convertUTCDateTimeToLocal = (value: string) => { + const date = new Date(value + 'Z'); + const twoChars = (x: number) => x.toString().padStart(2, '0'); + return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`; +}; + +function convertLocalDatetimeToUTC(value: string) { + const date = new Date(value); + const fullISO8601 = date.toISOString(); + return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); +} + +Rails.delegate( + document, + '#form_admin_settings_registrations_mode', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target); + }, +); + +const addTableRow = (tableId: string) => { + const templateElement = document.querySelector(`#${tableId} .template-row`)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion + const tableElement = document.querySelector(`#${tableId} tbody`)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion + + if ( + typeof templateElement === 'undefined' || + typeof tableElement === 'undefined' + ) + return; + + let temporaryId = 0; + tableElement + .querySelectorAll('.temporary_id') + .forEach((input) => { + if (parseInt(input.value) + 1 > temporaryId) { + temporaryId = parseInt(input.value) + 1; + } + }); + + const cloned = templateElement.cloneNode(true) as HTMLTableRowElement; + cloned.className = ''; + cloned.querySelector('.temporary_id')!.value = // eslint-disable-line @typescript-eslint/no-non-null-assertion + temporaryId.toString(); + cloned + .querySelectorAll('input[type=checkbox]') + .forEach((input) => { + input.value = temporaryId.toString(); + }); + tableElement.appendChild(cloned); +}; + +const removeTableRow = (target: EventTarget | null, tableId: string) => { + const tableRowElement = (target as HTMLElement).closest('tr') as Node; + const tableElement = document.querySelector(`#${tableId} tbody`)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion + + if ( + typeof tableRowElement === 'undefined' || + typeof tableElement === 'undefined' + ) + return; + + tableElement.removeChild(tableRowElement); +}; + +const setupTableList = (id: string) => { + Rails.delegate(document, `#${id} .add-row-button`, 'click', (ev) => { + ev.preventDefault(); + addTableRow(id); + }); + + Rails.delegate(document, `#${id} .delete-row-button`, 'click', (ev) => { + ev.preventDefault(); + removeTableRow(ev.target, id); + }); +}; + +setupTableList('sensitive-words-table'); +setupTableList('ng-words-table'); +setupTableList('white-list-table'); + +async function mountReactComponent(element: Element) { + const componentName = element.getAttribute('data-admin-component'); + const stringProps = element.getAttribute('data-props'); + + if (!stringProps) return; + + const componentProps = JSON.parse(stringProps) as object; + + const { default: AdminComponent } = await import( + '@/mastodon/containers/admin_component' + ); + + const { default: Component } = (await import( + `@/mastodon/components/admin/${componentName}` + )) as { default: React.ComponentType }; + + const root = createRoot(element); + + root.render( + + + , + ); +} + +ready(() => { + const domainBlockSeveritySelect = document.querySelector( + 'select#domain_block_severity', + ); + if (domainBlockSeveritySelect) + onDomainBlockSeverityChange(domainBlockSeveritySelect); + + const enableBootstrapTimelineAccounts = + document.querySelector( + 'input#form_admin_settings_enable_bootstrap_timeline_accounts', + ); + if (enableBootstrapTimelineAccounts) + onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); + + const registrationMode = document.querySelector( + 'select#form_admin_settings_registrations_mode', + ); + if (registrationMode) onChangeRegistrationMode(registrationMode); + + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + } + + document + .querySelector('a#add-instance-button') + ?.addEventListener('click', (e) => { + const domain = document.querySelector( + 'input[type="text"]#by_domain', + )?.value; + + if (domain && e.target instanceof HTMLAnchorElement) { + const url = new URL(e.target.href); + url.searchParams.set('_domain', domain); + e.target.href = url.toString(); + } + }); + + document + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value) { + element.value = convertUTCDateTimeToLocal(element.value); + } + if (element.placeholder) { + element.placeholder = convertUTCDateTimeToLocal(element.placeholder); + } + }); + + Rails.delegate(document, 'form', 'submit', ({ target }) => { + if (target instanceof HTMLFormElement) + target + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value && element.validity.valid) { + element.value = convertLocalDatetimeToUTC(element.value); + } + }); + }); + + const announcementStartsAt = document.querySelector( + 'input[type="datetime-local"]#announcement_starts_at', + ); + if (announcementStartsAt) { + setAnnouncementEndsAttributes(announcementStartsAt); + } + + document.querySelectorAll('[data-admin-component]').forEach((element) => { + void mountReactComponent(element); + }); +}).catch((reason: unknown) => { + throw reason; +}); diff --git a/app/javascript/packs/application.js b/app/javascript/entrypoints/application.ts similarity index 81% rename from app/javascript/packs/application.js rename to app/javascript/entrypoints/application.ts index d13388b479..1087b1c4cb 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/entrypoints/application.ts @@ -1,5 +1,5 @@ import './public-path'; -import main from "mastodon/main"; +import main from 'mastodon/main'; import { start } from '../mastodon/common'; import { loadLocale } from '../mastodon/locales'; @@ -10,6 +10,6 @@ start(); loadPolyfills() .then(loadLocale) .then(main) - .catch(e => { + .catch((e: unknown) => { console.error(e); }); diff --git a/app/javascript/packs/error.js b/app/javascript/entrypoints/error.ts similarity index 64% rename from app/javascript/packs/error.js rename to app/javascript/entrypoints/error.ts index 6376dc2f5d..db68484f3a 100644 --- a/app/javascript/packs/error.js +++ b/app/javascript/entrypoints/error.ts @@ -2,7 +2,9 @@ import './public-path'; import ready from '../mastodon/ready'; ready(() => { - const image = document.querySelector('img'); + const image = document.querySelector('img'); + + if (!image) return; image.addEventListener('mouseenter', () => { image.src = '/oops.gif'; @@ -11,4 +13,6 @@ ready(() => { image.addEventListener('mouseleave', () => { image.src = '/oops.png'; }); +}).catch((e: unknown) => { + console.error(e); }); diff --git a/app/javascript/packs/inert.js b/app/javascript/entrypoints/inert.ts similarity index 100% rename from app/javascript/packs/inert.js rename to app/javascript/entrypoints/inert.ts diff --git a/app/javascript/packs/mailer.js b/app/javascript/entrypoints/mailer.ts similarity index 100% rename from app/javascript/packs/mailer.js rename to app/javascript/entrypoints/mailer.ts diff --git a/app/javascript/packs/public-path.js b/app/javascript/entrypoints/public-path.ts similarity index 69% rename from app/javascript/packs/public-path.js rename to app/javascript/entrypoints/public-path.ts index f4d166a771..ac4b9355b9 100644 --- a/app/javascript/packs/public-path.js +++ b/app/javascript/entrypoints/public-path.ts @@ -2,7 +2,7 @@ // to share the same assets regardless of instance configuration. // See https://webpack.js.org/guides/public-path/#on-the-fly -function removeOuterSlashes(string) { +function removeOuterSlashes(string: string) { return string.replace(/^\/*/, '').replace(/\/*$/, ''); } @@ -15,7 +15,9 @@ function formatPublicPath(host = '', path = '') { return `${formattedHost}/${formattedPath}/`; } -const cdnHost = document.querySelector('meta[name=cdn-host]'); +const cdnHost = document.querySelector('meta[name=cdn-host]'); -// eslint-disable-next-line no-undef -__webpack_public_path__ = formatPublicPath(cdnHost ? cdnHost.content : '', process.env.PUBLIC_OUTPUT_PATH); +__webpack_public_path__ = formatPublicPath( + cdnHost ? cdnHost.content : '', + process.env.PUBLIC_OUTPUT_PATH, +); diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx new file mode 100644 index 0000000000..d45927226c --- /dev/null +++ b/app/javascript/entrypoints/public.tsx @@ -0,0 +1,462 @@ +import { createRoot } from 'react-dom/client'; + +import './public-path'; + +import { IntlMessageFormat } from 'intl-messageformat'; +import type { MessageDescriptor, PrimitiveType } from 'react-intl'; +import { defineMessages } from 'react-intl'; + +import Rails from '@rails/ujs'; +import axios from 'axios'; +import { throttle } from 'lodash'; + +import { start } from '../mastodon/common'; +import { timeAgoString } from '../mastodon/components/relative_timestamp'; +import emojify from '../mastodon/features/emoji/emoji'; +import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; +import { loadLocale, getLocale } from '../mastodon/locales'; +import { loadPolyfills } from '../mastodon/polyfills'; +import ready from '../mastodon/ready'; + +import 'cocoon-js-vanilla'; + +start(); + +const messages = defineMessages({ + usernameTaken: { + id: 'username.taken', + defaultMessage: 'That username is taken. Try another', + }, + passwordExceedsLength: { + id: 'password_confirmation.exceeds_maxlength', + defaultMessage: 'Password confirmation exceeds the maximum password length', + }, + passwordDoesNotMatch: { + id: 'password_confirmation.mismatching', + defaultMessage: 'Password confirmation does not match', + }, +}); + +interface SetHeightMessage { + type: 'setHeight'; + id: string; + height: number; +} + +function isSetHeightMessage(data: unknown): data is SetHeightMessage { + if ( + data && + typeof data === 'object' && + 'type' in data && + data.type === 'setHeight' + ) + return true; + else return false; +} + +window.addEventListener('message', (e) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases + if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; + + const data = e.data; + + ready(() => { + window.parent.postMessage( + { + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0].scrollHeight, + }, + '*', + ); + }).catch((e: unknown) => { + console.error('Error in setHeightMessage postMessage', e); + }); +}); + +function loaded() { + const { messages: localeData } = getLocale(); + + const locale = document.documentElement.lang; + + const dateTimeFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }); + + const dateFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + + const timeFormat = new Intl.DateTimeFormat(locale, { + timeStyle: 'short', + }); + + const formatMessage = ( + { id, defaultMessage }: MessageDescriptor, + values?: Record, + ) => { + let message: string | undefined = undefined; + + if (id) message = localeData[id]; + + if (!message) message = defaultMessage as string; + + const messageFormat = new IntlMessageFormat(message, locale); + return messageFormat.format(values) as string; + }; + + document.querySelectorAll('.emojify').forEach((content) => { + content.innerHTML = emojify(content.innerHTML); + }); + + document + .querySelectorAll('time.formatted') + .forEach((content) => { + const datetime = new Date(content.dateTime); + const formattedDate = dateTimeFormat.format(datetime); + + content.title = formattedDate; + content.textContent = formattedDate; + }); + + const isToday = (date: Date) => { + const today = new Date(); + + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); + }; + const todayFormat = new IntlMessageFormat( + localeData['relative_format.today'] || 'Today at {time}', + locale, + ); + + document + .querySelectorAll('time.relative-formatted') + .forEach((content) => { + const datetime = new Date(content.dateTime); + + let formattedContent: string; + + if (isToday(datetime)) { + const formattedTime = timeFormat.format(datetime); + + formattedContent = todayFormat.format({ + time: formattedTime, + }) as string; + } else { + formattedContent = dateFormat.format(datetime); + } + + content.title = formattedContent; + content.textContent = formattedContent; + }); + + document + .querySelectorAll('time.time-ago') + .forEach((content) => { + const datetime = new Date(content.dateTime); + const now = new Date(); + + const timeGiven = content.dateTime.includes('T'); + content.title = timeGiven + ? dateTimeFormat.format(datetime) + : dateFormat.format(datetime); + content.textContent = timeAgoString( + { + formatMessage, + formatDate: (date: Date, options) => + new Intl.DateTimeFormat(locale, options).format(date), + }, + datetime, + now.getTime(), + now.getFullYear(), + timeGiven, + ); + }); + + const reactComponents = document.querySelectorAll('[data-component]'); + + if (reactComponents.length > 0) { + import( + /* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container' + ) + .then(({ default: MediaContainer }) => { + reactComponents.forEach((component) => { + Array.from(component.children).forEach((child) => { + component.removeChild(child); + }); + }); + + const content = document.createElement('div'); + + const root = createRoot(content); + root.render( + , + ); + document.body.appendChild(content); + + return true; + }) + .catch((error: unknown) => { + console.error(error); + }); + } + + Rails.delegate( + document, + 'input#user_account_attributes_username', + 'input', + throttle( + ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + if (target.value && target.value.length > 0) { + axios + .get('/api/v1/accounts/lookup', { params: { acct: target.value } }) + .then(() => { + target.setCustomValidity(formatMessage(messages.usernameTaken)); + return true; + }) + .catch(() => { + target.setCustomValidity(''); + }); + } else { + target.setCustomValidity(''); + } + }, + 500, + { leading: false, trailing: true }, + ), + ); + + Rails.delegate( + document, + '#user_password,#user_password_confirmation', + 'input', + () => { + const password = document.querySelector( + 'input#user_password', + ); + const confirmation = document.querySelector( + 'input#user_password_confirmation', + ); + if (!confirmation || !password) return; + + if ( + confirmation.value && + confirmation.value.length > password.maxLength + ) { + confirmation.setCustomValidity( + formatMessage(messages.passwordExceedsLength), + ); + } else if (password.value && password.value !== confirmation.value) { + confirmation.setCustomValidity( + formatMessage(messages.passwordDoesNotMatch), + ); + } else { + confirmation.setCustomValidity(''); + } + }, + ); + + Rails.delegate( + document, + 'button.status__content__spoiler-link', + 'click', + function () { + if (!(this instanceof HTMLButtonElement)) return; + + const statusEl = this.parentNode?.parentNode; + + if ( + !( + statusEl instanceof HTMLDivElement && + statusEl.classList.contains('.status__content') + ) + ) + return; + + if (statusEl.dataset.spoiler === 'expanded') { + statusEl.dataset.spoiler = 'folded'; + this.textContent = new IntlMessageFormat( + localeData['status.show_more'] || 'Show more', + locale, + ).format() as string; + } else { + statusEl.dataset.spoiler = 'expanded'; + this.textContent = new IntlMessageFormat( + localeData['status.show_less'] || 'Show less', + locale, + ).format() as string; + } + }, + ); + + document + .querySelectorAll('button.status__content__spoiler-link') + .forEach((spoilerLink) => { + const statusEl = spoilerLink.parentNode?.parentNode; + + if ( + !( + statusEl instanceof HTMLDivElement && + statusEl.classList.contains('.status__content') + ) + ) + return; + + const message = + statusEl.dataset.spoiler === 'expanded' + ? localeData['status.show_less'] || 'Show less' + : localeData['status.show_more'] || 'Show more'; + spoilerLink.textContent = new IntlMessageFormat( + message, + locale, + ).format() as string; + }); +} + +Rails.delegate( + document, + '#edit_profile input[type=file]', + 'change', + ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const avatar = document.querySelector( + `img#${target.id}-preview`, + ); + + if (!avatar) return; + + let file: File | undefined; + if (target.files) file = target.files[0]; + + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + + if (url) avatar.src = url; + }, +); + +Rails.delegate(document, '.input-copy input', 'click', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + target.focus(); + target.select(); + target.setSelectionRange(0, target.value.length); +}); + +Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { + if (!(target instanceof HTMLButtonElement)) return; + + const input = target.parentNode?.querySelector( + '.input-copy__wrapper input', + ); + + if (!input) return; + + const oldReadOnly = input.readOnly; + + input.readOnly = false; + input.focus(); + input.select(); + input.setSelectionRange(0, input.value.length); + + try { + if (document.execCommand('copy')) { + input.blur(); + + const parent = target.parentElement; + + if (!parent) return; + parent.classList.add('copied'); + + setTimeout(() => { + parent.classList.remove('copied'); + }, 700); + } + } catch (err) { + console.error(err); + } + + input.readOnly = oldReadOnly; +}); + +const toggleSidebar = () => { + const sidebar = document.querySelector('.sidebar ul'); + const toggleButton = document.querySelector( + 'a.sidebar__toggle__icon', + ); + + if (!sidebar || !toggleButton) return; + + if (sidebar.classList.contains('visible')) { + document.body.style.overflow = ''; + toggleButton.setAttribute('aria-expanded', 'false'); + } else { + document.body.style.overflow = 'hidden'; + toggleButton.setAttribute('aria-expanded', 'true'); + } + + toggleButton.classList.toggle('active'); + sidebar.classList.toggle('visible'); +}; + +Rails.delegate(document, '.sidebar__toggle__icon', 'click', () => { + toggleSidebar(); +}); + +Rails.delegate(document, '.sidebar__toggle__icon', 'keydown', (e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + toggleSidebar(); + } +}); + +Rails.delegate(document, 'img.custom-emoji', 'mouseover', ({ target }) => { + if (target instanceof HTMLImageElement && target.dataset.original) + target.src = target.dataset.original; +}); +Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => { + if (target instanceof HTMLImageElement && target.dataset.static) + target.src = target.dataset.static; +}); + +// Empty the honeypot fields in JS in case something like an extension +// automatically filled them. +Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { + [ + 'user_website', + 'user_confirm_password', + 'registration_user_website', + 'registration_user_confirm_password', + ].forEach((id) => { + const field = document.querySelector(`input#${id}`); + if (field) { + field.value = ''; + } + }); +}); + +function main() { + ready(loaded).catch((error: unknown) => { + console.error(error); + }); +} + +loadPolyfills() + .then(loadLocale) + .then(main) + .then(loadKeyboardExtensions) + .catch((error: unknown) => { + console.error(error); + }); diff --git a/app/javascript/packs/remote_interaction_helper.ts b/app/javascript/entrypoints/remote_interaction_helper.ts similarity index 100% rename from app/javascript/packs/remote_interaction_helper.ts rename to app/javascript/entrypoints/remote_interaction_helper.ts diff --git a/app/javascript/packs/share.jsx b/app/javascript/entrypoints/share.tsx similarity index 61% rename from app/javascript/packs/share.jsx rename to app/javascript/entrypoints/share.tsx index 0f3b84549d..7926250851 100644 --- a/app/javascript/packs/share.jsx +++ b/app/javascript/entrypoints/share.tsx @@ -2,7 +2,7 @@ import './public-path'; import { createRoot } from 'react-dom/client'; import { start } from '../mastodon/common'; -import ComposeContainer from '../mastodon/containers/compose_container'; +import ComposeContainer from '../mastodon/containers/compose_container'; import { loadPolyfills } from '../mastodon/polyfills'; import ready from '../mastodon/ready'; @@ -13,18 +13,24 @@ function loaded() { if (mountNode) { const attr = mountNode.getAttribute('data-props'); - if(!attr) return; - const props = JSON.parse(attr); + if (!attr) return; + + const props = JSON.parse(attr) as object; const root = createRoot(mountNode); + root.render(); } } function main() { - ready(loaded); + ready(loaded).catch((error: unknown) => { + console.error(error); + }); } -loadPolyfills().then(main).catch(error => { - console.error(error); -}); +loadPolyfills() + .then(main) + .catch((error: unknown) => { + console.error(error); + }); diff --git a/app/javascript/entrypoints/sign_up.ts b/app/javascript/entrypoints/sign_up.ts new file mode 100644 index 0000000000..880738fcb7 --- /dev/null +++ b/app/javascript/entrypoints/sign_up.ts @@ -0,0 +1,48 @@ +import './public-path'; +import axios from 'axios'; + +import ready from '../mastodon/ready'; + +async function checkConfirmation() { + const response = await axios.get('/api/v1/emails/check_confirmation'); + + if (response.data) { + window.location.href = '/start'; + } +} + +ready(() => { + setInterval(() => { + void checkConfirmation(); + }, 5000); + + document + .querySelectorAll('button.timer-button') + .forEach((button) => { + let counter = 30; + + const container = document.createElement('span'); + + const updateCounter = () => { + container.innerText = ` (${counter})`; + }; + + updateCounter(); + + const countdown = setInterval(() => { + counter--; + + if (counter === 0) { + button.disabled = false; + button.removeChild(container); + clearInterval(countdown); + } else { + updateCounter(); + } + }, 1000); + + button.appendChild(container); + }); +}).catch((e: unknown) => { + throw e; +}); diff --git a/app/javascript/entrypoints/two_factor_authentication.ts b/app/javascript/entrypoints/two_factor_authentication.ts new file mode 100644 index 0000000000..981481694b --- /dev/null +++ b/app/javascript/entrypoints/two_factor_authentication.ts @@ -0,0 +1,197 @@ +import * as WebAuthnJSON from '@github/webauthn-json'; +import axios, { AxiosError } from 'axios'; + +import ready from '../mastodon/ready'; + +import 'regenerator-runtime/runtime'; + +type PublicKeyCredentialCreationOptionsJSON = + WebAuthnJSON.CredentialCreationOptionsJSON['publicKey']; + +function exceptionHasAxiosError( + error: unknown, +): error is AxiosError<{ error: unknown }> { + return ( + error instanceof AxiosError && + typeof error.response?.data === 'object' && + 'error' in error.response.data + ); +} + +function logAxiosResponseError(error: unknown) { + if (exceptionHasAxiosError(error)) console.error(error); +} + +function getCSRFToken() { + return document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute('content'); +} + +function hideFlashMessages() { + document.querySelectorAll('.flash-message').forEach((flashMessage) => { + flashMessage.classList.add('hidden'); + }); +} + +async function callback( + url: string, + body: + | { + credential: WebAuthnJSON.PublicKeyCredentialWithAttestationJSON; + nickname: string; + } + | { + user: { credential: WebAuthnJSON.PublicKeyCredentialWithAssertionJSON }; + }, +) { + try { + const response = await axios.post<{ redirect_path: string }>( + url, + JSON.stringify(body), + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-Token': getCSRFToken(), + }, + }, + ); + + window.location.replace(response.data.redirect_path); + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 422) { + const errorMessage = document.getElementById( + 'security-key-error-message', + ); + errorMessage?.classList.remove('hidden'); + + logAxiosResponseError(error); + } else { + console.error(error); + } + } +} + +async function handleWebauthnCredentialRegistration(nickname: string) { + try { + const response = await axios.get( + '/settings/security_keys/options', + ); + + const credentialOptions = response.data; + + try { + const credential = await WebAuthnJSON.create({ + publicKey: credentialOptions, + }); + + const params = { + credential: credential, + nickname: nickname, + }; + + await callback('/settings/security_keys', params); + } catch (error) { + const errorMessage = document.getElementById( + 'security-key-error-message', + ); + errorMessage?.classList.remove('hidden'); + console.error(error); + } + } catch (error) { + logAxiosResponseError(error); + } +} + +async function handleWebauthnCredentialAuthentication() { + try { + const response = await axios.get( + 'sessions/security_key_options', + ); + + const credentialOptions = response.data; + + try { + const credential = await WebAuthnJSON.get({ + publicKey: credentialOptions, + }); + + const params = { user: { credential: credential } }; + void callback('sign_in', params); + } catch (error) { + const errorMessage = document.getElementById( + 'security-key-error-message', + ); + errorMessage?.classList.remove('hidden'); + console.error(error); + } + } catch (error) { + logAxiosResponseError(error); + } +} + +ready(() => { + if (!WebAuthnJSON.supported()) { + const unsupported_browser_message = document.getElementById( + 'unsupported-browser-message', + ); + if (unsupported_browser_message) { + unsupported_browser_message.classList.remove('hidden'); + const button = document.querySelector( + 'button.btn.js-webauthn', + ); + if (button) button.disabled = true; + } + } + + const webAuthnCredentialRegistrationForm = + document.querySelector('form#new_webauthn_credential'); + if (webAuthnCredentialRegistrationForm) { + webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + if (!(event.target instanceof HTMLFormElement)) return; + + const nickname = event.target.querySelector( + 'input[name="new_webauthn_credential[nickname]"]', + ); + + if (nickname?.value) { + void handleWebauthnCredentialRegistration(nickname.value); + } else { + nickname?.focus(); + } + }); + } + + const webAuthnCredentialAuthenticationForm = + document.getElementById('webauthn-form'); + if (webAuthnCredentialAuthenticationForm) { + webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => { + event.preventDefault(); + void handleWebauthnCredentialAuthentication(); + }); + + const otpAuthenticationForm = document.getElementById( + 'otp-authentication-form', + ); + + const linkToOtp = document.getElementById('link-to-otp'); + + linkToOtp?.addEventListener('click', () => { + webAuthnCredentialAuthenticationForm.classList.add('hidden'); + otpAuthenticationForm?.classList.remove('hidden'); + hideFlashMessages(); + }); + + const linkToWebAuthn = document.getElementById('link-to-webauthn'); + linkToWebAuthn?.addEventListener('click', () => { + otpAuthenticationForm?.classList.add('hidden'); + webAuthnCredentialAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + } +}).catch((e: unknown) => { + throw e; +}); diff --git a/app/javascript/fonts/inter/inter-variable-font-slnt-wght.woff2 b/app/javascript/fonts/inter/inter-variable-font-slnt-wght.woff2 new file mode 100644 index 0000000000..e6345f2e3d Binary files /dev/null and b/app/javascript/fonts/inter/inter-variable-font-slnt-wght.woff2 differ diff --git a/app/javascript/icons/android-chrome-144x144.png b/app/javascript/icons/android-chrome-144x144.png old mode 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-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/favicon-16x16.png b/app/javascript/icons/favicon-16x16.png old mode 100644 new mode 100755 index eed8e0035c..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 9165746bcf..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 259676c0a9..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/check.svg b/app/javascript/images/check.svg new file mode 100644 index 0000000000..8a0ebe878d --- /dev/null +++ b/app/javascript/images/check.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/javascript/images/mailer-new/common/header-bg-end.png b/app/javascript/images/mailer-new/common/header-bg-end.png new file mode 100644 index 0000000000..900196678a Binary files /dev/null and b/app/javascript/images/mailer-new/common/header-bg-end.png differ diff --git a/app/javascript/images/mailer-new/common/header-bg-start.png b/app/javascript/images/mailer-new/common/header-bg-start.png new file mode 100644 index 0000000000..0037c1ad93 Binary files /dev/null and b/app/javascript/images/mailer-new/common/header-bg-start.png differ diff --git a/app/javascript/images/mailer-new/common/logo-footer.png b/app/javascript/images/mailer-new/common/logo-footer.png new file mode 100644 index 0000000000..2baafd8d7f Binary files /dev/null and b/app/javascript/images/mailer-new/common/logo-footer.png differ diff --git a/app/javascript/images/mailer-new/common/logo-header.png b/app/javascript/images/mailer-new/common/logo-header.png new file mode 100644 index 0000000000..46a6bddaa1 Binary files /dev/null and b/app/javascript/images/mailer-new/common/logo-header.png differ diff --git a/app/javascript/images/mailer-new/heading/2fa-disabled.png b/app/javascript/images/mailer-new/heading/2fa-disabled.png new file mode 100644 index 0000000000..b1e342a87c Binary files /dev/null and b/app/javascript/images/mailer-new/heading/2fa-disabled.png differ diff --git a/app/javascript/images/mailer-new/heading/2fa-enabled.png b/app/javascript/images/mailer-new/heading/2fa-enabled.png new file mode 100644 index 0000000000..3ce3e04f84 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/2fa-enabled.png differ diff --git a/app/javascript/images/mailer-new/heading/2fa-recovery.png b/app/javascript/images/mailer-new/heading/2fa-recovery.png new file mode 100644 index 0000000000..cefb21e1eb Binary files /dev/null and b/app/javascript/images/mailer-new/heading/2fa-recovery.png differ diff --git a/app/javascript/images/mailer-new/heading/LICENSE b/app/javascript/images/mailer-new/heading/LICENSE new file mode 100644 index 0000000000..974db1ac4b --- /dev/null +++ b/app/javascript/images/mailer-new/heading/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2024 Paweł Kuna + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/javascript/images/mailer-new/heading/README.md b/app/javascript/images/mailer-new/heading/README.md new file mode 100644 index 0000000000..ecd4b949e7 --- /dev/null +++ b/app/javascript/images/mailer-new/heading/README.md @@ -0,0 +1 @@ +Images in this folder are based on [Tabler.io icons](https://tabler.io/icons). diff --git a/app/javascript/images/mailer-new/heading/appeal-approved.png b/app/javascript/images/mailer-new/heading/appeal-approved.png new file mode 100755 index 0000000000..b2476ec346 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/appeal-approved.png differ diff --git a/app/javascript/images/mailer-new/heading/appeal-rejected.png b/app/javascript/images/mailer-new/heading/appeal-rejected.png new file mode 100644 index 0000000000..7ae38ad0c1 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/appeal-rejected.png differ diff --git a/app/javascript/images/mailer-new/heading/archive.png b/app/javascript/images/mailer-new/heading/archive.png new file mode 100644 index 0000000000..b0c7fad84d Binary files /dev/null and b/app/javascript/images/mailer-new/heading/archive.png differ diff --git a/app/javascript/images/mailer-new/heading/boost.png b/app/javascript/images/mailer-new/heading/boost.png new file mode 100644 index 0000000000..e33b759976 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/boost.png differ diff --git a/app/javascript/images/mailer-new/heading/email.png b/app/javascript/images/mailer-new/heading/email.png new file mode 100644 index 0000000000..c922c5239e Binary files /dev/null and b/app/javascript/images/mailer-new/heading/email.png differ diff --git a/app/javascript/images/mailer-new/heading/favorite.png b/app/javascript/images/mailer-new/heading/favorite.png new file mode 100644 index 0000000000..0e483ee9b2 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/favorite.png differ diff --git a/app/javascript/images/mailer-new/heading/follow.png b/app/javascript/images/mailer-new/heading/follow.png new file mode 100644 index 0000000000..ff5b7e0042 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/follow.png differ diff --git a/app/javascript/images/mailer-new/heading/key-added.png b/app/javascript/images/mailer-new/heading/key-added.png new file mode 100755 index 0000000000..82dcd464bf Binary files /dev/null and b/app/javascript/images/mailer-new/heading/key-added.png differ diff --git a/app/javascript/images/mailer-new/heading/key-deleted.png b/app/javascript/images/mailer-new/heading/key-deleted.png new file mode 100755 index 0000000000..2930f591a0 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/key-deleted.png differ diff --git a/app/javascript/images/mailer-new/heading/key-disabled.png b/app/javascript/images/mailer-new/heading/key-disabled.png new file mode 100755 index 0000000000..e0f259359a Binary files /dev/null and b/app/javascript/images/mailer-new/heading/key-disabled.png differ diff --git a/app/javascript/images/mailer-new/heading/key-enabled.png b/app/javascript/images/mailer-new/heading/key-enabled.png new file mode 100644 index 0000000000..b2476ec346 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/key-enabled.png differ diff --git a/app/javascript/images/mailer-new/heading/login.png b/app/javascript/images/mailer-new/heading/login.png new file mode 100644 index 0000000000..89a6e9ee33 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/login.png differ diff --git a/app/javascript/images/mailer-new/heading/mention.png b/app/javascript/images/mailer-new/heading/mention.png new file mode 100644 index 0000000000..c4dccff8ef Binary files /dev/null and b/app/javascript/images/mailer-new/heading/mention.png differ diff --git a/app/javascript/images/mailer-new/heading/password.png b/app/javascript/images/mailer-new/heading/password.png new file mode 100755 index 0000000000..552c7c0687 Binary files /dev/null and b/app/javascript/images/mailer-new/heading/password.png differ diff --git a/app/javascript/images/mailer-new/heading/user.png b/app/javascript/images/mailer-new/heading/user.png new file mode 100644 index 0000000000..f1dd58a18d Binary files /dev/null and b/app/javascript/images/mailer-new/heading/user.png differ diff --git a/app/javascript/images/mailer-new/heading/warning.png b/app/javascript/images/mailer-new/heading/warning.png new file mode 100755 index 0000000000..7764837abe Binary files /dev/null and b/app/javascript/images/mailer-new/heading/warning.png differ diff --git a/app/javascript/images/mailer-new/store-icons/btn-app-store.png b/app/javascript/images/mailer-new/store-icons/btn-app-store.png new file mode 100644 index 0000000000..ee3bd9385c Binary files /dev/null and b/app/javascript/images/mailer-new/store-icons/btn-app-store.png differ diff --git a/app/javascript/images/mailer-new/store-icons/btn-google-play.png b/app/javascript/images/mailer-new/store-icons/btn-google-play.png new file mode 100644 index 0000000000..ed43ff29aa Binary files /dev/null and b/app/javascript/images/mailer-new/store-icons/btn-google-play.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/LICENSE b/app/javascript/images/mailer-new/welcome-icons/LICENSE new file mode 100644 index 0000000000..974db1ac4b --- /dev/null +++ b/app/javascript/images/mailer-new/welcome-icons/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2024 Paweł Kuna + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/javascript/images/mailer-new/welcome-icons/README.md b/app/javascript/images/mailer-new/welcome-icons/README.md new file mode 100644 index 0000000000..ecd4b949e7 --- /dev/null +++ b/app/javascript/images/mailer-new/welcome-icons/README.md @@ -0,0 +1 @@ +Images in this folder are based on [Tabler.io icons](https://tabler.io/icons). diff --git a/app/javascript/images/mailer-new/welcome-icons/apps_step-off.png b/app/javascript/images/mailer-new/welcome-icons/apps_step-off.png new file mode 100755 index 0000000000..ca270f5478 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/apps_step-off.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/apps_step-on.png b/app/javascript/images/mailer-new/welcome-icons/apps_step-on.png new file mode 100644 index 0000000000..fd631bf97e Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/apps_step-on.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-off.png b/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-off.png new file mode 100644 index 0000000000..dfcdd04e16 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-off.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-on.png b/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-on.png new file mode 100644 index 0000000000..c3776d17df Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/edit_profile_step-on.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/follow_step-off.png b/app/javascript/images/mailer-new/welcome-icons/follow_step-off.png new file mode 100755 index 0000000000..a262454d2d Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/follow_step-off.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/follow_step-on.png b/app/javascript/images/mailer-new/welcome-icons/follow_step-on.png new file mode 100644 index 0000000000..3ac011539b Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/follow_step-on.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/post_step-off.png b/app/javascript/images/mailer-new/welcome-icons/post_step-off.png new file mode 100755 index 0000000000..972de65a56 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/post_step-off.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/post_step-on.png b/app/javascript/images/mailer-new/welcome-icons/post_step-on.png new file mode 100644 index 0000000000..aa318e66c8 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/post_step-on.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/share_step-off.png b/app/javascript/images/mailer-new/welcome-icons/share_step-off.png new file mode 100755 index 0000000000..f45e9a2c9a Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/share_step-off.png differ diff --git a/app/javascript/images/mailer-new/welcome-icons/share_step-on.png b/app/javascript/images/mailer-new/welcome-icons/share_step-on.png new file mode 100644 index 0000000000..98782d9317 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome-icons/share_step-on.png differ diff --git a/app/javascript/images/mailer-new/welcome/checkbox-off.png b/app/javascript/images/mailer-new/welcome/checkbox-off.png new file mode 100644 index 0000000000..51c190efe6 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/checkbox-off.png differ diff --git a/app/javascript/images/mailer-new/welcome/checkbox-on.png b/app/javascript/images/mailer-new/welcome/checkbox-on.png new file mode 100644 index 0000000000..162095e7df Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/checkbox-on.png differ diff --git a/app/javascript/images/mailer-new/welcome/feature_audience.png b/app/javascript/images/mailer-new/welcome/feature_audience.png new file mode 100644 index 0000000000..902de133b4 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/feature_audience.png differ diff --git a/app/javascript/images/mailer-new/welcome/feature_control.png b/app/javascript/images/mailer-new/welcome/feature_control.png new file mode 100644 index 0000000000..1afb6c238c Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/feature_control.png differ diff --git a/app/javascript/images/mailer-new/welcome/feature_creativity.png b/app/javascript/images/mailer-new/welcome/feature_creativity.png new file mode 100644 index 0000000000..3365856699 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/feature_creativity.png differ diff --git a/app/javascript/images/mailer-new/welcome/feature_moderation.png b/app/javascript/images/mailer-new/welcome/feature_moderation.png new file mode 100644 index 0000000000..7cee9b29b8 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/feature_moderation.png differ diff --git a/app/javascript/images/mailer-new/welcome/purple-extra-soft-spacer.png b/app/javascript/images/mailer-new/welcome/purple-extra-soft-spacer.png new file mode 100644 index 0000000000..ec1ad5c957 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/purple-extra-soft-spacer.png differ diff --git a/app/javascript/images/mailer-new/welcome/purple-extra-soft-wave.png b/app/javascript/images/mailer-new/welcome/purple-extra-soft-wave.png new file mode 100644 index 0000000000..ba8f6dd3d9 Binary files /dev/null and b/app/javascript/images/mailer-new/welcome/purple-extra-soft-wave.png differ diff --git a/app/javascript/images/warning-stripes.svg b/app/javascript/images/warning-stripes.svg new file mode 100755 index 0000000000..9d68acdada --- /dev/null +++ b/app/javascript/images/warning-stripes.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/mastodon/actions/antennas.js b/app/javascript/mastodon/actions/antennas.js new file mode 100644 index 0000000000..4716897586 --- /dev/null +++ b/app/javascript/mastodon/actions/antennas.js @@ -0,0 +1,986 @@ +import api from '../api'; + +import { showAlertForError } from './alerts'; +import { importFetchedAccounts } from './importer'; + +export const ANTENNA_FETCH_REQUEST = 'ANTENNA_FETCH_REQUEST'; +export const ANTENNA_FETCH_SUCCESS = 'ANTENNA_FETCH_SUCCESS'; +export const ANTENNA_FETCH_FAIL = 'ANTENNA_FETCH_FAIL'; + +export const ANTENNAS_FETCH_REQUEST = 'ANTENNAS_FETCH_REQUEST'; +export const ANTENNAS_FETCH_SUCCESS = 'ANTENNAS_FETCH_SUCCESS'; +export const ANTENNAS_FETCH_FAIL = 'ANTENNAS_FETCH_FAIL'; + +export const ANTENNA_EDITOR_TITLE_CHANGE = 'ANTENNA_EDITOR_TITLE_CHANGE'; +export const ANTENNA_EDITOR_RESET = 'ANTENNA_EDITOR_RESET'; +export const ANTENNA_EDITOR_SETUP = 'ANTENNA_EDITOR_SETUP'; + +export const ANTENNA_CREATE_REQUEST = 'ANTENNA_CREATE_REQUEST'; +export const ANTENNA_CREATE_SUCCESS = 'ANTENNA_CREATE_SUCCESS'; +export const ANTENNA_CREATE_FAIL = 'ANTENNA_CREATE_FAIL'; + +export const ANTENNA_UPDATE_REQUEST = 'ANTENNA_UPDATE_REQUEST'; +export const ANTENNA_UPDATE_SUCCESS = 'ANTENNA_UPDATE_SUCCESS'; +export const ANTENNA_UPDATE_FAIL = 'ANTENNA_UPDATE_FAIL'; + +export const ANTENNA_DELETE_REQUEST = 'ANTENNA_DELETE_REQUEST'; +export const ANTENNA_DELETE_SUCCESS = 'ANTENNA_DELETE_SUCCESS'; +export const ANTENNA_DELETE_FAIL = 'ANTENNA_DELETE_FAIL'; + +export const ANTENNA_ACCOUNTS_FETCH_REQUEST = 'ANTENNA_ACCOUNTS_FETCH_REQUEST'; +export const ANTENNA_ACCOUNTS_FETCH_SUCCESS = 'ANTENNA_ACCOUNTS_FETCH_SUCCESS'; +export const ANTENNA_ACCOUNTS_FETCH_FAIL = 'ANTENNA_ACCOUNTS_FETCH_FAIL'; + +export const ANTENNA_EDITOR_SUGGESTIONS_CHANGE = 'ANTENNA_EDITOR_SUGGESTIONS_CHANGE'; +export const ANTENNA_EDITOR_SUGGESTIONS_READY = 'ANTENNA_EDITOR_SUGGESTIONS_READY'; +export const ANTENNA_EDITOR_SUGGESTIONS_CLEAR = 'ANTENNA_EDITOR_SUGGESTIONS_CLEAR'; + +export const ANTENNA_EDITOR_ADD_REQUEST = 'ANTENNA_EDITOR_ADD_REQUEST'; +export const ANTENNA_EDITOR_ADD_SUCCESS = 'ANTENNA_EDITOR_ADD_SUCCESS'; +export const ANTENNA_EDITOR_ADD_FAIL = 'ANTENNA_EDITOR_ADD_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_REQUEST = 'ANTENNA_EDITOR_REMOVE_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_SUCCESS = 'ANTENNA_EDITOR_REMOVE_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_FAIL = 'ANTENNA_EDITOR_REMOVE_FAIL'; + +export const ANTENNA_EXCLUDE_ACCOUNTS_FETCH_REQUEST = 'ANTENNA_EXCLUDE_ACCOUNTS_FETCH_REQUEST'; +export const ANTENNA_EXCLUDE_ACCOUNTS_FETCH_SUCCESS = 'ANTENNA_EXCLUDE_ACCOUNTS_FETCH_SUCCESS'; +export const ANTENNA_EXCLUDE_ACCOUNTS_FETCH_FAIL = 'ANTENNA_EXCLUDE_ACCOUNTS_FETCH_FAIL'; + +export const ANTENNA_EDITOR_ADD_EXCLUDE_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDE_REQUEST'; +export const ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS'; +export const ANTENNA_EDITOR_ADD_EXCLUDE_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_FAIL'; + +export const ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST = 'ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST'; +export const ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS = 'ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS'; +export const ANTENNA_EDITOR_FETCH_DOMAINS_FAIL = 'ANTENNA_EDITOR_FETCH_DOMAINS_FAIL'; + +export const ANTENNA_EDITOR_ADD_DOMAIN_REQUEST = 'ANTENNA_EDITOR_ADD_DOMAIN_REQUEST'; +export const ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS'; +export const ANTENNA_EDITOR_ADD_DOMAIN_FAIL = 'ANTENNA_EDITOR_ADD_DOMAIN_FAIL'; + +export const ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDEDOMAIN_REQUEST'; +export const ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_SUCCESS'; +export const ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST = 'ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL = 'ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_FAIL'; + +export const ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST = 'ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST'; +export const ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS = 'ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS'; +export const ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL = 'ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL'; + +export const ANTENNA_EDITOR_ADD_KEYWORD_REQUEST = 'ANTENNA_EDITOR_ADD_KEYWORD_REQUEST'; +export const ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS'; +export const ANTENNA_EDITOR_ADD_KEYWORD_FAIL = 'ANTENNA_EDITOR_ADD_KEYWORD_FAIL'; + +export const ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_REQUEST'; +export const ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_SUCCESS'; +export const ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL = 'ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL'; + +export const ANTENNA_EDITOR_FETCH_TAGS_REQUEST = 'ANTENNA_EDITOR_FETCH_TAGS_REQUEST'; +export const ANTENNA_EDITOR_FETCH_TAGS_SUCCESS = 'ANTENNA_EDITOR_FETCH_TAGS_SUCCESS'; +export const ANTENNA_EDITOR_FETCH_TAGS_FAIL = 'ANTENNA_EDITOR_FETCH_TAGS_FAIL'; + +export const ANTENNA_EDITOR_ADD_TAG_REQUEST = 'ANTENNA_EDITOR_ADD_TAG_REQUEST'; +export const ANTENNA_EDITOR_ADD_TAG_SUCCESS = 'ANTENNA_EDITOR_ADD_TAG_SUCCESS'; +export const ANTENNA_EDITOR_ADD_TAG_FAIL = 'ANTENNA_EDITOR_ADD_TAG_FAIL'; + +export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST'; +export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS'; +export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_TAG_REQUEST = 'ANTENNA_EDITOR_REMOVE_TAG_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_TAG_SUCCESS = 'ANTENNA_EDITOR_REMOVE_TAG_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_TAG_FAIL = 'ANTENNA_EDITOR_REMOVE_TAG_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL'; + +export const ANTENNA_ADDER_RESET = 'ANTENNA_ADDER_RESET'; +export const ANTENNA_ADDER_SETUP = 'ANTENNA_ADDER_SETUP'; + +export const ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST'; +export const ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS'; +export const ANTENNA_ADDER_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_ANTENNAS_FETCH_FAIL'; + +export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST'; +export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS'; +export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL'; + +export const fetchAntenna = id => (dispatch, getState) => { + if (getState().getIn(['antennas', id])) { + return; + } + + dispatch(fetchAntennaRequest(id)); + + api(getState).get(`/api/v1/antennas/${id}`) + .then(({ data }) => dispatch(fetchAntennaSuccess(data))) + .catch(err => dispatch(fetchAntennaFail(id, err))); +}; + +export const fetchAntennaRequest = id => ({ + type: ANTENNA_FETCH_REQUEST, + id, +}); + +export const fetchAntennaSuccess = antenna => ({ + type: ANTENNA_FETCH_SUCCESS, + antenna, +}); + +export const fetchAntennaFail = (id, error) => ({ + type: ANTENNA_FETCH_FAIL, + id, + error, +}); + +export const fetchAntennas = () => (dispatch, getState) => { + dispatch(fetchAntennasRequest()); + + api(getState).get('/api/v1/antennas') + .then(({ data }) => dispatch(fetchAntennasSuccess(data))) + .catch(err => dispatch(fetchAntennasFail(err))); +}; + +export const fetchAntennasRequest = () => ({ + type: ANTENNAS_FETCH_REQUEST, +}); + +export const fetchAntennasSuccess = antennas => ({ + type: ANTENNAS_FETCH_SUCCESS, + antennas, +}); + +export const fetchAntennasFail = error => ({ + type: ANTENNAS_FETCH_FAIL, + error, +}); + +export const submitAntennaEditor = shouldReset => (dispatch, getState) => { + const antennaId = getState().getIn(['antennaEditor', 'antennaId']); + const title = getState().getIn(['antennaEditor', 'title']); + + if (antennaId === null) { + dispatch(createAntenna(title, shouldReset)); + } else { + dispatch(updateAntenna(antennaId, title, shouldReset)); + } +}; + +export const setupAntennaEditor = antennaId => (dispatch, getState) => { + dispatch({ + type: ANTENNA_EDITOR_SETUP, + antenna: getState().getIn(['antennas', antennaId]), + }); + + dispatch(fetchAntennaAccounts(antennaId)); +}; + +export const setupExcludeAntennaEditor = antennaId => (dispatch, getState) => { + dispatch({ + type: ANTENNA_EDITOR_SETUP, + antenna: getState().getIn(['antennas', antennaId]), + }); + + dispatch(fetchAntennaExcludeAccounts(antennaId)); +}; + +export const changeAntennaEditorTitle = value => ({ + type: ANTENNA_EDITOR_TITLE_CHANGE, + value, +}); + +export const createAntenna = (title, shouldReset) => (dispatch, getState) => { + dispatch(createAntennaRequest()); + + api(getState).post('/api/v1/antennas', { title }).then(({ data }) => { + dispatch(createAntennaSuccess(data)); + + if (shouldReset) { + dispatch(resetAntennaEditor()); + } + }).catch(err => dispatch(createAntennaFail(err))); +}; + +export const createAntennaRequest = () => ({ + type: ANTENNA_CREATE_REQUEST, +}); + +export const createAntennaSuccess = antenna => ({ + type: ANTENNA_CREATE_SUCCESS, + antenna, +}); + +export const createAntennaFail = error => ({ + type: ANTENNA_CREATE_FAIL, + error, +}); + +export const updateAntenna = (id, title, shouldReset, list_id, stl, ltl, with_media_only, ignore_reblog, insert_feeds) => (dispatch, getState) => { + dispatch(updateAntennaRequest(id)); + + api(getState).put(`/api/v1/antennas/${id}`, { title, list_id, stl, ltl, with_media_only, ignore_reblog, insert_feeds }).then(({ data }) => { + dispatch(updateAntennaSuccess(data)); + + if (shouldReset) { + dispatch(resetAntennaEditor()); + } + }).catch(err => dispatch(updateAntennaFail(id, err))); +}; + +export const updateAntennaRequest = id => ({ + type: ANTENNA_UPDATE_REQUEST, + id, +}); + +export const updateAntennaSuccess = antenna => ({ + type: ANTENNA_UPDATE_SUCCESS, + antenna, +}); + +export const updateAntennaFail = (id, error) => ({ + type: ANTENNA_UPDATE_FAIL, + id, + error, +}); + +export const resetAntennaEditor = () => ({ + type: ANTENNA_EDITOR_RESET, +}); + +export const deleteAntenna = id => (dispatch, getState) => { + dispatch(deleteAntennaRequest(id)); + + api(getState).delete(`/api/v1/antennas/${id}`) + .then(() => dispatch(deleteAntennaSuccess(id))) + .catch(err => dispatch(deleteAntennaFail(id, err))); +}; + +export const deleteAntennaRequest = id => ({ + type: ANTENNA_DELETE_REQUEST, + id, +}); + +export const deleteAntennaSuccess = id => ({ + type: ANTENNA_DELETE_SUCCESS, + id, +}); + +export const deleteAntennaFail = (id, error) => ({ + type: ANTENNA_DELETE_FAIL, + id, + error, +}); + +export const fetchAntennaAccounts = antennaId => (dispatch, getState) => { + dispatch(fetchAntennaAccountsRequest(antennaId)); + + api(getState).get(`/api/v1/antennas/${antennaId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchAntennaAccountsSuccess(antennaId, data)); + }).catch(err => dispatch(fetchAntennaAccountsFail(antennaId, err))); +}; + +export const fetchAntennaAccountsRequest = id => ({ + type: ANTENNA_ACCOUNTS_FETCH_REQUEST, + id, +}); + +export const fetchAntennaAccountsSuccess = (id, accounts, next) => ({ + type: ANTENNA_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +export const fetchAntennaAccountsFail = (id, error) => ({ + type: ANTENNA_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +export const fetchAntennaExcludeAccounts = antennaId => (dispatch, getState) => { + dispatch(fetchAntennaExcludeAccountsRequest(antennaId)); + + api(getState).get(`/api/v1/antennas/${antennaId}/exclude_accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchAntennaExcludeAccountsSuccess(antennaId, data)); + }).catch(err => dispatch(fetchAntennaExcludeAccountsFail(antennaId, err))); +}; + +export const fetchAntennaExcludeAccountsRequest = id => ({ + type: ANTENNA_EXCLUDE_ACCOUNTS_FETCH_REQUEST, + id, +}); + +export const fetchAntennaExcludeAccountsSuccess = (id, accounts, next) => ({ + type: ANTENNA_EXCLUDE_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +export const fetchAntennaExcludeAccountsFail = (id, error) => ({ + type: ANTENNA_EXCLUDE_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +export const fetchAntennaSuggestions = q => (dispatch, getState) => { + const params = { + q, + resolve: false, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchAntennaSuggestionsReady(q, data)); + }).catch(error => dispatch(showAlertForError(error))); +}; + +export const fetchAntennaSuggestionsReady = (query, accounts) => ({ + type: ANTENNA_EDITOR_SUGGESTIONS_READY, + query, + accounts, +}); + +export const clearAntennaSuggestions = () => ({ + type: ANTENNA_EDITOR_SUGGESTIONS_CLEAR, +}); + +export const changeAntennaSuggestions = value => ({ + type: ANTENNA_EDITOR_SUGGESTIONS_CHANGE, + value, +}); + +export const addToAntennaEditor = accountId => (dispatch, getState) => { + dispatch(addToAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); +}; + +export const addToAntenna = (antennaId, accountId) => (dispatch, getState) => { + dispatch(addToAntennaRequest(antennaId, accountId)); + + api(getState).post(`/api/v1/antennas/${antennaId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addToAntennaSuccess(antennaId, accountId))) + .catch(err => dispatch(addToAntennaFail(antennaId, accountId, err))); +}; + +export const addToAntennaRequest = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_ADD_REQUEST, + antennaId, + accountId, +}); + +export const addToAntennaSuccess = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_ADD_SUCCESS, + antennaId, + accountId, +}); + +export const addToAntennaFail = (antennaId, accountId, error) => ({ + type: ANTENNA_EDITOR_ADD_FAIL, + antennaId, + accountId, + error, +}); + +export const addExcludeToAntennaEditor = accountId => (dispatch, getState) => { + dispatch(addExcludeToAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); +}; + +export const addExcludeToAntenna = (antennaId, accountId) => (dispatch, getState) => { + dispatch(addExcludeToAntennaRequest(antennaId, accountId)); + + api(getState).post(`/api/v1/antennas/${antennaId}/exclude_accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addExcludeToAntennaSuccess(antennaId, accountId))) + .catch(err => dispatch(addExcludeToAntennaFail(antennaId, accountId, err))); +}; + +export const addExcludeToAntennaRequest = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_REQUEST, + antennaId, + accountId, +}); + +export const addExcludeToAntennaSuccess = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS, + antennaId, + accountId, +}); + +export const addExcludeToAntennaFail = (antennaId, accountId, error) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_FAIL, + antennaId, + accountId, + error, +}); + +export const removeFromAntennaEditor = accountId => (dispatch, getState) => { + dispatch(removeFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); +}; + +export const removeFromAntenna = (antennaId, accountId) => (dispatch, getState) => { + dispatch(removeFromAntennaRequest(antennaId, accountId)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeFromAntennaSuccess(antennaId, accountId))) + .catch(err => dispatch(removeFromAntennaFail(antennaId, accountId, err))); +}; + +export const removeFromAntennaRequest = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_REMOVE_REQUEST, + antennaId, + accountId, +}); + +export const removeFromAntennaSuccess = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_REMOVE_SUCCESS, + antennaId, + accountId, +}); + +export const removeFromAntennaFail = (antennaId, accountId, error) => ({ + type: ANTENNA_EDITOR_REMOVE_FAIL, + antennaId, + accountId, + error, +}); + +export const removeExcludeFromAntennaEditor = accountId => (dispatch, getState) => { + dispatch(removeExcludeFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); +}; + +export const removeExcludeFromAntenna = (antennaId, accountId) => (dispatch, getState) => { + dispatch(removeExcludeFromAntennaRequest(antennaId, accountId)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeExcludeFromAntennaSuccess(antennaId, accountId))) + .catch(err => dispatch(removeExcludeFromAntennaFail(antennaId, accountId, err))); +}; + +export const removeExcludeFromAntennaRequest = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_REQUEST, + antennaId, + accountId, +}); + +export const removeExcludeFromAntennaSuccess = (antennaId, accountId) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS, + antennaId, + accountId, +}); + +export const removeExcludeFromAntennaFail = (antennaId, accountId, error) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_FAIL, + antennaId, + accountId, + error, +}); + +export const fetchAntennaDomains = antennaId => (dispatch, getState) => { + dispatch(fetchAntennaDomainsRequest(antennaId)); + + api(getState).get(`/api/v1/antennas/${antennaId}/domains`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(fetchAntennaDomainsSuccess(antennaId, data)); + }).catch(err => dispatch(fetchAntennaDomainsFail(antennaId, err))); +}; + +export const fetchAntennaDomainsRequest = id => ({ + type: ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST, + id, +}); + +export const fetchAntennaDomainsSuccess = (id, domains) => ({ + type: ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS, + id, + domains, +}); + +export const fetchAntennaDomainsFail = (id, error) => ({ + type: ANTENNA_EDITOR_FETCH_DOMAINS_FAIL, + id, + error, +}); + +export const addDomainToAntenna = (antennaId, domain) => (dispatch, getState) => { + dispatch(addDomainToAntennaRequest(antennaId, domain)); + + api(getState).post(`/api/v1/antennas/${antennaId}/domains`, { domains: [domain] }) + .then(() => dispatch(addDomainToAntennaSuccess(antennaId, domain))) + .catch(err => dispatch(addDomainToAntennaFail(antennaId, domain, err))); +}; + +export const addDomainToAntennaRequest = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_ADD_DOMAIN_REQUEST, + antennaId, + domain, +}); + +export const addDomainToAntennaSuccess = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS, + antennaId, + domain, +}); + +export const addDomainToAntennaFail = (antennaId, domain, error) => ({ + type: ANTENNA_EDITOR_ADD_DOMAIN_FAIL, + antennaId, + domain, + error, +}); + +export const removeDomainFromAntenna = (antennaId, domain) => (dispatch, getState) => { + dispatch(removeDomainFromAntennaRequest(antennaId, domain)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/domains`, { params: { domains: [domain] } }) + .then(() => dispatch(removeDomainFromAntennaSuccess(antennaId, domain))) + .catch(err => dispatch(removeDomainFromAntennaFail(antennaId, domain, err))); +}; + +export const removeDomainFromAntennaRequest = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST, + antennaId, + domain, +}); + +export const removeDomainFromAntennaSuccess = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS, + antennaId, + domain, +}); + +export const removeDomainFromAntennaFail = (antennaId, domain, error) => ({ + type: ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL, + antennaId, + domain, + error, +}); + +export const addExcludeDomainToAntenna = (antennaId, domain) => (dispatch, getState) => { + dispatch(addExcludeDomainToAntennaRequest(antennaId, domain)); + + api(getState).post(`/api/v1/antennas/${antennaId}/exclude_domains`, { domains: [domain] }) + .then(() => dispatch(addExcludeDomainToAntennaSuccess(antennaId, domain))) + .catch(err => dispatch(addExcludeDomainToAntennaFail(antennaId, domain, err))); +}; + +export const addExcludeDomainToAntennaRequest = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_REQUEST, + antennaId, + domain, +}); + +export const addExcludeDomainToAntennaSuccess = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_SUCCESS, + antennaId, + domain, +}); + +export const addExcludeDomainToAntennaFail = (antennaId, domain, error) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_FAIL, + antennaId, + domain, + error, +}); + +export const removeExcludeDomainFromAntenna = (antennaId, domain) => (dispatch, getState) => { + dispatch(removeExcludeDomainFromAntennaRequest(antennaId, domain)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_domains`, { params: { domains: [domain] } }) + .then(() => dispatch(removeExcludeDomainFromAntennaSuccess(antennaId, domain))) + .catch(err => dispatch(removeExcludeDomainFromAntennaFail(antennaId, domain, err))); +}; + +export const removeExcludeDomainFromAntennaRequest = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_REQUEST, + antennaId, + domain, +}); + +export const removeExcludeDomainFromAntennaSuccess = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_SUCCESS, + antennaId, + domain, +}); + +export const removeExcludeDomainFromAntennaFail = (antennaId, domain, error) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_FAIL, + antennaId, + domain, + error, +}); + +export const fetchAntennaKeywords = antennaId => (dispatch, getState) => { + dispatch(fetchAntennaKeywordsRequest(antennaId)); + + api(getState).get(`/api/v1/antennas/${antennaId}/keywords`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(fetchAntennaKeywordsSuccess(antennaId, data)); + }).catch(err => dispatch(fetchAntennaKeywordsFail(antennaId, err))); +}; + +export const fetchAntennaKeywordsRequest = id => ({ + type: ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST, + id, +}); + +export const fetchAntennaKeywordsSuccess = (id, keywords) => ({ + type: ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS, + id, + keywords, +}); + +export const fetchAntennaKeywordsFail = (id, error) => ({ + type: ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL, + id, + error, +}); + +export const addKeywordToAntenna = (antennaId, keyword) => (dispatch, getState) => { + dispatch(addKeywordToAntennaRequest(antennaId, keyword)); + + api(getState).post(`/api/v1/antennas/${antennaId}/keywords`, { keywords: [keyword] }) + .then(() => dispatch(addKeywordToAntennaSuccess(antennaId, keyword))) + .catch(err => dispatch(addKeywordToAntennaFail(antennaId, keyword, err))); +}; + +export const addKeywordToAntennaRequest = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_ADD_KEYWORD_REQUEST, + antennaId, + keyword, +}); + +export const addKeywordToAntennaSuccess = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS, + antennaId, + keyword, +}); + +export const addKeywordToAntennaFail = (antennaId, keyword, error) => ({ + type: ANTENNA_EDITOR_ADD_KEYWORD_FAIL, + antennaId, + keyword, + error, +}); + +export const removeKeywordFromAntenna = (antennaId, keyword) => (dispatch, getState) => { + dispatch(removeKeywordFromAntennaRequest(antennaId, keyword)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/keywords`, { params: { keywords: [keyword] } }) + .then(() => dispatch(removeKeywordFromAntennaSuccess(antennaId, keyword))) + .catch(err => dispatch(removeKeywordFromAntennaFail(antennaId, keyword, err))); +}; + +export const removeKeywordFromAntennaRequest = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST, + antennaId, + keyword, +}); + +export const removeKeywordFromAntennaSuccess = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS, + antennaId, + keyword, +}); + +export const removeKeywordFromAntennaFail = (antennaId, keyword, error) => ({ + type: ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL, + antennaId, + keyword, + error, +}); + +export const addExcludeKeywordToAntenna = (antennaId, keyword) => (dispatch, getState) => { + dispatch(addExcludeKeywordToAntennaRequest(antennaId, keyword)); + + api(getState).post(`/api/v1/antennas/${antennaId}/exclude_keywords`, { keywords: [keyword] }) + .then(() => dispatch(addExcludeKeywordToAntennaSuccess(antennaId, keyword))) + .catch(err => dispatch(addExcludeKeywordToAntennaFail(antennaId, keyword, err))); +}; + +export const addExcludeKeywordToAntennaRequest = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_REQUEST, + antennaId, + keyword, +}); + +export const addExcludeKeywordToAntennaSuccess = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_SUCCESS, + antennaId, + keyword, +}); + +export const addExcludeKeywordToAntennaFail = (antennaId, keyword, error) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_FAIL, + antennaId, + keyword, + error, +}); + +export const removeExcludeKeywordFromAntenna = (antennaId, keyword) => (dispatch, getState) => { + dispatch(removeExcludeKeywordFromAntennaRequest(antennaId, keyword)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_keywords`, { params: { keywords: [keyword] } }) + .then(() => dispatch(removeExcludeKeywordFromAntennaSuccess(antennaId, keyword))) + .catch(err => dispatch(removeExcludeKeywordFromAntennaFail(antennaId, keyword, err))); +}; + +export const removeExcludeKeywordFromAntennaRequest = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_REQUEST, + antennaId, + keyword, +}); + +export const removeExcludeKeywordFromAntennaSuccess = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS, + antennaId, + keyword, +}); + +export const removeExcludeKeywordFromAntennaFail = (antennaId, keyword, error) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL, + antennaId, + keyword, + error, +}); + +export const fetchAntennaTags = antennaId => (dispatch, getState) => { + dispatch(fetchAntennaTagsRequest(antennaId)); + + api(getState).get(`/api/v1/antennas/${antennaId}/tags`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(fetchAntennaTagsSuccess(antennaId, data)); + }).catch(err => dispatch(fetchAntennaTagsFail(antennaId, err))); +}; + +export const fetchAntennaTagsRequest = id => ({ + type: ANTENNA_EDITOR_FETCH_TAGS_REQUEST, + id, +}); + +export const fetchAntennaTagsSuccess = (id, tags) => ({ + type: ANTENNA_EDITOR_FETCH_TAGS_SUCCESS, + id, + tags, +}); + +export const fetchAntennaTagsFail = (id, error) => ({ + type: ANTENNA_EDITOR_FETCH_TAGS_FAIL, + id, + error, +}); + +export const addTagToAntenna = (antennaId, tag) => (dispatch, getState) => { + dispatch(addTagToAntennaRequest(antennaId, tag)); + + api(getState).post(`/api/v1/antennas/${antennaId}/tags`, { tags: [tag] }) + .then(() => dispatch(addTagToAntennaSuccess(antennaId, tag))) + .catch(err => dispatch(addTagToAntennaFail(antennaId, tag, err))); +}; + +export const addTagToAntennaRequest = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_ADD_TAG_REQUEST, + antennaId, + tag, +}); + +export const addTagToAntennaSuccess = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_ADD_TAG_SUCCESS, + antennaId, + tag, +}); + +export const addTagToAntennaFail = (antennaId, tag, error) => ({ + type: ANTENNA_EDITOR_ADD_TAG_FAIL, + antennaId, + tag, + error, +}); + +export const removeTagFromAntenna = (antennaId, tag) => (dispatch, getState) => { + dispatch(removeTagFromAntennaRequest(antennaId, tag)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/tags`, { params: { tags: [tag] } }) + .then(() => dispatch(removeTagFromAntennaSuccess(antennaId, tag))) + .catch(err => dispatch(removeTagFromAntennaFail(antennaId, tag, err))); +}; + +export const removeTagFromAntennaRequest = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_REMOVE_TAG_REQUEST, + antennaId, + tag, +}); + +export const removeTagFromAntennaSuccess = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_REMOVE_TAG_SUCCESS, + antennaId, + tag, +}); + +export const removeTagFromAntennaFail = (antennaId, tag, error) => ({ + type: ANTENNA_EDITOR_REMOVE_TAG_FAIL, + antennaId, + tag, + error, +}); + +export const addExcludeTagToAntenna = (antennaId, tag) => (dispatch, getState) => { + dispatch(addExcludeTagToAntennaRequest(antennaId, tag)); + + api(getState).post(`/api/v1/antennas/${antennaId}/exclude_tags`, { tags: [tag] }) + .then(() => dispatch(addExcludeTagToAntennaSuccess(antennaId, tag))) + .catch(err => dispatch(addExcludeTagToAntennaFail(antennaId, tag, err))); +}; + +export const addExcludeTagToAntennaRequest = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST, + antennaId, + tag, +}); + +export const addExcludeTagToAntennaSuccess = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS, + antennaId, + tag, +}); + +export const addExcludeTagToAntennaFail = (antennaId, tag, error) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL, + antennaId, + tag, + error, +}); + +export const removeExcludeTagFromAntenna = (antennaId, tag) => (dispatch, getState) => { + dispatch(removeExcludeTagFromAntennaRequest(antennaId, tag)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_tags`, { params: { tags: [tag] } }) + .then(() => dispatch(removeExcludeTagFromAntennaSuccess(antennaId, tag))) + .catch(err => dispatch(removeExcludeTagFromAntennaFail(antennaId, tag, err))); +}; + +export const removeExcludeTagFromAntennaRequest = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST, + antennaId, + tag, +}); + +export const removeExcludeTagFromAntennaSuccess = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS, + antennaId, + tag, +}); + +export const removeExcludeTagFromAntennaFail = (antennaId, tag, error) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL, + antennaId, + tag, + error, +}); + +export const resetAntennaAdder = () => ({ + type: ANTENNA_ADDER_RESET, +}); + +export const setupAntennaAdder = accountId => (dispatch, getState) => { + dispatch({ + type: ANTENNA_ADDER_SETUP, + account: getState().getIn(['accounts', accountId]), + }); + dispatch(fetchAntennas()); + dispatch(fetchAccountAntennas(accountId)); +}; + +export const setupExcludeAntennaAdder = accountId => (dispatch, getState) => { + dispatch({ + type: ANTENNA_ADDER_SETUP, + account: getState().getIn(['accounts', accountId]), + }); + dispatch(fetchAntennas()); + dispatch(fetchExcludeAccountAntennas(accountId)); +}; + +export const fetchAccountAntennas = accountId => (dispatch, getState) => { + dispatch(fetchAccountAntennasRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/antennas`) + .then(({ data }) => dispatch(fetchAccountAntennasSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountAntennasFail(accountId, err))); +}; + +export const fetchAccountAntennasRequest = id => ({ + type:ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST, + id, +}); + +export const fetchAccountAntennasSuccess = (id, antennas) => ({ + type: ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS, + id, + antennas, +}); + +export const fetchAccountAntennasFail = (id, err) => ({ + type: ANTENNA_ADDER_ANTENNAS_FETCH_FAIL, + id, + err, +}); + +export const fetchExcludeAccountAntennas = accountId => (dispatch, getState) => { + dispatch(fetchExcludeAccountAntennasRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/exclude_antennas`) + .then(({ data }) => dispatch(fetchExcludeAccountAntennasSuccess(accountId, data))) + .catch(err => dispatch(fetchExcludeAccountAntennasFail(accountId, err))); +}; + +export const fetchExcludeAccountAntennasRequest = id => ({ + type:ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST, + id, +}); + +export const fetchExcludeAccountAntennasSuccess = (id, antennas) => ({ + type: ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS, + id, + antennas, +}); + +export const fetchExcludeAccountAntennasFail = (id, err) => ({ + type: ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL, + id, + err, +}); + +export const addToAntennaAdder = antennaId => (dispatch, getState) => { + dispatch(addToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); +}; + +export const removeFromAntennaAdder = antennaId => (dispatch, getState) => { + dispatch(removeFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); +}; + +export const addExcludeToAntennaAdder = antennaId => (dispatch, getState) => { + dispatch(addExcludeToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); +}; + +export const removeExcludeFromAntennaAdder = antennaId => (dispatch, getState) => { + dispatch(removeExcludeFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); +}; + diff --git a/app/javascript/mastodon/actions/blocks.js b/app/javascript/mastodon/actions/blocks.js index e293657ad3..54296d0905 100644 --- a/app/javascript/mastodon/actions/blocks.js +++ b/app/javascript/mastodon/actions/blocks.js @@ -12,8 +12,6 @@ export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; -export const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL'; - export function fetchBlocks() { return (dispatch, getState) => { dispatch(fetchBlocksRequest()); @@ -90,11 +88,12 @@ export function expandBlocksFail(error) { export function initBlockModal(account) { return dispatch => { - dispatch({ - type: BLOCKS_INIT_MODAL, - account, - }); - - dispatch(openModal({ modalType: 'BLOCK' })); + dispatch(openModal({ + modalType: 'BLOCK', + modalProps: { + accountId: account.get('id'), + acct: account.get('acct'), + }, + })); }; } diff --git a/app/javascript/mastodon/actions/bookmark_categories.js b/app/javascript/mastodon/actions/bookmark_categories.js new file mode 100644 index 0000000000..7d458b85ec --- /dev/null +++ b/app/javascript/mastodon/actions/bookmark_categories.js @@ -0,0 +1,394 @@ +import { bookmarkCategoryNeeded } from 'mastodon/initial_state'; +import { makeGetStatus } from 'mastodon/selectors'; + +import api, { getLinks } from '../api'; + +import { importFetchedStatuses } from './importer'; +import { unbookmark } from './interactions'; + +export const BOOKMARK_CATEGORY_FETCH_REQUEST = 'BOOKMARK_CATEGORY_FETCH_REQUEST'; +export const BOOKMARK_CATEGORY_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_FETCH_SUCCESS'; +export const BOOKMARK_CATEGORY_FETCH_FAIL = 'BOOKMARK_CATEGORY_FETCH_FAIL'; + +export const BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORIES_FETCH_REQUEST'; +export const BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORIES_FETCH_SUCCESS'; +export const BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORIES_FETCH_FAIL'; + +export const BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE = 'BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE'; +export const BOOKMARK_CATEGORY_EDITOR_RESET = 'BOOKMARK_CATEGORY_EDITOR_RESET'; +export const BOOKMARK_CATEGORY_EDITOR_SETUP = 'BOOKMARK_CATEGORY_EDITOR_SETUP'; + +export const BOOKMARK_CATEGORY_CREATE_REQUEST = 'BOOKMARK_CATEGORY_CREATE_REQUEST'; +export const BOOKMARK_CATEGORY_CREATE_SUCCESS = 'BOOKMARK_CATEGORY_CREATE_SUCCESS'; +export const BOOKMARK_CATEGORY_CREATE_FAIL = 'BOOKMARK_CATEGORY_CREATE_FAIL'; + +export const BOOKMARK_CATEGORY_UPDATE_REQUEST = 'BOOKMARK_CATEGORY_UPDATE_REQUEST'; +export const BOOKMARK_CATEGORY_UPDATE_SUCCESS = 'BOOKMARK_CATEGORY_UPDATE_SUCCESS'; +export const BOOKMARK_CATEGORY_UPDATE_FAIL = 'BOOKMARK_CATEGORY_UPDATE_FAIL'; + +export const BOOKMARK_CATEGORY_DELETE_REQUEST = 'BOOKMARK_CATEGORY_DELETE_REQUEST'; +export const BOOKMARK_CATEGORY_DELETE_SUCCESS = 'BOOKMARK_CATEGORY_DELETE_SUCCESS'; +export const BOOKMARK_CATEGORY_DELETE_FAIL = 'BOOKMARK_CATEGORY_DELETE_FAIL'; + +export const BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST'; +export const BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS'; +export const BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL = 'BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL'; + +export const BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST'; +export const BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS'; +export const BOOKMARK_CATEGORY_EDITOR_ADD_FAIL = 'BOOKMARK_CATEGORY_EDITOR_ADD_FAIL'; + +export const BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST'; +export const BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS'; +export const BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL'; + +export const BOOKMARK_CATEGORY_ADDER_RESET = 'BOOKMARK_CATEGORY_ADDER_RESET'; +export const BOOKMARK_CATEGORY_ADDER_SETUP = 'BOOKMARK_CATEGORY_ADDER_SETUP'; + +export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST'; +export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS'; +export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL'; + +export const BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST'; +export const BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS'; +export const BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL'; + +export const fetchBookmarkCategory = id => (dispatch, getState) => { + if (getState().getIn(['bookmark_categories', id])) { + return; + } + + dispatch(fetchBookmarkCategoryRequest(id)); + + api(getState).get(`/api/v1/bookmark_categories/${id}`) + .then(({ data }) => dispatch(fetchBookmarkCategorySuccess(data))) + .catch(err => dispatch(fetchBookmarkCategoryFail(id, err))); +}; + +export const fetchBookmarkCategoryRequest = id => ({ + type: BOOKMARK_CATEGORY_FETCH_REQUEST, + id, +}); + +export const fetchBookmarkCategorySuccess = bookmarkCategory => ({ + type: BOOKMARK_CATEGORY_FETCH_SUCCESS, + bookmarkCategory, +}); + +export const fetchBookmarkCategoryFail = (id, error) => ({ + type: BOOKMARK_CATEGORY_FETCH_FAIL, + id, + error, +}); + +export const fetchBookmarkCategories = () => (dispatch, getState) => { + dispatch(fetchBookmarkCategoriesRequest()); + + api(getState).get('/api/v1/bookmark_categories') + .then(({ data }) => dispatch(fetchBookmarkCategoriesSuccess(data))) + .catch(err => dispatch(fetchBookmarkCategoriesFail(err))); +}; + +export const fetchBookmarkCategoriesRequest = () => ({ + type: BOOKMARK_CATEGORIES_FETCH_REQUEST, +}); + +export const fetchBookmarkCategoriesSuccess = bookmarkCategories => ({ + type: BOOKMARK_CATEGORIES_FETCH_SUCCESS, + bookmarkCategories, +}); + +export const fetchBookmarkCategoriesFail = error => ({ + type: BOOKMARK_CATEGORIES_FETCH_FAIL, + error, +}); + +export const submitBookmarkCategoryEditor = shouldReset => (dispatch, getState) => { + const bookmarkCategoryId = getState().getIn(['bookmarkCategoryEditor', 'bookmarkCategoryId']); + const title = getState().getIn(['bookmarkCategoryEditor', 'title']); + + if (bookmarkCategoryId === null) { + dispatch(createBookmarkCategory(title, shouldReset)); + } else { + dispatch(updateBookmarkCategory(bookmarkCategoryId, title, shouldReset)); + } +}; + +export const setupBookmarkCategoryEditor = bookmarkCategoryId => (dispatch, getState) => { + dispatch({ + type: BOOKMARK_CATEGORY_EDITOR_SETUP, + bookmarkCategory: getState().getIn(['bookmark_categories', bookmarkCategoryId]), + }); + + dispatch(fetchBookmarkCategoryStatuses(bookmarkCategoryId)); +}; + +export const changeBookmarkCategoryEditorTitle = value => ({ + type: BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE, + value, +}); + +export const createBookmarkCategory = (title, shouldReset) => (dispatch, getState) => { + dispatch(createBookmarkCategoryRequest()); + + api(getState).post('/api/v1/bookmark_categories', { title }).then(({ data }) => { + dispatch(createBookmarkCategorySuccess(data)); + + if (shouldReset) { + dispatch(resetBookmarkCategoryEditor()); + } + }).catch(err => dispatch(createBookmarkCategoryFail(err))); +}; + +export const createBookmarkCategoryRequest = () => ({ + type: BOOKMARK_CATEGORY_CREATE_REQUEST, +}); + +export const createBookmarkCategorySuccess = bookmarkCategory => ({ + type: BOOKMARK_CATEGORY_CREATE_SUCCESS, + bookmarkCategory, +}); + +export const createBookmarkCategoryFail = error => ({ + type: BOOKMARK_CATEGORY_CREATE_FAIL, + error, +}); + +export const updateBookmarkCategory = (id, title, shouldReset) => (dispatch, getState) => { + dispatch(updateBookmarkCategoryRequest(id)); + + api(getState).put(`/api/v1/bookmark_categories/${id}`, { title }).then(({ data }) => { + dispatch(updateBookmarkCategorySuccess(data)); + + if (shouldReset) { + dispatch(resetBookmarkCategoryEditor()); + } + }).catch(err => dispatch(updateBookmarkCategoryFail(id, err))); +}; + +export const updateBookmarkCategoryRequest = id => ({ + type: BOOKMARK_CATEGORY_UPDATE_REQUEST, + id, +}); + +export const updateBookmarkCategorySuccess = bookmarkCategory => ({ + type: BOOKMARK_CATEGORY_UPDATE_SUCCESS, + bookmarkCategory, +}); + +export const updateBookmarkCategoryFail = (id, error) => ({ + type: BOOKMARK_CATEGORY_UPDATE_FAIL, + id, + error, +}); + +export const resetBookmarkCategoryEditor = () => ({ + type: BOOKMARK_CATEGORY_EDITOR_RESET, +}); + +export const deleteBookmarkCategory = id => (dispatch, getState) => { + dispatch(deleteBookmarkCategoryRequest(id)); + + api(getState).delete(`/api/v1/bookmark_categories/${id}`) + .then(() => dispatch(deleteBookmarkCategorySuccess(id))) + .catch(err => dispatch(deleteBookmarkCategoryFail(id, err))); +}; + +export const deleteBookmarkCategoryRequest = id => ({ + type: BOOKMARK_CATEGORY_DELETE_REQUEST, + id, +}); + +export const deleteBookmarkCategorySuccess = id => ({ + type: BOOKMARK_CATEGORY_DELETE_SUCCESS, + id, +}); + +export const deleteBookmarkCategoryFail = (id, error) => ({ + type: BOOKMARK_CATEGORY_DELETE_FAIL, + id, + error, +}); + +export const fetchBookmarkCategoryStatuses = bookmarkCategoryId => (dispatch, getState) => { + dispatch(fetchBookmarkCategoryStatusesRequest(bookmarkCategoryId)); + + api(getState).get(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`).then((response) => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchBookmarkCategoryStatusesSuccess(bookmarkCategoryId, response.data, next ? next.uri : null)); + }).catch(err => dispatch(fetchBookmarkCategoryStatusesFail(bookmarkCategoryId, err))); +}; + +export const fetchBookmarkCategoryStatusesRequest = id => ({ + type: BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST, + id, +}); + +export const fetchBookmarkCategoryStatusesSuccess = (id, statuses, next) => ({ + type: BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS, + id, + statuses, + next, +}); + +export const fetchBookmarkCategoryStatusesFail = (id, error) => ({ + type: BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL, + id, + error, +}); + +export const addToBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => { + dispatch(addToBookmarkCategoryRequest(bookmarkCategoryId, statusId)); + + api(getState).post(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { status_ids: [statusId] }) + .then(() => dispatch(addToBookmarkCategorySuccess(bookmarkCategoryId, statusId))) + .catch(err => dispatch(addToBookmarkCategoryFail(bookmarkCategoryId, statusId, err))); +}; + +export const addToBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({ + type: BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST, + bookmarkCategoryId, + statusId, +}); + +export const addToBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({ + type: BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS, + bookmarkCategoryId, + statusId, +}); + +export const addToBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({ + type: BOOKMARK_CATEGORY_EDITOR_ADD_FAIL, + bookmarkCategoryId, + statusId, + error, +}); + +export const removeFromBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => { + dispatch(removeFromBookmarkCategoryRequest(bookmarkCategoryId, statusId)); + + api(getState).delete(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { params: { status_ids: [statusId] } }) + .then(() => dispatch(removeFromBookmarkCategorySuccess(bookmarkCategoryId, statusId))) + .catch(err => dispatch(removeFromBookmarkCategoryFail(bookmarkCategoryId, statusId, err))); +}; + +export const removeFromBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({ + type: BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST, + bookmarkCategoryId, + statusId, +}); + +export const removeFromBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({ + type: BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS, + bookmarkCategoryId, + statusId, +}); + +export const removeFromBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({ + type: BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL, + bookmarkCategoryId, + statusId, + error, +}); + +export const resetBookmarkCategoryAdder = () => ({ + type: BOOKMARK_CATEGORY_ADDER_RESET, +}); + +export const setupBookmarkCategoryAdder = statusId => (dispatch, getState) => { + dispatch({ + type: BOOKMARK_CATEGORY_ADDER_SETUP, + status: getState().getIn(['statuses', statusId]), + }); + dispatch(fetchBookmarkCategories()); + dispatch(fetchStatusBookmarkCategories(statusId)); +}; + +export const fetchStatusBookmarkCategories = statusId => (dispatch, getState) => { + dispatch(fetchStatusBookmarkCategoriesRequest(statusId)); + + api(getState).get(`/api/v1/statuses/${statusId}/bookmark_categories`) + .then(({ data }) => dispatch(fetchStatusBookmarkCategoriesSuccess(statusId, data))) + .catch(err => dispatch(fetchStatusBookmarkCategoriesFail(statusId, err))); +}; + +export const fetchStatusBookmarkCategoriesRequest = id => ({ + type:BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST, + id, +}); + +export const fetchStatusBookmarkCategoriesSuccess = (id, bookmarkCategories) => ({ + type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS, + id, + bookmarkCategories, +}); + +export const fetchStatusBookmarkCategoriesFail = (id, err) => ({ + type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL, + id, + err, +}); + +export const addToBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => { + dispatch(addToBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId']))); +}; + +export const removeFromBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => { + if (bookmarkCategoryNeeded) { + const categories = getState().getIn(['bookmarkCategoryAdder', 'bookmarkCategories', 'items']); + if (categories && categories.count() <= 1) { + const status = makeGetStatus()(getState(), { id: getState().getIn(['bookmarkCategoryAdder', 'statusId']) }); + dispatch(unbookmark(status)); + } else { + dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId']))); + } + } else { + dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId']))); + } +}; + +export function expandBookmarkCategoryStatuses(bookmarkCategoryId) { + return (dispatch, getState) => { + const url = getState().getIn(['bookmark_categories', bookmarkCategoryId, 'next'], null); + + if (url === null || getState().getIn(['bookmark_categories', bookmarkCategoryId, 'isLoading'])) { + return; + } + + dispatch(expandBookmarkCategoryStatusesRequest(bookmarkCategoryId)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandBookmarkCategoryStatusesSuccess(bookmarkCategoryId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandBookmarkCategoryStatusesFail(bookmarkCategoryId, error)); + }); + }; +} + +export function expandBookmarkCategoryStatusesRequest(id) { + return { + type: BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST, + id, + }; +} + +export function expandBookmarkCategoryStatusesSuccess(id, statuses, next) { + return { + type: BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS, + id, + statuses, + next, + }; +} + +export function expandBookmarkCategoryStatusesFail(id, error) { + return { + type: BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL, + id, + error, + }; +} + diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js index 0b16f61e63..91c1d61e10 100644 --- a/app/javascript/mastodon/actions/bookmarks.js +++ b/app/javascript/mastodon/actions/bookmarks.js @@ -1,3 +1,5 @@ +// Kmyblue tracking marker: copied bookmark_categories.js + import api, { getLinks } from '../api'; import { importFetchedStatuses } from './importer'; diff --git a/app/javascript/mastodon/actions/boosts.js b/app/javascript/mastodon/actions/boosts.js deleted file mode 100644 index 1fc2e391e2..0000000000 --- a/app/javascript/mastodon/actions/boosts.js +++ /dev/null @@ -1,32 +0,0 @@ -import { openModal } from './modal'; - -export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL'; -export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY'; - -export function initBoostModal(props) { - return (dispatch, getState) => { - const default_privacy = getState().getIn(['compose', 'default_privacy']); - - const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy; - - dispatch({ - type: BOOSTS_INIT_MODAL, - privacy, - }); - - dispatch(openModal({ - modalType: 'BOOST', - modalProps: props, - })); - }; -} - - -export function changeBoostPrivacy(privacy) { - return dispatch => { - dispatch({ - type: BOOSTS_CHANGE_PRIVACY, - privacy, - }); - }; -} diff --git a/app/javascript/mastodon/actions/circles.js b/app/javascript/mastodon/actions/circles.js new file mode 100644 index 0000000000..7ed41b4045 --- /dev/null +++ b/app/javascript/mastodon/actions/circles.js @@ -0,0 +1,470 @@ +import api, { getLinks } from '../api'; + +import { showAlertForError } from './alerts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; + +export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST'; +export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS'; +export const CIRCLE_FETCH_FAIL = 'CIRCLE_FETCH_FAIL'; + +export const CIRCLES_FETCH_REQUEST = 'CIRCLES_FETCH_REQUEST'; +export const CIRCLES_FETCH_SUCCESS = 'CIRCLES_FETCH_SUCCESS'; +export const CIRCLES_FETCH_FAIL = 'CIRCLES_FETCH_FAIL'; + +export const CIRCLE_EDITOR_TITLE_CHANGE = 'CIRCLE_EDITOR_TITLE_CHANGE'; +export const CIRCLE_EDITOR_RESET = 'CIRCLE_EDITOR_RESET'; +export const CIRCLE_EDITOR_SETUP = 'CIRCLE_EDITOR_SETUP'; + +export const CIRCLE_CREATE_REQUEST = 'CIRCLE_CREATE_REQUEST'; +export const CIRCLE_CREATE_SUCCESS = 'CIRCLE_CREATE_SUCCESS'; +export const CIRCLE_CREATE_FAIL = 'CIRCLE_CREATE_FAIL'; + +export const CIRCLE_UPDATE_REQUEST = 'CIRCLE_UPDATE_REQUEST'; +export const CIRCLE_UPDATE_SUCCESS = 'CIRCLE_UPDATE_SUCCESS'; +export const CIRCLE_UPDATE_FAIL = 'CIRCLE_UPDATE_FAIL'; + +export const CIRCLE_DELETE_REQUEST = 'CIRCLE_DELETE_REQUEST'; +export const CIRCLE_DELETE_SUCCESS = 'CIRCLE_DELETE_SUCCESS'; +export const CIRCLE_DELETE_FAIL = 'CIRCLE_DELETE_FAIL'; + +export const CIRCLE_ACCOUNTS_FETCH_REQUEST = 'CIRCLE_ACCOUNTS_FETCH_REQUEST'; +export const CIRCLE_ACCOUNTS_FETCH_SUCCESS = 'CIRCLE_ACCOUNTS_FETCH_SUCCESS'; +export const CIRCLE_ACCOUNTS_FETCH_FAIL = 'CIRCLE_ACCOUNTS_FETCH_FAIL'; + +export const CIRCLE_EDITOR_SUGGESTIONS_CHANGE = 'CIRCLE_EDITOR_SUGGESTIONS_CHANGE'; +export const CIRCLE_EDITOR_SUGGESTIONS_READY = 'CIRCLE_EDITOR_SUGGESTIONS_READY'; +export const CIRCLE_EDITOR_SUGGESTIONS_CLEAR = 'CIRCLE_EDITOR_SUGGESTIONS_CLEAR'; + +export const CIRCLE_EDITOR_ADD_REQUEST = 'CIRCLE_EDITOR_ADD_REQUEST'; +export const CIRCLE_EDITOR_ADD_SUCCESS = 'CIRCLE_EDITOR_ADD_SUCCESS'; +export const CIRCLE_EDITOR_ADD_FAIL = 'CIRCLE_EDITOR_ADD_FAIL'; + +export const CIRCLE_EDITOR_REMOVE_REQUEST = 'CIRCLE_EDITOR_REMOVE_REQUEST'; +export const CIRCLE_EDITOR_REMOVE_SUCCESS = 'CIRCLE_EDITOR_REMOVE_SUCCESS'; +export const CIRCLE_EDITOR_REMOVE_FAIL = 'CIRCLE_EDITOR_REMOVE_FAIL'; + +export const CIRCLE_ADDER_RESET = 'CIRCLE_ADDER_RESET'; +export const CIRCLE_ADDER_SETUP = 'CIRCLE_ADDER_SETUP'; + +export const CIRCLE_ADDER_CIRCLES_FETCH_REQUEST = 'CIRCLE_ADDER_CIRCLES_FETCH_REQUEST'; +export const CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS = 'CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS'; +export const CIRCLE_ADDER_CIRCLES_FETCH_FAIL = 'CIRCLE_ADDER_CIRCLES_FETCH_FAIL'; + +export const CIRCLE_STATUSES_FETCH_REQUEST = 'CIRCLE_STATUSES_FETCH_REQUEST'; +export const CIRCLE_STATUSES_FETCH_SUCCESS = 'CIRCLE_STATUSES_FETCH_SUCCESS'; +export const CIRCLE_STATUSES_FETCH_FAIL = 'CIRCLE_STATUSES_FETCH_FAIL'; + +export const CIRCLE_STATUSES_EXPAND_REQUEST = 'CIRCLE_STATUSES_EXPAND_REQUEST'; +export const CIRCLE_STATUSES_EXPAND_SUCCESS = 'CIRCLE_STATUSES_EXPAND_SUCCESS'; +export const CIRCLE_STATUSES_EXPAND_FAIL = 'CIRCLE_STATUSES_EXPAND_FAIL'; + +export const fetchCircle = id => (dispatch, getState) => { + if (getState().getIn(['circles', id])) { + return; + } + + dispatch(fetchCircleRequest(id)); + + api(getState).get(`/api/v1/circles/${id}`) + .then(({ data }) => dispatch(fetchCircleSuccess(data))) + .catch(err => dispatch(fetchCircleFail(id, err))); +}; + +export const fetchCircleRequest = id => ({ + type: CIRCLE_FETCH_REQUEST, + id, +}); + +export const fetchCircleSuccess = circle => ({ + type: CIRCLE_FETCH_SUCCESS, + circle, +}); + +export const fetchCircleFail = (id, error) => ({ + type: CIRCLE_FETCH_FAIL, + id, + error, +}); + +export const fetchCircles = () => (dispatch, getState) => { + dispatch(fetchCirclesRequest()); + + api(getState).get('/api/v1/circles') + .then(({ data }) => dispatch(fetchCirclesSuccess(data))) + .catch(err => dispatch(fetchCirclesFail(err))); +}; + +export const fetchCirclesRequest = () => ({ + type: CIRCLES_FETCH_REQUEST, +}); + +export const fetchCirclesSuccess = circles => ({ + type: CIRCLES_FETCH_SUCCESS, + circles, +}); + +export const fetchCirclesFail = error => ({ + type: CIRCLES_FETCH_FAIL, + error, +}); + +export const submitCircleEditor = shouldReset => (dispatch, getState) => { + const circleId = getState().getIn(['circleEditor', 'circleId']); + const title = getState().getIn(['circleEditor', 'title']); + + if (circleId === null) { + dispatch(createCircle(title, shouldReset)); + } else { + dispatch(updateCircle(circleId, title, shouldReset)); + } +}; + +export const setupCircleEditor = circleId => (dispatch, getState) => { + dispatch({ + type: CIRCLE_EDITOR_SETUP, + circle: getState().getIn(['circles', circleId]), + }); + + dispatch(fetchCircleAccounts(circleId)); +}; + +export const changeCircleEditorTitle = value => ({ + type: CIRCLE_EDITOR_TITLE_CHANGE, + value, +}); + +export const createCircle = (title, shouldReset) => (dispatch, getState) => { + dispatch(createCircleRequest()); + + api(getState).post('/api/v1/circles', { title }).then(({ data }) => { + dispatch(createCircleSuccess(data)); + + if (shouldReset) { + dispatch(resetCircleEditor()); + } + }).catch(err => dispatch(createCircleFail(err))); +}; + +export const createCircleRequest = () => ({ + type: CIRCLE_CREATE_REQUEST, +}); + +export const createCircleSuccess = circle => ({ + type: CIRCLE_CREATE_SUCCESS, + circle, +}); + +export const createCircleFail = error => ({ + type: CIRCLE_CREATE_FAIL, + error, +}); + +export const updateCircle = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => { + dispatch(updateCircleRequest(id)); + + api(getState).put(`/api/v1/circles/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => { + dispatch(updateCircleSuccess(data)); + + if (shouldReset) { + dispatch(resetCircleEditor()); + } + }).catch(err => dispatch(updateCircleFail(id, err))); +}; + +export const updateCircleRequest = id => ({ + type: CIRCLE_UPDATE_REQUEST, + id, +}); + +export const updateCircleSuccess = circle => ({ + type: CIRCLE_UPDATE_SUCCESS, + circle, +}); + +export const updateCircleFail = (id, error) => ({ + type: CIRCLE_UPDATE_FAIL, + id, + error, +}); + +export const resetCircleEditor = () => ({ + type: CIRCLE_EDITOR_RESET, +}); + +export const deleteCircle = id => (dispatch, getState) => { + dispatch(deleteCircleRequest(id)); + + api(getState).delete(`/api/v1/circles/${id}`) + .then(() => dispatch(deleteCircleSuccess(id))) + .catch(err => dispatch(deleteCircleFail(id, err))); +}; + +export const deleteCircleRequest = id => ({ + type: CIRCLE_DELETE_REQUEST, + id, +}); + +export const deleteCircleSuccess = id => ({ + type: CIRCLE_DELETE_SUCCESS, + id, +}); + +export const deleteCircleFail = (id, error) => ({ + type: CIRCLE_DELETE_FAIL, + id, + error, +}); + +export const fetchCircleAccounts = circleId => (dispatch, getState) => { + dispatch(fetchCircleAccountsRequest(circleId)); + + api(getState).get(`/api/v1/circles/${circleId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchCircleAccountsSuccess(circleId, data)); + }).catch(err => dispatch(fetchCircleAccountsFail(circleId, err))); +}; + +export const fetchCircleAccountsRequest = id => ({ + type: CIRCLE_ACCOUNTS_FETCH_REQUEST, + id, +}); + +export const fetchCircleAccountsSuccess = (id, accounts, next) => ({ + type: CIRCLE_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +export const fetchCircleAccountsFail = (id, error) => ({ + type: CIRCLE_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +export const fetchCircleSuggestions = q => (dispatch, getState) => { + const params = { + q, + resolve: false, + follower: true, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchCircleSuggestionsReady(q, data)); + }).catch(error => dispatch(showAlertForError(error))); +}; + +export const fetchCircleSuggestionsReady = (query, accounts) => ({ + type: CIRCLE_EDITOR_SUGGESTIONS_READY, + query, + accounts, +}); + +export const clearCircleSuggestions = () => ({ + type: CIRCLE_EDITOR_SUGGESTIONS_CLEAR, +}); + +export const changeCircleSuggestions = value => ({ + type: CIRCLE_EDITOR_SUGGESTIONS_CHANGE, + value, +}); + +export const addToCircleEditor = accountId => (dispatch, getState) => { + dispatch(addToCircle(getState().getIn(['circleEditor', 'circleId']), accountId)); +}; + +export const addToCircle = (circleId, accountId) => (dispatch, getState) => { + dispatch(addToCircleRequest(circleId, accountId)); + + api(getState).post(`/api/v1/circles/${circleId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addToCircleSuccess(circleId, accountId))) + .catch(err => dispatch(addToCircleFail(circleId, accountId, err))); +}; + +export const addToCircleRequest = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_ADD_REQUEST, + circleId, + accountId, +}); + +export const addToCircleSuccess = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_ADD_SUCCESS, + circleId, + accountId, +}); + +export const addToCircleFail = (circleId, accountId, error) => ({ + type: CIRCLE_EDITOR_ADD_FAIL, + circleId, + accountId, + error, +}); + +export const removeFromCircleEditor = accountId => (dispatch, getState) => { + dispatch(removeFromCircle(getState().getIn(['circleEditor', 'circleId']), accountId)); +}; + +export const removeFromCircle = (circleId, accountId) => (dispatch, getState) => { + dispatch(removeFromCircleRequest(circleId, accountId)); + + api(getState).delete(`/api/v1/circles/${circleId}/accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeFromCircleSuccess(circleId, accountId))) + .catch(err => dispatch(removeFromCircleFail(circleId, accountId, err))); +}; + +export const removeFromCircleRequest = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_REMOVE_REQUEST, + circleId, + accountId, +}); + +export const removeFromCircleSuccess = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_REMOVE_SUCCESS, + circleId, + accountId, +}); + +export const removeFromCircleFail = (circleId, accountId, error) => ({ + type: CIRCLE_EDITOR_REMOVE_FAIL, + circleId, + accountId, + error, +}); + +export const resetCircleAdder = () => ({ + type: CIRCLE_ADDER_RESET, +}); + +export const setupCircleAdder = accountId => (dispatch, getState) => { + dispatch({ + type: CIRCLE_ADDER_SETUP, + account: getState().getIn(['accounts', accountId]), + }); + dispatch(fetchCircles()); + dispatch(fetchAccountCircles(accountId)); +}; + +export const fetchAccountCircles = accountId => (dispatch, getState) => { + dispatch(fetchAccountCirclesRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/circles`) + .then(({ data }) => dispatch(fetchAccountCirclesSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountCirclesFail(accountId, err))); +}; + +export const fetchAccountCirclesRequest = id => ({ + type:CIRCLE_ADDER_CIRCLES_FETCH_REQUEST, + id, +}); + +export const fetchAccountCirclesSuccess = (id, circles) => ({ + type: CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS, + id, + circles, +}); + +export const fetchAccountCirclesFail = (id, err) => ({ + type: CIRCLE_ADDER_CIRCLES_FETCH_FAIL, + id, + err, +}); + +export const addToCircleAdder = circleId => (dispatch, getState) => { + dispatch(addToCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); +}; + +export const removeFromCircleAdder = circleId => (dispatch, getState) => { + dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); +}; + +export function fetchCircleStatuses(circleId) { + return (dispatch, getState) => { + if (getState().getIn(['circles', circleId, 'isLoading'])) { + return; + } + const items = getState().getIn(['circles', circleId, 'items']); + if (items && items.size > 0) { + return; + } + + dispatch(fetchCircleStatusesRequest(circleId)); + + api(getState).get(`/api/v1/circles/${circleId}/statuses`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchCircleStatusesSuccess(circleId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchCircleStatusesFail(circleId, error)); + }); + }; +} + +export function fetchCircleStatusesRequest(id) { + return { + type: CIRCLE_STATUSES_FETCH_REQUEST, + id, + }; +} + +export function fetchCircleStatusesSuccess(id, statuses, next) { + return { + type: CIRCLE_STATUSES_FETCH_SUCCESS, + id, + statuses, + next, + }; +} + +export function fetchCircleStatusesFail(id, error) { + return { + type: CIRCLE_STATUSES_FETCH_FAIL, + id, + error, + }; +} + +export function expandCircleStatuses(circleId) { + return (dispatch, getState) => { + const url = getState().getIn(['circles', circleId, 'next'], null); + + if (url === null || getState().getIn(['circles', circleId, 'isLoading'])) { + return; + } + + dispatch(expandCircleStatusesRequest(circleId)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandCircleStatusesSuccess(circleId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandCircleStatusesFail(circleId, error)); + }); + }; +} + +export function expandCircleStatusesRequest(id) { + return { + type: CIRCLE_STATUSES_EXPAND_REQUEST, + id, + }; +} + +export function expandCircleStatusesSuccess(id, statuses, next) { + return { + type: CIRCLE_STATUSES_EXPAND_SUCCESS, + id, + statuses, + next, + }; +} + +export function expandCircleStatusesFail(id, error) { + return { + type: CIRCLE_STATUSES_EXPAND_FAIL, + id, + error, + }; +} + diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 6abfd6157e..b013e134fe 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -28,6 +28,8 @@ export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_RESET = 'COMPOSE_RESET'; +export const COMPOSE_WITH_CIRCLE_SUCCESS = 'COMPOSE_WITH_CIRCLE_SUCCESS'; + export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; @@ -54,11 +56,16 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; +export const COMPOSE_MARKDOWN_CHANGE = 'COMPOSE_MARKDOWN_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +export const COMPOSE_SEARCHABILITY_CHANGE= 'COMPOSE_SEARCHABILITY_CHANGE'; export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; +export const COMPOSE_EXPIRATION_INSERT = 'COMPOSE_EXPIRATION_INSERT'; +export const COMPOSE_FEATURED_TAG_INSERT = 'COMPOSE_FEATURED_TAG_INSERT'; +export const COMPOSE_REFERENCE_INSERT = 'COMPOSE_REFERENCE_INSERT'; export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; @@ -71,10 +78,13 @@ export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE'; export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; +export const COMPOSE_CIRCLE_CHANGE = 'COMPOSE_CIRCLE_CHANGE'; + export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL'; export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION'; export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; +export const COMPOSE_CHANGE_MEDIA_ORDER = 'COMPOSE_CHANGE_MEDIA_ORDER'; export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; @@ -168,6 +178,8 @@ export function submitCompose(routerHistory) { const status = getState().getIn(['compose', 'text'], ''); const media = getState().getIn(['compose', 'media_attachments']); const statusId = getState().getIn(['compose', 'id'], null); + const circleId = getState().getIn(['compose', 'circle_id'], null); + const privacy = getState().getIn(['compose', 'privacy']); if ((!status || !status.length) && media.size === 0) { return; @@ -203,9 +215,12 @@ export function submitCompose(routerHistory) { in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: media.map(item => item.get('id')), media_attributes, - sensitive: getState().getIn(['compose', 'sensitive']), + sensitive: media.size > 0 ? getState().getIn(['compose', 'spoiler']) : false, spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', + markdown: getState().getIn(['compose', 'markdown']), visibility: getState().getIn(['compose', 'privacy']), + searchability: getState().getIn(['compose', 'searchability']), + circle_id: getState().getIn(['compose', 'circle_id']), poll: getState().getIn(['compose', 'poll'], null), language: getState().getIn(['compose', 'language']), }, @@ -234,16 +249,20 @@ export function submitCompose(routerHistory) { dispatch(importFetchedStatus({ ...response.data })); } - if (statusId === null && response.data.visibility !== 'direct') { + if (statusId === null && response.data.visibility_ex !== 'direct') { insertIfOnline('home'); } - if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') { + if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility_ex === 'public') { insertIfOnline('community'); insertIfOnline('public'); insertIfOnline(`account:${response.data.account.id}`); } + if (statusId === null && privacy === 'circle' && circleId !== null && circleId !== 0) { + dispatch(submitComposeWithCircleSuccess({ ...response.data }, circleId)); + } + dispatch(showAlert({ message: statusId === null ? messages.published : messages.saved, action: messages.open, @@ -269,6 +288,14 @@ export function submitComposeSuccess(status) { }; } +export function submitComposeWithCircleSuccess(status, circleId) { + return { + type: COMPOSE_WITH_CIRCLE_SUCCESS, + status, + circleId, + }; +} + export function submitComposeFail(error) { return { type: COMPOSE_SUBMIT_FAIL, @@ -281,6 +308,8 @@ export function uploadCompose(files) { const uploadLimit = 4; const media = getState().getIn(['compose', 'media_attachments']); const pending = getState().getIn(['compose', 'pending_media_attachments']); + const defaultSensitive = getState().getIn(['compose', 'default_sensitive']); + const spoiler = getState().getIn(['compose', 'spoiler']); const progress = new Array(files.length).fill(0); let total = Array.from(files).reduce((a, v) => a + v.size, 0); @@ -290,15 +319,10 @@ export function uploadCompose(files) { return; } - if (getState().getIn(['compose', 'poll'])) { - dispatch(showAlert({ message: messages.uploadErrorPoll })); - return; - } - dispatch(uploadComposeRequest()); for (const [i, file] of Array.from(files).entries()) { - if (media.size + i > 3) break; + if (media.size + i >= 4) break; const data = new FormData(); data.append('file', file); @@ -314,6 +338,10 @@ export function uploadCompose(files) { if (status === 200) { dispatch(uploadComposeSuccess(data, file)); + + if (defaultSensitive && !spoiler && (media.size + i) === 0) { + dispatch(changeComposeSpoilerness()); + } } else if (status === 202) { dispatch(uploadComposeProcessing()); @@ -744,6 +772,12 @@ export function changeComposeSpoilerText(text) { }; } +export function changeComposeMarkdown() { + return { + type: COMPOSE_MARKDOWN_CHANGE, + }; +} + export function changeComposeVisibility(value) { return { type: COMPOSE_VISIBILITY_CHANGE, @@ -751,6 +785,13 @@ export function changeComposeVisibility(value) { }; } +export function changeComposeSearchability(value) { + return { + type: COMPOSE_SEARCHABILITY_CHANGE, + value, + }; +} + export function insertEmojiCompose(position, emoji, needsSpace) { return { type: COMPOSE_EMOJI_INSERT, @@ -760,6 +801,31 @@ export function insertEmojiCompose(position, emoji, needsSpace) { }; } +export function insertExpirationCompose(position, data) { + return { + type: COMPOSE_EXPIRATION_INSERT, + position, + data, + }; +} + +export function insertFeaturedTagCompose(position, data) { + return { + type: COMPOSE_FEATURED_TAG_INSERT, + position, + data, + }; +} + +export function insertReferenceCompose(position, url, attributeType) { + return { + type: COMPOSE_REFERENCE_INSERT, + position, + url, + attributeType, + }; +} + export function changeComposing(value) { return { type: COMPOSE_COMPOSING_CHANGE, @@ -786,11 +852,12 @@ export function addPollOption(title) { }; } -export function changePollOption(index, title) { +export function changePollOption(index, title, maxOptions) { return { type: COMPOSE_POLL_OPTION_CHANGE, index, title, + maxOptions, }; } @@ -808,3 +875,16 @@ export function changePollSettings(expiresIn, isMultiple) { isMultiple, }; } + +export const changeMediaOrder = (a, b) => ({ + type: COMPOSE_CHANGE_MEDIA_ORDER, + a, + b, +}); + +export function changeCircle(circleId) { + return { + type: COMPOSE_CIRCLE_CHANGE, + circleId, + }; +} diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js index 718002613f..55c0a6ce9d 100644 --- a/app/javascript/mastodon/actions/domain_blocks.js +++ b/app/javascript/mastodon/actions/domain_blocks.js @@ -1,6 +1,8 @@ import api, { getLinks } from '../api'; import { blockDomainSuccess, unblockDomainSuccess } from "./domain_blocks_typed"; +import { openModal } from './modal'; + export * from "./domain_blocks_typed"; @@ -150,3 +152,12 @@ export function expandDomainBlocksFail(error) { error, }; } + +export const initDomainBlockModal = account => dispatch => dispatch(openModal({ + modalType: 'DOMAIN_BLOCK', + modalProps: { + domain: account.get('acct').split('@')[1], + acct: account.get('acct'), + accountId: account.get('id'), + }, +})); diff --git a/app/javascript/mastodon/actions/emoji_reactions.js b/app/javascript/mastodon/actions/emoji_reactions.js new file mode 100644 index 0000000000..6b97b1f743 --- /dev/null +++ b/app/javascript/mastodon/actions/emoji_reactions.js @@ -0,0 +1,94 @@ +import api, { getLinks } from '../api'; + +import { importFetchedStatuses } from './importer'; + +export const EMOJI_REACTED_STATUSES_FETCH_REQUEST = 'EMOJI_REACTED_STATUSES_FETCH_REQUEST'; +export const EMOJI_REACTED_STATUSES_FETCH_SUCCESS = 'EMOJI_REACTED_STATUSES_FETCH_SUCCESS'; +export const EMOJI_REACTED_STATUSES_FETCH_FAIL = 'EMOJI_REACTED_STATUSES_FETCH_FAIL'; + +export const EMOJI_REACTED_STATUSES_EXPAND_REQUEST = 'EMOJI_REACTED_STATUSES_EXPAND_REQUEST'; +export const EMOJI_REACTED_STATUSES_EXPAND_SUCCESS = 'EMOJI_REACTED_STATUSES_EXPAND_SUCCESS'; +export const EMOJI_REACTED_STATUSES_EXPAND_FAIL = 'EMOJI_REACTED_STATUSES_EXPAND_FAIL'; + +export function fetchEmojiReactedStatuses() { + return (dispatch, getState) => { + if (getState().getIn(['status_lists', 'emoji_reactions', 'isLoading'])) { + return; + } + + dispatch(fetchEmojiReactedStatusesRequest()); + + api(getState).get('/api/v1/emoji_reactions').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchEmojiReactedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchEmojiReactedStatusesFail(error)); + }); + }; +} + +export function fetchEmojiReactedStatusesRequest() { + return { + type: EMOJI_REACTED_STATUSES_FETCH_REQUEST, + skipLoading: true, + }; +} + +export function fetchEmojiReactedStatusesSuccess(statuses, next) { + return { + type: EMOJI_REACTED_STATUSES_FETCH_SUCCESS, + statuses, + next, + skipLoading: true, + }; +} + +export function fetchEmojiReactedStatusesFail(error) { + return { + type: EMOJI_REACTED_STATUSES_FETCH_FAIL, + error, + skipLoading: true, + }; +} + +export function expandEmojiReactedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'emoji_reactions', 'next'], null); + + if (url === null || getState().getIn(['status_lists', 'emoji_reactions', 'isLoading'])) { + return; + } + + dispatch(expandEmojiReactedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandEmojiReactedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandEmojiReactedStatusesFail(error)); + }); + }; +} + +export function expandEmojiReactedStatusesRequest() { + return { + type: EMOJI_REACTED_STATUSES_EXPAND_REQUEST, + }; +} + +export function expandEmojiReactedStatusesSuccess(statuses, next) { + return { + type: EMOJI_REACTED_STATUSES_EXPAND_SUCCESS, + statuses, + next, + }; +} + +export function expandEmojiReactedStatusesFail(error) { + return { + type: EMOJI_REACTED_STATUSES_EXPAND_FAIL, + error, + }; +} diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js index 2d4d4e6206..e5e2c481aa 100644 --- a/app/javascript/mastodon/actions/favourites.js +++ b/app/javascript/mastodon/actions/favourites.js @@ -1,3 +1,5 @@ +// Kmyblue tracking marker: copied emoji_reactions.js + import api, { getLinks } from '../api'; import { importFetchedStatuses } from './importer'; diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 16f191b584..906e384354 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -72,6 +72,10 @@ export function importFetchedStatuses(statuses) { processStatus(status.reblog); } + if (status.quote && status.quote.id && !getState().getIn(['statuses', status.id])) { + processStatus(status.quote); + } + if (status.poll && status.poll.id) { pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index b5a30343e4..05b6250761 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -1,7 +1,7 @@ import escapeTextContentForBrowser from 'escape-html'; import emojify from '../../features/emoji/emoji'; -import { expandSpoilers } from '../../initial_state'; +import { expandSpoilers, me } from '../../initial_state'; const domParser = new DOMParser(); @@ -40,6 +40,14 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.filtered = status.filtered.map(normalizeFilterResult); } + if (status.emoji_reactions) { + normalStatus.emoji_reactions = normalizeEmojiReactions(status.emoji_reactions); + } + + if (!status.visibility_ex) { + normalStatus.visibility_ex = status.visibility; + } + // Only calculate these values when status first encountered and // when the underlying values change. Otherwise keep the ones // already in the reducer @@ -50,6 +58,11 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoiler_text = normalOldStatus.get('spoiler_text'); normalStatus.hidden = normalOldStatus.get('hidden'); + // for quoted post + if (!normalStatus.filtered && normalOldStatus.get('filtered')) { + normalStatus.filtered = normalOldStatus.get('filtered'); + } + if (normalOldStatus.get('translation')) { normalStatus.translation = normalOldStatus.get('translation'); } @@ -61,6 +74,10 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoiler_text = ''; } + if (normalStatus.emojis && normalStatus.emojis.some((emoji) => emoji.is_sensitive) && !normalStatus.spoiler_text) { + normalStatus.spoiler_text = '[Contains sensitive custom emoji(s)]'; + } + const spoilerText = normalStatus.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); const emojiMap = makeEmojiMap(normalStatus.emojis); @@ -86,6 +103,17 @@ export function normalizeStatus(status, normalOldStatus) { return normalStatus; } +export function normalizeEmojiReactions(emoji_reactions) { + const myAccountId = me; + let converted = []; + for (let emoji_reaction of emoji_reactions) { + let obj = emoji_reaction; + obj.me = obj.account_ids.some((id) => id === myAccountId); + converted.push(obj); + } + return converted; +} + export function normalizeStatusTranslation(translation, status) { const emojiMap = makeEmojiMap(status.get('emojis').toJS()); diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 7d0144438a..640c5c3128 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -1,7 +1,7 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; -import { importFetchedAccounts, importFetchedStatus } from './importer'; +import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer'; export const REBLOG_REQUEST = 'REBLOG_REQUEST'; export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; @@ -15,6 +15,10 @@ export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; +export const EMOJIREACT_REQUEST = 'EMOJIREACT_REQUEST'; +export const EMOJIREACT_SUCCESS = 'EMOJIREACT_SUCCESS'; +export const EMOJIREACT_FAIL = 'EMOJIREACT_FAIL'; + export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; export const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; @@ -23,6 +27,10 @@ export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; +export const UNEMOJIREACT_REQUEST = 'UNEMOJIREACT_REQUEST'; +export const UNEMOJIREACT_SUCCESS = 'UNEMOJIREACT_SUCCESS'; +export const UNEMOJIREACT_FAIL = 'UNEMOJIREACT_FAIL'; + export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; @@ -33,7 +41,19 @@ export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST'; export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS'; -export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL'; +export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL'; + +export const STATUS_REFERENCES_FETCH_REQUEST = 'STATUS_REFERENCES_FETCH_REQUEST'; +export const STATUS_REFERENCES_FETCH_SUCCESS = 'STATUS_REFERENCES_FETCH_SUCCESS'; +export const STATUS_REFERENCES_FETCH_FAIL = 'STATUS_REFERENCES_FETCH_FAIL'; + +export const EMOJI_REACTIONS_FETCH_REQUEST = 'EMOJI_REACTIONS_FETCH_REQUEST'; +export const EMOJI_REACTIONS_FETCH_SUCCESS = 'EMOJI_REACTIONS_FETCH_SUCCESS'; +export const EMOJI_REACTIONS_FETCH_FAIL = 'EMOJI_REACTIONS_FETCH_FAIL'; + +export const EMOJI_REACTIONS_EXPAND_REQUEST = 'EMOJI_REACTIONS_EXPAND_REQUEST'; +export const EMOJI_REACTIONS_EXPAND_SUCCESS = 'EMOJI_REACTIONS_EXPAND_SUCCESS'; +export const EMOJI_REACTIONS_EXPAND_FAIL = 'EMOJI_REACTIONS_EXPAND_FAIL'; export const PIN_REQUEST = 'PIN_REQUEST'; export const PIN_SUCCESS = 'PIN_SUCCESS'; @@ -51,6 +71,14 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; +export const MENTIONED_USERS_FETCH_REQUEST = 'MENTIONED_USERS_FETCH_REQUEST'; +export const MENTIONED_USERS_FETCH_SUCCESS = 'MENTIONED_USERS_FETCH_SUCCESS'; +export const MENTIONED_USERS_FETCH_FAIL = 'MENTIONED_USERS_FETCH_FAIL'; + +export const MENTIONED_USERS_EXPAND_REQUEST = 'MENTIONED_USERS_EXPAND_REQUEST'; +export const MENTIONED_USERS_EXPAND_SUCCESS = 'MENTIONED_USERS_EXPAND_SUCCESS'; +export const MENTIONED_USERS_EXPAND_FAIL = 'MENTIONED_USERS_EXPAND_FAIL'; + export function reblog(status, visibility) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -205,6 +233,91 @@ export function unfavouriteFail(status, error) { }; } +export function emojiReact(status, emoji) { + return function (dispatch, getState) { + dispatch(emojiReactRequest(status, emoji)); + + const api_emoji = typeof emoji !== 'string' ? (emoji.custom ? (emoji.name + (emoji.domain || '')) : emoji.native) : emoji; + + api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_reactions`, { emoji: api_emoji }).then(function (response) { + dispatch(importFetchedStatus(response.data)); + dispatch(emojiReactSuccess(status, emoji)); + }).catch(function (error) { + dispatch(emojiReactFail(status, emoji, error)); + }); + }; +} + +export function unEmojiReact(status, emoji) { + return (dispatch, getState) => { + dispatch(unEmojiReactRequest(status, emoji)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_unreaction`, { emoji }).then((response) => { + // TODO: do not update because this api has a bug + dispatch(importFetchedStatus(response.data)); + dispatch(unEmojiReactSuccess(status, emoji)); + }).catch(error => { + dispatch(unEmojiReactFail(status, emoji, error)); + }); + }; +} + +export function emojiReactRequest(status, emoji) { + return { + type: EMOJIREACT_REQUEST, + status: status, + emoji: emoji, + skipLoading: true, + }; +} + +export function emojiReactSuccess(status, emoji) { + return { + type: EMOJIREACT_SUCCESS, + status: status, + emoji: emoji, + skipLoading: true, + }; +} + +export function emojiReactFail(status, emoji, error) { + return { + type: EMOJIREACT_FAIL, + status: status, + emoji: emoji, + error: error, + skipLoading: true, + }; +} + +export function unEmojiReactRequest(status, emoji) { + return { + type: UNEMOJIREACT_REQUEST, + status: status, + emoji: emoji, + skipLoading: true, + }; +} + +export function unEmojiReactSuccess(status, emoji) { + return { + type: UNEMOJIREACT_SUCCESS, + status: status, + emoji: emoji, + skipLoading: true, + }; +} + +export function unEmojiReactFail(status, emoji, error) { + return { + type: UNEMOJIREACT_FAIL, + status: status, + emoji: emoji, + error: error, + skipLoading: true, + }; +} + export function bookmark(status) { return function (dispatch, getState) { dispatch(bookmarkRequest(status)); @@ -441,6 +554,120 @@ export function expandFavouritesFail(id, error) { }; } +export function fetchEmojiReactions(id) { + return (dispatch, getState) => { + dispatch(fetchEmojiReactionsRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/emoji_reactioned_by`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map((er) => er.account))); + dispatch(fetchEmojiReactionsSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchEmojiReactionsFail(id, error)); + }); + }; +} + +export function fetchEmojiReactionsRequest(id) { + return { + type: EMOJI_REACTIONS_FETCH_REQUEST, + id, + }; +} + +export function fetchEmojiReactionsSuccess(id, accounts, next) { + return { + type: EMOJI_REACTIONS_FETCH_SUCCESS, + id, + accounts, + next, + }; +} + +export function fetchEmojiReactionsFail(id, error) { + return { + type: EMOJI_REACTIONS_FETCH_FAIL, + error, + }; +} + +export function expandEmojiReactions(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'emoji_reactioned_by', id, 'next']); + if (url === null) { + return; + } + + dispatch(expandEmojiReactionsRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.map((er) => er.account))); + dispatch(expandEmojiReactionsSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => dispatch(expandEmojiReactionsFail(id, error))); + }; +} + +export function expandEmojiReactionsRequest(id) { + return { + type: EMOJI_REACTIONS_EXPAND_REQUEST, + id, + }; +} + +export function expandEmojiReactionsSuccess(id, accounts, next) { + return { + type: EMOJI_REACTIONS_EXPAND_SUCCESS, + id, + accounts, + next, + }; +} + +export function expandEmojiReactionsFail(id, error) { + return { + type: EMOJI_REACTIONS_EXPAND_FAIL, + id, + error, + }; +} + +export function fetchStatusReferences(id) { + return (dispatch, getState) => { + dispatch(fetchStatusReferencesRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/referred_by`).then(response => { + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchStatusReferencesSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchStatusReferencesFail(id, error)); + }); + }; +} + +export function fetchStatusReferencesRequest(id) { + return { + type: STATUS_REFERENCES_FETCH_REQUEST, + id, + }; +} + +export function fetchStatusReferencesSuccess(id, statuses) { + return { + type: STATUS_REFERENCES_FETCH_SUCCESS, + id, + statuses, + }; +} + +export function fetchStatusReferencesFail(id, error) { + return { + type: STATUS_REFERENCES_FETCH_FAIL, + error, + }; +} + export function pin(status) { return (dispatch, getState) => { dispatch(pinRequest(status)); @@ -516,3 +743,85 @@ export function unpinFail(status, error) { skipLoading: true, }; } + +export function fetchMentionedUsers(id) { + return (dispatch, getState) => { + dispatch(fetchMentionedUsersRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/mentioned_by`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchMentionedUsersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchMentionedUsersFail(id, error)); + }); + }; +} + +export function fetchMentionedUsersRequest(id) { + return { + type: MENTIONED_USERS_FETCH_REQUEST, + id, + }; +} + +export function fetchMentionedUsersSuccess(id, accounts, next) { + return { + type: MENTIONED_USERS_FETCH_SUCCESS, + id, + accounts, + next, + }; +} + +export function fetchMentionedUsersFail(id, error) { + return { + type: MENTIONED_USERS_FETCH_FAIL, + id, + error, + }; +} + +export function expandMentionedUsers(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'mentioned_users', id, 'next']); + if (url === null) { + return; + } + + dispatch(expandMentionedUsersRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandMentionedUsersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandMentionedUsersFail(id, error))); + }; +} + +export function expandMentionedUsersRequest(id) { + return { + type: MENTIONED_USERS_EXPAND_REQUEST, + id, + }; +} + +export function expandMentionedUsersSuccess(id, accounts, next) { + return { + type: MENTIONED_USERS_EXPAND_SUCCESS, + id, + accounts, + next, + }; +} + +export function expandMentionedUsersFail(id, error) { + return { + type: MENTIONED_USERS_EXPAND_FAIL, + id, + error, + }; +} diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js index b0789cd426..e494a40a5d 100644 --- a/app/javascript/mastodon/actions/lists.js +++ b/app/javascript/mastodon/actions/lists.js @@ -1,3 +1,5 @@ +// Kmyblue tracking marker: copied circles.js, antennas.js + import api from '../api'; import { showAlertForError } from './alerts'; @@ -151,10 +153,15 @@ export const createListFail = error => ({ error, }); -export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => { +export const updateList = (id, title, shouldReset, isExclusive, replies_policy, notify) => (dispatch, getState) => { dispatch(updateListRequest(id)); - api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => { + api(getState).put(`/api/v1/lists/${id}`, { + title, + replies_policy, + exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive, + notify: typeof notify === 'undefined' ? undefined : !!notify, + }).then(({ data }) => { dispatch(updateListSuccess(data)); if (shouldReset) { @@ -238,7 +245,6 @@ export const fetchListSuggestions = q => (dispatch, getState) => { const params = { q, resolve: false, - limit: 4, following: true, }; diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js deleted file mode 100644 index cfc329a8b7..0000000000 --- a/app/javascript/mastodon/actions/markers.js +++ /dev/null @@ -1,152 +0,0 @@ -import { List as ImmutableList } from 'immutable'; - -import { debounce } from 'lodash'; - -import api from '../api'; -import { compareId } from '../compare_id'; - -export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; -export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; -export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL'; -export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS'; - -export const synchronouslySubmitMarkers = () => (dispatch, getState) => { - const accessToken = getState().getIn(['meta', 'access_token'], ''); - const params = _buildParams(getState()); - - if (Object.keys(params).length === 0 || accessToken === '') { - return; - } - - // The Fetch API allows us to perform requests that will be carried out - // after the page closes. But that only works if the `keepalive` attribute - // is supported. - if (window.fetch && 'keepalive' in new Request('')) { - fetch('/api/v1/markers', { - keepalive: true, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, - }, - body: JSON.stringify(params), - }); - - return; - } else if (navigator && navigator.sendBeacon) { - // Failing that, we can use sendBeacon, but we have to encode the data as - // FormData for DoorKeeper to recognize the token. - const formData = new FormData(); - - formData.append('bearer_token', accessToken); - - for (const [id, value] of Object.entries(params)) { - formData.append(`${id}[last_read_id]`, value.last_read_id); - } - - if (navigator.sendBeacon('/api/v1/markers', formData)) { - return; - } - } - - // If neither Fetch nor sendBeacon worked, try to perform a synchronous - // request. - try { - const client = new XMLHttpRequest(); - - client.open('POST', '/api/v1/markers', false); - client.setRequestHeader('Content-Type', 'application/json'); - client.setRequestHeader('Authorization', `Bearer ${accessToken}`); - client.send(JSON.stringify(params)); - } catch (e) { - // Do not make the BeforeUnload handler error out - } -}; - -const _buildParams = (state) => { - const params = {}; - - const lastHomeId = state.getIn(['timelines', 'home', 'items'], ImmutableList()).find(item => item !== null); - const lastNotificationId = state.getIn(['notifications', 'lastReadId']); - - if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { - params.home = { - last_read_id: lastHomeId, - }; - } - - if (lastNotificationId && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) { - params.notifications = { - last_read_id: lastNotificationId, - }; - } - - return params; -}; - -const debouncedSubmitMarkers = debounce((dispatch, getState) => { - const accessToken = getState().getIn(['meta', 'access_token'], ''); - const params = _buildParams(getState()); - - if (Object.keys(params).length === 0 || accessToken === '') { - return; - } - - api(getState).post('/api/v1/markers', params).then(() => { - dispatch(submitMarkersSuccess(params)); - }).catch(() => {}); -}, 300000, { leading: true, trailing: true }); - -export function submitMarkersSuccess({ home, notifications }) { - return { - type: MARKERS_SUBMIT_SUCCESS, - home: (home || {}).last_read_id, - notifications: (notifications || {}).last_read_id, - }; -} - -export function submitMarkers(params = {}) { - const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); - - if (params.immediate === true) { - debouncedSubmitMarkers.flush(); - } - - return result; -} - -export const fetchMarkers = () => (dispatch, getState) => { - const params = { timeline: ['notifications'] }; - - dispatch(fetchMarkersRequest()); - - api(getState).get('/api/v1/markers', { params }).then(response => { - dispatch(fetchMarkersSuccess(response.data)); - }).catch(error => { - dispatch(fetchMarkersFail(error)); - }); -}; - -export function fetchMarkersRequest() { - return { - type: MARKERS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchMarkersSuccess(markers) { - return { - type: MARKERS_FETCH_SUCCESS, - markers, - skipLoading: true, - }; -} - -export function fetchMarkersFail(error) { - return { - type: MARKERS_FETCH_FAIL, - error, - skipLoading: true, - skipAlert: true, - }; -} diff --git a/app/javascript/mastodon/actions/markers.ts b/app/javascript/mastodon/actions/markers.ts new file mode 100644 index 0000000000..91f78ee286 --- /dev/null +++ b/app/javascript/mastodon/actions/markers.ts @@ -0,0 +1,146 @@ +import { debounce } from 'lodash'; + +import type { MarkerJSON } from 'mastodon/api_types/markers'; +import type { AppDispatch, RootState } from 'mastodon/store'; +import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; + +import api, { authorizationTokenFromState } from '../api'; +import { compareId } from '../compare_id'; + +export const synchronouslySubmitMarkers = createAppAsyncThunk( + 'markers/submit', + async (_args, { getState }) => { + const accessToken = authorizationTokenFromState(getState); + const params = buildPostMarkersParams(getState()); + + if (Object.keys(params).length === 0 || !accessToken) { + return; + } + + // The Fetch API allows us to perform requests that will be carried out + // after the page closes. But that only works if the `keepalive` attribute + // is supported. + if ('fetch' in window && 'keepalive' in new Request('')) { + await fetch('/api/v1/markers', { + keepalive: true, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(params), + }); + + return; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if ('navigator' && 'sendBeacon' in navigator) { + // Failing that, we can use sendBeacon, but we have to encode the data as + // FormData for DoorKeeper to recognize the token. + const formData = new FormData(); + + formData.append('bearer_token', accessToken); + + for (const [id, value] of Object.entries(params)) { + if (value.last_read_id) + formData.append(`${id}[last_read_id]`, value.last_read_id); + } + + if (navigator.sendBeacon('/api/v1/markers', formData)) { + return; + } + } + + // If neither Fetch nor sendBeacon worked, try to perform a synchronous + // request. + try { + const client = new XMLHttpRequest(); + + client.open('POST', '/api/v1/markers', false); + client.setRequestHeader('Content-Type', 'application/json'); + client.setRequestHeader('Authorization', `Bearer ${accessToken}`); + client.send(JSON.stringify(params)); + } catch (e) { + // Do not make the BeforeUnload handler error out + } + }, +); + +interface MarkerParam { + last_read_id?: string; +} + +function getLastNotificationId(state: RootState): string | undefined { + // @ts-expect-error state.notifications is not yet typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call + return state.getIn(['notifications', 'lastReadId']); +} + +const buildPostMarkersParams = (state: RootState) => { + const params = {} as { home?: MarkerParam; notifications?: MarkerParam }; + + const lastNotificationId = getLastNotificationId(state); + + if ( + lastNotificationId && + compareId(lastNotificationId, state.markers.notifications) > 0 + ) { + params.notifications = { + last_read_id: lastNotificationId, + }; + } + + return params; +}; + +export const submitMarkersAction = createAppAsyncThunk<{ + home: string | undefined; + notifications: string | undefined; +}>('markers/submitAction', async (_args, { getState }) => { + const accessToken = authorizationTokenFromState(getState); + const params = buildPostMarkersParams(getState()); + + if (Object.keys(params).length === 0 || accessToken === '') { + return { home: undefined, notifications: undefined }; + } + + await api(getState).post('/api/v1/markers', params); + + return { + home: params.home?.last_read_id, + notifications: params.notifications?.last_read_id, + }; +}); + +const debouncedSubmitMarkers = debounce( + (dispatch: AppDispatch) => { + void dispatch(submitMarkersAction()); + }, + 300000, + { + leading: true, + trailing: true, + }, +); + +export const submitMarkers = createAppAsyncThunk( + 'markers/submit', + (params: { immediate?: boolean }, { dispatch }) => { + debouncedSubmitMarkers(dispatch); + + if (params.immediate) { + debouncedSubmitMarkers.flush(); + } + }, +); + +export const fetchMarkers = createAppAsyncThunk( + 'markers/fetch', + async (_args, { getState }) => { + const response = await api(getState).get>( + `/api/v1/markers`, + { params: { timeline: ['notifications'] } }, + ); + + return { markers: response.data }; + }, +); diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js index fb041078b8..99c113f414 100644 --- a/app/javascript/mastodon/actions/mutes.js +++ b/app/javascript/mastodon/actions/mutes.js @@ -12,10 +12,6 @@ export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; -export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; -export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; -export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; - export function fetchMutes() { return (dispatch, getState) => { dispatch(fetchMutesRequest()); @@ -92,26 +88,12 @@ export function expandMutesFail(error) { export function initMuteModal(account) { return dispatch => { - dispatch({ - type: MUTES_INIT_MODAL, - account, - }); - - dispatch(openModal({ modalType: 'MUTE' })); - }; -} - -export function toggleHideNotifications() { - return dispatch => { - dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); - }; -} - -export function changeMuteDuration(duration) { - return dispatch => { - dispatch({ - type: MUTES_CHANGE_DURATION, - duration, - }); + dispatch(openModal({ + modalType: 'MUTE', + modalProps: { + accountId: account.get('id'), + acct: account.get('acct'), + }, + })); }; } diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index eafbf42d1b..c8b1178d77 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -4,7 +4,7 @@ import { defineMessages } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { compareId } from 'mastodon/compare_id'; -import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; +import { enableEmojiReaction, usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import api, { getLinks } from '../api'; import { unescapeHTML } from '../utils/html'; @@ -21,6 +21,7 @@ import { submitMarkers } from './markers'; import { notificationsUpdate } from "./notifications_typed"; import { register as registerPushNotifications } from './push_notifications'; import { saveSettings } from './settings'; +import { STATUS_EMOJI_REACTION_UPDATE } from './statuses'; export * from "./notifications_typed"; @@ -44,6 +45,38 @@ export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; +export const NOTIFICATION_POLICY_FETCH_REQUEST = 'NOTIFICATION_POLICY_FETCH_REQUEST'; +export const NOTIFICATION_POLICY_FETCH_SUCCESS = 'NOTIFICATION_POLICY_FETCH_SUCCESS'; +export const NOTIFICATION_POLICY_FETCH_FAIL = 'NOTIFICATION_POLICY_FETCH_FAIL'; + +export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST'; +export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS'; +export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL'; + +export const NOTIFICATION_REQUESTS_EXPAND_REQUEST = 'NOTIFICATION_REQUESTS_EXPAND_REQUEST'; +export const NOTIFICATION_REQUESTS_EXPAND_SUCCESS = 'NOTIFICATION_REQUESTS_EXPAND_SUCCESS'; +export const NOTIFICATION_REQUESTS_EXPAND_FAIL = 'NOTIFICATION_REQUESTS_EXPAND_FAIL'; + +export const NOTIFICATION_REQUEST_FETCH_REQUEST = 'NOTIFICATION_REQUEST_FETCH_REQUEST'; +export const NOTIFICATION_REQUEST_FETCH_SUCCESS = 'NOTIFICATION_REQUEST_FETCH_SUCCESS'; +export const NOTIFICATION_REQUEST_FETCH_FAIL = 'NOTIFICATION_REQUEST_FETCH_FAIL'; + +export const NOTIFICATION_REQUEST_ACCEPT_REQUEST = 'NOTIFICATION_REQUEST_ACCEPT_REQUEST'; +export const NOTIFICATION_REQUEST_ACCEPT_SUCCESS = 'NOTIFICATION_REQUEST_ACCEPT_SUCCESS'; +export const NOTIFICATION_REQUEST_ACCEPT_FAIL = 'NOTIFICATION_REQUEST_ACCEPT_FAIL'; + +export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMISS_REQUEST'; +export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS'; +export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL'; + +export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST'; +export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS'; +export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL'; + +export const NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST'; +export const NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS'; +export const NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL'; + defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, @@ -61,6 +94,14 @@ export const loadPending = () => ({ type: NOTIFICATIONS_LOAD_PENDING, }); +export function updateEmojiReactions(emoji_reaction) { + return (dispatch) => + dispatch({ + type: STATUS_EMOJI_REACTION_UPDATE, + emoji_reaction, + }); +} + export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); @@ -73,7 +114,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { if (['mention', 'status'].includes(notification.type) && notification.status.filtered) { const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications')); - if (filters.some(result => result.filter.filter_action === 'hide')) { + if (filters.some(result => result.filter.filter_action_ex === 'hide')) { return; } @@ -130,10 +171,13 @@ const excludeTypesFromFilter = filter => { 'follow', 'follow_request', 'favourite', + 'emoji_reaction', 'reblog', + 'status_reference', 'mention', 'poll', 'status', + 'list_status', 'update', 'admin.sign_up', 'admin.report', @@ -162,11 +206,16 @@ export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { } } + let exclude_types = activeFilter === 'all' + ? excludeTypesFromSettings(getState()) + : excludeTypesFromFilter(activeFilter); + if (!enableEmojiReaction && !exclude_types.includes('emoji_reaction')) { + exclude_types.push('emoji_reaction'); + } + const params = { max_id: maxId, - exclude_types: activeFilter === 'all' - ? excludeTypesFromSettings(getState()) - : excludeTypesFromFilter(activeFilter), + exclude_types, }; if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) { @@ -313,3 +362,270 @@ export function setBrowserPermission (value) { value, }; } + +export const fetchNotificationPolicy = () => (dispatch, getState) => { + dispatch(fetchNotificationPolicyRequest()); + + api(getState).get('/api/v1/notifications/policy').then(({ data }) => { + dispatch(fetchNotificationPolicySuccess(data)); + }).catch(err => { + dispatch(fetchNotificationPolicyFail(err)); + }); +}; + +export const fetchNotificationPolicyRequest = () => ({ + type: NOTIFICATION_POLICY_FETCH_REQUEST, +}); + +export const fetchNotificationPolicySuccess = policy => ({ + type: NOTIFICATION_POLICY_FETCH_SUCCESS, + policy, +}); + +export const fetchNotificationPolicyFail = error => ({ + type: NOTIFICATION_POLICY_FETCH_FAIL, + error, +}); + +export const updateNotificationsPolicy = params => (dispatch, getState) => { + dispatch(fetchNotificationPolicyRequest()); + + api(getState).put('/api/v1/notifications/policy', params).then(({ data }) => { + dispatch(fetchNotificationPolicySuccess(data)); + }).catch(err => { + dispatch(fetchNotificationPolicyFail(err)); + }); +}; + +export const fetchNotificationRequests = () => (dispatch, getState) => { + const params = {}; + + if (getState().getIn(['notificationRequests', 'isLoading'])) { + return; + } + + if (getState().getIn(['notificationRequests', 'items'])?.size > 0) { + params.since_id = getState().getIn(['notificationRequests', 'items', 0, 'id']); + } + + dispatch(fetchNotificationRequestsRequest()); + + api(getState).get('/api/v1/notifications/requests', { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(x => x.account))); + dispatch(fetchNotificationRequestsSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(fetchNotificationRequestsFail(err)); + }); +}; + +export const fetchNotificationRequestsRequest = () => ({ + type: NOTIFICATION_REQUESTS_FETCH_REQUEST, +}); + +export const fetchNotificationRequestsSuccess = (requests, next) => ({ + type: NOTIFICATION_REQUESTS_FETCH_SUCCESS, + requests, + next, +}); + +export const fetchNotificationRequestsFail = error => ({ + type: NOTIFICATION_REQUESTS_FETCH_FAIL, + error, +}); + +export const expandNotificationRequests = () => (dispatch, getState) => { + const url = getState().getIn(['notificationRequests', 'next']); + + if (!url || getState().getIn(['notificationRequests', 'isLoading'])) { + return; + } + + dispatch(expandNotificationRequestsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(x => x.account))); + dispatch(expandNotificationRequestsSuccess(response.data, next?.uri)); + }).catch(err => { + dispatch(expandNotificationRequestsFail(err)); + }); +}; + +export const expandNotificationRequestsRequest = () => ({ + type: NOTIFICATION_REQUESTS_EXPAND_REQUEST, +}); + +export const expandNotificationRequestsSuccess = (requests, next) => ({ + type: NOTIFICATION_REQUESTS_EXPAND_SUCCESS, + requests, + next, +}); + +export const expandNotificationRequestsFail = error => ({ + type: NOTIFICATION_REQUESTS_EXPAND_FAIL, + error, +}); + +export const fetchNotificationRequest = id => (dispatch, getState) => { + const current = getState().getIn(['notificationRequests', 'current']); + + if (current.getIn(['item', 'id']) === id || current.get('isLoading')) { + return; + } + + dispatch(fetchNotificationRequestRequest(id)); + + api(getState).get(`/api/v1/notifications/requests/${id}`).then(({ data }) => { + dispatch(fetchNotificationRequestSuccess(data)); + }).catch(err => { + dispatch(fetchNotificationRequestFail(id, err)); + }); +}; + +export const fetchNotificationRequestRequest = id => ({ + type: NOTIFICATION_REQUEST_FETCH_REQUEST, + id, +}); + +export const fetchNotificationRequestSuccess = request => ({ + type: NOTIFICATION_REQUEST_FETCH_SUCCESS, + request, +}); + +export const fetchNotificationRequestFail = (id, error) => ({ + type: NOTIFICATION_REQUEST_FETCH_FAIL, + id, + error, +}); + +export const acceptNotificationRequest = id => (dispatch, getState) => { + dispatch(acceptNotificationRequestRequest(id)); + + api(getState).post(`/api/v1/notifications/requests/${id}/accept`).then(() => { + dispatch(acceptNotificationRequestSuccess(id)); + }).catch(err => { + dispatch(acceptNotificationRequestFail(id, err)); + }); +}; + +export const acceptNotificationRequestRequest = id => ({ + type: NOTIFICATION_REQUEST_ACCEPT_REQUEST, + id, +}); + +export const acceptNotificationRequestSuccess = id => ({ + type: NOTIFICATION_REQUEST_ACCEPT_SUCCESS, + id, +}); + +export const acceptNotificationRequestFail = (id, error) => ({ + type: NOTIFICATION_REQUEST_ACCEPT_FAIL, + id, + error, +}); + +export const dismissNotificationRequest = id => (dispatch, getState) => { + dispatch(dismissNotificationRequestRequest(id)); + + api(getState).post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{ + dispatch(dismissNotificationRequestSuccess(id)); + }).catch(err => { + dispatch(dismissNotificationRequestFail(id, err)); + }); +}; + +export const dismissNotificationRequestRequest = id => ({ + type: NOTIFICATION_REQUEST_DISMISS_REQUEST, + id, +}); + +export const dismissNotificationRequestSuccess = id => ({ + type: NOTIFICATION_REQUEST_DISMISS_SUCCESS, + id, +}); + +export const dismissNotificationRequestFail = (id, error) => ({ + type: NOTIFICATION_REQUEST_DISMISS_FAIL, + id, + error, +}); + +export const fetchNotificationsForRequest = accountId => (dispatch, getState) => { + const current = getState().getIn(['notificationRequests', 'current']); + const params = { account_id: accountId }; + + if (current.getIn(['item', 'account']) === accountId) { + if (current.getIn(['notifications', 'isLoading'])) { + return; + } + + if (current.getIn(['notifications', 'items'])?.size > 0) { + params.since_id = current.getIn(['notifications', 'items', 0, 'id']); + } + } + + dispatch(fetchNotificationsForRequestRequest()); + + api(getState).get('/api/v1/notifications', { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(item => item.account))); + dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); + + dispatch(fetchNotificationsForRequestSuccess(response.data, next?.uri)); + }).catch(err => { + dispatch(fetchNotificationsForRequestFail(err)); + }); +}; + +export const fetchNotificationsForRequestRequest = () => ({ + type: NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST, +}); + +export const fetchNotificationsForRequestSuccess = (notifications, next) => ({ + type: NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS, + notifications, + next, +}); + +export const fetchNotificationsForRequestFail = (error) => ({ + type: NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL, + error, +}); + +export const expandNotificationsForRequest = () => (dispatch, getState) => { + const url = getState().getIn(['notificationRequests', 'current', 'notifications', 'next']); + + if (!url || getState().getIn(['notificationRequests', 'current', 'notifications', 'isLoading'])) { + return; + } + + dispatch(expandNotificationsForRequestRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(item => item.account))); + dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); + + dispatch(expandNotificationsForRequestSuccess(response.data, next?.uri)); + }).catch(err => { + dispatch(expandNotificationsForRequestFail(err)); + }); +}; + +export const expandNotificationsForRequestRequest = () => ({ + type: NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST, +}); + +export const expandNotificationsForRequestSuccess = (notifications, next) => ({ + type: NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS, + notifications, + next, +}); + +export const expandNotificationsForRequestFail = (error) => ({ + type: NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL, + error, +}); diff --git a/app/javascript/mastodon/actions/picture_in_picture.js b/app/javascript/mastodon/actions/picture_in_picture.js deleted file mode 100644 index 898375abeb..0000000000 --- a/app/javascript/mastodon/actions/picture_in_picture.js +++ /dev/null @@ -1,46 +0,0 @@ -// @ts-check - -export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; -export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; - -/** - * @typedef MediaProps - * @property {string} src - * @property {boolean} muted - * @property {number} volume - * @property {number} currentTime - * @property {string} poster - * @property {string} backgroundColor - * @property {string} foregroundColor - * @property {string} accentColor - */ - -/** - * @param {string} statusId - * @param {string} accountId - * @param {string} playerType - * @param {MediaProps} props - * @returns {object} - */ -export const deployPictureInPicture = (statusId, accountId, playerType, props) => { - // @ts-expect-error - return (dispatch, getState) => { - // Do not open a player for a toot that does not exist - if (getState().hasIn(['statuses', statusId])) { - dispatch({ - type: PICTURE_IN_PICTURE_DEPLOY, - statusId, - accountId, - playerType, - props, - }); - } - }; -}; - -/* - * @return {object} - */ -export const removePictureInPicture = () => ({ - type: PICTURE_IN_PICTURE_REMOVE, -}); diff --git a/app/javascript/mastodon/actions/picture_in_picture.ts b/app/javascript/mastodon/actions/picture_in_picture.ts new file mode 100644 index 0000000000..d34b508a33 --- /dev/null +++ b/app/javascript/mastodon/actions/picture_in_picture.ts @@ -0,0 +1,31 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { PIPMediaProps } from 'mastodon/reducers/picture_in_picture'; +import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; + +interface DeployParams { + statusId: string; + accountId: string; + playerType: 'audio' | 'video'; + props: PIPMediaProps; +} + +export const removePictureInPicture = createAction('pip/remove'); + +export const deployPictureInPictureAction = + createAction('pip/deploy'); + +export const deployPictureInPicture = createAppAsyncThunk( + 'pip/deploy', + (args: DeployParams, { dispatch, getState }) => { + const { statusId } = args; + + // Do not open a player for a toot that does not exist + + // @ts-expect-error state.statuses is not yet typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + if (getState().hasIn(['statuses', statusId])) { + dispatch(deployPictureInPictureAction(args)); + } + }, +); diff --git a/app/javascript/mastodon/actions/reaction_deck.js b/app/javascript/mastodon/actions/reaction_deck.js new file mode 100644 index 0000000000..11510d5d0d --- /dev/null +++ b/app/javascript/mastodon/actions/reaction_deck.js @@ -0,0 +1,79 @@ +import api from '../api'; + +export const REACTION_DECK_FETCH_REQUEST = 'REACTION_DECK_FETCH_REQUEST'; +export const REACTION_DECK_FETCH_SUCCESS = 'REACTION_DECK_FETCH_SUCCESS'; +export const REACTION_DECK_FETCH_FAIL = 'REACTION_DECK_FETCH_FAIL'; + +export const REACTION_DECK_UPDATE_REQUEST = 'REACTION_DECK_UPDATE_REQUEST'; +export const REACTION_DECK_UPDATE_SUCCESS = 'REACTION_DECK_UPDATE_SUCCESS'; +export const REACTION_DECK_UPDATE_FAIL = 'REACTION_DECK_UPDATE_FAIL'; + +export function fetchReactionDeck() { + return (dispatch, getState) => { + dispatch(fetchReactionDeckRequest()); + + api(getState).get('/api/v1/reaction_deck').then(response => { + dispatch(fetchReactionDeckSuccess(response.data)); + }).catch(error => { + dispatch(fetchReactionDeckFail(error)); + }); + }; +} + +export function fetchReactionDeckRequest() { + return { + type: REACTION_DECK_FETCH_REQUEST, + skipLoading: true, + }; +} + +export function fetchReactionDeckSuccess(emojis) { + return { + type: REACTION_DECK_FETCH_SUCCESS, + emojis, + skipLoading: true, + }; +} + +export function fetchReactionDeckFail(error) { + return { + type: REACTION_DECK_FETCH_FAIL, + error, + skipLoading: true, + }; +} + +export function updateReactionDeck(emojis) { + return (dispatch, getState) => { + dispatch(updateReactionDeckRequest()); + + api(getState).post('/api/v1/reaction_deck', { emojis }).then(response => { + dispatch(updateReactionDeckSuccess(response.data)); + }).catch(error => { + dispatch(updateReactionDeckFail(error)); + }); + }; +} + +export function updateReactionDeckRequest() { + return { + type: REACTION_DECK_UPDATE_REQUEST, + skipLoading: true, + }; +} + +export function updateReactionDeckSuccess(emojis) { + return { + type: REACTION_DECK_UPDATE_SUCCESS, + emojis, + skipLoading: true, + }; +} + +export function updateReactionDeckFail(error) { + return { + type: REACTION_DECK_UPDATE_FAIL, + error, + skipLoading: true, + }; +} diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index 38a089b486..a34a490e76 100644 --- a/app/javascript/mastodon/actions/search.js +++ b/app/javascript/mastodon/actions/search.js @@ -179,6 +179,11 @@ export const openURL = (value, history, onFailure) => (dispatch, getState) => { export const clickSearchResult = (q, type) => (dispatch, getState) => { const previous = getState().getIn(['search', 'recent']); + + if (previous.some(x => x.get('q') === q && x.get('type') === type)) { + return; + } + const me = getState().getIn(['meta', 'me']); const current = previous.add(fromJS({ type, q })).takeLast(4); @@ -207,4 +212,4 @@ export const hydrateSearch = () => (dispatch, getState) => { if (history !== null) { dispatch(updateSearchHistory(history)); } -}; \ No newline at end of file +}; diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js index 3685b0684e..fbd89f9d4b 100644 --- a/app/javascript/mastodon/actions/settings.js +++ b/app/javascript/mastodon/actions/settings.js @@ -20,7 +20,7 @@ export function changeSetting(path, value) { } const debouncedSave = debounce((dispatch, getState) => { - if (getState().getIn(['settings', 'saved'])) { + if (getState().getIn(['settings', 'saved']) || !getState().getIn(['meta', 'me'])) { return; } diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 3aed807358..9807e6f67c 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -39,6 +39,8 @@ export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS'; export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; +export const STATUS_EMOJI_REACTION_UPDATE = 'STATUS_EMOJI_REACTION_UPDATE'; + export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, @@ -178,9 +180,9 @@ export function fetchContext(id) { return (dispatch, getState) => { dispatch(fetchContextRequest(id)); - api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { - dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants))); - dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); + api(getState).get(`/api/v1/statuses/${id}/context?with_reference=1`).then(response => { + dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants).concat(response.data.references))); + dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants, response.data.references)); }).catch(error => { if (error.response && error.response.status === 404) { @@ -199,12 +201,13 @@ export function fetchContextRequest(id) { }; } -export function fetchContextSuccess(id, ancestors, descendants) { +export function fetchContextSuccess(id, ancestors, descendants, references) { return { type: CONTEXT_FETCH_SUCCESS, id, ancestors, descendants, + references, statuses: ancestors.concat(descendants), }; } @@ -348,3 +351,8 @@ export const undoStatusTranslation = (id, pollId) => ({ id, pollId, }); + +export const updateEmojiReaction = (emoji_reaction) => ({ + type: STATUS_EMOJI_REACTION_UPDATE, + emoji_reaction, +}); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 9daeb3c60f..6517e5ad20 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -10,7 +10,7 @@ import { deleteAnnouncement, } from './announcements'; import { updateConversations } from './conversations'; -import { updateNotifications, expandNotifications } from './notifications'; +import { updateNotifications, expandNotifications, updateEmojiReactions } from './notifications'; import { updateStatus } from './statuses'; import { updateTimeline, @@ -22,6 +22,7 @@ import { fillPublicTimelineGaps, fillCommunityTimelineGaps, fillListTimelineGaps, + fillAntennaTimelineGaps, } from './timelines'; /** @@ -102,6 +103,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti // @ts-expect-error dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); break; + case 'emoji_reaction': + // @ts-expect-error + dispatch(updateEmojiReactions(JSON.parse(data.payload))); + break; case 'conversation': // @ts-expect-error dispatch(updateConversations(JSON.parse(data.payload))); @@ -181,3 +186,10 @@ export const connectDirectStream = () => */ export const connectListStream = listId => connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) }); + +/** + * @param {string} antennaId + * @returns {function(): void} + */ +export const connectAntennaStream = antennaId => + connectTimelineStream(`antenna:${antennaId}`, 'antenna', { antenna: antennaId }, { fillGaps: () => fillAntennaTimelineGaps(antennaId) }); diff --git a/app/javascript/mastodon/actions/suggestions.js b/app/javascript/mastodon/actions/suggestions.js index 870a311024..8eafe38b21 100644 --- a/app/javascript/mastodon/actions/suggestions.js +++ b/app/javascript/mastodon/actions/suggestions.js @@ -54,12 +54,5 @@ export const dismissSuggestion = accountId => (dispatch, getState) => { id: accountId, }); - api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => { - dispatch(fetchSuggestionsRequest()); - - api(getState).get('/api/v2/suggestions').then(response => { - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(fetchSuggestionsSuccess(response.data)); - }).catch(error => dispatch(fetchSuggestionsFail(error))); - }).catch(() => {}); + api(getState).delete(`/api/v1/suggestions/${accountId}`).catch(() => {}); }; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 08561c71f4..786eb7e4b3 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -21,6 +21,10 @@ export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL'; +export const TIMELINE_INSERT = 'TIMELINE_INSERT'; + +export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions'; +export const TIMELINE_GAP = null; export const loadPending = timeline => ({ type: TIMELINE_LOAD_PENDING, @@ -112,9 +116,19 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); + if (timelineId === 'home' && !isLoadingMore && !isLoadingRecent) { + const now = new Date(); + const fittingIndex = response.data.findIndex(status => now - (new Date(status.created_at)) > 4 * 3600 * 1000); + + if (fittingIndex !== -1) { + dispatch(insertIntoTimeline(timelineId, TIMELINE_SUGGESTIONS, Math.max(1, fittingIndex))); + } + } + if (timelineId === 'home') { dispatch(submitMarkers()); } @@ -149,6 +163,7 @@ export const expandAccountTimeline = (accountId, { maxId, withReplies, t export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); +export const expandAntennaTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`antenna:${id}`, `/api/v1/timelines/antenna/${id}`, { max_id: maxId }, done); export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, @@ -163,6 +178,7 @@ export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia }, done); export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done); export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done); +export const fillAntennaTimelineGaps = (id, done = noOp) => fillTimelineGaps(`antenna:${id}`, `/api/v1/timelines/antenna/${id}`, {}, done); export function expandTimelineRequest(timeline, isLoadingMore) { return { @@ -221,3 +237,10 @@ export const markAsPartial = timeline => ({ type: TIMELINE_MARK_AS_PARTIAL, timeline, }); + +export const insertIntoTimeline = (timeline, key, index) => ({ + type: TIMELINE_INSERT, + timeline, + index, + key, +}); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index f262fd8570..de597a3e3b 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -29,9 +29,14 @@ const setCSRFHeader = () => { void ready(setCSRFHeader); +export const authorizationTokenFromState = (getState?: GetState) => { + return ( + getState && (getState().meta.get('access_token', '') as string | false) + ); +}; + const authorizationHeaderFromState = (getState?: GetState) => { - const accessToken = - getState && (getState().meta.get('access_token', '') as string); + const accessToken = authorizationTokenFromState(getState); if (!accessToken) { return {}; diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 5bf3e64288..41694af4b4 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -12,6 +12,32 @@ export interface ApiAccountRoleJSON { name: string; } +export interface ApiAccountOtherSettingsJSON { + noindex: boolean; + hide_network: boolean; + hide_statuses_count: boolean; + hide_following_count: boolean; + hide_followers_count: boolean; + translatable_private: boolean; + link_preview: boolean; + allow_quote: boolean; + emoji_reaction_policy: + | 'allow' + | 'outside_only' + | 'following_only' + | 'followers_only' + | 'mutuals_only' + | 'block'; + subscription_policy: 'allow' | 'followers_only' | 'block'; +} + +export interface ApiServerFeaturesJSON { + circle: boolean; + emoji_reaction: boolean; + quote: boolean; + status_reference: boolean; +} + // See app/serializers/rest/account_serializer.rb export interface ApiAccountJSON { acct: string; @@ -22,6 +48,7 @@ export interface ApiAccountJSON { discoverable: boolean; indexable: boolean; display_name: string; + emoji_reaction_available_server: boolean; emojis: ApiCustomEmojiJSON[]; fields: ApiAccountFieldJSON[]; followers_count: number; @@ -34,7 +61,10 @@ export interface ApiAccountJSON { locked: boolean; noindex?: boolean; note: string; + other_settings: ApiAccountOtherSettingsJSON; roles?: ApiAccountJSON[]; + server_features: ApiServerFeaturesJSON; + subscribable: boolean; statuses_count: number; uri: string; url: string; diff --git a/app/javascript/mastodon/api_types/custom_emoji.ts b/app/javascript/mastodon/api_types/custom_emoji.ts index 05144d6f68..9f25e6e410 100644 --- a/app/javascript/mastodon/api_types/custom_emoji.ts +++ b/app/javascript/mastodon/api_types/custom_emoji.ts @@ -5,4 +5,9 @@ export interface ApiCustomEmojiJSON { url: string; category?: string; visible_in_picker: boolean; + width?: number; + height?: number; + sensitive?: boolean; + aliases?: string[]; + license?: string; } diff --git a/app/javascript/mastodon/api_types/markers.ts b/app/javascript/mastodon/api_types/markers.ts new file mode 100644 index 0000000000..f7664fd7c1 --- /dev/null +++ b/app/javascript/mastodon/api_types/markers.ts @@ -0,0 +1,7 @@ +// See app/serializers/rest/account_serializer.rb + +export interface MarkerJSON { + last_read_id: string; + version: string; + updated_at: string; +} diff --git a/app/javascript/mastodon/api_types/media_attachments.ts b/app/javascript/mastodon/api_types/media_attachments.ts new file mode 100644 index 0000000000..fc027ccd2a --- /dev/null +++ b/app/javascript/mastodon/api_types/media_attachments.ts @@ -0,0 +1,22 @@ +// See app/serializers/rest/media_attachment_serializer.rb + +export type MediaAttachmentType = + | 'image' + | 'gifv' + | 'video' + | 'unknown' + | 'audio'; + +export interface ApiMediaAttachmentJSON { + id: string; + type: MediaAttachmentType; + url: string; + preview_url: string; + remoteUrl: string; + preview_remote_url: string; + text_url: string; + // TODO: how to define this? + meta: unknown; + description?: string; + blurhash: string; +} diff --git a/app/javascript/mastodon/api_types/polls.ts b/app/javascript/mastodon/api_types/polls.ts new file mode 100644 index 0000000000..8181f7b813 --- /dev/null +++ b/app/javascript/mastodon/api_types/polls.ts @@ -0,0 +1,23 @@ +import type { ApiCustomEmojiJSON } from './custom_emoji'; + +// See app/serializers/rest/poll_serializer.rb + +export interface ApiPollOptionJSON { + title: string; + votes_count: number; +} + +export interface ApiPollJSON { + id: string; + expires_at: string; + expired: boolean; + multiple: boolean; + votes_count: number; + voters_count: number; + + options: ApiPollOptionJSON[]; + emojis: ApiCustomEmojiJSON[]; + + voted: boolean; + own_votes: number[]; +} diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts new file mode 100644 index 0000000000..51dccd14d6 --- /dev/null +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -0,0 +1,97 @@ +// See app/serializers/rest/status_serializer.rb + +import type { ApiAccountJSON } from './accounts'; +import type { ApiCustomEmojiJSON } from './custom_emoji'; +import type { ApiMediaAttachmentJSON } from './media_attachments'; +import type { ApiPollJSON } from './polls'; + +// See app/modals/status.rb visibility+limited_scope +export type StatusVisibility = + | 'public' + | 'unlisted' + | 'private' + | 'direct' + | 'public_unlisted' + | 'login' + | 'mutual' + | 'circle' + | 'personal' + | 'reply' + | 'limited'; + +export interface ApiStatusApplicationJSON { + name: string; + website: string; +} + +export interface ApiTagJSON { + name: string; + url: string; +} + +export interface ApiMentionJSON { + id: string; + username: string; + url: string; + acct: string; +} + +export interface ApiPreviewCardJSON { + url: string; + title: string; + description: string; + language: string; + type: string; + author_name: string; + author_url: string; + provider_name: string; + provider_url: string; + html: string; + width: number; + height: number; + image: string; + image_description: string; + embed_url: string; + blurhash: string; + published_at: string; +} + +export interface ApiStatusJSON { + id: string; + created_at: string; + in_reply_to_id?: string; + in_reply_to_account_id?: string; + sensitive: boolean; + spoiler_text?: string; + visibility: StatusVisibility; + language: string; + uri: string; + url: string; + replies_count: number; + reblogs_count: number; + favorites_count: number; + edited_at?: string; + + favorited?: boolean; + reblogged?: boolean; + muted?: boolean; + bookmarked?: boolean; + pinned?: boolean; + + // filtered: FilterResult[] + filtered: unknown; // TODO + content?: string; + text?: string; + + reblog?: ApiStatusJSON; + application?: ApiStatusApplicationJSON; + account: ApiAccountJSON; + media_attachments: ApiMediaAttachmentJSON[]; + mentions: ApiMentionJSON[]; + + tags: ApiTagJSON[]; + emojis: ApiCustomEmojiJSON[]; + + card?: ApiPreviewCardJSON; + poll?: ApiPollJSON; +} diff --git a/app/javascript/mastodon/common.js b/app/javascript/mastodon/common.js index 0ec8449343..511568aa0f 100644 --- a/app/javascript/mastodon/common.js +++ b/app/javascript/mastodon/common.js @@ -2,7 +2,7 @@ import Rails from '@rails/ujs'; import 'font-awesome/css/font-awesome.css'; export function start() { - require.context('../images/', true); + require.context('../images/', true, /\.(jpg|png|svg)$/); try { Rails.start(); diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap index 1c37278483..dc955b7abe 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap @@ -9,7 +9,11 @@ exports[` renders emoji with custom url 1`] = ` className="emojione" src="http://example.com/emoji.png" /> - :foobar: +

+ :foobar: +
`; @@ -22,6 +26,10 @@ exports[` renders native emoji 1`] = ` className="emojione" src="/emoji/1f499.svg" /> - :foobar: +
+ :foobar: +
`; diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx index f82dd9153a..980dc9e100 100644 --- a/app/javascript/mastodon/components/account.jsx +++ b/app/javascript/mastodon/components/account.jsx @@ -1,17 +1,19 @@ import PropTypes from 'prop-types'; +import { useCallback } from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import { Link } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { EmptyAccount } from 'mastodon/components/empty_account'; import { ShortNumber } from 'mastodon/components/short_number'; import { VerifiedBadge } from 'mastodon/components/verified_badge'; +import DropdownMenuContainer from '../containers/dropdown_menu_container'; import { me } from '../initial_state'; import { Avatar } from './avatar'; @@ -30,151 +32,162 @@ const messages = defineMessages({ unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' }, mute: { id: 'account.mute_short', defaultMessage: 'Mute' }, block: { id: 'account.block_short', defaultMessage: 'Block' }, + more: { id: 'status.more', defaultMessage: 'More' }, }); -class Account extends ImmutablePureComponent { +const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, hideButtons, minimal, defaultAction, children, withBio }) => { + const intl = useIntl(); - static propTypes = { - size: PropTypes.number, - account: ImmutablePropTypes.record, - onFollow: PropTypes.func.isRequired, - onBlock: PropTypes.func.isRequired, - onMute: PropTypes.func.isRequired, - onMuteNotifications: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - hidden: PropTypes.bool, - minimal: PropTypes.bool, - defaultAction: PropTypes.string, - withBio: PropTypes.bool, - }; + const handleFollow = useCallback(() => { + onFollow(account); + }, [onFollow, account]); - static defaultProps = { - size: 46, - }; + const handleBlock = useCallback(() => { + onBlock(account); + }, [onBlock, account]); - handleFollow = () => { - this.props.onFollow(this.props.account); - }; + const handleMute = useCallback(() => { + onMute(account); + }, [onMute, account]); - handleBlock = () => { - this.props.onBlock(this.props.account); - }; + const handleMuteNotifications = useCallback(() => { + onMuteNotifications(account, true); + }, [onMuteNotifications, account]); - handleMute = () => { - this.props.onMute(this.props.account); - }; + const handleUnmuteNotifications = useCallback(() => { + onMuteNotifications(account, false); + }, [onMuteNotifications, account]); - handleMuteNotifications = () => { - this.props.onMuteNotifications(this.props.account, true); - }; - - handleUnmuteNotifications = () => { - this.props.onMuteNotifications(this.props.account, false); - }; - - render () { - const { account, intl, hidden, withBio, defaultAction, size, minimal } = this.props; - - if (!account) { - return ; - } - - if (hidden) { - return ( - <> - {account.get('display_name')} - {account.get('username')} - - ); - } - - let buttons; - - if (account.get('id') !== me && account.get('relationship', null) !== null) { - const following = account.getIn(['relationship', 'following']); - const requested = account.getIn(['relationship', 'requested']); - const blocking = account.getIn(['relationship', 'blocking']); - const muting = account.getIn(['relationship', 'muting']); - - if (requested) { - buttons =