diff --git a/.bundler-audit.yml b/.bundler-audit.yml deleted file mode 100644 index f84ec80872696e..00000000000000 --- a/.bundler-audit.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -ignore: - - CVE-2015-9284 # Mitigation following https://github.com/omniauth/omniauth/wiki/Resolving-CVE-2015-9284#mitigating-in-rails-applications diff --git a/.devcontainer/codespaces/devcontainer.json b/.devcontainer/codespaces/devcontainer.json new file mode 100644 index 00000000000000..d2358657f6d664 --- /dev/null +++ b/.devcontainer/codespaces/devcontainer.json @@ -0,0 +1,51 @@ +{ + "name": "Mastodon on GitHub Codespaces", + "dockerComposeFile": "../compose.yaml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + "features": { + "ghcr.io/devcontainers/features/sshd:1": {} + }, + + "runServices": ["app", "db", "redis"], + + "forwardPorts": [3000, 4000], + + "portsAttributes": { + "3000": { + "label": "web", + "onAutoForward": "notify" + }, + "4000": { + "label": "stream", + "onAutoForward": "silent" + } + }, + + "remoteUser": "root", + + "otherPortsAttributes": { + "onAutoForward": "silent" + }, + + "remoteEnv": { + "LOCAL_DOMAIN": "${localEnv:CODESPACE_NAME}-3000.app.github.dev", + "LOCAL_HTTPS": "true", + "STREAMING_API_BASE_URL": "https://${localEnv:CODESPACE_NAME}-4000.app.github.dev", + "DISABLE_FORGERY_REQUEST_PROTECTION": "true", + "ES_ENABLED": "", + "LIBRE_TRANSLATE_ENDPOINT": "" + }, + + "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "postCreateCommand": "bin/setup", + "waitFor": "postCreateCommand", + + "customizations": { + "vscode": { + "settings": {}, + "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"] + } + } +} diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml new file mode 100644 index 00000000000000..5c7263c87467d9 --- /dev/null +++ b/.devcontainer/compose.yaml @@ -0,0 +1,89 @@ +services: + app: + working_dir: /workspaces/mastodon/ + build: + context: .. + dockerfile: .devcontainer/Dockerfile + volumes: + - ..:/workspaces/mastodon:cached + environment: + RAILS_ENV: development + NODE_ENV: development + BIND: 0.0.0.0 + REDIS_HOST: redis + REDIS_PORT: '6379' + DB_HOST: db + DB_USER: postgres + DB_PASS: postgres + DB_PORT: '5432' + ES_ENABLED: 'true' + ES_HOST: es + ES_PORT: '9200' + LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000 + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + ports: + - '127.0.0.1:3000:3000' + - '127.0.0.1:3035:3035' + - '127.0.0.1:4000:4000' + networks: + - external_network + - internal_network + + db: + image: postgres:14-alpine + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_HOST_AUTH_METHOD: trust + networks: + - internal_network + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis-data:/data + networks: + - internal_network + + es: + image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2 + restart: unless-stopped + environment: + ES_JAVA_OPTS: -Xms512m -Xmx512m + cluster.name: es-mastodon + discovery.type: single-node + bootstrap.memory_lock: 'true' + volumes: + - es-data:/usr/share/elasticsearch/data + networks: + - internal_network + ulimits: + memlock: + soft: -1 + hard: -1 + + libretranslate: + image: libretranslate/libretranslate:v1.6.1 + restart: unless-stopped + volumes: + - lt-data:/home/libretranslate/.local + networks: + - external_network + - internal_network + +volumes: + postgres-data: + redis-data: + es-data: + lt-data: + +networks: + external_network: + internal_network: + internal: true diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml deleted file mode 100644 index 73fe22f3aabd3c..00000000000000 --- a/.devcontainer/docker-compose.yml +++ /dev/null @@ -1,90 +0,0 @@ -version: '3' - -services: - app: - build: - context: . - dockerfile: Dockerfile - volumes: - - ../..:/workspaces:cached - environment: - RAILS_ENV: development - NODE_ENV: development - BIND: 0.0.0.0 - REDIS_HOST: redis - REDIS_PORT: '6379' - DB_HOST: db - DB_USER: postgres - DB_PASS: postgres - DB_PORT: '5432' - ES_ENABLED: 'true' - ES_HOST: es - ES_PORT: '9200' - LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000 - # Overrides default command so things don't shut down after the process ends. - command: sleep infinity - ports: - - '127.0.0.1:3000:3000' - - '127.0.0.1:4000:4000' - - '127.0.0.1:80:3000' - networks: - - external_network - - internal_network - - db: - image: postgres:14-alpine - restart: unless-stopped - volumes: - - postgres-data:/var/lib/postgresql/data - environment: - POSTGRES_USER: postgres - POSTGRES_DB: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_HOST_AUTH_METHOD: trust - networks: - - internal_network - - redis: - image: redis:7-alpine - restart: unless-stopped - volumes: - - redis-data:/data - networks: - - internal_network - - es: - image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2 - restart: unless-stopped - environment: - ES_JAVA_OPTS: -Xms512m -Xmx512m - cluster.name: es-mastodon - discovery.type: single-node - bootstrap.memory_lock: 'true' - volumes: - - es-data:/usr/share/elasticsearch/data - networks: - - internal_network - ulimits: - memlock: - soft: -1 - hard: -1 - - libretranslate: - image: libretranslate/libretranslate:v1.3.10 - restart: unless-stopped - volumes: - - lt-data:/home/libretranslate/.local - networks: - - external_network - - internal_network - -volumes: - postgres-data: - redis-data: - es-data: - lt-data: - -networks: - external_network: - internal_network: - internal: true diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh deleted file mode 100755 index a075cc7b3bc298..00000000000000 --- a/.devcontainer/post-create.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -set -e # Fail the whole script on first error - -# Fetch Ruby gem dependencies -bundle config path 'vendor/bundle' -bundle config with 'development test' -bundle install - -# Make Gemfile.lock pristine again -git checkout -- Gemfile.lock - -# Fetch Javascript dependencies -yarn --frozen-lockfile - -# [re]create, migrate, and seed the test database -RAILS_ENV=test ./bin/rails db:setup - -# [re]create, migrate, and seed the development database -RAILS_ENV=development ./bin/rails db:setup - -# Precompile assets for development -RAILS_ENV=development ./bin/rails assets:precompile - -# Precompile assets for test -RAILS_ENV=test NODE_ENV=tests ./bin/rails assets:precompile diff --git a/.env.development b/.env.development new file mode 100644 index 00000000000000..0330da83776199 --- /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/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml new file mode 100644 index 00000000000000..f897a7d7da434e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml @@ -0,0 +1,77 @@ +name: Bug Report (Web Interface) +description: There is a problem using Mastodon's web interface. +labels: ['status/to triage', 'area/web interface'] +type: Bug +body: + - type: markdown + attributes: + value: | + Make sure that you are submitting a new bug that was not previously reported or already fixed. + + Please use a concise and distinct title for the issue. + - type: textarea + attributes: + label: Steps to reproduce the problem + description: What were you trying to do? + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: input + attributes: + label: Expected behaviour + description: What should have happened? + validations: + required: true + - type: input + attributes: + label: Actual behaviour + description: What happened? + validations: + required: true + - type: textarea + attributes: + label: Detailed description + validations: + required: false + - type: input + attributes: + label: Mastodon instance + description: The address of the Mastodon instance where you experienced the issue + placeholder: mastodon.social + validations: + required: true + - type: input + attributes: + label: Mastodon version + description: | + This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` + placeholder: v4.3.0 + validations: + required: 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 131.0.0 + validations: + required: true + - type: input + attributes: + label: Operating system + description: | + What OS are you running? Please specify the version as well. + placeholder: macOS 15.0.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.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml new file mode 100644 index 00000000000000..a66f5c1076d504 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml @@ -0,0 +1,66 @@ +name: Bug Report (server / API) +description: | + There is a problem with the HTTP server, REST API, ActivityPub interaction, etc. +labels: ['status/to triage'] +type: 'Bug' +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.4.0-alpha.1` + placeholder: v4.3.0 + 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.3.5) + - Node.js version: (from `node --version`, eg. v20.18.0) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml new file mode 100644 index 00000000000000..eeb74b160b1040 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml @@ -0,0 +1,74 @@ +name: Deployment troubleshooting +description: | + You are a server administrator and you are encountering a technical issue during installation, upgrade or operations of Mastodon. +labels: ['status/to triage'] +type: 'Troubleshooting' +body: + - type: markdown + attributes: + value: | + Make sure that you are submitting a new bug that was not previously reported or already fixed. + + Please use a concise and distinct title for the issue. + - type: textarea + attributes: + label: Steps to reproduce the problem + description: What were you trying to do? + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: input + attributes: + label: Expected behaviour + description: What should have happened? + validations: + required: true + - type: input + attributes: + label: Actual behaviour + description: What happened? + validations: + required: true + - type: textarea + attributes: + label: Detailed description + validations: + required: false + - type: input + attributes: + label: Mastodon instance + description: The address of the Mastodon instance where you experienced the issue + placeholder: mastodon.social + validations: + required: true + - type: input + attributes: + label: Mastodon version + description: | + This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` + placeholder: v4.3.0 + validations: + required: false + - type: textarea + attributes: + label: Environment + description: | + Details about your environment, like how Mastodon is deployed, if containers are used, version numbers, etc. + value: | + Please at least include those informations: + - Operating system: (eg. Ubuntu 22.04) + - Ruby version: (from `ruby --version`, eg. v3.3.5) + - Node.js version: (from `node --version`, eg. v20.18.0) + validations: + required: false + - type: textarea + attributes: + label: Technical details + description: | + Any additional technical details you may have, like logs or error traces + validations: + required: false diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml new file mode 100644 index 00000000000000..808adc7de64f96 --- /dev/null +++ b/.github/actions/setup-javascript/action.yml @@ -0,0 +1,42 @@ +name: 'Setup Javascript' +description: 'Setup a Javascript environment ready to run the Mastodon code' +inputs: + onlyProduction: + description: Only install production dependencies + default: 'false' + +runs: + using: 'composite' + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + # The following is needed because we can not use `cache: true` for `setup-node`, as it does not support Corepack yet and mess up with the cache location if ran after Node is installed + - name: Enable corepack + shell: bash + run: corepack enable + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + shell: bash + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - 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 }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install all yarn packages + shell: bash + run: yarn install --immutable + if: inputs.onlyProduction == 'false' + + - name: Install all production yarn packages + shell: bash + run: yarn workspaces focus --production + if: inputs.onlyProduction != 'false' diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml new file mode 100644 index 00000000000000..3e232f134c9422 --- /dev/null +++ b/.github/actions/setup-ruby/action.yml @@ -0,0 +1,23 @@ +name: 'Setup RUby' +description: 'Setup a Ruby environment ready to run the Mastodon code' +inputs: + ruby-version: + description: The Ruby version to install + default: '.ruby-version' + additional-system-dependencies: + description: 'Additional packages to install' + +runs: + using: 'composite' + steps: + - name: Install system dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }} + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ inputs.ruby-version }} + bundler-cache: true diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 00000000000000..21af6d0d457da9 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,13 @@ +comment: false # Do not leave PR comments +coverage: + status: + project: + default: + # GitHub status check is not blocking + informational: true + patch: + default: + # GitHub status check is not blocking + informational: true +github_checks: + annotations: false diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 00000000000000..8a106762838bcb --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,158 @@ +{ + $schema: 'https://docs.renovatebot.com/renovate-schema.json', + extends: [ + 'config:recommended', + 'customManagers:dockerfileVersions', + ':labels(dependencies)', + ':prConcurrentLimitNone', // Remove limit for open PRs at any time. + ':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour. + ], + rebaseWhen: 'conflicted', + minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it + // packageRules order is important, they are applied from top to bottom and are merged, + // meaning the most important ones must be at the bottom, for example grouping rules + // If we do not want a package to be grouped with others, we need to set its groupName + // to `null` after any other rule set it to something. + dependencyDashboardHeader: 'This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. Before approving any upgrade: read the description and comments in the [`renovate.json5` file](https://github.com/mastodon/mastodon/blob/main/.github/renovate.json5).', + postUpdateOptions: ['yarnDedupeHighest'], + packageRules: [ + { + // Require Dependency Dashboard Approval for major version bumps of these node packages + matchManagers: ['npm'], + matchPackageNames: [ + 'tesseract.js', // Requires code changes + 'react-hotkeys', // Requires code changes + + // Requires Webpacker upgrade or replacement + '@svgr/webpack', + '@types/webpack', + 'babel-loader', + 'compression-webpack-plugin', + 'css-loader', + 'imports-loader', + 'mini-css-extract-plugin', + 'postcss-loader', + 'sass-loader', + 'terser-webpack-plugin', + 'webpack', + 'webpack-assets-manifest', + 'webpack-bundle-analyzer', + 'webpack-dev-server', + 'webpack-cli', + + // react-router: Requires manual upgrade + 'history', + 'react-router-dom', + ], + matchUpdateTypes: ['major'], + dependencyDashboardApproval: true, + }, + { + // Require Dependency Dashboard Approval for major version bumps of these Ruby packages + matchManagers: ['bundler'], + matchPackageNames: [ + 'rack', // Needs to be synced with Rails version + 'strong_migrations', // Requires manual upgrade + 'sidekiq', // Requires manual upgrade + 'sidekiq-unique-jobs', // Requires manual upgrades and sync with Sidekiq version + 'redis', // Requires manual upgrade and sync with Sidekiq version + ], + matchUpdateTypes: ['major'], + dependencyDashboardApproval: true, + }, + { + // Update GitHub Actions and Docker images weekly + matchManagers: ['github-actions', 'dockerfile', 'docker-compose'], + extends: ['schedule:weekly'], + }, + { + // Require Dependency Dashboard Approval for major & minor bumps for the ruby image, this needs to be synced with .ruby-version + matchManagers: ['dockerfile'], + matchPackageNames: ['moritzheiber/ruby-jemalloc'], + matchUpdateTypes: ['minor', 'major'], + dependencyDashboardApproval: true, + }, + { + // Require Dependency Dashboard Approval for major bumps for the node image, this needs to be synced with .nvmrc + matchManagers: ['dockerfile'], + matchPackageNames: ['node'], + matchUpdateTypes: ['major'], + dependencyDashboardApproval: true, + }, + { + // Require Dependency Dashboard Approval for major postgres bumps in the docker-compose file, as those break dev environments + matchManagers: ['docker-compose'], + matchPackageNames: ['postgres'], + matchUpdateTypes: ['major'], + dependencyDashboardApproval: true, + }, + { + // Update devDependencies every week, with one grouped PR + matchManagers: ['npm'], + matchDepTypes: 'devDependencies', + matchUpdateTypes: ['patch', 'minor'], + groupName: 'devDependencies (non-major)', + extends: ['schedule:weekly'], + }, + { + // Group all eslint-related packages with `eslint` in the same PR + matchManagers: ['npm'], + matchPackageNames: ['eslint', 'eslint-*', '@typescript-eslint/*'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'eslint (non-major)', + }, + { + // Group actions/*-artifact in the same PR + matchManagers: ['github-actions'], + matchPackageNames: [ + 'actions/download-artifact', + 'actions/upload-artifact', + ], + matchUpdateTypes: ['major'], + groupName: 'artifact actions (major)', + }, + { + // Update @types/* packages every week, with one grouped PR + matchManagers: ['npm'], + matchPackageNames: '@types/*', + matchUpdateTypes: ['patch', 'minor'], + groupName: 'DefinitelyTyped types (non-major)', + extends: ['schedule:weekly'], + addLabels: ['typescript'], + }, + { + // We want those packages to always have their own PR + matchManagers: ['npm'], + matchPackageNames: [ + 'typescript', // Typescript has code-impacting changes in minor versions + ], + groupName: null, // We dont want them to belong to any group + }, + { + // Group all RuboCop packages with `rubocop` in the same PR + matchManagers: ['bundler'], + matchPackageNames: ['rubocop', 'rubocop-*'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'RuboCop (non-major)', + }, + { + // Group all RSpec packages with `rspec` in the same PR + matchManagers: ['bundler'], + matchPackageNames: ['rspec', 'rspec-*'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'RSpec (non-major)', + }, + { + // Group all opentelemetry-ruby packages in the same PR + matchManagers: ['bundler'], + matchPackageNames: ['opentelemetry-*'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'opentelemetry-ruby (non-major)', + }, + // Add labels depending on package manager + { matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] }, + { matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] }, + { matchManagers: ['docker-compose', 'dockerfile'], addLabels: ['docker'] }, + { matchManagers: ['github-actions'], addLabels: ['github_actions'] }, + ], +} diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml new file mode 100644 index 00000000000000..03a0f5bf37b81b --- /dev/null +++ b/.github/workflows/build-container-image.yml @@ -0,0 +1,102 @@ +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@v6 + 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-image.yml b/.github/workflows/build-image.yml deleted file mode 100644 index ff8f0404e49b1c..00000000000000 --- a/.github/workflows/build-image.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Build container image -on: - workflow_dispatch: - push: - branches: - - 'main' - tags: - - '*' -jobs: - build-image: - runs-on: ubuntu-latest - - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - steps: - - uses: actions/checkout@v3 - - uses: hadolint/hadolint-action@v3.1.0 - - uses: docker/setup-qemu-action@v2 - - uses: docker/setup-buildx-action@v2 - - - name: Log in to the Github Container registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - if: github.event_name != 'pull_request' - - - uses: docker/metadata-action@v4 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/mastodon - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=edge,branch=main - type=sha,prefix=,format=long - - - uses: docker/build-push-action@v4 - with: - context: . - platforms: linux/amd64,linux/arm64 - provenance: false - builder: ${{ steps.buildx.outputs.name }} - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml new file mode 100644 index 00000000000000..0ea553cfda2335 --- /dev/null +++ b/.github/workflows/build-nightly.yml @@ -0,0 +1,64 @@ +name: Build nightly container image +on: + workflow_dispatch: + schedule: + - cron: '0 2 * * *' # run at 2 AM UTC + +permissions: + contents: read + packages: write + +jobs: + compute-suffix: + runs-on: ubuntu-latest + if: github.repository == 'glitch-soc/mastodon' + steps: + - id: version_vars + env: + TZ: Etc/UTC + run: | + echo mastodon_version_prerelease=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT + outputs: + prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }} + + build-image: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: false + cache: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + tags: | + type=raw,value=edge + type=raw,value=nightly + type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit + + build-image-streaming: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: streaming/Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: false + cache: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon-streaming + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + tags: | + type=raw,value=edge + type=raw,value=nightly + type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml new file mode 100644 index 00000000000000..d6324bad89259a --- /dev/null +++ b/.github/workflows/build-push-pr.yml @@ -0,0 +1,62 @@ +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 ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT + echo mastodon_short_sha=$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT + outputs: + metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }} + short_sha: ${{ steps.version_vars.outputs.mastodon_short_sha }} + + 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: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon + version_metadata: ${{ needs.compute-suffix.outputs.metadata }} + flavor: | + latest=auto + tags: | + type=ref,event=pr + type=ref,event=pr,suffix=-${{ needs.compute-suffix.outputs.short_sha }} + 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: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon-streaming + version_metadata: ${{ needs.compute-suffix.outputs.metadata }} + flavor: | + latest=auto + tags: | + type=ref,event=pr + type=ref,event=pr,suffix=-${{ needs.compute-suffix.outputs.short_sha }} + secrets: inherit diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml new file mode 100644 index 00000000000000..808ffe115d952e --- /dev/null +++ b/.github/workflows/build-releases.yml @@ -0,0 +1,49 @@ +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: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/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.3.') }} + 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: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/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-security.yml b/.github/workflows/build-security.yml new file mode 100644 index 00000000000000..37c8ba59a37d54 --- /dev/null +++ b/.github/workflows/build-security.yml @@ -0,0 +1,61 @@ +name: Build security nightly container image +on: + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + compute-suffix: + runs-on: ubuntu-latest + steps: + - id: version_vars + env: + TZ: Etc/UTC + run: | + echo mastodon_version_prerelease=nightly.$(date --date='next day' +'%Y-%m-%d')-security>> $GITHUB_OUTPUT + outputs: + prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }} + + build-image: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: false + cache: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + tags: | + type=raw,value=edge + type=raw,value=nightly + type=raw,value=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit + + build-image-streaming: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: streaming/Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: false + cache: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon-streaming + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + tags: | + type=raw,value=edge + type=raw,value=nightly + type=raw,value=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml new file mode 100644 index 00000000000000..2341d6e67f6241 --- /dev/null +++ b/.github/workflows/bundler-audit.yml @@ -0,0 +1,39 @@ +name: Bundler Audit +on: + merge_group: + push: + branches: + - 'main' + - 'stable-*' + paths: + - 'Gemfile*' + - '.ruby-version' + - '.github/workflows/bundler-audit.yml' + + pull_request: + paths: + - 'Gemfile*' + - '.ruby-version' + - '.github/workflows/bundler-audit.yml' + + schedule: + - cron: '0 5 * * 1' + +jobs: + security: + runs-on: ubuntu-latest + + env: + BUNDLE_ONLY: development + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run bundler-audit + run: bundle exec bundler-audit check --update diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml new file mode 100644 index 00000000000000..18911d49c2ff25 --- /dev/null +++ b/.github/workflows/crowdin-download-stable.yml @@ -0,0 +1,70 @@ +name: Crowdin / Download translations (stable branches) +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + download-translations-stable: + runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Increase Git http.postBuffer + # This is needed due to a bug in Ubuntu's cURL version? + # See https://github.com/orgs/community/discussions/55820 + run: | + git config --global http.version HTTP/1.1 + git config --global http.postBuffer 157286400 + + # Download the translation files from Crowdin + - name: crowdin action + uses: crowdin/github-action@v2 + with: + config: crowdin-glitch.yml + upload_sources: false + upload_translations: false + download_translations: true + crowdin_branch_name: ${{ github.base_ref || github.ref_name }} + push_translations: false + create_pull_request: false + env: + CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + # As the files are extracted from a Docker container, they belong to root:root + # We need to fix this before the next steps + - name: Fix file permissions + run: sudo chown -R runner:docker . + + # This is needed to run the normalize step + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Run i18n normalize task + run: bundle exec i18n-tasks normalize + + # Create or update the pull request + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7.0.5 + with: + commit-message: 'New Crowdin translations' + title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' + author: 'GitHub Actions ' + body: | + New Crowdin translations, automated with GitHub Actions + + See `.github/workflows/crowdin-download.yml` + + This PR will be updated every day with new translations. + + Due to a limitation in GitHub Actions, checks are not running on this PR without manual action. + If you want to run the checks, then close and re-open it. + branch: i18n/crowdin/translations-${{ github.base_ref || github.ref_name }} + base: ${{ github.base_ref || github.ref_name }} + labels: i18n diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml new file mode 100644 index 00000000000000..02d8a949dec541 --- /dev/null +++ b/.github/workflows/crowdin-download.yml @@ -0,0 +1,72 @@ +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 == 'glitch-soc/mastodon' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Increase Git http.postBuffer + # This is needed due to a bug in Ubuntu's cURL version? + # See https://github.com/orgs/community/discussions/55820 + run: | + git config --global http.version HTTP/1.1 + git config --global http.postBuffer 157286400 + + # Download the translation files from Crowdin + - name: crowdin action + uses: crowdin/github-action@v2 + with: + config: crowdin-glitch.yml + 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@v7.0.5 + 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 new file mode 100644 index 00000000000000..c48668d3bc3e55 --- /dev/null +++ b/.github/workflows/crowdin-upload.yml @@ -0,0 +1,38 @@ +name: Crowdin / Upload translations + +on: + push: + branches: + - 'main' + - 'stable-*' + paths: + - crowdin-glitch.yml + - app/javascript/flavours/glitch/locales/en.json + - config/locales-glitch/en.yml + - config/locales-glitch/simple_form.en.yml + - config/locales-glitch/activerecord.en.yml + - config/locales-glitch/devise.en.yml + - config/locales-glitch/doorkeeper.en.yml + - .github/workflows/crowdin-upload.yml + +jobs: + upload-translations: + runs-on: ubuntu-latest + if: github.repository == 'glitch-soc/mastodon' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: crowdin action + uses: crowdin/github-action@v2 + with: + config: crowdin-glitch.yml + upload_sources: true + upload_translations: false + download_translations: false + crowdin_branch_name: ${{ github.base_ref || github.ref_name }} + + 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 00000000000000..c10f350a02ef28 --- /dev/null +++ b/.github/workflows/format-check.yml @@ -0,0 +1,22 @@ +name: Check formatting +on: + merge_group: + push: + branches: + - 'main' + - 'stable-*' + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Check formatting with Prettier + run: yarn format:check diff --git a/.github/workflows/lint-json.yml b/.github/workflows/lint-json.yml deleted file mode 100644 index 98f101ad95aa69..00000000000000 --- a/.github/workflows/lint-json.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: JSON Linting -on: - push: - branches-ignore: - - 'dependabot/**' - 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@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - cache: yarn - node-version-file: '.nvmrc' - - - name: Install all yarn packages - run: yarn --frozen-lockfile - - - name: Prettier - run: yarn prettier --check "**/*.json" diff --git a/.github/workflows/lint-md.yml b/.github/workflows/lint-md.yml deleted file mode 100644 index 6f76dd60c2fca6..00000000000000 --- a/.github/workflows/lint-md.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Markdown Linting -on: - push: - branches-ignore: - - 'dependabot/**' - paths: - - '.github/workflows/lint-md.yml' - - '.prettier*' - - '**/*.md' - - '!AUTHORS.md' - - 'package.json' - - 'yarn.lock' - - pull_request: - paths: - - '.github/workflows/lint-md.yml' - - '.prettier*' - - '**/*.md' - - '!AUTHORS.md' - - 'package.json' - - 'yarn.lock' - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - cache: yarn - - - name: Install all yarn packages - run: yarn --frozen-lockfile - - - name: Prettier - run: yarn prettier --check "**/*.md" diff --git a/.github/workflows/lint-yml.yml b/.github/workflows/lint-yml.yml deleted file mode 100644 index 6f79babcfd45d2..00000000000000 --- a/.github/workflows/lint-yml.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: YML Linting -on: - push: - branches-ignore: - - 'dependabot/**' - 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@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - cache: yarn - node-version-file: '.nvmrc' - - - name: Install all yarn packages - run: yarn --frozen-lockfile - - - name: Prettier - run: yarn prettier --check "**/*.{yml,yaml}" diff --git a/.github/workflows/test-image-build.yml b/.github/workflows/test-image-build.yml new file mode 100644 index 00000000000000..980e071897c911 --- /dev/null +++ b/.github/workflows/test-image-build.yml @@ -0,0 +1,35 @@ +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-migrations-one-step.yml b/.github/workflows/test-migrations-one-step.yml deleted file mode 100644 index d7e424a8c46782..00000000000000 --- a/.github/workflows/test-migrations-one-step.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Test one step migrations -on: - push: - branches-ignore: - - 'dependabot/**' - pull_request: - -jobs: - pre_job: - runs-on: ubuntu-latest - - outputs: - should_skip: ${{ steps.skip_check.outputs.should_skip }} - - steps: - - id: skip_check - uses: fkirc/skip-duplicate-actions@v5 - with: - paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations-one-step.yml", "lib/tasks/tests.rake"]' - - test: - runs-on: ubuntu-latest - needs: pre_job - if: needs.pre_job.outputs.should_skip != 'true' - - 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: - CONTINUOUS_INTEGRATION: true - DB_HOST: localhost - DB_USER: postgres - DB_PASS: postgres - DISABLE_SIMPLECOV: true - RAILS_ENV: test - BUNDLE_CLEAN: true - BUNDLE_FROZEN: true - BUNDLE_WITHOUT: 'development production' - BUNDLE_JOBS: 3 - BUNDLE_RETRY: 3 - - steps: - - uses: actions/checkout@v3 - - - name: Install native Ruby dependencies - run: | - sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev - - - name: Set up bundler cache - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Create database - run: './bin/rails db:create' - - - name: Run migrations up to v2.0.0 - run: './bin/rails db:migrate VERSION=20171010025614' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2' - - - name: Run migrations up to v2.4.0 - run: './bin/rails db:migrate VERSION=20180514140000' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4' - - - name: Run migrations up to v2.4.3 - run: './bin/rails db:migrate VERSION=20180707154237' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4_3' - - - name: Run all remaining migrations - run: './bin/rails db:migrate' - - - name: Check migration result - run: './bin/rails tests:migrations:check_database' diff --git a/.github/workflows/test-migrations-two-step.yml b/.github/workflows/test-migrations-two-step.yml deleted file mode 100644 index 25bf5ba87f164d..00000000000000 --- a/.github/workflows/test-migrations-two-step.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: Test two step migrations -on: - push: - branches-ignore: - - 'dependabot/**' - pull_request: - -jobs: - pre_job: - runs-on: ubuntu-latest - - outputs: - should_skip: ${{ steps.skip_check.outputs.should_skip }} - - steps: - - id: skip_check - uses: fkirc/skip-duplicate-actions@v5 - with: - paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations-two-step.yml", "lib/tasks/tests.rake"]' - - test: - runs-on: ubuntu-latest - needs: pre_job - if: needs.pre_job.outputs.should_skip != 'true' - - 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: - CONTINUOUS_INTEGRATION: true - DB_HOST: localhost - DB_USER: postgres - DB_PASS: postgres - DISABLE_SIMPLECOV: true - RAILS_ENV: test - BUNDLE_CLEAN: true - BUNDLE_FROZEN: true - BUNDLE_WITHOUT: 'development production' - BUNDLE_JOBS: 3 - BUNDLE_RETRY: 3 - - steps: - - uses: actions/checkout@v3 - - - name: Install native Ruby dependencies - run: | - sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev - - - name: Set up bundler cache - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Create database - run: './bin/rails db:create' - - - name: Run migrations up to v2.0.0 - run: './bin/rails db:migrate VERSION=20171010025614' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2' - - - name: Run pre-deployment migrations up to v2.4.0 - run: './bin/rails db:migrate VERSION=20180514140000' - env: - SKIP_POST_DEPLOYMENT_MIGRATIONS: true - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4' - - - name: Run migrations up to v2.4.3 - run: './bin/rails db:migrate VERSION=20180707154237' - env: - SKIP_POST_DEPLOYMENT_MIGRATIONS: true - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4_3' - - - name: Run all remaining pre-deployment migrations - run: './bin/rails db:migrate' - env: - SKIP_POST_DEPLOYMENT_MIGRATIONS: true - - - name: Run all post-deployment migrations - run: './bin/rails db:migrate' - - - name: Check migration result - run: './bin/rails tests:migrations:check_database' diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml new file mode 100644 index 00000000000000..5b80fef03724db --- /dev/null +++ b/.github/workflows/test-migrations.yml @@ -0,0 +1,95 @@ +name: Historical data migration test + +on: + merge_group: + push: + branches: + - 'main' + - 'stable-*' + paths: + - 'Gemfile*' + - '.ruby-version' + - '**/*.rb' + - '.github/workflows/test-migrations.yml' + - 'lib/tasks/tests.rake' + + pull_request: + paths: + - 'Gemfile*' + - '.ruby-version' + - '**/*.rb' + - '.github/workflows/test-migrations.yml' + - 'lib/tasks/tests.rake' + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + + matrix: + postgres: + - 14-alpine + - 15-alpine + - 16-alpine + - 17-alpine + + services: + postgres: + image: postgres:${{ matrix.postgres}} + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10ms + --health-timeout 3s + --health-retries 50 + ports: + - 6379:6379 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + DISABLE_SIMPLECOV: true + RAILS_ENV: test + BUNDLE_CLEAN: true + BUNDLE_FROZEN: true + BUNDLE_WITHOUT: 'development:production' + BUNDLE_JOBS: 3 + BUNDLE_RETRY: 3 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Test "one step migration" flow + run: | + bin/rails db:drop + bin/rails db:create + bin/rails tests:migrations:prepare_database + bin/rails db:migrate + bin/rails tests:migrations:check_database + + - name: Test "two step migration" flow + run: | + bin/rails db:drop + bin/rails db:create + SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database + SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate + bin/rails db:migrate + bin/rails tests:migrations:check_database diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml deleted file mode 100644 index c6016839078150..00000000000000 --- a/.haml-lint_todo.yml +++ /dev/null @@ -1,106 +0,0 @@ -# This configuration was generated by -# `haml-lint --auto-gen-config` -# on 2023-03-15 00:55:01 -0400 using Haml-Lint version 0.45.0. -# The point is for the user to remove these configuration records -# one by one as the lints are removed from the code base. -# Note that changes in the inspected code, or installation of new -# versions of Haml-Lint, may require this file to be generated again. - -linters: - # Offense count: 63 - RuboCop: - exclude: - - 'app/views/accounts/_og.html.haml' - - 'app/views/admin/account_warnings/_account_warning.html.haml' - - 'app/views/admin/accounts/index.html.haml' - - 'app/views/admin/accounts/show.html.haml' - - 'app/views/admin/announcements/edit.html.haml' - - 'app/views/admin/announcements/new.html.haml' - - 'app/views/admin/disputes/appeals/_appeal.html.haml' - - 'app/views/admin/domain_blocks/edit.html.haml' - - 'app/views/admin/domain_blocks/new.html.haml' - - 'app/views/admin/ip_blocks/new.html.haml' - - 'app/views/admin/reports/actions/preview.html.haml' - - 'app/views/admin/reports/index.html.haml' - - 'app/views/admin/reports/show.html.haml' - - 'app/views/admin/roles/_form.html.haml' - - 'app/views/admin/settings/about/show.html.haml' - - 'app/views/admin/settings/appearance/show.html.haml' - - 'app/views/admin/settings/registrations/show.html.haml' - - 'app/views/admin/statuses/show.html.haml' - - 'app/views/auth/registrations/new.html.haml' - - 'app/views/disputes/strikes/show.html.haml' - - 'app/views/filters/_filter_fields.html.haml' - - 'app/views/invites/_form.html.haml' - - 'app/views/layouts/application.html.haml' - - 'app/views/layouts/error.html.haml' - - 'app/views/notification_mailer/_status.html.haml' - - 'app/views/settings/applications/_fields.html.haml' - - 'app/views/settings/imports/show.html.haml' - - 'app/views/settings/preferences/appearance/show.html.haml' - - 'app/views/settings/preferences/other/show.html.haml' - - 'app/views/statuses/_detailed_status.html.haml' - - 'app/views/statuses/_poll.html.haml' - - 'app/views/statuses/show.html.haml' - - 'app/views/statuses_cleanup/show.html.haml' - - 'app/views/user_mailer/warning.html.haml' - - # Offense count: 913 - LineLength: - enabled: false - - # Offense count: 22 - UnnecessaryStringOutput: - exclude: - - 'app/views/accounts/show.html.haml' - - 'app/views/admin/custom_emojis/_custom_emoji.html.haml' - - 'app/views/admin/relays/_relay.html.haml' - - 'app/views/admin/rules/_rule.html.haml' - - 'app/views/admin/statuses/index.html.haml' - - 'app/views/auth/registrations/_sessions.html.haml' - - 'app/views/disputes/strikes/show.html.haml' - - 'app/views/notification_mailer/_status.html.haml' - - 'app/views/settings/two_factor_authentication_methods/index.html.haml' - - 'app/views/statuses/_detailed_status.html.haml' - - 'app/views/statuses/_poll.html.haml' - - 'app/views/statuses/_simple_status.html.haml' - - 'app/views/user_mailer/suspicious_sign_in.html.haml' - - 'app/views/user_mailer/webauthn_credential_added.html.haml' - - 'app/views/user_mailer/webauthn_credential_deleted.html.haml' - - 'app/views/user_mailer/welcome.html.haml' - - # Offense count: 3 - ViewLength: - exclude: - - 'app/views/admin/accounts/show.html.haml' - - 'app/views/admin/reports/show.html.haml' - - 'app/views/disputes/strikes/show.html.haml' - - # Offense count: 41 - InstanceVariables: - exclude: - - 'app/views/admin/reports/_actions.html.haml' - - 'app/views/admin/roles/_form.html.haml' - - 'app/views/admin/webhooks/_form.html.haml' - - 'app/views/auth/registrations/_sessions.html.haml' - - 'app/views/auth/registrations/_status.html.haml' - - 'app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml' - - 'app/views/authorize_interactions/_post_follow_actions.html.haml' - - 'app/views/invites/_form.html.haml' - - 'app/views/relationships/_account.html.haml' - - 'app/views/shared/_og.html.haml' - - 'app/views/statuses/_status.html.haml' - - # Offense count: 6 - ConsecutiveSilentScripts: - exclude: - - 'app/views/admin/settings/shared/_links.html.haml' - - 'app/views/settings/login_activities/_login_activity.html.haml' - - 'app/views/statuses/_poll.html.haml' - - # Offense count: 3 - IdNames: - exclude: - - 'app/views/authorize_interactions/error.html.haml' - - 'app/views/oauth/authorizations/error.html.haml' - - 'app/views/shared/_error_messages.html.haml' diff --git a/.nanoignore b/.nanoignore deleted file mode 100644 index 80e93970355ce3..00000000000000 --- a/.nanoignore +++ /dev/null @@ -1,19 +0,0 @@ -.DS_Store -.git/ -.gitignore - -.bundle/ -.cache/ -config/deploy/* -coverage -docs/ -.env -log/*.log -neo4j/ -node_modules/ -public/assets/ -public/system/ -spec/ -tmp/ -.vagrant/ -vendor/bundle/ diff --git a/.profile b/.profile deleted file mode 100644 index c6d57b609d3b87..00000000000000 --- a/.profile +++ /dev/null @@ -1 +0,0 @@ -LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio diff --git a/.rubocop/custom.yml b/.rubocop/custom.yml new file mode 100644 index 00000000000000..63035837f86119 --- /dev/null +++ b/.rubocop/custom.yml @@ -0,0 +1,6 @@ +--- +require: + - ../lib/linter/rubocop_middle_dot + +Style/MiddleDot: + Enabled: true diff --git a/.rubocop/layout.yml b/.rubocop/layout.yml new file mode 100644 index 00000000000000..487879ca2c1134 --- /dev/null +++ b/.rubocop/layout.yml @@ -0,0 +1,6 @@ +--- +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +Layout/LineLength: + Max: 300 # Default of 120 causes a duplicate entry in generated todo file diff --git a/.rubocop/metrics.yml b/.rubocop/metrics.yml new file mode 100644 index 00000000000000..89532af42abb15 --- /dev/null +++ b/.rubocop/metrics.yml @@ -0,0 +1,23 @@ +--- +Metrics/AbcSize: + Exclude: + - lib/mastodon/cli/*.rb + +Metrics/BlockLength: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CyclomaticComplexity: + Exclude: + - lib/mastodon/cli/*.rb + +Metrics/MethodLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + +Metrics/ParameterLists: + CountKeywordArgs: false diff --git a/.rubocop/naming.yml b/.rubocop/naming.yml new file mode 100644 index 00000000000000..da6ad4ac579a63 --- /dev/null +++ b/.rubocop/naming.yml @@ -0,0 +1,3 @@ +--- +Naming/BlockForwarding: + EnforcedStyle: explicit diff --git a/.rubocop/rails.yml b/.rubocop/rails.yml new file mode 100644 index 00000000000000..ae31c1f266142e --- /dev/null +++ b/.rubocop/rails.yml @@ -0,0 +1,23 @@ +--- +Rails/BulkChangeTable: + Enabled: false # Conflicts with strong_migrations features + +Rails/FilePath: + EnforcedStyle: arguments + +Rails/HttpStatus: + EnforcedStyle: numeric + +Rails/NegateInclude: + Enabled: false + +Rails/RakeEnvironment: + Exclude: # Tasks are doing local work which do not need full env loaded + - lib/tasks/auto_annotate_models.rake + - lib/tasks/emojis.rake + - lib/tasks/mastodon.rake + - lib/tasks/repo.rake + - lib/tasks/statistics.rake + +Rails/SkipsModelValidations: + Enabled: false diff --git a/.rubocop/rspec.yml b/.rubocop/rspec.yml new file mode 100644 index 00000000000000..d2d2f8325d83b0 --- /dev/null +++ b/.rubocop/rspec.yml @@ -0,0 +1,27 @@ +--- +RSpec/ExampleLength: + CountAsOne: ['array', 'heredoc', 'method_call'] + Max: 20 # Override default of 5 + +RSpec/MultipleExpectations: + Max: 10 # Overrides default of 1 + +RSpec/MultipleMemoizedHelpers: + Max: 20 # Overrides default of 5 + +RSpec/NamedSubject: + EnforcedStyle: named_only + +RSpec/NestedGroups: + Max: 10 # Overrides default of 3 + +RSpec/NotToNot: + EnforcedStyle: to_not + +RSpec/SpecFilePathFormat: + CustomTransform: + ActivityPub: activitypub + DeepL: deepl + FetchOEmbedService: fetch_oembed_service + OEmbedController: oembed_controller + OStatus: ostatus diff --git a/.rubocop/rspec_rails.yml b/.rubocop/rspec_rails.yml new file mode 100644 index 00000000000000..993a5689ad091e --- /dev/null +++ b/.rubocop/rspec_rails.yml @@ -0,0 +1,3 @@ +--- +RSpecRails/HttpStatus: + EnforcedStyle: numeric diff --git a/.rubocop/strict.yml b/.rubocop/strict.yml new file mode 100644 index 00000000000000..c2655a1470cc8f --- /dev/null +++ b/.rubocop/strict.yml @@ -0,0 +1,24 @@ +Lint/Debugger: # Remove any `binding.pry` + Enabled: true + Exclude: [] + +RSpec/Focus: # Require full spec run on CI + Enabled: true + Exclude: [] + +Rails/Output: # Remove any `puts` debugging + inherit_mode: + merge: + - Include + Enabled: true + Exclude: [] + Include: + - spec/**/*.rb + +Rails/FindEach: # Using `each` could impact performance, use `find_each` + Enabled: true + Exclude: [] + +Rails/UniqBeforePluck: # Require `uniq.pluck` and not `pluck.uniq` + Enabled: true + Exclude: [] diff --git a/.rubocop/style.yml b/.rubocop/style.yml new file mode 100644 index 00000000000000..03e35a70ac43cd --- /dev/null +++ b/.rubocop/style.yml @@ -0,0 +1,47 @@ +--- +Style/ClassAndModuleChildren: + Enabled: false + +Style/Documentation: + Enabled: false + +Style/FormatStringToken: + AllowedMethods: + - redirect_with_vary # Route redirects are not token-formatted + inherit_mode: + merge: + - AllowedMethods + +Style/HashAsLastArrayItem: + Enabled: false + +Style/HashSyntax: + EnforcedShorthandSyntax: either + EnforcedStyle: ruby19_no_mixed_keys + +Style/NumericLiterals: + AllowedPatterns: + - \d{4}_\d{2}_\d{2}_\d{6} + +Style/PercentLiteralDelimiters: + PreferredDelimiters: + '%i': () + '%w': () + +Style/RedundantBegin: + Enabled: false + +Style/RedundantFetchBlock: + Enabled: false + +Style/RescueStandardError: + EnforcedStyle: implicit + +Style/SymbolArray: + Enabled: false + +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: comma + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: comma diff --git a/.watchmanconfig b/.watchmanconfig new file mode 100644 index 00000000000000..29e4f231e9e802 --- /dev/null +++ b/.watchmanconfig @@ -0,0 +1,3 @@ +{ + "ignore_dirs": ["node_modules/", "public/"] +} diff --git a/.yarn/.gitkeep b/.yarn/.gitkeep new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch b/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch new file mode 100644 index 00000000000000..0b3f94d09ee83a --- /dev/null +++ b/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch @@ -0,0 +1,13 @@ +diff --git a/lib/index.js b/lib/index.js +index 16ed6be8be8f555cc99096c2ff60954b42dc313d..d009c069770d066ad0db7ad02de1ea473a29334e 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -99,7 +99,7 @@ function lodash(_ref) { + + var node = _ref3; + +- if ((0, _types.isModuleDeclaration)(node)) { ++ if ((0, _types.isImportDeclaration)(node) || (0, _types.isExportDeclaration)(node)) { + isModule = true; + break; + } diff --git a/.yarnclean b/.yarnclean deleted file mode 100644 index 21eb734a6c6bb5..00000000000000 --- a/.yarnclean +++ /dev/null @@ -1,49 +0,0 @@ -# test directories -__tests__ -test -tests -powered-test - -# asset directories -docs -doc -website -images -# assets - -# examples -example -examples - -# code coverage directories -coverage -.nyc_output - -# build scripts -Makefile -Gulpfile.js -Gruntfile.js - -# configs -.tern-project -.gitattributes -.editorconfig -.*ignore -.eslintrc -.jshintrc -.flowconfig -.documentup.json -.yarn-metadata.json -.*.yml -*.yml - -# misc -*.gz -*.md - -# for specific ignore -!.svgo.yml -!sass-lint/**/*.yml - -# breaks lint-staged or generally anything using https://github.com/eemeli/yaml/issues/384 -!**/yaml/dist/**/doc diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 00000000000000..3186f3f0795ab6 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/Capfile b/Capfile deleted file mode 100644 index 86efa5bacf8d6b..00000000000000 --- a/Capfile +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require 'capistrano/setup' -require 'capistrano/deploy' -require 'capistrano/scm/git' - -install_plugin Capistrano::SCM::Git - -require 'capistrano/rbenv' -require 'capistrano/bundler' -require 'capistrano/yarn' -require 'capistrano/rails/assets' -require 'capistrano/rails/migrations' - -Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r } diff --git a/app/chewy/concerns/datetime_clamping_concern.rb b/app/chewy/concerns/datetime_clamping_concern.rb new file mode 100644 index 00000000000000..7f176b6e5489f4 --- /dev/null +++ b/app/chewy/concerns/datetime_clamping_concern.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module DatetimeClampingConcern + extend ActiveSupport::Concern + + MIN_ISO8601_DATETIME = '0000-01-01T00:00:00Z'.to_datetime.freeze + MAX_ISO8601_DATETIME = '9999-12-31T23:59:59Z'.to_datetime.freeze + + class_methods do + def clamp_date(datetime) + datetime.clamp(MIN_ISO8601_DATETIME, MAX_ISO8601_DATETIME) + end + end +end diff --git a/app/chewy/instances_index.rb b/app/chewy/instances_index.rb new file mode 100644 index 00000000000000..8f10d13b6da51f --- /dev/null +++ b/app/chewy/instances_index.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class InstancesIndex < Chewy::Index + settings index: index_preset(refresh_interval: '30s') + + index_scope ::Instance.searchable + + root date_detection: false do + field :domain, type: 'text', index_prefixes: { min_chars: 1, max_chars: 5 } + field :accounts_count, type: 'long' + end +end diff --git a/app/chewy/public_statuses_index.rb b/app/chewy/public_statuses_index.rb new file mode 100644 index 00000000000000..09a4dfc09320a3 --- /dev/null +++ b/app/chewy/public_statuses_index.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +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 + ), + }, + }, + } + + index_scope ::Status.unscoped + .kept + .indexable + .includes(:media_attachments, :preloadable_poll, :tags, 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(:language, type: 'keyword') + field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties }) + field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) }) + end +end diff --git a/app/controllers/activitypub/claims_controller.rb b/app/controllers/activitypub/claims_controller.rb deleted file mode 100644 index 339333e462ccc3..00000000000000 --- a/app/controllers/activitypub/claims_controller.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -class ActivityPub::ClaimsController < ActivityPub::BaseController - include SignatureVerification - include AccountOwnedConcern - - skip_before_action :authenticate_user! - - before_action :require_account_signature! - before_action :set_claim_result - - def create - render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer - end - - private - - def set_claim_result - @claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id]) - end -end diff --git a/app/controllers/activitypub/likes_controller.rb b/app/controllers/activitypub/likes_controller.rb new file mode 100644 index 00000000000000..4aa6a4a771f156 --- /dev/null +++ b/app/controllers/activitypub/likes_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::LikesController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_status + + def index + expires_in 0, public: @status.distributable? && public_fetch_mode? + render json: likes_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_status + @status = @account.statuses.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def likes_collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_status_likes_url(@account, @status), + type: :unordered, + size: @status.favourites_count + ) + end +end diff --git a/app/controllers/activitypub/shares_controller.rb b/app/controllers/activitypub/shares_controller.rb new file mode 100644 index 00000000000000..65b4a5b3831326 --- /dev/null +++ b/app/controllers/activitypub/shares_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::SharesController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_status + + def index + expires_in 0, public: @status.distributable? && public_fetch_mode? + render json: shares_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_status + @status = @account.statuses.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def shares_collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_status_shares_url(@account, @status), + type: :unordered, + size: @status.reblogs_count + ) + end +end diff --git a/app/controllers/admin/software_updates_controller.rb b/app/controllers/admin/software_updates_controller.rb new file mode 100644 index 00000000000000..52d8cb41e6dd94 --- /dev/null +++ b/app/controllers/admin/software_updates_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Admin + class SoftwareUpdatesController < BaseController + before_action :check_enabled! + + def index + authorize :software_update, :index? + @software_updates = SoftwareUpdate.all.sort_by(&:gem_version) + end + + private + + def check_enabled! + not_found unless SoftwareUpdate.check_enabled? + end + end +end diff --git a/app/controllers/api/v1/admin/tags_controller.rb b/app/controllers/api/v1/admin/tags_controller.rb new file mode 100644 index 00000000000000..283383acb4ac46 --- /dev/null +++ b/app/controllers/api/v1/admin/tags_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class Api::V1::Admin::TagsController < Api::BaseController + include Authorization + before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show] + before_action -> { authorize_if_got_token! :'admin:write' }, only: :update + + before_action :set_tags, only: :index + before_action :set_tag, except: :index + + after_action :insert_pagination_headers, only: :index + after_action :verify_authorized + + LIMIT = 100 + + PERMITTED_PARAMS = %i( + display_name + listable + trendable + usable + ).freeze + + def index + authorize :tag, :index? + render json: @tags, each_serializer: REST::Admin::TagSerializer + end + + def show + authorize @tag, :show? + render json: @tag, serializer: REST::Admin::TagSerializer + end + + def update + authorize @tag, :update? + @tag.update!(tag_params.merge(reviewed_at: Time.now.utc)) + render json: @tag, serializer: REST::Admin::TagSerializer + end + + private + + def set_tag + @tag = Tag.find(params[:id]) + end + + def set_tags + @tags = Tag.all.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + end + + def tag_params + params + .slice(*PERMITTED_PARAMS) + .permit(*PERMITTED_PARAMS) + end + + def next_path + api_v1_admin_tags_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + api_v1_admin_tags_url(pagination_params(min_id: pagination_since_id)) unless @tags.empty? + end + + def pagination_collection + @tags + end + + def records_continue? + @tags.size == limit_param(LIMIT) + end +end diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb new file mode 100644 index 00000000000000..9bc8e68ac2430b --- /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/crypto/deliveries_controller.rb b/app/controllers/api/v1/crypto/deliveries_controller.rb deleted file mode 100644 index aa9df6e03b20f2..00000000000000 --- a/app/controllers/api/v1/crypto/deliveries_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::DeliveriesController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_current_device - - def create - devices.each do |device_params| - DeliverToDeviceService.new.call(current_account, @current_device, device_params) - end - - render_empty - end - - private - - def set_current_device - @current_device = Device.find_by!(access_token: doorkeeper_token) - end - - def resource_params - params.require(:device) - params.permit(device: [:account_id, :device_id, :type, :body, :hmac]) - end - - def devices - Array(resource_params[:device]) - end -end diff --git a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb deleted file mode 100644 index 68cf4384f79bd0..00000000000000 --- a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController - LIMIT = 80 - - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_current_device - - before_action :set_encrypted_messages, only: :index - after_action :insert_pagination_headers, only: :index - - def index - render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer - end - - def clear - @current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all - render_empty - end - - private - - def set_current_device - @current_device = Device.find_by!(access_token: doorkeeper_token) - end - - def set_encrypted_messages - @encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) - end - - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def next_path - api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue? - end - - def prev_path - api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty? - end - - def pagination_max_id - @encrypted_messages.last.id - end - - def pagination_since_id - @encrypted_messages.first.id - end - - def records_continue? - @encrypted_messages.size == limit_param(LIMIT) - end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end -end diff --git a/app/controllers/api/v1/crypto/keys/claims_controller.rb b/app/controllers/api/v1/crypto/keys/claims_controller.rb deleted file mode 100644 index f9d202d67b8ed8..00000000000000 --- a/app/controllers/api/v1/crypto/keys/claims_controller.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_claim_results - - def create - render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer - end - - private - - def set_claim_results - @claim_results = devices.filter_map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) } - end - - def resource_params - params.permit(device: [:account_id, :device_id]) - end - - def devices - Array(resource_params[:device]) - end -end diff --git a/app/controllers/api/v1/crypto/keys/counts_controller.rb b/app/controllers/api/v1/crypto/keys/counts_controller.rb deleted file mode 100644 index ffd7151b78291e..00000000000000 --- a/app/controllers/api/v1/crypto/keys/counts_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::CountsController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_current_device - - def show - render json: { one_time_keys: @current_device.one_time_keys.count } - end - - private - - def set_current_device - @current_device = Device.find_by!(access_token: doorkeeper_token) - end -end diff --git a/app/controllers/api/v1/crypto/keys/queries_controller.rb b/app/controllers/api/v1/crypto/keys/queries_controller.rb deleted file mode 100644 index e6ce9f9192ac86..00000000000000 --- a/app/controllers/api/v1/crypto/keys/queries_controller.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::QueriesController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_accounts - before_action :set_query_results - - def create - render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer - end - - private - - def set_accounts - @accounts = Account.where(id: account_ids).includes(:devices) - end - - def set_query_results - @query_results = @accounts.filter_map { |account| ::Keys::QueryService.new.call(account) } - end - - def account_ids - Array(params[:id]).map(&:to_i) - end -end diff --git a/app/controllers/api/v1/crypto/keys/uploads_controller.rb b/app/controllers/api/v1/crypto/keys/uploads_controller.rb deleted file mode 100644 index fc4abf63b3a421..00000000000000 --- a/app/controllers/api/v1/crypto/keys/uploads_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::UploadsController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - - def create - device = Device.find_or_initialize_by(access_token: doorkeeper_token) - - device.transaction do - device.account = current_account - device.update!(resource_params[:device]) - - if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable) - resource_params[:one_time_keys].each do |one_time_key_params| - device.one_time_keys.create!(one_time_key_params) - end - end - end - - render json: device, serializer: REST::Keys::DeviceSerializer - end - - private - - def resource_params - params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature]) - end -end diff --git a/app/controllers/api/v1/domain_blocks/previews_controller.rb b/app/controllers/api/v1/domain_blocks/previews_controller.rb new file mode 100644 index 00000000000000..a917bddd98ed4c --- /dev/null +++ b/app/controllers/api/v1/domain_blocks/previews_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Api::V1::DomainBlocks::PreviewsController < Api::BaseController + before_action -> { doorkeeper_authorize! :follow, :write, :'write:blocks' } + before_action :require_user! + before_action :set_domain + before_action :set_domain_block_preview + + def show + render json: @domain_block_preview, serializer: REST::DomainBlockPreviewSerializer + end + + private + + def set_domain + @domain = TagManager.instance.normalize_domain(params[:domain]) + end + + def set_domain_block_preview + @domain_block_preview = with_read_replica do + DomainBlockPreviewPresenter.new( + following_count: current_account.following.where(domain: @domain).count, + followers_count: current_account.followers.where(domain: @domain).count + ) + end + end +end diff --git a/app/controllers/api/v1/instances/base_controller.rb b/app/controllers/api/v1/instances/base_controller.rb new file mode 100644 index 00000000000000..ed0bebf0ffd3d5 --- /dev/null +++ b/app/controllers/api/v1/instances/base_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Api::V1::Instances::BaseController < Api::BaseController + skip_before_action :require_authenticated_user!, + unless: :limited_federation_mode? + + vary_by '' +end diff --git a/app/controllers/api/v1/instances/languages_controller.rb b/app/controllers/api/v1/instances/languages_controller.rb new file mode 100644 index 00000000000000..ea184d90daa4c4 --- /dev/null +++ b/app/controllers/api/v1/instances/languages_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Instances::LanguagesController < Api::V1::Instances::BaseController + skip_around_action :set_locale + + before_action :set_languages + + def show + cache_even_if_authenticated! + render json: @languages, each_serializer: REST::LanguageSerializer + end + + private + + def set_languages + @languages = LanguagesHelper::SUPPORTED_LOCALES.keys.map { |code| LanguagePresenter.new(code) } + end +end diff --git a/app/controllers/api/v1/invites_controller.rb b/app/controllers/api/v1/invites_controller.rb new file mode 100644 index 00000000000000..ea17ba74038b30 --- /dev/null +++ b/app/controllers/api/v1/invites_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::InvitesController < Api::BaseController + include RegistrationHelper + + skip_before_action :require_authenticated_user! + skip_around_action :set_locale + + before_action :set_invite + before_action :check_enabled_registrations! + + # Override `current_user` to avoid reading session cookies + def current_user; end + + def show + render json: { invite_code: params[:invite_code], instance_api_url: api_v2_instance_url }, status: 200 + end + + private + + def set_invite + @invite = Invite.find_by!(code: params[:invite_code]) + end + + def check_enabled_registrations! + return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use? + + raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite) + end +end diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb new file mode 100644 index 00000000000000..9d70c283bec001 --- /dev/null +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Api::V1::Notifications::PoliciesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update + + before_action :require_user! + before_action :set_policy + + def show + render json: @policy, serializer: REST::V1::NotificationPolicySerializer + end + + def update + @policy.update!(resource_params) + render json: @policy, serializer: REST::V1::NotificationPolicySerializer + end + + private + + def set_policy + @policy = NotificationPolicy.find_or_initialize_by(account: current_account) + + with_read_replica do + @policy.summarize! + end + end + + def resource_params + params.permit( + :filter_not_following, + :filter_not_followers, + :filter_new_accounts, + :filter_private_mentions + ) + end +end diff --git a/app/controllers/api/v1/notifications/requests_controller.rb b/app/controllers/api/v1/notifications/requests_controller.rb new file mode 100644 index 00000000000000..3c90f13ce24ec9 --- /dev/null +++ b/app/controllers/api/v1/notifications/requests_controller.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Api::V1::Notifications::RequestsController < Api::BaseController + include Redisable + + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: [:index, :show, :merged?] + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: [:index, :show, :merged?] + + before_action :require_user! + before_action :set_request, only: [:show, :accept, :dismiss] + before_action :set_requests, only: [:accept_bulk, :dismiss_bulk] + + after_action :insert_pagination_headers, only: :index + + def index + with_read_replica do + @requests = load_requests + @relationships = relationships + end + + render json: @requests, each_serializer: REST::NotificationRequestSerializer, relationships: @relationships + end + + def merged? + render json: { merged: redis.get("notification_unfilter_jobs:#{current_account.id}").to_i <= 0 } + end + + def show + render json: @request, serializer: REST::NotificationRequestSerializer + end + + def accept + AcceptNotificationRequestService.new.call(@request) + render_empty + end + + def dismiss + DismissNotificationRequestService.new.call(@request) + render_empty + end + + def accept_bulk + @requests.each { |request| AcceptNotificationRequestService.new.call(request) } + render_empty + end + + def dismiss_bulk + @requests.each(&:destroy!) + render_empty + end + + private + + def load_requests + requests = NotificationRequest.where(account: current_account).without_suspended.includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) + + NotificationRequest.preload_cache_collection(requests) do |statuses| + preload_collection(statuses, Status) + end + end + + def relationships + StatusRelationshipsPresenter.new(@requests.map(&:last_status), current_user&.account_id) + end + + def set_request + @request = NotificationRequest.where(account: current_account).find(params[:id]) + end + + def set_requests + @requests = NotificationRequest.where(account: current_account, id: Array(params[:id]).uniq.map(&:to_i)) + end + + def next_path + api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_notifications_requests_url pagination_params(min_id: pagination_since_id) unless @requests.empty? + end + + def records_continue? + @requests.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_max_id + @requests.last.id + end + + def pagination_since_id + @requests.first.id + end +end diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb new file mode 100644 index 00000000000000..d9c82327022194 --- /dev/null +++ b/app/controllers/api/v1/peers/search_controller.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class Api::V1::Peers::SearchController < Api::BaseController + before_action :require_enabled_api! + before_action :set_domains + + skip_before_action :require_authenticated_user!, unless: :limited_federation_mode? + skip_around_action :set_locale + + LIMIT = 10 + + vary_by '' + + def index + cache_even_if_authenticated! + render json: @domains + end + + private + + def require_enabled_api! + head 404 unless Setting.peers_api_enabled && !limited_federation_mode? + end + + def set_domains + return if params[:q].blank? + + if Chewy.enabled? + @domains = InstancesIndex.query(function_score: { + query: { + prefix: { + domain: normalized_domain, + }, + }, + + field_value_factor: { + field: 'accounts_count', + modifier: 'log2p', + }, + }).limit(LIMIT).pluck(:domain) + else + domain = normalized_domain + @domains = Instance.searchable.domain_starts_with(domain).limit(LIMIT).pluck(:domain) + end + rescue Addressable::URI::InvalidURIError + @domains = [] + end + + def normalized_domain + TagManager.instance.normalize_domain(query_value) + end + + def query_value + params[:q].strip + end +end diff --git a/app/controllers/api/v1/profile/avatars_controller.rb b/app/controllers/api/v1/profile/avatars_controller.rb new file mode 100644 index 00000000000000..bc4d01a597f544 --- /dev/null +++ b/app/controllers/api/v1/profile/avatars_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Api::V1::Profile::AvatarsController < Api::BaseController + before_action -> { doorkeeper_authorize! :write, :'write:accounts' } + before_action :require_user! + + def destroy + @account = current_account + UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + render json: @account, serializer: REST::CredentialAccountSerializer + end +end diff --git a/app/controllers/api/v1/profile/headers_controller.rb b/app/controllers/api/v1/profile/headers_controller.rb new file mode 100644 index 00000000000000..9f4daa2f77f63e --- /dev/null +++ b/app/controllers/api/v1/profile/headers_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Api::V1::Profile::HeadersController < Api::BaseController + before_action -> { doorkeeper_authorize! :write, :'write:accounts' } + before_action :require_user! + + def destroy + @account = current_account + UpdateAccountService.new.call(@account, { header: nil }, raise_error: true) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + render json: @account, serializer: REST::CredentialAccountSerializer + end +end diff --git a/app/controllers/api/v1/statuses/base_controller.rb b/app/controllers/api/v1/statuses/base_controller.rb new file mode 100644 index 00000000000000..3f56b68bcf41f1 --- /dev/null +++ b/app/controllers/api/v1/statuses/base_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::BaseController < Api::BaseController + include Authorization + + before_action :set_status + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/api/v1/timelines/base_controller.rb b/app/controllers/api/v1/timelines/base_controller.rb new file mode 100644 index 00000000000000..1dba4a5bb21d58 --- /dev/null +++ b/app/controllers/api/v1/timelines/base_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::BaseController < Api::BaseController + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + before_action :require_user!, if: :require_auth? + + private + + def require_auth? + !Setting.timeline_preview + end + + def pagination_collection + @statuses + end + + def next_path_params + permitted_params.merge(max_id: pagination_max_id) + end + + def prev_path_params + permitted_params.merge(min_id: pagination_since_id) + end + + def permitted_params + params + .slice(*self.class::PERMITTED_PARAMS) + .permit(*self.class::PERMITTED_PARAMS) + end +end diff --git a/app/controllers/api/v1/timelines/link_controller.rb b/app/controllers/api/v1/timelines/link_controller.rb new file mode 100644 index 00000000000000..37ed084f0626ad --- /dev/null +++ b/app/controllers/api/v1/timelines/link_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController + before_action -> { authorize_if_got_token! :read, :'read:statuses' } + before_action :set_preview_card + before_action :set_statuses + + PERMITTED_PARAMS = %i( + url + limit + ).freeze + + def show + cache_if_unauthenticated! + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def set_preview_card + @preview_card = PreviewCard.joins(:trend).merge(PreviewCardTrend.allowed).find_by!(url: params[:url]) + end + + def set_statuses + @statuses = @preview_card.nil? ? [] : preload_collection(link_timeline_statuses, Status) + end + + def link_timeline_statuses + link_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id], + params[:min_id] + ) + end + + def link_feed + LinkFeed.new(@preview_card, current_account) + end + + def next_path + api_v1_timelines_link_url next_path_params + end + + def prev_path + api_v1_timelines_link_url prev_path_params + end +end diff --git a/app/controllers/api/v2/notifications/accounts_controller.rb b/app/controllers/api/v2/notifications/accounts_controller.rb new file mode 100644 index 00000000000000..771e9663883479 --- /dev/null +++ b/app/controllers/api/v2/notifications/accounts_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Api::V2::Notifications::AccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' } + before_action :require_user! + before_action :set_notifications! + after_action :insert_pagination_headers, only: :index + + def index + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def load_accounts + @paginated_notifications.map(&:from_account) + end + + def set_notifications! + @paginated_notifications = begin + current_account + .notifications + .without_suspended + .where(group_key: params[:notification_group_key]) + .includes(from_account: [:account_stat, :user]) + .paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + end + + def next_path + api_v2_notification_accounts_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v2_notification_accounts_url pagination_params(min_id: pagination_since_id) unless @paginated_notifications.empty? + end + + def pagination_collection + @paginated_notifications + end + + def records_continue? + @paginated_notifications.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end +end diff --git a/app/controllers/api/v2/notifications/policies_controller.rb b/app/controllers/api/v2/notifications/policies_controller.rb new file mode 100644 index 00000000000000..637587967fe328 --- /dev/null +++ b/app/controllers/api/v2/notifications/policies_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Api::V2::Notifications::PoliciesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update + + before_action :require_user! + before_action :set_policy + + def show + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + def update + @policy.update!(resource_params) + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + private + + def set_policy + @policy = NotificationPolicy.find_or_initialize_by(account: current_account) + + with_read_replica do + @policy.summarize! + end + end + + def resource_params + params.permit( + :for_not_following, + :for_not_followers, + :for_new_accounts, + :for_private_mentions, + :for_limited_accounts + ) + end +end diff --git a/app/controllers/api/v2/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb new file mode 100644 index 00000000000000..c070c0e5e71892 --- /dev/null +++ b/app/controllers/api/v2/notifications_controller.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +class Api::V2::NotificationsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss] + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss] + before_action :require_user! + after_action :insert_pagination_headers, only: :index + + DEFAULT_NOTIFICATIONS_LIMIT = 40 + DEFAULT_NOTIFICATIONS_COUNT_LIMIT = 100 + MAX_NOTIFICATIONS_COUNT_LIMIT = 1_000 + + def index + with_read_replica do + @notifications = load_notifications + @grouped_notifications = load_grouped_notifications + @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) + @presenter = GroupedNotificationsPresenter.new(@grouped_notifications, expand_accounts: expand_accounts_param) + + # Preload associations to avoid N+1s + ActiveRecord::Associations::Preloader.new(records: @presenter.accounts, associations: [:account_stat, { user: :role }]).call + end + + MastodonOTELTracer.in_span('Api::V2::NotificationsController#index rendering') do |span| + statuses = @grouped_notifications.filter_map { |group| group.target_status&.id } + + span.add_attributes( + 'app.notification_grouping.count' => @grouped_notifications.size, + 'app.notification_grouping.account.count' => @presenter.accounts.size, + 'app.notification_grouping.partial_account.count' => @presenter.partial_accounts.size, + 'app.notification_grouping.status.count' => statuses.size, + 'app.notification_grouping.status.unique_count' => statuses.uniq.size, + 'app.notification_grouping.expand_accounts_param' => expand_accounts_param + ) + + render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param + end + end + + def unread_count + limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT) + + with_read_replica do + render json: { count: browserable_account_notifications.paginate_groups_by_min_id(limit, min_id: notification_marker&.last_read_id, grouped_types: params[:grouped_types]).count } + end + end + + def show + @notification = current_account.notifications.without_suspended.find_by!(group_key: params[:group_key]) + presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification])) + render json: presenter, serializer: REST::DedupNotificationGroupSerializer + end + + def clear + current_account.notifications.delete_all + render_empty + end + + def dismiss + current_account.notifications.where(group_key: params[:group_key]).destroy_all + render_empty + end + + private + + def load_notifications + MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_notifications') do + notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id( + limit_param(DEFAULT_NOTIFICATIONS_LIMIT), + params.slice(:max_id, :since_id, :min_id, :grouped_types).permit(:max_id, :since_id, :min_id, grouped_types: []) + ) + + Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| + preload_collection(target_statuses, Status) + end + end + end + + def load_grouped_notifications + return [] if @notifications.empty? + + MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_grouped_notifications') do + NotificationGroup.from_notifications(@notifications, pagination_range: (@notifications.last.id)..(@notifications.first.id), grouped_types: params[:grouped_types]) + end + end + + def browserable_account_notifications + current_account.notifications.without_suspended.browserable( + types: Array(browserable_params[:types]), + exclude_types: Array(browserable_params[:exclude_types]), + include_filtered: truthy_param?(:include_filtered) + ) + end + + def notification_marker + current_user.markers.find_by(timeline: 'notifications') + end + + def target_statuses_from_notifications + @notifications.filter_map(&:target_status) + end + + def next_path + api_v2_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? + end + + def prev_path + api_v2_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? + end + + def pagination_collection + @notifications + end + + def browserable_params + params.slice(:include_filtered, :types, :exclude_types, :grouped_types).permit(:include_filtered, types: [], exclude_types: [], grouped_types: []) + end + + def pagination_params(core_params) + params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types).permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: []).merge(core_params) + end + + def expand_accounts_param + case params[:expand_accounts] + when nil, 'full' + 'full' + when 'partial_avatars' + 'partial_avatars' + else + raise Mastodon::InvalidParameterError, "Invalid value for 'expand_accounts': '#{params[:expand_accounts]}', allowed values are 'full' and 'partial_avatars'" + end + end +end diff --git a/app/controllers/concerns/admin/export_controller_concern.rb b/app/controllers/concerns/admin/export_controller_concern.rb new file mode 100644 index 00000000000000..6228ae67fe1b9a --- /dev/null +++ b/app/controllers/concerns/admin/export_controller_concern.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Admin::ExportControllerConcern + extend ActiveSupport::Concern + + private + + def send_export_file + respond_to do |format| + format.csv { send_data export_data, filename: export_filename } + end + end + + def export_data + raise 'Override in controller' + end + + def export_filename + raise 'Override in controller' + end + + def set_dummy_import! + @import = Admin::Import.new + end + + def import_params + params.require(:admin_import).permit(:data) + end +end diff --git a/app/controllers/concerns/admin_export_controller_concern.rb b/app/controllers/concerns/admin_export_controller_concern.rb deleted file mode 100644 index 4ac48a04b7ced9..00000000000000 --- a/app/controllers/concerns/admin_export_controller_concern.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module AdminExportControllerConcern - extend ActiveSupport::Concern - - private - - def send_export_file - respond_to do |format| - format.csv { send_data export_data, filename: export_filename } - end - end - - def export_data - raise 'Override in controller' - end - - def export_filename - raise 'Override in controller' - end - - def set_dummy_import! - @import = Admin::Import.new - end - - def import_params - params.require(:admin_import).permit(:data) - end -end diff --git a/app/controllers/concerns/access_token_tracking_concern.rb b/app/controllers/concerns/api/access_token_tracking_concern.rb similarity index 92% rename from app/controllers/concerns/access_token_tracking_concern.rb rename to app/controllers/concerns/api/access_token_tracking_concern.rb index cf60cfb995ba49..bc6ae51c772d77 100644 --- a/app/controllers/concerns/access_token_tracking_concern.rb +++ b/app/controllers/concerns/api/access_token_tracking_concern.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module AccessTokenTrackingConcern +module Api::AccessTokenTrackingConcern extend ActiveSupport::Concern ACCESS_TOKEN_UPDATE_FREQUENCY = 24.hours.freeze diff --git a/app/controllers/concerns/api/caching_concern.rb b/app/controllers/concerns/api/caching_concern.rb new file mode 100644 index 00000000000000..55d7fe56d7b744 --- /dev/null +++ b/app/controllers/concerns/api/caching_concern.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Api::CachingConcern + extend ActiveSupport::Concern + + def cache_if_unauthenticated! + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? + end + + def cache_even_if_authenticated! + expires_in(5.minutes, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless limited_federation_mode? + end +end diff --git a/app/controllers/concerns/api/content_security_policy.rb b/app/controllers/concerns/api/content_security_policy.rb new file mode 100644 index 00000000000000..8116dca57b52e6 --- /dev/null +++ b/app/controllers/concerns/api/content_security_policy.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Api::ContentSecurityPolicy + extend ActiveSupport::Concern + + included do + content_security_policy do |policy| + # Set every directive that does not have a fallback + policy.default_src :none + policy.frame_ancestors :none + policy.form_action :none + + # Disable every directive with a fallback to cut on response size + policy.base_uri false + policy.font_src false + policy.img_src false + policy.style_src false + policy.media_src false + policy.frame_src false + policy.manifest_src false + policy.connect_src false + policy.script_src false + policy.child_src false + policy.worker_src false + end + 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 00000000000000..9ce4795b02bcac --- /dev/null +++ b/app/controllers/concerns/api/error_handling.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Api::ErrorHandling + extend ActiveSupport::Concern + + included do + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| + render json: { error: e.to_s }, status: 422 + end + + rescue_from ActiveRecord::RecordNotUnique do + render json: { error: 'Duplicate record' }, status: 422 + end + + rescue_from Date::Error do + render json: { error: 'Invalid date supplied' }, status: 422 + end + + rescue_from ActiveRecord::RecordNotFound do + render json: { error: 'Record not found' }, status: 404 + end + + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, Mastodon::UnexpectedResponseError) do + render json: { error: 'Remote data could not be fetched' }, status: 503 + end + + rescue_from OpenSSL::SSL::SSLError do + render json: { error: 'Remote SSL certificate could not be verified' }, status: 503 + end + + rescue_from Mastodon::NotPermittedError do + render json: { error: 'This action is not allowed' }, status: 403 + end + + rescue_from Seahorse::Client::NetworkingError do |e| + Rails.logger.warn "Storage server error: #{e}" + render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do + render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from Mastodon::RateLimitExceededError do + render json: { error: I18n.t('errors.429') }, status: 429 + end + + rescue_from ActionController::ParameterMissing, Mastodon::InvalidParameterError do |e| + render json: { error: e.to_s }, status: 400 + end + end +end diff --git a/app/controllers/concerns/api/pagination.rb b/app/controllers/concerns/api/pagination.rb new file mode 100644 index 00000000000000..b0b4ae4603f24b --- /dev/null +++ b/app/controllers/concerns/api/pagination.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Api::Pagination + extend ActiveSupport::Concern + + PAGINATION_PARAMS = %i(limit).freeze + + protected + + def pagination_max_id + pagination_collection.last.id + end + + def pagination_since_id + pagination_collection.first.id + end + + def set_pagination_headers(next_path = nil, prev_path = nil) + links = [] + links << [next_path, [%w(rel next)]] if next_path + links << [prev_path, [%w(rel prev)]] if prev_path + response.headers['Link'] = LinkHeader.new(links).to_s unless links.empty? + end + + def require_valid_pagination_options! + render json: { error: 'Pagination values for `offset` and `limit` must be positive' }, status: 400 if pagination_options_invalid? + end + + def pagination_params(core_params) + params + .slice(*PAGINATION_PARAMS) + .permit(*PAGINATION_PARAMS) + .merge(core_params) + end + + private + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_options_invalid? + params.slice(:limit, :offset).values.map(&:to_i).any?(&:negative?) + end +end diff --git a/app/controllers/concerns/rate_limit_headers.rb b/app/controllers/concerns/api/rate_limit_headers.rb similarity index 96% rename from app/controllers/concerns/rate_limit_headers.rb rename to app/controllers/concerns/api/rate_limit_headers.rb index 30702f00e73114..fe57b6f6bd53e0 100644 --- a/app/controllers/concerns/rate_limit_headers.rb +++ b/app/controllers/concerns/api/rate_limit_headers.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module RateLimitHeaders +module Api::RateLimitHeaders extend ActiveSupport::Concern class_methods do @@ -61,7 +61,7 @@ def api_throttle_data end def request_time - @_request_time ||= Time.now.utc + @request_time ||= Time.now.utc end def reset_period_offset diff --git a/app/controllers/concerns/captcha_concern.rb b/app/controllers/concerns/auth/captcha_concern.rb similarity index 90% rename from app/controllers/concerns/captcha_concern.rb rename to app/controllers/concerns/auth/captcha_concern.rb index 538c1ffb147658..cfd93978cea576 100644 --- a/app/controllers/concerns/captcha_concern.rb +++ b/app/controllers/concerns/auth/captcha_concern.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true -module CaptchaConcern +module Auth::CaptchaConcern extend ActiveSupport::Concern + include Hcaptcha::Adapters::ViewMethods included do @@ -35,20 +36,26 @@ def check_captcha! flash.delete(:hcaptcha_error) yield message end + false end end def extend_csp_for_captcha! - policy = request.content_security_policy + policy = request.content_security_policy&.clone + return unless captcha_required? && policy.present? %w(script_src frame_src style_src connect_src).each do |directive| values = policy.send(directive) + values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:') values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:') + policy.send(directive, *values) end + + request.content_security_policy = policy end def render_captcha diff --git a/app/controllers/concerns/registration_spam_concern.rb b/app/controllers/concerns/auth/registration_spam_concern.rb similarity index 81% rename from app/controllers/concerns/registration_spam_concern.rb rename to app/controllers/concerns/auth/registration_spam_concern.rb index af434c985a0c96..9f4798b5372d59 100644 --- a/app/controllers/concerns/registration_spam_concern.rb +++ b/app/controllers/concerns/auth/registration_spam_concern.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module RegistrationSpamConcern +module Auth::RegistrationSpamConcern extend ActiveSupport::Concern def set_registration_form_time diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/auth/two_factor_authentication_concern.rb similarity index 89% rename from app/controllers/concerns/two_factor_authentication_concern.rb rename to app/controllers/concerns/auth/two_factor_authentication_concern.rb index b30cd354d27e3c..0fb11428dca520 100644 --- a/app/controllers/concerns/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/auth/two_factor_authentication_concern.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true -module TwoFactorAuthenticationConcern +module Auth::TwoFactorAuthenticationConcern extend ActiveSupport::Concern included do prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] + helper_method :webauthn_enabled? end def two_factor_enabled? @@ -65,6 +66,11 @@ def authenticate_with_two_factor_via_webauthn(user) 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 @@ -75,11 +81,8 @@ def authenticate_with_two_factor_via_otp(user) end def prompt_for_two_factor(user) - set_attempt_session(user) - - use_pack 'auth' + register_attempt_in_session(user) - @body_classes = 'lighter' @webauthn_enabled = user.webauthn_enabled? @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank? 'webauthn' @@ -89,4 +92,10 @@ def prompt_for_two_factor(user) set_locale { render :two_factor } end + + protected + + def webauthn_enabled? + @webauthn_enabled + end end diff --git a/app/controllers/concerns/export_controller_concern.rb b/app/controllers/concerns/export_controller_concern.rb deleted file mode 100644 index 24cfc7a0124832..00000000000000 --- a/app/controllers/concerns/export_controller_concern.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module ExportControllerConcern - extend ActiveSupport::Concern - - included do - before_action :authenticate_user! - before_action :load_export - - skip_before_action :require_functional! - end - - private - - def load_export - @export = Export.new(current_account) - end - - def send_export_file - respond_to do |format| - format.csv { send_data export_data, filename: export_filename } - end - end - - def export_data - raise 'Override in controller' - end - - def export_filename - "#{controller_name}.csv" - end -end diff --git a/app/controllers/concerns/preloading_concern.rb b/app/controllers/concerns/preloading_concern.rb new file mode 100644 index 00000000000000..61e22136498048 --- /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/settings/export_controller_concern.rb b/app/controllers/concerns/settings/export_controller_concern.rb new file mode 100644 index 00000000000000..2cf28cced87234 --- /dev/null +++ b/app/controllers/concerns/settings/export_controller_concern.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Settings::ExportControllerConcern + extend ActiveSupport::Concern + + included do + before_action :authenticate_user! + before_action :load_export + + skip_before_action :check_self_destruct! + skip_before_action :require_functional! + end + + private + + def load_export + @export = Export.new(current_account) + end + + def send_export_file + respond_to do |format| + format.csv { send_data export_data, filename: export_filename } + end + end + + def export_data + raise 'Override in controller' + end + + def export_filename + "#{controller_name}.csv" + end +end diff --git a/app/controllers/mail_subscriptions_controller.rb b/app/controllers/mail_subscriptions_controller.rb new file mode 100644 index 00000000000000..34df75f63ad6c4 --- /dev/null +++ b/app/controllers/mail_subscriptions_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class MailSubscriptionsController < ApplicationController + layout 'auth' + + skip_before_action :require_functional! + + before_action :set_user + before_action :set_type + + protect_from_forgery with: :null_session + + def show; end + + def create + @user.settings[email_type_from_param] = false + @user.save! + end + + private + + def set_user + @user = GlobalID::Locator.locate_signed(params[:token], for: 'unsubscribe') + not_found unless @user + end + + def set_type + @type = email_type_from_param + end + + def email_type_from_param + case params[:type] + when 'follow', 'reblog', 'favourite', 'mention', 'follow_request' + "notification_emails.#{params[:type]}" + else + not_found + end + end +end diff --git a/app/controllers/redirect/accounts_controller.rb b/app/controllers/redirect/accounts_controller.rb new file mode 100644 index 00000000000000..713ccf2ca1f410 --- /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 00000000000000..34558a41261472 --- /dev/null +++ b/app/controllers/redirect/base_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Redirect::BaseController < ApplicationController + vary_by 'Accept-Language' + + before_action :set_resource + + def show + @redirect_path = ActivityPub::TagManager.instance.url_for(@resource) + + render 'redirects/show', layout: 'application' + end + + private + + def set_resource + raise NotImplementedError + end +end diff --git a/app/controllers/redirect/statuses_controller.rb b/app/controllers/redirect/statuses_controller.rb new file mode 100644 index 00000000000000..37a938c651a70a --- /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/remote_interaction_helper_controller.rb b/app/controllers/remote_interaction_helper_controller.rb new file mode 100644 index 00000000000000..90c853f47b63ff --- /dev/null +++ b/app/controllers/remote_interaction_helper_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class RemoteInteractionHelperController < ApplicationController + vary_by '' + + skip_before_action :require_functional! + skip_around_action :set_locale + skip_before_action :update_user_sign_in + + content_security_policy do |p| + # We inherit the normal `script-src` + + # Set every directive that does not have a fallback + p.default_src :none + p.form_action :none + p.base_uri :none + + # Disable every directive with a fallback to cut on response size + p.base_uri false + p.font_src false + p.img_src false + p.style_src false + p.media_src false + p.frame_src false + p.manifest_src false + p.connect_src false + p.child_src false + p.worker_src false + + # Widen the directives that we do need + p.frame_ancestors :self + p.connect_src :https + end + + def index + expires_in(5.minutes, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) + + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + response.headers['Referrer-Policy'] = 'no-referrer' + + render layout: 'helper_frame' + end +end diff --git a/app/controllers/settings/preferences/base_controller.rb b/app/controllers/settings/preferences/base_controller.rb new file mode 100644 index 00000000000000..c1f8b49898e059 --- /dev/null +++ b/app/controllers/settings/preferences/base_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Settings::Preferences::BaseController < Settings::BaseController + def show; end + + def update + if current_user.update(user_params) + I18n.locale = current_user.locale + redirect_to after_update_redirect_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end + end + + private + + def after_update_redirect_path + raise 'Override in controller' + end + + def user_params + params.require(:user).permit(:locale, :time_zone, chosen_languages: [], settings_attributes: UserSettings.keys) + end +end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb deleted file mode 100644 index 281deb64d175a1..00000000000000 --- a/app/controllers/settings/preferences_controller.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -class Settings::PreferencesController < Settings::BaseController - def show; end - - def update - if current_user.update(user_params) - I18n.locale = current_user.locale - redirect_to after_update_redirect_path, notice: I18n.t('generic.changes_saved_msg') - else - render :show - end - end - - private - - def after_update_redirect_path - settings_preferences_path - end - - def user_params - params.require(:user).permit(:locale, chosen_languages: [], settings_attributes: UserSettings.keys) - end -end diff --git a/app/controllers/settings/privacy_controller.rb b/app/controllers/settings/privacy_controller.rb new file mode 100644 index 00000000000000..1102c89fad5f47 --- /dev/null +++ b/app/controllers/settings/privacy_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Settings::PrivacyController < 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_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end + end + + private + + def account_params + params.require(:account).permit(:discoverable, :unlocked, :indexable, :show_collections, settings: UserSettings.keys) + end + + def set_account + @account = current_account + end +end diff --git a/app/controllers/settings/verifications_controller.rb b/app/controllers/settings/verifications_controller.rb new file mode 100644 index 00000000000000..4e0663253cc82e --- /dev/null +++ b/app/controllers/settings/verifications_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Settings::VerificationsController < Settings::BaseController + before_action :set_account + before_action :set_verified_links + + def show; end + + def update + if UpdateAccountService.new.call(@account, account_params) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end + end + + private + + def account_params + params.require(:account).permit(:attribution_domains_as_text) + end + + def set_account + @account = current_account + end + + def set_verified_links + @verified_links = @account.fields.select(&:verified?) + end +end diff --git a/app/controllers/severed_relationships_controller.rb b/app/controllers/severed_relationships_controller.rb new file mode 100644 index 00000000000000..965753a26f8ac9 --- /dev/null +++ b/app/controllers/severed_relationships_controller.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class SeveredRelationshipsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + 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_cache_headers + response.cache_control.replace(private: true, no_store: true) + end +end diff --git a/app/controllers/well_known/node_info_controller.rb b/app/controllers/well_known/node_info_controller.rb new file mode 100644 index 00000000000000..e20e8c62a0c3fc --- /dev/null +++ b/app/controllers/well_known/node_info_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module WellKnown + class NodeInfoController < 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 index + expires_in 3.days, public: true + render_with_cache json: {}, serializer: NodeInfo::DiscoverySerializer, adapter: NodeInfo::Adapter, expires_in: 3.days, root: 'nodeinfo' + end + + def show + expires_in 30.minutes, public: true + render_with_cache json: {}, serializer: NodeInfo::Serializer, adapter: NodeInfo::Adapter, expires_in: 30.minutes, root: 'nodeinfo' + end + end +end diff --git a/app/controllers/well_known/nodeinfo_controller.rb b/app/controllers/well_known/nodeinfo_controller.rb deleted file mode 100644 index 11a699ebc82715..00000000000000 --- a/app/controllers/well_known/nodeinfo_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module WellKnown - class NodeInfoController < ActionController::Base - include CacheConcern - - before_action { response.headers['Vary'] = 'Accept' } - - def index - expires_in 3.days, public: true - render_with_cache json: {}, serializer: NodeInfo::DiscoverySerializer, adapter: NodeInfo::Adapter, expires_in: 3.days, root: 'nodeinfo' - end - - def show - expires_in 30.minutes, public: true - render_with_cache json: {}, serializer: NodeInfo::Serializer, adapter: NodeInfo::Adapter, expires_in: 30.minutes, root: 'nodeinfo' - end - end -end diff --git a/app/controllers/well_known/oauth_metadata_controller.rb b/app/controllers/well_known/oauth_metadata_controller.rb new file mode 100644 index 00000000000000..c80be2d65258c5 --- /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/admin/account_actions_helper.rb b/app/helpers/admin/account_actions_helper.rb new file mode 100644 index 00000000000000..e132680a688a53 --- /dev/null +++ b/app/helpers/admin/account_actions_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Admin::AccountActionsHelper + def account_action_type_label(type) + safe_join( + [ + I18n.t("simple_form.labels.admin_account_action.types.#{type}"), + content_tag(:span, I18n.t("simple_form.hints.admin_account_action.types.#{type}"), class: 'hint'), + ] + ) + end +end diff --git a/app/helpers/admin/accounts_helper.rb b/app/helpers/admin/accounts_helper.rb new file mode 100644 index 00000000000000..a936797e888074 --- /dev/null +++ b/app/helpers/admin/accounts_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Admin::AccountsHelper + def admin_accounts_moderation_options + [ + [t('admin.accounts.moderation.active'), 'active'], + [t('admin.accounts.moderation.silenced'), 'silenced'], + [t('admin.accounts.moderation.disabled'), 'disabled'], + [t('admin.accounts.moderation.suspended'), 'suspended'], + [safe_join([t('admin.accounts.moderation.pending'), "(#{pending_user_count_label})"], ' '), 'pending'], + ] + end + + private + + def pending_user_count_label + number_with_delimiter User.pending.count + end +end diff --git a/app/helpers/admin/announcements_helper.rb b/app/helpers/admin/announcements_helper.rb new file mode 100644 index 00000000000000..97abe8e0118131 --- /dev/null +++ b/app/helpers/admin/announcements_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Admin::AnnouncementsHelper + def datetime_pattern + '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}' + end + + def datetime_placeholder + Time.zone.now.strftime('%FT%R') + end +end diff --git a/app/helpers/admin/disputes_helper.rb b/app/helpers/admin/disputes_helper.rb new file mode 100644 index 00000000000000..366a470ed2a451 --- /dev/null +++ b/app/helpers/admin/disputes_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Admin + module DisputesHelper + def strike_action_label(appeal) + t(key_for_action(appeal), + scope: 'admin.strikes.actions', + name: content_tag(:span, appeal.strike.account.username, class: 'username'), + target: content_tag(:span, appeal.account.username, class: 'target')) + .html_safe + end + + private + + def key_for_action(appeal) + AccountWarning.actions.slice(appeal.strike.action).keys.first + end + end +end diff --git a/app/helpers/admin/ip_blocks_helper.rb b/app/helpers/admin/ip_blocks_helper.rb new file mode 100644 index 00000000000000..4aae3aae7abe51 --- /dev/null +++ b/app/helpers/admin/ip_blocks_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Admin::IpBlocksHelper + def ip_blocks_severity_label(severity) + safe_join( + [ + I18n.t("simple_form.labels.ip_block.severities.#{severity}"), + content_tag(:span, I18n.t("simple_form.hints.ip_block.severities.#{severity}"), class: 'hint'), + ] + ) + end +end diff --git a/app/helpers/admin/roles_helper.rb b/app/helpers/admin/roles_helper.rb new file mode 100644 index 00000000000000..7b4702e268fe54 --- /dev/null +++ b/app/helpers/admin/roles_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Admin + module RolesHelper + def privilege_label(privilege) + safe_join( + [ + t("admin.roles.privileges.#{privilege}"), + content_tag(:span, t("admin.roles.privileges.#{privilege}_description"), class: 'hint'), + ] + ) + end + + def disable_permissions?(permissions) + permissions.filter { |privilege| role_flag_value(privilege).zero? } + end + + private + + def role_flag_value(privilege) + UserRole::FLAGS[privilege] & current_user.role.computed_permissions + end + end +end diff --git a/app/helpers/admin/settings/discovery_helper.rb b/app/helpers/admin/settings/discovery_helper.rb new file mode 100644 index 00000000000000..0aa4d4368f30bf --- /dev/null +++ b/app/helpers/admin/settings/discovery_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Admin::Settings::DiscoveryHelper + def discovery_warning_hint_text + authorized_fetch_overridden? ? t('admin.settings.security.authorized_fetch_overridden_hint') : nil + end + + def discovery_hint_text + t('admin.settings.security.authorized_fetch_hint') + end + + def discovery_recommended_value + authorized_fetch_overridden? ? :overridden : nil + end +end diff --git a/app/helpers/admin/tags_helper.rb b/app/helpers/admin/tags_helper.rb new file mode 100644 index 00000000000000..eb928a6db2beb0 --- /dev/null +++ b/app/helpers/admin/tags_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Admin::TagsHelper + def admin_tags_moderation_options + [ + [t('admin.tags.moderation.reviewed'), 'reviewed'], + [t('admin.tags.moderation.review_requested'), 'review_requested'], + [t('admin.tags.moderation.unreviewed'), 'unreviewed'], + [t('admin.tags.moderation.trendable'), 'trendable'], + [t('admin.tags.moderation.not_trendable'), 'not_trendable'], + [t('admin.tags.moderation.usable'), 'usable'], + [t('admin.tags.moderation.not_usable'), 'not_usable'], + ] + end +end diff --git a/app/helpers/authorized_fetch_helper.rb b/app/helpers/authorized_fetch_helper.rb new file mode 100644 index 00000000000000..cc1b8d02c3d253 --- /dev/null +++ b/app/helpers/authorized_fetch_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module AuthorizedFetchHelper + def authorized_fetch_mode? + ENV.fetch('AUTHORIZED_FETCH') { Setting.authorized_fetch && 'true' } == 'true' || Rails.configuration.x.limited_federation_mode + end + + def authorized_fetch_overridden? + ENV.key?('AUTHORIZED_FETCH') || Rails.configuration.x.limited_federation_mode + end +end diff --git a/app/helpers/database_helper.rb b/app/helpers/database_helper.rb new file mode 100644 index 00000000000000..62a26a0c2a05c8 --- /dev/null +++ b/app/helpers/database_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module DatabaseHelper + def replica_enabled? + ENV['REPLICA_DB_NAME'] || ENV.fetch('REPLICA_DATABASE_URL', nil) + end + module_function :replica_enabled? + + def with_read_replica(&block) + if replica_enabled? + ApplicationRecord.connected_to(role: :reading, prevent_writes: true, &block) + else + yield + end + end + + def with_primary(&block) + if replica_enabled? + ApplicationRecord.connected_to(role: :writing, &block) + else + yield + end + end +end diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb new file mode 100644 index 00000000000000..22a1c172de2c86 --- /dev/null +++ b/app/helpers/filters_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module FiltersHelper + def filter_action_label(action) + safe_join( + [ + t("simple_form.labels.filters.actions.#{action}"), + content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint'), + ] + ) + end +end diff --git a/app/helpers/invites_helper.rb b/app/helpers/invites_helper.rb new file mode 100644 index 00000000000000..c189061db0bf0f --- /dev/null +++ b/app/helpers/invites_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module InvitesHelper + def invites_max_uses_options + [1, 5, 10, 25, 50, 100] + end + + def invites_expires_options + [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week] + end +end diff --git a/app/helpers/mascot_helper.rb b/app/helpers/mascot_helper.rb deleted file mode 100644 index 0124c74f19b9a5..00000000000000 --- a/app/helpers/mascot_helper.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module MascotHelper - def mascot_url - full_asset_url(instance_presenter.mascot&.file&.url || asset_pack_path('media/images/elephant_ui_plane.svg')) - end - - private - - def instance_presenter - @instance_presenter ||= InstancePresenter.new - end -end diff --git a/app/helpers/media_component_helper.rb b/app/helpers/media_component_helper.rb new file mode 100644 index 00000000000000..60ccdd08359038 --- /dev/null +++ b/app/helpers/media_component_helper.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module MediaComponentHelper + def render_video_component(status, **options) + video = status.ordered_media_attachments.first + + meta = video.file.meta || {} + + component_params = { + sensitive: sensitive_viewer?(status, current_account), + src: full_asset_url(video.file.url(:original)), + preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), + alt: video.description, + blurhash: video.blurhash, + frameRate: meta.dig('original', 'frame_rate'), + inline: true, + aspectRatio: "#{meta.dig('original', 'width')} / #{meta.dig('original', 'height')}", + media: [ + serialize_media_attachment(video), + ].as_json, + }.merge(**options) + + react_component :video, component_params do + render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } + end + end + + def render_audio_component(status, **options) + audio = status.ordered_media_attachments.first + + meta = audio.file.meta || {} + + component_params = { + src: full_asset_url(audio.file.url(:original)), + poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), + alt: audio.description, + backgroundColor: meta.dig('colors', 'background'), + foregroundColor: meta.dig('colors', 'foreground'), + accentColor: meta.dig('colors', 'accent'), + duration: meta.dig('original', 'duration'), + }.merge(**options) + + react_component :audio, component_params do + render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } + end + end + + def render_media_gallery_component(status, **options) + component_params = { + sensitive: sensitive_viewer?(status, current_account), + autoplay: prefers_autoplay?, + media: status.ordered_media_attachments.map { |a| serialize_media_attachment(a).as_json }, + }.merge(**options) + + react_component :media_gallery, component_params do + render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } + end + end + + private + + def serialize_media_attachment(attachment) + ActiveModelSerializers::SerializableResource.new( + attachment, + serializer: REST::MediaAttachmentSerializer + ) + end + + def sensitive_viewer?(status, account) + if !account.nil? && account.id == status.account_id + status.sensitive + else + status.account.sensitized? || status.sensitive + end + end +end diff --git a/app/helpers/react_component_helper.rb b/app/helpers/react_component_helper.rb new file mode 100644 index 00000000000000..821a6f1e2d451d --- /dev/null +++ b/app/helpers/react_component_helper.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ReactComponentHelper + def react_component(name, props = {}, &block) + data = { component: name.to_s.camelcase, props: Oj.dump(props) } + if block.nil? + div_tag_with_data(data) + else + content_tag(:div, data: data, &block) + end + end + + def react_admin_component(name, props = {}) + data = { 'admin-component': name.to_s.camelcase, props: Oj.dump(props) } + 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 new file mode 100644 index 00000000000000..ef5462ac887413 --- /dev/null +++ b/app/helpers/registration_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module RegistrationHelper + extend ActiveSupport::Concern + + def allowed_registration?(remote_ip, invite) + !Rails.configuration.x.single_user_mode && !omniauth_only? && (registrations_open? || invite&.valid_for_use?) && !ip_blocked?(remote_ip) + end + + def registrations_open? + Setting.registrations_mode != 'none' + end + + def omniauth_only? + ENV['OMNIAUTH_ONLY'] == 'true' + end + + def ip_blocked?(remote_ip) + IpBlock.where(severity: :sign_up_block).exists?(['ip >>= ?', remote_ip.to_s]) + end +end diff --git a/app/helpers/self_destruct_helper.rb b/app/helpers/self_destruct_helper.rb new file mode 100644 index 00000000000000..78557c25e522f2 --- /dev/null +++ b/app/helpers/self_destruct_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module SelfDestructHelper + def self.self_destruct? + value = ENV.fetch('SELF_DESTRUCT', nil) + value.present? && Rails.application.message_verifier('self-destruct').verify(value) == ENV['LOCAL_DOMAIN'] + rescue ActiveSupport::MessageVerifier::InvalidSignature + false + end + + def self_destruct? + SelfDestructHelper.self_destruct? + end +end diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb new file mode 100644 index 00000000000000..c5d226f70e6205 --- /dev/null +++ b/app/helpers/theme_helper.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ThemeHelper + def theme_style_tags(flavour_and_skin) + flavour, theme = flavour_and_skin + + if theme == 'system' + ''.html_safe.tap do |tags| + tags << stylesheet_pack_tag("skins/#{flavour}/mastodon-light", media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') + tags << stylesheet_pack_tag("skins/#{flavour}/default", media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') + end + else + stylesheet_pack_tag "skins/#{flavour}/#{theme}", media: 'all', crossorigin: 'anonymous' + end + end + + def theme_color_tags(flavour_and_skin) + _, theme = flavour_and_skin + + if theme == 'system' + ''.html_safe.tap do |tags| + tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)') + tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)') + end + else + tag.meta name: 'theme-color', content: theme_color_for(theme) + end + end + + private + + def theme_color_for(theme) + theme == 'mastodon-light' ? Themes::THEME_COLORS[:light] : Themes::THEME_COLORS[:dark] + end +end diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb deleted file mode 100644 index 482f4e19eabef0..00000000000000 --- a/app/helpers/webfinger_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module WebfingerHelper - def webfinger!(uri) - Webfinger.new(uri).perform - end -end diff --git a/app/javascript/__mocks__/svg.js b/app/javascript/__mocks__/svg.js new file mode 100644 index 00000000000000..762bc165d04558 --- /dev/null +++ b/app/javascript/__mocks__/svg.js @@ -0,0 +1,3 @@ +const ReactComponent = 'div'; + +export default ReactComponent; diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js deleted file mode 100644 index ac1b2f95fbf605..00000000000000 --- a/app/javascript/core/admin.js +++ /dev/null @@ -1,227 +0,0 @@ -// This file will be loaded on admin pages, regardless of theme. - -import 'packs/public-path'; -import { delegate } from '@rails/ujs'; -import ready from '../mastodon/ready'; - -const setAnnouncementEndsAttributes = (target) => { - const valid = target?.value && target?.validity?.valid; - const element = document.querySelector('input[type="datetime-local"]#announcement_ends_at'); - if (valid) { - element.classList.remove('optional'); - element.required = true; - element.min = target.value; - } else { - element.classList.add('optional'); - element.removeAttribute('required'); - element.removeAttribute('min'); - } -}; - -delegate(document, 'input[type="datetime-local"]#announcement_starts_at', 'change', ({ target }) => { - 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('#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'); - hiddenField.value = '0'; -}; - -delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { - const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); - - [].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => { - content.checked = target.checked; - }); - - if (selectAllMatchingElement) { - if (target.checked) { - showSelectAll(); - } else { - hideSelectAll(); - } - } -}); - -delegate(document, '.batch-table__select-all button', 'click', () => { - const hiddenField = document.querySelector('#select_all_matching'); - 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 (active) { - hiddenField.value = '0'; - selectedMsg.classList.remove('active'); - notSelectedMsg.classList.add('active'); - } else { - hiddenField.value = '1'; - notSelectedMsg.classList.remove('active'); - selectedMsg.classList.add('active'); - } -}); - -delegate(document, batchCheckboxClassName, 'change', () => { - const checkAllElement = document.querySelector('#batch_checkbox_all'); - const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); - - if (checkAllElement) { - checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); - checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); - - if (selectAllMatchingElement) { - if (checkAllElement.checked) { - showSelectAll(); - } else { - hideSelectAll(); - } - } - } -}); - -delegate(document, '.media-spoiler-show-button', 'click', () => { - [].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => { - element.click(); - }); -}); - -delegate(document, '.media-spoiler-hide-button', 'click', () => { - [].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => { - element.click(); - }); -}); - -delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => { - target.form.submit(); -}); - -const onDomainBlockSeverityChange = (target) => { - 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.style.display = (target.value === 'suspend') ? 'none' : 'block'; - } - - if (rejectReportsDiv) { - rejectReportsDiv.style.display = (target.value === 'suspend') ? 'none' : 'block'; - } -}; - -delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target)); - -const onEnableBootstrapTimelineAccountsChange = (target) => { - 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'); - } - } -}; - -delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target)); - -const onChangeRegistrationMode = (target) => { - const enabled = target.value === 'approved'; - - [].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => { - input.disabled = !enabled; - if (enabled) { - let element = input; - do { - element.classList.remove('disabled'); - element = element.parentElement; - } while (element && !element.classList.contains('fields-group')); - } else { - let element = input; - do { - element.classList.add('disabled'); - element = element.parentElement; - } while (element && !element.classList.contains('fields-group')); - } - }); -}; - -const convertUTCDateTimeToLocal = (value) => { - const date = new Date(value + 'Z'); - const twoChars = (x) => (x.toString().padStart(2, '0')); - return `${date.getFullYear()}-${twoChars(date.getMonth()+1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`; -}; - -const convertLocalDatetimeToUTC = (value) => { - const re = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})/; - const match = re.exec(value); - const date = new Date(match[1], match[2] - 1, match[3], match[4], match[5]); - const fullISO8601 = date.toISOString(); - return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); -}; - -delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target)); - -ready(() => { - const domainBlockSeverityInput = document.getElementById('domain_block_severity'); - if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput); - - const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts'); - if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); - - const registrationMode = document.getElementById('form_admin_settings_registrations_mode'); - if (registrationMode) onChangeRegistrationMode(registrationMode); - - const checkAllElement = document.querySelector('#batch_checkbox_all'); - if (checkAllElement) { - checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); - checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); - } - - document.querySelector('a#add-instance-button')?.addEventListener('click', (e) => { - const domain = document.querySelector('input[type="text"]#by_domain')?.value; - - if (domain) { - const url = new URL(event.target.href); - url.searchParams.set('_domain', domain); - e.target.href = url; - } - }); - - [].forEach.call(document.querySelectorAll('input[type="datetime-local"]'), element => { - if (element.value) { - element.value = convertUTCDateTimeToLocal(element.value); - } - if (element.placeholder) { - element.placeholder = convertUTCDateTimeToLocal(element.placeholder); - } - }); - - delegate(document, 'form', 'submit', ({ target }) => { - [].forEach.call(target.querySelectorAll('input[type="datetime-local"]'), 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); - } -}); diff --git a/app/javascript/core/auth.js b/app/javascript/core/auth.js deleted file mode 100644 index d1d14d99e8df93..00000000000000 --- a/app/javascript/core/auth.js +++ /dev/null @@ -1,3 +0,0 @@ -import 'packs/public-path'; -import './settings'; -import './two_factor_authentication'; diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js deleted file mode 100644 index 1cee2f6036f08c..00000000000000 --- a/app/javascript/core/common.js +++ /dev/null @@ -1,6 +0,0 @@ -// This file will be loaded on all pages, regardless of theme. - -import 'packs/public-path'; -import 'font-awesome/css/font-awesome.css'; - -require.context('../images/', true); diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js deleted file mode 100644 index d1e8f6b10896c4..00000000000000 --- a/app/javascript/core/embed.js +++ /dev/null @@ -1,25 +0,0 @@ -// This file will be loaded on embed pages, regardless of theme. - -import 'packs/public-path'; - -window.addEventListener('message', e => { - const data = e.data || {}; - - if (!window.parent || data.type !== 'setHeight') { - return; - } - - function setEmbedHeight () { - window.parent.postMessage({ - type: 'setHeight', - id: data.id, - height: document.getElementsByTagName('html')[0].scrollHeight, - }, '*'); - } - - if (['interactive', 'complete'].includes(document.readyState)) { - setEmbedHeight(); - } else { - document.addEventListener('DOMContentLoaded', setEmbedHeight); - } -}); diff --git a/app/javascript/core/mailer.js b/app/javascript/core/mailer.js deleted file mode 100644 index a4b6d54464584a..00000000000000 --- a/app/javascript/core/mailer.js +++ /dev/null @@ -1,3 +0,0 @@ -require('../styles/mailer.scss'); - -require.context('../icons'); diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js deleted file mode 100644 index 5c7a51f447171b..00000000000000 --- a/app/javascript/core/public.js +++ /dev/null @@ -1,30 +0,0 @@ -// This file will be loaded on public pages, regardless of theme. - -import 'packs/public-path'; -import ready from '../mastodon/ready'; - -const { delegate } = require('@rails/ujs'); -const { length } = require('stringz'); - -const getProfileAvatarAnimationHandler = (swapTo) => { - //animate avatar gifs on the profile page when moused over - return ({ target }) => { - const swapSrc = target.getAttribute(swapTo); - //only change the img source if autoplay is off and the image src is actually different - if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) { - target.src = swapSrc; - } - }; -}; - -delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original')); - -delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static')); - -delegate(document, '#account_header', 'change', ({ target }) => { - const header = document.querySelector('.card .card__img img'); - const [file] = target.files || []; - const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; - - header.src = url; -}); diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js deleted file mode 100644 index d578463a33a874..00000000000000 --- a/app/javascript/core/settings.js +++ /dev/null @@ -1,79 +0,0 @@ -// This file will be loaded on settings pages, regardless of theme. - -import 'packs/public-path'; -import escapeTextContentForBrowser from 'escape-html'; - -const { delegate } = require('@rails/ujs'); - -import emojify from '../mastodon/features/emoji/emoji'; - -delegate(document, '#account_display_name', 'input', ({ target }) => { - const name = document.querySelector('.card .display-name strong'); - if (name) { - if (target.value) { - name.innerHTML = emojify(escapeTextContentForBrowser(target.value)); - } else { - name.textContent = name.textContent = target.dataset.default; - } - } -}); - -delegate(document, '#account_avatar', 'change', ({ target }) => { - const avatar = document.querySelector('.card .avatar img'); - const [file] = target.files || []; - const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; - - avatar.src = url; -}); - -delegate(document, '#account_header', 'change', ({ target }) => { - const header = document.querySelector('.card .card__img img'); - const [file] = target.files || []; - const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; - - header.src = url; -}); - -delegate(document, '#account_locked', 'change', ({ target }) => { - const lock = document.querySelector('.card .display-name i'); - - if (lock) { - if (target.checked) { - delete lock.dataset.hidden; - } else { - lock.dataset.hidden = 'true'; - } - } -}); - -delegate(document, '.input-copy input', 'click', ({ target }) => { - target.focus(); - target.select(); - target.setSelectionRange(0, target.value.length); -}); - -delegate(document, '.input-copy button', 'click', ({ target }) => { - const input = target.parentNode.querySelector('.input-copy__wrapper input'); - - const oldReadOnly = input.readonly; - - input.readonly = false; - input.focus(); - input.select(); - input.setSelectionRange(0, input.value.length); - - try { - if (document.execCommand('copy')) { - input.blur(); - target.parentNode.classList.add('copied'); - - setTimeout(() => { - target.parentNode.classList.remove('copied'); - }, 700); - } - } catch (err) { - console.error(err); - } - - input.readonly = oldReadOnly; -}); diff --git a/app/javascript/core/theme.yml b/app/javascript/core/theme.yml deleted file mode 100644 index b9144e43aa525f..00000000000000 --- a/app/javascript/core/theme.yml +++ /dev/null @@ -1,19 +0,0 @@ -# These packs will be loaded on every appropriate page, regardless of -# theme. -pack: - about: - admin: admin.js - auth: auth.js - common: - filename: common.js - stylesheet: true - embed: embed.js - error: - home: - mailer: - filename: mailer.js - stylesheet: true - modal: public.js - public: public.js - settings: settings.js - share: diff --git a/app/javascript/core/two_factor_authentication.js b/app/javascript/core/two_factor_authentication.js deleted file mode 100644 index f076cdf30a989e..00000000000000 --- a/app/javascript/core/two_factor_authentication.js +++ /dev/null @@ -1,119 +0,0 @@ -import 'packs/public-path'; -import axios from 'axios'; -import * as WebAuthnJSON from '@github/webauthn-json'; -import ready from '../mastodon/ready'; -import 'regenerator-runtime/runtime'; - -function getCSRFToken() { - var CSRFSelector = document.querySelector('meta[name="csrf-token"]'); - if (CSRFSelector) { - return CSRFSelector.getAttribute('content'); - } else { - return null; - } -} - -function hideFlashMessages() { - Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) { - flashMessage.classList.add('hidden'); - }); -} - -function callback(url, body) { - axios.post(url, JSON.stringify(body), { - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-CSRF-Token': getCSRFToken(), - }, - credentials: 'same-origin', - }).then(function(response) { - window.location.replace(response.data.redirect_path); - }).catch(function(error) { - if (error.response.status === 422) { - const errorMessage = document.getElementById('security-key-error-message'); - errorMessage.classList.remove('hidden'); - console.error(error.response.data.error); - } else { - console.error(error); - } - }); -} - -ready(() => { - if (!WebAuthnJSON.supported()) { - const unsupported_browser_message = document.getElementById('unsupported-browser-message'); - if (unsupported_browser_message) { - unsupported_browser_message.classList.remove('hidden'); - document.querySelector('.btn.js-webauthn').disabled = true; - } - } - - - const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential'); - if (webAuthnCredentialRegistrationForm) { - webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => { - event.preventDefault(); - - var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]'); - if (nickname.value) { - axios.get('/settings/security_keys/options') - .then((response) => { - const credentialOptions = response.data; - - WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => { - var params = { 'credential': credential, 'nickname': nickname.value }; - callback('/settings/security_keys', params); - }).catch((error) => { - const errorMessage = document.getElementById('security-key-error-message'); - errorMessage.classList.remove('hidden'); - console.error(error); - }); - }).catch((error) => { - console.error(error.response.data.error); - }); - } else { - nickname.focus(); - } - }); - } - - const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form'); - if (webAuthnCredentialAuthenticationForm) { - webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => { - event.preventDefault(); - - axios.get('sessions/security_key_options') - .then((response) => { - const credentialOptions = response.data; - - WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => { - var params = { 'user': { 'credential': credential } }; - callback('sign_in', params); - }).catch((error) => { - const errorMessage = document.getElementById('security-key-error-message'); - errorMessage.classList.remove('hidden'); - console.error(error); - }); - }).catch((error) => { - console.error(error.response.data.error); - }); - }); - - 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(); - }); - } -}); diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx new file mode 100644 index 00000000000000..225cb16330fe54 --- /dev/null +++ b/app/javascript/entrypoints/admin.tsx @@ -0,0 +1,368 @@ +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'; + }); + + document + .querySelectorAll( + 'input#form_admin_settings_require_invite_text', + ) + .forEach((input) => { + input.disabled = !enabled; + if (enabled) { + 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')); + } + }); +}; + +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); + }, +); + +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/entrypoints/application.ts b/app/javascript/entrypoints/application.ts new file mode 100644 index 00000000000000..1087b1c4cb5c86 --- /dev/null +++ b/app/javascript/entrypoints/application.ts @@ -0,0 +1,15 @@ +import './public-path'; +import main from 'mastodon/main'; + +import { start } from '../mastodon/common'; +import { loadLocale } from '../mastodon/locales'; +import { loadPolyfills } from '../mastodon/polyfills'; + +start(); + +loadPolyfills() + .then(loadLocale) + .then(main) + .catch((e: unknown) => { + console.error(e); + }); diff --git a/app/javascript/entrypoints/common.js b/app/javascript/entrypoints/common.js new file mode 100644 index 00000000000000..fce857be920086 --- /dev/null +++ b/app/javascript/entrypoints/common.js @@ -0,0 +1,4 @@ +/* This file is a hack to have something more reliable than the upstream `common` tag + that is implicitly generated as the common chunk through webpack's `splitChunks` config */ + +import './public-path'; diff --git a/app/javascript/entrypoints/embed.tsx b/app/javascript/entrypoints/embed.tsx new file mode 100644 index 00000000000000..f8c824d287ad31 --- /dev/null +++ b/app/javascript/entrypoints/embed.tsx @@ -0,0 +1,74 @@ +import './public-path'; +import { createRoot } from 'react-dom/client'; + +import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal'; + +import { start } from '../mastodon/common'; +import { Status } from '../mastodon/features/standalone/status'; +import { loadPolyfills } from '../mastodon/polyfills'; +import ready from '../mastodon/ready'; + +start(); + +function loaded() { + const mountNode = document.getElementById('mastodon-status'); + + if (mountNode) { + const attr = mountNode.getAttribute('data-props'); + + if (!attr) return; + + const props = JSON.parse(attr) as { id: string; locale: string }; + const root = createRoot(mountNode); + + root.render(); + } +} + +function main() { + ready(loaded).catch((error: unknown) => { + console.error(error); + }); +} + +loadPolyfills() + .then(main) + .catch((error: unknown) => { + console.error(error); + }); + +interface SetHeightMessage { + type: 'setHeight'; + id: string; + height: number; +} + +function isSetHeightMessage(data: unknown): data is SetHeightMessage { + if ( + data && + typeof data === 'object' && + 'type' in data && + data.type === 'setHeight' + ) + return true; + else return false; +} + +window.addEventListener('message', (e) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases + if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; + + const data = e.data; + + // We use a timeout to allow for the React page to render before calculating the height + afterInitialRender(() => { + window.parent.postMessage( + { + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0]?.scrollHeight, + }, + '*', + ); + }); +}); diff --git a/app/javascript/entrypoints/error.ts b/app/javascript/entrypoints/error.ts new file mode 100644 index 00000000000000..db68484f3a886c --- /dev/null +++ b/app/javascript/entrypoints/error.ts @@ -0,0 +1,18 @@ +import './public-path'; +import ready from '../mastodon/ready'; + +ready(() => { + const image = document.querySelector('img'); + + if (!image) return; + + image.addEventListener('mouseenter', () => { + image.src = '/oops.gif'; + }); + + image.addEventListener('mouseleave', () => { + image.src = '/oops.png'; + }); +}).catch((e: unknown) => { + console.error(e); +}); diff --git a/app/javascript/entrypoints/inert.ts b/app/javascript/entrypoints/inert.ts new file mode 100644 index 00000000000000..7c04a97fafd8a6 --- /dev/null +++ b/app/javascript/entrypoints/inert.ts @@ -0,0 +1,4 @@ +/* Placeholder file to have `inert.scss` compiled by Webpack + This is used by the `wicg-inert` polyfill */ + +import '../styles/inert.scss'; diff --git a/app/javascript/entrypoints/mailer.ts b/app/javascript/entrypoints/mailer.ts new file mode 100644 index 00000000000000..a2ad5e73ac20e8 --- /dev/null +++ b/app/javascript/entrypoints/mailer.ts @@ -0,0 +1,3 @@ +import '../styles/mailer.scss'; + +require.context('../icons'); diff --git a/app/javascript/entrypoints/public-path.ts b/app/javascript/entrypoints/public-path.ts new file mode 100644 index 00000000000000..ac4b9355b952d7 --- /dev/null +++ b/app/javascript/entrypoints/public-path.ts @@ -0,0 +1,23 @@ +// Dynamically set webpack's loading path depending on a meta header, in order +// to share the same assets regardless of instance configuration. +// See https://webpack.js.org/guides/public-path/#on-the-fly + +function removeOuterSlashes(string: string) { + return string.replace(/^\/*/, '').replace(/\/*$/, ''); +} + +function formatPublicPath(host = '', path = '') { + let formattedHost = removeOuterSlashes(host); + if (formattedHost && !/^http/i.test(formattedHost)) { + formattedHost = `//${formattedHost}`; + } + const formattedPath = removeOuterSlashes(path); + return `${formattedHost}/${formattedPath}/`; +} + +const cdnHost = document.querySelector('meta[name=cdn-host]'); + +__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 00000000000000..d33e00d5da88ce --- /dev/null +++ b/app/javascript/entrypoints/public.tsx @@ -0,0 +1,461 @@ +import { createRoot } from 'react-dom/client'; + +import './public-path'; + +import { IntlMessageFormat } from 'intl-messageformat'; +import type { MessageDescriptor, PrimitiveType } from 'react-intl'; +import { defineMessages } from 'react-intl'; + +import Rails from '@rails/ujs'; +import axios from 'axios'; +import { throttle } from 'lodash'; + +import { start } from '../mastodon/common'; +import { timeAgoString } from '../mastodon/components/relative_timestamp'; +import emojify from '../mastodon/features/emoji/emoji'; +import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; +import { loadLocale, getLocale } from '../mastodon/locales'; +import { loadPolyfills } from '../mastodon/polyfills'; +import ready from '../mastodon/ready'; + +import 'cocoon-js-vanilla'; + +start(); + +const messages = defineMessages({ + usernameTaken: { + id: 'username.taken', + defaultMessage: 'That username is taken. Try another', + }, + passwordExceedsLength: { + id: 'password_confirmation.exceeds_maxlength', + defaultMessage: 'Password confirmation exceeds the maximum password length', + }, + passwordDoesNotMatch: { + id: 'password_confirmation.mismatching', + defaultMessage: 'Password confirmation does not match', + }, +}); + +function loaded() { + const { messages: localeData } = getLocale(); + + const locale = document.documentElement.lang; + + const dateTimeFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }); + + const dateFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + + const timeFormat = new Intl.DateTimeFormat(locale, { + timeStyle: 'short', + }); + + const formatMessage = ( + { id, defaultMessage }: MessageDescriptor, + values?: Record, + ) => { + let message: string | undefined = undefined; + + if (id) message = localeData[id]; + + 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; +}); + +const setInputDisabled = ( + input: HTMLInputElement | HTMLSelectElement, + disabled: boolean, +) => { + input.disabled = disabled; + + const wrapper = input.closest('.with_label'); + if (wrapper) { + wrapper.classList.toggle('disabled', input.disabled); + + const hidden = + input.type === 'checkbox' && + wrapper.querySelector('input[type=hidden][value="0"]'); + if (hidden) { + hidden.disabled = input.disabled; + } + } +}; + +Rails.delegate( + document, + '#account_statuses_cleanup_policy_enabled', + 'change', + ({ target }) => { + if (!(target instanceof HTMLInputElement) || !target.form) return; + + target.form + .querySelectorAll< + HTMLInputElement | HTMLSelectElement + >('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select') + .forEach((input) => { + setInputDisabled(input, !target.checked); + }); + }, +); + +// Empty the honeypot fields in JS in case something like an extension +// automatically filled them. +Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { + [ + 'user_website', + 'user_confirm_password', + 'registration_user_website', + 'registration_user_confirm_password', + ].forEach((id) => { + const field = document.querySelector(`input#${id}`); + if (field) { + field.value = ''; + } + }); +}); + +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/entrypoints/remote_interaction_helper.ts b/app/javascript/entrypoints/remote_interaction_helper.ts new file mode 100644 index 00000000000000..419571c8964168 --- /dev/null +++ b/app/javascript/entrypoints/remote_interaction_helper.ts @@ -0,0 +1,181 @@ +/* + +This script is meant to to be used in an `iframe` with the sole purpose of doing webfinger queries +client-side without being restricted by a strict `connect-src` Content-Security-Policy directive. + +It communicates with the parent window through message events that are authenticated by origin, +and performs no other task. + +*/ + +import './public-path'; + +import axios from 'axios'; + +interface JRDLink { + rel: string; + template?: string; + href?: string; +} + +const isJRDLink = (link: unknown): link is JRDLink => + typeof link === 'object' && + link !== null && + 'rel' in link && + typeof link.rel === 'string' && + (!('template' in link) || typeof link.template === 'string') && + (!('href' in link) || typeof link.href === 'string'); + +const findLink = (rel: string, data: unknown): JRDLink | undefined => { + if ( + typeof data === 'object' && + data !== null && + 'links' in data && + data.links instanceof Array + ) { + return data.links.find( + (link): link is JRDLink => isJRDLink(link) && link.rel === rel, + ); + } else { + return undefined; + } +}; + +const findTemplateLink = (data: unknown) => + findLink('http://ostatus.org/schema/1.0/subscribe', data)?.template; + +const fetchInteractionURLSuccess = ( + uri_or_domain: string, + template: string, +) => { + window.parent.postMessage( + { + type: 'fetchInteractionURL-success', + uri_or_domain, + template, + }, + window.origin, + ); +}; + +const fetchInteractionURLFailure = () => { + window.parent.postMessage( + { + type: 'fetchInteractionURL-failure', + }, + window.origin, + ); +}; + +const isValidDomain = (value: unknown) => { + if (typeof value !== 'string') return false; + + const url = new URL('https:///path'); + url.hostname = value; + return url.hostname === value; +}; + +// Attempt to find a remote interaction URL from a domain +const fromDomain = (domain: string) => { + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: `https://${domain}` }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(domain, template ?? fallbackTemplate); + return; + }) + .catch(() => { + fetchInteractionURLSuccess(domain, fallbackTemplate); + }); +}; + +// Attempt to find a remote interaction URL from an arbitrary URL +const fromURL = (url: string) => { + const domain = new URL(url).host; + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: url }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(url, template ?? fallbackTemplate); + return; + }) + .catch(() => { + fromDomain(domain); + }); +}; + +// Attempt to find a remote interaction URL from a `user@domain` string +const fromAcct = (acct: string) => { + acct = acct.replace(/^@/, ''); + + const segments = acct.split('@'); + + if (segments.length !== 2 || !segments[0] || !isValidDomain(segments[1])) { + fetchInteractionURLFailure(); + return; + } + + const domain = segments[1]; + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + if (!domain) { + fetchInteractionURLFailure(); + return; + } + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: `acct:${acct}` }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(acct, template ?? fallbackTemplate); + return; + }) + .catch(() => { + // TODO: handle host-meta? + fromDomain(domain); + }); +}; + +const fetchInteractionURL = (uri_or_domain: string) => { + if (uri_or_domain === '') { + fetchInteractionURLFailure(); + } else if (/^https?:\/\//.test(uri_or_domain)) { + fromURL(uri_or_domain); + } else if (uri_or_domain.includes('@')) { + fromAcct(uri_or_domain); + } else { + fromDomain(uri_or_domain); + } +}; + +window.addEventListener('message', (event: MessageEvent) => { + // Check message origin + if ( + !window.origin || + window.parent !== event.source || + event.origin !== window.origin + ) { + return; + } + + if ( + event.data && + typeof event.data === 'object' && + 'type' in event.data && + event.data.type === 'fetchInteractionURL' && + 'uri_or_domain' in event.data && + typeof event.data.uri_or_domain === 'string' + ) { + fetchInteractionURL(event.data.uri_or_domain); + } +}); diff --git a/app/javascript/entrypoints/share.tsx b/app/javascript/entrypoints/share.tsx new file mode 100644 index 00000000000000..792625085106dc --- /dev/null +++ b/app/javascript/entrypoints/share.tsx @@ -0,0 +1,36 @@ +import './public-path'; +import { createRoot } from 'react-dom/client'; + +import { start } from '../mastodon/common'; +import ComposeContainer from '../mastodon/containers/compose_container'; +import { loadPolyfills } from '../mastodon/polyfills'; +import ready from '../mastodon/ready'; + +start(); + +function loaded() { + const mountNode = document.getElementById('mastodon-compose'); + + if (mountNode) { + const attr = mountNode.getAttribute('data-props'); + + if (!attr) return; + + const props = JSON.parse(attr) as object; + const root = createRoot(mountNode); + + root.render(); + } +} + +function main() { + ready(loaded).catch((error: unknown) => { + console.error(error); + }); +} + +loadPolyfills() + .then(main) + .catch((error: unknown) => { + console.error(error); + }); diff --git a/app/javascript/entrypoints/sign_up.ts b/app/javascript/entrypoints/sign_up.ts new file mode 100644 index 00000000000000..880738fcb771da --- /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 00000000000000..981481694b8f7a --- /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/flavours/glitch/actions/account_notes.js b/app/javascript/flavours/glitch/actions/account_notes.js deleted file mode 100644 index 62a6b4cbb85cba..00000000000000 --- a/app/javascript/flavours/glitch/actions/account_notes.js +++ /dev/null @@ -1,69 +0,0 @@ -import api from '../api'; - -export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; -export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; -export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; - -export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT'; -export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL'; - -export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; - -export function submitAccountNote() { - return (dispatch, getState) => { - dispatch(submitAccountNoteRequest()); - - const id = getState().getIn(['account_notes', 'edit', 'account_id']); - - api(getState).post(`/api/v1/accounts/${id}/note`, { - comment: getState().getIn(['account_notes', 'edit', 'comment']), - }).then(response => { - dispatch(submitAccountNoteSuccess(response.data)); - }).catch(error => dispatch(submitAccountNoteFail(error))); - }; -} - -export function submitAccountNoteRequest() { - return { - type: ACCOUNT_NOTE_SUBMIT_REQUEST, - }; -} - -export function submitAccountNoteSuccess(relationship) { - return { - type: ACCOUNT_NOTE_SUBMIT_SUCCESS, - relationship, - }; -} - -export function submitAccountNoteFail(error) { - return { - type: ACCOUNT_NOTE_SUBMIT_FAIL, - error, - }; -} - -export function initEditAccountNote(account) { - return (dispatch, getState) => { - const comment = getState().getIn(['relationships', account.get('id'), 'note']); - - dispatch({ - type: ACCOUNT_NOTE_INIT_EDIT, - account, - comment, - }); - }; -} - -export function cancelAccountNote() { - return { - type: ACCOUNT_NOTE_CANCEL, - }; -} - -export function changeAccountNoteComment(comment) { - return { - type: ACCOUNT_NOTE_CHANGE_COMMENT, - comment, - }; -} diff --git a/app/javascript/flavours/glitch/actions/account_notes.ts b/app/javascript/flavours/glitch/actions/account_notes.ts new file mode 100644 index 00000000000000..162fa675050dd2 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/account_notes.ts @@ -0,0 +1,9 @@ +import { apiSubmitAccountNote } from 'flavours/glitch/api/accounts'; +import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; + +export const submitAccountNote = createDataLoadingThunk( + 'account_note/submit', + ({ accountId, note }: { accountId: string; note: string }) => + apiSubmitAccountNote(accountId, note), + (relationship) => ({ relationship }), +); diff --git a/app/javascript/flavours/glitch/actions/accounts_typed.ts b/app/javascript/flavours/glitch/actions/accounts_typed.ts new file mode 100644 index 00000000000000..22aaa48a0dca02 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/accounts_typed.ts @@ -0,0 +1,97 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; +import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships'; + +export const revealAccount = createAction<{ + id: string; +}>('accounts/revealAccount'); + +export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>( + 'accounts/importAccounts', +); + +function actionWithSkipLoadingTrue(args: Args) { + return { + payload: { + ...args, + skipLoading: true, + }, + }; +} + +export const followAccountSuccess = createAction( + 'accounts/followAccount/SUCCESS', + actionWithSkipLoadingTrue<{ + relationship: ApiRelationshipJSON; + alreadyFollowing: boolean; + }>, +); + +export const unfollowAccountSuccess = createAction( + 'accounts/unfollowAccount/SUCCESS', + actionWithSkipLoadingTrue<{ + relationship: ApiRelationshipJSON; + statuses: unknown; + alreadyFollowing?: boolean; + }>, +); + +export const authorizeFollowRequestSuccess = createAction<{ id: string }>( + 'accounts/followRequestAuthorize/SUCCESS', +); + +export const rejectFollowRequestSuccess = createAction<{ id: string }>( + 'accounts/followRequestReject/SUCCESS', +); + +export const followAccountRequest = createAction( + 'accounts/follow/REQUEST', + actionWithSkipLoadingTrue<{ id: string; locked: boolean }>, +); + +export const followAccountFail = createAction( + 'accounts/follow/FAIL', + actionWithSkipLoadingTrue<{ id: string; error: string; locked: boolean }>, +); + +export const unfollowAccountRequest = createAction( + 'accounts/unfollow/REQUEST', + actionWithSkipLoadingTrue<{ id: string }>, +); + +export const unfollowAccountFail = createAction( + 'accounts/unfollow/FAIL', + actionWithSkipLoadingTrue<{ id: string; error: string }>, +); + +export const blockAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; + statuses: unknown; +}>('accounts/block/SUCCESS'); + +export const unblockAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/unblock/SUCCESS'); + +export const muteAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; + statuses: unknown; +}>('accounts/mute/SUCCESS'); + +export const unmuteAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/unmute/SUCCESS'); + +export const pinAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/pin/SUCCESS'); + +export const unpinAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/unpin/SUCCESS'); + +export const fetchRelationshipsSuccess = createAction( + 'relationships/fetch/SUCCESS', + actionWithSkipLoadingTrue<{ relationships: ApiRelationshipJSON[] }>, +); diff --git a/app/javascript/flavours/glitch/actions/app.js b/app/javascript/flavours/glitch/actions/app.js deleted file mode 100644 index de2d93e2927a0f..00000000000000 --- a/app/javascript/flavours/glitch/actions/app.js +++ /dev/null @@ -1,6 +0,0 @@ -export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE'; - -export const changeLayout = layout => ({ - type: APP_LAYOUT_CHANGE, - layout, -}); diff --git a/app/javascript/flavours/glitch/actions/app.ts b/app/javascript/flavours/glitch/actions/app.ts new file mode 100644 index 00000000000000..be1a5cced20f4d --- /dev/null +++ b/app/javascript/flavours/glitch/actions/app.ts @@ -0,0 +1,12 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { LayoutType } from '../is_mobile'; + +export const focusApp = createAction('APP_FOCUS'); +export const unfocusApp = createAction('APP_UNFOCUS'); + +interface ChangeLayoutPayload { + layout: LayoutType; +} +export const changeLayout = + createAction('APP_LAYOUT_CHANGE'); diff --git a/app/javascript/flavours/glitch/actions/boosts.js b/app/javascript/flavours/glitch/actions/boosts.js deleted file mode 100644 index c0f0f3acc512f8..00000000000000 --- a/app/javascript/flavours/glitch/actions/boosts.js +++ /dev/null @@ -1,29 +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('BOOST', props)); - }; -} - - -export function changeBoostPrivacy(privacy) { - return dispatch => { - dispatch({ - type: BOOSTS_CHANGE_PRIVACY, - privacy, - }); - }; -} diff --git a/app/javascript/flavours/glitch/actions/directory.js b/app/javascript/flavours/glitch/actions/directory.js deleted file mode 100644 index 4b2b6dd56dce58..00000000000000 --- a/app/javascript/flavours/glitch/actions/directory.js +++ /dev/null @@ -1,61 +0,0 @@ -import api from '../api'; -import { importFetchedAccounts } from './importer'; -import { fetchRelationships } from './accounts'; - -export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; -export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; -export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; - -export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; -export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; -export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; - -export const fetchDirectory = params => (dispatch, getState) => { - dispatch(fetchDirectoryRequest()); - - api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchDirectorySuccess(data)); - dispatch(fetchRelationships(data.map(x => x.id))); - }).catch(error => dispatch(fetchDirectoryFail(error))); -}; - -export const fetchDirectoryRequest = () => ({ - type: DIRECTORY_FETCH_REQUEST, -}); - -export const fetchDirectorySuccess = accounts => ({ - type: DIRECTORY_FETCH_SUCCESS, - accounts, -}); - -export const fetchDirectoryFail = error => ({ - type: DIRECTORY_FETCH_FAIL, - error, -}); - -export const expandDirectory = params => (dispatch, getState) => { - dispatch(expandDirectoryRequest()); - - const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; - - api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(expandDirectorySuccess(data)); - dispatch(fetchRelationships(data.map(x => x.id))); - }).catch(error => dispatch(expandDirectoryFail(error))); -}; - -export const expandDirectoryRequest = () => ({ - type: DIRECTORY_EXPAND_REQUEST, -}); - -export const expandDirectorySuccess = accounts => ({ - type: DIRECTORY_EXPAND_SUCCESS, - accounts, -}); - -export const expandDirectoryFail = error => ({ - type: DIRECTORY_EXPAND_FAIL, - error, -}); diff --git a/app/javascript/flavours/glitch/actions/directory.ts b/app/javascript/flavours/glitch/actions/directory.ts new file mode 100644 index 00000000000000..3e0f1356b3ae05 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/directory.ts @@ -0,0 +1,37 @@ +import type { List as ImmutableList } from 'immutable'; + +import { apiGetDirectory } from 'flavours/glitch/api/directory'; +import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +export const fetchDirectory = createDataLoadingThunk( + 'directory/fetch', + async (params: Parameters[0]) => + apiGetDirectory(params), + (data, { dispatch }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((x) => x.id))); + + return { accounts: data }; + }, +); + +export const expandDirectory = createDataLoadingThunk( + 'directory/expand', + async (params: Parameters[0], { getState }) => { + const loadedItems = getState().user_lists.getIn([ + 'directory', + 'items', + ]) as ImmutableList; + + return apiGetDirectory({ ...params, offset: loadedItems.size }, 20); + }, + (data, { dispatch }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((x) => x.id))); + + return { accounts: data }; + }, +); diff --git a/app/javascript/flavours/glitch/actions/domain_blocks_typed.ts b/app/javascript/flavours/glitch/actions/domain_blocks_typed.ts new file mode 100644 index 00000000000000..c5c4a76ba67a77 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/domain_blocks_typed.ts @@ -0,0 +1,13 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { Account } from 'flavours/glitch/models/account'; + +export const blockDomainSuccess = createAction<{ + domain: string; + accounts: Account[]; +}>('domain_blocks/block/SUCCESS'); + +export const unblockDomainSuccess = createAction<{ + domain: string; + accounts: Account[]; +}>('domain_blocks/unblock/SUCCESS'); diff --git a/app/javascript/flavours/glitch/actions/dropdown_menu.js b/app/javascript/flavours/glitch/actions/dropdown_menu.js deleted file mode 100644 index 023151d4bfc55a..00000000000000 --- a/app/javascript/flavours/glitch/actions/dropdown_menu.js +++ /dev/null @@ -1,10 +0,0 @@ -export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; -export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; - -export function openDropdownMenu(id, keyboard, scroll_key) { - return { type: DROPDOWN_MENU_OPEN, id, keyboard, scroll_key }; -} - -export function closeDropdownMenu(id) { - return { type: DROPDOWN_MENU_CLOSE, id }; -} diff --git a/app/javascript/flavours/glitch/actions/dropdown_menu.ts b/app/javascript/flavours/glitch/actions/dropdown_menu.ts new file mode 100644 index 00000000000000..3694df1ae03e29 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/dropdown_menu.ts @@ -0,0 +1,11 @@ +import { createAction } from '@reduxjs/toolkit'; + +export const openDropdownMenu = createAction<{ + id: string; + keyboard: boolean; + scrollKey: string; +}>('dropdownMenu/open'); + +export const closeDropdownMenu = createAction<{ id: string }>( + 'dropdownMenu/close', +); diff --git a/app/javascript/flavours/glitch/actions/identity_proofs.js b/app/javascript/flavours/glitch/actions/identity_proofs.js deleted file mode 100644 index 10398395660345..00000000000000 --- a/app/javascript/flavours/glitch/actions/identity_proofs.js +++ /dev/null @@ -1,31 +0,0 @@ -import api from '../api'; - -export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST'; -export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS'; -export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL'; - -export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => { - dispatch(fetchAccountIdentityProofsRequest(accountId)); - - api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`) - .then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data))) - .catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err))); -}; - -export const fetchAccountIdentityProofsRequest = id => ({ - type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST, - id, -}); - -export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({ - type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS, - accountId, - identity_proofs, -}); - -export const fetchAccountIdentityProofsFail = (accountId, err) => ({ - type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL, - accountId, - err, - skipNotFound: true, -}); diff --git a/app/javascript/flavours/glitch/actions/interactions_typed.ts b/app/javascript/flavours/glitch/actions/interactions_typed.ts new file mode 100644 index 00000000000000..075fc242e4e640 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/interactions_typed.ts @@ -0,0 +1,35 @@ +import { apiReblog, apiUnreblog } from 'flavours/glitch/api/interactions'; +import type { StatusVisibility } from 'flavours/glitch/models/status'; +import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; + +import { importFetchedStatus } from './importer'; + +export const reblog = createDataLoadingThunk( + 'status/reblog', + ({ + statusId, + visibility, + }: { + statusId: string; + visibility: StatusVisibility; + }) => apiReblog(statusId, visibility), + (data, { dispatch, discardLoadData }) => { + // The reblog API method returns a new status wrapped around the original. In this case we are only + // interested in how the original is modified, hence passing it skipping the wrapper + dispatch(importFetchedStatus(data.reblog)); + + // The payload is not used in any actions + return discardLoadData; + }, +); + +export const unreblog = createDataLoadingThunk( + 'status/unreblog', + ({ statusId }: { statusId: string }) => apiUnreblog(statusId), + (data, { dispatch, discardLoadData }) => { + dispatch(importFetchedStatus(data)); + + // The payload is not used in any actions + return discardLoadData; + }, +); diff --git a/app/javascript/flavours/glitch/actions/languages.js b/app/javascript/flavours/glitch/actions/languages.js deleted file mode 100644 index ad186ba0ccd3e5..00000000000000 --- a/app/javascript/flavours/glitch/actions/languages.js +++ /dev/null @@ -1,12 +0,0 @@ -import { saveSettings } from './settings'; - -export const LANGUAGE_USE = 'LANGUAGE_USE'; - -export const useLanguage = language => dispatch => { - dispatch({ - type: LANGUAGE_USE, - language, - }); - - dispatch(saveSettings()); -}; diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js deleted file mode 100644 index f82675342648b1..00000000000000 --- a/app/javascript/flavours/glitch/actions/markers.js +++ /dev/null @@ -1,150 +0,0 @@ -import api from '../api'; -import { debounce } from 'lodash'; -import compareId from '../compare_id'; -import { List as ImmutableList } from 'immutable'; - -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 && lastNotificationId !== '0' && 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/flavours/glitch/actions/markers.ts b/app/javascript/flavours/glitch/actions/markers.ts new file mode 100644 index 00000000000000..ea325109c5ea3b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/markers.ts @@ -0,0 +1,146 @@ +import { debounce } from 'lodash'; + +import type { MarkerJSON } from 'flavours/glitch/api_types/markers'; +import { getAccessToken } from 'flavours/glitch/initial_state'; +import type { AppDispatch, RootState } from 'flavours/glitch/store'; +import { createAppAsyncThunk } from 'flavours/glitch/store/typed_functions'; + +import api from '../api'; +import { compareId } from '../compare_id'; + +export const synchronouslySubmitMarkers = createAppAsyncThunk( + 'markers/submit', + async (_args, { getState }) => { + const accessToken = getAccessToken(); + const params = buildPostMarkersParams(getState()); + + if ( + Object.keys(params).length === 0 || + !accessToken || + accessToken === '' + ) { + return; + } + + // The Fetch API allows us to perform requests that will be carried out + // after the page closes. But that only works if the `keepalive` attribute + // is supported. + if ('fetch' in window && 'keepalive' in new Request('')) { + await fetch('/api/v1/markers', { + keepalive: true, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(params), + }); + + return; + } else if ('sendBeacon' in navigator) { + // Failing that, we can use sendBeacon, but we have to encode the data as + // FormData for DoorKeeper to recognize the token. + const formData = new FormData(); + + formData.append('bearer_token', accessToken); + + for (const [id, value] of Object.entries(params)) { + if (value.last_read_id) + formData.append(`${id}[last_read_id]`, value.last_read_id); + } + + if (navigator.sendBeacon('/api/v1/markers', formData)) { + return; + } + } + + // If neither Fetch nor sendBeacon worked, try to perform a synchronous + // request. + try { + const client = new XMLHttpRequest(); + + client.open('POST', '/api/v1/markers', false); + client.setRequestHeader('Content-Type', 'application/json'); + client.setRequestHeader('Authorization', `Bearer ${accessToken}`); + client.send(JSON.stringify(params)); + } catch { + // Do not make the BeforeUnload handler error out + } + }, +); + +interface MarkerParam { + last_read_id?: string; +} + +function getLastNotificationId(state: RootState): string | undefined { + return state.notificationGroups.lastReadId; +} + +const buildPostMarkersParams = (state: RootState) => { + const params = {} as { home?: MarkerParam; notifications?: MarkerParam }; + + const lastNotificationId = getLastNotificationId(state); + + if ( + lastNotificationId && + lastNotificationId !== '0' && + compareId(lastNotificationId, state.markers.notifications) > 0 + ) { + params.notifications = { + last_read_id: lastNotificationId, + }; + } + + return params; +}; + +export const submitMarkersAction = createAppAsyncThunk<{ + home: string | undefined; + notifications: string | undefined; +}>('markers/submitAction', async (_args, { getState }) => { + const accessToken = getAccessToken(); + const params = buildPostMarkersParams(getState()); + + if (Object.keys(params).length === 0 || !accessToken || accessToken === '') { + return { home: undefined, notifications: undefined }; + } + + await api().post('/api/v1/markers', params); + + return { + home: params.home?.last_read_id, + notifications: params.notifications?.last_read_id, + }; +}); + +const debouncedSubmitMarkers = debounce( + (dispatch: AppDispatch) => { + void dispatch(submitMarkersAction()); + }, + 300000, + { + leading: true, + trailing: true, + }, +); + +export const submitMarkers = createAppAsyncThunk( + 'markers/submit', + (params: { immediate?: boolean }, { dispatch }) => { + debouncedSubmitMarkers(dispatch); + + if (params.immediate) { + debouncedSubmitMarkers.flush(); + } + }, +); + +export const fetchMarkers = createAppAsyncThunk('markers/fetch', async () => { + const response = await api().get>( + `/api/v1/markers`, + { params: { timeline: ['notifications'] } }, + ); + + return { markers: response.data }; +}); diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js deleted file mode 100644 index ef2ae0e4c7fe6c..00000000000000 --- a/app/javascript/flavours/glitch/actions/modal.js +++ /dev/null @@ -1,18 +0,0 @@ -export const MODAL_OPEN = 'MODAL_OPEN'; -export const MODAL_CLOSE = 'MODAL_CLOSE'; - -export function openModal(type, props) { - return { - type: MODAL_OPEN, - modalType: type, - modalProps: props, - }; -} - -export function closeModal(type, options = { ignoreFocus: false }) { - return { - type: MODAL_CLOSE, - modalType: type, - ignoreFocus: options.ignoreFocus, - }; -} diff --git a/app/javascript/flavours/glitch/actions/modal.ts b/app/javascript/flavours/glitch/actions/modal.ts new file mode 100644 index 00000000000000..01a95051e3c805 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/modal.ts @@ -0,0 +1,19 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { ModalProps } from 'flavours/glitch/reducers/modal'; + +import type { MODAL_COMPONENTS } from '../features/ui/components/modal_root'; + +export type ModalType = keyof typeof MODAL_COMPONENTS; + +interface OpenModalPayload { + modalType: ModalType; + modalProps: ModalProps; +} +export const openModal = createAction('MODAL_OPEN'); + +interface CloseModalPayload { + modalType: ModalType | undefined; + ignoreFocus: boolean; +} +export const closeModal = createAction('MODAL_CLOSE'); diff --git a/app/javascript/flavours/glitch/actions/notification_groups.ts b/app/javascript/flavours/glitch/actions/notification_groups.ts new file mode 100644 index 00000000000000..f85dfac9335e72 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notification_groups.ts @@ -0,0 +1,250 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { + apiClearNotifications, + apiFetchNotificationGroups, +} from 'flavours/glitch/api/notifications'; +import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; +import type { + ApiNotificationGroupJSON, + ApiNotificationJSON, + NotificationType, +} from 'flavours/glitch/api_types/notifications'; +import { allNotificationTypes } from 'flavours/glitch/api_types/notifications'; +import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses'; +import { usePendingItems } from 'flavours/glitch/initial_state'; +import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups'; +import { + selectSettingsNotificationsExcludedTypes, + selectSettingsNotificationsGroupFollows, + selectSettingsNotificationsQuickFilterActive, + selectSettingsNotificationsShows, +} from 'flavours/glitch/selectors/settings'; +import type { AppDispatch, RootState } from 'flavours/glitch/store'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from 'flavours/glitch/store/typed_functions'; + +import { importFetchedAccounts, importFetchedStatuses } from './importer'; +import { NOTIFICATIONS_FILTER_SET } from './notifications'; +import { saveSettings } from './settings'; + +function excludeAllTypesExcept(filter: string) { + return allNotificationTypes.filter((item) => item !== filter); +} + +function getExcludedTypes(state: RootState) { + const activeFilter = selectSettingsNotificationsQuickFilterActive(state); + + return activeFilter === 'all' + ? selectSettingsNotificationsExcludedTypes(state) + : excludeAllTypesExcept(activeFilter); +} + +function dispatchAssociatedRecords( + dispatch: AppDispatch, + notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[], +) { + const fetchedAccounts: ApiAccountJSON[] = []; + const fetchedStatuses: ApiStatusJSON[] = []; + + notifications.forEach((notification) => { + if (notification.type === 'admin.report') { + fetchedAccounts.push(notification.report.target_account); + } + + if (notification.type === 'moderation_warning') { + fetchedAccounts.push(notification.moderation_warning.target_account); + } + + if ('status' in notification && notification.status) { + fetchedStatuses.push(notification.status); + } + }); + + if (fetchedAccounts.length > 0) + dispatch(importFetchedAccounts(fetchedAccounts)); + + if (fetchedStatuses.length > 0) + dispatch(importFetchedStatuses(fetchedStatuses)); +} + +function selectNotificationGroupedTypes(state: RootState) { + const types: NotificationType[] = ['favourite', 'reblog']; + + if (selectSettingsNotificationsGroupFollows(state)) types.push('follow'); + + return types; +} + +export const fetchNotifications = createDataLoadingThunk( + 'notificationGroups/fetch', + async (_params, { getState }) => + apiFetchNotificationGroups({ + grouped_types: selectNotificationGroupedTypes(getState()), + exclude_types: getExcludedTypes(getState()), + }), + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + const payload: (ApiNotificationGroupJSON | NotificationGap)[] = + notifications; + + // TODO: might be worth not using gaps for that… + // if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri }); + if (notifications.length > 1) + payload.push({ type: 'gap', maxId: notifications.at(-1)?.page_min_id }); + + return payload; + // dispatch(submitMarkers()); + }, +); + +export const fetchNotificationsGap = createDataLoadingThunk( + 'notificationGroups/fetchGap', + async (params: { gap: NotificationGap }, { getState }) => + apiFetchNotificationGroups({ + grouped_types: selectNotificationGroupedTypes(getState()), + max_id: params.gap.maxId, + exclude_types: getExcludedTypes(getState()), + }), + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications }; + }, +); + +export const pollRecentNotifications = createDataLoadingThunk( + 'notificationGroups/pollRecentNotifications', + async (_params, { getState }) => { + return apiFetchNotificationGroups({ + grouped_types: selectNotificationGroupedTypes(getState()), + max_id: undefined, + exclude_types: getExcludedTypes(getState()), + // In slow mode, we don't want to include notifications that duplicate the already-displayed ones + since_id: usePendingItems + ? getState().notificationGroups.groups.find( + (group) => group.type !== 'gap', + )?.page_max_id + : undefined, + }); + }, + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications }; + }, +); + +export const processNewNotificationForGroups = createAppAsyncThunk( + 'notificationGroups/processNew', + (notification: ApiNotificationJSON, { dispatch, getState }) => { + const state = getState(); + const activeFilter = selectSettingsNotificationsQuickFilterActive(state); + const notificationShows = selectSettingsNotificationsShows(state); + + const showInColumn = + activeFilter === 'all' + ? notificationShows[notification.type] + : activeFilter === notification.type; + + if (!showInColumn) return; + + if ( + (notification.type === 'mention' || notification.type === 'update') && + notification.status?.filtered + ) { + const filters = notification.status.filtered.filter((result) => + result.filter.context.includes('notifications'), + ); + + if (filters.some((result) => result.filter.filter_action === 'hide')) { + return; + } + } + + dispatchAssociatedRecords(dispatch, [notification]); + + return { + notification, + groupedTypes: selectNotificationGroupedTypes(state), + }; + }, +); + +export const loadPending = createAction('notificationGroups/loadPending'); + +export const updateScrollPosition = createAppAsyncThunk( + 'notificationGroups/updateScrollPosition', + ({ top }: { top: boolean }, { dispatch, getState }) => { + if ( + top && + getState().notificationGroups.mergedNotifications === 'needs-reload' + ) { + void dispatch(fetchNotifications()); + } + + return { top }; + }, +); + +export const setNotificationsFilter = createAppAsyncThunk( + 'notifications/filter/set', + ({ filterType }: { filterType: string }, { dispatch }) => { + dispatch({ + type: NOTIFICATIONS_FILTER_SET, + path: ['notifications', 'quickFilter', 'active'], + value: filterType, + }); + void dispatch(fetchNotifications()); + dispatch(saveSettings()); + }, +); + +export const clearNotifications = createDataLoadingThunk( + 'notifications/clear', + () => apiClearNotifications(), +); + +export const markNotificationsAsRead = createAction( + 'notificationGroups/markAsRead', +); + +export const mountNotifications = createAppAsyncThunk( + 'notificationGroups/mount', + (_, { dispatch, getState }) => { + const state = getState(); + + if ( + state.notificationGroups.mounted === 0 && + state.notificationGroups.mergedNotifications === 'needs-reload' + ) { + void dispatch(fetchNotifications()); + } + }, +); + +export const unmountNotifications = createAction('notificationGroups/unmount'); + +export const refreshStaleNotificationGroups = createAppAsyncThunk<{ + deferredRefresh: boolean; +}>('notificationGroups/refreshStale', (_, { dispatch, getState }) => { + const state = getState(); + + if ( + state.notificationGroups.scrolledToTop || + !state.notificationGroups.mounted + ) { + void dispatch(fetchNotifications()); + return { deferredRefresh: false }; + } + + return { deferredRefresh: true }; +}); diff --git a/app/javascript/flavours/glitch/actions/notification_policies.ts b/app/javascript/flavours/glitch/actions/notification_policies.ts new file mode 100644 index 00000000000000..a64175831f3b1f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notification_policies.ts @@ -0,0 +1,22 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { + apiGetNotificationPolicy, + apiUpdateNotificationsPolicy, +} from 'flavours/glitch/api/notification_policies'; +import type { NotificationPolicy } from 'flavours/glitch/models/notification_policy'; +import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; + +export const fetchNotificationPolicy = createDataLoadingThunk( + 'notificationPolicy/fetch', + () => apiGetNotificationPolicy(), +); + +export const updateNotificationsPolicy = createDataLoadingThunk( + 'notificationPolicy/update', + (policy: Partial) => apiUpdateNotificationsPolicy(policy), +); + +export const decreasePendingRequestsCount = createAction( + 'notificationPolicy/decreasePendingRequestsCount', +); diff --git a/app/javascript/flavours/glitch/actions/notification_requests.ts b/app/javascript/flavours/glitch/actions/notification_requests.ts new file mode 100644 index 00000000000000..f53668abd529f8 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notification_requests.ts @@ -0,0 +1,214 @@ +import { + apiFetchNotificationRequest, + apiFetchNotificationRequests, + apiFetchNotifications, + apiAcceptNotificationRequest, + apiDismissNotificationRequest, + apiAcceptNotificationRequests, + apiDismissNotificationRequests, +} from 'flavours/glitch/api/notifications'; +import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; +import type { + ApiNotificationGroupJSON, + ApiNotificationJSON, +} from 'flavours/glitch/api_types/notifications'; +import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses'; +import type { AppDispatch } from 'flavours/glitch/store'; +import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; + +import { importFetchedAccounts, importFetchedStatuses } from './importer'; +import { decreasePendingRequestsCount } from './notification_policies'; + +// TODO: refactor with notification_groups +function dispatchAssociatedRecords( + dispatch: AppDispatch, + notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[], +) { + const fetchedAccounts: ApiAccountJSON[] = []; + const fetchedStatuses: ApiStatusJSON[] = []; + + notifications.forEach((notification) => { + if (notification.type === 'admin.report') { + fetchedAccounts.push(notification.report.target_account); + } + + if (notification.type === 'moderation_warning') { + fetchedAccounts.push(notification.moderation_warning.target_account); + } + + if ('status' in notification && notification.status) { + fetchedStatuses.push(notification.status); + } + }); + + if (fetchedAccounts.length > 0) + dispatch(importFetchedAccounts(fetchedAccounts)); + + if (fetchedStatuses.length > 0) + dispatch(importFetchedStatuses(fetchedStatuses)); +} + +export const fetchNotificationRequests = createDataLoadingThunk( + 'notificationRequests/fetch', + async (_params, { getState }) => { + let sinceId = undefined; + + if (getState().notificationRequests.items.length > 0) { + sinceId = getState().notificationRequests.items[0]?.id; + } + + return apiFetchNotificationRequests({ + since_id: sinceId, + }); + }, + ({ requests, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatch(importFetchedAccounts(requests.map((request) => request.account))); + + return { requests, next: next?.uri }; + }, + { + condition: (_params, { getState }) => + !getState().notificationRequests.isLoading, + }, +); + +export const fetchNotificationRequest = createDataLoadingThunk( + 'notificationRequest/fetch', + async ({ id }: { id: string }) => apiFetchNotificationRequest(id), + { + condition: ({ id }, { getState }) => + !( + getState().notificationRequests.current.item?.id === id || + getState().notificationRequests.current.isLoading + ), + }, +); + +export const expandNotificationRequests = createDataLoadingThunk( + 'notificationRequests/expand', + async (_, { getState }) => { + const nextUrl = getState().notificationRequests.next; + if (!nextUrl) throw new Error('missing URL'); + + return apiFetchNotificationRequests(undefined, nextUrl); + }, + ({ requests, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatch(importFetchedAccounts(requests.map((request) => request.account))); + + return { requests, next: next?.uri }; + }, + { + condition: (_, { getState }) => + !!getState().notificationRequests.next && + !getState().notificationRequests.isLoading, + }, +); + +export const fetchNotificationsForRequest = createDataLoadingThunk( + 'notificationRequest/fetchNotifications', + async ({ accountId }: { accountId: string }, { getState }) => { + const sinceId = + // @ts-expect-error current.notifications.items is not yet typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + getState().notificationRequests.current.notifications.items[0]?.get( + 'id', + ) as string | undefined; + + return apiFetchNotifications({ + since_id: sinceId, + account_id: accountId, + }); + }, + ({ notifications, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications, next: next?.uri }; + }, + { + condition: ({ accountId }, { getState }) => { + const current = getState().notificationRequests.current; + return !( + current.item?.account_id === accountId && + current.notifications.isLoading + ); + }, + }, +); + +export const expandNotificationsForRequest = createDataLoadingThunk( + 'notificationRequest/expandNotifications', + async (_, { getState }) => { + const nextUrl = getState().notificationRequests.current.notifications.next; + if (!nextUrl) throw new Error('missing URL'); + + return apiFetchNotifications(undefined, nextUrl); + }, + ({ notifications, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications, next: next?.uri }; + }, + { + condition: ({ accountId }: { accountId: string }, { getState }) => { + const url = getState().notificationRequests.current.notifications.next; + + return ( + !!url && + !getState().notificationRequests.current.notifications.isLoading && + getState().notificationRequests.current.item?.account_id === accountId + ); + }, + }, +); + +export const acceptNotificationRequest = createDataLoadingThunk( + 'notificationRequest/accept', + ({ id }: { id: string }) => apiAcceptNotificationRequest(id), + (_data, { dispatch, discardLoadData }) => { + dispatch(decreasePendingRequestsCount(1)); + + // The payload is not used in any functions + return discardLoadData; + }, +); + +export const dismissNotificationRequest = createDataLoadingThunk( + 'notificationRequest/dismiss', + ({ id }: { id: string }) => apiDismissNotificationRequest(id), + (_data, { dispatch, discardLoadData }) => { + dispatch(decreasePendingRequestsCount(1)); + + // The payload is not used in any functions + return discardLoadData; + }, +); + +export const acceptNotificationRequests = createDataLoadingThunk( + 'notificationRequests/acceptBulk', + ({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids), + (_data, { dispatch, discardLoadData, actionArg: { ids } }) => { + dispatch(decreasePendingRequestsCount(ids.length)); + + // The payload is not used in any functions + return discardLoadData; + }, +); + +export const dismissNotificationRequests = createDataLoadingThunk( + 'notificationRequests/dismissBulk', + ({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids), + (_data, { dispatch, discardLoadData, actionArg: { ids } }) => { + dispatch(decreasePendingRequestsCount(ids.length)); + + // The payload is not used in any functions + return discardLoadData; + }, +); diff --git a/app/javascript/flavours/glitch/actions/notifications_migration.tsx b/app/javascript/flavours/glitch/actions/notifications_migration.tsx new file mode 100644 index 00000000000000..6f2d38b9e94677 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notifications_migration.tsx @@ -0,0 +1,10 @@ +import { createAppAsyncThunk } from 'flavours/glitch/store'; + +import { fetchNotifications } from './notification_groups'; + +export const initializeNotifications = createAppAsyncThunk( + 'notifications/initialize', + (_, { dispatch }) => { + void dispatch(fetchNotifications()); + }, +); diff --git a/app/javascript/flavours/glitch/actions/notifications_typed.ts b/app/javascript/flavours/glitch/actions/notifications_typed.ts new file mode 100644 index 00000000000000..55896f4c378f49 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notifications_typed.ts @@ -0,0 +1,18 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { ApiNotificationJSON } from 'flavours/glitch/api_types/notifications'; + +export const notificationsUpdate = createAction( + 'notifications/update', + ({ + playSound, + ...args + }: { + notification: ApiNotificationJSON; + usePendingItems: boolean; + playSound: boolean; + }) => ({ + payload: args, + meta: { sound: playSound ? 'boop' : undefined }, + }), +); diff --git a/app/javascript/flavours/glitch/actions/picture_in_picture.js b/app/javascript/flavours/glitch/actions/picture_in_picture.js deleted file mode 100644 index 33d8d57d475d05..00000000000000 --- a/app/javascript/flavours/glitch/actions/picture_in_picture.js +++ /dev/null @@ -1,45 +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 - * @return {object} - */ -export const deployPictureInPicture = (statusId, accountId, playerType, props) => { - 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/flavours/glitch/actions/picture_in_picture.ts b/app/javascript/flavours/glitch/actions/picture_in_picture.ts new file mode 100644 index 00000000000000..9ad88ae29bd6bd --- /dev/null +++ b/app/javascript/flavours/glitch/actions/picture_in_picture.ts @@ -0,0 +1,31 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { PIPMediaProps } from 'flavours/glitch/reducers/picture_in_picture'; +import { createAppAsyncThunk } from 'flavours/glitch/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/flavours/glitch/actions/timelines_typed.ts b/app/javascript/flavours/glitch/actions/timelines_typed.ts new file mode 100644 index 00000000000000..485b94ed524fd3 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/timelines_typed.ts @@ -0,0 +1,20 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state'; + +export const disconnectTimeline = createAction( + 'timeline/disconnect', + ({ timeline }: { timeline: string }) => ({ + payload: { + timeline, + usePendingItems: preferPendingItems, + }, + }), +); + +export const timelineDelete = createAction<{ + statusId: string; + accountId: string; + references: string[]; + reblogOf: string | null; +}>('timelines/delete'); diff --git a/app/javascript/flavours/glitch/api.js b/app/javascript/flavours/glitch/api.js deleted file mode 100644 index 6bbddbef66257a..00000000000000 --- a/app/javascript/flavours/glitch/api.js +++ /dev/null @@ -1,75 +0,0 @@ -// @ts-check - -import axios from 'axios'; -import LinkHeader from 'http-link-header'; -import ready from './ready'; - -/** - * @param {import('axios').AxiosResponse} response - * @returns {LinkHeader} - */ -export const getLinks = response => { - const value = response.headers.link; - - if (!value) { - return new LinkHeader(); - } - - return LinkHeader.parse(value); -}; - -/** @type {import('axios').RawAxiosRequestHeaders} */ -const csrfHeader = {}; - -/** - * @returns {void} - */ -const setCSRFHeader = () => { - /** @type {HTMLMetaElement | null} */ - const csrfToken = document.querySelector('meta[name=csrf-token]'); - - if (csrfToken) { - csrfHeader['X-CSRF-Token'] = csrfToken.content; - } -}; - -ready(setCSRFHeader); - -/** - * @param {() => import('immutable').Map} getState - * @returns {import('axios').RawAxiosRequestHeaders} - */ -const authorizationHeaderFromState = getState => { - const accessToken = getState && getState().getIn(['meta', 'access_token'], ''); - - if (!accessToken) { - return {}; - } - - return { - 'Authorization': `Bearer ${accessToken}`, - }; -}; - -/** - * @param {() => import('immutable').Map} getState - * @returns {import('axios').AxiosInstance} - */ -export default function api(getState) { - return axios.create({ - headers: { - ...csrfHeader, - ...authorizationHeaderFromState(getState), - }, - - transformResponse: [ - function (data) { - try { - return JSON.parse(data); - } catch { - return data; - } - }, - ], - }); -} diff --git a/app/javascript/flavours/glitch/api.ts b/app/javascript/flavours/glitch/api.ts new file mode 100644 index 00000000000000..51cbe0b6954e09 --- /dev/null +++ b/app/javascript/flavours/glitch/api.ts @@ -0,0 +1,111 @@ +import type { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios'; +import axios from 'axios'; +import LinkHeader from 'http-link-header'; + +import { getAccessToken } from './initial_state'; +import ready from './ready'; + +export const getLinks = (response: AxiosResponse) => { + const value = response.headers.link as string | undefined; + + if (!value) { + return new LinkHeader(); + } + + return LinkHeader.parse(value); +}; + +const csrfHeader: RawAxiosRequestHeaders = {}; + +const setCSRFHeader = () => { + const csrfToken = document.querySelector( + 'meta[name=csrf-token]', + ); + + if (csrfToken) { + csrfHeader['X-CSRF-Token'] = csrfToken.content; + } +}; + +void ready(setCSRFHeader); + +const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => { + const accessToken = getAccessToken(); + + if (!accessToken) return {}; + + return { + Authorization: `Bearer ${accessToken}`, + }; +}; + +// eslint-disable-next-line import/no-default-export +export default function api(withAuthorization = true) { + return axios.create({ + transitional: { + clarifyTimeoutError: true, + }, + headers: { + ...csrfHeader, + ...(withAuthorization ? authorizationTokenFromInitialState() : {}), + }, + + transformResponse: [ + function (data: unknown) { + try { + return JSON.parse(data as string) as unknown; + } catch { + return data; + } + }, + ], + }); +} + +type RequestParamsOrData = Record; + +export async function apiRequest( + method: Method, + url: string, + args: { + params?: RequestParamsOrData; + data?: RequestParamsOrData; + timeout?: number; + } = {}, +) { + const { data } = await api().request({ + method, + url: '/api/' + url, + ...args, + }); + + return data; +} + +export async function apiRequestGet( + url: string, + params?: RequestParamsOrData, +) { + return apiRequest('GET', url, { params }); +} + +export async function apiRequestPost( + url: string, + data?: RequestParamsOrData, +) { + return apiRequest('POST', url, { data }); +} + +export async function apiRequestPut( + url: string, + data?: RequestParamsOrData, +) { + return apiRequest('PUT', url, { data }); +} + +export async function apiRequestDelete( + url: string, + params?: RequestParamsOrData, +) { + return apiRequest('DELETE', url, { params }); +} diff --git a/app/javascript/flavours/glitch/api/accounts.ts b/app/javascript/flavours/glitch/api/accounts.ts new file mode 100644 index 00000000000000..410f3d20e359de --- /dev/null +++ b/app/javascript/flavours/glitch/api/accounts.ts @@ -0,0 +1,7 @@ +import { apiRequestPost } from 'flavours/glitch/api'; +import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships'; + +export const apiSubmitAccountNote = (id: string, value: string) => + apiRequestPost(`v1/accounts/${id}/note`, { + comment: value, + }); diff --git a/app/javascript/flavours/glitch/api/directory.ts b/app/javascript/flavours/glitch/api/directory.ts new file mode 100644 index 00000000000000..72743a2584c4f6 --- /dev/null +++ b/app/javascript/flavours/glitch/api/directory.ts @@ -0,0 +1,15 @@ +import { apiRequestGet } from 'flavours/glitch/api'; +import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; + +export const apiGetDirectory = ( + params: { + order: string; + local: boolean; + offset?: number; + }, + limit = 20, +) => + apiRequestGet('v1/directory', { + ...params, + limit, + }); diff --git a/app/javascript/flavours/glitch/api/interactions.ts b/app/javascript/flavours/glitch/api/interactions.ts new file mode 100644 index 00000000000000..172f97a25634b0 --- /dev/null +++ b/app/javascript/flavours/glitch/api/interactions.ts @@ -0,0 +1,10 @@ +import { apiRequestPost } from 'flavours/glitch/api'; +import type { Status, StatusVisibility } from 'flavours/glitch/models/status'; + +export const apiReblog = (statusId: string, visibility: StatusVisibility) => + apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, { + visibility, + }); + +export const apiUnreblog = (statusId: string) => + apiRequestPost(`v1/statuses/${statusId}/unreblog`); diff --git a/app/javascript/flavours/glitch/api/notification_policies.ts b/app/javascript/flavours/glitch/api/notification_policies.ts new file mode 100644 index 00000000000000..48b78f96fc8333 --- /dev/null +++ b/app/javascript/flavours/glitch/api/notification_policies.ts @@ -0,0 +1,9 @@ +import { apiRequestGet, apiRequestPut } from 'flavours/glitch/api'; +import type { NotificationPolicyJSON } from 'flavours/glitch/api_types/notification_policies'; + +export const apiGetNotificationPolicy = () => + apiRequestGet('v2/notifications/policy'); + +export const apiUpdateNotificationsPolicy = ( + policy: Partial, +) => apiRequestPut('v2/notifications/policy', policy); diff --git a/app/javascript/flavours/glitch/api/notifications.ts b/app/javascript/flavours/glitch/api/notifications.ts new file mode 100644 index 00000000000000..9453f545e61c5d --- /dev/null +++ b/app/javascript/flavours/glitch/api/notifications.ts @@ -0,0 +1,96 @@ +import api, { + apiRequest, + getLinks, + apiRequestGet, + apiRequestPost, +} from 'flavours/glitch/api'; +import type { + ApiNotificationGroupsResultJSON, + ApiNotificationRequestJSON, + ApiNotificationJSON, +} from 'flavours/glitch/api_types/notifications'; + +export const apiFetchNotifications = async ( + params?: { + account_id?: string; + since_id?: string; + }, + url?: string, +) => { + const response = await api().request({ + method: 'GET', + url: url ?? '/api/v1/notifications', + params, + }); + + return { + notifications: response.data, + links: getLinks(response), + }; +}; + +export const apiFetchNotificationGroups = async (params?: { + url?: string; + grouped_types?: string[]; + exclude_types?: string[]; + max_id?: string; + since_id?: string; +}) => { + const response = await api().request({ + method: 'GET', + url: '/api/v2/notifications', + params, + }); + + const { statuses, accounts, notification_groups } = response.data; + + return { + statuses, + accounts, + notifications: notification_groups, + links: getLinks(response), + }; +}; + +export const apiClearNotifications = () => + apiRequest('POST', 'v1/notifications/clear'); + +export const apiFetchNotificationRequests = async ( + params?: { + since_id?: string; + }, + url?: string, +) => { + const response = await api().request({ + method: 'GET', + url: url ?? '/api/v1/notifications/requests', + params, + }); + + return { + requests: response.data, + links: getLinks(response), + }; +}; + +export const apiFetchNotificationRequest = async (id: string) => { + return apiRequestGet( + `v1/notifications/requests/${id}`, + ); +}; + +export const apiAcceptNotificationRequest = async (id: string) => { + return apiRequestPost(`v1/notifications/requests/${id}/accept`); +}; + +export const apiDismissNotificationRequest = async (id: string) => { + return apiRequestPost(`v1/notifications/requests/${id}/dismiss`); +}; + +export const apiAcceptNotificationRequests = async (id: string[]) => { + return apiRequestPost('v1/notifications/requests/accept', { id }); +}; + +export const apiDismissNotificationRequests = async (id: string[]) => { + return apiRequestPost('v1/notifications/requests/dismiss', { id }); +}; diff --git a/app/javascript/flavours/glitch/api_types/accounts.ts b/app/javascript/flavours/glitch/api_types/accounts.ts new file mode 100644 index 00000000000000..fdbd7523fc15cd --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/accounts.ts @@ -0,0 +1,56 @@ +import type { ApiCustomEmojiJSON } from './custom_emoji'; + +export interface ApiAccountFieldJSON { + name: string; + value: string; + verified_at: string | null; +} + +export interface ApiAccountRoleJSON { + color: string; + id: string; + name: string; +} + +// See app/serializers/rest/account_serializer.rb +export interface BaseApiAccountJSON { + acct: string; + avatar: string; + avatar_static: string; + bot: boolean; + created_at: string; + discoverable: boolean; + indexable: boolean; + display_name: string; + emojis: ApiCustomEmojiJSON[]; + fields: ApiAccountFieldJSON[]; + followers_count: number; + following_count: number; + group: boolean; + header: string; + header_static: string; + id: string; + last_status_at: string; + locked: boolean; + noindex?: boolean; + note: string; + roles?: ApiAccountJSON[]; + statuses_count: number; + uri: string; + url: string; + username: string; + moved?: ApiAccountJSON; + suspended?: boolean; + limited?: boolean; + memorial?: boolean; + hide_collections: boolean; +} + +// See app/serializers/rest/muted_account_serializer.rb +export interface ApiMutedAccountJSON extends BaseApiAccountJSON { + mute_expires_at?: string | null; +} + +// For now, we have the same type representing both `Account` and `MutedAccount` +// objects, but we should refactor this in the future. +export type ApiAccountJSON = ApiMutedAccountJSON; diff --git a/app/javascript/flavours/glitch/api_types/custom_emoji.ts b/app/javascript/flavours/glitch/api_types/custom_emoji.ts new file mode 100644 index 00000000000000..05144d6f68d0e8 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/custom_emoji.ts @@ -0,0 +1,8 @@ +// See app/serializers/rest/account_serializer.rb +export interface ApiCustomEmojiJSON { + shortcode: string; + static_url: string; + url: string; + category?: string; + visible_in_picker: boolean; +} diff --git a/app/javascript/flavours/glitch/api_types/markers.ts b/app/javascript/flavours/glitch/api_types/markers.ts new file mode 100644 index 00000000000000..f7664fd7c123e6 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api_types/media_attachments.ts b/app/javascript/flavours/glitch/api_types/media_attachments.ts new file mode 100644 index 00000000000000..fc027ccd2a5d27 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api_types/notification_policies.ts b/app/javascript/flavours/glitch/api_types/notification_policies.ts new file mode 100644 index 00000000000000..1c3970782cb464 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/notification_policies.ts @@ -0,0 +1,15 @@ +// See app/serializers/rest/notification_policy_serializer.rb + +export type NotificationPolicyValue = 'accept' | 'filter' | 'drop'; + +export interface NotificationPolicyJSON { + for_not_following: NotificationPolicyValue; + for_not_followers: NotificationPolicyValue; + for_new_accounts: NotificationPolicyValue; + for_private_mentions: NotificationPolicyValue; + for_limited_accounts: NotificationPolicyValue; + summary: { + pending_requests_count: number; + pending_notifications_count: number; + }; +} diff --git a/app/javascript/flavours/glitch/api_types/notifications.ts b/app/javascript/flavours/glitch/api_types/notifications.ts new file mode 100644 index 00000000000000..17c2ede32bd86e --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/notifications.ts @@ -0,0 +1,160 @@ +// See app/serializers/rest/notification_group_serializer.rb + +import type { AccountWarningAction } from 'flavours/glitch/models/notification_group'; + +import type { ApiAccountJSON } from './accounts'; +import type { ApiReportJSON } from './reports'; +import type { ApiStatusJSON } from './statuses'; + +// See app/model/notification.rb +export const allNotificationTypes = [ + 'follow', + 'follow_request', + 'favourite', + 'reblog', + 'mention', + 'poll', + 'status', + 'update', + 'admin.sign_up', + 'admin.report', + 'moderation_warning', + 'severed_relationships', +]; + +export type NotificationWithStatusType = + | 'favourite' + | 'reblog' + | 'status' + | 'mention' + | 'poll' + | 'update'; + +export type NotificationType = + | NotificationWithStatusType + | 'follow' + | 'follow_request' + | 'moderation_warning' + | 'severed_relationships' + | 'admin.sign_up' + | 'admin.report'; + +export interface BaseNotificationJSON { + id: string; + type: NotificationType; + created_at: string; + group_key: string; + account: ApiAccountJSON; +} + +export interface BaseNotificationGroupJSON { + group_key: string; + notifications_count: number; + type: NotificationType; + sample_account_ids: string[]; + latest_page_notification_at: string; // FIXME: This will only be present if the notification group is returned in a paginated list, not requested directly + most_recent_notification_id: string; + page_min_id?: string; + page_max_id?: string; +} + +interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { + type: NotificationWithStatusType; + status_id: string | null; +} + +interface NotificationWithStatusJSON extends BaseNotificationJSON { + type: NotificationWithStatusType; + status: ApiStatusJSON | null; +} + +interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON { + type: 'admin.report'; + report: ApiReportJSON; +} + +interface ReportNotificationJSON extends BaseNotificationJSON { + type: 'admin.report'; + report: ApiReportJSON; +} + +type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up'; +interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON { + type: SimpleNotificationTypes; +} + +interface SimpleNotificationJSON extends BaseNotificationJSON { + type: SimpleNotificationTypes; +} + +export interface ApiAccountWarningJSON { + id: string; + action: AccountWarningAction; + text: string; + status_ids: string[]; + created_at: string; + target_account: ApiAccountJSON; + appeal: unknown; +} + +interface ModerationWarningNotificationGroupJSON + extends BaseNotificationGroupJSON { + type: 'moderation_warning'; + moderation_warning: ApiAccountWarningJSON; +} + +interface ModerationWarningNotificationJSON extends BaseNotificationJSON { + type: 'moderation_warning'; + moderation_warning: ApiAccountWarningJSON; +} + +export interface ApiAccountRelationshipSeveranceEventJSON { + id: string; + type: 'account_suspension' | 'domain_block' | 'user_domain_block'; + purged: boolean; + target_name: string; + followers_count: number; + following_count: number; + created_at: string; +} + +interface AccountRelationshipSeveranceNotificationGroupJSON + extends BaseNotificationGroupJSON { + type: 'severed_relationships'; + event: ApiAccountRelationshipSeveranceEventJSON; +} + +interface AccountRelationshipSeveranceNotificationJSON + extends BaseNotificationJSON { + type: 'severed_relationships'; + event: ApiAccountRelationshipSeveranceEventJSON; +} + +export type ApiNotificationJSON = + | SimpleNotificationJSON + | ReportNotificationJSON + | AccountRelationshipSeveranceNotificationJSON + | NotificationWithStatusJSON + | ModerationWarningNotificationJSON; + +export type ApiNotificationGroupJSON = + | SimpleNotificationGroupJSON + | ReportNotificationGroupJSON + | AccountRelationshipSeveranceNotificationGroupJSON + | NotificationGroupWithStatusJSON + | ModerationWarningNotificationGroupJSON; + +export interface ApiNotificationGroupsResultJSON { + accounts: ApiAccountJSON[]; + statuses: ApiStatusJSON[]; + notification_groups: ApiNotificationGroupJSON[]; +} + +export interface ApiNotificationRequestJSON { + id: string; + created_at: string; + updated_at: string; + notifications_count: string; + account: ApiAccountJSON; + last_status?: ApiStatusJSON; +} diff --git a/app/javascript/flavours/glitch/api_types/polls.ts b/app/javascript/flavours/glitch/api_types/polls.ts new file mode 100644 index 00000000000000..8181f7b813b82a --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api_types/relationships.ts b/app/javascript/flavours/glitch/api_types/relationships.ts new file mode 100644 index 00000000000000..9f26a0ce9b333d --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/relationships.ts @@ -0,0 +1,18 @@ +// See app/serializers/rest/relationship_serializer.rb +export interface ApiRelationshipJSON { + blocked_by: boolean; + blocking: boolean; + domain_blocking: boolean; + endorsed: boolean; + followed_by: boolean; + following: boolean; + id: string; + languages: string[] | null; + muting_notifications: boolean; + muting: boolean; + note: string; + notifying: boolean; + requested_by: boolean; + requested: boolean; + showing_reblogs: boolean; +} diff --git a/app/javascript/flavours/glitch/api_types/reports.ts b/app/javascript/flavours/glitch/api_types/reports.ts new file mode 100644 index 00000000000000..b11cfdd2eb59c8 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/reports.ts @@ -0,0 +1,16 @@ +import type { ApiAccountJSON } from './accounts'; + +export type ReportCategory = 'other' | 'spam' | 'legal' | 'violation'; + +export interface ApiReportJSON { + id: string; + action_taken: unknown; + action_taken_at: unknown; + category: ReportCategory; + comment: string; + forwarded: boolean; + created_at: string; + status_ids: string[]; + rule_ids: string[]; + target_account: ApiAccountJSON; +} diff --git a/app/javascript/flavours/glitch/api_types/statuses.ts b/app/javascript/flavours/glitch/api_types/statuses.ts new file mode 100644 index 00000000000000..c84ecc7afc4266 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/statuses.ts @@ -0,0 +1,125 @@ +// 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 +export type StatusVisibility = + | 'public' + | 'unlisted' + | 'private' + // | 'limited' // This is never exposed to the API (they become `private`) + | 'direct'; + +export interface ApiStatusApplicationJSON { + name: string; + website: string; +} + +export interface ApiTagJSON { + name: string; + url: string; +} + +export interface ApiMentionJSON { + id: string; + username: string; + url: string; + acct: string; +} + +export interface ApiPreviewCardAuthorJSON { + name: string; + url: string; + account?: ApiAccountJSON; +} + +export interface ApiPreviewCardJSON { + url: string; + title: string; + description: string; + language: string; + type: string; + author_name: string; + author_url: string; + author_account?: ApiAccountJSON; + provider_name: string; + provider_url: string; + html: string; + width: number; + height: number; + image: string; + image_description: string; + embed_url: string; + blurhash: string; + published_at: string; + authors: ApiPreviewCardAuthorJSON[]; +} + +export type FilterContext = + | 'home' + | 'notifications' + | 'public' + | 'thread' + | 'account'; + +export interface ApiFilterJSON { + id: string; + title: string; + context: FilterContext; + expires_at: string; + filter_action: 'warn' | 'hide'; + keywords?: unknown[]; // TODO: FilterKeywordSerializer + statuses?: unknown[]; // TODO: FilterStatusSerializer +} + +export interface ApiFilterResultJSON { + filter: ApiFilterJSON; + keyword_matches: string[]; + status_matches: string[]; +} + +export interface ApiStatusJSON { + id: string; + created_at: string; + in_reply_to_id?: string; + in_reply_to_account_id?: string; + sensitive: boolean; + spoiler_text?: string; + visibility: StatusVisibility; + language: string; + uri: string; + url: string; + replies_count: number; + reblogs_count: number; + favorites_count: number; + edited_at?: string; + + favorited?: boolean; + reblogged?: boolean; + muted?: boolean; + bookmarked?: boolean; + pinned?: boolean; + + filtered?: ApiFilterResultJSON[]; + content?: string; + text?: string; + + reblog?: ApiStatusJSON; + application?: ApiStatusApplicationJSON; + account: ApiAccountJSON; + media_attachments: ApiMediaAttachmentJSON[]; + mentions: ApiMentionJSON[]; + + tags: ApiTagJSON[]; + emojis: ApiCustomEmojiJSON[]; + + card?: ApiPreviewCardJSON; + poll?: ApiPollJSON; + + // glitch-soc additions + local_only?: boolean; + content_type?: string; +} diff --git a/app/javascript/flavours/glitch/base_polyfills.js b/app/javascript/flavours/glitch/base_polyfills.js deleted file mode 100644 index 91bc5d6dc8bf4d..00000000000000 --- a/app/javascript/flavours/glitch/base_polyfills.js +++ /dev/null @@ -1,37 +0,0 @@ -import 'intl'; -import 'intl/locale-data/jsonp/en'; -import 'es6-symbol/implement'; -import assign from 'object-assign'; -import values from 'object.values'; -import { decode as decodeBase64 } from './utils/base64'; -import promiseFinally from 'promise.prototype.finally'; - -if (!Object.assign) { - Object.assign = assign; -} - -if (!Object.values) { - values.shim(); -} - -promiseFinally.shim(); - -if (!HTMLCanvasElement.prototype.toBlob) { - const BASE64_MARKER = ';base64,'; - - Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { - value(callback, type = 'image/png', quality) { - const dataURL = this.toDataURL(type, quality); - let data; - - if (dataURL.indexOf(BASE64_MARKER) >= 0) { - const [, base64] = dataURL.split(BASE64_MARKER); - data = decodeBase64(base64); - } else { - [, data] = dataURL.split(','); - } - - callback(new Blob([data], { type })); - }, - }); -} diff --git a/app/javascript/flavours/glitch/blurhash.js b/app/javascript/flavours/glitch/blurhash.js deleted file mode 100644 index 5adcc3e77021e1..00000000000000 --- a/app/javascript/flavours/glitch/blurhash.js +++ /dev/null @@ -1,112 +0,0 @@ -const DIGIT_CHARACTERS = [ - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - 'A', - 'B', - 'C', - 'D', - 'E', - 'F', - 'G', - 'H', - 'I', - 'J', - 'K', - 'L', - 'M', - 'N', - 'O', - 'P', - 'Q', - 'R', - 'S', - 'T', - 'U', - 'V', - 'W', - 'X', - 'Y', - 'Z', - 'a', - 'b', - 'c', - 'd', - 'e', - 'f', - 'g', - 'h', - 'i', - 'j', - 'k', - 'l', - 'm', - 'n', - 'o', - 'p', - 'q', - 'r', - 's', - 't', - 'u', - 'v', - 'w', - 'x', - 'y', - 'z', - '#', - '$', - '%', - '*', - '+', - ',', - '-', - '.', - ':', - ';', - '=', - '?', - '@', - '[', - ']', - '^', - '_', - '{', - '|', - '}', - '~', -]; - -export const decode83 = (str) => { - let value = 0; - let c, digit; - - for (let i = 0; i < str.length; i++) { - c = str[i]; - digit = DIGIT_CHARACTERS.indexOf(c); - value = value * 83 + digit; - } - - return value; -}; - -export const intToRGB = int => ({ - r: Math.max(0, (int >> 16)), - g: Math.max(0, (int >> 8) & 255), - b: Math.max(0, (int & 255)), -}); - -export const getAverageFromBlurhash = blurhash => { - if (!blurhash) { - return null; - } - - return intToRGB(decode83(blurhash.slice(2, 6))); -}; diff --git a/app/javascript/flavours/glitch/blurhash.ts b/app/javascript/flavours/glitch/blurhash.ts new file mode 100644 index 00000000000000..cafe7b12dcff8b --- /dev/null +++ b/app/javascript/flavours/glitch/blurhash.ts @@ -0,0 +1,111 @@ +const DIGIT_CHARACTERS = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + '#', + '$', + '%', + '*', + '+', + ',', + '-', + '.', + ':', + ';', + '=', + '?', + '@', + '[', + ']', + '^', + '_', + '{', + '|', + '}', + '~', +]; + +export const decode83 = (str: string) => { + let value = 0; + let digit; + + for (const c of str) { + digit = DIGIT_CHARACTERS.indexOf(c); + value = value * 83 + digit; + } + + return value; +}; + +export const intToRGB = (int: number) => ({ + r: Math.max(0, int >> 16), + g: Math.max(0, (int >> 8) & 255), + b: Math.max(0, int & 255), +}); + +export const getAverageFromBlurhash = (blurhash: string) => { + if (!blurhash) { + return null; + } + + return intToRGB(decode83(blurhash.slice(2, 6))); +}; diff --git a/app/javascript/flavours/glitch/common.js b/app/javascript/flavours/glitch/common.js new file mode 100644 index 00000000000000..71f45dbf3bfe6a --- /dev/null +++ b/app/javascript/flavours/glitch/common.js @@ -0,0 +1,11 @@ +import Rails from '@rails/ujs'; + +export function start() { + require.context('@/images/', true, /\.(jpg|png|svg)$/); + + try { + Rails.start(); + } catch { + // If called twice + } +} diff --git a/app/javascript/flavours/glitch/compare_id.js b/app/javascript/flavours/glitch/compare_id.js deleted file mode 100644 index d2bd74f447ab3c..00000000000000 --- a/app/javascript/flavours/glitch/compare_id.js +++ /dev/null @@ -1,11 +0,0 @@ -export default function compareId (id1, id2) { - if (id1 === id2) { - return 0; - } - - if (id1.length === id2.length) { - return id1 > id2 ? 1 : -1; - } else { - return id1.length > id2.length ? 1 : -1; - } -} diff --git a/app/javascript/flavours/glitch/compare_id.ts b/app/javascript/flavours/glitch/compare_id.ts new file mode 100644 index 00000000000000..30b05724817892 --- /dev/null +++ b/app/javascript/flavours/glitch/compare_id.ts @@ -0,0 +1,11 @@ +export function compareId(id1: string, id2: string) { + if (id1 === id2) { + return 0; + } + + if (id1.length === id2.length) { + return id1 > id2 ? 1 : -1; + } else { + return id1.length > id2.length ? 1 : -1; + } +} diff --git a/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx b/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx new file mode 100644 index 00000000000000..b7225fc92e01e4 --- /dev/null +++ b/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx @@ -0,0 +1,214 @@ +import { fromJS } from 'immutable'; + +import type { StatusLike } from '../hashtag_bar'; +import { computeHashtagBarForStatus } from '../hashtag_bar'; + +function createStatus( + content: string, + hashtags: string[], + hasMedia = false, + spoilerText?: string, +) { + return fromJS({ + tags: hashtags.map((name) => ({ name })), + contentHtml: content, + media_attachments: hasMedia ? ['fakeMedia'] : [], + spoiler_text: spoilerText, + }) as unknown as StatusLike; // need to force the type here, as it is not properly defined +} + +describe('computeHashtagBarForStatus', () => { + it('does nothing when there are no tags', () => { + const status = createStatus('

Simple text

', []); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Simple text

"`, + ); + }); + + it('displays out of band hashtags in the bar', () => { + const status = createStatus( + '

Simple text #hashtag

', + ['hashtag', 'test'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['test']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Simple text #hashtag

"`, + ); + }); + + it('does not truncate the contents when the last child is a text node', () => { + const status = createStatus( + 'this is a #test. Some more text', + ['test'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"this is a #test. Some more text"`, + ); + }); + + it('extract tags from the last line', () => { + const status = createStatus( + '

Simple text

#hashtag

', + ['hashtag'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['hashtag']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Simple text

"`, + ); + }); + + it('does not include tags from content', () => { + const status = createStatus( + '

Simple text with a #hashtag

#hashtag

', + ['hashtag'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Simple text with a #hashtag

"`, + ); + }); + + it('works with one line status and hashtags', () => { + const status = createStatus( + '

#test. And another #hashtag

', + ['hashtag', 'test'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

#test. And another #hashtag

"`, + ); + }); + + it('de-duplicate accentuated characters with case differences', () => { + const status = createStatus( + '

Text

#éaa #Éaa

', + ['éaa'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['Éaa']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Text

"`, + ); + }); + + it('handles server-side normalized tags with accentuated characters', () => { + const status = createStatus( + '

Text

#éaa #Éaa

', + ['eaa'], // The server may normalize the hashtags in the `tags` attribute + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['Éaa']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Text

"`, + ); + }); + + it('does not display in bar a hashtag in content with a case difference', () => { + const status = createStatus( + '

Text #Éaa

#éaa

', + ['éaa'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Text #Éaa

"`, + ); + }); + + it('does not modify a status with a line of hashtags only', () => { + const status = createStatus( + '

#test #hashtag

', + ['test', 'hashtag'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

#test #hashtag

"`, + ); + }); + + it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => { + const status = createStatus( + '

This is my content! #hashtag

', + ['hashtag'], + true, + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

This is my content! #hashtag

"`, + ); + }); + + it('puts the hashtags in the bar if a status content is only hashtags and has a media', () => { + const status = createStatus( + '

#test #hashtag

', + ['test', 'hashtag'], + true, + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['test', 'hashtag']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot(`""`); + }); + + it('does not use the hashtag bar if the status content is only hashtags, has a CW and a media', () => { + const status = createStatus( + '

#test #hashtag

', + ['test', 'hashtag'], + true, + 'My CW text', + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

#test #hashtag

"`, + ); + }); +}); diff --git a/app/javascript/flavours/glitch/components/account_bio.tsx b/app/javascript/flavours/glitch/components/account_bio.tsx new file mode 100644 index 00000000000000..567a2374c20f9f --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_bio.tsx @@ -0,0 +1,20 @@ +import { useLinks } from 'flavours/glitch/hooks/useLinks'; + +export const AccountBio: React.FC<{ + note: string; + className: string; +}> = ({ note, className }) => { + const handleClick = useLinks(); + + if (note.length === 0 || note === '

') { + return null; + } + + return ( +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/account_fields.tsx b/app/javascript/flavours/glitch/components/account_fields.tsx new file mode 100644 index 00000000000000..768eb1fa4b343d --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_fields.tsx @@ -0,0 +1,42 @@ +import classNames from 'classnames'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import { Icon } from 'flavours/glitch/components/icon'; +import { useLinks } from 'flavours/glitch/hooks/useLinks'; +import type { Account } from 'flavours/glitch/models/account'; + +export const AccountFields: React.FC<{ + fields: Account['fields']; + limit: number; +}> = ({ fields, limit = -1 }) => { + const handleClick = useLinks(); + + if (fields.size === 0) { + return null; + } + + return ( +
+ {fields.take(limit).map((pair, i) => ( +
+
+ +
+ {pair.get('verified_at') && ( + + )} + +
+
+ ))} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/admin/ImpactReport.jsx b/app/javascript/flavours/glitch/components/admin/ImpactReport.jsx new file mode 100644 index 00000000000000..06d1a6a343e54b --- /dev/null +++ b/app/javascript/flavours/glitch/components/admin/ImpactReport.jsx @@ -0,0 +1,91 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedNumber, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import api from 'flavours/glitch/api'; +import { Skeleton } from 'flavours/glitch/components/skeleton'; + +export default class ImpactReport extends PureComponent { + + static propTypes = { + domain: PropTypes.string.isRequired, + }; + + state = { + loading: true, + data: null, + }; + + componentDidMount () { + const { domain } = this.props; + + const params = { + domain: domain, + include_subdomains: true, + }; + + api(false).post('/api/v1/admin/measures', { + keys: ['instance_accounts', 'instance_follows', 'instance_followers'], + start_at: null, + end_at: null, + instance_accounts: params, + instance_follows: params, + instance_followers: params, + }).then(res => { + this.setState({ + loading: false, + data: res.data, + }); + }).catch(err => { + console.error(err); + }); + } + + render () { + const { loading, data } = this.state; + + return ( +
+

+ + + + + + + + + + 0 })}> + + + + + + 0 })}> + + + + + +
+ + + {loading ? : } +
+ + + {loading ? : } +
+ + + {loading ? : } +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/components/alt_text_badge.tsx b/app/javascript/flavours/glitch/components/alt_text_badge.tsx new file mode 100644 index 00000000000000..99bec1ee51cf71 --- /dev/null +++ b/app/javascript/flavours/glitch/components/alt_text_badge.tsx @@ -0,0 +1,67 @@ +import { useState, useCallback, useRef } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import Overlay from 'react-overlays/Overlay'; +import type { + OffsetValue, + UsePopperOptions, +} from 'react-overlays/esm/usePopper'; + +const offset = [0, 4] as OffsetValue; +const popperConfig = { strategy: 'fixed' } as UsePopperOptions; + +export const AltTextBadge: React.FC<{ + description: string; +}> = ({ description }) => { + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleClick = useCallback(() => { + setOpen((v) => !v); + }, [setOpen]); + + const handleClose = useCallback(() => { + setOpen(false); + }, [setOpen]); + + return ( + <> + + + + {({ props }) => ( +
+
+

+ +

+

{description}

+
+
+ )} +
+ + ); +}; diff --git a/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx b/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx deleted file mode 100644 index d787ed07ad3403..00000000000000 --- a/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ShortNumber from 'flavours/glitch/components/short_number'; -import { FormattedMessage } from 'react-intl'; - -export default class AutosuggestHashtag extends React.PureComponent { - - static propTypes = { - tag: PropTypes.shape({ - name: PropTypes.string.isRequired, - url: PropTypes.string, - history: PropTypes.array, - }).isRequired, - }; - - render() { - const { tag } = this.props; - const weeklyUses = tag.history && ( - total + day.uses * 1, 0)} - /> - ); - - return ( -
-
- #{tag.name} -
- {tag.history !== undefined && ( -
- -
- )} -
- ); - } - -} diff --git a/app/javascript/flavours/glitch/components/autosuggest_hashtag.tsx b/app/javascript/flavours/glitch/components/autosuggest_hashtag.tsx new file mode 100644 index 00000000000000..808f303754e65d --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_hashtag.tsx @@ -0,0 +1,31 @@ +import { ShortNumber } from 'flavours/glitch/components/short_number'; + +interface Props { + tag: { + name: string; + url?: string; + history?: { + uses: number; + accounts: string; + day: string; + }[]; + following?: boolean; + type: 'hashtag'; + }; +} + +export const AutosuggestHashtag: React.FC = ({ tag }) => ( +
+
+ #{tag.name} +
+ + {tag.history !== undefined && ( +
+ total + day.uses * 1, 0)} + /> +
+ )} +
+); diff --git a/app/javascript/flavours/glitch/components/avatar.jsx b/app/javascript/flavours/glitch/components/avatar.jsx deleted file mode 100644 index f30b33e700ec48..00000000000000 --- a/app/javascript/flavours/glitch/components/avatar.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { autoPlayGif } from 'flavours/glitch/initial_state'; -import classNames from 'classnames'; - -export default class Avatar extends React.PureComponent { - - static propTypes = { - account: ImmutablePropTypes.map, - className: PropTypes.string, - size: PropTypes.number.isRequired, - style: PropTypes.object, - inline: PropTypes.bool, - animate: PropTypes.bool, - }; - - static defaultProps = { - animate: autoPlayGif, - size: 20, - inline: false, - }; - - state = { - hovering: false, - }; - - handleMouseEnter = () => { - if (this.props.animate) return; - this.setState({ hovering: true }); - }; - - handleMouseLeave = () => { - if (this.props.animate) return; - this.setState({ hovering: false }); - }; - - render () { - const { - account, - animate, - className, - inline, - size, - } = this.props; - const { hovering } = this.state; - - const style = { - ...this.props.style, - width: `${size}px`, - height: `${size}px`, - backgroundSize: `${size}px ${size}px`, - }; - - if (account) { - const src = account.get('avatar'); - const staticSrc = account.get('avatar_static'); - - if (hovering || animate) { - style.backgroundImage = `url(${src})`; - } else { - style.backgroundImage = `url(${staticSrc})`; - } - } - - return ( -
- ); - } - -} diff --git a/app/javascript/flavours/glitch/components/avatar.tsx b/app/javascript/flavours/glitch/components/avatar.tsx new file mode 100644 index 00000000000000..bb146f0edbd0f7 --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar.tsx @@ -0,0 +1,76 @@ +import { useState, useCallback } from 'react'; + +import classNames from 'classnames'; + +import { useHovering } from 'flavours/glitch/hooks/useHovering'; +import { autoPlayGif } from 'flavours/glitch/initial_state'; +import type { Account } from 'flavours/glitch/models/account'; + +interface Props { + account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there + size: number; + style?: React.CSSProperties; + inline?: boolean; + animate?: boolean; + counter?: number | string; + counterBorderColor?: string; +} + +export const Avatar: React.FC = ({ + account, + animate = autoPlayGif, + size = 20, + inline = false, + style: styleFromParent, + counter, + counterBorderColor, +}) => { + const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const style = { + ...styleFromParent, + width: `${size}px`, + height: `${size}px`, + }; + + const src = + hovering || animate + ? account?.get('avatar') + : account?.get('avatar_static'); + + const handleLoad = useCallback(() => { + setLoading(false); + }, [setLoading]); + + const handleError = useCallback(() => { + setError(true); + }, [setError]); + + return ( +
+ {src && !error && ( + + )} + + {counter && ( +
+ {counter} +
+ )} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/avatar_overlay.jsx b/app/javascript/flavours/glitch/components/avatar_overlay.jsx deleted file mode 100644 index 01dec587a5db5a..00000000000000 --- a/app/javascript/flavours/glitch/components/avatar_overlay.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { autoPlayGif } from 'flavours/glitch/initial_state'; - -export default class AvatarOverlay extends React.PureComponent { - - static propTypes = { - account: ImmutablePropTypes.map.isRequired, - friend: ImmutablePropTypes.map.isRequired, - animate: PropTypes.bool, - }; - - static defaultProps = { - animate: autoPlayGif, - }; - - render() { - const { account, friend, animate } = this.props; - - const baseStyle = { - backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`, - }; - - const overlayStyle = { - backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`, - }; - - return ( -
-
-
-
- ); - } - -} diff --git a/app/javascript/flavours/glitch/components/avatar_overlay.tsx b/app/javascript/flavours/glitch/components/avatar_overlay.tsx new file mode 100644 index 00000000000000..cddca43d5b2177 --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar_overlay.tsx @@ -0,0 +1,57 @@ +import type { Account } from 'flavours/glitch/models/account'; + +import { useHovering } from '../hooks/useHovering'; +import { autoPlayGif } from '../initial_state'; + +interface Props { + account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there + friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there + size?: number; + baseSize?: number; + overlaySize?: number; +} + +export const AvatarOverlay: React.FC = ({ + account, + friend, + size = 46, + baseSize = 36, + overlaySize = 24, +}) => { + const { hovering, handleMouseEnter, handleMouseLeave } = + useHovering(autoPlayGif); + const accountSrc = hovering + ? account?.get('avatar') + : account?.get('avatar_static'); + const friendSrc = hovering + ? friend?.get('avatar') + : friend?.get('avatar_static'); + + return ( +
+
+
+ {accountSrc && {account?.get('acct')}} +
+
+
+
+ {friendSrc && {friend?.get('acct')}} +
+
+
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/badge.jsx b/app/javascript/flavours/glitch/components/badge.jsx new file mode 100644 index 00000000000000..2a335d7f5062fb --- /dev/null +++ b/app/javascript/flavours/glitch/components/badge.jsx @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +import GroupsIcon from '@/material-icons/400-24px/group.svg?react'; +import PersonIcon from '@/material-icons/400-24px/person.svg?react'; +import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react'; + + +export const Badge = ({ icon = , label, domain, roleId }) => ( +
+ {icon} + {label} + {domain && {domain}} +
+); + +Badge.propTypes = { + icon: PropTypes.node, + label: PropTypes.node, + domain: PropTypes.node, + roleId: PropTypes.string +}; + +export const GroupBadge = () => ( + } label={} /> +); + +export const AutomatedBadge = () => ( + } label={} /> +); diff --git a/app/javascript/flavours/glitch/components/blurhash.jsx b/app/javascript/flavours/glitch/components/blurhash.jsx deleted file mode 100644 index 2af5cfc5685f81..00000000000000 --- a/app/javascript/flavours/glitch/components/blurhash.jsx +++ /dev/null @@ -1,65 +0,0 @@ -// @ts-check - -import { decode } from 'blurhash'; -import React, { useRef, useEffect } from 'react'; -import PropTypes from 'prop-types'; - -/** - * @typedef BlurhashPropsBase - * @property {string?} hash Hash to render - * @property {number} width - * Width of the blurred region in pixels. Defaults to 32 - * @property {number} [height] - * Height of the blurred region in pixels. Defaults to width - * @property {boolean} [dummy] - * Whether dummy mode is enabled. If enabled, nothing is rendered - * and canvas left untouched - */ - -/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */ - -/** - * Component that is used to render blurred of blurhash string - * - * @param {BlurhashProps} param1 Props of the component - * @returns Canvas which will render blurred region element to embed - */ -function Blurhash({ - hash, - width = 32, - height = width, - dummy = false, - ...canvasProps -}) { - const canvasRef = /** @type {import('react').MutableRefObject} */ (useRef()); - - useEffect(() => { - const { current: canvas } = canvasRef; - canvas.width = canvas.width; // resets canvas - - if (dummy || !hash) return; - - try { - const pixels = decode(hash, width, height); - const ctx = canvas.getContext('2d'); - const imageData = new ImageData(pixels, width, height); - - ctx.putImageData(imageData, 0, 0); - } catch (err) { - console.error('Blurhash decoding failure', { err, hash }); - } - }, [dummy, hash, width, height]); - - return ( - - ); -} - -Blurhash.propTypes = { - hash: PropTypes.string.isRequired, - width: PropTypes.number, - height: PropTypes.number, - dummy: PropTypes.bool, -}; - -export default React.memo(Blurhash); diff --git a/app/javascript/flavours/glitch/components/blurhash.tsx b/app/javascript/flavours/glitch/components/blurhash.tsx new file mode 100644 index 00000000000000..8e2a8af23e5937 --- /dev/null +++ b/app/javascript/flavours/glitch/components/blurhash.tsx @@ -0,0 +1,48 @@ +import { memo, useRef, useEffect } from 'react'; + +import { decode } from 'blurhash'; + +interface Props extends React.HTMLAttributes { + hash: string; + width?: number; + height?: number; + dummy?: boolean; // Whether dummy mode is enabled. If enabled, nothing is rendered and canvas left untouched + children?: never; +} +const Blurhash: React.FC = ({ + hash, + width = 32, + height = width, + dummy = false, + ...canvasProps +}) => { + const canvasRef = useRef(null); + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const canvas = canvasRef.current!; + + // eslint-disable-next-line no-self-assign + canvas.width = canvas.width; // resets canvas + + if (dummy || !hash) return; + + try { + const pixels = decode(hash, width, height); + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, width, height); + + ctx?.putImageData(imageData, 0, 0); + } catch (err) { + console.error('Blurhash decoding failure', { err, hash }); + } + }, [dummy, hash, width, height]); + + return ( + + ); +}; + +const MemoizedBlurhash = memo(Blurhash); + +export { MemoizedBlurhash as Blurhash }; diff --git a/app/javascript/flavours/glitch/components/button.jsx b/app/javascript/flavours/glitch/components/button.jsx deleted file mode 100644 index 40b8f5a1597b5f..00000000000000 --- a/app/javascript/flavours/glitch/components/button.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; - -export default class Button extends React.PureComponent { - - static propTypes = { - text: PropTypes.node, - onClick: PropTypes.func, - disabled: PropTypes.bool, - block: PropTypes.bool, - secondary: PropTypes.bool, - className: PropTypes.string, - title: PropTypes.string, - children: PropTypes.node, - }; - - handleClick = (e) => { - if (!this.props.disabled) { - this.props.onClick(e); - } - }; - - setRef = (c) => { - this.node = c; - }; - - focus() { - this.node.focus(); - } - - render () { - let attrs = { - className: classNames('button', this.props.className, { - 'button-secondary': this.props.secondary, - 'button--block': this.props.block, - }), - disabled: this.props.disabled, - onClick: this.handleClick, - ref: this.setRef, - }; - - if (this.props.title) attrs.title = this.props.title; - - return ( - - ); - } - -} diff --git a/app/javascript/flavours/glitch/components/button.tsx b/app/javascript/flavours/glitch/components/button.tsx new file mode 100644 index 00000000000000..3e720f7ceead2a --- /dev/null +++ b/app/javascript/flavours/glitch/components/button.tsx @@ -0,0 +1,62 @@ +import type { PropsWithChildren } from 'react'; +import { useCallback } from 'react'; + +import classNames from 'classnames'; + +interface BaseProps + extends Omit, 'children'> { + block?: boolean; + secondary?: boolean; + dangerous?: boolean; +} + +interface PropsChildren extends PropsWithChildren { + text?: undefined; +} + +interface PropsWithText extends BaseProps { + text: JSX.Element | string; + children?: undefined; +} + +type Props = PropsWithText | PropsChildren; + +export const Button: React.FC = ({ + type = 'button', + onClick, + disabled, + block, + secondary, + dangerous, + className, + title, + text, + children, + ...props +}) => { + const handleClick = useCallback>( + (e) => { + if (!disabled && onClick) { + onClick(e); + } + }, + [disabled, onClick], + ); + + return ( + + ); +}; diff --git a/app/javascript/flavours/glitch/components/check.jsx b/app/javascript/flavours/glitch/components/check.jsx deleted file mode 100644 index ee2ef1595a2d03..00000000000000 --- a/app/javascript/flavours/glitch/components/check.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -const Check = () => ( - - - -); - -export default Check; diff --git a/app/javascript/flavours/glitch/components/check_box.tsx b/app/javascript/flavours/glitch/components/check_box.tsx new file mode 100644 index 00000000000000..9bd137abf57cce --- /dev/null +++ b/app/javascript/flavours/glitch/components/check_box.tsx @@ -0,0 +1,48 @@ +import classNames from 'classnames'; + +import CheckIndeterminateSmallIcon from '@/material-icons/400-24px/check_indeterminate_small.svg?react'; +import DoneIcon from '@/material-icons/400-24px/done.svg?react'; + +import { Icon } from './icon'; + +interface Props { + value: string; + checked: boolean; + indeterminate: boolean; + name: string; + onChange: (event: React.ChangeEvent) => void; + label: React.ReactNode; +} + +export const CheckBox: React.FC = ({ + name, + value, + checked, + indeterminate, + onChange, + label, +}) => { + return ( + + ); +}; diff --git a/app/javascript/flavours/glitch/components/circular_progress.tsx b/app/javascript/flavours/glitch/components/circular_progress.tsx new file mode 100644 index 00000000000000..850eb93e482cf1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/circular_progress.tsx @@ -0,0 +1,27 @@ +interface Props { + size: number; + strokeWidth: number; +} + +export const CircularProgress: React.FC = ({ size, strokeWidth }) => { + const viewBox = `0 0 ${size} ${size}`; + const radius = (size - strokeWidth) / 2; + + return ( + + + + ); +}; diff --git a/app/javascript/flavours/glitch/components/collapse_button.jsx b/app/javascript/flavours/glitch/components/collapse_button.jsx new file mode 100644 index 00000000000000..36cda45361de5f --- /dev/null +++ b/app/javascript/flavours/glitch/components/collapse_button.jsx @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import { useCallback } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import ExpandLessIcon from '@/material-icons/400-24px/expand_less.svg?react'; + +import { IconButton } from './icon_button'; + +const messages = defineMessages({ + collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, + uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, +}); + +export const CollapseButton = ({ collapsed, setCollapsed }) => { + const intl = useIntl(); + + const handleCollapsedClick = useCallback((e) => { + if (e.button === 0) { + setCollapsed(!collapsed); + e.preventDefault(); + } + }, [collapsed, setCollapsed]); + + return ( + + ); +}; + +CollapseButton.propTypes = { + collapsed: PropTypes.bool, + setCollapsed: PropTypes.func.isRequired, +}; diff --git a/app/javascript/flavours/glitch/components/column_back_button.jsx b/app/javascript/flavours/glitch/components/column_back_button.jsx deleted file mode 100644 index e9e2615cbb1c02..00000000000000 --- a/app/javascript/flavours/glitch/components/column_back_button.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import PropTypes from 'prop-types'; -import Icon from 'flavours/glitch/components/icon'; -import { createPortal } from 'react-dom'; - -export default class ColumnBackButton extends React.PureComponent { - - static contextTypes = { - router: PropTypes.object, - }; - - static propTypes = { - multiColumn: PropTypes.bool, - }; - - handleClick = (event) => { - // if history is exhausted, or we would leave mastodon, just go to root. - if (window.history.state) { - const state = this.context.router.history.location.state; - if (event.shiftKey && state && state.mastodonBackSteps) { - this.context.router.history.go(-state.mastodonBackSteps); - } else { - this.context.router.history.goBack(); - } - } else { - this.context.router.history.push('/'); - } - }; - - render () { - const { multiColumn } = this.props; - - const component = ( - - ); - - if (multiColumn) { - return component; - } else { - // The portal container and the component may be rendered to the DOM in - // the same React render pass, so the container might not be available at - // the time `render()` is called. - const container = document.getElementById('tabs-bar__portal'); - if (container === null) { - // The container wasn't available, force a re-render so that the - // component can eventually be inserted in the container and not scroll - // with the rest of the area. - this.forceUpdate(); - return component; - } else { - return createPortal(component, container); - } - } - } - -} diff --git a/app/javascript/flavours/glitch/components/column_back_button.tsx b/app/javascript/flavours/glitch/components/column_back_button.tsx new file mode 100644 index 00000000000000..75c594c20accd0 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_back_button.tsx @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; +import { Icon } from 'flavours/glitch/components/icon'; +import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context'; + +import { useAppHistory } from './router'; + +type OnClickCallback = () => void; + +function useHandleClick(onClick?: OnClickCallback) { + const history = useAppHistory(); + + return useCallback(() => { + if (onClick) { + onClick(); + } else if (history.location.state?.fromMastodon) { + history.goBack(); + } else { + history.push('/'); + } + }, [history, onClick]); +} + +export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({ + onClick, +}) => { + const handleClick = useHandleClick(onClick); + + const component = ( + + ); + + return {component}; +}; diff --git a/app/javascript/flavours/glitch/components/column_back_button_slim.jsx b/app/javascript/flavours/glitch/components/column_back_button_slim.jsx deleted file mode 100644 index 4df045b5fb6b0d..00000000000000 --- a/app/javascript/flavours/glitch/components/column_back_button_slim.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import PropTypes from 'prop-types'; -import Icon from 'flavours/glitch/components/icon'; - -export default class ColumnBackButtonSlim extends React.PureComponent { - - static contextTypes = { - router: PropTypes.object, - }; - - handleClick = (event) => { - // if history is exhausted, or we would leave mastodon, just go to root. - if (window.history.state) { - const state = this.context.router.history.location.state; - if (event.shiftKey && state && state.mastodonBackSteps) { - this.context.router.history.go(-state.mastodonBackSteps); - } else { - this.context.router.history.goBack(); - } - } else { - this.context.router.history.push('/'); - } - }; - - render () { - return ( -
-
- - -
-
- ); - } - -} diff --git a/app/javascript/flavours/glitch/components/column_header.jsx b/app/javascript/flavours/glitch/components/column_header.jsx deleted file mode 100644 index 6fbe2955d74134..00000000000000 --- a/app/javascript/flavours/glitch/components/column_header.jsx +++ /dev/null @@ -1,221 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { createPortal } from 'react-dom'; -import classNames from 'classnames'; -import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; -import Icon from 'flavours/glitch/components/icon'; - -const messages = defineMessages({ - show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, - hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, - moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, - moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, -}); - -class ColumnHeader extends React.PureComponent { - - static contextTypes = { - router: PropTypes.object, - identity: PropTypes.object, - }; - - static propTypes = { - intl: PropTypes.object.isRequired, - title: PropTypes.node, - icon: PropTypes.string, - active: PropTypes.bool, - multiColumn: PropTypes.bool, - extraButton: PropTypes.node, - showBackButton: PropTypes.bool, - children: PropTypes.node, - pinned: PropTypes.bool, - placeholder: PropTypes.bool, - onPin: PropTypes.func, - onMove: PropTypes.func, - onClick: PropTypes.func, - appendContent: PropTypes.node, - collapseIssues: PropTypes.bool, - }; - - state = { - collapsed: true, - animating: false, - }; - - historyBack = (skip) => { - // if history is exhausted, or we would leave mastodon, just go to root. - if (window.history.state) { - const state = this.context.router.history.location.state; - if (skip && state && state.mastodonBackSteps) { - this.context.router.history.go(-state.mastodonBackSteps); - } else { - this.context.router.history.goBack(); - } - } else { - this.context.router.history.push('/'); - } - }; - - handleToggleClick = (e) => { - e.stopPropagation(); - this.setState({ collapsed: !this.state.collapsed, animating: true }); - }; - - handleTitleClick = () => { - this.props.onClick?.(); - }; - - handleMoveLeft = () => { - this.props.onMove(-1); - }; - - handleMoveRight = () => { - this.props.onMove(1); - }; - - handleBackClick = (event) => { - this.historyBack(event.shiftKey); - }; - - handleTransitionEnd = () => { - this.setState({ animating: false }); - }; - - handlePin = () => { - if (!this.props.pinned) { - this.historyBack(); - } - this.props.onPin(); - }; - - render () { - const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; - const { collapsed, animating } = this.state; - - const wrapperClassName = classNames('column-header__wrapper', { - 'active': active, - }); - - const buttonClassName = classNames('column-header', { - 'active': active, - }); - - const collapsibleClassName = classNames('column-header__collapsible', { - 'collapsed': collapsed, - 'animating': animating, - }); - - const collapsibleButtonClassName = classNames('column-header__button', { - 'active': !collapsed, - }); - - let extraContent, pinButton, moveButtons, backButton, collapseButton; - - if (children) { - extraContent = ( -
- {children} -
- ); - } - - if (multiColumn && pinned) { - pinButton = ; - - moveButtons = ( -
- - -
- ); - } else if (multiColumn && this.props.onPin) { - pinButton = ; - } - - if (!pinned && (multiColumn || showBackButton)) { - backButton = ( - - ); - } - - const collapsedContent = [ - extraContent, - ]; - - if (multiColumn) { - collapsedContent.push(pinButton); - collapsedContent.push(moveButtons); - } - - if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) { - collapseButton = ( - - ); - } - - const hasTitle = icon && title; - - const component = ( -
-

- {hasTitle && ( - - )} - - {!hasTitle && backButton} - -
- {hasTitle && backButton} - {extraButton} - {collapseButton} -
-

- -
-
- {(!collapsed || animating) && collapsedContent} -
-
- - {appendContent} -
- ); - - if (multiColumn || placeholder) { - return component; - } else { - // The portal container and the component may be rendered to the DOM in - // the same React render pass, so the container might not be available at - // the time `render()` is called. - const container = document.getElementById('tabs-bar__portal'); - if (container === null) { - // The container wasn't available, force a re-render so that the - // component can eventually be inserted in the container and not scroll - // with the rest of the area. - this.forceUpdate(); - return component; - } else { - return createPortal(component, container); - } - } - } - -} - -export default injectIntl(ColumnHeader); diff --git a/app/javascript/flavours/glitch/components/column_header.tsx b/app/javascript/flavours/glitch/components/column_header.tsx new file mode 100644 index 00000000000000..9bd155990466c8 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_header.tsx @@ -0,0 +1,301 @@ +import { useCallback, useState } from 'react'; + +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import AddIcon from '@/material-icons/400-24px/add.svg?react'; +import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; +import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import SettingsIcon from '@/material-icons/400-24px/settings.svg?react'; +import type { IconProp } from 'flavours/glitch/components/icon'; +import { Icon } from 'flavours/glitch/components/icon'; +import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context'; +import { useIdentity } from 'flavours/glitch/identity_context'; + +import { useAppHistory } from './router'; + +const messages = defineMessages({ + show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, + hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, + moveLeft: { + id: 'column_header.moveLeft_settings', + defaultMessage: 'Move column to the left', + }, + moveRight: { + id: 'column_header.moveRight_settings', + defaultMessage: 'Move column to the right', + }, + back: { id: 'column_back_button.label', defaultMessage: 'Back' }, +}); + +const BackButton: React.FC<{ + onlyIcon: boolean; +}> = ({ onlyIcon }) => { + const history = useAppHistory(); + const intl = useIntl(); + + const handleBackClick = useCallback(() => { + if (history.location.state?.fromMastodon) { + history.goBack(); + } else { + history.push('/'); + } + }, [history]); + + return ( + + ); +}; + +export interface Props { + title?: string; + icon?: string; + iconComponent?: IconProp; + active?: boolean; + children?: React.ReactNode; + pinned?: boolean; + multiColumn?: boolean; + extraButton?: React.ReactNode; + showBackButton?: boolean; + placeholder?: boolean; + appendContent?: React.ReactNode; + collapseIssues?: boolean; + onClick?: () => void; + onMove?: (arg0: number) => void; + onPin?: () => void; +} + +export const ColumnHeader: React.FC = ({ + title, + icon, + iconComponent, + active, + children, + pinned, + multiColumn, + extraButton, + showBackButton, + placeholder, + appendContent, + collapseIssues, + onClick, + onMove, + onPin, +}) => { + const intl = useIntl(); + const { signedIn } = useIdentity(); + const history = useAppHistory(); + const [collapsed, setCollapsed] = useState(true); + const [animating, setAnimating] = useState(false); + + const handleToggleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setCollapsed((value) => !value); + setAnimating(true); + }, + [setCollapsed, setAnimating], + ); + + const handleTitleClick = useCallback(() => { + onClick?.(); + }, [onClick]); + + const handleMoveLeft = useCallback(() => { + onMove?.(-1); + }, [onMove]); + + const handleMoveRight = useCallback(() => { + onMove?.(1); + }, [onMove]); + + const handleTransitionEnd = useCallback(() => { + setAnimating(false); + }, [setAnimating]); + + const handlePin = useCallback(() => { + if (!pinned) { + history.replace('/'); + } + + onPin?.(); + }, [history, pinned, onPin]); + + const wrapperClassName = classNames('column-header__wrapper', { + active, + }); + + const buttonClassName = classNames('column-header', { + active, + }); + + const collapsibleClassName = classNames('column-header__collapsible', { + collapsed, + animating, + }); + + const collapsibleButtonClassName = classNames('column-header__button', { + active: !collapsed, + }); + + let extraContent, pinButton, moveButtons, backButton, collapseButton; + + if (children) { + extraContent = ( +
+ {children} +
+ ); + } + + if (multiColumn && pinned) { + pinButton = ( + + ); + + moveButtons = ( +
+ + +
+ ); + } else if (multiColumn && onPin) { + pinButton = ( + + ); + } + + if ( + !pinned && + ((multiColumn && history.location.state?.fromMastodon) || showBackButton) + ) { + backButton = ; + } + + const collapsedContent = [extraContent]; + + if (multiColumn) { + collapsedContent.push( +
+ {pinButton} + {moveButtons} +
, + ); + } + + if (signedIn && (children || (multiColumn && onPin))) { + collapseButton = ( + + ); + } + + const hasIcon = icon && iconComponent; + const hasTitle = hasIcon && title; + + const component = ( +
+

+ {hasTitle && ( + <> + {backButton} + + + + )} + + {!hasTitle && backButton} + +
+ {extraButton} + {collapseButton} +
+

+ +
+
+ {(!collapsed || animating) && collapsedContent} +
+
+ + {appendContent} +
+ ); + + if (placeholder) { + return component; + } else { + return {component}; + } +}; + +// eslint-disable-next-line import/no-default-export +export default ColumnHeader; diff --git a/app/javascript/flavours/glitch/components/common_counter.jsx b/app/javascript/flavours/glitch/components/common_counter.jsx deleted file mode 100644 index dd9b62de9b1b3d..00000000000000 --- a/app/javascript/flavours/glitch/components/common_counter.jsx +++ /dev/null @@ -1,62 +0,0 @@ -// @ts-check -import React from 'react'; -import { FormattedMessage } from 'react-intl'; - -/** - * Returns custom renderer for one of the common counter types - * - * @param {"statuses" | "following" | "followers"} counterType - * Type of the counter - * @param {boolean} isBold Whether display number must be displayed in bold - * @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} - * Renderer function - * @throws If counterType is not covered by this function - */ -export function counterRenderer(counterType, isBold = true) { - /** - * @type {(displayNumber: JSX.Element) => JSX.Element} - */ - const renderCounter = isBold - ? (displayNumber) => {displayNumber} - : (displayNumber) => displayNumber; - - switch (counterType) { - case 'statuses': { - return (displayNumber, pluralReady) => ( - - ); - } - case 'following': { - return (displayNumber, pluralReady) => ( - - ); - } - case 'followers': { - return (displayNumber, pluralReady) => ( - - ); - } - default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`); - } -} diff --git a/app/javascript/flavours/glitch/components/content_warning.tsx b/app/javascript/flavours/glitch/components/content_warning.tsx new file mode 100644 index 00000000000000..82f9ca83ed4907 --- /dev/null +++ b/app/javascript/flavours/glitch/components/content_warning.tsx @@ -0,0 +1,27 @@ +/* Significantly rewritten from upstream to keep the old design for now */ + +import { FormattedMessage } from 'react-intl'; + +export const ContentWarning: React.FC<{ + text: string; + expanded?: boolean; + onClick?: () => void; + icons?: React.ReactNode[]; +}> = ({ text, expanded, onClick, icons }) => ( +

+ {' '} + +

+); diff --git a/app/javascript/flavours/glitch/components/copy_icon_button.jsx b/app/javascript/flavours/glitch/components/copy_icon_button.jsx new file mode 100644 index 00000000000000..9c8d5fb8a0d3a8 --- /dev/null +++ b/app/javascript/flavours/glitch/components/copy_icon_button.jsx @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import { useState, useCallback } from 'react'; + +import { defineMessages } from 'react-intl'; + +import classNames from 'classnames'; + +import { useDispatch } from 'react-redux'; + +import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; +import { showAlert } from 'flavours/glitch/actions/alerts'; +import { IconButton } from 'flavours/glitch/components/icon_button'; + +const messages = defineMessages({ + copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' }, +}); + +export const CopyIconButton = ({ title, value, className }) => { + const [copied, setCopied] = useState(false); + const dispatch = useDispatch(); + + const handleClick = useCallback(() => { + navigator.clipboard.writeText(value); + setCopied(true); + dispatch(showAlert({ message: messages.copied })); + setTimeout(() => setCopied(false), 700); + }, [setCopied, value, dispatch]); + + return ( + + ); +}; + +CopyIconButton.propTypes = { + title: PropTypes.string, + value: PropTypes.string, + className: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/components/copy_paste_text.tsx b/app/javascript/flavours/glitch/components/copy_paste_text.tsx new file mode 100644 index 00000000000000..053654b6e0f561 --- /dev/null +++ b/app/javascript/flavours/glitch/components/copy_paste_text.tsx @@ -0,0 +1,90 @@ +import { useRef, useState, useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; +import { Icon } from 'flavours/glitch/components/icon'; +import { useTimeout } from 'flavours/glitch/hooks/useTimeout'; + +export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => { + const inputRef = useRef(null); + const [copied, setCopied] = useState(false); + const [focused, setFocused] = useState(false); + const [setAnimationTimeout] = useTimeout(); + + const handleInputClick = useCallback(() => { + setCopied(false); + + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + inputRef.current.setSelectionRange(0, value.length); + } + }, [setCopied, value]); + + const handleButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + void navigator.clipboard.writeText(value); + inputRef.current?.blur(); + setCopied(true); + setAnimationTimeout(() => { + setCopied(false); + }, 700); + }, + [setCopied, setAnimationTimeout, value], + ); + + const handleKeyUp = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== ' ') return; + void navigator.clipboard.writeText(value); + setCopied(true); + setAnimationTimeout(() => { + setCopied(false); + }, 700); + }, + [setCopied, setAnimationTimeout, value], + ); + + const handleFocus = useCallback(() => { + setFocused(true); + }, [setFocused]); + + const handleBlur = useCallback(() => { + setFocused(false); + }, [setFocused]); + + return ( +
+