diff --git a/.bundler-audit.yml b/.bundler-audit.yml deleted file mode 100644 index a457fc41e8..0000000000 --- a/.bundler-audit.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -ignore: - # Sidekiq security issue, fixes in the latest Sidekiq 7 but we can not upgrade. Will be fixed in Sidekiq 6.5.10 - - CVE-2023-26141 diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 20aecd71d6..0369521963 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -70,7 +70,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.3.11 + image: libretranslate/libretranslate:v1.3.12 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.eslintrc.js b/.eslintrc.js index d5f0ae1ac5..3bac9ed694 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -9,7 +9,6 @@ module.exports = { 'plugin:import/recommended', 'plugin:promise/recommended', 'plugin:jsdoc/recommended', - 'plugin:prettier/recommended', ], env: { @@ -63,7 +62,9 @@ module.exports = { 'consistent-return': 'error', 'dot-notation': 'error', eqeqeq: ['error', 'always', { 'null': 'ignore' }], + 'indent': ['error', 2], 'jsx-quotes': ['error', 'prefer-single'], + 'semi': ['error', 'always'], 'no-case-declarations': 'off', 'no-catch-shadow': 'error', 'no-console': [ diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index be750a5e41..fa7a0c5353 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1 @@ -patreon: mastodon -open_collective: mastodon -custom: https://sponsor.joinmastodon.org +custom: https://fantia.jp/fanclubs/484677 diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml new file mode 100644 index 0000000000..10e7e53458 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -0,0 +1,74 @@ +name: バグ報告 +description: kmyblueのバグ報告 +labels: [bug] +body: + - type: textarea + attributes: + label: バグの再現手順 + description: どのように操作したらバグが発生したのか、バグが発生する直前までの手順を順番に詳しく教えてください + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: textarea + attributes: + label: 期待する動作 + description: どのように動いてほしかったですか? + validations: + required: true + - type: textarea + attributes: + label: 実際の動作 + description: どのようなバグが発生しましたか? + validations: + required: true + - type: textarea + attributes: + label: 詳しい情報 + validations: + required: false + - type: input + attributes: + label: バグが発生したkmyblueサーバーのドメイン + description: サーバー固有の問題の可能性もありますので、プライバシー上可能な範囲内で、できるだけ書いてください + placeholder: kmy.blue + validations: + required: false + - type: input + attributes: + label: バグが発生したkmyblueのバージョン + description: | + Mastodonではなくkmyblueのバージョンを記述してください。例えばバージョン表記が `v4.2.0+kmyblue.5.1-LTS` の場合、バージョンは `5.1`になります + + バージョンは、PCだと画面左下、スマホだと概要画面の一番下に書いてあります + placeholder: '5.1' + validations: + required: true + - type: input + attributes: + label: ブラウザの名前 + description: | + ブラウザの名前を書いてください。可能であればバージョンも併記してください + placeholder: Firefox 105.0.3 + validations: + required: false + - type: input + attributes: + label: OS + description: | + あなたのOSと、できればバージョンも教えてください。スマホの場合は、「Android」「iPhone」にバージョンをつけてください + placeholder: Windows11 + validations: + required: false + - type: textarea + attributes: + label: その他の詳細情報 + description: | + あなたの環境が特殊な場合、詳しいことを教えてください(例: VPS、tor、学内LANなど) + + サーバー管理者の場合は、Ruby、Node.jsのバージョン、Cloudflareの使用可否なども可能なら書いてください + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml deleted file mode 100644 index 20e27d103c..0000000000 --- a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Bug Report (Web Interface) -description: If you are using Mastodon's web interface and something is not working as expected -labels: [bug, 'status/to triage', 'area/web interface'] -body: - - type: markdown - attributes: - value: | - Make sure that you are submitting a new bug that was not previously reported or already fixed. - - Please use a concise and distinct title for the issue. - - type: textarea - attributes: - label: Steps to reproduce the problem - description: What were you trying to do? - value: | - 1. - 2. - 3. - ... - validations: - required: true - - type: input - attributes: - label: Expected behaviour - description: What should have happened? - validations: - required: true - - type: input - attributes: - label: Actual behaviour - description: What happened? - validations: - required: true - - type: textarea - attributes: - label: Detailed description - validations: - required: false - - type: input - attributes: - label: Mastodon instance - description: The address of the Mastodon instance where you experienced the issue - placeholder: mastodon.social - validations: - required: true - - type: input - attributes: - label: Mastodon version - description: | - This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` - placeholder: v4.1.2 - validations: - required: true - - type: input - attributes: - label: Browser name and version - description: | - What browser are you using when getting this bug? Please specify the version as well. - placeholder: Firefox 105.0.3 - validations: - required: true - - type: input - attributes: - label: Operating system - description: | - What OS are you running? Please specify the version as well. - placeholder: macOS 13.4.1 - validations: - required: true - - type: textarea - attributes: - label: Technical details - description: | - Any additional technical details you may have. This can include the full error log, inspector's output… - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/2.feature_request.yml b/.github/ISSUE_TEMPLATE/2.feature_request.yml new file mode 100644 index 0000000000..10fb4bb23b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2.feature_request.yml @@ -0,0 +1,16 @@ +name: 機能要望 +description: 機能の提案 +labels: [enhancement] +body: + - type: textarea + attributes: + label: 欲しい機能 + description: 欲しい機能の詳細を書いてください + validations: + required: true + - type: textarea + attributes: + label: 必要性 + description: この機能はあなたにとってなぜ必要でしょうか?どういった状況で使われるものですか? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml deleted file mode 100644 index 49d5f57209..0000000000 --- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Bug Report (server / API) -description: | - If something is not working as expected, but is not from using the web interface. -labels: [bug, 'status/to triage'] -body: - - type: markdown - attributes: - value: | - Make sure that you are submitting a new bug that was not previously reported or already fixed. - - Please use a concise and distinct title for the issue. - - type: textarea - attributes: - label: Steps to reproduce the problem - description: What were you trying to do? - value: | - 1. - 2. - 3. - ... - validations: - required: true - - type: input - attributes: - label: Expected behaviour - description: What should have happened? - validations: - required: true - - type: input - attributes: - label: Actual behaviour - description: What happened? - validations: - required: true - - type: textarea - attributes: - label: Detailed description - validations: - required: false - - type: input - attributes: - label: Mastodon instance - description: The address of the Mastodon instance where you experienced the issue - placeholder: mastodon.social - validations: - required: false - - type: input - attributes: - label: Mastodon version - description: | - This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` - placeholder: v4.1.2 - validations: - required: false - - type: textarea - attributes: - label: Technical details - description: | - Any additional technical details you may have, like logs or error traces - value: | - If this is happening on your own Mastodon server, please fill out those: - - Ruby version: (from `ruby --version`, eg. v3.1.2) - - Node.js version: (from `node --version`, eg. v18.16.0) - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/3.feature_request.yml b/.github/ISSUE_TEMPLATE/3.feature_request.yml deleted file mode 100644 index 2cabcf61e0..0000000000 --- a/.github/ISSUE_TEMPLATE/3.feature_request.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Feature Request -description: I have a suggestion -labels: [suggestion] -body: - - type: markdown - attributes: - value: | - Please use a concise and distinct title for the issue. - - Consider: Could it be implemented as a 3rd party app using the REST API instead? - - type: textarea - attributes: - label: Pitch - description: Describe your idea for a feature. Make sure it has not already been suggested/implemented/turned down before. - validations: - required: true - - type: textarea - attributes: - label: Motivation - description: Why do you think this feature is needed? Who would benefit from it? - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/3.spec_change_request.yml b/.github/ISSUE_TEMPLATE/3.spec_change_request.yml new file mode 100644 index 0000000000..e71befe859 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3.spec_change_request.yml @@ -0,0 +1,28 @@ +name: 仕様変更・改善要望 +description: 既存の仕様や挙動変更の要望 +labels: [specchange] +body: + - type: markdown + attributes: + value: 意図したものとは明らかに異なる挙動をしているものはバグとして、もともと仕様として決められた動きをしているものを変更したいときはこちらでお願いします + - type: textarea + attributes: + label: 挙動を変更してほしい機能や動作 + validations: + required: true + - type: textarea + attributes: + label: 現在の挙動 + validations: + required: true + - type: textarea + attributes: + label: 変更してほしい新しい挙動 + validations: + required: true + - type: textarea + attributes: + label: 必要性 + description: この変更はあなたにとってなぜ必要でしょうか?どういった状況で使われるものですか? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index f5d3196528..0086358db1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1 @@ -blank_issues_enabled: false -contact_links: - - name: GitHub Discussions - url: https://github.com/mastodon/mastodon/discussions - about: Please ask and answer questions here. +blank_issues_enabled: true diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 12b991f762..bcf63bb59e 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -284,8 +284,8 @@ jobs: ports: - 6379:6379 - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:7.17.13 + search: + image: ${{ matrix.search-image }} env: discovery.type: single-node xpack.security.enabled: false @@ -315,6 +315,11 @@ jobs: - '3.0' - '3.1' - '.ruby-version' + search-image: + - docker.elastic.co/elasticsearch/elasticsearch:7.17.13 + include: + - ruby-version: '.ruby-version' + search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2 steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 2bc8b18c8f..cb442609a1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,9 +31,6 @@ # Ignore Vagrant files .vagrant/ -# Ignore Capistrano customizations -/config/deploy/* - # Ignore IDE files .vscode/ .idea/ diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml index a839e3789f..6ba6945cd4 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -1,13 +1,13 @@ # This configuration was generated by # `haml-lint --auto-gen-config` -# on 2023-07-20 09:47:50 -0400 using Haml-Lint version 0.48.0. +# on 2023-10-11 11:31:24 -0400 using Haml-Lint version 0.51.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: 951 + # Offense count: 946 LineLength: enabled: false @@ -15,7 +15,7 @@ linters: UnnecessaryStringOutput: enabled: false - # Offense count: 57 + # Offense count: 44 RuboCop: enabled: false @@ -27,23 +27,13 @@ linters: - 'app/views/admin/reports/show.html.haml' - 'app/views/disputes/strikes/show.html.haml' - # Offense count: 32 + # Offense count: 15 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/_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/application/_sidebar.html.haml' - # Offense count: 3 + # Offense count: 2 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/.nvmrc b/.nvmrc index b1b396bcfa..fa69d015bd 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.7 +20.8 diff --git a/.prettierignore b/.prettierignore index 91029f665d..305f0fd753 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,9 +31,6 @@ # Ignore Vagrant files .vagrant/ -# Ignore Capistrano customizations -/config/deploy/* - # Ignore IDE files .vscode/ .idea/ diff --git a/.rubocop.yml b/.rubocop.yml index ef40e95a2a..786a724f0c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -28,6 +28,7 @@ AllCops: - 'Vagrantfile' - 'vendor/**/*' - 'lib/json_ld/*' # Generated files + - 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab - 'lib/templates/**/*' # Reason: Prefer Hashes without extreme indentation @@ -76,12 +77,6 @@ Metrics/AbcSize: - 'lib/mastodon/cli/*.rb' - db/*migrate/**/* -# Reason: -# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocknesting -Metrics/BlockNesting: - Exclude: - - 'lib/mastodon/cli/*.rb' - # Reason: Currently disabled in .rubocop_todo.yml # https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity Metrics/CyclomaticComplexity: diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 3e93f4432b..a31e00d540 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -13,32 +13,6 @@ Bundler/OrderedGems: Exclude: - 'Gemfile' -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, IndentationWidth. -# SupportedStyles: with_first_argument, with_fixed_indentation -Layout/ArgumentAlignment: - Exclude: - - 'config/initializers/cors.rb' - - 'config/initializers/session_store.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle. -# SupportedHashRocketStyles: key, separator, table -# SupportedColonStyles: key, separator, table -# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit -Layout/HashAlignment: - Exclude: - - 'config/environments/production.rb' - - 'config/initializers/rack_attack.rb' - - 'config/routes.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowDoxygenCommentStyle, AllowGemfileRubyComment. -Layout/LeadingCommentSpace: - Exclude: - - 'config/application.rb' - - 'config/initializers/3_omniauth.rb' - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. # URISchemes: http, https @@ -46,14 +20,6 @@ Layout/LineLength: Exclude: - 'app/models/account.rb' -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: require_no_space, require_space -Layout/SpaceInLambdaLiteral: - Exclude: - - 'config/environments/production.rb' - - 'config/initializers/content_security_policy.rb' - # Configuration parameters: AllowComments, AllowEmptyLambdas. Lint/EmptyBlock: Exclude: @@ -844,6 +810,5 @@ Style/TrailingCommaInHashLiteral: Style/WordArray: Exclude: - 'app/helpers/languages_helper.rb' - - 'config/initializers/cors.rb' - 'spec/controllers/settings/imports_controller_spec.rb' - 'spec/models/form/import_spec.rb' diff --git a/Capfile b/Capfile deleted file mode 100644 index 86efa5bacf..0000000000 --- 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/Dockerfile b/Dockerfile index f73bdcf786..8e40091add 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.4 # This needs to be bookworm-slim because the Ruby image is built on bookworm-slim -ARG NODE_VERSION="20.6-bookworm-slim" +ARG NODE_VERSION="20.8-bookworm-slim" FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby FROM node:${NODE_VERSION} as build diff --git a/Gemfile b/Gemfile index 18452b1ce9..cb02c03a74 100644 --- a/Gemfile +++ b/Gemfile @@ -172,12 +172,6 @@ group :development do # Linter CLI for HAML files gem 'haml_lint', require: false - # Deployment automation - gem 'capistrano', '~> 3.17' - gem 'capistrano-rails', '~> 1.6' - gem 'capistrano-rbenv', '~> 2.2' - gem 'capistrano-yarn', '~> 2.0' - # Validate missing i18n keys gem 'i18n-tasks', '~> 1.0', require: false end diff --git a/Gemfile.lock b/Gemfile.lock index d348811d3a..f084549968 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -84,9 +84,9 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - active_model_serializers (0.10.13) - actionpack (>= 4.1, < 7.1) - activemodel (>= 4.1, < 7.1) + active_model_serializers (0.10.14) + actionpack (>= 4.1) + activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) activejob (7.0.8) @@ -112,8 +112,6 @@ GEM addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) aes_key_wrap (1.1.0) - airbrussh (1.4.1) - sshkit (>= 1.6.1, != 1.7.0) android_key_attestation (0.3.0) annotate (3.2.0) activerecord (>= 3.2, < 8.0) @@ -148,7 +146,7 @@ GEM net-http-persistent (~> 4.0) nokogiri (~> 1, >= 1.10.8) base64 (0.1.1) - bcrypt (3.1.18) + bcrypt (3.1.19) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) @@ -175,21 +173,6 @@ GEM bundler-audit (0.9.1) bundler (>= 1.2.0, < 3) thor (~> 1.0) - capistrano (3.17.3) - airbrussh (>= 1.0.0) - i18n - rake (>= 10.0.0) - sshkit (>= 1.9.0) - capistrano-bundler (2.1.0) - capistrano (~> 3.1) - capistrano-rails (1.6.3) - capistrano (~> 3.1) - capistrano-bundler (>= 1.1, < 3) - capistrano-rbenv (2.2.0) - capistrano (~> 3.1) - sshkit (~> 1.3) - capistrano-yarn (2.0.2) - capistrano (~> 3.0) capybara (3.39.2) addressable matrix @@ -227,7 +210,7 @@ GEM database_cleaner-core (2.0.1) date (3.3.3) debug_inspector (1.1.0) - devise (4.9.2) + devise (4.9.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) @@ -324,7 +307,7 @@ GEM ruby-progressbar (~> 1.4) globalid (1.1.0) activesupport (>= 5.0) - haml (6.1.2) + haml (6.2.0) temple (>= 0.8.2) thor tilt @@ -333,8 +316,8 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.50.0) - haml (>= 4.0, < 6.2) + haml_lint (0.51.0) + haml (>= 4.0) parallel (~> 1.10) rainbow rubocop (>= 1.0) @@ -429,12 +412,12 @@ GEM llhttp-ffi (0.4.0) ffi-compiler (~> 1.0) rake (~> 13.0) - lograge (0.13.0) + lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.21.3) + loofah (2.21.4) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -457,7 +440,7 @@ GEM mime-types-data (3.2023.0808) mini_mime (1.1.5) mini_portile2 (2.8.4) - minitest (5.19.0) + minitest (5.20.0) msgpack (1.7.1) multi_json (1.15.0) multipart-post (2.3.0) @@ -473,11 +456,8 @@ GEM net-protocol net-protocol (0.2.1) timeout - net-scp (4.0.0) - net-ssh (>= 2.6.5, < 8.0.0) net-smtp (0.3.3) net-protocol - net-ssh (7.1.0) nio4r (2.5.9) nokogiri (1.15.4) mini_portile2 (~> 2.8.2) @@ -513,7 +493,7 @@ GEM orm_adapter (0.5.0) ox (2.14.17) parallel (1.23.0) - parser (3.2.2.3) + parser (3.2.2.4) ast (~> 2.4.1) racc parslet (2.0.0) @@ -574,7 +554,7 @@ GEM actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1) - rails-dom-testing (2.1.1) + rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) @@ -603,10 +583,10 @@ GEM redis (>= 4) redlock (1.3.2) redis (>= 3.0.0, < 6.0) - regexp_parser (2.8.1) + regexp_parser (2.8.2) request_store (1.5.1) rack (>= 1.4) - responders (3.1.0) + responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) rexml (3.2.6) @@ -642,12 +622,12 @@ GEM sidekiq (>= 5, < 8) rspec-support (3.12.1) rspec_chunked (0.6) - rubocop (1.56.3) + rubocop (1.57.0) base64 (~> 0.1.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.2.3) + parser (>= 3.2.2.4) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) @@ -688,12 +668,12 @@ GEM scenic (1.7.0) activerecord (>= 4.0.0) railties (>= 4.0.0) - selenium-webdriver (4.11.0) + selenium-webdriver (4.13.1) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) semantic_range (3.0.0) - sidekiq (6.5.10) + sidekiq (6.5.12) connection_pool (>= 2.2.5, < 3) rack (~> 2.0) redis (>= 4.5.0, < 5) @@ -728,9 +708,6 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - sshkit (1.21.5) - net-scp (>= 1.1.2) - net-ssh (>= 2.8.0) stackprof (0.2.25) statsd-ruby (1.5.0) stoplight (3.0.2) @@ -749,7 +726,7 @@ GEM climate_control (>= 0.0.3, < 1.0) test-prof (1.2.3) thor (1.2.2) - tilt (2.2.0) + tilt (2.3.0) timeout (0.4.0) tpm-key_attestation (0.12.0) bindata (~> 2.4) @@ -775,7 +752,7 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.8.2) - unicode-display_width (2.4.2) + unicode-display_width (2.5.0) uri (0.12.2) validate_email (0.1.6) activemodel (>= 3.0) @@ -806,7 +783,7 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - websocket (1.2.9) + websocket (1.2.10) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -814,7 +791,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.11) + zeitwerk (2.6.12) PLATFORMS ruby @@ -831,10 +808,6 @@ DEPENDENCIES brakeman (~> 6.0) browser bundler-audit (~> 0.9) - capistrano (~> 3.17) - capistrano-rails (~> 1.6) - capistrano-rbenv (~> 2.2) - capistrano-yarn (~> 2.0) capybara (~> 3.39) charlock_holmes (~> 0.7.7) chewy (~> 7.3) diff --git a/README.md b/README.md index 5620e7069c..887231ea41 100644 --- a/README.md +++ b/README.md @@ -37,16 +37,34 @@ RAILS_ENV=test ES_ENABLED=true RUN_SEARCH_SPECS=true bundle exec rspec spec/sear ## kmyblueの強み +追加の詳細は下記記事もご覧ください。 + +https://note.com/kmycode/n/n5fd5e823ed40 + ### 本家Mastodonへの積極的追従 kmyblueは、いくつかのフォークと異なり、追加機能を控えめにする代わりに本家Mastodonに積極的に追従を行います。バージョン 4 には 4 のよさがありますが、技術的に可能である限り、バージョン 5 へのアップグレードもやぶさかではありません。 kmyblueの追加機能そのままに、Mastodonの新機能も利用できるよう調整を行います。 +### ゆるやかな内輪での運用 + +kmyblueは同人向けサーバーとして出発したため、同人作家に需要のある「内輪ノリを外部にできるだけもらさない」という部分に特化しています。 + +「ローカル公開」という機能によって、「ローカルタイムラインに流すが他のサーバーの連合タイムラインに流さない」投稿が可能です。ただしMisskeyのローカル限定とは異なり、他のサーバーのフォロワーのタイムラインにも投稿は流れます。自分のサーバーの中で内輪で盛り上がって、他のサーバーの連合タイムラインには外面だけの投稿を流すことも可能です。 + +また、通常のMastodonでは公開投稿を他のサーバーの人に自由に検索できるようにすることも可能ですが、kmyblueでは未収載投稿に対して同様の設定が可能です。つまり、ローカルタイムラインにも連合タイムラインにも流れない、誰かの目に自然に触れることはない、でも特定キーワードを使った検索では引っかかりたい、そのような需要に対応できます。ただしこの検索ができるのはMisskeyならびにkmyblueフォークだけです。 + ### 絵文字リアクション対応 kmyblueは絵文字リアクションに対応しているフォークの1つです。絵文字リアクションは Misskey 標準搭載の機能で、需要が高い機能である割には、サーバーに負荷がかかるため本家Mastodonには搭載されていません。絵文字リアクションによってユーザーは「お気に入り」以上「返信」以下のコミュニケーションを気軽に行うことができ、Mastodonの利用体験が向上します。 各ユーザーが自分の投稿に絵文字リアクションをつけることを拒否できるほか、サーバー全体として絵文字リアクションを無効にする設定も可能です(この場合、他サーバーから来た絵文字リアクションはお気に入りとして保存されます) +### プライバシーへの配慮 + +- **ローカル公開** - ローカルタイムラインにのみ投稿を流し、他サーバーの連合タイムラインに流しません。他のサーバーには未収載として配信されます +- **検索許可** - 投稿ごとに検索を許可する範囲を細かく制御できます。これは本家Mastodonにはない特徴です +- **Misskeyへの投稿配送制限** - Misskeyへ未収載投稿を配送する時、「フォロワーのみ」に変換する設定がユーザー個別に可能です。Misskeyの自由な検索からkmyblue上の投稿を保護します + ## kmyblueのブランチ - **main** - 管理者が本家MastodonにPRするときに使うことがあります @@ -58,7 +76,9 @@ kmyblueは絵文字リアクションに対応しているフォークの1つ ## 本家Mastodonからの追加機能 -kmyblueは、本家Mastodonにいくつかの改造を加えています。以下に示します。 +kmyblueは、本家Mastodonにいくつかの改造を加えています。以下に示します。ただし以下はあくまで一例です。ほぼ完全な一覧は、以下の記事を参照してください。 + +https://note.com/kmycode/n/n5fd5e823ed40 ### ローカル公開 diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index c4b7e9c9d2..ffccf7a28e 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -5,15 +5,7 @@ class AboutController < ApplicationController skip_before_action :require_functional! - before_action :set_instance_presenter - def show expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? end - - private - - def set_instance_presenter - @instance_presenter = InstancePresenter.new - end end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index c91b9b7163..edacbd5adc 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -89,17 +89,17 @@ module Admin def update_params params.require(:domain_block).permit(:severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag, - :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) + :reject_straight_follow, :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) end def resource_params params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag, - :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) + :reject_straight_follow, :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) end def form_domain_block_batch_params params.require(:form_domain_block_batch).permit(domain_blocks_attributes: [:enabled, :domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, - :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous]) + :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous]) end def action_from_button diff --git a/app/controllers/admin/friend_servers_controller.rb b/app/controllers/admin/friend_servers_controller.rb new file mode 100644 index 0000000000..729d3b3912 --- /dev/null +++ b/app/controllers/admin/friend_servers_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Admin + class FriendServersController < BaseController + before_action :set_friend, except: [:index, :new, :create] + before_action :warn_signatures_not_enabled!, only: [:new, :edit, :create, :follow, :unfollow, :accept, :reject] + + def index + authorize :friend_server, :update? + @friends = FriendDomain.all + end + + def new + authorize :friend_server, :update? + @friend = FriendDomain.new + end + + def edit + authorize :friend_server, :update? + end + + def create + authorize :friend_server, :update? + + @friend = FriendDomain.new(resource_params) + + if @friend.save + @friend.follow! + redirect_to admin_friend_servers_path + else + render action: :new + end + end + + def update + authorize :friend_server, :update? + + if @friend.update(update_resource_params) + redirect_to admin_friend_servers_path + else + render action: :edit + end + end + + def destroy + authorize :friend_server, :update? + @friend.destroy + redirect_to admin_friend_servers_path + end + + def follow + authorize :friend_server, :update? + @friend.follow! + render action: :edit + end + + def unfollow + authorize :friend_server, :update? + @friend.unfollow! + render action: :edit + end + + def accept + authorize :friend_server, :update? + @friend.accept! + render action: :edit + end + + def reject + authorize :friend_server, :update? + @friend.reject! + render action: :edit + end + + private + + def set_friend + @friend = FriendDomain.find(params[:id]) + end + + def resource_params + params.require(:friend_domain).permit(:domain, :inbox_url, :available, :pseudo_relay, :delivery_local, :unlocked, :allow_all_posts) + end + + def update_resource_params + params.require(:friend_domain).permit(:inbox_url, :available, :pseudo_relay, :delivery_local, :unlocked, :allow_all_posts) + end + + def warn_signatures_not_enabled! + flash.now[:error] = I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode? + end + end +end diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb index bd0660dbaa..e157ed1e1f 100644 --- a/app/controllers/api/v1/admin/domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb @@ -70,7 +70,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController def domain_block_params params.permit(:severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_reports, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, - :reject_new_follow, :detect_invalid_subscription, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) + :reject_new_follow, :reject_friend, :detect_invalid_subscription, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) end def insert_pagination_headers @@ -103,6 +103,6 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController def resource_params params.permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow, - :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) + :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous) end end diff --git a/app/controllers/api/v1/circles/statuses_controller.rb b/app/controllers/api/v1/circles/statuses_controller.rb new file mode 100644 index 0000000000..705731936b --- /dev/null +++ b/app/controllers/api/v1/circles/statuses_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class Api::V1::Circles::StatusesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + + before_action :require_user! + before_action :set_circle + + after_action :insert_pagination_headers, only: :show + + def show + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer + end + + private + + def set_circle + @circle = current_account.circles.find(params[:circle_id]) + end + + def load_statuses + if unlimited? + @circle.statuses.includes(:status_stat).all + else + @circle.statuses.includes(:status_stat).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) + end + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_circle_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_circle_statuses_url pagination_params(since_id: pagination_since_id) unless @statuses.empty? + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end + + def records_continue? + @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index 3b097a3478..4345b61ac7 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -52,11 +52,11 @@ class Api::V1::FiltersController < Api::BaseController end def resource_params - params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :whole_word, context: []) + params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :whole_word, context: []) end def filter_params - resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :context) + resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :context) end def keyword_params diff --git a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb index 4dc4bd92c8..f437576d1b 100644 --- a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb +++ b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb @@ -31,7 +31,7 @@ class Api::V1::Statuses::EmojiReactionsController < Api::BaseController end render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new( - [@status], current_account.id, emoji_reactions_map: { @status.id => false } + [@status], current_account.id ) rescue Mastodon::NotPermittedError not_found diff --git a/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb b/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb new file mode 100644 index 0000000000..4d905ef1a6 --- /dev/null +++ b/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::MentionedAccountsController < Api::BaseController + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:accounts' } + before_action :set_status + after_action :insert_pagination_headers + + def index + cache_if_unauthenticated! + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def load_accounts + scope = default_accounts + scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope.merge(paginated_mentioned_users).to_a + end + + def default_accounts + Account + .without_suspended + .includes(:mentions, :account_stat) + .references(:mentions) + .where(mentions: { status_id: @status.id }) + end + + def paginated_mentioned_users + Mention.paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_status_mentioned_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_status_mentioned_by_index_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.mentions.last.id + end + + def pagination_since_id + @accounts.first.mentions.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show_mentioned_users? + rescue Mastodon::NotPermittedError + not_found + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v2/filters_controller.rb b/app/controllers/api/v2/filters_controller.rb index f3e9938d8c..5e39d77416 100644 --- a/app/controllers/api/v2/filters_controller.rb +++ b/app/controllers/api/v2/filters_controller.rb @@ -43,6 +43,6 @@ class Api::V2::FiltersController < Api::BaseController end def resource_params - params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index e70ae5b1b8..b0f2a02aa3 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -10,7 +10,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :configure_sign_up_params, only: [:create] before_action :set_sessions, only: [:edit, :update] before_action :set_strikes, only: [:edit, :update] - before_action :set_instance_presenter, only: [:new, :create, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :require_not_suspended!, only: [:update] before_action :set_cache_headers, only: [:edit, :update] @@ -107,10 +106,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController private - def set_instance_presenter - @instance_presenter = InstancePresenter.new - end - def set_body_classes @body_classes = %w(edit update).include?(action_name) ? 'admin' : 'lighter' end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 06a3deee2b..5327192b81 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -11,7 +11,6 @@ class Auth::SessionsController < Devise::SessionsController include TwoFactorAuthenticationConcern - before_action :set_instance_presenter, only: [:new] before_action :set_body_classes content_security_policy only: :new do |p| @@ -99,10 +98,6 @@ class Auth::SessionsController < Devise::SessionsController private - def set_instance_presenter - @instance_presenter = InstancePresenter.new - end - def set_body_classes @body_classes = 'lighter' end diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index e9cff22ca8..d63bcc85c9 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -9,17 +9,11 @@ module AccountControllerConcern FOLLOW_PER_PAGE = 12 included do - before_action :set_instance_presenter - after_action :set_link_headers, if: -> { request.format.nil? || request.format == :html } end private - def set_instance_presenter - @instance_presenter = InstancePresenter.new - end - def set_link_headers response.headers['Link'] = LinkHeader.new( [ diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb index 9eb45b90d6..bc2d194c33 100644 --- a/app/controllers/concerns/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/two_factor_authentication_concern.rb @@ -5,6 +5,7 @@ module TwoFactorAuthenticationConcern included do prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] + helper_method :webauthn_enabled? end def two_factor_enabled? @@ -87,4 +88,10 @@ module TwoFactorAuthenticationConcern set_locale { render :two_factor } end + + protected + + def webauthn_enabled? + @webauthn_enabled + end end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index b0b2168884..9549ae3500 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -49,7 +49,7 @@ class FiltersController < ApplicationController end def resource_params - params.require(:custom_filter).permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + params.require(:custom_filter).permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end def set_body_classes diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index ee940e6707..03aa3eb52a 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -3,15 +3,7 @@ class HomeController < ApplicationController include WebAppControllerConcern - before_action :set_instance_presenter - def index expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? end - - private - - def set_instance_presenter - @instance_presenter = InstancePresenter.new - end end diff --git a/app/controllers/privacy_controller.rb b/app/controllers/privacy_controller.rb index 070ee8a06a..860e7c77a0 100644 --- a/app/controllers/privacy_controller.rb +++ b/app/controllers/privacy_controller.rb @@ -5,15 +5,7 @@ class PrivacyController < ApplicationController skip_before_action :require_functional! - before_action :set_instance_presenter - def show expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? end - - private - - def set_instance_presenter - @instance_presenter = InstancePresenter.new - end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 50a8763b72..7d18aef132 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -10,7 +10,6 @@ class StatusesController < ApplicationController before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_status - before_action :set_instance_presenter before_action :redirect_to_original, only: :show before_action :set_body_classes, only: :embed @@ -72,10 +71,6 @@ class StatusesController < ApplicationController not_found end - def set_instance_presenter - @instance_presenter = InstancePresenter.new - end - def redirect_to_original redirect_to(ActivityPub::TagManager.instance.url_for(@status.reblog), allow_other_host: true) if @status.reblog? end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 2007fe8462..b0bdbde956 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -14,7 +14,6 @@ class TagsController < ApplicationController before_action :set_local before_action :set_tag before_action :set_statuses, if: -> { request.format == :rss } - before_action :set_instance_presenter skip_before_action :require_functional!, unless: :limited_federation_mode? @@ -49,10 +48,6 @@ class TagsController < ApplicationController @statuses = cache_collection(TagFeed.new(@tag, nil, local: @local).get(limit_param), Status) end - def set_instance_presenter - @instance_presenter = InstancePresenter.new - end - def limit_param params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE end diff --git a/app/helpers/admin/announcements_helper.rb b/app/helpers/admin/announcements_helper.rb new file mode 100644 index 0000000000..97abe8e011 --- /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/invites_helper.rb b/app/helpers/invites_helper.rb new file mode 100644 index 0000000000..c189061db0 --- /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/kmyblue_capabilities_helper.rb b/app/helpers/kmyblue_capabilities_helper.rb index c1a67b2627..455561a597 100644 --- a/app/helpers/kmyblue_capabilities_helper.rb +++ b/app/helpers/kmyblue_capabilities_helper.rb @@ -17,7 +17,8 @@ module KmyblueCapabilitiesHelper :kmyblue_bookmark_category, :kmyblue_quote, :kmyblue_searchability_limited, - :kmyblue_visibility_public_unlisted, + :kmyblue_searchability_public_unlisted, + :kmyblue_circle_history, ] capabilities << :profile_search unless Chewy.enabled? @@ -25,6 +26,32 @@ module KmyblueCapabilitiesHelper capabilities << :emoji_reaction capabilities << :enable_wide_emoji_reaction end + capabilities << :kmyblue_visibility_public_unlisted if Setting.enable_public_unlisted_visibility + + capabilities + end + + def capabilities_for_nodeinfo + capabilities = %i( + wide_emoji + status_reference + quote + kmyblue_quote + searchability + kmyblue_searchability + visibility_mutual + visibility_limited + kmyblue_antenna + kmyblue_bookmark_category + kmyblue_searchability_limited + kmyblue_circle_history + ) + + capabilities << :full_text_search if Chewy.enabled? + if Setting.enable_emoji_reaction + capabilities << :emoji_reaction + capabilities << :enable_wide_emoji_reaction + end capabilities end diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index a8c66552cf..ddb10aa25f 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -230,6 +230,24 @@ module LanguagesHelper 'sr-Latn': 'Srpski (latinica)', }.freeze + # Helper for self.sorted_locale_keys + private_class_method def self.locale_name_for_sorting(locale) + if locale.blank? || locale == 'und' + '000' + elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym]) + ASCIIFolding.new.fold(supported_locale[1]).downcase + elsif (regional_locale = REGIONAL_LOCALE_NAMES[locale.to_sym]) + ASCIIFolding.new.fold(regional_locale).downcase + else + locale + end + end + + # Sort locales by native name for dropdown menus + def self.sorted_locale_keys(locale_keys) + locale_keys.sort_by { |key, _| locale_name_for_sorting(key) } + end + def native_locale_name(locale) if locale.blank? || locale == 'und' I18n.t('generic.none') @@ -254,6 +272,7 @@ module LanguagesHelper def valid_locale_or_nil(str) return if str.blank? + return str if valid_locale?(str) code, = str.to_s.split(/[_-]/) # Strip out the region from e.g. en_US or ja-JP diff --git a/app/helpers/mascot_helper.rb b/app/helpers/mascot_helper.rb index 0124c74f19..8ee04383ec 100644 --- a/app/helpers/mascot_helper.rb +++ b/app/helpers/mascot_helper.rb @@ -5,8 +5,6 @@ module MascotHelper 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 diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index 0d5a8505a2..2fb9ce72cb 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -3,11 +3,12 @@ module RoutingHelper extend ActiveSupport::Concern - include Rails.application.routes.url_helpers include ActionView::Helpers::AssetTagHelper include Webpacker::Helper included do + include Rails.application.routes.url_helpers + def default_url_options ActionMailer::Base.default_url_options end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 889ca7f402..fce36bf43e 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -2,7 +2,11 @@ module SettingsHelper def filterable_languages - LanguagesHelper::SUPPORTED_LOCALES.keys + LanguagesHelper.sorted_locale_keys(LanguagesHelper::SUPPORTED_LOCALES.keys) + end + + def ui_languages + LanguagesHelper.sorted_locale_keys(I18n.available_locales) end def session_device_icon(session) diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js index 051a9675b3..42834146bf 100644 --- a/app/javascript/mastodon/actions/alerts.js +++ b/app/javascript/mastodon/actions/alerts.js @@ -56,4 +56,4 @@ export const showAlertForError = (error, skipNotFound = false) => { title: messages.unexpectedTitle, message: messages.unexpectedMessage, }); -} +}; diff --git a/app/javascript/mastodon/actions/circles.js b/app/javascript/mastodon/actions/circles.js index 6a52e541c9..a497b27d5d 100644 --- a/app/javascript/mastodon/actions/circles.js +++ b/app/javascript/mastodon/actions/circles.js @@ -1,7 +1,7 @@ -import api from '../api'; +import api, { getLinks } from '../api'; import { showAlertForError } from './alerts'; -import { importFetchedAccounts } from './importer'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST'; export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS'; @@ -50,6 +50,14 @@ export const CIRCLE_ADDER_CIRCLES_FETCH_REQUEST = 'CIRCLE_ADDER_CIRCLES_FETCH_RE export const CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS = 'CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS'; export const CIRCLE_ADDER_CIRCLES_FETCH_FAIL = 'CIRCLE_ADDER_CIRCLES_FETCH_FAIL'; +export const CIRCLE_STATUSES_FETCH_REQUEST = 'CIRCLE_STATUSES_FETCH_REQUEST'; +export const CIRCLE_STATUSES_FETCH_SUCCESS = 'CIRCLE_STATUSES_FETCH_SUCCESS'; +export const CIRCLE_STATUSES_FETCH_FAIL = 'CIRCLE_STATUSES_FETCH_FAIL'; + +export const CIRCLE_STATUSES_EXPAND_REQUEST = 'CIRCLE_STATUSES_EXPAND_REQUEST'; +export const CIRCLE_STATUSES_EXPAND_SUCCESS = 'CIRCLE_STATUSES_EXPAND_SUCCESS'; +export const CIRCLE_STATUSES_EXPAND_FAIL = 'CIRCLE_STATUSES_EXPAND_FAIL'; + export const fetchCircle = id => (dispatch, getState) => { if (getState().getIn(['circles', id])) { return; @@ -370,3 +378,89 @@ export const removeFromCircleAdder = circleId => (dispatch, getState) => { dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); }; +export function fetchCircleStatuses(circleId) { + return (dispatch, getState) => { + if (getState().getIn(['circles', circleId, 'statuses', 'isLoading'])) { + return; + } + + dispatch(fetchCircleStatusesRequest(circleId)); + + api(getState).get(`/api/v1/circles/${circleId}/statuses`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchCircleStatusesSuccess(circleId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchCircleStatusesFail(circleId, error)); + }); + }; +} + +export function fetchCircleStatusesRequest(id) { + return { + type: CIRCLE_STATUSES_FETCH_REQUEST, + id, + }; +} + +export function fetchCircleStatusesSuccess(id, statuses, next) { + return { + type: CIRCLE_STATUSES_FETCH_SUCCESS, + id, + statuses, + next, + }; +} + +export function fetchCircleStatusesFail(id, error) { + return { + type: CIRCLE_STATUSES_FETCH_FAIL, + id, + error, + }; +} + +export function expandCircleStatuses(circleId) { + return (dispatch, getState) => { + const url = getState().getIn(['circles', circleId, 'statuses', 'next'], null); + + if (url === null || getState().getIn(['circles', circleId, 'statuses', 'isLoading'])) { + return; + } + + dispatch(expandCircleStatusesRequest(circleId)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandCircleStatusesSuccess(circleId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandCircleStatusesFail(circleId, error)); + }); + }; +} + +export function expandCircleStatusesRequest(id) { + return { + type: CIRCLE_STATUSES_EXPAND_REQUEST, + id, + }; +} + +export function expandCircleStatusesSuccess(id, statuses, next) { + return { + type: CIRCLE_STATUSES_EXPAND_SUCCESS, + id, + statuses, + next, + }; +} + +export function expandCircleStatusesFail(id, error) { + return { + type: CIRCLE_STATUSES_EXPAND_FAIL, + id, + error, + }; +} + diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 1f682d1321..608c1f07aa 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -28,6 +28,8 @@ export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_RESET = 'COMPOSE_RESET'; +export const COMPOSE_WITH_CIRCLE_SUCCESS = 'COMPOSE_WITH_CIRCLE_SUCCESS'; + export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; @@ -174,6 +176,7 @@ export function submitCompose(routerHistory) { const status = getState().getIn(['compose', 'text'], ''); const media = getState().getIn(['compose', 'media_attachments']); const statusId = getState().getIn(['compose', 'id'], null); + const circleId = getState().getIn(['compose', 'circle_id'], null); if ((!status || !status.length) && media.size === 0) { return; @@ -253,6 +256,10 @@ export function submitCompose(routerHistory) { insertIfOnline(`account:${response.data.account.id}`); } + if (statusId === null && circleId !== null && circleId !== 0) { + dispatch(submitComposeWithCircleSuccess({ ...response.data }, circleId)); + } + dispatch(showAlert({ message: statusId === null ? messages.published : messages.saved, action: messages.open, @@ -278,6 +285,14 @@ export function submitComposeSuccess(status) { }; } +export function submitComposeWithCircleSuccess(status, circleId) { + return { + type: COMPOSE_WITH_CIRCLE_SUCCESS, + status, + circleId, + }; +} + export function submitComposeFail(error) { return { type: COMPOSE_SUBMIT_FAIL, diff --git a/app/javascript/mastodon/actions/dropdown_menu.js b/app/javascript/mastodon/actions/dropdown_menu.js deleted file mode 100644 index 023151d4bf..0000000000 --- a/app/javascript/mastodon/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/mastodon/actions/dropdown_menu.ts b/app/javascript/mastodon/actions/dropdown_menu.ts new file mode 100644 index 0000000000..3694df1ae0 --- /dev/null +++ b/app/javascript/mastodon/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/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 369be6b8fb..50b90ef655 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -80,6 +80,10 @@ export function importFetchedStatuses(statuses) { processStatus(status.reblog); } + if (status.quote && status.quote.id) { + processStatus(status.quote); + } + if (status.poll && status.poll.id) { pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 3220118f3d..3965c477c0 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -85,6 +85,11 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoiler_text = normalOldStatus.get('spoiler_text'); normalStatus.hidden = normalOldStatus.get('hidden'); + // for quoted post + if (!normalStatus.filtered && normalOldStatus.get('filtered')) { + normalStatus.filtered = normalOldStatus.get('filtered'); + } + if (normalOldStatus.get('translation')) { normalStatus.translation = normalOldStatus.get('translation'); } @@ -116,7 +121,7 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.media_attachments.forEach(item => { const oldItem = list.find(i => i.get('id') === item.id); if (oldItem && oldItem.get('description') === item.description) { - item.translation = oldItem.get('translation') + item.translation = oldItem.get('translation'); } }); } @@ -160,13 +165,13 @@ export function normalizePoll(poll, normalOldPoll) { ...option, voted: poll.own_votes && poll.own_votes.includes(index), titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap), - } + }; if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) { normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']); } - return normalOption + return normalOption; }); return normalPoll; diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index b361809309..640c5c3128 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -71,6 +71,14 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; +export const MENTIONED_USERS_FETCH_REQUEST = 'MENTIONED_USERS_FETCH_REQUEST'; +export const MENTIONED_USERS_FETCH_SUCCESS = 'MENTIONED_USERS_FETCH_SUCCESS'; +export const MENTIONED_USERS_FETCH_FAIL = 'MENTIONED_USERS_FETCH_FAIL'; + +export const MENTIONED_USERS_EXPAND_REQUEST = 'MENTIONED_USERS_EXPAND_REQUEST'; +export const MENTIONED_USERS_EXPAND_SUCCESS = 'MENTIONED_USERS_EXPAND_SUCCESS'; +export const MENTIONED_USERS_EXPAND_FAIL = 'MENTIONED_USERS_EXPAND_FAIL'; + export function reblog(status, visibility) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -735,3 +743,85 @@ export function unpinFail(status, error) { skipLoading: true, }; } + +export function fetchMentionedUsers(id) { + return (dispatch, getState) => { + dispatch(fetchMentionedUsersRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/mentioned_by`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchMentionedUsersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchMentionedUsersFail(id, error)); + }); + }; +} + +export function fetchMentionedUsersRequest(id) { + return { + type: MENTIONED_USERS_FETCH_REQUEST, + id, + }; +} + +export function fetchMentionedUsersSuccess(id, accounts, next) { + return { + type: MENTIONED_USERS_FETCH_SUCCESS, + id, + accounts, + next, + }; +} + +export function fetchMentionedUsersFail(id, error) { + return { + type: MENTIONED_USERS_FETCH_FAIL, + id, + error, + }; +} + +export function expandMentionedUsers(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'mentioned_users', id, 'next']); + if (url === null) { + return; + } + + dispatch(expandMentionedUsersRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandMentionedUsersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandMentionedUsersFail(id, error))); + }; +} + +export function expandMentionedUsersRequest(id) { + return { + type: MENTIONED_USERS_EXPAND_REQUEST, + id, + }; +} + +export function expandMentionedUsersSuccess(id, accounts, next) { + return { + type: MENTIONED_USERS_EXPAND_SUCCESS, + id, + accounts, + next, + }; +} + +export function expandMentionedUsersFail(id, error) { + return { + type: MENTIONED_USERS_EXPAND_FAIL, + id, + error, + }; +} diff --git a/app/javascript/mastodon/actions/modal.ts b/app/javascript/mastodon/actions/modal.ts index af34f5d6af..ab03e46765 100644 --- a/app/javascript/mastodon/actions/modal.ts +++ b/app/javascript/mastodon/actions/modal.ts @@ -1,12 +1,14 @@ import { createAction } from '@reduxjs/toolkit'; +import type { ModalProps } from 'mastodon/reducers/modal'; + import type { MODAL_COMPONENTS } from '../features/ui/components/modal_root'; export type ModalType = keyof typeof MODAL_COMPONENTS; interface OpenModalPayload { modalType: ModalType; - modalProps: unknown; + modalProps: ModalProps; } export const openModal = createAction('MODAL_OPEN'); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 262d055448..d430ae7acf 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -192,4 +192,4 @@ export const connectListStream = listId => * @returns {function(): void} */ export const connectAntennaStream = antennaId => -connectTimelineStream(`antenna:${antennaId}`, 'antenna', { antenna: antennaId }, { fillGaps: () => fillAntennaTimelineGaps(antennaId) }); + connectTimelineStream(`antenna:${antennaId}`, 'antenna', { antenna: antennaId }, { fillGaps: () => fillAntennaTimelineGaps(antennaId) }); diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts new file mode 100644 index 0000000000..e216fb4dd5 --- /dev/null +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -0,0 +1,65 @@ +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; +} + +export interface ApiAccountOtherSettingsJSON { + noindex: boolean; + noai: boolean; + hide_network: boolean; + hide_statuses_count: boolean; + hide_following_count: boolean; + hide_followers_count: boolean; + translatable_private: boolean; + link_preview: boolean; + emoji_reaction_policy?: + | 'allow' + | 'outside_only' + | 'following_only' + | 'followers_only' + | 'mutuals_only' + | 'block'; +} + +// See app/serializers/rest/account_serializer.rb +export interface ApiAccountJSON { + acct: string; + avatar: string; + avatar_static: string; + bot: boolean; + created_at: string; + discoverable: 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; + other_settings: ApiAccountOtherSettingsJSON; + roles: ApiAccountJSON[]; + subscribable: boolean; + statuses_count: number; + uri: string; + url: string; + username: string; + moved?: ApiAccountJSON; + suspended?: boolean; + limited?: boolean; + memorial?: boolean; +} diff --git a/app/javascript/mastodon/api_types/custom_emoji.ts b/app/javascript/mastodon/api_types/custom_emoji.ts new file mode 100644 index 0000000000..45439f0d5a --- /dev/null +++ b/app/javascript/mastodon/api_types/custom_emoji.ts @@ -0,0 +1,12 @@ +// See app/serializers/rest/account_serializer.rb +export interface ApiCustomEmojiJSON { + shortcode: string; + static_url: string; + url: string; + category?: string; + visible_in_picker: boolean; + width?: number; + height?: number; + sensitive?: boolean; + aliases?: string[]; +} diff --git a/app/javascript/mastodon/api_types/relationships.ts b/app/javascript/mastodon/api_types/relationships.ts new file mode 100644 index 0000000000..9f26a0ce9b --- /dev/null +++ b/app/javascript/mastodon/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/mastodon/components/column.jsx b/app/javascript/mastodon/components/column.jsx index cf9df3ba27..abc87a57e5 100644 --- a/app/javascript/mastodon/components/column.jsx +++ b/app/javascript/mastodon/components/column.jsx @@ -22,7 +22,7 @@ export default class Column extends PureComponent { scrollable = document.scrollingElement; } else { scrollable = this.node.querySelector('.scrollable'); - } + } if (!scrollable) { return; diff --git a/app/javascript/mastodon/components/compacted_status.jsx b/app/javascript/mastodon/components/compacted_status.jsx new file mode 100644 index 0000000000..3ce8a542d3 --- /dev/null +++ b/app/javascript/mastodon/components/compacted_status.jsx @@ -0,0 +1,503 @@ +import PropTypes from 'prop-types'; + +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { HotKeys } from 'react-hotkeys'; + +import AttachmentList from 'mastodon/components/attachment_list'; +import { Icon } from 'mastodon/components/icon'; + +import Card from '../features/status/components/card'; +// We use the component (and not the container) since we do not want +// to use the progress bar to show download progress +import Bundle from '../features/ui/components/bundle'; +import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; +import { displayMedia } from '../initial_state'; + +import { Avatar } from './avatar'; +import { DisplayName } from './display_name'; +import { getHashtagBarForStatus } from './hashtag_bar'; +import { RelativeTimestamp } from './relative_timestamp'; +import StatusContent from './status_content'; + +const domParser = new DOMParser(); + +export const textForScreenReader = (intl, status, rebloggedByText = false) => { + const displayName = status.getIn(['account', 'display_name']); + + const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text'); + const contentHtml = status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); + const contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent; + + const values = [ + displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName, + spoilerText && status.get('hidden') ? spoilerText : contentText, + intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), + status.getIn(['account', 'acct']), + ]; + + if (rebloggedByText) { + values.push(rebloggedByText); + } + + return values.join(', '); +}; + +export const defaultMediaVisibility = (status) => { + if (!status) { + return undefined; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + status = status.get('reblog'); + } + + return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); +}; + +const messages = defineMessages({ + public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' }, + login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, + limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, + mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, + circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' }, + personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, + edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, +}); + +class CompactedStatus extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map, + previousId: PropTypes.string, + nextInReplyToId: PropTypes.string, + rootId: PropTypes.string, + onClick: PropTypes.func, + onOpenMedia: PropTypes.func, + onOpenVideo: PropTypes.func, + onHeightChange: PropTypes.func, + onToggleHidden: PropTypes.func, + onToggleCollapsed: PropTypes.func, + onTranslate: PropTypes.func, + onInteractionModal: PropTypes.func, + muted: PropTypes.bool, + hidden: PropTypes.bool, + unread: PropTypes.bool, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, + showThread: PropTypes.bool, + getScrollPosition: PropTypes.func, + updateScrollBottom: PropTypes.func, + cacheMediaWidth: PropTypes.func, + cachedMediaWidth: PropTypes.number, + }; + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'muted', + 'hidden', + 'unread', + ]; + + state = { + showMedia: defaultMediaVisibility(this.props.status), + statusId: undefined, + forceFilter: undefined, + }; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { + return { + showMedia: defaultMediaVisibility(nextProps.status), + statusId: nextProps.status.get('id'), + }; + } else { + return null; + } + } + + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + }; + + handleClick = e => { + if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) { + return; + } + + if (e) { + e.preventDefault(); + } + + this.handleHotkeyOpen(); + }; + + handlePrependAccountClick = e => { + this.handleAccountClick(e, false); + }; + + handleAccountClick = (e, proper = true) => { + if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) { + return; + } + + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + this._openProfile(proper); + }; + + handleExpandedToggle = () => { + this.props.onToggleHidden(this._properStatus()); + }; + + handleCollapsedToggle = isCollapsed => { + this.props.onToggleCollapsed(this._properStatus(), isCollapsed); + }; + + handleTranslate = () => { + this.props.onTranslate(this._properStatus()); + }; + + getAttachmentAspectRatio () { + const attachments = this._properStatus().get('media_attachments'); + + if (attachments.getIn([0, 'type']) === 'video') { + return `${attachments.getIn([0, 'meta', 'original', 'width'])} / ${attachments.getIn([0, 'meta', 'original', 'height'])}`; + } else if (attachments.getIn([0, 'type']) === 'audio') { + return '16 / 9'; + } else { + return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2'; + } + } + + renderLoadingMediaGallery = () => { + return ( +
+ ); + }; + + renderLoadingVideoPlayer = () => { + return ( +
+ ); + }; + + renderLoadingAudioPlayer = () => { + return ( +
+ ); + }; + + handleOpenVideo = (options) => { + const status = this._properStatus(); + const lang = status.getIn(['translation', 'language']) || status.get('language'); + this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, options); + }; + + handleOpenMedia = (media, index) => { + const status = this._properStatus(); + const lang = status.getIn(['translation', 'language']) || status.get('language'); + this.props.onOpenMedia(status.get('id'), media, index, lang); + }; + + handleHotkeyOpenMedia = e => { + const { onOpenMedia, onOpenVideo } = this.props; + const status = this._properStatus(); + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + const lang = status.getIn(['translation', 'language']) || status.get('language'); + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 }); + } else { + onOpenMedia(status.get('id'), status.get('media_attachments'), 0, lang); + } + } + }; + + handleHotkeyOpen = () => { + if (this.props.onClick) { + this.props.onClick(); + return; + } + + const { router } = this.context; + const status = this._properStatus(); + + if (!router) { + return; + } + + router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`); + }; + + handleHotkeyOpenProfile = () => { + this._openProfile(); + }; + + _openProfile = (proper = true) => { + const { router } = this.context; + const status = proper ? this._properStatus() : this.props.status; + + if (!router) { + return; + } + + router.history.push(`/@${status.getIn(['account', 'acct'])}`); + }; + + handleHotkeyMoveUp = e => { + this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured')); + }; + + handleHotkeyMoveDown = e => { + this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured')); + }; + + handleHotkeyToggleHidden = () => { + this.props.onToggleHidden(this._properStatus()); + }; + + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + }; + + _properStatus () { + const { status } = this.props; + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + return status.get('reblog'); + } else { + return status; + } + } + + handleRef = c => { + this.node = c; + }; + + render () { + const { intl, hidden, featured, unread, showThread, previousId, nextInReplyToId, rootId } = this.props; + + let { status } = this.props; + + if (status === null) { + return null; + } + + const handlers = this.props.muted ? {} : { + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleHidden: this.handleHotkeyToggleHidden, + toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, + }; + + let media, isCardMediaWithSensitive, prepend, rebloggedByText; + + if (hidden) { + return ( + +
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {status.get('content')} +
+
+ ); + } + + const connectUp = previousId && previousId === status.get('in_reply_to_id'); + const connectToRoot = rootId && rootId === status.get('in_reply_to_id'); + const connectReply = nextInReplyToId && nextInReplyToId === status.get('id'); + + if (showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) { + const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; + + prepend = ( +
+
+ }} /> +
+ ); + } + + if (status.get('quote_muted')) { + const minHandlers = { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; + + return ( + + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ +
+
+ ); + } + + isCardMediaWithSensitive = false; + + if (status.get('media_attachments').size > 0) { + const language = status.getIn(['translation', 'language']) || status.get('language'); + + if (this.props.muted) { + media = ( + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + + media = ( + + {Component => ( + + )} + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + + media = ( + + {Component => ( + + )} + + ); + } else { + media = ( + + {Component => ( + + )} + + ); + } + } else if (status.get('card') && !this.props.muted) { + media = ( + + ); + isCardMediaWithSensitive = status.get('spoiler_text').length > 0; + } + + const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); + const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0; + + return ( + +
+ {prepend} + +
+ {(connectReply || connectUp || connectToRoot) &&
} + + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + + + + + {(!isCardMediaWithSensitive || !status.get('hidden')) && media} + + {(!status.get('spoiler_text') || expanded) && hashtagBar} +
+
+ + ); + } + +} + +export default injectIntl(CompactedStatus); diff --git a/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js b/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js index a0896d985e..726fee9076 100644 --- a/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js @@ -4,9 +4,14 @@ import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_m import { fetchHistory } from 'mastodon/actions/history'; import DropdownMenu from 'mastodon/components/dropdown_menu'; +/** + * + * @param {import('mastodon/store').RootState} state + * @param {*} props + */ const mapStateToProps = (state, { statusId }) => ({ - openDropdownId: state.getIn(['dropdown_menu', 'openId']), - openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), + openDropdownId: state.dropdownMenu.openId, + openedViaKeyboard: state.dropdownMenu.keyboard, items: state.getIn(['history', statusId, 'items']), loading: state.getIn(['history', statusId, 'loading']), }); @@ -15,11 +20,11 @@ const mapDispatchToProps = (dispatch, { statusId }) => ({ onOpen (id, onItemClick, keyboard) { dispatch(fetchHistory(statusId)); - dispatch(openDropdownMenu(id, keyboard)); + dispatch(openDropdownMenu({ id, keyboard })); }, onClose (id) { - dispatch(closeDropdownMenu(id)); + dispatch(closeDropdownMenu({ id })); }, }); diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx index be2f457c76..cc4ca95fb8 100644 --- a/app/javascript/mastodon/components/media_gallery.jsx +++ b/app/javascript/mastodon/components/media_gallery.jsx @@ -236,6 +236,7 @@ class MediaGallery extends PureComponent { visible: PropTypes.bool, autoplay: PropTypes.bool, onToggleVisibility: PropTypes.func, + compact: PropTypes.bool, }; state = { @@ -306,7 +307,7 @@ class MediaGallery extends PureComponent { } render () { - const { media, lang, intl, sensitive, defaultWidth, autoplay } = this.props; + const { media, lang, intl, sensitive, defaultWidth, autoplay, compact } = this.props; const { visible } = this.state; const width = this.state.width || defaultWidth; @@ -355,13 +356,14 @@ class MediaGallery extends PureComponent { const rowClass = (size === 5 || size === 6 || size === 9 || size === 10 || size === 11 || size === 12) ? 'media-gallery--row3' : (size === 7 || size === 8 || size === 13 || size === 14 || size === 15 || size === 16) ? 'media-gallery--row4' : - 'media-gallery--row2'; + 'media-gallery--row2'; const columnClass = (size === 9) ? 'media-gallery--column3' : (size === 10 || size === 11 || size === 12 || size === 13 || size === 14 || size === 15 || size === 16) ? 'media-gallery--column4' : - 'media-gallery--column2'; + 'media-gallery--column2'; + const compactClass = compact ? 'media-gallery__compact' : null; return ( -
+
{spoilerButton}
diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx index 4304f9acd4..2d992d73ae 100644 --- a/app/javascript/mastodon/components/poll.jsx +++ b/app/javascript/mastodon/components/poll.jsx @@ -132,7 +132,7 @@ class Poll extends ImmutablePureComponent { handleReveal = () => { this.setState({ revealed: true }); - } + }; renderOption (option, optionIndex, showResults) { const { poll, lang, disabled, intl } = this.props; diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 8b96df13e5..b091659d56 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -23,9 +23,14 @@ const MOUSE_IDLE_DELAY = 300; const listenerOptions = supportsPassiveEvents ? { passive: true } : false; +/** + * + * @param {import('mastodon/store').RootState} state + * @param {*} props + */ const mapStateToProps = (state, { scrollKey }) => { return { - preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']), + preventScroll: scrollKey === state.dropdownMenu.scrollKey, }; }; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 2263fc542d..65fff3c7c1 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -13,12 +13,13 @@ import AttachmentList from 'mastodon/components/attachment_list'; import { Icon } from 'mastodon/components/icon'; import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; +import CompactedStatusContainer from '../containers/compacted_status_container'; import Card from '../features/status/components/card'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress import Bundle from '../features/ui/components/bundle'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; -import { displayMedia, enableEmojiReaction, showEmojiReactionOnTimeline } from '../initial_state'; +import { displayMedia, enableEmojiReaction, showEmojiReactionOnTimeline, showQuoteInHome, showQuoteInPublic } from '../initial_state'; import { Avatar } from './avatar'; import { AvatarOverlay } from './avatar_overlay'; @@ -73,6 +74,7 @@ const messages = defineMessages({ limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' }, + personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, }); @@ -86,6 +88,7 @@ class Status extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map, account: ImmutablePropTypes.map, + contextType: PropTypes.string, previousId: PropTypes.string, nextInReplyToId: PropTypes.string, rootId: PropTypes.string, @@ -210,7 +213,7 @@ class Status extends ImmutablePureComponent { } else if (attachments.getIn([0, 'type']) === 'audio') { return '16 / 9'; } else { - return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2' + return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2'; } } @@ -356,15 +359,17 @@ class Status extends ImmutablePureComponent { }; render () { - const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props; + const { intl, hidden, featured, unread, muted, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props; let { status, account, ...other } = this.props; + + const contextType = (this.props.contextType || '').split(':')[0]; if (status === null) { return null; } - const handlers = this.props.muted ? {} : { + const handlers = muted ? {} : { reply: this.handleHotkeyReply, favourite: this.handleHotkeyFavourite, boost: this.handleHotkeyBoost, @@ -383,7 +388,7 @@ class Status extends ImmutablePureComponent { if (hidden) { return ( -
+
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('content')}
@@ -405,30 +410,12 @@ class Status extends ImmutablePureComponent { 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) }, + 'personal': { icon: 'sticky-note-o', text: intl.formatMessage(messages.personal_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; let visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; - if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { - const minHandlers = this.props.muted ? {} : { - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - }; - - return ( - -
- : {matchedFilters.join(', ')}. - {' '} - -
-
- ); - } - if (featured) { prepend = (
@@ -469,6 +456,63 @@ class Status extends ImmutablePureComponent { ); } + if (account === undefined || account === null) { + statusAvatar = ; + } else { + statusAvatar = ; + } + + if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { + const minHandlers = muted ? {} : { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; + + if (status.get('filter_action') === 'half_warn') { + return ( + +
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + + +
+ : {matchedFilters.join(', ')}. + {' '} + +
+
+
+ ); + } + + return ( + +
+ : {matchedFilters.join(', ')}. + {' '} + +
+
+ ); + } + isCardMediaWithSensitive = false; if (pictureInPicture.get('inUse')) { @@ -476,7 +520,7 @@ class Status extends ImmutablePureComponent { } else if (status.get('media_attachments').size > 0) { const language = status.getIn(['translation', 'language']) || status.get('language'); - if (this.props.muted) { + if (muted) { media = ( ); } - } else if (status.get('card') && !this.props.muted) { + } else if (status.get('card') && !muted) { media = ( 0; } - if (account === undefined || account === null) { - statusAvatar = ; - } else { - statusAvatar = ; - } - visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; let emojiReactionsBar = null; @@ -586,20 +624,24 @@ class Status extends ImmutablePureComponent { const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0; const withLimited = status.get('visibility_ex') === 'limited' && status.get('limited_scope') ? : null; - const withReference = status.get('status_references_count') > 0 ? : null; + const withQuote = status.get('quote_id') ? : null; + const withReference = (!withQuote && status.get('status_references_count') > 0) ? : null; const withExpiration = status.get('expires_at') ? : null; + const quote = !muted && status.get('quote_id') && (['public', 'community'].includes(contextType) ? showQuoteInPublic : showQuoteInHome) && ; + return ( -
+
{prepend} -
+
{(connectReply || connectUp || connectToRoot) &&
} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
+ {withQuote} {withReference} {withExpiration} {withLimited} @@ -627,6 +669,8 @@ class Status extends ImmutablePureComponent { {...statusContentProps} /> + {(!status.get('spoiler_text') || expanded) && quote} + {(!isCardMediaWithSensitive || !status.get('hidden')) && media} {(!status.get('spoiler_text') || expanded) && hashtagBar} diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 02a42a92dd..8fef327039 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -24,6 +24,7 @@ const messages = defineMessages({ edit: { id: 'status.edit', defaultMessage: 'Edit' }, direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + mentions: { id: 'status.mentions', defaultMessage: 'Mentioned users' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, @@ -249,6 +250,10 @@ class StatusActionBar extends ImmutablePureComponent { this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`); }; + handleOpenMentions = () => { + this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}/mentioned_users`); + }; + handleEmbed = () => { this.props.onEmbed(this.props.status); }; @@ -293,6 +298,7 @@ class StatusActionBar extends ImmutablePureComponent { const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']); + const allowQuote = status.getIn(['account', 'other_settings', 'allow_quote']); let menu = []; @@ -315,7 +321,11 @@ class StatusActionBar extends ImmutablePureComponent { } if (signedIn) { - if (!simpleTimelineMenu) { + if (writtenByMe) { + menu.push({ text: intl.formatMessage(messages.mentions), action: this.handleOpenMentions }); + } + + if (!simpleTimelineMenu || writtenByMe) { menu.push(null); } @@ -323,7 +333,10 @@ class StatusActionBar extends ImmutablePureComponent { if (publicStatus) { menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference }); - menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote }); + + if (allowQuote) { + menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote }); + } } menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClickOriginal }); diff --git a/app/javascript/mastodon/containers/compacted_status_container.jsx b/app/javascript/mastodon/containers/compacted_status_container.jsx new file mode 100644 index 0000000000..8d483ed36f --- /dev/null +++ b/app/javascript/mastodon/containers/compacted_status_container.jsx @@ -0,0 +1,78 @@ +import { injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { openModal } from '../actions/modal'; +import { + hideStatus, + revealStatus, + toggleStatusCollapse, + translateStatus, + undoStatusTranslation, +} from '../actions/statuses'; +import CompactedStatus from '../components/compacted_status'; +import { makeGetStatus, makeGetPictureInPicture } from '../selectors'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props), + nextInReplyToId: props.nextId ? state.getIn(['statuses', props.nextId, 'in_reply_to_id']) : null, + pictureInPicture: getPictureInPicture(state, props), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch) => ({ + + onTranslate (status) { + if (status.get('translation')) { + dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); + } else { + dispatch(translateStatus(status.get('id'))); + } + }, + + onOpenMedia (statusId, media, index, lang) { + dispatch(openModal({ + modalType: 'MEDIA', + modalProps: { statusId, media, index, lang }, + })); + }, + + onOpenVideo (statusId, media, lang, options) { + dispatch(openModal({ + modalType: 'VIDEO', + modalProps: { statusId, media, lang, options }, + })); + }, + + onToggleHidden (status) { + if (status.get('hidden')) { + dispatch(revealStatus(status.get('id'))); + } else { + dispatch(hideStatus(status.get('id'))); + } + }, + + onToggleCollapsed (status, isCollapsed) { + dispatch(toggleStatusCollapse(status.get('id'), isCollapsed)); + }, + + onInteractionModal (type, status) { + dispatch(openModal({ + modalType: 'INTERACTION', + modalProps: { + type, + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + })); + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(CompactedStatus)); diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js index 6cf180cd53..bc9124c041 100644 --- a/app/javascript/mastodon/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -7,9 +7,12 @@ import { openModal, closeModal } from '../actions/modal'; import DropdownMenu from '../components/dropdown_menu'; import { isUserTouching } from '../is_mobile'; +/** + * @param {import('mastodon/store').RootState} state + */ const mapStateToProps = state => ({ - openDropdownId: state.getIn(['dropdown_menu', 'openId']), - openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), + openDropdownId: state.dropdownMenu.openId, + openedViaKeyboard: state.dropdownMenu.keyboard, }); const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ @@ -25,7 +28,7 @@ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ actions: items, onClick: onItemClick, }, - }) : openDropdownMenu(id, keyboard, scrollKey)); + }) : openDropdownMenu({ id, keyboard, scrollKey })); }, onClose(id) { @@ -33,7 +36,7 @@ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ modalType: 'ACTIONS', ignoreFocus: false, })); - dispatch(closeDropdownMenu(id)); + dispatch(closeDropdownMenu({ id })); }, }); diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index da3058334b..4b79fc271c 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -80,6 +80,8 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ + contextType, + onReply (status, router) { dispatch((_, getState) => { let state = getState(); diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 03f32a7e88..85d9ca183b 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -28,6 +28,11 @@ const messages = defineMessages({ silencedExplanation: { id: 'about.domain_blocks.silenced.explanation', defaultMessage: 'You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.' }, suspended: { id: 'about.domain_blocks.suspended.title', defaultMessage: 'Suspended' }, suspendedExplanation: { id: 'about.domain_blocks.suspended.explanation', defaultMessage: 'No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.' }, + publicUnlistedVisibility: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' }, + emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Stamp' }, + enabled: { id: 'about.enabled', defaultMessage: 'Enabled' }, + disabled: { id: 'about.disabled', defaultMessage: 'Disabled' }, + capabilities: { id: 'about.kmyblue_capabilities', defaultMessage: 'kmyblue capabilities' }, }); const severityMessages = { @@ -122,6 +127,10 @@ class About extends PureComponent { const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props; const isLoading = server.get('isLoading'); + const fedibirdCapabilities = server.get('fedibird_capabilities'); + const isPublicUnlistedVisibility = fedibirdCapabilities.includes('kmyblue_visibility_public_unlisted'); + const isEmojiReaction = fedibirdCapabilities.includes('emoji_reaction'); + return (
@@ -182,6 +191,20 @@ class About extends PureComponent { ))} +
+

+ {!isLoading && ( +
    +
  1. + {intl.formatMessage(messages.emojiReaction)}: {intl.formatMessage(isEmojiReaction ? messages.enabled : messages.disabled)} +
  2. +
  3. + {intl.formatMessage(messages.publicUnlistedVisibility)}: {intl.formatMessage(isPublicUnlistedVisibility ? messages.enabled : messages.disabled)} +
  4. +
+ )} +
+
{domainBlocks.get('isLoading') ? ( <> diff --git a/app/javascript/mastodon/features/antenna_setting/components/radio_panel.jsx b/app/javascript/mastodon/features/antenna_setting/components/radio_panel.jsx index 57a88eba65..9408d13569 100644 --- a/app/javascript/mastodon/features/antenna_setting/components/radio_panel.jsx +++ b/app/javascript/mastodon/features/antenna_setting/components/radio_panel.jsx @@ -33,7 +33,7 @@ class RadioPanel extends PureComponent {
{values.map((val) => ( ))} diff --git a/app/javascript/mastodon/features/antenna_setting/index.jsx b/app/javascript/mastodon/features/antenna_setting/index.jsx index 086c1de6b6..19651ea855 100644 --- a/app/javascript/mastodon/features/antenna_setting/index.jsx +++ b/app/javascript/mastodon/features/antenna_setting/index.jsx @@ -167,7 +167,7 @@ class AntennaSetting extends PureComponent { handleEditAntennaClick = () => { window.open(`/antennas/${this.props.params.id}/edit`, '_blank'); - } + }; handleDeleteClick = () => { const { dispatch, columnId, intl } = this.props; @@ -193,7 +193,7 @@ class AntennaSetting extends PureComponent { handleTimelineClick = () => { this.context.router.history.push(`/antennast/${this.props.params.id}`); - } + }; onStlToggle = ({ target }) => { const { dispatch } = this.props; @@ -336,7 +336,7 @@ class AntennaSetting extends PureComponent {
- ) + ); } let stlAlert; @@ -369,7 +369,7 @@ class AntennaSetting extends PureComponent { const contentRadioAlert = antenna.get(contentRadioValue.get('value') === 'tags' ? 'keywords_count' : 'tags_count') > 0; const listOptions = lists.toArray().filter((list) => list.length >= 2 && list[1]).map((list) => { - return { value: list[1].get('id'), label: list[1].get('title') } + return { value: list[1].get('id'), label: list[1].get('title') }; }); return ( @@ -470,7 +470,7 @@ class AntennaSetting extends PureComponent { icon='sitemap' label={intl.formatMessage(messages.addDomainLabel)} title={intl.formatMessage(messages.addDomainTitle)} - /> + /> )} {rangeRadioAlert &&
} @@ -487,7 +487,7 @@ class AntennaSetting extends PureComponent { icon='hashtag' label={intl.formatMessage(messages.addTagLabel)} title={intl.formatMessage(messages.addTagTitle)} - /> + /> )} {contentRadioValue.get('value') === 'keywords' && ( @@ -500,7 +500,7 @@ class AntennaSetting extends PureComponent { icon='paragraph' label={intl.formatMessage(messages.addKeywordLabel)} title={intl.formatMessage(messages.addKeywordTitle)} - /> + /> )} {contentRadioAlert &&
} @@ -518,7 +518,7 @@ class AntennaSetting extends PureComponent { icon='sitemap' label={intl.formatMessage(messages.addDomainLabel)} title={intl.formatMessage(messages.addDomainTitle)} - /> + />

-

- + /> +

+ )}
diff --git a/app/javascript/mastodon/features/antennas/index.jsx b/app/javascript/mastodon/features/antennas/index.jsx index a575527fce..d77ee54163 100644 --- a/app/javascript/mastodon/features/antennas/index.jsx +++ b/app/javascript/mastodon/features/antennas/index.jsx @@ -79,7 +79,7 @@ class Antennas extends ImmutablePureComponent { > {antennas.map(antenna => ( + badge={antenna.get('insert_feeds') ? intl.formatMessage(antenna.get('list') ? messages.insert_list : messages.insert_home) : undefined} /> ))} diff --git a/app/javascript/mastodon/features/circle_statuses/index.jsx b/app/javascript/mastodon/features/circle_statuses/index.jsx new file mode 100644 index 0000000000..2896455ab5 --- /dev/null +++ b/app/javascript/mastodon/features/circle_statuses/index.jsx @@ -0,0 +1,182 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { deleteCircle, expandCircleStatuses, fetchCircle, fetchCircleStatuses } from 'mastodon/actions/circles'; +import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; +import { openModal } from 'mastodon/actions/modal'; +import ColumnHeader from 'mastodon/components/column_header'; +import { Icon } from 'mastodon/components/icon'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import StatusList from 'mastodon/components/status_list'; +import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; +import Column from 'mastodon/features/ui/components/column'; +import { getCircleStatusList } from 'mastodon/selectors'; + + +const messages = defineMessages({ + deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' }, + deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' }, + heading: { id: 'column.circles', defaultMessage: 'Circles' }, +}); + +const mapStateToProps = (state, { params }) => ({ + circle: state.getIn(['circles', params.id]), + statusIds: getCircleStatusList(state, params.id), + isLoading: state.getIn(['circles', params.id, 'isLoading'], true), + isEditing: state.getIn(['circleEditor', 'circleId']) === params.id, + hasMore: !!state.getIn(['circles', params.id, 'next']), +}); + +class CircleStatuses extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + circle: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]), + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + UNSAFE_componentWillMount () { + this.props.dispatch(fetchCircle(this.props.params.id)); + this.props.dispatch(fetchCircleStatuses(this.props.params.id)); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('CIRCLE_STATUSES', { id: this.props.params.id })); + this.context.router.history.push('/'); + } + }; + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + handleEditClick = () => { + this.props.dispatch(openModal({ + modalType: 'CIRCLE_EDITOR', + modalProps: { circleId: this.props.params.id }, + })); + }; + + handleDeleteClick = () => { + const { dispatch, columnId, intl } = this.props; + const { id } = this.props.params; + + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => { + dispatch(deleteCircle(id)); + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + this.context.router.history.push('/circles'); + } + }, + }, + })); + }; + + setRef = c => { + this.column = c; + }; + + handleLoadMore = debounce(() => { + this.props.dispatch(expandCircleStatuses()); + }, 300, { leading: true }); + + render () { + const { intl, circle, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; + const pinned = !!columnId; + + if (typeof circle === 'undefined') { + return ( + +
+ +
+
+ ); + } else if (circle === false) { + return ( + + ); + } + + const emptyMessage = ; + + return ( + + +
+ + + +
+
+ + + + + {intl.formatMessage(messages.heading)} + + +
+ ); + } + +} + +export default connect(mapStateToProps)(injectIntl(CircleStatuses)); diff --git a/app/javascript/mastodon/features/circles/index.jsx b/app/javascript/mastodon/features/circles/index.jsx index 1b83876827..1cd3ae417f 100644 --- a/app/javascript/mastodon/features/circles/index.jsx +++ b/app/javascript/mastodon/features/circles/index.jsx @@ -13,7 +13,6 @@ import { fetchCircles, deleteCircle } from 'mastodon/actions/circles'; import { openModal } from 'mastodon/actions/modal'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; -import { IconButton } from 'mastodon/components/icon_button'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import ScrollableList from 'mastodon/components/scrollable_list'; import ColumnLink from 'mastodon/features/ui/components/column_link'; @@ -106,10 +105,7 @@ class Circles extends ImmutablePureComponent { bindToDocument={!multiColumn} > {circles.map(circle => - (
- - -
) + , )} diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 9782aa17bc..ef98aae479 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -103,11 +103,11 @@ class ComposeForm extends ImmutablePureComponent { }; canSubmit = () => { - const { isSubmitting, isChangingUpload, isUploading, anyMedia, privacy, circleId } = this.props; + const { isSubmitting, isChangingUpload, isUploading, anyMedia, privacy, circleId, isEditing } = this.props; const fulltext = this.getFulltextForCharacterCounting(); const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0; - return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia) || (privacy === 'circle' && !circleId)); + return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia) || (privacy === 'circle' && !isEditing && !circleId)); }; handleSubmit = (e) => { diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.jsx b/app/javascript/mastodon/features/compose/components/navigation_bar.jsx index 5af38da43c..e842ab1f8d 100644 --- a/app/javascript/mastodon/features/compose/components/navigation_bar.jsx +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.jsx @@ -20,7 +20,7 @@ export default class NavigationBar extends ImmutablePureComponent { }; render () { - const username = this.props.account.get('acct') + const username = this.props.account.get('acct'); return (
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx index b01c0ede8b..b8acc4b4f2 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx @@ -9,7 +9,7 @@ import { supportsPassiveEvents } from 'detect-passive-events'; import Overlay from 'react-overlays/Overlay'; import { Icon } from 'mastodon/components/icon'; -import { enableLoginPrivacy } from 'mastodon/initial_state'; +import { enableLoginPrivacy, enableLocalPrivacy } from 'mastodon/initial_state'; import { IconButton } from '../../../components/icon_button'; @@ -246,6 +246,10 @@ class PrivacyDropdown extends PureComponent { this.selectableOptions = this.selectableOptions.filter((opt) => opt.value !== 'login'); } + if (!enableLocalPrivacy) { + this.selectableOptions = this.selectableOptions.filter((opt) => opt.value !== 'public_unlisted'); + } + if (this.props.noDirect) { this.selectableOptions = this.selectableOptions.filter((opt) => opt.value !== 'direct'); } diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx index 7584dbc819..c2c82bdff7 100644 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ b/app/javascript/mastodon/features/compose/components/search.jsx @@ -57,17 +57,17 @@ class Search extends PureComponent { }; defaultOptions = [ - { label: <>has: , action: e => { e.preventDefault(); this._insertText('has:') } }, - { label: <>is: , action: e => { e.preventDefault(); this._insertText('is:') } }, - { label: <>my: , action: e => { e.preventDefault(); this._insertText('my:') } }, - { label: <>language: , action: e => { e.preventDefault(); this._insertText('language:') } }, - { label: <>from: , action: e => { e.preventDefault(); this._insertText('from:') } }, - { label: <>domain: , action: e => { e.preventDefault(); this._insertText('domain:') } }, - { label: <>before: , action: e => { e.preventDefault(); this._insertText('before:') } }, - { label: <>during: , action: e => { e.preventDefault(); this._insertText('during:') } }, - { label: <>after: , action: e => { e.preventDefault(); this._insertText('after:') } }, - { label: <>in: , action: e => { e.preventDefault(); this._insertText('in:') } }, - { label: <>order: , action: e => { e.preventDefault(); this._insertText('order:') } }, + { label: <>has: , action: e => { e.preventDefault(); this._insertText('has:'); } }, + { label: <>is: , action: e => { e.preventDefault(); this._insertText('is:'); } }, + { label: <>my: , action: e => { e.preventDefault(); this._insertText('my:'); } }, + { label: <>language: , action: e => { e.preventDefault(); this._insertText('language:'); } }, + { label: <>from: , action: e => { e.preventDefault(); this._insertText('from:'); } }, + { label: <>domain: , action: e => { e.preventDefault(); this._insertText('domain:'); } }, + { label: <>before: , action: e => { e.preventDefault(); this._insertText('before:'); } }, + { label: <>during: , action: e => { e.preventDefault(); this._insertText('during:'); } }, + { label: <>after: , action: e => { e.preventDefault(); this._insertText('after:'); } }, + { label: <>in: , action: e => { e.preventDefault(); this._insertText('in:'); } }, + { label: <>order: , action: e => { e.preventDefault(); this._insertText('order:'); } }, ]; setRef = c => { diff --git a/app/javascript/mastodon/features/compose/components/searchability_dropdown.jsx b/app/javascript/mastodon/features/compose/components/searchability_dropdown.jsx index e7a002b9d8..3b597e7735 100644 --- a/app/javascript/mastodon/features/compose/components/searchability_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/searchability_dropdown.jsx @@ -15,6 +15,8 @@ import { IconButton } from '../../../components/icon_button'; const messages = defineMessages({ public_short: { id: 'searchability.public.short', defaultMessage: 'Public' }, public_long: { id: 'searchability.public.long', defaultMessage: 'Anyone can find' }, + public_unlisted_short: { id: 'searchability.public_unlisted.short', defaultMessage: 'Public unlisted' }, + public_unlisted_long: { id: 'searchability.public_unlisted.long', defaultMessage: 'Local users and followers can find' }, private_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' }, private_long: { id: 'searchability.unlisted.long', defaultMessage: 'Your followers can find' }, direct_short: { id: 'searchability.private.short', defaultMessage: 'Reactionners' }, @@ -223,6 +225,7 @@ class SearchabilityDropdown extends PureComponent { this.options = [ { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, + { icon: 'cloud', value: 'public_unlisted', text: formatMessage(messages.public_unlisted_short), meta: formatMessage(messages.public_unlisted_long) }, { icon: 'unlock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, { icon: 'lock', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, { icon: 'at', value: 'limited', text: formatMessage(messages.limited_short), meta: formatMessage(messages.limited_long) }, diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.jsx b/app/javascript/mastodon/features/compose/components/text_icon_button.jsx index 46b5d7fada..166d022b88 100644 --- a/app/javascript/mastodon/features/compose/components/text_icon_button.jsx +++ b/app/javascript/mastodon/features/compose/components/text_icon_button.jsx @@ -4,7 +4,7 @@ import { PureComponent } from 'react'; const iconStyle = { height: null, lineHeight: '27px', - width: `${18 * 1.28571429}px`, + minWidth: `${18 * 1.28571429}px`, }; export default class TextIconButton extends PureComponent { diff --git a/app/javascript/mastodon/features/compose/containers/circle_select_container.js b/app/javascript/mastodon/features/compose/containers/circle_select_container.js index c3a81140bd..141288c803 100644 --- a/app/javascript/mastodon/features/compose/containers/circle_select_container.js +++ b/app/javascript/mastodon/features/compose/containers/circle_select_container.js @@ -4,7 +4,7 @@ import { changeCircle } from '../../../actions/compose'; import CircleSelect from '../components/circle_select'; const mapStateToProps = state => ({ - unavailable: state.getIn(['compose', 'privacy']) !== 'circle', + unavailable: state.getIn(['compose', 'privacy']) !== 'circle' || !!state.getIn(['compose', 'id']), circles: state.get('circles'), circleId: state.getIn(['compose', 'circle_id']), }); diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.jsx b/app/javascript/mastodon/features/compose/containers/warning_container.jsx index cfa8e8ab7d..bc74d1209b 100644 --- a/app/javascript/mastodon/features/compose/containers/warning_container.jsx +++ b/app/javascript/mastodon/features/compose/containers/warning_container.jsx @@ -6,6 +6,7 @@ import { connect } from 'react-redux'; import { me } from 'mastodon/initial_state'; import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags'; +import { MENTION_PATTERN_REGEX } from 'mastodon/utils/mentions'; import Warning from '../components/warning'; @@ -14,10 +15,11 @@ const mapStateToProps = state => ({ hashtagWarning: !['public', 'public_unlisted', 'login'].includes(state.getIn(['compose', 'privacy'])) && state.getIn(['compose', 'searchability']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])), directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', searchabilityWarning: state.getIn(['compose', 'searchability']) === 'limited', - limitedPostWarning: ['mutual', 'circle'].includes(state.getIn(['compose', 'privacy'])), + mentionWarning: ['mutual', 'circle', 'limited'].includes(state.getIn(['compose', 'privacy'])) && MENTION_PATTERN_REGEX.test(state.getIn(['compose', 'text'])), + limitedPostWarning: ['mutual', 'circle'].includes(state.getIn(['compose', 'privacy'])) && !state.getIn(['compose', 'limited_scope']), }); -const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, searchabilityWarning, limitedPostWarning }) => { +const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, searchabilityWarning, mentionWarning, limitedPostWarning }) => { if (needsLockWarning) { return }} />} />; } @@ -40,6 +42,10 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning return } />; } + if (mentionWarning) { + return } />; + } + if (limitedPostWarning) { return } />; } @@ -52,6 +58,7 @@ WarningWrapper.propTypes = { hashtagWarning: PropTypes.bool, directMessageWarning: PropTypes.bool, searchabilityWarning: PropTypes.bool, + mentionWarning: PropTypes.bool, limitedPostWarning: PropTypes.bool, }; diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx index 4c23d6422d..a3c6adee9d 100644 --- a/app/javascript/mastodon/features/explore/results.jsx +++ b/app/javascript/mastodon/features/explore/results.jsx @@ -80,7 +80,7 @@ class Results extends PureComponent { } return null; - }; + } handleSelectAll = () => { const { submittedType, dispatch } = this.props; @@ -116,7 +116,7 @@ class Results extends PureComponent { } this.setState({ type: 'hashtags' }); - } + }; handleSelectStatuses = () => { const { submittedType, dispatch } = this.props; @@ -128,7 +128,7 @@ class Results extends PureComponent { } this.setState({ type: 'statuses' }); - } + }; handleLoadMoreAccounts = () => this._loadMore('accounts'); handleLoadMoreStatuses = () => this._loadMore('statuses'); diff --git a/app/javascript/mastodon/features/firehose/index.jsx b/app/javascript/mastodon/features/firehose/index.jsx index efde58a5c0..849ee38f5f 100644 --- a/app/javascript/mastodon/features/firehose/index.jsx +++ b/app/javascript/mastodon/features/firehose/index.jsx @@ -199,7 +199,7 @@ const Firehose = ({ feedType, multiColumn }) => { ); -} +}; Firehose.propTypes = { multiColumn: PropTypes.bool, diff --git a/app/javascript/mastodon/features/interaction_modal/index.jsx b/app/javascript/mastodon/features/interaction_modal/index.jsx index e670452018..014ba993a4 100644 --- a/app/javascript/mastodon/features/interaction_modal/index.jsx +++ b/app/javascript/mastodon/features/interaction_modal/index.jsx @@ -27,9 +27,9 @@ const mapStateToProps = (state, { accountId }) => ({ const mapDispatchToProps = (dispatch) => ({ onSignupClick() { dispatch(closeModal({ - modalType: undefined, - ignoreFocus: false, - })); + modalType: undefined, + ignoreFocus: false, + })); dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); }, }); @@ -187,7 +187,7 @@ class LoginForm extends React.PureComponent { setIFrameRef = (iframe) => { this.iframeRef = iframe; - } + }; handleFocus = () => { this.setState({ expanded: true }); diff --git a/app/javascript/mastodon/features/lists/index.jsx b/app/javascript/mastodon/features/lists/index.jsx index fb52973d23..33a42389e4 100644 --- a/app/javascript/mastodon/features/lists/index.jsx +++ b/app/javascript/mastodon/features/lists/index.jsx @@ -78,7 +78,7 @@ class Lists extends ImmutablePureComponent { > {lists.map(list => ( 0) ? intl.formatMessage(messages.with_antenna) : undefined} />), + badge={(list.get('antennas') && list.get('antennas').size > 0) ? intl.formatMessage(messages.with_antenna) : undefined} />), )} diff --git a/app/javascript/mastodon/features/mentioned_users/index.jsx b/app/javascript/mastodon/features/mentioned_users/index.jsx new file mode 100644 index 0000000000..f32e38820e --- /dev/null +++ b/app/javascript/mastodon/features/mentioned_users/index.jsx @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; + +import { injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { fetchMentionedUsers, expandMentionedUsers } from 'mastodon/actions/interactions'; +import ColumnHeader from 'mastodon/components/column_header'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import AccountContainer from 'mastodon/containers/account_container'; +import Column from 'mastodon/features/ui/components/column'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'mentioned_users', props.params.statusId, 'items']), + hasMore: !!state.getIn(['user_lists', 'mentioned_users', props.params.statusId, 'next']), + isLoading: state.getIn(['user_lists', 'mentioned_users', props.params.statusId, 'isLoading'], true), +}); + +class MentionedUsers extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + multiColumn: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + UNSAFE_componentWillMount () { + if (!this.props.accountIds) { + this.props.dispatch(fetchMentionedUsers(this.props.params.statusId)); + } + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandMentionedUsers(this.props.params.statusId)); + }, 300, { leading: true }); + + render () { + const { accountIds, hasMore, isLoading, multiColumn } = this.props; + + if (!accountIds) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + + + + {accountIds.map(id => + , + )} + + + + + + + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(MentionedUsers)); diff --git a/app/javascript/mastodon/features/reaction_deck/components/reaction_emoji.jsx b/app/javascript/mastodon/features/reaction_deck/components/reaction_emoji.jsx index 7fb6f49285..651596cda4 100644 --- a/app/javascript/mastodon/features/reaction_deck/components/reaction_emoji.jsx +++ b/app/javascript/mastodon/features/reaction_deck/components/reaction_emoji.jsx @@ -59,7 +59,7 @@ class ReactionEmoji extends ImmutablePureComponent { const html = { __html: emojify(emoji) }; content = ( - ) + ); } return ( diff --git a/app/javascript/mastodon/features/reaction_deck/index.jsx b/app/javascript/mastodon/features/reaction_deck/index.jsx index ebc2985abc..4a7d468dbb 100644 --- a/app/javascript/mastodon/features/reaction_deck/index.jsx +++ b/app/javascript/mastodon/features/reaction_deck/index.jsx @@ -100,7 +100,7 @@ class ReactionDeck extends ImmutablePureComponent { const newDeck = this.deckToArray(); newDeck.push('👍'); this.props.onChange(newDeck); - } + }; render () { const { intl, deck, emojiMap, multiColumn } = this.props; @@ -123,38 +123,38 @@ class ReactionDeck extends ImmutablePureComponent { showBackButton /> - - - - {(provided) => ( -
- {deck.map((emoji, index) => ( - - {(provided2) => ( -
- - -
- )} -
- ))} - {provided.placeholder} + + + + {(provided) => ( +
+ {deck.map((emoji, index) => ( + + {(provided2) => ( +
+ + +
+ )} +
+ ))} + {provided.placeholder} -
- )} -
-
-
+
+ )} +
+
+
diff --git a/app/javascript/mastodon/features/reblogs/index.jsx b/app/javascript/mastodon/features/reblogs/index.jsx index 0c4e6dbb93..9de905790a 100644 --- a/app/javascript/mastodon/features/reblogs/index.jsx +++ b/app/javascript/mastodon/features/reblogs/index.jsx @@ -45,7 +45,7 @@ class Reblogs extends ImmutablePureComponent { if (!this.props.accountIds) { this.props.dispatch(fetchReblogs(this.props.params.statusId)); } - }; + } handleRefresh = () => { this.props.dispatch(fetchReblogs(this.props.params.statusId)); diff --git a/app/javascript/mastodon/features/report/comment.jsx b/app/javascript/mastodon/features/report/comment.jsx index 98ac4caa0a..ca9ea9d268 100644 --- a/app/javascript/mastodon/features/report/comment.jsx +++ b/app/javascript/mastodon/features/report/comment.jsx @@ -104,7 +104,7 @@ const Comment = ({ comment, domain, statusIds, isRemote, isSubmitting, selectedD
); -} +}; Comment.propTypes = { comment: PropTypes.string.isRequired, diff --git a/app/javascript/mastodon/features/report/components/status_check_box.jsx b/app/javascript/mastodon/features/report/components/status_check_box.jsx index a9610db75c..81b58296d5 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.jsx +++ b/app/javascript/mastodon/features/report/components/status_check_box.jsx @@ -23,6 +23,7 @@ const messages = defineMessages({ limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' }, + personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, }); @@ -57,6 +58,7 @@ class StatusCheckBox extends PureComponent { 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) }, + 'personal': { icon: 'sticky-note-o', text: intl.formatMessage(messages.personal_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index 56086075fc..3b258b60bb 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -22,6 +22,7 @@ const messages = defineMessages({ edit: { id: 'status.edit', defaultMessage: 'Edit' }, direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + mentions: { id: 'status.mentions', defaultMessage: 'Mentioned users' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, @@ -95,6 +96,10 @@ class ActionBar extends PureComponent { intl: PropTypes.object.isRequired, }; + handleOpenMentions = () => { + this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}/mentioned_users`); + }; + handleReplyClick = () => { this.props.onReply(this.props.status); }; @@ -231,6 +236,7 @@ class ActionBar extends PureComponent { const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']); + const allowQuote = status.getIn(['account', 'other_settings', 'allow_quote']); let menu = []; @@ -254,7 +260,10 @@ class ActionBar extends PureComponent { if (publicStatus) { menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference }); - menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote }); + + if (allowQuote) { + menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote }); + } } menu.push({ text: intl.formatMessage(messages.bookmark_category), action: this.handleBookmarkCategoryAdderClick }); @@ -264,6 +273,7 @@ class ActionBar extends PureComponent { menu.push(null); } + menu.push({ text: intl.formatMessage(messages.mentions), action: this.handleOpenMentions }); menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx index 63772a83d8..3c581b7a38 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.jsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx @@ -35,8 +35,10 @@ const messages = defineMessages({ limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' }, + personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, searchability_public_short: { id: 'searchability.public.short', defaultMessage: 'Public' }, + searchability_public_unlisted_short: { id: 'searchability.public_unlisted.short', defaultMessage: 'Public unlisted' }, searchability_private_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' }, searchability_direct_short: { id: 'searchability.private.short', defaultMessage: 'Reactionners' }, searchability_limited_short: { id: 'searchability.direct.short', defaultMessage: 'Self only' }, @@ -145,7 +147,7 @@ class DetailedStatus extends ImmutablePureComponent { } else if (attachments.getIn([0, 'type']) === 'audio') { return '16 / 9'; } else { - return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2' + return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2'; } } @@ -260,6 +262,7 @@ class DetailedStatus extends ImmutablePureComponent { 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) }, + 'personal': { icon: 'sticky-note-o', text: intl.formatMessage(messages.personal_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; @@ -268,6 +271,7 @@ class DetailedStatus extends ImmutablePureComponent { const searchabilityIconInfo = { 'public': { icon: 'globe', text: intl.formatMessage(messages.searchability_public_short) }, + 'public_unlisted': { icon: 'cloud', text: intl.formatMessage(messages.searchability_public_unlisted_short) }, 'private': { icon: 'unlock', text: intl.formatMessage(messages.searchability_private_short) }, 'direct': { icon: 'lock', text: intl.formatMessage(messages.searchability_direct_short) }, 'limited': { icon: 'at', text: intl.formatMessage(messages.searchability_limited_short) }, diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.jsx b/app/javascript/mastodon/features/ui/components/boost_modal.jsx index fed1ef69df..f1a026b2b3 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/boost_modal.jsx @@ -30,6 +30,7 @@ const messages = defineMessages({ limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' }, + personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, }); @@ -100,6 +101,7 @@ class BoostModal extends ImmutablePureComponent { 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) }, + 'personal': { icon: 'sticky-note-o', text: intl.formatMessage(messages.personal_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx index af549d21ed..280330f530 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.jsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx @@ -24,6 +24,7 @@ import { BookmarkCategoryStatuses, AntennaSetting, AntennaTimeline, + CircleStatuses, } from '../util/async-components'; import BundleColumnError from './bundle_column_error'; @@ -45,6 +46,7 @@ const componentMap = { 'EMOJI_REACTIONS': EmojiReactedStatuses, 'BOOKMARKS': BookmarkedStatuses, 'BOOKMARKS_EX': BookmarkCategoryStatuses, + 'CIRCLE_STATUSES': CircleStatuses, 'ANTENNA': AntennaSetting, 'ANTENNA_TIMELINE': AntennaTimeline, 'LIST': ListTimeline, diff --git a/app/javascript/mastodon/features/ui/components/header.jsx b/app/javascript/mastodon/features/ui/components/header.jsx index 17db2db53b..9a41c48b40 100644 --- a/app/javascript/mastodon/features/ui/components/header.jsx +++ b/app/javascript/mastodon/features/ui/components/header.jsx @@ -83,8 +83,8 @@ class Header extends PureComponent { if (sso_redirect) { content = ( - - ) + + ); } else { let signupButton; diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 4d41ff4e0d..d81a7d381f 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -127,7 +127,7 @@ export default class ModalRoot extends PureComponent { {(SpecificComponent) => { const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined; - return + return ; }} diff --git a/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx b/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx index d5f0c00dca..4216f3da38 100644 --- a/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx +++ b/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx @@ -25,7 +25,7 @@ const SignInBanner = () => {

- ) + ); } if (registrationsOpen) { diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index f23fdc66fc..ee51220df6 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -46,6 +46,7 @@ import { Favourites, EmojiReactions, StatusReferences, + MentionedUsers, DirectTimeline, HashtagTimeline, AntennaTimeline, @@ -65,6 +66,7 @@ import { Lists, Antennas, Circles, + CircleStatuses, AntennaSetting, Directory, Explore, @@ -90,7 +92,7 @@ const mapStateToProps = state => ({ hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0, hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4, - dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null, + dropdownMenuIsOpen: state.dropdownMenu.openId !== null, firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION, username: state.getIn(['accounts', me, 'username']), }); @@ -242,6 +244,7 @@ class SwitchingColumnsArea extends PureComponent { + {/* Legacy routes, cannot be easily factored with other routes because they share a param name */} @@ -259,6 +262,7 @@ class SwitchingColumnsArea extends PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 81d83ec818..2857fcec38 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -54,6 +54,10 @@ export function Circles () { return import(/* webpackChunkName: "features/circles" */'../../circles'); } +export function CircleStatuses () { + return import(/* webpackChunkName: "features/circle_statuses" */'../../circle_statuses'); +} + export function Status () { return import(/* webpackChunkName: "features/status" */'../../status'); } @@ -102,6 +106,10 @@ export function StatusReferences () { return import(/* webpackChunkName: "features/status_references" */'../../status_references'); } +export function MentionedUsers () { + return import(/* webpackChunkName: "features/mentioned_users" */'../../mentioned_users'); +} + export function FollowRequests () { return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests'); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 902158bc77..0948e5d255 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -61,8 +61,10 @@ * @property {string} dtl_tag * @property {boolean} enable_emoji_reaction * @property {boolean} enable_login_privacy + * @property {boolean} enable_local_privacy * @property {boolean} enable_dtl_menu * @property {boolean=} expand_spoilers + * @property {boolean} hide_blocking_quote * @property {boolean} hide_recent_emojis * @property {boolean} limited_federation_mode * @property {string} locale @@ -77,6 +79,8 @@ * @property {boolean} search_enabled * @property {boolean} trends_enabled * @property {boolean} show_emoji_reaction_on_timeline + * @property {boolean} show_quote_in_home + * @property {boolean} show_quote_in_public * @property {string} simple_timeline_menu * @property {boolean} single_user_mode * @property {string} source_url @@ -130,10 +134,12 @@ export const displayMediaExpand = getMeta('display_media_expand'); export const domain = getMeta('domain'); export const dtlTag = getMeta('dtl_tag'); export const enableEmojiReaction = getMeta('enable_emoji_reaction'); +export const enableLocalPrivacy = getMeta('enable_local_privacy'); export const enableLoginPrivacy = getMeta('enable_login_privacy'); export const enableDtlMenu = getMeta('enable_dtl_menu'); export const expandSpoilers = getMeta('expand_spoilers'); export const forceSingleColumn = !getMeta('advanced_layout'); +export const hideBlockingQuote = getMeta('hide_blocking_quote'); export const hideRecentEmojis = getMeta('hide_recent_emojis'); export const limitedFederationMode = getMeta('limited_federation_mode'); export const mascot = getMeta('mascot'); @@ -147,6 +153,8 @@ export const repository = getMeta('repository'); export const searchEnabled = getMeta('search_enabled'); export const trendsEnabled = getMeta('trends_enabled'); export const showEmojiReactionOnTimeline = getMeta('show_emoji_reaction_on_timeline'); +export const showQuoteInHome = getMeta('show_quote_in_home'); +export const showQuoteInPublic = getMeta('show_quote_in_public'); export const showTrends = getMeta('show_trends'); export const simpleTimelineMenu = getMeta('simple_timeline_menu'); export const singleUserMode = getMeta('single_user_mode'); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 6cbb18c9b8..8fbc847d1a 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -1,6 +1,7 @@ { "about.blocks": "Moderated servers", "about.contact": "Contact:", + "about.disabled": "Disabled", "about.disclaimer": "Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.", "about.domain_blocks.no_reason_available": "Reason not available", "about.domain_blocks.preamble": "Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.", @@ -10,6 +11,8 @@ "about.domain_blocks.silenced.title": "Limited", "about.domain_blocks.suspended.explanation": "No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.", "about.domain_blocks.suspended.title": "Suspended", + "about.enabled": "Enabled", + "about.kmyblue_capability": "This server is using kmyblue, a fork of Mastodon. On this server, kmyblues unique features are configured as follows.", "about.not_available": "This information has not been made available on this server.", "about.powered_by": "Decentralized social media powered by {mastodon}", "about.rules": "Server rules", @@ -104,6 +107,8 @@ "bundle_modal_error.close": "Close", "bundle_modal_error.message": "Something went wrong while loading this component.", "bundle_modal_error.retry": "Try again", + "circles.delete": "Delete circle", + "circles.edit": "Edit circle", "closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.", "closed_registrations_modal.description": "Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.", "closed_registrations_modal.find_another_server": "Find another server", @@ -151,6 +156,7 @@ "compose_form.lock_disclaimer.lock": "locked", "compose_form.markdown.marked": "Markdown is available", "compose_form.markdown.unmarked": "Markdown is NOT available", + "compose_form.mention_warning": "When you add a mention to a limited post, the person you are mentioning can also see this post.", "compose_form.placeholder": "What's on your mind?", "compose_form.searchability_warning": "Self only searchability is not available other mastodon servers. Others can search your post.", "compose_form.poll.add_option": "Add a choice", @@ -236,6 +242,7 @@ "empty_column.account_unavailable": "Profile unavailable", "empty_column.blocks": "You haven't blocked any users yet.", "empty_column.bookmarked_statuses": "You don't have any bookmarked posts yet. When you bookmark one, it will show up here.", + "empty_column.circle_statuses": "You don't have any circle posts yet. When you post one as circle, it will show up here.", "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", "empty_column.direct": "You don't have any private mentions yet. When you send or receive one, it will show up here.", "empty_column.domain_blocks": "There are no blocked domains yet.", @@ -533,6 +540,7 @@ "privacy.login.short": "Login only", "privacy.mutual.long": "Mutual followers only", "privacy.mutual.short": "Mutual", + "privacy.personal.short": "Yourself only", "privacy.private.long": "Visible for followers only", "privacy.private.short": "Followers only", "privacy.public.long": "Visible for all", @@ -621,6 +629,8 @@ "searchability.private.short": "Reactionners", "searchability.public.long": "Anyone can find", "searchability.public.short": "Everyone", + "searchability.public_unlisted.long": "Local users and followers can find", + "searchability.public_unlisted.short": "Local and followers", "searchability.unlisted.long": "Your followers and reactionners can find", "searchability.unlisted.short": "Followers and reactionners", "search_popout.domain": "domain", @@ -684,7 +694,7 @@ "status.open": "Expand this post", "status.pin": "Pin on profile", "status.pinned": "Pinned post", - "status.quote": "Ref (quote in other servers)", + "status.quote": "Quote", "status.read_more": "Read more", "status.reblog": "Boost", "status.reblog_private": "Boost with original visibility", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 87d7c90c41..cef2250339 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -1,6 +1,6 @@ { "about.blocks": "Valvotut palvelimet", - "about.contact": "Yhteydenotto:", + "about.contact": "Ota yhteyttä:", "about.disclaimer": "Mastodon on vapaa avoimen lähdekoodin ohjelmisto ja Mastodon gGmbH:n tavaramerkki.", "about.domain_blocks.no_reason_available": "Syytä ei ole ilmoitettu", "about.domain_blocks.preamble": "Mastodonin avulla voidaan yleensä tarkastella minkä tahansa fediversumiin kuuluvan palvelimen sisältöä ja vuorovaikuttaa eri palvelinten käyttäjien kanssa. Nämä ovat tälle palvelimelle määritetyt poikkeukset.", @@ -31,10 +31,10 @@ "account.featured_tags.last_status_never": "Ei julkaisuja", "account.featured_tags.title": "Käyttäjän {name} esille nostetut aihetunnisteet", "account.follow": "Seuraa", - "account.followers": "seuraaja(t)", + "account.followers": "Seuraajat", "account.followers.empty": "Kukaan ei seuraa tätä käyttäjää vielä.", "account.followers_counter": "{count, plural, one {{counter} seuraaja} other {{counter} seuraajaa}}", - "account.following": "Seurataan", + "account.following": "Seuratut", "account.following_counter": "{count, plural, one {{counter} seurattu} other {{counter} seurattua}}", "account.follows.empty": "Tämä käyttäjä ei vielä seuraa ketään.", "account.follows_you": "Seuraa sinua", @@ -194,7 +194,7 @@ "copypaste.copied": "Kopioitu", "copypaste.copy_to_clipboard": "Kopioi leikepöydälle", "directory.federated": "Koko tunnettu fediversumi", - "directory.local": "Vain palvelusta {domain}", + "directory.local": "Vain palvelimelta {domain}", "directory.new_arrivals": "Äskettäin saapuneet", "directory.recently_active": "Hiljattain aktiiviset", "disabled_account_banner.account_settings": "Tilin asetukset", @@ -266,7 +266,7 @@ "filter_modal.select_filter.context_mismatch": "ei sovellu tähän kontekstiin", "filter_modal.select_filter.expired": "vanhentunut", "filter_modal.select_filter.prompt_new": "Uusi luokka: {name}", - "filter_modal.select_filter.search": "Etsi tai luo", + "filter_modal.select_filter.search": "Hae tai luo", "filter_modal.select_filter.subtitle": "Käytä olemassa olevaa luokkaa tai luo uusi", "filter_modal.select_filter.title": "Suodata tämä julkaisu", "filter_modal.title.status": "Suodata julkaisu", @@ -336,9 +336,9 @@ "keyboard_shortcuts.blocked": "Avaa estettyjen käyttäjien luettelo", "keyboard_shortcuts.boost": "Tehosta julkaisua", "keyboard_shortcuts.column": "Kohdista sarakkeeseen", - "keyboard_shortcuts.compose": "siirry tekstinsyöttöön", + "keyboard_shortcuts.compose": "Kohdista kirjoituskenttään", "keyboard_shortcuts.description": "Kuvaus", - "keyboard_shortcuts.direct": "avataksesi yksityisten mainintojen sarakkeen", + "keyboard_shortcuts.direct": "Avaa yksityisten mainintojen sarake", "keyboard_shortcuts.down": "Siirry listassa alaspäin", "keyboard_shortcuts.enter": "Avaa julkaisu", "keyboard_shortcuts.favourite": "Lisää julkaisu suosikkeihin", @@ -347,22 +347,22 @@ "keyboard_shortcuts.heading": "Pikanäppäimet", "keyboard_shortcuts.home": "Avaa kotiaikajana", "keyboard_shortcuts.hotkey": "Pikanäppäin", - "keyboard_shortcuts.legend": "Näytä tämä selite", + "keyboard_shortcuts.legend": "Näytä tämä ohje", "keyboard_shortcuts.local": "Avaa paikallinen aikajana", - "keyboard_shortcuts.mention": "Mainitse julkaisija", - "keyboard_shortcuts.muted": "Avaa lista mykistetyistä käyttäjistä", + "keyboard_shortcuts.mention": "Mainitse kirjoittaja", + "keyboard_shortcuts.muted": "Avaa mykistettyjen käyttäjien luettelo", "keyboard_shortcuts.my_profile": "Avaa profiilisi", - "keyboard_shortcuts.notifications": "Avaa ilmoitukset-valikko", + "keyboard_shortcuts.notifications": "Avaa ilmoitussarake", "keyboard_shortcuts.open_media": "Avaa media", "keyboard_shortcuts.pinned": "Avaa kiinnitettyjen julkaisujen luettelo", "keyboard_shortcuts.profile": "Avaa kirjoittajan profiili", "keyboard_shortcuts.reply": "Vastaa julkaisuun", "keyboard_shortcuts.requests": "Avaa seuraamispyyntöjen luettelo", - "keyboard_shortcuts.search": "siirry hakukenttään", + "keyboard_shortcuts.search": "Kohdista hakukenttään", "keyboard_shortcuts.spoilers": "Näytä/piilota sisältövaroituskenttä", - "keyboard_shortcuts.start": "avaa \"Aloitus\"", - "keyboard_shortcuts.toggle_hidden": "näytä/piilota sisältövaroituksella merkitty teksti", - "keyboard_shortcuts.toggle_sensitivity": "näytä/piilota media", + "keyboard_shortcuts.start": "Avaa Näin pääset alkuun -sarake", + "keyboard_shortcuts.toggle_hidden": "Näytä/piilota sisältövaroituksella merkitty teksti", + "keyboard_shortcuts.toggle_sensitivity": "Näytä/piilota media", "keyboard_shortcuts.toot": "Luo uusi julkaisu", "keyboard_shortcuts.unfocus": "Poistu teksti-/hakukentästä", "keyboard_shortcuts.up": "Siirry listassa ylöspäin", @@ -419,7 +419,7 @@ "navigation_bar.pins": "Kiinnitetyt julkaisut", "navigation_bar.preferences": "Asetukset", "navigation_bar.public_timeline": "Yleinen aikajana", - "navigation_bar.search": "Haku", + "navigation_bar.search": "Hae", "navigation_bar.security": "Turvallisuus", "not_signed_in_indicator.not_signed_in": "Sinun on kirjauduttava sisään käyttääksesi resurssia.", "notification.admin.report": "{name} teki ilmoituksen käytäjästä {target}", @@ -475,11 +475,11 @@ "onboarding.actions.go_to_explore": "Siirry suosituimpien aiheiden syötteeseen", "onboarding.actions.go_to_home": "Siirry kotisyötteeseeni", "onboarding.compose.template": "Tervehdys #Mastodon!", - "onboarding.follows.empty": "Valitettavasti tuloksia ei voida näyttää juuri nyt. Voit kokeilla hakua tai selata tutustumissivua löytääksesi seurattavaa, tai yrittää myöhemmin uudelleen.", + "onboarding.follows.empty": "Valitettavasti tuloksia ei voida näyttää juuri nyt. Voit kokeilla hakua tai selata tutustumissivua löytääksesi seurattavaa tai yrittää myöhemmin uudelleen.", "onboarding.follows.lead": "Kokoat oman kotisyötteesi itse. Mitä enemmän ihmisiä seuraat, sitä aktiivisempi ja kiinnostavampi syöte on. Nämä profiilit voivat olla alkuun hyvä lähtökohta — voit aina lopettaa niiden seuraamisen myöhemmin!", "onboarding.follows.title": "Mukauta kotisyötettäsi", "onboarding.share.lead": "Kerro ihmisille, kuinka he voivat löytää sinut Mastodonista!", - "onboarding.share.message": "Olen {username} #Mastodon'issa! Seuraa minua osoitteessa {url}", + "onboarding.share.message": "Olen {username} #Mastodon⁠issa! Seuraa minua osoitteessa {url}", "onboarding.share.next_steps": "Mahdolliset seuraavat vaiheet:", "onboarding.share.title": "Jaa profiilisi", "onboarding.start.lead": "Uusi Mastodon-tilisi on nyt valmiina käyttöön. Kyseessä on ainutlaatuinen, hajautettu sosiaalisen median alusta, jolla sinä itse – algoritmin sijaan – määrität käyttökokemuksesi. Näin hyödyt Mastodonista eniten:", @@ -584,17 +584,17 @@ "report_notification.open": "Avaa raportti", "search.no_recent_searches": "Ei viimeaikaisia hakuja", "search.placeholder": "Hae", - "search.quick_action.account_search": "Profiilit, jotka vastaavat hakua {x}", - "search.quick_action.go_to_account": "Avaa profiili {x}", + "search.quick_action.account_search": "Profiilit haulla {x}", + "search.quick_action.go_to_account": "Siirry profiiliin {x}", "search.quick_action.go_to_hashtag": "Siirry aihetunnisteeseen {x}", "search.quick_action.open_url": "Avaa URL-osoite Mastodonissa", "search.quick_action.status_search": "Julkaisut haulla {x}", - "search.search_or_paste": "Etsi tai kirjoita URL-osoite", + "search.search_or_paste": "Hae tai kirjoita URL-osoite", "search_popout.full_text_search_disabled_message": "Ei saatavilla palvelimella {domain}.", "search_popout.language_code": "ISO-kielikoodi", "search_popout.options": "Haun asetukset", "search_popout.quick_actions": "Pikatoiminnot", - "search_popout.recent": "Viime haut", + "search_popout.recent": "Viimeaikaiset haut", "search_popout.specific_date": "tietty päivämäärä", "search_popout.user": "käyttäjä", "search_results.accounts": "Profiilit", @@ -637,7 +637,7 @@ "status.history.created": "{name} luotu {date}", "status.history.edited": "{name} muokkasi {date}", "status.load_more": "Lataa lisää", - "status.media.open": "Napsauta avataksesi", + "status.media.open": "Avaa napsauttamalla", "status.media.show": "Napsauta näyttääksesi", "status.media_hidden": "Media piilotettu", "status.mention": "Mainitse @{name}", @@ -654,7 +654,7 @@ "status.reblogs.empty": "Kukaan ei ole vielä tehostanut tätä julkaisua. Kun joku tekee niin, tulee hän tähän näkyviin.", "status.redraft": "Poista ja palauta muokattavaksi", "status.remove_bookmark": "Poista kirjanmerkki", - "status.replied_to": "Vastattu {name}", + "status.replied_to": "Vastaus käyttäjälle {name}", "status.reply": "Vastaa", "status.replyAll": "Vastaa ketjuun", "status.report": "Raportoi @{name}", @@ -715,7 +715,7 @@ "upload_modal.preview_label": "Esikatselu ({ratio})", "upload_progress.label": "Ladataan...", "upload_progress.processing": "Käsitellään…", - "username.taken": "Käyttäjätunnus on jo varattu. Kokeile toista", + "username.taken": "Käyttäjänimi on jo varattu. Kokeile toista", "video.close": "Sulje video", "video.download": "Lataa tiedosto", "video.exit_fullscreen": "Poistu koko näytön tilasta", diff --git a/app/javascript/mastodon/locales/gd.json b/app/javascript/mastodon/locales/gd.json index ea1ce59e84..4f485cfc55 100644 --- a/app/javascript/mastodon/locales/gd.json +++ b/app/javascript/mastodon/locales/gd.json @@ -7,7 +7,7 @@ "about.domain_blocks.silenced.explanation": "San fharsaingeachd, chan fhaic thu pròifilean agus susbaint an fhrithealaiche seo ach ma nì thu lorg no ma tha thu ga leantainn.", "about.domain_blocks.silenced.title": "Cuingichte", "about.domain_blocks.suspended.explanation": "Cha dèid dàta sam bith on fhrithealaiche seo a phròiseasadh, a stòradh no iomlaid agus chan urrainn do na cleachdaichean on fhrithealaiche sin conaltradh no eadar-ghnìomh a ghabhail an-seo.", - "about.domain_blocks.suspended.title": "’Na dhàil", + "about.domain_blocks.suspended.title": "À rèim", "about.not_available": "Cha deach am fiosrachadh seo a sholar air an fhrithealaiche seo.", "about.powered_by": "Lìonra sòisealta sgaoilte le cumhachd {mastodon}", "about.rules": "Riaghailtean an fhrithealaiche", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index efaadec145..c15342c2b3 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -1,6 +1,7 @@ { "about.blocks": "制限中のサーバー", "about.contact": "連絡先", + "about.disabled": "無効", "about.disclaimer": "Mastodonは自由なオープンソースソフトウェアであり、Mastodon gGmbHの商標です。", "about.domain_blocks.no_reason_available": "理由未記載", "about.domain_blocks.preamble": "Mastodonでは原則的にあらゆるサーバー同士で交流したり、互いの投稿を読んだりできますが、当サーバーでは例外的に次のような制限を設けています。", @@ -10,6 +11,8 @@ "about.domain_blocks.silenced.title": "制限", "about.domain_blocks.suspended.explanation": "これらのサーバーからのデータは処理されず、保存や変換もされません。該当するユーザーとの交流もできません。", "about.domain_blocks.suspended.title": "停止中", + "about.enabled": "有効", + "about.kmyblue_capability": "このサーバーは、kmyblueというMastodonフォークを利用しています。kmyblue独自機能の一部は、サーバー管理者によって有効・無効を切り替えることができます。", "about.not_available": "この情報はこのサーバーでは利用できません。", "about.powered_by": "{mastodon}による分散型ソーシャルメディア", "about.rules": "サーバーのルール", @@ -154,8 +157,10 @@ "bundle_modal_error.close": "閉じる", "bundle_modal_error.message": "コンポーネントの読み込み中に問題が発生しました。", "bundle_modal_error.retry": "再試行", - "circles.account.add": "おはぎに追加", - "circles.account.remove": "おはぎから外す", + "circles.account.add": "サークルに追加", + "circles.account.remove": "サークルから外す", + "circles.delete": "サークルを削除", + "circles.edit": "サークルを編集", "circles.edit.submit": "タイトルを変更", "circles.new.create": "サークルを作成", "circles.new.title_placeholder": "新規サークル名", @@ -213,6 +218,7 @@ "compose_form.lock_disclaimer.lock": "承認制", "compose_form.markdown.marked": "Markdown有効", "compose_form.markdown.unmarked": "Markdownは有効になっていません", + "compose_form.mention_warning": "限定投稿にメンションを追加すると、そのアカウントはサークルメンバー・相互などに関係なくこの投稿を読むことができます", "compose_form.placeholder": "今なにしてる?", "compose_form.searchability_warning": "検索許可「自分のみ」はkmyblue内の検索でのみ有効です。他のサーバーでは「リアクションした人のみ」と同等に扱われます", "compose_form.poll.add_option": "追加", @@ -308,6 +314,7 @@ "empty_column.bookmark_categories": "まだ分類がありません。分類を作るとここに表示されます。", "empty_column.bookmarked_statuses": "まだ何もブックマーク登録していません。ブックマーク登録するとここに表示されます。", "empty_column.circles": "まだサークルがありません。サークルを作るとここに表示されます。", + "empty_column.circle_statuses": "まだサークル投稿がありません。このサークルでなにか投稿するとここに表示されます。", "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!", "empty_column.direct": "非公開の返信はまだありません。非公開でやりとりをするとここに表示されます。", "empty_column.domain_blocks": "ブロックしているドメインはありません。", @@ -333,7 +340,7 @@ "errors.unexpected_crash.report_issue": "問題を報告", "explore.search_results": "検索結果", "explore.suggested_follows": "ユーザー", - "explore.title": "エクスプローラー", + "explore.title": "探索する", "explore.trending_links": "ニュース", "explore.trending_statuses": "投稿", "explore.trending_tags": "ハッシュタグ", @@ -618,6 +625,7 @@ "privacy.login.short": "ログインユーザーのみ", "privacy.mutual.long": "相互フォローさんのみ閲覧可、限定投稿", "privacy.mutual.short": "相互のみ", + "privacy.personal.short": "自分限定", "privacy.private.long": "フォロワーのみ閲覧可", "privacy.private.short": "フォロワーのみ", "privacy.public.long": "誰でも閲覧可、ホーム+ローカル+連合TL", @@ -706,6 +714,8 @@ "searchability.private.short": "反応者のみ", "searchability.public.long": "この投稿は誰でも検索できます", "searchability.public.short": "誰でも", + "searchability.public_unlisted.long": "ローカルユーザーとフォロワーが検索できます", + "searchability.public_unlisted.short": "ローカルとフォロワー", "searchability.unlisted.long": "この投稿はあなたのフォロワーと反応者だけが検索できます", "searchability.unlisted.short": "フォロワーと反応者", "search_popout.domain": "ドメイン", @@ -770,7 +780,7 @@ "status.open": "詳細を表示", "status.pin": "プロフィールに固定表示", "status.pinned": "固定された投稿", - "status.quote": "参照 (他サーバーで引用扱い)", + "status.quote": "引用", "status.read_more": "もっと見る", "status.reblog": "ブースト", "status.reblog_private": "ブースト", diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json index 842d5ebb02..7f71d9318b 100644 --- a/app/javascript/mastodon/locales/lv.json +++ b/app/javascript/mastodon/locales/lv.json @@ -244,7 +244,7 @@ "empty_column.public": "Šeit vēl nekā nav! Ieraksti ko publiski vai pieseko lietotājiem no citiem serveriem", "error.unexpected_crash.explanation": "Koda kļūdas vai pārlūkprogrammas saderības problēmas dēļ šo lapu nevarēja parādīt pareizi.", "error.unexpected_crash.explanation_addons": "Šo lapu nevarēja parādīt pareizi. Šo kļūdu, iespējams, izraisīja pārlūkprogrammas papildinājums vai automātiskās tulkošanas rīki.", - "error.unexpected_crash.next_steps": "Mēģini atsvaidzināt lapu. Ja tas nepalīdz, vari lietot Mastodon, izmantojot citu pārlūkprogrammu vai lietotni.", + "error.unexpected_crash.next_steps": "Mēģini atsvaidzināt lapu. Ja tas nepalīdz, iespējams, varēsi lietot Mastodon, izmantojot citu pārlūkprogrammu vai lietotni.", "error.unexpected_crash.next_steps_addons": "Mēģini tos atspējot un atsvaidzināt lapu. Ja tas nepalīdz, iespējams, varēsi lietot Mastodon, izmantojot citu pārlūkprogrammu vai lietotni.", "errors.unexpected_crash.copy_stacktrace": "Kopēt stacktrace uz starpliktuvi", "errors.unexpected_crash.report_issue": "Ziņot par problēmu", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 1a22194554..6b82654500 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -65,6 +65,7 @@ "account.unendorse": "Mostrar pas pel perfil", "account.unfollow": "Quitar de sègre", "account.unmute": "Quitar de rescondre @{name}", + "account.unmute_notifications_short": "Restablir las notificacions", "account.unmute_short": "Tornar afichar", "account_note.placeholder": "Clicar per ajustar una nòta", "admin.dashboard.retention.average": "Mejana", @@ -97,6 +98,8 @@ "column.direct": "Mencions privadas", "column.directory": "Percórrer los perfils", "column.domain_blocks": "Domenis resconduts", + "column.favourites": "Favorits", + "column.firehose": "Tuts en dirèct", "column.follow_requests": "Demandas d’abonament", "column.home": "Acuèlh", "column.lists": "Listas", @@ -117,6 +120,7 @@ "community.column_settings.remote_only": "Sonque alonhat", "compose.language.change": "Cambiar de lenga", "compose.language.search": "Recercar de lengas...", + "compose.published.body": "Tut publicat.", "compose.published.open": "Dobrir", "compose.saved.body": "Publicacion enregistrada.", "compose_form.direct_message_warning_learn_more": "Ne saber mai", @@ -170,6 +174,7 @@ "conversation.open": "Veire la conversacion", "conversation.with": "Amb {names}", "copypaste.copied": "Copiat", + "copypaste.copy_to_clipboard": "Copiar al quichapapièr", "directory.federated": "Del fediverse conegut", "directory.local": "Solament de {domain}", "directory.new_arrivals": "Nòus-venguts", @@ -220,6 +225,7 @@ "errors.unexpected_crash.copy_stacktrace": "Copiar las traças al quichapapièrs", "errors.unexpected_crash.report_issue": "Senhalar un problèma", "explore.search_results": "Resultats de recèrca", + "explore.suggested_follows": "Personas", "explore.title": "Explorar", "explore.trending_links": "Novèlas", "explore.trending_statuses": "Publicacions", @@ -234,6 +240,7 @@ "filter_modal.select_filter.search": "Cercar o crear", "filter_modal.select_filter.title": "Filtrar aquesta publicacion", "filter_modal.title.status": "Filtrar una publicacion", + "firehose.local": "Aqueste servidor", "follow_request.authorize": "Acceptar", "follow_request.reject": "Regetar", "follow_requests.unlocked_explanation": "Encara que vòstre compte siasque pas verrolhat, la còla de {domain} pensèt que volriatz benlèu repassar las demandas d’abonament d’aquestes comptes.", @@ -257,12 +264,19 @@ "hashtag.column_settings.tag_mode.any": "Un d’aquestes", "hashtag.column_settings.tag_mode.none": "Cap d’aquestes", "hashtag.column_settings.tag_toggle": "Inclure las etiquetas suplementàrias dins aquesta colomna", + "hashtag.counter_by_accounts": "{count, plural, one {{counter} participant} other {{counter} participants}}", + "hashtag.counter_by_uses": "{count, plural, one {{counter} tut} other {{counter} tuts}}", + "hashtag.counter_by_uses_today": "{count, plural, one {{counter} tut} other {{counter} tuts}} uèi", "hashtag.follow": "Sègre l’etiqueta", "hashtag.unfollow": "Quitar de sègre l’etiqueta", + "hashtags.and_other": "…e {count, plural, one {}other {# de mai}}", + "home.actions.go_to_explore": "Agachatz las tendéncias", + "home.actions.go_to_suggestions": "Trobatz de monde de sègre", "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Mostrar los partatges", "home.column_settings.show_replies": "Mostrar las responsas", "home.hide_announcements": "Rescondre las anóncias", + "home.pending_critical_update.link": "Veire las mesas a jorn", "home.show_announcements": "Mostrar las anóncias", "interaction_modal.on_another_server": "Sus un autre servidor", "interaction_modal.on_this_server": "Sus aqueste servidor", @@ -332,14 +346,17 @@ "mute_modal.hide_notifications": "Rescondre las notificacions d’aquesta persona ?", "mute_modal.indefinite": "Cap de data de fin", "navigation_bar.about": "A prepaus", + "navigation_bar.advanced_interface": "Dobrir l’interfàcia web avançada", "navigation_bar.blocks": "Personas blocadas", "navigation_bar.bookmarks": "Marcadors", "navigation_bar.community_timeline": "Flux public local", "navigation_bar.compose": "Escriure un nòu tut", + "navigation_bar.direct": "Mencions privadas", "navigation_bar.discover": "Trobar", "navigation_bar.domain_blocks": "Domenis resconduts", "navigation_bar.edit_profile": "Modificar lo perfil", "navigation_bar.explore": "Explorar", + "navigation_bar.favourites": "Favorits", "navigation_bar.filters": "Mots ignorats", "navigation_bar.follow_requests": "Demandas d’abonament", "navigation_bar.followed_tags": "Etiquetas seguidas", @@ -369,6 +386,7 @@ "notifications.column_settings.admin.report": "Senhalaments novèls :", "notifications.column_settings.admin.sign_up": "Nòus inscrits :", "notifications.column_settings.alert": "Notificacions localas", + "notifications.column_settings.favourite": "Favorits :", "notifications.column_settings.filter_bar.advanced": "Mostrar totas las categorias", "notifications.column_settings.filter_bar.category": "Barra de recèrca rapida", "notifications.column_settings.filter_bar.show_bar": "Afichar la barra de filtres", @@ -386,6 +404,7 @@ "notifications.column_settings.update": "Modificacions :", "notifications.filter.all": "Totas", "notifications.filter.boosts": "Partages", + "notifications.filter.favourites": "Favorits", "notifications.filter.follows": "Seguiments", "notifications.filter.mentions": "Mencions", "notifications.filter.polls": "Resultats del sondatge", @@ -399,15 +418,21 @@ "notifications_permission_banner.enable": "Activar las notificacions burèu", "notifications_permission_banner.how_to_control": "Per recebre las notificacions de Mastodon quand es pas dobèrt, activatz las notificacions de burèu. Podètz precisar quin tipe de notificacion generarà una notificacion de burèu via lo boton {icon} dessús un còp activadas.", "notifications_permission_banner.title": "Manquetz pas jamai res", + "onboarding.action.back": "Tornar en rèire", + "onboarding.actions.back": "Tornar en rèire", "onboarding.actions.go_to_explore": "See what's trending", "onboarding.actions.go_to_home": "Go to your home feed", + "onboarding.compose.template": "Adiu #Mastodon !", "onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!", "onboarding.follows.title": "Popular on Mastodon", + "onboarding.share.title": "Partejar vòstre perfil", "onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:", "onboarding.start.skip": "Want to skip right ahead?", + "onboarding.start.title": "Tot es prèst !", "onboarding.steps.follow_people.body": "You curate your own feed. Lets fill it with interesting people.", "onboarding.steps.follow_people.title": "Follow {count, plural, one {one person} other {# people}}", "onboarding.steps.publish_status.body": "Say hello to the world.", + "onboarding.steps.publish_status.title": "Escrivètz vòstre primièr tut", "onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.", "onboarding.steps.setup_profile.title": "Customize your profile", "onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon!", @@ -415,6 +440,7 @@ "picture_in_picture.restore": "Lo tornar", "poll.closed": "Tampat", "poll.refresh": "Actualizar", + "poll.reveal": "Veire los resultats", "poll.total_people": "{count, plural, one {# persona} other {# personas}}", "poll.total_votes": "{count, plural, one {# vòte} other {# vòtes}}", "poll.vote": "Votar", @@ -482,11 +508,17 @@ "report_notification.open": "Dobrir lo senhalament", "search.placeholder": "Recercar", "search.search_or_paste": "Recercar o picar una URL", + "search_popout.language_code": "Còdi ISO de lenga", + "search_popout.options": "Opcions de recèrca", + "search_popout.quick_actions": "Accions rapidas", + "search_popout.recent": "Recèrcas recentas", + "search_popout.specific_date": "data especifica", "search_popout.user": "utilizaire", "search_results.accounts": "Perfils", "search_results.all": "Tot", "search_results.hashtags": "Etiquetas", "search_results.nothing_found": "Cap de resultat per aquestes tèrmes de recèrca", + "search_results.see_all": "O veire tot", "search_results.statuses": "Tuts", "search_results.title": "Recèrca : {q}", "server_banner.active_users": "utilizaires actius", @@ -506,16 +538,20 @@ "status.copy": "Copiar lo ligam de l’estatut", "status.delete": "Escafar", "status.detailed_status": "Vista detalhada de la convèrsa", + "status.direct_indicator": "Mencion privada", "status.edit": "Modificar", "status.edited": "Modificat {date}", "status.edited_x_times": "Modificat {count, plural, un {{count} còp} other {{count} còps}}", "status.embed": "Embarcar", + "status.favourite": "Apondre als favorits", "status.filter": "Filtrar aquesta publicacion", "status.filtered": "Filtrat", "status.hide": "Amagar la publicacion", "status.history.created": "{name} o creèt lo {date}", "status.history.edited": "{name} o modifiquèt lo {date}", "status.load_more": "Cargar mai", + "status.media.open": "Clicar per dobrir", + "status.media.show": "Clicar per mostar", "status.media_hidden": "Mèdia rescondut", "status.mention": "Mencionar", "status.more": "Mai", @@ -546,6 +582,7 @@ "status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}", "status.translate": "Traduire", "status.translated_from_with": "Traduch del {lang} amb {provider}", + "status.uncached_media_warning": "Apercebut indisponible", "status.unmute_conversation": "Tornar mostrar la conversacion", "status.unpin": "Tirar del perfil", "subscribed_languages.lead": "Sonque las publicacions dins las lengas seleccionadas apreissaràn dins vòstre acuèlh e linha cronologica aprèp aqueste cambiament. Seleccionatz pas res per recebre las publicacions en quina lenga que siá.", diff --git a/app/javascript/mastodon/locales/si.json b/app/javascript/mastodon/locales/si.json index 43bd5a1bb5..7b26a9b488 100644 --- a/app/javascript/mastodon/locales/si.json +++ b/app/javascript/mastodon/locales/si.json @@ -218,7 +218,10 @@ "home.hide_announcements": "නිවේදන සඟවන්න", "home.pending_critical_update.link": "යාවත්කාල බලන්න", "home.show_announcements": "නිවේදන පෙන්වන්න", + "interaction_modal.login.action": "මුලට ගෙනයන්න", "interaction_modal.on_this_server": "මෙම සේවාදායකයෙහි", + "interaction_modal.title.favourite": "{name}ගේ ලිපිය ප්‍රිය කරන්න", + "interaction_modal.title.follow": "{name} අනුගමනය", "intervals.full.days": "{number, plural, one {දවස් #} other {දවස් #}}", "intervals.full.hours": "{number, plural, one {පැය #} other {පැය #}}", "intervals.full.minutes": "{number, plural, one {විනාඩි #} other {විනාඩි #}}", @@ -319,6 +322,7 @@ "notifications.mark_as_read": "සියළු දැනුම්දීම් කියවූ බව යොදන්න", "notifications_permission_banner.enable": "වැඩතල දැනුම්දීම් සබල කරන්න", "notifications_permission_banner.title": "කිසිවක් අතපසු නොකරන්න", + "onboarding.actions.go_to_explore": "නැගී එන දෑ වෙත ගෙනයන්න", "onboarding.compose.template": "ආයුබෝ #මාස්ටඩන්!", "onboarding.share.title": "ඔබගේ පැතිකඩ බෙදාගන්න", "onboarding.steps.publish_status.title": "පළමු ලිපිය පළ කරන්න", @@ -358,6 +362,7 @@ "report.categories.other": "වෙනත්", "report.categories.spam": "ආයාචිත", "report.categories.violation": "අන්තර්ගතය නිසා සේවාදායකයේ නීතියක් හෝ කිහිපයක් කඩ වේ", + "report.category.subtitle": "හොඳම ගැලපීම තෝරන්න", "report.category.title": "මෙම {type}සමඟ සිදුවන්නේ කුමක්දැයි අපට කියන්න", "report.category.title_account": "පැතිකඩ", "report.category.title_status": "ලිපිය", @@ -394,14 +399,25 @@ "report_notification.categories.spam": "ආයාචිත", "report_notification.categories.violation": "නීතිය කඩ කිරීම", "report_notification.open": "විවෘත වාර්තාව", + "search.no_recent_searches": "මෑත සෙවීම් නැත", "search.placeholder": "සොයන්න", + "search.quick_action.account_search": "ගැළපෙන පැතිකඩ {x}", + "search.quick_action.go_to_account": "{x} පැතිකඩ වෙත යන්න", "search.quick_action.open_url": "ලිපිනය මාස්ටඩන්හි අරින්න", + "search.quick_action.status_search": "ගැළපෙන ලිපි {x}", "search.search_or_paste": "සොයන්න හෝ ඒ.ස.නි. අලවන්න", + "search_popout.options": "සෙවුම් විකල්ප", + "search_popout.quick_actions": "ඉක්මන් ක්‍රියාමාර්ග", + "search_popout.recent": "මෑත සෙවීම්", + "search_popout.specific_date": "නිශ්චිත දිනයකට", + "search_popout.user": "පරිශ්‍රීලකයා", + "search_results.accounts": "පැතිකඩ", "search_results.all": "සියල්ල", "search_results.nothing_found": "මෙම සෙවුම් පද සඳහා කිසිවක් සොයාගත නොහැකි විය", "search_results.see_all": "සියල්ල බලන්න", "search_results.statuses": "ලිපි", "search_results.title": "{q} සොයන්න", + "server_banner.active_users": "සක්‍රිය පරිශ්‍රීලකයින්", "server_banner.learn_more": "තව දැනගන්න", "sign_in_banner.create_account": "ගිණුමක් සාදන්න", "sign_in_banner.sign_in": "පිවිසෙන්න", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 8b5f7de2f3..27fd0ad35e 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -409,7 +409,7 @@ "navigation_bar.favourites": "最愛", "navigation_bar.filters": "已靜音的關鍵字", "navigation_bar.follow_requests": "跟隨請求", - "navigation_bar.followed_tags": "已跟隨的主題標籤", + "navigation_bar.followed_tags": "已跟隨主題標籤", "navigation_bar.follows_and_followers": "跟隨中與跟隨者", "navigation_bar.lists": "列表", "navigation_bar.logout": "登出", diff --git a/app/javascript/mastodon/reducers/antenna_adder.js b/app/javascript/mastodon/reducers/antenna_adder.js index ace105d634..aae4a43a0f 100644 --- a/app/javascript/mastodon/reducers/antenna_adder.js +++ b/app/javascript/mastodon/reducers/antenna_adder.js @@ -38,10 +38,10 @@ export default function antennaAdderReducer(state = initialState, action) { return state.setIn(['antennas', 'isLoading'], true); case ANTENNA_ADDER_ANTENNAS_FETCH_FAIL: case ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL: - return state.setIn(['antennas', 'isLoading'], false); + return state.setIn(['antennas', 'isLoading'], false); case ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS: case ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS: - return state.update('antennas', antennas => antennas.withMutations(map => { + return state.update('antennas', antennas => antennas.withMutations(map => { map.set('isLoading', false); map.set('loaded', true); map.set('items', ImmutableList(action.antennas.map(item => item.id))); diff --git a/app/javascript/mastodon/reducers/circles.js b/app/javascript/mastodon/reducers/circles.js index 805d7f186a..ea82493ab3 100644 --- a/app/javascript/mastodon/reducers/circles.js +++ b/app/javascript/mastodon/reducers/circles.js @@ -1,4 +1,4 @@ -import { List as ImmutableList, fromJS } from 'immutable'; +import { List as ImmutableList, Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { CIRCLE_FETCH_SUCCESS, @@ -7,31 +7,107 @@ import { CIRCLE_CREATE_SUCCESS, CIRCLE_UPDATE_SUCCESS, CIRCLE_DELETE_SUCCESS, + CIRCLE_STATUSES_FETCH_REQUEST, + CIRCLE_STATUSES_FETCH_SUCCESS, + CIRCLE_STATUSES_FETCH_FAIL, + CIRCLE_STATUSES_EXPAND_REQUEST, + CIRCLE_STATUSES_EXPAND_SUCCESS, + CIRCLE_STATUSES_EXPAND_FAIL, } from '../actions/circles'; +import { + COMPOSE_WITH_CIRCLE_SUCCESS, +} from '../actions/compose'; const initialState = ImmutableList(); -const normalizeList = (state, circle) => state.set(circle.id, fromJS(circle)); +const initialStatusesState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, + loaded: true, + next: null, +}); -const normalizeLists = (state, circles) => { +const normalizeCircle = (state, circle) => { + const old = state.get(circle.id); + if (old === false) { + return state; + } + + let s = state.set(circle.id, fromJS(circle)); + if (old) { + s = s.setIn([circle.id, 'statuses'], old.get('statuses')); + } else { + s = s.setIn([circle.id, 'statuses'], initialStatusesState); + } + return s.setIn([circle.id, 'isLoading'], false).setIn([circle.id, 'isLoaded'], true); +}; + +const normalizeCircles = (state, circles) => { circles.forEach(circle => { - state = normalizeList(state, circle); + state = normalizeCircle(state, circle); }); return state; }; +const normalizeCircleStatuses = (state, circleId, statuses, next) => { + return state.updateIn([circleId, 'statuses'], listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('isLoading', false); + map.set('items', ImmutableOrderedSet(statuses.map(item => item.id))); + })); +}; + +const appendToCircleStatuses = (state, circleId, statuses, next) => { + return appendToCircleStatusesById(state, circleId, statuses.map(item => item.id), next); +}; + +const appendToCircleStatusesById = (state, circleId, statuses, next) => { + return state.updateIn([circleId, 'statuses'], listMap => listMap.withMutations(map => { + if (typeof next !== 'undefined') { + map.set('next', next); + } + map.set('isLoading', false); + if (map.get('items')) { + map.set('items', map.get('items').union(statuses)); + } + })); +}; + +const prependToCircleStatusById = (state, circleId, statusId) => { + if (!state.get(circleId)) return state; + + return state.updateIn([circleId], circle => circle.withMutations(map => { + if (map.getIn(['statuses', 'items'])) { + map.updateIn(['statuses', 'items'], list => ImmutableOrderedSet([statusId]).union(list)); + } + })); +}; + export default function circles(state = initialState, action) { switch(action.type) { case CIRCLE_FETCH_SUCCESS: case CIRCLE_CREATE_SUCCESS: case CIRCLE_UPDATE_SUCCESS: - return normalizeList(state, action.circle); + return normalizeCircle(state, action.circle); case CIRCLES_FETCH_SUCCESS: - return normalizeLists(state, action.circles); + return normalizeCircles(state, action.circles); case CIRCLE_DELETE_SUCCESS: case CIRCLE_FETCH_FAIL: return state.set(action.id, false); + case CIRCLE_STATUSES_FETCH_REQUEST: + case CIRCLE_STATUSES_EXPAND_REQUEST: + return state.setIn([action.id, 'statuses', 'isLoading'], true); + case CIRCLE_STATUSES_FETCH_FAIL: + case CIRCLE_STATUSES_EXPAND_FAIL: + return state.setIn([action.id, 'statuses', 'isLoading'], false); + case CIRCLE_STATUSES_FETCH_SUCCESS: + return normalizeCircleStatuses(state, action.id, action.statuses, action.next); + case CIRCLE_STATUSES_EXPAND_SUCCESS: + return appendToCircleStatuses(state, action.id, action.statuses, action.next); + case COMPOSE_WITH_CIRCLE_SUCCESS: + return prependToCircleStatusById(state, action.circleId, action.status.id); default: return state; } diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 612ee01d9b..dc34e7f1af 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -58,7 +58,7 @@ import { import { REDRAFT } from '../actions/statuses'; import { STORE_HYDRATE } from '../actions/store'; import { TIMELINE_DELETE } from '../actions/timelines'; -import { me } from '../initial_state'; +import { enableLocalPrivacy, enableLoginPrivacy, me } from '../initial_state'; import { unescapeHTML } from '../utils/html'; import { uuid } from '../uuid'; @@ -138,9 +138,13 @@ function clearAll(state) { if (state.get('stay_privacy') && !state.get('in_reply_to')) { map.set('default_privacy', state.get('privacy')); } + if ((map.get('privacy') === 'login' && !enableLoginPrivacy) || (map.get('privacy') === 'public_unlisted' && !enableLocalPrivacy)) { + map.set('privacy', 'public'); + } if (!state.get('in_reply_to')) { map.set('posted_on_this_session', true); } + map.set('limited_scope', null); map.set('id', null); map.set('in_reply_to', null); map.set('searchability', state.get('default_searchability')); @@ -408,6 +412,7 @@ export default function compose(state = initialState, action) { map.set('in_reply_to', action.status.get('id')); map.set('text', statusToTextMentions(state, action.status)); map.set('privacy', privacyPreference(action.status.get('visibility_ex'), state.get('default_privacy'))); + map.set('limited_scope', null); map.set('searchability', privacyPreference(action.status.get('searchability'), state.get('default_searchability'))); map.set('focusDate', new Date()); map.set('caretPosition', null); @@ -544,6 +549,7 @@ export default function compose(state = initialState, action) { map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status))); map.set('in_reply_to', action.status.get('in_reply_to_id')); map.set('privacy', action.status.get('visibility_ex')); + map.set('limited_scope', null); map.set('media_attachments', action.status.get('media_attachments').map((media) => media.set('unattached', true))); map.set('focusDate', new Date()); map.set('caretPosition', null); @@ -574,7 +580,12 @@ export default function compose(state = initialState, action) { map.set('id', action.status.get('id')); map.set('text', action.text); map.set('in_reply_to', action.status.get('in_reply_to_id')); - map.set('privacy', action.status.get('visibility_ex')); + if (action.status.get('visibility_ex') !== 'limited') { + map.set('privacy', action.status.get('visibility_ex')); + } else { + map.set('privacy', action.status.get('limited_scope') === 'mutual' ? 'mutual' : 'circle'); + } + map.set('limited_scope', action.status.get('limited_scope')); map.set('media_attachments', action.status.get('media_attachments')); map.set('focusDate', new Date()); map.set('caretPosition', null); diff --git a/app/javascript/mastodon/reducers/dropdown_menu.js b/app/javascript/mastodon/reducers/dropdown_menu.js deleted file mode 100644 index 6f92f1bbe8..0000000000 --- a/app/javascript/mastodon/reducers/dropdown_menu.js +++ /dev/null @@ -1,19 +0,0 @@ -import Immutable from 'immutable'; - -import { - DROPDOWN_MENU_OPEN, - DROPDOWN_MENU_CLOSE, -} from '../actions/dropdown_menu'; - -const initialState = Immutable.Map({ openId: null, keyboard: false, scroll_key: null }); - -export default function dropdownMenu(state = initialState, action) { - switch (action.type) { - case DROPDOWN_MENU_OPEN: - return state.merge({ openId: action.id, keyboard: action.keyboard, scroll_key: action.scroll_key }); - case DROPDOWN_MENU_CLOSE: - return state.get('openId') === action.id ? state.set('openId', null).set('scroll_key', null) : state; - default: - return state; - } -} diff --git a/app/javascript/mastodon/reducers/dropdown_menu.ts b/app/javascript/mastodon/reducers/dropdown_menu.ts new file mode 100644 index 0000000000..59e19bb16d --- /dev/null +++ b/app/javascript/mastodon/reducers/dropdown_menu.ts @@ -0,0 +1,33 @@ +import { createReducer } from '@reduxjs/toolkit'; + +import { closeDropdownMenu, openDropdownMenu } from '../actions/dropdown_menu'; + +interface DropdownMenuState { + openId: string | null; + keyboard: boolean; + scrollKey: string | null; +} + +const initialState: DropdownMenuState = { + openId: null, + keyboard: false, + scrollKey: null, +}; + +export const dropdownMenuReducer = createReducer(initialState, (builder) => { + builder + .addCase( + openDropdownMenu, + (state, { payload: { id, keyboard, scrollKey } }) => { + state.openId = id; + state.keyboard = keyboard; + state.scrollKey = scrollKey; + }, + ) + .addCase(closeDropdownMenu, (state, { payload: { id } }) => { + if (state.openId === id) { + state.openId = null; + state.scrollKey = null; + } + }); +}); diff --git a/app/javascript/mastodon/reducers/filters.js b/app/javascript/mastodon/reducers/filters.js index 566ad0c6ca..28f0c3e6e4 100644 --- a/app/javascript/mastodon/reducers/filters.js +++ b/app/javascript/mastodon/reducers/filters.js @@ -11,6 +11,7 @@ const normalizeFilter = (state, filter) => { filter_action: filter.filter_action, keywords: filter.keywords, expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null, + with_quote: filter.with_quote, }); if (is(state.get(filter.id), normalizedFilter)) { diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 05c24b7dfc..0731f42bbf 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -24,7 +24,7 @@ import contexts from './contexts'; import conversations from './conversations'; import custom_emojis from './custom_emojis'; import domain_lists from './domain_lists'; -import dropdown_menu from './dropdown_menu'; +import { dropdownMenuReducer } from './dropdown_menu'; import filters from './filters'; import followed_tags from './followed_tags'; import height_cache from './height_cache'; @@ -56,7 +56,7 @@ import user_lists from './user_lists'; const reducers = { announcements, - dropdown_menu, + dropdownMenu: dropdownMenuReducer, timelines, meta, alerts, diff --git a/app/javascript/mastodon/reducers/modal.ts b/app/javascript/mastodon/reducers/modal.ts index dab1e8301c..73a2afb916 100644 --- a/app/javascript/mastodon/reducers/modal.ts +++ b/app/javascript/mastodon/reducers/modal.ts @@ -1,13 +1,13 @@ import { Record as ImmutableRecord, Stack } from 'immutable'; -import type { PayloadAction } from '@reduxjs/toolkit'; +import type { Reducer } from '@reduxjs/toolkit'; import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose'; import type { ModalType } from '../actions/modal'; import { openModal, closeModal } from '../actions/modal'; import { TIMELINE_DELETE } from '../actions/timelines'; -type ModalProps = Record; +export type ModalProps = Record; interface Modal { modalType: ModalType; modalProps: ModalProps; @@ -62,33 +62,22 @@ const pushModal = ( }); }; -export function modalReducer( - state: State = initialState, - action: PayloadAction<{ - modalType: ModalType; - ignoreFocus: boolean; - modalProps: Record; - }>, -) { - switch (action.type) { - case openModal.type: - return pushModal( - state, - action.payload.modalType, - action.payload.modalProps, - ); - case closeModal.type: - return popModal(state, action.payload); - case COMPOSE_UPLOAD_CHANGE_SUCCESS: - return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false }); - case TIMELINE_DELETE: - return state.update('stack', (stack) => - stack.filterNot( - // @ts-expect-error TIMELINE_DELETE action is not typed yet. - (modal) => modal.get('modalProps').statusId === action.id, - ), - ); - default: - return state; - } -} +export const modalReducer: Reducer = (state = initialState, action) => { + if (openModal.match(action)) + return pushModal( + state, + action.payload.modalType, + action.payload.modalProps, + ); + else if (closeModal.match(action)) return popModal(state, action.payload); + // TODO: type those actions + else if (action.type === COMPOSE_UPLOAD_CHANGE_SUCCESS) + return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false }); + else if (action.type === TIMELINE_DELETE) + return state.update('stack', (stack) => + stack.filterNot( + (modal) => modal.get('modalProps').statusId === action.id, + ), + ); + else return state; +}; diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js index 2cb41cd03d..ecf08dfbc3 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -64,6 +64,12 @@ import { EMOJI_REACTIONS_EXPAND_SUCCESS, EMOJI_REACTIONS_EXPAND_FAIL, STATUS_REFERENCES_FETCH_SUCCESS, + MENTIONED_USERS_FETCH_REQUEST, + MENTIONED_USERS_FETCH_SUCCESS, + MENTIONED_USERS_FETCH_FAIL, + MENTIONED_USERS_EXPAND_REQUEST, + MENTIONED_USERS_EXPAND_SUCCESS, + MENTIONED_USERS_EXPAND_FAIL, } from '../actions/interactions'; import { MUTES_FETCH_REQUEST, @@ -92,6 +98,7 @@ const initialState = ImmutableMap({ favourited_by: initialListState, emoji_reactioned_by: initialListState, referred_by: initialListState, + mentioned_users: initialListState, follow_requests: initialListState, blocks: initialListState, mutes: initialListState, @@ -205,6 +212,16 @@ export default function userLists(state = initialState, action) { return appendToEmojiReactionList(state, ['emoji_reactioned_by', action.id], action.accounts, action.next); case STATUS_REFERENCES_FETCH_SUCCESS: return state.setIn(['referred_by', action.id], ImmutableList(action.statuses.map(item => item.id))); + case MENTIONED_USERS_FETCH_SUCCESS: + return normalizeList(state, ['mentioned_users', action.id], action.accounts, action.next); + case MENTIONED_USERS_EXPAND_SUCCESS: + return appendToList(state, ['mentioned_users', action.id], action.accounts, action.next); + case MENTIONED_USERS_FETCH_REQUEST: + case MENTIONED_USERS_EXPAND_REQUEST: + return state.setIn(['mentioned_users', action.id, 'isLoading'], true); + case MENTIONED_USERS_FETCH_FAIL: + case MENTIONED_USERS_EXPAND_FAIL: + return state.setIn(['mentioned_users', action.id, 'isLoading'], false); case NOTIFICATIONS_UPDATE: return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; case FOLLOW_REQUESTS_FETCH_SUCCESS: diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 6d5adbeb48..6c5891ca57 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -3,7 +3,7 @@ import { createSelector } from 'reselect'; import { toServerSideType } from 'mastodon/utils/filters'; -import { me } from '../initial_state'; +import { me, hideBlockingQuote } from '../initial_state'; const getAccountBase = (state, id) => state.getIn(['accounts', id], null); const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null); @@ -37,38 +37,57 @@ export const makeGetStatus = () => { [ (state, { id }) => state.getIn(['statuses', id]), (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), + (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id'])]), + (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'quote_id'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), getFilters, ], - (statusBase, statusReblog, accountBase, accountReblog, filters) => { + (statusBase, statusReblog, statusQuote, statusReblogQuote, accountBase, accountReblog, filters) => { if (!statusBase || statusBase.get('isLoading')) { return null; } if (statusReblog) { statusReblog = statusReblog.set('account', accountReblog); + statusQuote = statusReblogQuote; } else { statusReblog = null; } + if (hideBlockingQuote && (statusReblog || statusBase).getIn(['quote', 'quote_muted'])) { + return null; + } + let filtered = false; + let filterAction = 'warn'; if ((accountReblog || accountBase).get('id') !== me && filters) { let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList(); + const quoteFilterResults = statusQuote?.get('filtered'); + if (quoteFilterResults) { + const filterWithQuote = quoteFilterResults.some((result) => filters.getIn([result.get('filter'), 'with_quote'])); + if (filterWithQuote) { + filterResults = filterResults.concat(quoteFilterResults); + } + } + if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { return null; } filterResults = filterResults.filter(result => filters.has(result.get('filter'))); if (!filterResults.isEmpty()) { filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); + filterAction = filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'warn') ? 'warn' : 'half_warn'; } } return statusBase.withMutations(map => { map.set('reblog', statusReblog); + map.set('quote', statusQuote); map.set('account', accountBase); map.set('matched_filters', filtered); + map.set('filter_action', filterAction); }); }, ); @@ -135,3 +154,7 @@ export const getStatusList = createSelector([ export const getBookmarkCategoryStatusList = createSelector([ (state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']), ], (items) => items ? items.toList() : ImmutableList()); + +export const getCircleStatusList = createSelector([ + (state, circleId) => state.getIn(['circles', circleId, 'statuses', 'items']), +], (items) => items ? items.toList() : ImmutableList()); diff --git a/app/javascript/mastodon/utils/mentions.ts b/app/javascript/mastodon/utils/mentions.ts new file mode 100644 index 0000000000..0fbf28a3cf --- /dev/null +++ b/app/javascript/mastodon/utils/mentions.ts @@ -0,0 +1,29 @@ +const MENTION_SEPARATORS = '_\\u00b7\\u200c'; +const ALPHA = '\\p{L}\\p{M}'; +const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}'; + +const buildMentionPatternRegex = () => { + try { + return new RegExp( + `(?:^|[^\\/\\)\\w])@(([${WORD}_][${WORD}${MENTION_SEPARATORS}]*[${ALPHA}${MENTION_SEPARATORS}][${WORD}${MENTION_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))`, + 'iu', + ); + } catch { + return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i; + } +}; + +const buildMentionRegex = () => { + try { + return new RegExp( + `^(([${WORD}_][${WORD}${MENTION_SEPARATORS}]*[${ALPHA}${MENTION_SEPARATORS}][${WORD}${MENTION_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))$`, + 'iu', + ); + } catch { + return /^(\w*[a-zA-Z·]\w*)$/i; + } +}; + +export const MENTION_PATTERN_REGEX = buildMentionPatternRegex(); + +export const MENTION_REGEX = buildMentionRegex(); diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index f26321c41a..d13388b479 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -1,5 +1,5 @@ import './public-path'; -import main from "mastodon/main" +import main from "mastodon/main"; import { start } from '../mastodon/common'; import { loadLocale } from '../mastodon/locales'; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index d6ee4f18f9..ad12fc3b3e 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -284,6 +284,7 @@ font-size: 11px; padding: 0 3px; line-height: 27px; + white-space: nowrap; &:hover, &:active, @@ -1732,6 +1733,11 @@ a.account__display-name { .status__avatar { width: 46px; height: 46px; + + &.status__avatar__compact { + width: 24px; + height: 24px; + } } .muted { @@ -8482,6 +8488,13 @@ noscript { .status__wrapper { position: relative; + &.status__wrapper__compact { + border-radius: 4px; + border: 1px solid $ui-primary-color; + margin-block-start: 16px; + cursor: pointer; + } + &.unread { &::before { content: ''; diff --git a/app/lib/account_statuses_filter.rb b/app/lib/account_statuses_filter.rb index 9a4a2b5d6e..4ff5975610 100644 --- a/app/lib/account_statuses_filter.rb +++ b/app/lib/account_statuses_filter.rb @@ -26,7 +26,7 @@ class AccountStatusesFilter scope.merge!(no_reblogs_scope) if exclude_reblogs? scope.merge!(hashtag_scope) if tagged? - available_searchabilities = [:public, :unlisted, :private, :direct, :limited, nil] + available_searchabilities = [:public, :public_unlisted, :unlisted, :private, :direct, :limited, nil] available_visibilities = [:public, :public_unlisted, :login, :unlisted, :private, :direct, :limited] available_searchabilities = [:public] if domain_block&.reject_send_not_public_searchability diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb index 5126e23c6a..649c3503f7 100644 --- a/app/lib/activitypub/activity/accept.rb +++ b/app/lib/activitypub/activity/accept.rb @@ -3,6 +3,7 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity def perform return accept_follow_for_relay if relay_follow? + return accept_follow_for_friend if friend_follow? return accept_follow!(follow_request_from_object) unless follow_request_from_object.nil? case @object['type'] @@ -43,6 +44,18 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity relay.present? end + def accept_follow_for_friend + friend.update!(active_state: :accepted, passive_state: :idle) + end + + def friend + @friend ||= FriendDomain.find_by(domain: @account.domain, active_follow_activity_id: object_uri, active_state: [:pending, :accepted]) if @account.domain.present? + end + + def friend_follow? + friend.present? + end + def target_uri @target_uri ||= value_or_id(@object['actor']) end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 49c822f5dd..b2bf2afc20 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -72,12 +72,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) } end - def audience_searchable_by - return nil if @object['searchableBy'].nil? - - @audience_searchable_by = as_array(@object['searchableBy']).map { |x| value_or_id(x) } - end - def process_status @tags = [] @mentions = [] @@ -99,9 +93,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity resolve_thread(@status) fetch_replies(@status) + process_references! distribute forward_for_reply - process_references! join_group! end @@ -120,7 +114,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def process_status_params - @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url, object: @object) + @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url, object: @object, account: @account, friend_domain: friend_domain?) @params = { uri: @status_parser.uri, @@ -136,7 +130,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity sensitive: @account.sensitized? || @status_parser.sensitive || false, visibility: @status_parser.visibility, limited_scope: @status_parser.limited_scope, - searchability: searchability, + searchability: @status_parser.searchability, thread: replied_to_status, conversation: conversation_from_uri(@object['conversation']), media_attachment_ids: process_attachments.take(MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX).map(&:id), @@ -453,7 +447,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def related_to_local_activity? fetch? || followed_by_local_accounts? || requested_through_relay? || - responds_to_followed_account? || addresses_local_accounts? + responds_to_followed_account? || addresses_local_accounts? || quote_local? || free_friend_domain? end def responds_to_followed_account? @@ -494,103 +488,33 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def process_references! references = @object['references'].nil? ? [] : ActivityPub::FetchReferencesService.new.call(@status, @object['references']) - quote = @object['quote'] || @object['quoteUrl'] || @object['quoteURL'] || @object['_misskey_quote'] - references << quote if quote - ProcessReferencesService.perform_worker_async(@status, [], references) + ProcessReferencesService.call_service_without_error(@status, [], references, [quote].compact) + end + + def quote_local? + url = quote + + if url.present? + ResolveURLService.new.call(url, on_behalf_of: @account, local_only: true).present? + else + false + end + end + + def free_friend_domain? + FriendDomain.free_receivings.exists?(domain: @account.domain) + end + + def friend_domain? + FriendDomain.enabled.find_by(domain: @account.domain)&.accepted? + end + + def quote + @quote ||= @object['quote'] || @object['quoteUrl'] || @object['quoteURL'] || @object['_misskey_quote'] end def join_group! GroupReblogService.new.call(@status) end - - def searchability_from_audience - if audience_searchable_by.nil? - nil - elsif audience_searchable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) } - :public - elsif audience_searchable_by.include?('kmyblue:Limited') || audience_searchable_by.include?('as:Limited') - :limited - elsif audience_searchable_by.include?(@account.followers_url) - :private - else - :direct - end - end - - SCAN_SEARCHABILITY_RE = /\[searchability:(public|followers|reactors|private)\]/ - SCAN_SEARCHABILITY_FEDIBIRD_RE = /searchable_by_(all_users|followers_only|reacted_users_only|nobody)/ - - def searchability - from_audience = searchability_from_audience - return from_audience if from_audience - return nil if default_searchability_from_bio? - - searchability_from_bio || (misskey_software? ? misskey_searchability : nil) - end - - def default_searchability_from_bio? - note = @account.note - return false if note.blank? - - note.include?('searchable_by_default_range') - end - - def searchability_from_bio - note = @account.note - return nil if note.blank? - - searchability_bio = note.scan(SCAN_SEARCHABILITY_FEDIBIRD_RE).first || note.scan(SCAN_SEARCHABILITY_RE).first - return nil unless searchability_bio - - searchability = searchability_bio[0] - return nil if searchability.nil? - - searchability = :public if %w(public all_users).include?(searchability) - searchability = :private if %w(followers followers_only).include?(searchability) - searchability = :direct if %w(reactors reacted_users_only).include?(searchability) - searchability = :limited if %w(private nobody).include?(searchability) - - searchability - end - - def instance_info - @instance_info ||= InstanceInfo.find_by(domain: @account.domain) - end - - def misskey_software? - info = instance_info - return false if info.nil? - - %w(misskey calckey).include?(info.software) - end - - def misskey_searchability - visibility = visibility_from_audience - %i(public unlisted).include?(visibility) ? :public : :limited - end - - def visibility_from_audience - if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) } - :public - elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) } - :unlisted - elsif audience_to.include?('kmyblue:LoginOnly') || audience_to.include?('as:LoginOnly') || audience_to.include?('LoginUser') - :login - elsif audience_to.include?(@account.followers_url) - :private - else - :direct - end - end - - def visibility_from_audience_with_silence - visibility = visibility_from_audience - - if @account.silenced? && %i(public).include?(visibility) - :unlisted - else - visibility - end - end end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 61f6ca6997..f401714430 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -4,6 +4,8 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity def perform if @account.uri == object_uri delete_person + elsif object_uri == ActivityPub::TagManager::COLLECTIONS[:public] + delete_friend else delete_note end @@ -42,6 +44,11 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity end end + def delete_friend + friend = FriendDomain.find_by(domain: @account.domain) + friend&.destroy + end + def forwarder @forwarder ||= ActivityPub::Forwarder.new(@account, @json, @status) end diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb index a586298eec..fdded98440 100644 --- a/app/lib/activitypub/activity/follow.rb +++ b/app/lib/activitypub/activity/follow.rb @@ -4,6 +4,8 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity include Payloadable def perform + return request_follow_for_friend if friend_follow? + target_account = account_from_uri(object_uri) return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) @@ -30,7 +32,7 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity follow_request = FollowRequest.create!(account: @account, target_account: target_account, uri: @json['id']) - if target_account.locked? || @account.silenced? || block_straight_follow? || (@account.bot? && target_account.user&.setting_lock_follow_from_bot) + if target_account.locked? || @account.silenced? || block_straight_follow? || ((@account.bot? || proxy_account?) && target_account.user&.setting_lock_follow_from_bot) LocalNotificationWorker.perform_async(target_account.id, follow_request.id, 'FollowRequest', 'follow_request') else AuthorizeFollowService.new.call(@account, target_account) @@ -43,6 +45,40 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity ActivityPub::DeliveryWorker.perform_async(json, target_account.id, @account.inbox_url) end + def request_follow_for_friend + already_accepted = false + + if friend.present? + already_accepted = friend.accepted? + friend.update!(passive_state: :pending, active_state: :idle, passive_follow_activity_id: @json['id']) + else + @friend = FriendDomain.new(domain: @account.domain, passive_state: :pending, passive_follow_activity_id: @json['id']) + @friend.inbox_url = @json['inboxUrl'].presence || @friend.default_inbox_url + @friend.save! + end + + if already_accepted || Setting.unlocked_friend + friend.accept! + + # Notify for admin even if unlocked + notify_staff_about_pending_friend_server! unless already_accepted + else + notify_staff_about_pending_friend_server! + end + end + + def friend + @friend ||= FriendDomain.find_by(domain: @account.domain) if @account.domain.present? + end + + def friend_follow? + @json['object'] == ActivityPub::TagManager::COLLECTIONS[:public] && !block_friend? + end + + def block_friend? + @block_friend ||= DomainBlock.reject_friend?(@account.domain) || DomainBlock.blocked?(@account.domain) + end + def block_straight_follow? @block_straight_follow ||= DomainBlock.reject_straight_follow?(@account.domain) end @@ -50,4 +86,35 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity def block_new_follow? @block_new_follow ||= DomainBlock.reject_new_follow?(@account.domain) end + + def proxy_account? + (@account.username.downcase.include?('_proxy') || + @account.username.downcase.end_with?('proxy') || + @account.username.downcase.include?('_bot_') || + @account.username.downcase.end_with?('bot') || + @account.display_name&.downcase&.include?('proxy') || + @account.display_name&.include?('プロキシ') || + @account.note&.include?('プロキシ')) && + (@account.following_count.zero? || @account.following_count > @account.followers_count) && + proxyable_software? + end + + def proxyable_software? + info = instance_info + return false if info.nil? + + %w(misskey calckey firefish meisskey cherrypick).include?(info.software) + end + + def instance_info + @instance_info ||= InstanceInfo.find_by(domain: @account.domain) + end + + def notify_staff_about_pending_friend_server! + User.those_who_can(:manage_federation).includes(:account).find_each do |u| + next unless u.allows_pending_friend_server_emails? + + AdminMailer.with(recipient: u.account).new_pending_friend_server(friend).deliver_later + end + end end diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index 9d2abcccf5..86aaba076d 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -57,6 +57,7 @@ class ActivityPub::Activity::Like < ActivityPub::Activity NotifyService.new.call(@original_status.account, :emoji_reaction, reaction) forward_for_emoji_reaction relay_for_emoji_reaction + relay_friend_for_emoji_reaction end rescue Seahorse::Client::NetworkingError nil @@ -76,6 +77,14 @@ class ActivityPub::Activity::Like < ActivityPub::Activity end end + def relay_friend_for_emoji_reaction + return unless @json['signature'].present? && @original_status.distributable_friend? + + ActivityPub::DeliveryWorker.push_bulk(FriendDomain.distributables.pluck(:inbox_url)) do |inbox_url| + [Oj.dump(@json), @original_status.account.id, inbox_url] + end + end + def shortcode return @shortcode if defined?(@shortcode) diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb index 886dddb235..e1eb3c2368 100644 --- a/app/lib/activitypub/activity/reject.rb +++ b/app/lib/activitypub/activity/reject.rb @@ -3,6 +3,7 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity def perform return reject_follow_for_relay if relay_follow? + return reject_follow_for_friend if friend_follow? return follow_request_from_object.reject! unless follow_request_from_object.nil? return UnfollowService.new.call(follow_from_object.account, @account) unless follow_from_object.nil? @@ -37,6 +38,18 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity relay.present? end + def reject_follow_for_friend + friend.update!(active_state: :rejected, passive_state: :idle) + end + + def friend + @friend ||= FriendDomain.find_by(domain: @account.domain, active_follow_activity_id: object_uri, active_state: [:pending, :accepted]) if @account.domain.present? + end + + def friend_follow? + friend.present? + end + def target_uri @target_uri ||= value_or_id(@object['actor']) end diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb index f070043d15..eac34efdc9 100644 --- a/app/lib/activitypub/activity/undo.rb +++ b/app/lib/activitypub/activity/undo.rb @@ -87,6 +87,8 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity end def undo_follow + return remove_follow_from_friend if friend_follow? + target_account = account_from_uri(target_uri) return if target_account.nil? || !target_account.local? @@ -100,6 +102,18 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity end end + def remove_follow_from_friend + friend.destroy_without_signal! + end + + def friend + @friend ||= FriendDomain.find_by(domain: @account.domain) if @account.domain.present? && @object['object'] == ActivityPub::TagManager::COLLECTIONS[:public] + end + + def friend_follow? + friend.present? + end + def undo_like_original status = status_from_uri(target_uri) @@ -132,6 +146,7 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity if @original_status.account.local? forward_for_undo_emoji_reaction relay_for_undo_emoji_reaction + relay_friend_for_undo_emoji_reaction end end else @@ -170,6 +185,14 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity end end + def relay_friend_for_undo_emoji_reaction + return unless @json['signature'].present? && @original_status.distributable_friend? + + ActivityPub::DeliveryWorker.push_bulk(FriendDomain.distributables.pluck(:inbox_url)) do |inbox_url| + [Oj.dump(@json), @original_status.account.id, inbox_url] + end + end + def shortcode return @shortcode if defined?(@shortcode) diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb index 891c5a5d43..5f2430600e 100644 --- a/app/lib/activitypub/case_transform.rb +++ b/app/lib/activitypub/case_transform.rb @@ -21,6 +21,8 @@ module ActivityPub::CaseTransform value elsif value.start_with?('_:') "_:#{value.delete_prefix('_:').underscore.camelize(:lower)}" + elsif LanguagesHelper::ISO_639_1_REGIONAL.key?(value.to_sym) # rubocop:disable Lint/DuplicateBranch + value else value.underscore.camelize(:lower) end diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index a9ec958195..af2033cf12 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -10,6 +10,8 @@ class ActivityPub::Parser::StatusParser @json = json @object = magic_values[:object] || json['object'] || json @magic_values = magic_values + @account = magic_values[:account] + @friend = magic_values[:friend_domain] end def uri @@ -75,6 +77,8 @@ class ActivityPub::Parser::StatusParser def visibility if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) } :public + elsif audience_to.include?('kmyblue:LocalPublic') && @friend + :public_unlisted elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) } :unlisted elsif audience_to.include?('kmyblue:LoginOnly') || audience_to.include?('as:LoginOnly') || audience_to.include?('LoginUser') @@ -86,6 +90,14 @@ class ActivityPub::Parser::StatusParser end end + def searchability + from_audience = searchability_from_audience + return from_audience if from_audience + return nil if default_searchability_from_bio? + + searchability_from_bio || (misskey_software? ? misskey_searchability : nil) + end + def limited_scope case @object['limitedScope'] when 'Mutual' @@ -98,6 +110,10 @@ class ActivityPub::Parser::StatusParser end def language + @language ||= original_language || (misskey_software? ? 'ja' : nil) + end + + def original_language if content_language_map? @object['contentMap'].keys.first elsif name_language_map? @@ -117,6 +133,12 @@ class ActivityPub::Parser::StatusParser as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) } end + def audience_searchable_by + return nil if @object['searchableBy'].nil? + + @audience_searchable_by = as_array(@object['searchableBy']).map { |x| value_or_id(x) } + end + def summary_language_map? @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty? end @@ -128,4 +150,63 @@ class ActivityPub::Parser::StatusParser def name_language_map? @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty? end + + def instance_info + @instance_info ||= InstanceInfo.find_by(domain: @account.domain) + end + + def misskey_software? + info = instance_info + return false if info.nil? + + %w(misskey calckey).include?(info.software) + end + + def misskey_searchability + %i(public unlisted).include?(visibility) ? :public : :limited + end + + SCAN_SEARCHABILITY_RE = /\[searchability:(public|followers|reactors|private)\]/ + SCAN_SEARCHABILITY_FEDIBIRD_RE = /searchable_by_(all_users|followers_only|reacted_users_only|nobody)/ + + def default_searchability_from_bio? + note = @account.note + return false if note.blank? + + note.include?('searchable_by_default_range') + end + + def searchability_from_bio + note = @account.note + return nil if note.blank? + + searchability_bio = note.scan(SCAN_SEARCHABILITY_FEDIBIRD_RE).first || note.scan(SCAN_SEARCHABILITY_RE).first + return nil unless searchability_bio + + searchability = searchability_bio[0] + return nil if searchability.nil? + + searchability = :public if %w(public all_users).include?(searchability) + searchability = :private if %w(followers followers_only).include?(searchability) + searchability = :direct if %w(reactors reacted_users_only).include?(searchability) + searchability = :limited if %w(private nobody).include?(searchability) + + searchability + end + + def searchability_from_audience + if audience_searchable_by.nil? + nil + elsif audience_searchable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) } + :public + elsif audience_searchable_by.include?('kmyblue:Limited') || audience_searchable_by.include?('as:Limited') + :limited + elsif audience_searchable_by.include?('kmyblue:LocalPublic') && @friend + :public_unlisted + elsif audience_searchable_by.include?(@account.followers_url) + :private + else + :direct + end + end end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index f9b67867ef..f032267280 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -126,6 +126,12 @@ class ActivityPub::TagManager end end + def to_for_friend(status) + to = to(status) + to << 'kmyblue:LocalPublic' if status.public_unlisted_visibility? + to + end + # Secondary audience of a status # Public statuses go out to followers as well # Unlisted statuses go to the public as well @@ -147,7 +153,7 @@ class ActivityPub::TagManager end def cc_for_misskey(status) - if (status.account.user&.setting_reject_unlisted_subscription && status.visibility == 'unlisted') || (status.account.user&.setting_reject_public_unlisted_subscription && status.visibility == 'public_unlisted') + if (status.account.user&.setting_reject_unlisted_subscription && status.unlisted_visibility?) || (status.account.user&.setting_reject_public_unlisted_subscription && status.public_unlisted_visibility?) cc = cc_private_visibility(status) cc << uri_for(status.reblog.account) if status.reblog? return cc @@ -251,6 +257,12 @@ class ActivityPub::TagManager searchable_by.concat(mentions_uris(status)).compact end + def searchable_by_for_friend(status) + searchable = searchable_by(status) + searchable << 'kmyblue:LocalPublic' if status.compute_searchability_local == 'public_unlisted' + searchable + end + def account_searchable_by(account) case account.compute_searchability_activitypub when 'public' diff --git a/app/lib/admin/metrics/dimension/software_versions_dimension.rb b/app/lib/admin/metrics/dimension/software_versions_dimension.rb index 72a98a88ab..ccf556eae0 100644 --- a/app/lib/admin/metrics/dimension/software_versions_dimension.rb +++ b/app/lib/admin/metrics/dimension/software_versions_dimension.rb @@ -25,7 +25,8 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim end def ruby_version - value = "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}" + yjit = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? + value = "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}#{yjit ? ' +YJIT' : ''}" { key: 'ruby', diff --git a/app/lib/admin/metrics/dimension/space_usage_dimension.rb b/app/lib/admin/metrics/dimension/space_usage_dimension.rb index cc85608904..f1b6dba040 100644 --- a/app/lib/admin/metrics/dimension/space_usage_dimension.rb +++ b/app/lib/admin/metrics/dimension/space_usage_dimension.rb @@ -11,7 +11,7 @@ class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension protected def perform_query - [postgresql_size, redis_size, media_size] + [postgresql_size, redis_size, media_size, search_size].compact end def postgresql_size @@ -65,4 +65,22 @@ class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension redis.info end end + + def search_size + return unless Chewy.enabled? + + client_info = Chewy.client.info + + value = Chewy.client.indices.stats['indices'].values.sum { |index_data| index_data['primaries']['store']['size_in_bytes'] } + + { + key: 'search', + human_key: client_info.dig('version', 'distribution') == 'opensearch' ? 'OpenSearch' : 'Elasticsearch', + value: value.to_s, + unit: 'bytes', + human_value: number_to_human_size(value), + } + rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error + nil + end end diff --git a/app/lib/admin/system_check/elasticsearch_check.rb b/app/lib/admin/system_check/elasticsearch_check.rb index 406bb5bcb9..ea35807f30 100644 --- a/app/lib/admin/system_check/elasticsearch_check.rb +++ b/app/lib/admin/system_check/elasticsearch_check.rb @@ -76,14 +76,35 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck end def compatible_version? - return false if running_version.nil? - - Gem::Version.new(running_version) >= Gem::Version.new(required_version) || - Gem::Version.new(compatible_wire_version) >= Gem::Version.new(required_version) + running_version_ok? || compatible_wire_version_ok? rescue ArgumentError false end + def running_version_ok? + return false if running_version.blank? + + gem_version_running >= gem_version_required + end + + def compatible_wire_version_ok? + return false if compatible_wire_version.blank? + + gem_version_compatible_wire >= gem_version_required + end + + def gem_version_running + Gem::Version.new(running_version) + end + + def gem_version_required + Gem::Version.new(required_version) + end + + def gem_version_compatible_wire + Gem::Version.new(compatible_wire_version) + end + def mismatched_indexes @mismatched_indexes ||= INDEXES.filter_map do |klass| klass.base_name if Chewy.client.indices.get_mapping[klass.index_name]&.deep_symbolize_keys != klass.mappings_hash diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index ff2ad7a01d..13b33bcbbf 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -58,7 +58,7 @@ class FeedManager # @param [Boolean] update # @return [Boolean] def push_to_home(account, status, update: false) - return false unless add_to_feed(:home, account.id, status, aggregate_reblogs: account.user&.aggregates_reblogs?) + return false unless add_to_feed(:home, account.id, status, aggregate_reblogs: account.user&.aggregates_reblogs?, update: update) trim(:home, account.id) PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}", { 'update' => update }) if push_update_required?("timeline:#{account.id}") @@ -83,7 +83,7 @@ class FeedManager # @param [Boolean] update # @return [Boolean] def push_to_list(list, status, update: false) - return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) + return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?, update: update) trim(:list, list.id) PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}", { 'update' => update }) if push_update_required?("timeline:list:#{list.id}") @@ -91,7 +91,7 @@ class FeedManager end def push_to_antenna(antenna, status, update: false) - return false unless add_to_feed(:antenna, antenna.id, status, aggregate_reblogs: antenna.account.user&.aggregates_reblogs?) + return false unless add_to_feed(:antenna, antenna.id, status, aggregate_reblogs: antenna.account.user&.aggregates_reblogs?, update: update) trim(:antenna, antenna.id) PushUpdateWorker.perform_async(antenna.account_id, status.id, "timeline:antenna:#{antenna.id}", { 'update' => update }) if push_update_required?("timeline:antenna:#{antenna.id}") @@ -498,7 +498,9 @@ class FeedManager # @param [Status] status # @param [Boolean] aggregate_reblogs # @return [Boolean] - def add_to_feed(timeline_type, account_id, status, aggregate_reblogs: true) + def add_to_feed(timeline_type, account_id, status, aggregate_reblogs: true, update: false) + return true if update + timeline_key = key(timeline_type, account_id) reblog_key = key(timeline_type, account_id, 'reblogs') diff --git a/app/lib/importer/statuses_index_importer.rb b/app/lib/importer/statuses_index_importer.rb index 9fbce14478..5a8fa29c86 100644 --- a/app/lib/importer/statuses_index_importer.rb +++ b/app/lib/importer/statuses_index_importer.rb @@ -23,7 +23,7 @@ class Importer::StatusesIndexImporter < Importer::BaseImporter to_index.map do |object| # This is unlikely to happen, but the post may have been # un-interacted with since it was queued for indexing - if object.searchable_by.empty? && %w(public private).exclude?(object.searchability) + if object.searchable_by.empty? && %w(public public_unlisted private).exclude?(object.searchability) deleted += 1 { delete: { _id: object.id } } else diff --git a/app/lib/search_query_transformer.rb b/app/lib/search_query_transformer.rb index ba4383f674..567b10ade2 100644 --- a/app/lib/search_query_transformer.rb +++ b/app/lib/search_query_transformer.rb @@ -89,10 +89,10 @@ class SearchQueryTransformer < Parslet::Transform public_index, searchability_limited, ] - definition_should << searchability_public if %i(public).include?(@searchability) - definition_should << searchability_private if %i(public unlisted private).include?(@searchability) - definition_should << searchable_by_me if %i(public unlisted private direct).include?(@searchability) - definition_should << self_posts if %i(public unlisted private direct).exclude?(@searchability) + definition_should << searchability_public if %i(public public_unlisted).include?(@searchability) + definition_should << searchability_private if %i(public public_unlisted unlisted private).include?(@searchability) + definition_should << searchable_by_me if %i(public public_unlisted unlisted private direct).include?(@searchability) + definition_should << self_posts if %i(public public_unlisted unlisted private direct).exclude?(@searchability) { bool: { @@ -199,8 +199,8 @@ class SearchQueryTransformer < Parslet::Transform def following_account_ids return @following_account_ids if defined?(@following_account_ids) - account_exists_sql = Account.where('accounts.id = follows.target_account_id').where(searchability: %w(public private)).reorder(nil).select(1).to_sql - status_exists_sql = Status.where('statuses.account_id = follows.target_account_id').where(reblog_of_id: nil).where(searchability: %w(public private)).reorder(nil).select(1).to_sql + account_exists_sql = Account.where('accounts.id = follows.target_account_id').where(searchability: %w(public public_unlisted private)).reorder(nil).select(1).to_sql + status_exists_sql = Status.where('statuses.account_id = follows.target_account_id').where(reblog_of_id: nil).where(searchability: %w(public public_unlisted private)).reorder(nil).select(1).to_sql following_accounts = Follow.where(account_id: @options[:current_account].id).merge(Account.where("EXISTS (#{account_exists_sql})").or(Account.where("EXISTS (#{status_exists_sql})"))) @following_account_ids = following_accounts.pluck(:target_account_id) end diff --git a/app/lib/status_reach_finder.rb b/app/lib/status_reach_finder.rb index 8639a30a9f..22b0d94ff6 100644 --- a/app/lib/status_reach_finder.rb +++ b/app/lib/status_reach_finder.rb @@ -10,7 +10,7 @@ class StatusReachFinder end def inboxes - (reached_account_inboxes + followers_inboxes + relay_inboxes).uniq + (reached_account_inboxes + followers_inboxes + relay_inboxes + nolocal_friend_inboxes).uniq end def inboxes_for_misskey @@ -21,6 +21,10 @@ class StatusReachFinder end end + def inboxes_for_friend + (reached_account_inboxes_for_friend + followers_inboxes_for_friend + friend_inboxes).uniq + end + private def reached_account_inboxes @@ -32,7 +36,7 @@ class StatusReachFinder elsif @status.limited_visibility? Account.where(id: mentioned_account_ids).where.not(domain: banned_domains).inboxes else - Account.where(id: reached_account_ids).where.not(domain: banned_domains).inboxes + Account.where(id: reached_account_ids).where.not(domain: banned_domains + friend_domains).inboxes end end @@ -42,7 +46,17 @@ class StatusReachFinder elsif @status.limited_visibility? Account.where(id: mentioned_account_ids).where(domain: banned_domains_for_misskey).inboxes else - Account.where(id: reached_account_ids).where(domain: banned_domains_for_misskey).inboxes + Account.where(id: reached_account_ids).where(domain: banned_domains_for_misskey - friend_domains).inboxes + end + end + + def reached_account_inboxes_for_friend + if @status.reblog? + [] + elsif @status.limited_visibility? + Account.where(id: mentioned_account_ids).where.not(domain: banned_domains).inboxes + else + Account.where(id: reached_account_ids, domain: friend_domains).where.not(domain: banned_domains - friend_domains).inboxes end end @@ -54,6 +68,7 @@ class StatusReachFinder reblogs_account_ids, favourites_account_ids, replies_account_ids, + quoted_account_id, ].tap do |arr| arr.flatten! arr.compact! @@ -88,23 +103,37 @@ class StatusReachFinder @status.replies.pluck(:account_id) if distributable? || unsafe? end + def quoted_account_id + @status.quote.account_id if @status.quote? + end + def followers_inboxes if @status.in_reply_to_local_account? && distributable? - @status.account.followers.or(@status.thread.account.followers.not_domain_blocked_by_account(@status.account)).where.not(domain: banned_domains).inboxes + @status.account.followers.or(@status.thread.account.followers.not_domain_blocked_by_account(@status.account)).where.not(domain: banned_domains + friend_domains).inboxes elsif @status.direct_visibility? || @status.limited_visibility? [] else - @status.account.followers.where.not(domain: banned_domains).inboxes + @status.account.followers.where.not(domain: banned_domains + friend_domains).inboxes end end def followers_inboxes_for_misskey if @status.in_reply_to_local_account? && distributable? - @status.account.followers.or(@status.thread.account.followers.not_domain_blocked_by_account(@status.account)).where(domain: banned_domains_for_misskey).inboxes + @status.account.followers.or(@status.thread.account.followers.not_domain_blocked_by_account(@status.account)).where(domain: banned_domains_for_misskey - friend_domains).inboxes elsif @status.direct_visibility? || @status.limited_visibility? [] else - @status.account.followers.where(domain: banned_domains_for_misskey).inboxes + @status.account.followers.where(domain: banned_domains_for_misskey - friend_domains).inboxes + end + end + + def followers_inboxes_for_friend + if @status.in_reply_to_local_account? && distributable? + @status.account.followers.or(@status.thread.account.followers.not_domain_blocked_by_account(@status.account)).where(domain: friend_domains).inboxes + elsif @status.direct_visibility? || @status.limited_visibility? + [] + else + @status.account.followers.where(domain: friend_domains).inboxes end end @@ -116,6 +145,22 @@ class StatusReachFinder end end + def friend_inboxes + if @status.public_visibility? || @status.public_unlisted_visibility? || (@status.unlisted_visibility? && (@status.public_searchability? || @status.public_unlisted_searchability?)) + DeliveryFailureTracker.without_unavailable(FriendDomain.distributables.where(delivery_local: true).where.not(domain: AccountDomainBlock.where(account: @status.account).select(:domain)).pluck(:inbox_url)) + else + [] + end + end + + def nolocal_friend_inboxes + if @status.public_visibility? + DeliveryFailureTracker.without_unavailable(FriendDomain.distributables.where(delivery_local: false).where.not(domain: AccountDomainBlock.where(account: @status.account).select(:domain)).pluck(:inbox_url)) + else + [] + end + end + def distributable? @status.public_visibility? || @status.unlisted_visibility? || @status.public_unlisted_visibility? end @@ -124,6 +169,13 @@ class StatusReachFinder @options[:unsafe] end + def friend_domains + return @friend_domains if defined?(@friend_domains) + + @friend_domains = FriendDomain.deliver_locals.pluck(:domain) + @friend_domains -= UnavailableDomain.where(domain: @friend_domains).pluck(:domain) + end + def banned_domains return @banned_domains if @banned_domains @@ -157,9 +209,10 @@ class StatusReachFinder end def banned_domains_for_misskey_of_status(status) + return [] if status.public_searchability? return [] unless (status.public_unlisted_visibility? && status.account.user&.setting_reject_public_unlisted_subscription) || (status.unlisted_visibility? && status.account.user&.setting_reject_unlisted_subscription) - from_info = InstanceInfo.where(software: %w(misskey calckey)).pluck(:domain) + from_info = InstanceInfo.where(software: %w(misskey calckey cherrypick)).pluck(:domain) from_domain_block = DomainBlock.where(detect_invalid_subscription: true).pluck(:domain) (from_info + from_domain_block).uniq end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index 990b92c337..11262144be 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -35,6 +35,14 @@ class AdminMailer < ApplicationMailer end end + def new_pending_friend_server(friend_server) + @friend = friend_server + + locale_for_account(@me) do + mail subject: default_i18n_subject(instance: @instance, domain: @friend.domain) + end + end + def new_trends(links, tags, statuses) @links = links @tags = tags diff --git a/app/models/account.rb b/app/models/account.rb index 5ff438839d..3b3dc09f0e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -330,6 +330,13 @@ class Account < ApplicationRecord true end + def allow_quote? + return user.setting_allow_quote if local? && user.present? + return settings['allow_quote'] if settings.present? && settings.key?('allow_quote') + + true + end + def public_statuses_count hide_statuses_count? ? 0 : statuses_count end @@ -397,6 +404,7 @@ class Account < ApplicationRecord end def public_settings + # Please update `app/javascript/mastodon/api_types/accounts.ts` when making changes to the attributes config = { 'noindex' => noindex?, 'noai' => noai?, @@ -406,6 +414,7 @@ class Account < ApplicationRecord 'hide_followers_count' => hide_followers_count?, 'translatable_private' => translatable_private?, 'link_preview' => link_preview?, + 'allow_quote' => allow_quote?, } if Setting.enable_emoji_reaction config = config.merge({ diff --git a/app/models/circle.rb b/app/models/circle.rb index cb58b97bce..7c11ab59ea 100644 --- a/app/models/circle.rb +++ b/app/models/circle.rb @@ -20,10 +20,12 @@ class Circle < ApplicationRecord has_many :circle_accounts, inverse_of: :circle, dependent: :destroy has_many :accounts, through: :circle_accounts + has_many :circle_statuses, inverse_of: :circle, dependent: :destroy + has_many :statuses, through: :circle_statuses validates :title, presence: true validates_each :account_id, on: :create do |record, _attr, value| - record.errors.add(:base, I18n.t('lists.errors.limit')) if List.where(account_id: value).count >= PER_ACCOUNT_LIMIT + record.errors.add(:base, I18n.t('lists.errors.limit')) if Circle.where(account_id: value).count >= PER_ACCOUNT_LIMIT end end diff --git a/app/models/circle_status.rb b/app/models/circle_status.rb new file mode 100644 index 0000000000..b394a4f927 --- /dev/null +++ b/app/models/circle_status.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: circle_statuses +# +# id :bigint(8) not null, primary key +# circle_id :bigint(8) +# status_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class CircleStatus < ApplicationRecord + belongs_to :circle + belongs_to :status + + validates :status, uniqueness: { scope: :circle } + validate :account_own_status + + private + + def account_own_status + errors.add(:status_id, :invalid) unless status.account_id == circle.account_id + end +end diff --git a/app/models/concerns/has_user_settings.rb b/app/models/concerns/has_user_settings.rb index 780c6345bb..820c41360d 100644 --- a/app/models/concerns/has_user_settings.rb +++ b/app/models/concerns/has_user_settings.rb @@ -111,6 +111,22 @@ module HasUserSettings settings['web.use_system_font'] end + def setting_show_quote_in_home + settings['web.show_quote_in_home'] + end + + def setting_show_quote_in_public + settings['web.show_quote_in_public'] + end + + def setting_hide_blocking_quote + settings['web.hide_blocking_quote'] + end + + def setting_allow_quote + settings['allow_quote'] + end + def setting_noindex settings['noindex'] end @@ -127,10 +143,6 @@ module HasUserSettings settings['link_preview'] end - def setting_single_ref_to_quote - settings['single_ref_to_quote'] - end - def setting_dtl_force_with_tag settings['dtl_force_with_tag']&.to_sym || :none end @@ -251,6 +263,10 @@ module HasUserSettings settings['notification_emails.pending_account'] end + def allows_pending_friend_server_emails? + settings['notification_emails.pending_friend_server'] + end + def allows_appeal_emails? settings['notification_emails.appeal'] end diff --git a/app/models/concerns/status_search_concern.rb b/app/models/concerns/status_search_concern.rb index 376f82e509..e73a12d511 100644 --- a/app/models/concerns/status_search_concern.rb +++ b/app/models/concerns/status_search_concern.rb @@ -5,7 +5,7 @@ module StatusSearchConcern included do scope :indexable, -> { without_reblogs.where(visibility: [:public, :login], searchability: nil).joins(:account).where(account: { indexable: true }) } - scope :remote_dynamic_searchability, -> { remote.where(searchability: [:public, :private]) } + scope :remote_dynamic_searchability, -> { remote.where(searchability: [:public, :public_unlisted, :private]) } end def searchable_by diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index f443d08ca6..d63a7e6f9f 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -14,6 +14,7 @@ # action :integer default("warn"), not null # exclude_follows :boolean default(FALSE), not null # exclude_localusers :boolean default(FALSE), not null +# with_quote :boolean default(TRUE), not null # class CustomFilter < ApplicationRecord @@ -33,7 +34,7 @@ class CustomFilter < ApplicationRecord include Expireable include Redisable - enum action: { warn: 0, hide: 1 }, _suffix: :action + enum action: { warn: 0, hide: 1, half_warn: 2 }, _suffix: :action belongs_to :account has_many :keywords, class_name: 'CustomFilterKeyword', inverse_of: :custom_filter, dependent: :destroy @@ -103,11 +104,15 @@ class CustomFilter < ApplicationRecord if rules[:keywords].present? match = rules[:keywords].match(status.proper.searchable_text) - match = rules[:keywords].match(status.proper.references.pluck(:text).join("\n\n")) if match.nil? && status.proper.references.exists? + if match.nil? && filter.with_quote && status.proper.references.exists? + match = rules[:keywords].match(status.proper.references.pluck(:text).join("\n\n")) + match = rules[:keywords].match(status.proper.references.pluck(:spoiler_text).join("\n\n")) if match.nil? + end end keyword_matches = [match.to_s] unless match.nil? - status_matches = [status.id, status.reblog_of_id].compact & rules[:status_ids] if rules[:status_ids].present? + reference_ids = filter.with_quote ? status.proper.references.pluck(:id) : [] + status_matches = ([status.id, status.reblog_of_id] + reference_ids).compact & rules[:status_ids] if rules[:status_ids].present? next if keyword_matches.blank? && status_matches.blank? diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index b05fa19476..16d7ac2128 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -28,6 +28,7 @@ # hidden_anonymous :boolean default(FALSE), not null # detect_invalid_subscription :boolean default(FALSE), not null # reject_reply_exclude_followers :boolean default(FALSE), not null +# reject_friend :boolean default(FALSE), not null # class DomainBlock < ApplicationRecord @@ -44,7 +45,16 @@ class DomainBlock < ApplicationRecord scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :with_user_facing_limitations, -> { where(hidden: false) } - scope :with_limitations, -> { where(severity: [:silence, :suspend]).or(where(reject_media: true)).or(where(reject_favourite: true)).or(where(reject_reply: true)).or(where(reject_reply_exclude_followers: true)).or(where(reject_new_follow: true)).or(where(reject_straight_follow: true)) } + scope :with_limitations, lambda { + where(severity: [:silence, :suspend]) + .or(where(reject_media: true)) + .or(where(reject_favourite: true)) + .or(where(reject_reply: true)) + .or(where(reject_reply_exclude_followers: true)) + .or(where(reject_new_follow: true)) + .or(where(reject_straight_follow: true)) + .or(where(reject_friend: true)) + } scope :by_severity, -> { order(Arel.sql('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), domain')) } def to_log_human_identifier @@ -68,6 +78,7 @@ class DomainBlock < ApplicationRecord reject_hashtag? ? :reject_hashtag : nil, reject_straight_follow? ? :reject_straight_follow : nil, reject_new_follow? ? :reject_new_follow : nil, + reject_friend? ? :reject_friend : nil, detect_invalid_subscription? ? :detect_invalid_subscription : nil, reject_reports? ? :reject_reports : nil].reject { |policy| policy == :noop || policy.nil? } end @@ -110,6 +121,10 @@ class DomainBlock < ApplicationRecord !!rule_for(domain)&.reject_new_follow? end + def reject_friend?(domain) + !!rule_for(domain)&.reject_friend? + end + def detect_invalid_subscription?(domain) !!rule_for(domain)&.detect_invalid_subscription? end diff --git a/app/models/emoji_reaction.rb b/app/models/emoji_reaction.rb index f97679347c..e9dc6a37d3 100644 --- a/app/models/emoji_reaction.rb +++ b/app/models/emoji_reaction.rb @@ -39,10 +39,6 @@ class EmojiReaction < ApplicationRecord custom_emoji.present? end - def remote_custom_emoji? - custom_emoji? && !custom_emoji.local? - end - def sign? true end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 08b546561d..681b13814a 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -47,6 +47,8 @@ class Form::AdminSettings streaming_other_servers_emoji_reaction enable_emoji_reaction check_lts_version_only + enable_public_unlisted_visibility + unlocked_friend ).freeze INTEGER_KEYS = %i( @@ -74,6 +76,8 @@ class Form::AdminSettings streaming_other_servers_emoji_reaction enable_emoji_reaction check_lts_version_only + enable_public_unlisted_visibility + unlocked_friend ).freeze UPLOAD_KEYS = %i( diff --git a/app/models/form/import.rb b/app/models/form/import.rb index 2fc74715b5..29a2975c7b 100644 --- a/app/models/form/import.rb +++ b/app/models/form/import.rb @@ -43,14 +43,14 @@ class Form::Import validate :validate_data def guessed_type - return :muting if csv_data.headers.include?('Hide notifications') - return :following if csv_data.headers.include?('Show boosts') || csv_data.headers.include?('Notify on new posts') || csv_data.headers.include?('Languages') - return :following if data.original_filename&.start_with?('follows') || data.original_filename&.start_with?('following_accounts') - return :blocking if data.original_filename&.start_with?('blocks') || data.original_filename&.start_with?('blocked_accounts') - return :muting if data.original_filename&.start_with?('mutes') || data.original_filename&.start_with?('muted_accounts') - return :domain_blocking if data.original_filename&.start_with?('domain_blocks') || data.original_filename&.start_with?('blocked_domains') - return :bookmarks if data.original_filename&.start_with?('bookmarks') - return :lists if data.original_filename&.start_with?('lists') + return :muting if csv_headers_match?('Hide notifications') + return :following if csv_headers_match?('Show boosts') || csv_headers_match?('Notify on new posts') || csv_headers_match?('Languages') + return :following if file_name_matches?('follows') || file_name_matches?('following_accounts') + return :blocking if file_name_matches?('blocks') || file_name_matches?('blocked_accounts') + return :muting if file_name_matches?('mutes') || file_name_matches?('muted_accounts') + return :domain_blocking if file_name_matches?('domain_blocks') || file_name_matches?('blocked_domains') + return :bookmarks if file_name_matches?('bookmarks') + return :lists if file_name_matches?('lists') end # Whether the uploaded CSV file seems to correspond to a different import type than the one selected @@ -79,6 +79,14 @@ class Form::Import private + def file_name_matches?(string) + data.original_filename&.start_with?(string) + end + + def csv_headers_match?(string) + csv_data.headers.include?(string) + end + def default_csv_headers case type.to_sym when :following, :blocking, :muting diff --git a/app/models/friend_domain.rb b/app/models/friend_domain.rb new file mode 100644 index 0000000000..0842500033 --- /dev/null +++ b/app/models/friend_domain.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: friend_domains +# +# id :bigint(8) not null, primary key +# domain :string default(""), not null +# inbox_url :string default(""), not null +# active_state :integer default("idle"), not null +# passive_state :integer default("idle"), not null +# active_follow_activity_id :string +# passive_follow_activity_id :string +# available :boolean default(TRUE), not null +# pseudo_relay :boolean default(FALSE), not null +# allow_all_posts :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# delivery_local :boolean default(TRUE), not null +# + +class FriendDomain < ApplicationRecord + validates :domain, presence: true, uniqueness: true, if: :will_save_change_to_domain? + validates :inbox_url, presence: true, uniqueness: true, if: :will_save_change_to_inbox_url? + + enum active_state: { idle: 0, pending: 1, accepted: 2, rejected: 3 }, _prefix: :i_am + enum passive_state: { idle: 0, pending: 1, accepted: 2, rejected: 3 }, _prefix: :they_are + + scope :by_domain_and_subdomains, ->(domain) { where(domain: Instance.by_domain_and_subdomains(domain).select(:domain)) } + scope :enabled, -> { where(active_state: :accepted).or(FriendDomain.where(passive_state: :accepted)).where(available: true) } + scope :distributables, -> { enabled.where(pseudo_relay: true) } + scope :deliver_locals, -> { enabled.where(delivery_local: true) } + scope :free_receivings, -> { enabled.where(allow_all_posts: true) } + + before_destroy :ensure_disabled + after_commit :set_default_inbox_url + + def accepted? + i_am_accepted? || they_are_accepted? + end + + def pending? + !accepted? && (i_am_pending? || they_are_pending?) + end + + def idle? + (i_am_idle? || i_am_rejected?) && (they_are_idle? || they_are_rejected?) + end + + def follow! + activity_id = ActivityPub::TagManager.instance.generate_uri_for(nil) + payload = Oj.dump(follow_activity(activity_id)) + + update!(active_state: :pending, passive_state: :idle, active_follow_activity_id: activity_id) + DeliveryFailureTracker.reset!(inbox_url) + ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) + end + + def unfollow! + activity_id = ActivityPub::TagManager.instance.generate_uri_for(nil) + payload = Oj.dump(unfollow_activity(activity_id)) + + update!(active_state: :idle, passive_state: :idle, active_follow_activity_id: nil) + DeliveryFailureTracker.reset!(inbox_url) + ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) + end + + def accept! + return if they_are_idle? + + activity_id = passive_follow_activity_id + payload = Oj.dump(accept_follow_activity(activity_id)) + + update!(passive_state: :accepted, active_state: :idle) + DeliveryFailureTracker.reset!(inbox_url) + ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) + end + + def reject! + return if they_are_idle? + + activity_id = passive_follow_activity_id + payload = Oj.dump(reject_follow_activity(activity_id)) + + update!(passive_state: :rejected, active_state: :idle, passive_follow_activity_id: nil) + DeliveryFailureTracker.reset!(inbox_url) + ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) + end + + def destroy_without_signal! + self.active_state = :idle + self.passive_state = :idle + destroy! + end + + def default_inbox_url + "https://#{domain}/inbox" + end + + private + + def delete_for_friend! + activity_id = ActivityPub::TagManager.instance.generate_uri_for(nil) + payload = Oj.dump(delete_follow_activity(activity_id)) + + DeliveryFailureTracker.reset!(inbox_url) + ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) + end + + def follow_activity(activity_id) + { + '@context': ActivityPub::TagManager::CONTEXT, + id: activity_id, + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(some_local_account), + object: ActivityPub::TagManager::COLLECTIONS[:public], + + # Cannot use inbox_url method because this model also has inbox_url column + inboxUrl: "https://#{Rails.configuration.x.web_domain}/inbox", + } + end + + def unfollow_activity(activity_id) + { + '@context': ActivityPub::TagManager::CONTEXT, + id: activity_id, + type: 'Undo', + actor: ActivityPub::TagManager.instance.uri_for(some_local_account), + object: { + id: active_follow_activity_id, + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(some_local_account), + object: ActivityPub::TagManager::COLLECTIONS[:public], + }, + } + end + + def accept_follow_activity(activity_id) + { + '@context': ActivityPub::TagManager::CONTEXT, + id: "#{activity_id}#accepts/friends", + type: 'Accept', + actor: ActivityPub::TagManager.instance.uri_for(some_local_account), + object: activity_id, + } + end + + def reject_follow_activity(activity_id) + { + '@context': ActivityPub::TagManager::CONTEXT, + id: "#{activity_id}#rejects/friends", + type: 'Reject', + actor: ActivityPub::TagManager.instance.uri_for(some_local_account), + object: activity_id, + } + end + + def delete_follow_activity(activity_id) + { + '@context': ActivityPub::TagManager::CONTEXT, + id: "#{activity_id}#delete/friends", + type: 'Delete', + actor: ActivityPub::TagManager.instance.uri_for(some_local_account), + object: ActivityPub::TagManager::COLLECTIONS[:public], + } + end + + def some_local_account + @some_local_account ||= Account.representative + end + + def ensure_disabled + delete_for_friend! unless id.nil? || (i_am_idle? && they_are_idle?) + end + + def set_default_inbox_url + self.inbox_url = default_inbox_url if inbox_url.blank? + end +end diff --git a/app/models/instance.rb b/app/models/instance.rb index 0fb1d3e96a..09e823bfbb 100644 --- a/app/models/instance.rb +++ b/app/models/instance.rb @@ -20,6 +20,7 @@ class Instance < ApplicationRecord belongs_to :domain_allow belongs_to :unavailable_domain # skipcq: RB-RL1031 belongs_to :instance_info + belongs_to :friend_domain end scope :searchable, -> { where.not(domain: DomainBlock.select(:domain)) } diff --git a/app/models/mention.rb b/app/models/mention.rb index 2348b2905c..5addfcc583 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -13,6 +13,8 @@ # class Mention < ApplicationRecord + include Paginable + belongs_to :account, inverse_of: :mentions belongs_to :status diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb index a641e77039..0ca7621060 100644 --- a/app/models/public_feed.rb +++ b/app/models/public_feed.rb @@ -19,7 +19,7 @@ class PublicFeed # @param [Integer] min_id # @return [Array] def get(limit, max_id = nil, since_id = nil, min_id = nil) - scope = local_only? ? public_scope : global_timeline_only_scope + scope = public_scope scope.merge!(without_replies_scope) unless with_replies? scope.merge!(without_reblogs_scope) unless with_reblogs? @@ -70,10 +70,6 @@ class PublicFeed Status.with_public_visibility.joins(:account).merge(Account.without_suspended.without_silenced) end - def global_timeline_only_scope - Status.with_global_timeline_visibility.joins(:account).merge(Account.without_suspended.without_silenced) - end - def public_search_scope Status.with_public_search_visibility.joins(:account).merge(Account.without_suspended.without_silenced) end diff --git a/app/models/report_filter.rb b/app/models/report_filter.rb index c9b3bce2d1..fd0e23cb81 100644 --- a/app/models/report_filter.rb +++ b/app/models/report_filter.rb @@ -19,7 +19,7 @@ class ReportFilter scope = Report.unresolved params.each do |key, value| - scope = scope.merge scope_for(key, value), rewhere: true + scope = scope.merge scope_for(key, value) end scope diff --git a/app/models/status.rb b/app/models/status.rb index a5501fcbf6..4b718cdc92 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -30,6 +30,7 @@ # searchability :integer # markdown :boolean default(FALSE) # limited_scope :integer +# quote_of_id :bigint(8) # require 'ostruct' @@ -58,7 +59,7 @@ class Status < ApplicationRecord enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10, login: 11 }, _suffix: :visibility enum searchability: { public: 0, private: 1, direct: 2, limited: 3, unsupported: 4, public_unlisted: 10 }, _suffix: :searchability - enum limited_scope: { none: 0, mutual: 1, circle: 2 }, _suffix: :limited + enum limited_scope: { none: 0, mutual: 1, circle: 2, personal: 3 }, _suffix: :limited belongs_to :application, class_name: 'Doorkeeper::Application', optional: true @@ -69,12 +70,14 @@ class Status < ApplicationRecord belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true + belongs_to :quote, foreign_key: 'quote_of_id', class_name: 'Status', inverse_of: :quotes, optional: true has_many :favourites, inverse_of: :status, dependent: :destroy has_many :emoji_reactions, inverse_of: :status, dependent: :destroy has_many :bookmarks, inverse_of: :status, dependent: :destroy has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy has_many :reblogged_by_accounts, through: :reblogs, class_name: 'Account', source: :account + has_many :quotes, foreign_key: 'quote_of_id', class_name: 'Status', inverse_of: :quote has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread has_many :mentions, dependent: :destroy, inverse_of: :status has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account' @@ -106,6 +109,7 @@ class Status < ApplicationRecord has_one :poll, inverse_of: :status, dependent: :destroy has_one :trend, class_name: 'StatusTrend', inverse_of: :status has_one :scheduled_expiration_status, inverse_of: :status, dependent: :destroy + has_one :circle_status, inverse_of: :status, dependent: :destroy validates :uri, uniqueness: true, presence: true, unless: :local? validates :text, presence: true, unless: -> { with_media? || reblog? } @@ -125,8 +129,7 @@ class Status < ApplicationRecord scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') } scope :without_reblogs, -> { where(statuses: { reblog_of_id: nil }) } scope :with_public_visibility, -> { where(visibility: [:public, :public_unlisted, :login]) } - scope :with_public_search_visibility, -> { merge(where(visibility: [:public, :public_unlisted, :login]).or(Status.where(searchability: :public))) } - scope :with_global_timeline_visibility, -> { where(visibility: [:public, :login]) } + scope :with_public_search_visibility, -> { merge(where(visibility: [:public, :public_unlisted, :login]).or(Status.where(searchability: [:public, :public_unlisted]))) } scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) } scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) } scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) } @@ -192,6 +195,19 @@ class Status < ApplicationRecord account: [:account_stat, user: :role], active_mentions: { account: :account_stat }, ], + quote: [ + :application, + :tags, + :preview_cards, + :media_attachments, + :conversation, + :status_stat, + :preloadable_poll, + :reference_objects, + :scheduled_expiration_status, + account: [:account_stat, user: :role], + active_mentions: { account: :account_stat }, + ], thread: { account: :account_stat } delegate :domain, to: :account, prefix: true @@ -226,8 +242,8 @@ class Status < ApplicationRecord !reblog_of_id.nil? end - def quote - reference_objects.where(attribute_type: 'QT').first&.target_status + def quote? + !quote_of_id.nil? end def within_realtime_window? @@ -425,18 +441,27 @@ class Status < ApplicationRecord def compute_searchability local = account.local? + check_searchability = public_unlisted_searchability? ? 'public' : searchability - return 'private' if public_searchability? && account.silenced? + return 'private' if %w(public public_unlisted).include?(check_searchability) && account.silenced? return 'direct' if unsupported_searchability? - return searchability if local && !searchability.nil? - return 'direct' if local || [:public, :private, :direct, :limited].exclude?(account.searchability.to_sym) + return check_searchability if local && !check_searchability.nil? + return 'direct' if local || %i(public private direct limited).exclude?(account.searchability.to_sym) account_searchability = Status.searchabilities[account.searchability] - status_searchability = Status.searchabilities[searchability.nil? ? 'direct' : searchability] + status_searchability = Status.searchabilities[check_searchability.nil? ? 'direct' : check_searchability] Status.searchabilities.invert.fetch([account_searchability, status_searchability].max) || 'direct' end def compute_searchability_activitypub + return 'private' if public_unlisted_searchability? + + compute_searchability + end + + def compute_searchability_local + return 'public_unlisted' if public_unlisted_searchability? + compute_searchability end @@ -448,14 +473,20 @@ class Status < ApplicationRecord class << self def selectable_visibilities - visibilities.keys - %w(direct limited) + vs = visibilities.keys - %w(direct limited) + vs -= %w(public_unlisted) unless Setting.enable_public_unlisted_visibility + vs end def selectable_reblog_visibilities - %w(unset) + visibilities.keys - %w(direct limited) + %w(unset) + selectable_visibilities end def selectable_searchabilities + searchabilities.keys - %w(unsupported) + end + + def selectable_searchabilities_for_search searchabilities.keys - %w(public_unlisted unsupported) end @@ -475,12 +506,16 @@ class Status < ApplicationRecord ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true } end - def pins_map(status_ids, account_id) - StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true } + def blocks_map(account_ids, account_id) + Block.where(account_id: account_id, target_account_id: account_ids).each_with_object({}) { |b, h| h[b.target_account_id] = true } end - def emoji_reactions_map(status_ids, account_id) - EmojiReaction.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |e, h| h[e.status_id] = true } + def domain_blocks_map(domains, account_id) + AccountDomainBlock.where(account_id: account_id, domain: domains).each_with_object({}) { |d, h| h[d.domain] = true } + end + + def pins_map(status_ids, account_id) + StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true } end def emoji_reaction_allows_map(status_ids, account_id) @@ -556,6 +591,10 @@ class Status < ApplicationRecord end end + def distributable_friend? + public_visibility? || public_unlisted_visibility? || (unlisted_visibility? && (public_searchability? || public_unlisted_searchability?)) + end + private def update_status_stat!(attrs) @@ -595,7 +634,7 @@ class Status < ApplicationRecord elsif visibility == 'limited' :limited elsif visibility == 'private' - searchability == 'public' ? :private : searchability + searchability == 'public' || searchability == 'public_unlisted' ? :private : searchability elsif visibility == 'direct' searchability == 'limited' ? :limited : :direct else diff --git a/app/models/status_reference.rb b/app/models/status_reference.rb index 8d5d6eba8b..7bbd7b3232 100644 --- a/app/models/status_reference.rb +++ b/app/models/status_reference.rb @@ -10,6 +10,7 @@ # created_at :datetime not null # updated_at :datetime not null # attribute_type :string +# quote :boolean default(FALSE), not null # class StatusReference < ApplicationRecord @@ -19,6 +20,8 @@ class StatusReference < ApplicationRecord has_one :notification, as: :activity, dependent: :destroy after_commit :reset_parent_cache + after_create_commit :set_quote + after_destroy_commit :remove_quote private @@ -26,4 +29,18 @@ class StatusReference < ApplicationRecord Rails.cache.delete("statuses/#{status_id}") Rails.cache.delete("statuses/#{target_status_id}") end + + def set_quote + return unless quote + return if status.quote_of_id.present? + + status.quote_of_id = target_status_id + end + + def remove_quote + return unless quote + return unless status.quote_of_id == target_status_id + + status.quote_of_id = nil + end end diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb index f2c04f220d..c7fe097904 100644 --- a/app/models/trends/statuses.rb +++ b/app/models/trends/statuses.rb @@ -106,7 +106,8 @@ class Trends::Statuses < Trends::Base private def eligible?(status) - (status.searchability.nil? || status.public_searchability?) && (status.public_visibility? || status.public_unlisted_visibility?) && + (status.searchability.nil? || status.compute_searchability == 'public') && + (status.public_visibility? || status.public_unlisted_visibility?) && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && (!status.sensitive? || status.media_attachments.none?) && !status.reply? && valid_locale?(status.language) end diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb index 93f7f4a64f..e353ec7b2a 100644 --- a/app/models/user_settings.rb +++ b/app/models/user_settings.rb @@ -25,7 +25,7 @@ class UserSettings setting :default_privacy, default: nil, in: %w(public public_unlisted login unlisted private) setting :stay_privacy, default: false setting :default_reblog_privacy, default: nil - setting :default_searchability, default: :direct, in: %w(public private direct limited) + setting :default_searchability, default: :direct, in: %w(public private direct limited public_unlisted) setting :default_searchability_of_search, default: :public, in: %w(public private direct limited) setting :use_public_index, default: true setting :disallow_unlisted_public_searchability, default: false @@ -41,7 +41,7 @@ class UserSettings setting :dtl_force_with_tag, default: :none, in: %w(full searchability none) setting :dtl_force_subscribable, default: false setting :lock_follow_from_bot, default: false - setting :single_ref_to_quote, default: false + setting :allow_quote, default: true setting_inverse_alias :indexable, :noindex @@ -67,6 +67,9 @@ class UserSettings setting :display_media_expand, default: true setting :auto_play, default: true setting :simple_timeline_menu, default: false + setting :show_quote_in_home, default: true + setting :show_quote_in_public, default: false + setting :hide_blocking_quote, default: true end namespace :notification_emails do @@ -77,6 +80,7 @@ class UserSettings setting :follow_request, default: true setting :report, default: true setting :pending_account, default: true + setting :pending_friend_server, default: true setting :trends, default: true setting :appeal, default: true setting :software_updates, default: 'critical', in: %w(none critical patch all) diff --git a/app/policies/friend_server_policy.rb b/app/policies/friend_server_policy.rb new file mode 100644 index 0000000000..c84b2b825a --- /dev/null +++ b/app/policies/friend_server_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class FriendServerPolicy < ApplicationPolicy + def update? + role.can?(:manage_federation) + end +end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 335abe9e92..b30d48e374 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -24,6 +24,10 @@ class StatusPolicy < ApplicationPolicy end end + def show_mentioned_users? + owned? + end + def reblog? !requires_mention? && (!private? || owned?) && show? && !blocking_author? end @@ -36,6 +40,10 @@ class StatusPolicy < ApplicationPolicy show? && !blocking_author? end + def quote? + %i(public public_unlisted unlisted).include?(record.visibility.to_sym) && show? && !blocking_author? + end + def destroy? owned? end diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb index 5066a57f8c..46105df073 100644 --- a/app/presenters/activitypub/activity_presenter.rb +++ b/app/presenters/activitypub/activity_presenter.rb @@ -4,13 +4,13 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model attributes :id, :type, :actor, :published, :to, :cc, :virtual_object class << self - def from_status(status, use_bearcap: true, allow_inlining: true, for_misskey: false) + def from_status(status, use_bearcap: true, allow_inlining: true, for_misskey: false, for_friend: false) new.tap do |presenter| presenter.id = ActivityPub::TagManager.instance.activity_uri_for(status) presenter.type = status.reblog? ? 'Announce' : 'Create' presenter.actor = ActivityPub::TagManager.instance.uri_for(status.account) presenter.published = status.created_at - presenter.to = ActivityPub::TagManager.instance.to(status) + presenter.to = for_friend ? ActivityPub::TagManager.instance.to_for_friend(status) : ActivityPub::TagManager.instance.to(status) presenter.cc = for_misskey ? ActivityPub::TagManager.instance.cc_for_misskey(status) : ActivityPub::TagManager.instance.cc(status) presenter.virtual_object = begin diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 9e55742403..35c9e3e3f3 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -3,8 +3,8 @@ class StatusRelationshipsPresenter PINNABLE_VISIBILITIES = %w(public public_unlisted unlisted login private).freeze - attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, - :bookmarks_map, :filters_map, :emoji_reactions_map, :attributes_map, :emoji_reaction_allows_map + attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, :blocks_map, :domain_blocks_map, + :bookmarks_map, :filters_map, :attributes_map, :emoji_reaction_allows_map def initialize(statuses, current_account_id = nil, **options) @current_account_id = current_account_id @@ -14,25 +14,28 @@ class StatusRelationshipsPresenter @favourites_map = {} @bookmarks_map = {} @mutes_map = {} + @blocks_map = {} + @domain_blocks_map = {} @pins_map = {} @filters_map = {} - @emoji_reactions_map = {} @emoji_reaction_allows_map = nil else - statuses = statuses.compact + statuses = statuses.compact + statuses += statuses.filter_map(&:quote) status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact conversation_ids = statuses.filter_map(&:conversation_id).uniq pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) } - @filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {}) - @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {}) - @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) - @bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {}) - @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) - @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) - @emoji_reactions_map = Status.emoji_reactions_map(status_ids, current_account_id).merge(options[:emoji_reactions_map] || {}) + @filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {}) + @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {}) + @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) + @bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {}) + @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) + @blocks_map = Status.blocks_map(statuses.map(&:account_id), current_account_id).merge(options[:blocks_map] || {}) + @domain_blocks_map = Status.domain_blocks_map(statuses.filter_map { |status| status.account.domain }.uniq, current_account_id).merge(options[:domain_blocks_map] || {}) + @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) @emoji_reaction_allows_map = Status.emoji_reaction_allows_map(status_ids, current_account_id).merge(options[:emoji_reaction_allows_map] || {}) - @attributes_map = options[:attributes_map] || {} + @attributes_map = options[:attributes_map] || {} end end diff --git a/app/serializers/activitypub/activity_for_friend_serializer.rb b/app/serializers/activitypub/activity_for_friend_serializer.rb new file mode 100644 index 0000000000..b968e00fa6 --- /dev/null +++ b/app/serializers/activitypub/activity_for_friend_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ActivityPub::ActivityForFriendSerializer < ActivityPub::Serializer + def self.serializer_for(model, options) + case model.class.name + when 'Status' + ActivityPub::NoteForFriendSerializer + when 'DeliverToDeviceService::EncryptedMessage' + ActivityPub::EncryptedMessageSerializer + else + super + end + end + + attributes :id, :type, :actor, :published, :to, :cc + + has_one :virtual_object, key: :object + + def published + object.published.iso8601 + end +end diff --git a/app/serializers/activitypub/note_for_friend_serializer.rb b/app/serializers/activitypub/note_for_friend_serializer.rb new file mode 100644 index 0000000000..fdceaf421c --- /dev/null +++ b/app/serializers/activitypub/note_for_friend_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ActivityPub::NoteForFriendSerializer < ActivityPub::NoteSerializer + def to + ActivityPub::TagManager.instance.to_for_friend(object) + end + + def searchable_by + ActivityPub::TagManager.instance.searchable_by_for_friend(object) + end +end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index d8f7a328ed..3c89c7b632 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -170,12 +170,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer object.account.local? end - def quote? - @quote ||= (object.reference_objects.count == 1 && object.account.user&.settings&.[]('single_ref_to_quote')) || object.reference_objects.where(attribute_type: 'QT').count == 1 - end + delegate :quote?, to: :object def quote_post - @quote_post ||= object.quote || object.references.first + @quote_post ||= object.quote end def quote_uri diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 8f67f7e754..eb2990703a 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -37,6 +37,7 @@ class InitialStateSerializer < ActiveModel::Serializer status_page_url: Setting.status_page_url, sso_redirect: sso_redirect, dtl_tag: DTL_ENABLED ? DTL_TAG : nil, + enable_local_privacy: Setting.enable_public_unlisted_visibility, } if object.current_account @@ -61,6 +62,9 @@ class InitialStateSerializer < ActiveModel::Serializer store[:show_trends] = Setting.trends && object.current_account.user.setting_trends store[:bookmark_category_needed] = object.current_account.user.setting_bookmark_category_needed store[:simple_timeline_menu] = object.current_account.user.setting_simple_timeline_menu + store[:show_quote_in_home] = object.current_account.user.setting_show_quote_in_home + store[:show_quote_in_public] = object.current_account.user.setting_show_quote_in_public + store[:hide_blocking_quote] = object.current_account.user.setting_hide_blocking_quote else store[:auto_play_gif] = Setting.auto_play_gif store[:display_media] = Setting.display_media @@ -68,6 +72,8 @@ class InitialStateSerializer < ActiveModel::Serializer store[:use_blurhash] = Setting.use_blurhash store[:enable_emoji_reaction] = Setting.enable_emoji_reaction store[:show_emoji_reaction_on_timeline] = Setting.enable_emoji_reaction + store[:show_quote_in_home] = true + store[:show_quote_in_public] = true end store[:disabled_account_id] = object.disabled_account.id.to_s if object.disabled_account diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb index 48f3aa7a6a..aa7f7ce5d8 100644 --- a/app/serializers/manifest_serializer.rb +++ b/app/serializers/manifest_serializer.rb @@ -84,6 +84,10 @@ class ManifestSerializer < ActiveModel::Serializer name: 'Notifications', url: '/notifications', }, + { + name: 'Explore', + url: '/explore', + }, ] end end diff --git a/app/serializers/nodeinfo/serializer.rb b/app/serializers/nodeinfo/serializer.rb index 3555f0bd8d..83d63bb397 100644 --- a/app/serializers/nodeinfo/serializer.rb +++ b/app/serializers/nodeinfo/serializer.rb @@ -40,7 +40,7 @@ class NodeInfo::Serializer < ActiveModel::Serializer def metadata { - features: fedibird_capabilities, + features: capabilities_for_nodeinfo, } end diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 772b71fe87..e9aa04bb43 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -4,6 +4,8 @@ class REST::AccountSerializer < ActiveModel::Serializer include RoutingHelper include FormattingHelper + # Please update `app/javascript/mastodon/api_types/accounts.ts` when making changes to the attributes + attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at, :note, :url, :uri, :avatar, :avatar_static, :header, :header_static, :subscribable, :followers_count, :following_count, :statuses_count, :last_status_at, :other_settings, :noindex diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb index efcdaa8e86..13aee101d6 100644 --- a/app/serializers/rest/custom_emoji_serializer.rb +++ b/app/serializers/rest/custom_emoji_serializer.rb @@ -3,6 +3,8 @@ class REST::CustomEmojiSerializer < REST::CustomEmojiSlimSerializer include RoutingHelper + # Please update `app/javascript/mastodon/api_types/custom_emoji.ts` when making changes to the attributes + attribute :aliases, if: :aliases? def aliases? diff --git a/app/serializers/rest/custom_emoji_slim_serializer.rb b/app/serializers/rest/custom_emoji_slim_serializer.rb index c5542fbd27..6b17c15a03 100644 --- a/app/serializers/rest/custom_emoji_slim_serializer.rb +++ b/app/serializers/rest/custom_emoji_slim_serializer.rb @@ -3,6 +3,8 @@ class REST::CustomEmojiSlimSerializer < ActiveModel::Serializer include RoutingHelper + # Please update `app/javascript/mastodon/api_types/custom_emoji.ts` when making changes to the attributes + attributes :shortcode, :url, :static_url, :visible_in_picker attribute :category, if: :category_loaded? diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb index 1b5d70dffd..08fadda582 100644 --- a/app/serializers/rest/filter_serializer.rb +++ b/app/serializers/rest/filter_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class REST::FilterSerializer < ActiveModel::Serializer - attributes :id, :title, :exclude_follows, :exclude_localusers, :context, :expires_at, :filter_action + attributes :id, :title, :exclude_follows, :exclude_localusers, :with_quote, :context, :expires_at, :filter_action has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested? has_many :statuses, serializer: REST::FilterStatusSerializer, if: :rules_requested? diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb index b533874012..4d7ed75935 100644 --- a/app/serializers/rest/relationship_serializer.rb +++ b/app/serializers/rest/relationship_serializer.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class REST::RelationshipSerializer < ActiveModel::Serializer + # Please update `app/javascript/mastodon/api_types/relationships.ts` when making changes to the attributes + attributes :id, :following, :showing_reblogs, :notifying, :languages, :followed_by, :blocking, :blocked_by, :muting, :muting_notifications, :requested, :requested_by, :domain_blocking, :endorsed, :note diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 3603026f39..1fcc939cdb 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -16,6 +16,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :pinned, if: :pinnable? attribute :reactions, if: :reactions? attribute :expires_at, if: :will_expire? + attribute :quote_id, if: :quote? has_many :filtered, serializer: REST::FilterResultSerializer, if: :current_user? attribute :content, unless: :source_requested? @@ -33,6 +34,23 @@ class REST::StatusSerializer < ActiveModel::Serializer has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer + class QuotedStatusSerializer < REST::StatusSerializer + attribute :quote_muted, if: :current_user? + + def quote + nil + end + + def quote_muted + if relationships + muted || relationships.blocks_map[object.account_id] || relationships.domain_blocks_map[object.account.domain] || false + else + muted || current_user.account.blocking?(object.account_id) || current_user.account.domain_blocking?(object.account.domain) + end + end + end + belongs_to :quote, if: :quote?, serializer: QuotedStatusSerializer, relationships: -> { relationships } + def id object.id.to_s end @@ -75,7 +93,7 @@ class REST::StatusSerializer < ActiveModel::Serializer end def searchability - object.compute_searchability + object.compute_searchability_local end def sensitive @@ -159,6 +177,12 @@ class REST::StatusSerializer < ActiveModel::Serializer end end + def quote_id + object.quote_of_id.to_s + end + + delegate :quote?, to: :object + def reblogged if relationships relationships.reblogs_map[object.id] || false diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 92d5788151..c7bc93f781 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -10,7 +10,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService @activity_json = activity_json @json = object_json - @status_parser = ActivityPub::Parser::StatusParser.new(@json) + @status_parser = ActivityPub::Parser::StatusParser.new(@json, account: status.account) @uri = @status_parser.uri @status = status @account = status.account @@ -259,9 +259,8 @@ class ActivityPub::ProcessStatusUpdateService < BaseService def update_references! references = @json['references'].nil? ? [] : ActivityPub::FetchReferencesService.new.call(@status, @json['references']) quote = @json['quote'] || @json['quoteUrl'] || @json['quoteURL'] || @json['_misskey_quote'] - references << quote if quote - ProcessReferencesService.perform_worker_async(@status, [], references) + ProcessReferencesService.call_service_without_error(@status, [], references, [quote].compact) end def expected_type? diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index 76cc36ff6b..511068bdf9 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -24,6 +24,9 @@ class BlockDomainService < BaseService silence_accounts! elsif domain_block.suspend? suspend_accounts! + remove_friends! + elsif domain_block.reject_friend? + remove_friends! end DomainClearMediaWorker.perform_async(domain_block.id) if domain_block.reject_media? @@ -41,6 +44,10 @@ class BlockDomainService < BaseService end end + def remove_friends! + blocked_friends.find_each(&:destroy) + end + def blocked_domain domain_block.domain end @@ -48,4 +55,8 @@ class BlockDomainService < BaseService def blocked_domain_accounts Account.by_domain_and_subdomains(blocked_domain) end + + def blocked_friends + @blocked_friends ||= FriendDomain.by_domain_and_subdomains(blocked_domain) + end end diff --git a/app/services/delivery_antenna_service.rb b/app/services/delivery_antenna_service.rb index 3d26321995..fb60473c30 100644 --- a/app/services/delivery_antenna_service.rb +++ b/app/services/delivery_antenna_service.rb @@ -64,8 +64,10 @@ class DeliveryAntennaService next if antenna.exclude_accounts&.include?(@status.account_id) next if antenna.exclude_domains&.include?(domain) next if antenna.exclude_tags&.any? { |tag_id| tag_ids.include?(tag_id) } - next if @status.unlisted_visibility? && !@status.public_searchability? && follower_ids.exclude?(antenna.account_id) - next if @status.unlisted_visibility? && @status.public_searchability? && follower_ids.exclude?(antenna.account_id) && antenna.any_keywords && antenna.any_tags + + searchability = @status.compute_searchability + next if @status.unlisted_visibility? && searchability != 'public' && follower_ids.exclude?(antenna.account_id) + next if @status.unlisted_visibility? && searchability == 'public' && follower_ids.exclude?(antenna.account_id) && antenna.any_keywords && antenna.any_tags collection.push(antenna) end @@ -121,7 +123,7 @@ class DeliveryAntennaService when :public, :public_unlisted, :login, :limited false when :unlisted - !@status.public_searchability? + @status.compute_searchability != 'public' else true end diff --git a/app/services/emoji_react_service.rb b/app/services/emoji_react_service.rb index 768fc7ac5c..a827ddc93e 100644 --- a/app/services/emoji_react_service.rb +++ b/app/services/emoji_react_service.rb @@ -14,6 +14,7 @@ class EmojiReactService < BaseService def call(account, status, name) status = status.reblog if status.reblog? && !status.reblog.nil? authorize_with account, status, :emoji_reaction? + @status = status emoji_reaction = nil @@ -39,6 +40,8 @@ class EmojiReactService < BaseService notify_to_followers(emoji_reaction) bump_potential_friendship(account, status) write_stream(emoji_reaction) + relay_for_emoji_reaction!(emoji_reaction) + relay_friend_for_emoji_reaction!(emoji_reaction) emoji_reaction end @@ -62,7 +65,6 @@ class EmojiReactService < BaseService status = emoji_reaction.status return unless status.account.local? - return if emoji_reaction.remote_custom_emoji? ActivityPub::RawDistributionWorker.perform_async(build_json(emoji_reaction), status.account_id) end @@ -82,11 +84,27 @@ class EmojiReactService < BaseService end def build_json(emoji_reaction) - Oj.dump(serialize_payload(emoji_reaction, ActivityPub::EmojiReactionSerializer)) + @build_json = Oj.dump(serialize_payload(emoji_reaction, ActivityPub::EmojiReactionSerializer, signer: emoji_reaction.account)) end def render_emoji_reaction(emoji_group) # @rendered_emoji_reaction ||= InlineRenderer.render(HashObject.new(emoji_group), nil, :emoji_reaction) @render_emoji_reaction ||= Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json) end + + def relay_for_emoji_reaction!(emoji_reaction) + return unless @status.local? && @status.public_visibility? + + ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| + [build_json(emoji_reaction), @status.account.id, inbox_url] + end + end + + def relay_friend_for_emoji_reaction!(emoji_reaction) + return unless @status.local? && @status.distributable_friend? + + ActivityPub::DeliveryWorker.push_bulk(FriendDomain.distributables.pluck(:inbox_url)) do |inbox_url| + [build_json(emoji_reaction), @status.account.id, inbox_url] + end + end end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index a2e2653813..89e1b6c9c5 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -21,9 +21,6 @@ class FanOutOnWriteService < BaseService if broadcastable? fan_out_to_public_recipients! fan_out_to_public_streams! - elsif broadcastable_unlisted? - fan_out_to_public_recipients! - fan_out_to_public_unlisted_streams! elsif broadcastable_unlisted2? fan_out_to_unlisted_streams! end @@ -75,11 +72,6 @@ class FanOutOnWriteService < BaseService broadcast_to_public_streams! end - def fan_out_to_public_unlisted_streams! - broadcast_to_hashtag_streams! - broadcast_to_public_unlisted_streams! - end - def fan_out_to_unlisted_streams! broadcast_to_hashtag_streams! end @@ -176,16 +168,6 @@ class FanOutOnWriteService < BaseService end end - def broadcast_to_public_unlisted_streams! - return if @status.reply? && @status.in_reply_to_account_id != @account.id - - redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', anonymous_payload) - - if @status.with_media? - redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', anonymous_payload) - end - end - def deliver_to_conversation! AccountConversation.add_status(@account, @status) unless update? end @@ -210,14 +192,10 @@ class FanOutOnWriteService < BaseService end def broadcastable? - (@status.public_visibility? || @status.login_visibility?) && !@status.reblog? && !@account.silenced? - end - - def broadcastable_unlisted? - @status.public_unlisted_visibility? && !@status.reblog? && !@account.silenced? + (@status.public_visibility? || @status.public_unlisted_visibility? || @status.login_visibility?) && !@status.reblog? && !@account.silenced? end def broadcastable_unlisted2? - @status.unlisted_visibility? && @status.public_searchability? && !@status.reblog? && !@account.silenced? + @status.unlisted_visibility? && @status.compute_searchability == 'public' && !@status.reblog? && !@account.silenced? end end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index f48555245e..cdd1aa8674 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -78,14 +78,16 @@ class PostStatusService < BaseService @visibility = :direct if @in_reply_to&.limited_visibility? @visibility = :limited if %w(mutual circle).include?(@options[:visibility]) @visibility = :unlisted if (@visibility&.to_sym == :public || @visibility&.to_sym == :public_unlisted || @visibility&.to_sym == :login) && @account.silenced? - @visibility = :public_unlisted if @visibility&.to_sym == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted + @visibility = :public_unlisted if @visibility&.to_sym == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted && Setting.enable_public_unlisted_visibility @limited_scope = @options[:visibility]&.to_sym if @visibility == :limited @searchability = searchability - @searchability = :private if @account.silenced? && @searchability&.to_sym == :public + @searchability = :private if @account.silenced? && %i(public public_unlisted).include?(@searchability&.to_sym) @markdown = @options[:markdown] || false @scheduled_at = @options[:scheduled_at]&.to_datetime @scheduled_at = nil if scheduled_in_the_past? @reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?) + raise ArgumentError if !Setting.enable_public_unlisted_visibility && @visibility == :public_unlisted + load_circle overwrite_dtl_post process_sensitive_words @@ -127,6 +129,8 @@ class PostStatusService < BaseService case @options[:searchability]&.to_sym when :public case @visibility&.to_sym when :public, :public_unlisted, :login, :unlisted then :public when :private then :private else :direct end + when :public_unlisted + case @visibility&.to_sym when :public, :public_unlisted, :login, :unlisted then :public_unlisted when :private then :private else :direct end when :private case @visibility&.to_sym when :public, :public_unlisted, :login, :unlisted, :private then :private else :direct end when :direct @@ -143,6 +147,8 @@ class PostStatusService < BaseService process_mentions_service.call(@status, limited_type: @status.limited_visibility? ? @limited_scope : '', circle: @circle, save_records: false) safeguard_mentions!(@status) + @status.limited_scope = :personal if @status.limited_visibility? && !process_mentions_service.mentions? + UpdateStatusExpirationService.new.call(@status) # The following transaction block is needed to wrap the UPDATEs to @@ -192,9 +198,9 @@ class PostStatusService < BaseService ProcessReferencesService.call_service(@status, @reference_ids, []) LinkCrawlWorker.perform_async(@status.id) DistributionWorker.perform_async(@status.id) - ActivityPub::DistributionWorker.perform_async(@status.id) + ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.personal_limited? PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll - GroupReblogService.new.call(@status) + GroupReblogService.new.call(@status) unless @status.personal_limited? end def validate_status! @@ -219,7 +225,7 @@ class PostStatusService < BaseService end def process_mentions_service - ProcessMentionsService.new + @process_mentions_service ||= ProcessMentionsService.new end def process_hashtags_service diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 493facbea7..5053cf4ce3 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -24,6 +24,10 @@ class ProcessMentionsService < BaseService end end + def mentions? + @current_mentions.present? + end + private def scan_text! @@ -112,5 +116,7 @@ class ProcessMentionsService < BaseService @circle.accounts.find_each do |target_account| @current_mentions << @status.mentions.new(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id) end + + @circle.statuses << @status end end diff --git a/app/services/process_references_service.rb b/app/services/process_references_service.rb index faf5965a7a..40405c6ee7 100644 --- a/app/services/process_references_service.rb +++ b/app/services/process_references_service.rb @@ -10,14 +10,17 @@ class ProcessReferencesService < BaseService REFURL_EXP = /(RT|QT|BT|RN|RE)((:|;)?\s+|:|;)(#{URI::DEFAULT_PARSER.make_regexp(%w(http https))})/ MAX_REFERENCES = 5 - def call(status, reference_parameters, urls: nil, fetch_remote: true, no_fetch_urls: nil) + def call(status, reference_parameters, urls: nil, fetch_remote: true, no_fetch_urls: nil, quote_urls: nil) @status = status @reference_parameters = reference_parameters || [] - @urls = urls || [] + @quote_urls = quote_urls || [] + @urls = (urls - @quote_urls) || [] @no_fetch_urls = no_fetch_urls || [] @fetch_remote = fetch_remote @again = false + @attributes = {} + with_redis_lock("process_status_refs:#{@status.id}") do @references_count = old_references.size @@ -38,27 +41,37 @@ class ProcessReferencesService < BaseService launch_worker if @again end - def self.need_process?(status, reference_parameters, urls) - reference_parameters.any? || (urls || []).any? || FormattingHelper.extract_status_plain_text(status).scan(REFURL_EXP).pluck(3).uniq.any? + def self.need_process?(status, reference_parameters, urls, quote_urls) + reference_parameters.any? || (urls || []).any? || (quote_urls || []).any? || FormattingHelper.extract_status_plain_text(status).scan(REFURL_EXP).pluck(3).uniq.any? end - def self.perform_worker_async(status, reference_parameters, urls) - return unless need_process?(status, reference_parameters, urls) + def self.perform_worker_async(status, reference_parameters, urls, quote_urls) + return unless need_process?(status, reference_parameters, urls, quote_urls) Rails.cache.write("status_reference:#{status.id}", true, expires_in: 10.minutes) - ProcessReferencesWorker.perform_async(status.id, reference_parameters, urls, []) + ProcessReferencesWorker.perform_async(status.id, reference_parameters, urls, [], quote_urls || []) end - def self.call_service(status, reference_parameters, urls) - return unless need_process?(status, reference_parameters, urls) + def self.call_service(status, reference_parameters, urls, quote_urls = []) + return unless need_process?(status, reference_parameters, urls, quote_urls) - ProcessReferencesService.new.call(status, reference_parameters || [], urls: urls || [], fetch_remote: false) + ProcessReferencesService.new.call(status, reference_parameters || [], urls: urls || [], fetch_remote: false, quote_urls: quote_urls) + end + + def self.call_service_without_error(status, reference_parameters, urls, quote_urls = []) + return unless need_process?(status, reference_parameters, urls, quote_urls) + + begin + ProcessReferencesService.new.call(status, reference_parameters || [], urls: urls || [], quote_urls: quote_urls) + rescue + true + end end private def references - @references ||= @reference_parameters + scan_text! + @references ||= @reference_parameters + scan_text! + quote_status_ids end def old_references @@ -88,12 +101,24 @@ class ProcessReferencesService < BaseService target_urls = urls + @urls target_urls.map do |url| - status = ResolveURLService.new.call(url, on_behalf_of: @status.account, fetch_remote: @fetch_remote && @no_fetch_urls.exclude?(url)) - @no_fetch_urls << url if !@fetch_remote && status.present? && status.local? + status = url_to_status(url) + @no_fetch_urls << url if !@fetch_remote && status.present? status end end + def url_to_status(url) + ResolveURLService.new.call(url, on_behalf_of: @status.account, fetch_remote: @fetch_remote && @no_fetch_urls.exclude?(url)) + end + + def quote_status_ids + @quote_status_ids ||= @quote_urls.filter_map { |url| url_to_status(url) }.map(&:id) + end + + def quotable?(target_status) + target_status.account.allow_quote? && (!@status.local? || StatusPolicy.new(@status.account, target_status).quote?) + end + def add_references return if added_references.empty? @@ -101,7 +126,12 @@ class ProcessReferencesService < BaseService statuses = Status.where(id: added_references) statuses.each do |status| - @added_objects << @status.reference_objects.new(target_status: status, attribute_type: @attributes[status.id]) + attribute_type = quote_status_ids.include?(status.id) ? 'QT' : @attributes[status.id] + attribute_type = 'BT' unless quotable?(status) + quote_type = attribute_type.present? ? (attribute_type.casecmp('QT').zero? || attribute_type.casecmp('RE').zero?) : false + @status.quote_of_id = status.id if quote_type && (@status.quote_of_id.nil? || references.exclude?(@status.quote_of_id)) + @added_objects << @status.reference_objects.new(target_status: status, attribute_type: attribute_type, quote: quote_type) + status.increment_count!(:status_referred_by_count) @references_count += 1 @@ -133,6 +163,6 @@ class ProcessReferencesService < BaseService end def launch_worker - ProcessReferencesWorker.perform_async(@status.id, @reference_parameters, @urls, @no_fetch_urls) + ProcessReferencesWorker.perform_async(@status.id, @reference_parameters, @urls, @no_fetch_urls, @quote_urls) end end diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index 2f28907cb6..1194afc368 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -6,16 +6,16 @@ class ResolveURLService < BaseService USERNAME_STATUS_RE = %r{/@(?#{Account::USERNAME_RE})/(?[0-9]+)\Z} - def call(url, on_behalf_of: nil, fetch_remote: true) + def call(url, on_behalf_of: nil, fetch_remote: true, local_only: false) @url = url @on_behalf_of = on_behalf_of @fetch_remote = fetch_remote if local_url? process_local_url - elsif fetch_remote && !fetched_resource.nil? + elsif !local_only && fetch_remote && !fetched_resource.nil? process_url - else + elsif !local_only process_url_from_db end end diff --git a/app/services/un_emoji_react_service.rb b/app/services/un_emoji_react_service.rb index b473d43459..6364938a99 100644 --- a/app/services/un_emoji_react_service.rb +++ b/app/services/un_emoji_react_service.rb @@ -15,6 +15,9 @@ class UnEmojiReactService < BaseService create_notification(emoji_reaction) if !@status.account.local? && @status.account.activitypub? notify_to_followers(emoji_reaction) if @status.account.local? write_stream(emoji_reaction) + + relay_for_undo_emoji_reaction!(emoji_reaction) + relay_friend_for_undo_emoji_reaction!(emoji_reaction) else account = Account.find(account_id) bulk(account, @status) @@ -52,11 +55,27 @@ class UnEmojiReactService < BaseService end def build_json(emoji_reaction) - Oj.dump(serialize_payload(emoji_reaction, ActivityPub::UndoEmojiReactionSerializer)) + @build_json = Oj.dump(serialize_payload(emoji_reaction, ActivityPub::UndoEmojiReactionSerializer, signer: emoji_reaction.account)) end def render_emoji_reaction(emoji_group) # @rendered_emoji_reaction ||= InlineRenderer.render(emoji_group, nil, :emoji_reaction) Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json) end + + def relay_for_undo_emoji_reaction!(emoji_reaction) + return unless @status.local? && @status.public_visibility? + + ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| + [build_json(emoji_reaction), @status.account.id, inbox_url] + end + end + + def relay_friend_for_undo_emoji_reaction!(emoji_reaction) + return unless @status.local? && @status.distributable_friend? + + ActivityPub::DeliveryWorker.push_bulk(FriendDomain.distributables.pluck(:inbox_url)) do |inbox_url| + [build_json(emoji_reaction), @status.account.id, inbox_url] + end + end end diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb index 625097a16d..a0573e2d1b 100644 --- a/app/services/update_account_service.rb +++ b/app/services/update_account_service.rb @@ -38,7 +38,13 @@ class UpdateAccountService < BaseService end def check_links(account) - VerifyAccountLinksWorker.perform_async(account.id) if account.fields.any?(&:requires_verification?) + return unless account.fields.any?(&:requires_verification?) + + if account.local? + VerifyAccountLinksWorker.perform_async(account.id) + else + VerifyAccountLinksWorker.perform_in(rand(10.minutes.to_i), account.id) + end end def process_hashtags(account) diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb index 6e63fa9742..0a5de6b907 100644 --- a/app/services/update_status_service.rb +++ b/app/services/update_status_service.rb @@ -167,7 +167,13 @@ class UpdateStatusService < BaseService def update_metadata! ProcessHashtagsService.new.call(@status) - ProcessMentionsService.new.call(@status) + process_mentions_service.call(@status) + + @status.update(limited_scope: :circle) if process_mentions_service.mentions? + end + + def process_mentions_service + @process_mentions_service ||= ProcessMentionsService.new end def broadcast_updates! diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 44867d0a26..7801ef1913 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -72,7 +72,7 @@ .dashboard__counters__label= t 'admin.accounts.login_status' - if @account.local? && @account.user.nil? - = link_to t('admin.accounts.unblock_email'), unblock_email_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unblock_email, @account) && CanonicalEmailBlock.where(reference_account_id: @account.id).exists? + = link_to t('admin.accounts.unblock_email'), unblock_email_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unblock_email, @account) && CanonicalEmailBlock.exists?(reference_account_id: @account.id) - else .table-wrapper %table.table.inline-table diff --git a/app/views/admin/announcements/edit.html.haml b/app/views/admin/announcements/edit.html.haml index df1ac455fb..150d98272f 100644 --- a/app/views/admin/announcements/edit.html.haml +++ b/app/views/admin/announcements/edit.html.haml @@ -5,8 +5,8 @@ = render 'shared/error_messages', object: @announcement .fields-group - = f.input :starts_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}', placeholder: Time.now.strftime('%FT%R') } - = f.input :ends_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}', placeholder: Time.now.strftime('%FT%R') } + = f.input :starts_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: datetime_pattern, placeholder: datetime_placeholder } + = f.input :ends_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: datetime_pattern, placeholder: datetime_placeholder } .fields-group = f.input :all_day, as: :boolean, wrapper: :with_label @@ -16,7 +16,7 @@ - unless @announcement.published? .fields-group - = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}', placeholder: Time.now.strftime('%FT%R') } + = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: datetime_pattern, placeholder: datetime_placeholder } .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/announcements/new.html.haml b/app/views/admin/announcements/new.html.haml index cb39672e16..0123632ff4 100644 --- a/app/views/admin/announcements/new.html.haml +++ b/app/views/admin/announcements/new.html.haml @@ -5,8 +5,8 @@ = render 'shared/error_messages', object: @announcement .fields-group - = f.input :starts_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}', placeholder: Time.now.strftime('%FT%R') } - = f.input :ends_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}', placeholder: Time.now.strftime('%FT%R') } + = f.input :starts_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: datetime_pattern, placeholder: datetime_placeholder } + = f.input :ends_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: datetime_pattern, placeholder: datetime_placeholder } .fields-group = f.input :all_day, as: :boolean, wrapper: :with_label @@ -15,7 +15,7 @@ = f.input :text, wrapper: :with_block_label .fields-group - = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}', placeholder: Time.now.strftime('%FT%R') } + = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: datetime_pattern, placeholder: datetime_placeholder } .actions = f.button :button, t('.create'), type: :submit diff --git a/app/views/admin/domain_blocks/edit.html.haml b/app/views/admin/domain_blocks/edit.html.haml index 8a06441508..cf83d383e9 100644 --- a/app/views/admin/domain_blocks/edit.html.haml +++ b/app/views/admin/domain_blocks/edit.html.haml @@ -47,6 +47,9 @@ .fields-group = f.input :reject_new_follow, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_new_follow'), hint: I18n.t('admin.domain_blocks.reject_new_follow_hint') + .fields-group + = f.input :reject_friend, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_friend'), hint: I18n.t('admin.domain_blocks.reject_friend_hint') + .fields-group = f.input :detect_invalid_subscription, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.detect_invalid_subscription'), hint: I18n.t('admin.domain_blocks.detect_invalid_subscription_hint') diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml index 606a784e12..ed5142934f 100644 --- a/app/views/admin/domain_blocks/new.html.haml +++ b/app/views/admin/domain_blocks/new.html.haml @@ -47,6 +47,9 @@ .fields-group = f.input :reject_new_follow, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_new_follow'), hint: I18n.t('admin.domain_blocks.reject_new_follow_hint') + .fields-group + = f.input :reject_friend, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_friend'), hint: I18n.t('admin.domain_blocks.reject_friend_hint') + .fields-group = f.input :detect_invalid_subscription, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.detect_invalid_subscription'), hint: I18n.t('admin.domain_blocks.detect_invalid_subscription_hint') diff --git a/app/views/admin/friend_servers/_friend_domain.html.haml b/app/views/admin/friend_servers/_friend_domain.html.haml new file mode 100644 index 0000000000..7da4db1b89 --- /dev/null +++ b/app/views/admin/friend_servers/_friend_domain.html.haml @@ -0,0 +1,31 @@ +%tr + %td + - unless friend.available + %span.negative-hint + = fa_icon('times') + = ' ' + = t 'admin.friend_servers.disabled' + %samp= friend.domain + %td + - if friend.accepted? + %span.positive-hint + = fa_icon('check') + = ' ' + = t 'admin.friend_servers.enabled' + - elsif friend.i_am_pending? + = fa_icon('hourglass') + = ' ' + = t 'admin.friend_servers.pending' + - elsif friend.they_are_pending? + %span.warning-hint + = fa_icon('hourglass') + = ' ' + = t 'admin.friend_servers.pending_you' + - else + %span.negative-hint + = fa_icon('times') + = ' ' + = t 'admin.friend_servers.disabled' + %td + = table_link_to 'pencil', t('admin.friend_servers.edit_friend'), edit_admin_friend_server_path(friend) + = table_link_to 'times', t('admin.friend_servers.delete'), admin_friend_server_path(friend), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/admin/friend_servers/_friend_fields.html.haml b/app/views/admin/friend_servers/_friend_fields.html.haml new file mode 100644 index 0000000000..ad9b0d60ca --- /dev/null +++ b/app/views/admin/friend_servers/_friend_fields.html.haml @@ -0,0 +1,20 @@ +%p= t 'admin.friend_servers.edit.description' +%hr.spacer/ + +.fields-group + = f.input :domain, as: :string, wrapper: :with_label, required: true, disabled: !friend.id.nil?, label: t('admin.friend_servers.edit.domain') + +.fields-group + = f.input :inbox_url, as: :string, wrapper: :with_label, label: t('admin.friend_servers.edit.inbox_url'), hint: t('admin.friend_servers.edit.inbox_url_hint') + +.fields-group + = f.input :available, as: :boolean, wrapper: :with_label, label: t('admin.friend_servers.edit.available') + +.fields-group + = f.input :delivery_local, as: :boolean, wrapper: :with_label, label: t('admin.friend_servers.edit.delivery_local'), hint: t('admin.friend_servers.edit.delivery_local_hint') + +.fields-group + = f.input :pseudo_relay, as: :boolean, wrapper: :with_label, label: t('admin.friend_servers.edit.pseudo_relay'), hint: t('admin.friend_servers.edit.pseudo_relay_hint') + +.fields-group + = f.input :allow_all_posts, as: :boolean, wrapper: :with_label, label: t('admin.friend_servers.edit.allow_all_posts'), hint: t('admin.friend_servers.edit.allow_all_posts_hint') diff --git a/app/views/admin/friend_servers/edit.html.haml b/app/views/admin/friend_servers/edit.html.haml new file mode 100644 index 0000000000..06cff11bfb --- /dev/null +++ b/app/views/admin/friend_servers/edit.html.haml @@ -0,0 +1,32 @@ +- content_for :page_title do + = t('admin.friend_servers.edit_friend') + += simple_form_for @friend, url: admin_friend_server_path(@friend), method: :put do |f| + = render 'shared/error_messages', object: @friend + = render 'friend_fields', f: f, friend: @friend + + .fields-group + %h4= t('admin.friend_servers.status') + .fields-group + - if @friend.accepted? + %span.positive-hint + = fa_icon('check') + = ' ' + = t 'admin.friend_servers.enabled' + - elsif @friend.pending? + = fa_icon('hourglass') + = ' ' + = t 'admin.friend_servers.pending' + - else + %span.negative-hint + = fa_icon('times') + = ' ' + = t 'admin.friend_servers.disabled' + .action-buttons + %div + = link_to t('admin.friend_servers.follow'), follow_admin_friend_server_path(@friend), class: 'button', method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if @friend.idle? + = link_to t('admin.friend_servers.accept'), accept_admin_friend_server_path(@friend), class: 'button', method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if @friend.they_are_pending? + = link_to t('admin.friend_servers.reject'), reject_admin_friend_server_path(@friend), class: 'button', method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if @friend.they_are_pending? + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/friend_servers/index.html.haml b/app/views/admin/friend_servers/index.html.haml new file mode 100644 index 0000000000..196407c4c4 --- /dev/null +++ b/app/views/admin/friend_servers/index.html.haml @@ -0,0 +1,20 @@ +- content_for :page_title do + = t('admin.friend_servers.title') + +.simple_form + %p.hint= t('admin.friend_servers.description_html') + = link_to @friends.empty? ? t('admin.friend_servers.setup') : t('admin.friend_servers.add_new'), new_admin_friend_server_path, class: 'block-button' + +- unless @friends.empty? + %hr.spacer + + .table-wrapper + %table.table + %thead + %tr + %th= t('admin.friend_servers.domain') + %th= t('admin.friend_servers.status') + %th + %tbody + - @friends.each do |friend| + = render 'friend_domain', friend: friend diff --git a/app/views/admin/friend_servers/new.html.haml b/app/views/admin/friend_servers/new.html.haml new file mode 100644 index 0000000000..1cb5b06b6f --- /dev/null +++ b/app/views/admin/friend_servers/new.html.haml @@ -0,0 +1,9 @@ +- content_for :page_title do + = t('admin.friend_servers.add_new') + += simple_form_for @friend, url: admin_friend_servers_path do |f| + = render 'shared/error_messages', object: @friend + = render 'friend_fields', f: f, friend: @friend + + .actions + = f.button :button, t('admin.friend_servers.save_and_enable'), type: :submit diff --git a/app/views/admin/invites/index.html.haml b/app/views/admin/invites/index.html.haml index ee6ba0f574..964deaba8f 100644 --- a/app/views/admin/invites/index.html.haml +++ b/app/views/admin/invites/index.html.haml @@ -14,7 +14,8 @@ - if policy(:invite).create? %p= t('invites.prompt') - = render 'invites/form' + = simple_form_for(@invite, url: admin_invites_path) do |form| + = render partial: 'invites/form', object: form %hr.spacer/ diff --git a/app/views/admin/reports/_actions.html.haml b/app/views/admin/reports/_actions.html.haml index 0fe558dafe..daa554566e 100644 --- a/app/views/admin/reports/_actions.html.haml +++ b/app/views/admin/reports/_actions.html.haml @@ -1,11 +1,11 @@ -= form_tag preview_admin_report_actions_path(@report), method: :post do += form_tag preview_admin_report_actions_path(report), method: :post do .report-actions .report-actions__item .report-actions__item__button - = link_to t('admin.reports.mark_as_resolved'), resolve_admin_report_path(@report), method: :post, class: 'button' + = link_to t('admin.reports.mark_as_resolved'), resolve_admin_report_path(report), method: :post, class: 'button' .report-actions__item__description = t('admin.reports.actions.resolve_description_html') - - if @statuses.any? { |status| (status.with_media? || status.with_preview_card?) && !status.discarded? } + - if statuses.any? { |status| (status.with_media? || status.with_preview_card?) && !status.discarded? } .report-actions__item .report-actions__item__button = button_tag t('admin.reports.mark_as_sensitive'), name: :mark_as_sensitive, class: 'button' @@ -33,6 +33,6 @@ = t('admin.reports.actions.suspend_description_html') .report-actions__item .report-actions__item__button - = link_to t('admin.accounts.custom'), new_admin_account_action_path(@report.target_account_id, report_id: @report.id), class: 'button' + = link_to t('admin.accounts.custom'), new_admin_account_action_path(report.target_account_id, report_id: report.id), class: 'button' .report-actions__item__description = t('admin.reports.actions.other_description_html') diff --git a/app/views/admin/reports/actions/preview.html.haml b/app/views/admin/reports/actions/preview.html.haml index eb67eebe0d..8634bb215c 100644 --- a/app/views/admin/reports/actions/preview.html.haml +++ b/app/views/admin/reports/actions/preview.html.haml @@ -61,7 +61,7 @@ = fa_icon 'link' = media_attachment.file_file_name .strike-card__statuses-list__item__meta - = link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank' do + = link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank', rel: 'noopener noreferrer' do %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) - unless status.application.nil? · diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml index 2508bc2b5b..41ce73cfcf 100644 --- a/app/views/admin/reports/show.html.haml +++ b/app/views/admin/reports/show.html.haml @@ -179,7 +179,7 @@ %p#actions= t(@report.target_account.local? ? 'admin.reports.actions_description_html' : 'admin.reports.actions_description_remote_html') - = render partial: 'admin/reports/actions' + = render partial: 'admin/reports/actions', locals: { report: @report, statuses: @statuses } - unless @action_logs.empty? %hr.spacer/ diff --git a/app/views/admin/roles/_form.html.haml b/app/views/admin/roles/_form.html.haml index 3cbec0d0b5..2400332145 100644 --- a/app/views/admin/roles/_form.html.haml +++ b/app/views/admin/roles/_form.html.haml @@ -1,40 +1,36 @@ -= simple_form_for @role, url: @role.new_record? ? admin_roles_path : admin_role_path(@role) do |f| - = render 'shared/error_messages', object: @role += render 'shared/error_messages', object: form.object - - if @role.everyone? - .flash-message.info - = t('admin.roles.everyone_full_description_html') - - else +- if form.object.everyone? + .flash-message.info + = t('admin.roles.everyone_full_description_html') +- else + .fields-group + = form.input :name, wrapper: :with_label + + - unless current_user.role == form.object .fields-group - = f.input :name, wrapper: :with_label + = form.input :position, wrapper: :with_label, input_html: { max: current_user.role.position - 1 } - - unless current_user.role.id == @role.id - .fields-group - = f.input :position, wrapper: :with_label, input_html: { max: current_user.role.position - 1 } + .fields-group + = form.input :color, wrapper: :with_label, input_html: { placeholder: '#000000', type: 'color' } - .fields-group - = f.input :color, wrapper: :with_label, input_html: { placeholder: '#000000', type: 'color' } + %hr.spacer/ - %hr.spacer/ + .fields-group + = form.input :highlighted, wrapper: :with_label - .fields-group - = f.input :highlighted, wrapper: :with_label + %hr.spacer/ - %hr.spacer/ +- unless current_user.role == form.object - - unless current_user.role.id == @role.id + .field-group + .input.with_block_label + %label= t('simple_form.labels.user_role.permissions_as_keys') + %span.hint= t('simple_form.hints.user_role.permissions_as_keys') - .field-group - .input.with_block_label - %label= t('simple_form.labels.user_role.permissions_as_keys') - %span.hint= t('simple_form.hints.user_role.permissions_as_keys') + - (form.object.everyone? ? UserRole::Flags::CATEGORIES.slice(:invites) : UserRole::Flags::CATEGORIES).each do |category, permissions| + %h4= t(category, scope: 'admin.roles.categories') - - (@role.everyone? ? UserRole::Flags::CATEGORIES.slice(:invites) : UserRole::Flags::CATEGORIES).each do |category, permissions| - %h4= t(category, scope: 'admin.roles.categories') + = form.input :permissions_as_keys, collection: permissions, wrapper: :with_block_label, include_blank: false, label_method: ->(privilege) { safe_join([t("admin.roles.privileges.#{privilege}"), content_tag(:span, t("admin.roles.privileges.#{privilege}_description"), class: 'hint')]) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label: false, hint: false, disabled: permissions.filter { |privilege| UserRole::FLAGS[privilege] & current_user.role.computed_permissions == 0 } - = f.input :permissions_as_keys, collection: permissions, wrapper: :with_block_label, include_blank: false, label_method: ->(privilege) { safe_join([t("admin.roles.privileges.#{privilege}"), content_tag(:span, t("admin.roles.privileges.#{privilege}_description"), class: 'hint')]) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label: false, hint: false, disabled: permissions.filter { |privilege| UserRole::FLAGS[privilege] & current_user.role.computed_permissions == 0 } - - %hr.spacer/ - - .actions - = f.button :button, @role.new_record? ? t('admin.roles.add_new') : t('generic.save_changes'), type: :submit + %hr.spacer/ diff --git a/app/views/admin/roles/edit.html.haml b/app/views/admin/roles/edit.html.haml index 5688b69b1f..ec3f5b6fbe 100644 --- a/app/views/admin/roles/edit.html.haml +++ b/app/views/admin/roles/edit.html.haml @@ -4,4 +4,7 @@ - content_for :heading_actions do = link_to t('admin.roles.delete'), admin_role_path(@role), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:destroy, @role) -= render partial: 'form' += simple_form_for @role, url: admin_role_path(@role) do |form| + = render partial: 'form', object: form + .actions + = form.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/roles/new.html.haml b/app/views/admin/roles/new.html.haml index 8210792718..6ca0c2137b 100644 --- a/app/views/admin/roles/new.html.haml +++ b/app/views/admin/roles/new.html.haml @@ -1,4 +1,7 @@ - content_for :page_title do = t('admin.roles.add_new') -= render partial: 'form' += simple_form_for @role, url: admin_roles_path do |form| + = render partial: 'form', object: form + .actions + = form.button :button, t('admin.roles.add_new'), type: :submit diff --git a/app/views/admin/settings/discovery/show.html.haml b/app/views/admin/settings/discovery/show.html.haml index 6ea9e4fb4b..bedafdb499 100644 --- a/app/views/admin/settings/discovery/show.html.haml +++ b/app/views/admin/settings/discovery/show.html.haml @@ -40,6 +40,16 @@ .fields-group = f.input :streaming_other_servers_emoji_reaction, as: :boolean, wrapper: :with_label, kmyblue: true + %h4= t('admin.settings.discovery.visibilities') + + .fields-group + = f.input :enable_public_unlisted_visibility, as: :boolean, wrapper: :with_label, kmyblue: true, hint: false + + %h4= t('admin.settings.discovery.friend_servers') + + .fields-group + = f.input :unlocked_friend, as: :boolean, wrapper: :with_label, kmyblue: true, hint: false + %h4= t('admin.settings.discovery.publish_statistics') .fields-group diff --git a/app/views/admin/statuses/show.html.haml b/app/views/admin/statuses/show.html.haml index ceab7dee3e..317ccde078 100644 --- a/app/views/admin/statuses/show.html.haml +++ b/app/views/admin/statuses/show.html.haml @@ -2,7 +2,7 @@ = t('statuses.title', name: display_name(@account), quote: truncate(@status.spoiler_text.presence || @status.text, length: 50, omission: '…', escape: false)) - content_for :heading_actions do - = link_to t('admin.statuses.open'), ActivityPub::TagManager.instance.url_for(@status), class: 'button', target: '_blank' + = link_to t('admin.statuses.open'), ActivityPub::TagManager.instance.url_for(@status), class: 'button', target: '_blank', rel: 'noopener noreferrer' %h3= t('admin.statuses.metadata') diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml index 71bce0c0cb..0878887cea 100644 --- a/app/views/admin/tags/show.html.haml +++ b/app/views/admin/tags/show.html.haml @@ -9,7 +9,7 @@ .dashboard .dashboard__item - = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure'), href: tag_url(@tag), target: '_blank' + = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure'), href: tag_url(@tag), target: '_blank', rel: 'noopener noreferrer' .dashboard__item = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure') .dashboard__item diff --git a/app/views/admin/webhooks/_form.html.haml b/app/views/admin/webhooks/_form.html.haml index c870e943f4..6c4574fd3b 100644 --- a/app/views/admin/webhooks/_form.html.haml +++ b/app/views/admin/webhooks/_form.html.haml @@ -1,14 +1,10 @@ -= simple_form_for @webhook, url: @webhook.new_record? ? admin_webhooks_path : admin_webhook_path(@webhook) do |f| - = render 'shared/error_messages', object: @webhook += render 'shared/error_messages', object: form.object - .fields-group - = f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' } +.fields-group + = form.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' } - .fields-group - = f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', disabled: Webhook::EVENTS.filter { |event| !current_user.role.can?(Webhook.permission_for_event(event)) } +.fields-group + = form.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', disabled: Webhook::EVENTS.filter { |event| !current_user.role.can?(Webhook.permission_for_event(event)) } - .fields-group - = f.input :template, wrapper: :with_block_label, input_html: { placeholder: '{ "content": "Hello {{object.username}}" }' } - - .actions - = f.button :button, @webhook.new_record? ? t('admin.webhooks.add_new') : t('generic.save_changes'), type: :submit +.fields-group + = form.input :template, wrapper: :with_block_label, input_html: { placeholder: '{ "content": "Hello {{object.username}}" }' } diff --git a/app/views/admin/webhooks/edit.html.haml b/app/views/admin/webhooks/edit.html.haml index 3dc0ace9bf..2c2a7aa034 100644 --- a/app/views/admin/webhooks/edit.html.haml +++ b/app/views/admin/webhooks/edit.html.haml @@ -1,4 +1,7 @@ - content_for :page_title do = t('admin.webhooks.edit') -= render partial: 'form' += simple_form_for @webhook, url: admin_webhook_path(@webhook) do |form| + = render partial: 'form', object: form + .actions + = form.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/webhooks/new.html.haml b/app/views/admin/webhooks/new.html.haml index 1258df74ab..f51b039ce8 100644 --- a/app/views/admin/webhooks/new.html.haml +++ b/app/views/admin/webhooks/new.html.haml @@ -1,4 +1,7 @@ - content_for :page_title do = t('admin.webhooks.new') -= render partial: 'form' += simple_form_for @webhook, url: admin_webhooks_path do |form| + = render partial: 'form', object: form + .actions + = form.button :button, t('admin.webhooks.add_new'), type: :submit diff --git a/app/views/admin_mailer/new_pending_friend_server.text.erb b/app/views/admin_mailer/new_pending_friend_server.text.erb new file mode 100644 index 0000000000..89c9ec1b09 --- /dev/null +++ b/app/views/admin_mailer/new_pending_friend_server.text.erb @@ -0,0 +1,5 @@ +<%= raw t('application_mailer.salutation', name: display_name(@me)) %> + +<%= raw t('admin_mailer.new_pending_friend_server.body', domain: @friend.domain) %> + +<%= raw t('application_mailer.view')%> <%= admin_friend_servers_url %> diff --git a/app/views/auth/registrations/_status.html.haml b/app/views/auth/registrations/_status.html.haml index 759bbc41c0..8f44eee015 100644 --- a/app/views/auth/registrations/_status.html.haml +++ b/app/views/auth/registrations/_status.html.haml @@ -1,30 +1,30 @@ -- if !@user.confirmed? +- if !user.confirmed? .flash-message.warning = t('auth.status.confirming') = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path -- elsif !@user.approved? +- elsif !user.approved? .flash-message.warning = t('auth.status.pending') -- elsif @user.account.moved_to_account_id.present? +- elsif user.account.moved_to_account_id.present? .flash-message.warning - = t('auth.status.redirecting_to', acct: @user.account.moved_to_account.pretty_acct) + = t('auth.status.redirecting_to', acct: user.account.moved_to_account.pretty_acct) = link_to t('migrations.cancel'), settings_migration_path %h3= t('auth.status.account_status') %p.hint - - if @user.account.suspended? + - if user.account.suspended? %span.negative-hint= t('user_mailer.warning.explanation.suspend') - - elsif @user.disabled? + - elsif user.disabled? %span.negative-hint= t('user_mailer.warning.explanation.disable') - - elsif @user.account.silenced? + - elsif user.account.silenced? %span.warning-hint= t('user_mailer.warning.explanation.silence') - else %span.positive-hint= t('auth.status.functional') -= render partial: 'account_warning', collection: @strikes += render partial: 'account_warning', collection: strikes -- if @user.account.strikes.exists? +- if user.account.strikes.exists? %hr.spacer/ %p.muted-hint diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml index 3e9b0cb6bd..908119a21a 100644 --- a/app/views/auth/registrations/edit.html.haml +++ b/app/views/auth/registrations/edit.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('settings.account_settings') -= render 'status' += render partial: 'status', locals: { user: @user, strikes: @strikes } %h3= t('auth.security') diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index 8724b94804..f9e6d4ef2d 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -26,7 +26,7 @@ = f.input :confirm_password, as: :string, placeholder: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label': t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), autocomplete: 'off' }, hint: false = f.input :website, as: :url, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label': t('simple_form.labels.defaults.honeypot', label: 'Website'), autocomplete: 'off' } - - if approved_registrations? && !@invite.present? + - if approved_registrations? && @invite.blank? %p.lead= t('auth.sign_up.manual_review', domain: site_hostname) .fields-group diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml index 20232d8dc2..653f155801 100644 --- a/app/views/auth/sessions/two_factor.html.haml +++ b/app/views/auth/sessions/two_factor.html.haml @@ -3,7 +3,7 @@ = javascript_pack_tag 'two_factor_authentication', crossorigin: 'anonymous' -- if @webauthn_enabled +- if webauthn_enabled? = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' } = render partial: 'auth/sessions/two_factor/otp_authentication_form', locals: { hidden: @scheme_type != 'totp' } diff --git a/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml index 094b502b17..8cc2c85610 100644 --- a/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml +++ b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml @@ -13,6 +13,6 @@ - if Setting.site_contact_email.present? %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil)) - - if @webauthn_enabled + - if webauthn_enabled? .form-footer = link_to(t('auth.link_to_webauth'), '#', id: 'link-to-webauthn') diff --git a/app/views/disputes/strikes/show.html.haml b/app/views/disputes/strikes/show.html.haml index ce52e470d9..de883bd873 100644 --- a/app/views/disputes/strikes/show.html.haml +++ b/app/views/disputes/strikes/show.html.haml @@ -25,7 +25,7 @@ - unless @strike.none_action? %p= t "user_mailer.warning.explanation.#{@strike.action}", instance: Rails.configuration.x.local_domain - - unless @strike.text.blank? + - if @strike.text.present? = linkify(@strike.text) - if @strike.report && !@strike.report.other? @@ -57,7 +57,7 @@ = fa_icon 'link' = media_attachment.file_file_name .strike-card__statuses-list__item__meta - = link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank' do + = link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank', rel: 'noopener noreferrer' do %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) - unless status.application.nil? · diff --git a/app/views/filters/_filter_fields.html.haml b/app/views/filters/_filter_fields.html.haml index ac97ccb87c..74f969cf34 100644 --- a/app/views/filters/_filter_fields.html.haml +++ b/app/views/filters/_filter_fields.html.haml @@ -10,12 +10,15 @@ %hr.spacer/ .fields-group - = f.input :filter_action, as: :radio_buttons, collection: %i(warn hide), include_blank: false, wrapper: :with_block_label, label_method: ->(action) { safe_join([t("simple_form.labels.filters.actions.#{action}"), content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint')]) }, hint: t('simple_form.hints.filters.action'), required: true + = f.input :filter_action, as: :radio_buttons, collection: %i(half_warn warn hide), include_blank: false, wrapper: :with_block_label, label_method: ->(action) { safe_join([t("simple_form.labels.filters.actions.#{action}"), content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint')]) }, hint: t('simple_form.hints.filters.action'), required: true .fields-group = f.input :exclude_follows, wrapper: :with_label, kmyblue: true, label: t('simple_form.labels.filters.options.exclude_follows') = f.input :exclude_localusers, wrapper: :with_label, kmyblue: true, label: t('simple_form.labels.filters.options.exclude_localusers') +.fields-group + = f.input :with_quote, wrapper: :with_label, kmyblue: true, label: t('simple_form.labels.filters.options.with_quote') + %hr.spacer/ - unless f.object.statuses.empty? diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml index 7ea521ebc7..dbbb785e83 100644 --- a/app/views/invites/_form.html.haml +++ b/app/views/invites/_form.html.haml @@ -1,14 +1,13 @@ -= simple_form_for(@invite, url: controller.is_a?(Admin::InvitesController) ? admin_invites_path : invites_path) do |f| - = render 'shared/error_messages', object: @invite += render 'shared/error_messages', object: form.object - .fields-row - .fields-row__column.fields-row__column-6.fields-group - = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: ->(num) { I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt') - .fields-row__column.fields-row__column-6.fields-group - = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: ->(i) { I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = form.input :max_uses, wrapper: :with_label, collection: invites_max_uses_options, label_method: ->(num) { I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt') + .fields-row__column.fields-row__column-6.fields-group + = form.input :expires_in, wrapper: :with_label, collection: invites_expires_options.map(&:to_i), label_method: ->(i) { I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') - .fields-group - = f.input :autofollow, wrapper: :with_label +.fields-group + = form.input :autofollow, wrapper: :with_label - .actions - = f.button :button, t('invites.generate'), type: :submit +.actions + = form.button :button, t('invites.generate'), type: :submit diff --git a/app/views/invites/index.html.haml b/app/views/invites/index.html.haml index 61420ab1e4..88ed662af8 100644 --- a/app/views/invites/index.html.haml +++ b/app/views/invites/index.html.haml @@ -4,7 +4,8 @@ - if policy(:invite).create? %p= t('invites.prompt') - = render 'form' + = simple_form_for(@invite, url: invites_path) do |form| + = render partial: 'form', object: form %hr.spacer/ diff --git a/app/views/relationships/_account.html.haml b/app/views/relationships/_account.html.haml index 0fa3cffb55..43a3d64bc8 100644 --- a/app/views/relationships/_account.html.haml +++ b/app/views/relationships/_account.html.haml @@ -6,7 +6,7 @@ %tbody %tr %td.accounts-table__interrelationships - = interrelationships_icon(@relationships, account.id) + = interrelationships_icon(relationships, account.id) %td= account_link_to account %td.accounts-table__count.optional = friendly_number_to_human account.statuses_count diff --git a/app/views/relationships/show.html.haml b/app/views/relationships/show.html.haml index f08e9c1df8..97ba49eb52 100644 --- a/app/views/relationships/show.html.haml +++ b/app/views/relationships/show.html.haml @@ -53,6 +53,6 @@ - if @accounts.empty? = nothing_here 'nothing-here--under-tabs' - else - = render partial: 'account', collection: @accounts, locals: { f: f } + = render partial: 'account', collection: @accounts, locals: { f: f, relationships: @relationships } = paginate @accounts diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index fd41160c13..dccb66ff1d 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -7,7 +7,7 @@ = simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put, id: 'edit_user' } do |f| .fields-row .fields-group.fields-row__column.fields-row__column-6 - = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: ->(locale) { native_locale_name(locale) }, selected: I18n.locale, hint: false + = f.input :locale, collection: ui_languages, wrapper: :with_label, include_blank: false, label_method: ->(locale) { native_locale_name(locale) }, selected: I18n.locale, hint: false .fields-group.fields-row__column.fields-row__column-6 = f.input :time_zone, wrapper: :with_label, collection: ActiveSupport::TimeZone.all.map { |tz| ["(GMT#{tz.formatted_offset}) #{tz.name}", tz.tzinfo.name] }, hint: false @@ -46,6 +46,11 @@ .fields-group = ff.input :'web.bookmark_category_needed', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_bookmark_category_needed'), hint: I18n.t('simple_form.hints.defaults.setting_bookmark_category_needed') + .fields-group + = ff.input :'web.show_quote_in_home', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_show_quote_in_home'), hint: false + = ff.input :'web.show_quote_in_public', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_show_quote_in_public'), hint: false + = ff.input :'web.hide_blocking_quote', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_hide_blocking_quote'), hint: false + .fields-group = ff.input :'web.simple_timeline_menu', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_simple_timeline_menu') diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml index 06af9c1360..0f29221ede 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -22,13 +22,14 @@ .fields-group = ff.input :always_send_emails, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_always_send_emails'), hint: I18n.t('simple_form.hints.defaults.setting_always_send_emails') - - if current_user.can?(:manage_reports, :manage_appeals, :manage_users, :manage_taxonomies) || (SoftwareUpdate.check_enabled? && current_user.can?(:view_devops)) + - if current_user.can?(:manage_reports, :manage_appeals, :manage_users, :manage_taxonomies, :manage_federation) || (SoftwareUpdate.check_enabled? && current_user.can?(:view_devops)) %h4= t 'notifications.administration_emails' .fields-group = ff.input :'notification_emails.report', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.report') if current_user.can?(:manage_reports) = ff.input :'notification_emails.appeal', as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.appeal') if current_user.can?(:manage_appeals) = ff.input :'notification_emails.pending_account', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.pending_account') if current_user.can?(:manage_users) + = ff.input :'notification_emails.pending_friend_server', as: :boolean, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.notification_emails.pending_friend_server') if current_user.can?(:manage_federation) = ff.input :'notification_emails.trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_tag') if current_user.can?(:manage_taxonomies) - if SoftwareUpdate.check_enabled? && current_user.can?(:view_devops) diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml index c596013ef6..353715df01 100644 --- a/app/views/settings/preferences/other/show.html.haml +++ b/app/views/settings/preferences/other/show.html.haml @@ -14,9 +14,6 @@ .fields-group = ff.input :lock_follow_from_bot, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_lock_follow_from_bot') - .fields-group - = ff.input :single_ref_to_quote, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_single_ref_to_quote'), hint: I18n.t('simple_form.hints.defaults.setting_single_ref_to_quote') - %h4= t 'preferences.posting_defaults' .fields-row diff --git a/app/views/settings/preferences/reaching/show.html.haml b/app/views/settings/preferences/reaching/show.html.haml index cfeaeff68e..3e330ad590 100644 --- a/app/views/settings/preferences/reaching/show.html.haml +++ b/app/views/settings/preferences/reaching/show.html.haml @@ -21,8 +21,9 @@ .fields-group = ff.input :stay_privacy, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_stay_privacy') - .fields-group - = ff.input :public_post_to_unlisted, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_public_post_to_unlisted'), hint: I18n.t('simple_form.hints.defaults.setting_public_post_to_unlisted') + - if Setting.enable_public_unlisted_visibility + .fields-group + = ff.input :public_post_to_unlisted, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_public_post_to_unlisted'), hint: I18n.t('simple_form.hints.defaults.setting_public_post_to_unlisted') .fields-group = ff.input :'web.enable_login_privacy', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_enable_login_privacy'), hint: false @@ -40,7 +41,7 @@ .fields-row .fields-group.fields-row__column.fields-row__column-12 - = ff.input :default_searchability_of_search, collection: Status.selectable_searchabilities, wrapper: :with_label, kmyblue: true, include_blank: false, label_method: lambda { |searchability| safe_join([I18n.t("statuses.searchabilities.#{searchability}"), I18n.t("statuses.searchabilities.#{searchability}_search_long")], ' - ') }, required: false, hint: false, label: I18n.t('simple_form.labels.defaults.setting_default_searchability_of_search') + = ff.input :default_searchability_of_search, collection: Status.selectable_searchabilities_for_search, wrapper: :with_label, kmyblue: true, include_blank: false, label_method: lambda { |searchability| safe_join([I18n.t("statuses.searchabilities.#{searchability}"), I18n.t("statuses.searchabilities.#{searchability}_search_long")], ' - ') }, required: false, hint: false, label: I18n.t('simple_form.labels.defaults.setting_default_searchability_of_search') .fields-group = ff.input :use_public_index, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_use_public_index') diff --git a/app/views/settings/privacy_extra/show.html.haml b/app/views/settings/privacy_extra/show.html.haml index dd07719bcb..5e102f6565 100644 --- a/app/views/settings/privacy_extra/show.html.haml +++ b/app/views/settings/privacy_extra/show.html.haml @@ -32,8 +32,9 @@ %p.lead= t('privacy_extra.stop_deliver_hint_html') = f.simple_fields_for :settings, current_user.settings do |ff| - .fields-group - = ff.input :reject_public_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_public_unlisted_subscription') + - if Setting.enable_public_unlisted_visibility + .fields-group + = ff.input :reject_public_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_public_unlisted_subscription') .fields-group = ff.input :reject_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_unlisted_subscription'), hint: I18n.t('simple_form.hints.defaults.setting_reject_unlisted_subscription') diff --git a/app/views/shared/_og.html.haml b/app/views/shared/_og.html.haml index a5d99ae33a..385351ee14 100644 --- a/app/views/shared/_og.html.haml +++ b/app/views/shared/_og.html.haml @@ -1,12 +1,12 @@ -- thumbnail = @instance_presenter.thumbnail -- description ||= @instance_presenter.description.presence || strip_tags(t('about.about_mastodon_html')) +- thumbnail = instance_presenter.thumbnail +- description ||= instance_presenter.description.presence || strip_tags(t('about.about_mastodon_html')) %meta{ name: 'description', content: description }/ = opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname) = opengraph 'og:url', url_for(only_path: false) = opengraph 'og:type', 'website' -= opengraph 'og:title', @instance_presenter.title += opengraph 'og:title', instance_presenter.title = opengraph 'og:description', description = opengraph 'og:image', full_asset_url(thumbnail&.file&.url(:'@1x') || asset_pack_path('media/images/preview.png', protocol: :request)) = opengraph 'og:image:width', thumbnail ? thumbnail.meta['width'] : '1200' diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index 5e564d5f3f..eb86326c68 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -87,4 +87,4 @@ - if user_signed_in? · - = link_to t('statuses.open_in_web'), web_url("@#{status.account.pretty_acct}/#{status.id}"), class: 'detailed-status__application', target: '_blank' + = link_to t('statuses.open_in_web'), web_url("@#{status.account.pretty_acct}/#{status.id}"), class: 'detailed-status__application', target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml index 8a878bead6..5d64e83247 100644 --- a/app/views/user_mailer/warning.html.haml +++ b/app/views/user_mailer/warning.html.haml @@ -39,7 +39,7 @@ - unless @warning.none_action? %p= t "user_mailer.warning.explanation.#{@warning.action}", instance: @instance - - unless @warning.text.blank? + - if @warning.text.present? = linkify(@warning.text) - if @warning.report && !@warning.report.other? @@ -68,7 +68,7 @@ %table.content-section{ cellspacing: 0, cellpadding: 0 } %tbody %tr - %td.content-cell{ class: @statuses.nil? || @statuses.empty? ? '' : 'content-start' } + %td.content-cell{ class: @statuses.blank? ? '' : 'content-start' } %table.column{ cellspacing: 0, cellpadding: 0 } %tbody %tr diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index 34b6f6e32f..12cb66aeb4 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -22,6 +22,10 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker @inboxes_for_misskey ||= status_reach_finder.inboxes_for_misskey end + def inboxes_for_friend + @inboxes_for_friend ||= status_reach_finder.inboxes_for_friend + end + def status_reach_finder @status_reach_finder ||= StatusReachFinder.new(@status) end @@ -34,6 +38,10 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker @payload_for_misskey ||= Oj.dump(serialize_payload(activity_for_misskey, ActivityPub::ActivityForMisskeySerializer, signer: @account)) end + def payload_for_friend + @payload_for_friend ||= Oj.dump(serialize_payload(activity_for_friend, ActivityPub::ActivityForFriendSerializer, signer: @account)) + end + def activity ActivityPub::ActivityPresenter.from_status(@status) end @@ -42,6 +50,10 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker ActivityPub::ActivityPresenter.from_status(@status, for_misskey: true) end + def activity_for_friend + ActivityPub::ActivityPresenter.from_status(@status, for_friend: true) + end + def options { 'synchronize_followers' => @status.private_visibility? } end diff --git a/app/workers/activitypub/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb index 611b5210d8..a1fc778559 100644 --- a/app/workers/activitypub/raw_distribution_worker.rb +++ b/app/workers/activitypub/raw_distribution_worker.rb @@ -29,6 +29,12 @@ class ActivityPub::RawDistributionWorker end end + unless inboxes_for_friend.empty? + ActivityPub::DeliveryWorker.push_bulk(inboxes_for_friend, limit: 1_000) do |inbox_url| + [payload_for_friend, source_account_id, inbox_url, options] + end + end + return if inboxes.empty? ActivityPub::DeliveryWorker.push_bulk(inboxes, limit: 1_000) do |inbox_url| @@ -44,6 +50,10 @@ class ActivityPub::RawDistributionWorker payload end + def payload_for_friend + payload + end + def source_account_id @account.id end @@ -56,6 +66,10 @@ class ActivityPub::RawDistributionWorker [] end + def inboxes_for_friend + [] + end + def options {} end diff --git a/app/workers/process_references_worker.rb b/app/workers/process_references_worker.rb index f082744857..26dfbae465 100644 --- a/app/workers/process_references_worker.rb +++ b/app/workers/process_references_worker.rb @@ -5,8 +5,8 @@ class ProcessReferencesWorker sidekiq_options queue: 'pull', retry: 3 - def perform(status_id, ids, urls, no_fetch_urls = nil) - ProcessReferencesService.new.call(Status.find(status_id), ids || [], urls: urls || [], no_fetch_urls: no_fetch_urls) + def perform(status_id, ids, urls, no_fetch_urls = nil, quote_urls = nil) + ProcessReferencesService.new.call(Status.find(status_id), ids || [], urls: urls || [], no_fetch_urls: no_fetch_urls, quote_urls: quote_urls || []) rescue ActiveRecord::RecordNotFound true end diff --git a/config/application.rb b/config/application.rb index 2a62c37e8b..d98e332339 100644 --- a/config/application.rb +++ b/config/application.rb @@ -5,15 +5,15 @@ require_relative 'boot' require 'rails' require 'active_record/railtie' -#require 'active_storage/engine' +# require 'active_storage/engine' require 'action_controller/railtie' require 'action_view/railtie' require 'action_mailer/railtie' require 'active_job/railtie' -#require 'action_cable/engine' -#require 'action_mailbox/engine' -#require 'action_text/engine' -#require 'rails/test_unit/railtie' +# require 'action_cable/engine' +# require 'action_mailbox/engine' +# require 'action_text/engine' +# require 'rails/test_unit/railtie' require 'sprockets/railtie' # Used to be implicitly required in action_mailbox/engine diff --git a/config/environments/production.rb b/config/environments/production.rb index 4d80a66af6..7da9d62410 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -44,7 +44,7 @@ Rails.application.configure do config.force_ssl = true config.ssl_options = { redirect: { - exclude: -> request { request.path.start_with?('/health') || request.headers["Host"].end_with?('.onion') || request.headers["Host"].end_with?('.i2p') } + exclude: ->request { request.path.start_with?('/health') || request.headers["Host"].end_with?('.onion') || request.headers["Host"].end_with?('.i2p') } } } @@ -148,11 +148,11 @@ Rails.application.configure do config.action_mailer.delivery_method = ENV.fetch('SMTP_DELIVERY_METHOD', 'smtp').to_sym config.action_dispatch.default_headers = { - 'Server' => 'Mastodon', - 'X-Frame-Options' => 'DENY', + 'Server' => 'Mastodon', + 'X-Frame-Options' => 'DENY', 'X-Content-Type-Options' => 'nosniff', - 'X-XSS-Protection' => '0', - 'Referrer-Policy' => 'same-origin', + 'X-XSS-Protection' => '0', + 'Referrer-Policy' => 'same-origin', } config.x.otp_secret = ENV.fetch('OTP_SECRET') diff --git a/config/initializers/3_omniauth.rb b/config/initializers/3_omniauth.rb index 7520f09e5e..566e7362a5 100644 --- a/config/initializers/3_omniauth.rb +++ b/config/initializers/3_omniauth.rb @@ -76,35 +76,35 @@ Devise.setup do |config| # OpenID Connect Strategy if ENV['OIDC_ENABLED'] == 'true' oidc_options = {} - oidc_options[:display_name] = ENV['OIDC_DISPLAY_NAME'] #OPTIONAL - oidc_options[:issuer] = ENV['OIDC_ISSUER'] if ENV['OIDC_ISSUER'] #NEED - oidc_options[:discovery] = ENV['OIDC_DISCOVERY'] == 'true' if ENV['OIDC_DISCOVERY'] #OPTIONAL (default: false) - oidc_options[:client_auth_method] = ENV['OIDC_CLIENT_AUTH_METHOD'] if ENV['OIDC_CLIENT_AUTH_METHOD'] #OPTIONAL (default: basic) - scope_string = ENV['OIDC_SCOPE'] if ENV['OIDC_SCOPE'] #NEED + oidc_options[:display_name] = ENV['OIDC_DISPLAY_NAME'] # OPTIONAL + oidc_options[:issuer] = ENV['OIDC_ISSUER'] if ENV['OIDC_ISSUER'] # NEED + oidc_options[:discovery] = ENV['OIDC_DISCOVERY'] == 'true' if ENV['OIDC_DISCOVERY'] # OPTIONAL (default: false) + oidc_options[:client_auth_method] = ENV['OIDC_CLIENT_AUTH_METHOD'] if ENV['OIDC_CLIENT_AUTH_METHOD'] # OPTIONAL (default: basic) + scope_string = ENV['OIDC_SCOPE'] if ENV['OIDC_SCOPE'] # NEED scopes = scope_string.split(',') oidc_options[:scope] = scopes.map { |x| x.to_sym } - oidc_options[:response_type] = ENV['OIDC_RESPONSE_TYPE'] if ENV['OIDC_RESPONSE_TYPE'] #OPTIONAL (default: code) - oidc_options[:response_mode] = ENV['OIDC_RESPONSE_MODE'] if ENV['OIDC_RESPONSE_MODE'] #OPTIONAL (default: query) - oidc_options[:display] = ENV['OIDC_DISPLAY'] if ENV['OIDC_DISPLAY'] #OPTIONAL (default: page) - oidc_options[:prompt] = ENV['OIDC_PROMPT'] if ENV['OIDC_PROMPT'] #OPTIONAL - oidc_options[:send_nonce] = ENV['OIDC_SEND_NONCE'] == 'true' if ENV['OIDC_SEND_NONCE'] #OPTIONAL (default: true) - oidc_options[:send_scope_to_token_endpoint] = ENV['OIDC_SEND_SCOPE_TO_TOKEN_ENDPOINT'] == 'true' if ENV['OIDC_SEND_SCOPE_TO_TOKEN_ENDPOINT'] #OPTIONAL (default: true) - oidc_options[:post_logout_redirect_uri] = ENV['OIDC_IDP_LOGOUT_REDIRECT_URI'] if ENV['OIDC_IDP_LOGOUT_REDIRECT_URI'] #OPTIONAL - oidc_options[:uid_field] = ENV['OIDC_UID_FIELD'] if ENV['OIDC_UID_FIELD'] #NEED + oidc_options[:response_type] = ENV['OIDC_RESPONSE_TYPE'] if ENV['OIDC_RESPONSE_TYPE'] # OPTIONAL (default: code) + oidc_options[:response_mode] = ENV['OIDC_RESPONSE_MODE'] if ENV['OIDC_RESPONSE_MODE'] # OPTIONAL (default: query) + oidc_options[:display] = ENV['OIDC_DISPLAY'] if ENV['OIDC_DISPLAY'] # OPTIONAL (default: page) + oidc_options[:prompt] = ENV['OIDC_PROMPT'] if ENV['OIDC_PROMPT'] # OPTIONAL + oidc_options[:send_nonce] = ENV['OIDC_SEND_NONCE'] == 'true' if ENV['OIDC_SEND_NONCE'] # OPTIONAL (default: true) + oidc_options[:send_scope_to_token_endpoint] = ENV['OIDC_SEND_SCOPE_TO_TOKEN_ENDPOINT'] == 'true' if ENV['OIDC_SEND_SCOPE_TO_TOKEN_ENDPOINT'] # OPTIONAL (default: true) + oidc_options[:post_logout_redirect_uri] = ENV['OIDC_IDP_LOGOUT_REDIRECT_URI'] if ENV['OIDC_IDP_LOGOUT_REDIRECT_URI'] # OPTIONAL + oidc_options[:uid_field] = ENV['OIDC_UID_FIELD'] if ENV['OIDC_UID_FIELD'] # NEED oidc_options[:client_options] = {} - oidc_options[:client_options][:identifier] = ENV['OIDC_CLIENT_ID'] if ENV['OIDC_CLIENT_ID'] #NEED - oidc_options[:client_options][:secret] = ENV['OIDC_CLIENT_SECRET'] if ENV['OIDC_CLIENT_SECRET'] #NEED - oidc_options[:client_options][:redirect_uri] = ENV['OIDC_REDIRECT_URI'] if ENV['OIDC_REDIRECT_URI'] #NEED - oidc_options[:client_options][:scheme] = ENV['OIDC_HTTP_SCHEME'] if ENV['OIDC_HTTP_SCHEME'] #OPTIONAL (default: https) - oidc_options[:client_options][:host] = ENV['OIDC_HOST'] if ENV['OIDC_HOST'] #OPTIONAL - oidc_options[:client_options][:port] = ENV['OIDC_PORT'] if ENV['OIDC_PORT'] #OPTIONAL - oidc_options[:client_options][:authorization_endpoint] = ENV['OIDC_AUTH_ENDPOINT'] if ENV['OIDC_AUTH_ENDPOINT'] #NEED when discovery != true - oidc_options[:client_options][:token_endpoint] = ENV['OIDC_TOKEN_ENDPOINT'] if ENV['OIDC_TOKEN_ENDPOINT'] #NEED when discovery != true - oidc_options[:client_options][:userinfo_endpoint] = ENV['OIDC_USER_INFO_ENDPOINT'] if ENV['OIDC_USER_INFO_ENDPOINT'] #NEED when discovery != true - oidc_options[:client_options][:jwks_uri] = ENV['OIDC_JWKS_URI'] if ENV['OIDC_JWKS_URI'] #NEED when discovery != true - oidc_options[:client_options][:end_session_endpoint] = ENV['OIDC_END_SESSION_ENDPOINT'] if ENV['OIDC_END_SESSION_ENDPOINT'] #OPTIONAL + oidc_options[:client_options][:identifier] = ENV['OIDC_CLIENT_ID'] if ENV['OIDC_CLIENT_ID'] # NEED + oidc_options[:client_options][:secret] = ENV['OIDC_CLIENT_SECRET'] if ENV['OIDC_CLIENT_SECRET'] # NEED + oidc_options[:client_options][:redirect_uri] = ENV['OIDC_REDIRECT_URI'] if ENV['OIDC_REDIRECT_URI'] # NEED + oidc_options[:client_options][:scheme] = ENV['OIDC_HTTP_SCHEME'] if ENV['OIDC_HTTP_SCHEME'] # OPTIONAL (default: https) + oidc_options[:client_options][:host] = ENV['OIDC_HOST'] if ENV['OIDC_HOST'] # OPTIONAL + oidc_options[:client_options][:port] = ENV['OIDC_PORT'] if ENV['OIDC_PORT'] # OPTIONAL + oidc_options[:client_options][:authorization_endpoint] = ENV['OIDC_AUTH_ENDPOINT'] if ENV['OIDC_AUTH_ENDPOINT'] # NEED when discovery != true + oidc_options[:client_options][:token_endpoint] = ENV['OIDC_TOKEN_ENDPOINT'] if ENV['OIDC_TOKEN_ENDPOINT'] # NEED when discovery != true + oidc_options[:client_options][:userinfo_endpoint] = ENV['OIDC_USER_INFO_ENDPOINT'] if ENV['OIDC_USER_INFO_ENDPOINT'] # NEED when discovery != true + oidc_options[:client_options][:jwks_uri] = ENV['OIDC_JWKS_URI'] if ENV['OIDC_JWKS_URI'] # NEED when discovery != true + oidc_options[:client_options][:end_session_endpoint] = ENV['OIDC_END_SESSION_ENDPOINT'] if ENV['OIDC_END_SESSION_ENDPOINT'] # OPTIONAL oidc_options[:security] = {} - oidc_options[:security][:assume_email_is_verified] = ENV['OIDC_SECURITY_ASSUME_EMAIL_IS_VERIFIED'] == 'true' #OPTIONAL + oidc_options[:security][:assume_email_is_verified] = ENV['OIDC_SECURITY_ASSUME_EMAIL_IS_VERIFIED'] == 'true' # OPTIONAL config.omniauth :openid_connect, oidc_options end end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 6ce84a6e42..5fd9199440 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -74,7 +74,7 @@ end # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only # Rails.application.config.content_security_policy_report_only = true -Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } +Rails.application.config.content_security_policy_nonce_generator = ->request { SecureRandom.base64(16) } Rails.application.config.content_security_policy_nonce_directives = %w(style-src) diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 1fde35f9d0..3d94e38e8e 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -11,26 +11,16 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins '*' - resource '/.well-known/*', - headers: :any, - methods: [:get], - credentials: false - resource '/@:username', - headers: :any, - methods: [:get], - credentials: false - resource '/users/:username', - headers: :any, - methods: [:get], - credentials: false - resource '/api/*', - headers: :any, - methods: [:post, :put, :delete, :get, :patch, :options], - credentials: false, - expose: ['Link', 'X-RateLimit-Reset', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-Request-Id'] - resource '/oauth/token', - headers: :any, - methods: [:post], - credentials: false + with_options headers: :any, credentials: false do + with_options methods: [:get] do + resource '/.well-known/*' + resource '/@:username' + resource '/users/:username' + end + resource '/api/*', + expose: %w(Link X-RateLimit-Reset X-RateLimit-Limit X-RateLimit-Remaining X-Request-Id), + methods: %i(post put delete get patch options) + resource '/oauth/token', methods: [:post] + end end end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index d0af0fe940..429dbd3248 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -142,10 +142,10 @@ class Rack::Attack match_data = request.env['rack.attack.match_data'] headers = { - 'Content-Type' => 'application/json', - 'X-RateLimit-Limit' => match_data[:limit].to_s, + 'Content-Type' => 'application/json', + 'X-RateLimit-Limit' => match_data[:limit].to_s, 'X-RateLimit-Remaining' => '0', - 'X-RateLimit-Reset' => (now + (match_data[:period] - (now.to_i % match_data[:period]))).iso8601(6), + 'X-RateLimit-Reset' => (now + (match_data[:period] - (now.to_i % match_data[:period]))).iso8601(6), } [429, headers, [{ error: I18n.t('errors.429') }.to_json]] diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index b29e0a8159..eac23a79b9 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -2,7 +2,10 @@ # Be sure to restart your server when you modify this file. -Rails.application.config.session_store :cookie_store, - key: '_mastodon_session', - secure: false, # All cookies have their secure flag set by the force_ssl option in production - same_site: :lax +Rails + .application + .config + .session_store :cookie_store, + key: '_mastodon_session', + secure: false, # All cookies have their secure flag set by the force_ssl option in production + same_site: :lax diff --git a/config/locales/activerecord.fi.yml b/config/locales/activerecord.fi.yml index bfa19d0cfe..7af54a1d34 100644 --- a/config/locales/activerecord.fi.yml +++ b/config/locales/activerecord.fi.yml @@ -10,7 +10,7 @@ fi: locale: Alue password: Salasana user/account: - username: Käyttäjätunnus + username: Käyttäjänimi user/invite_request: text: Syy errors: diff --git a/config/locales/en.yml b/config/locales/en.yml index ecb59c9463..23b3bf24e2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -426,6 +426,8 @@ en: public_comment_hint: Comment about this domain limitation for the general public, if advertising the list of domain limitations is enabled. reject_favourite: Reject favorites reject_favourite_hint: Reject favorites or emoji-reaction in the future + reject_friend: Reject friend server applications + reject_friend_hint: Reject friend server application in the future reject_hashtag: Reject hashtags reject_hashtag_hint: Reject hashtags in the future reject_media: Reject media files @@ -497,6 +499,38 @@ en: suppressed: Suppressed title: Follow recommendations unsuppress: Restore follow recommendation + friend_servers: + accept: Accept + add_new: Add and make a new application + delete: Delete + description_html: フレンドサーバーとは、お互いのローカル公開・ローカル検索許可の投稿をそのまま交換するシステムです。 + disabled: Disabled + domain: Domain + edit: + allow_all_posts: Receive all posts + allow_all_posts_hint: 通常は自分のサーバーの誰もフォローしていないアカウントの投稿は例外を除き受け入れがブロックされます。そのブロックを解除します。スパムが発生した場合など、いつでもブロックを再開できます。 + available: Available + delivery_local: Deliver without changing public unlisted visibility and searchability + delivery_local_hint: Public unlisted posts will be added the friend's global timeline + description: フレンドサーバーは、登録と同時に相手方のサーバーへ申請されます。 + domain: Domain + inbox_url: Friend server inbox URL + inbox_url_hint: Default value is https://domain/inbox if you input empty (For example, https://example.com/inbox) + pseudo_relay: Send all public or searchable posts + pseudo_relay_hint: お互いに有効で、かつ相手側で「このサーバーからの投稿を無条件で受け入れる」が有効になっている必要があります + unlocked: Approve automatically receiving new request + edit_friend: Edit + enabled: Enabled + follow: Request + pending: Pending + pending_you: Your review requested + reject: Reject + save_and_enable: Save and enable + setup: Add and make a new application + signatures_not_enabled: セキュアモードまたは連合制限モードが有効の場合、フレンドサーバーの動作を確認していないため正常に動作しない可能性があります + status: Status + title: Friend server + unfollow: Cancel request instances: availability: description_html: @@ -520,6 +554,7 @@ en: limited_federation_mode_description_html: You can chose whether to allow federation with this domain. policies: reject_favourite: Reject favorite + reject_friend: Reject friend server application reject_hashtag: Reject hashtags reject_media: Reject media reject_new_follow: Reject follows @@ -810,6 +845,7 @@ en: discovery: emoji_reactions: Stamp follow_recommendations: Follow recommendations + friend_servers: Friend servers preamble: Surfacing interesting content is instrumental in onboarding new users who may not know anyone Mastodon. Control how various discovery features work on your server. profile_directory: Profile directory public_timelines: Public timelines @@ -817,6 +853,7 @@ en: publish_statistics: Publish statistics title: Discovery trends: Trends + visibilities: Visibilities domain_blocks: all: To everyone disabled: To no one @@ -1049,6 +1086,9 @@ en: new_pending_account: body: The details of the new account are below. You can approve or reject this application. subject: New account up for review on %{instance} (%{username}) + new_pending_friend_server: + body: The new friend server %{domain} is waiting for your review. You can approve or reject this application. + subject: New friend server up for review on %{instance} (%{domain}) new_report: body: "%{reporter} has reported %{target}" body_remote: Someone from %{domain} has reported %{target} @@ -1835,6 +1875,8 @@ en: public: Public public_long: Anyone can find public_search_long: You can search all posts permitted to search + public_unlisted: Local and followers + public_unlisted_long: Local users and followers can find show_more: Show more show_newer: Show newer show_older: Show older diff --git a/config/locales/fi.yml b/config/locales/fi.yml index 3511affda8..1fb818b580 100644 --- a/config/locales/fi.yml +++ b/config/locales/fi.yml @@ -162,7 +162,7 @@ fi: unsilenced_msg: Tilin %{username} rajoituksen kumoaminen onnistui unsubscribe: Lopeta tilaus unsuspended_msg: Tilin %{username} jäädytyksen kumoaminen onnistui - username: Käyttäjätunnus + username: Käyttäjänimi view_domain: Näytä verkkotunnuksen yhteenveto warn: Varoita web: Verkko @@ -832,7 +832,7 @@ fi: database_schema_check: message_html: Tietokannan siirto on vireillä. Suorita ne varmistaaksesi, että sovellus toimii odotetulla tavalla elasticsearch_health_red: - message_html: Elasticsearch-klusteri on vikatilassa (punainen tila); hakuominaisuudet eivät ole käytettävissä + message_html: Elasticsearch-klusteri on vikatilassa (punainen tila), joten hakuominaisuudet eivät ole käytettävissä elasticsearch_health_yellow: message_html: Elasticsearch-klusteri on häiriötilassa (keltainen tila), joten suosittelemme tutkimaan syyn elasticsearch_index_mismatch: @@ -842,13 +842,13 @@ fi: message_html: Elasticsearch-klusterissa on useampi kuin yksi solmu, mutta Mastodonia ei ole määritetty käyttämään niitä. elasticsearch_preset_single_node: action: Katso käyttöohjeet - message_html: Elasticsearch-klusterissa on vain yksi solmu, ES_PRESET tulisi asettaa arvoon single_node_cluster. + message_html: Elasticsearch-klusterissa on vain yksi solmu. ES_PRESET tulisi asettaa arvoon single_node_cluster. elasticsearch_reset_chewy: message_html: Elasticsearch-järjestelmäindeksi on vanhentunut asetusmuutoksen vuoksi. Suorita tootctl search deploy --reset-chewy päivittääksesi sen. elasticsearch_running_check: - message_html: Ei saatu yhteyttä Elasticsearch. Tarkista, että se on käynnissä tai poista kokotekstihaku käytöstä + message_html: Ei saatu yhteyttä Elasticsearchiin. Tarkista, että se on käynnissä tai poista kokotekstihaku käytöstä elasticsearch_version_check: - message_html: 'Yhteensopimaton Elasticsearch versio: %{value}' + message_html: 'Yhteensopimaton Elasticsearch-versio: %{value}' version_comparison: Elasticsearch %{running_version} on käynnissä, kun %{required_version} vaaditaan rules_check: action: Hallitse palvelimen sääntöjä @@ -1088,7 +1088,7 @@ fi: new_confirmation_instructions_sent: Saat uuden vahvistuslinkin sisältävän sähköpostiviestin muutamassa minuutissa! title: Tarkista sähköpostilaatikkosi sign_in: - preamble_html: Kirjaudu %{domain}-tunnuksellasi. Jos tilisi sijaitsee eri palvelimella, et voi kirjautua täällä. + preamble_html: Kirjaudu %{domain}-tunnuksellasi. Jos tilisi sijaitsee eri palvelimella, et voi kirjautua tässä. title: Kirjaudu palvelimelle %{domain} sign_up: manual_review: Palvelimen %{domain} valvojat tarkistavat rekisteröitymiset käsin. Helpottaaksesi rekisteröitymisesi käsittelyä kerro hieman itsestäsi ja miksi haluat luoda käyttäjätilin palvelimelle %{domain}. @@ -1270,7 +1270,7 @@ fi: other: Kaikki %{count} kohdetta tällä sivulla on valittu. all_matching_items_selected_html: one: "%{count} kohde, joka vastaa hakuasi." - other: Kaikki %{count} kohdetta, jotka vastaavat hakuasi. + other: Kaikki %{count} hakuasi vastaavaa kohdetta. cancel: Peruuta changes_saved_msg: Muutosten tallennus onnistui! confirm: Vahvista @@ -1282,7 +1282,7 @@ fi: save_changes: Tallenna muutokset select_all_matching_items: one: Valitse %{count} kohde, joka vastaa hakuasi. - other: Valitse kaikki %{count} kohdetta, jotka vastaavat hakuasi. + other: Valitse kaikki %{count} hakuasi vastaavaa kohdetta. today: tänään validation_errors: one: Kaikki ei ole aivan oikein! Tarkasta alla oleva virhe @@ -1402,7 +1402,7 @@ fi: not_ready: Ei voi liittää tiedostoja, joiden käsittely on kesken. Yritä hetken kuluttua uudelleen! too_many: Tiedostoja voi liittää enintään 4 migrations: - acct: uuden tilin käyttäjätunnus@verkkotunnus + acct: Muuttanut tunnukselle cancel: Peruuta uudelleenohjaus cancel_explanation: Uudelleenohjauksen peruuttaminen aktivoi uudelleen nykyisen tilisi, mutta ei palauta seuraajia, jotka on siirretty kyseiselle tilille. cancelled_msg: Uudelleenohjaus peruttu onnistuneesti. @@ -1525,7 +1525,7 @@ fi: privacy_hint_html: Määritä, kuinka paljon muita avustavia tietoja haluat paljastaa. Käyttäjät löytävät kiinnostavia profiileja ja hienoja sovelluksia, kun he selaavat toisten seuraamia käyttäjiä ja kun he näkevät, millä sovelluksilla nämä julkaisevat. Saatat kuitenkin haluta piilottaa nämä tiedot. reach: Tavoittavuus reach_hint_html: Määritä, haluatko tulla uusien käyttäjien löytämäksi ja seuraamaksi. Haluatko julkaisujesi näkyvän Selaa-sivulla? Haluatko muiden käyttäjien näkevän sinut seuraamissuosituksissaan? Haluatko hyväksyä kaikki uudet seuraajat automaattisesti vai päättää jokaisesta erikseen? - search: Haku + search: Hae search_hint_html: Määritä, kuinka haluat tulla löydetyksi. Haluatko, että ihmiset löytävät sinut julkisten julkaisujesi perusteella? Haluatko, että ihmiset Mastodonin ulkopuolella löytävät profiilisi tehdessään hakuja verkossa? Otathan huomioon, ettei julkisten tietojen täyttä kaikista hakukoneista poisjäämistä voi taata. title: Yksityisyys ja tavoittavuus privacy_policy: diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 85cb345e21..1b5890e67e 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -422,6 +422,8 @@ ja: public_comment_hint: ドメインブロックの公開を有効にしている場合、このコメントも公開されます。 reject_favourite: お気に入り、スタンプを拒否 reject_favourite_hint: 今後のお気に入り、スタンプを拒否します。停止とは無関係です + reject_friend: フレンドサーバー申請を拒否 + reject_friend_hint: 今後のフレンドサーバー申請を全て拒否します。停止とは無関係です reject_hashtag: ハッシュタグを拒否 reject_hashtag_hint: ハッシュタグで検索できなくなり、トレンドにも影響しなくなります。停止とは無関係です reject_media: メディアファイルを拒否 @@ -492,6 +494,38 @@ ja: suppressed: 非表示 title: おすすめフォロー unsuppress: おすすめフォローを復元 + friend_servers: + accept: 相手の申請を承認する + add_new: フレンドサーバーを追加・申請 + delete: 削除 + description_html: フレンドサーバーとは、お互いのローカル公開・ローカル検索許可の投稿をそのまま交換するシステムです。 + disabled: 無効 + domain: ドメイン + edit: + allow_all_posts: このサーバーからの投稿を無条件で受け入れる + allow_all_posts_hint: 通常は自分のサーバーの誰もフォローしていないアカウントの投稿は例外を除き受け入れがブロックされます。そのブロックを解除します。スパムが発生した場合など、いつでもブロックを再開できます。 + available: 有効にする + delivery_local: ローカル公開の公開範囲・検索許可を持った投稿をそのまま相手と共有する + delivery_local_hint: ローカル公開投稿は、通常は非収載に変換されて配送されます。その処理をせず、相手サーバーにもローカル公開と認識されるようにします。相手の連合タイムラインに掲載されます + description: フレンドサーバーは、登録と同時に相手方のサーバーへ申請されます。 + domain: ドメイン + inbox_url: フレンドサーバーの inbox URL + inbox_url_hint: 空欄にした場合、自動で「https://ドメイン名/inbox」に設定されます。(例:https://example.com/inbox)相手のサーバーがinbox URLを特別に指定している場合、入力してください。 + pseudo_relay: 全ての公開・ローカル公開・非収載かつ検索可能な投稿を送信する + pseudo_relay_hint: お互いに有効で、かつ相手側で「このサーバーからの投稿を無条件で受け入れる」が有効になっている必要があります + unlocked: このサーバーからの申請を自動で承認する + edit_friend: 編集 + enabled: 有効 + follow: こちらから申請する + pending: 承認待ち + pending_you: あなたの承認が必要 + reject: 相手からの申請を却下する + save_and_enable: 保存して有効にする + setup: フレンドサーバーを追加・申請 + signatures_not_enabled: セキュアモードまたは連合制限モードが有効の場合、フレンドサーバーの動作を確認していないため正常に動作しない可能性があります + status: ステータス + title: フレンドサーバー + unfollow: こちらの申請を取り消す instances: availability: description_html: @@ -514,6 +548,7 @@ ja: policies: detect_invalid_subscription: 購読のプライバシーなし reject_favourite: お気に入りを拒否 + reject_friend: フレンドサーバー申請を拒否 reject_hashtag: ハッシュタグを拒否 reject_media: メディアを拒否する reject_new_follow: 新規フォローを拒否 @@ -807,6 +842,7 @@ ja: discovery: emoji_reactions: スタンプ follow_recommendations: おすすめフォロー + friend_servers: フレンドサーバー preamble: Mastodon を知らないユーザーを取り込むには、興味深いコンテンツを浮上させることが重要です。サーバー上で様々なディスカバリー機能がどのように機能するかを制御します。 profile_directory: ディレクトリ public_timelines: 公開タイムライン @@ -814,6 +850,7 @@ ja: publish_statistics: 統計情報を公開する title: 見つける trends: トレンド + visibilities: 公開範囲 domain_blocks: all: 誰にでも許可 disabled: 誰にも許可しない @@ -1042,6 +1079,9 @@ ja: new_pending_account: body: 新しいアカウントの詳細は以下の通りです。この申請を承認または却下することができます。 subject: '%{instance}で新しいアカウント (%{username}) が承認待ちです' + new_pending_friend_server: + body: 新しいフレンドサーバー %{domain} の申請が届いています。この申請を承認または却下することができます。 + subject: '%{instance}で新しいフレンドサーバー (%{domain}) が承認待ちです' new_report: body: "%{reporter}さんが%{target}さんを通報しました" body_remote: "%{domain}の誰かが%{target}さんを通報しました" @@ -1813,6 +1853,8 @@ ja: public: 誰でも public_long: この投稿は誰でも検索できます public_search_long: 検索が許可された全ての投稿が検索できます + public_unlisted: ローカルとフォロワー + public_unlisted_long: ローカル・フォロワー・反応者のみが検索できます show_more: もっと見る show_newer: 新しいものを表示 show_older: 古いものを表示 @@ -1824,11 +1866,11 @@ ja: private: フォロワー限定 private_long: フォロワーにのみ表示されます public: 公開 - public_long: 誰でも見ることができ、かつ連合・ローカルタイムラインに表示されます + public_long: 誰でも見ることができ、かつ公開タイムラインに表示されます public_unlisted: ローカル公開 - public_unlisted_long: 誰でも見ることができますが、連合タイムラインには表示されません - unlisted: 未収載 - unlisted_long: 誰でも見ることができますが、連合・ローカルタイムラインには表示されません + public_unlisted_long: 誰でも見ることができますが、他のサーバーの連合タイムラインには表示されません + unlisted: 非収載 + unlisted_long: 誰でも見ることができますが、公開タイムラインには表示されません unset: 設定なし unset_long: デフォルトの挙動に従います statuses_cleanup: diff --git a/config/locales/ko.yml b/config/locales/ko.yml index b6cbb46cb4..3a7d0c7f7a 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -878,9 +878,9 @@ ko: only_allowed: 허용된 것만 pending_review: 심사 대기 preview_card_providers: - allowed: 이 발행처의 링크는 유행록에 실릴 수 있음 + allowed: 이 발행처의 링크는 유행 목록에 실릴 수 있음 description_html: 당신의 서버에서 많은 링크가 공유되고 있는 도메인들입니다. 링크의 도메인이 승인되기 전까지는 링크들은 공개적으로 트렌드에 게시되지 않습니다. 당신의 승인(또는 거절)은 서브도메인까지 확장됩니다. - rejected: 이 발행처의 링크는 유행록에 실리지 않음 + rejected: 이 발행처의 링크는 유행 목록에 실리지 않음 title: 발행처 rejected: 거부됨 statuses: @@ -903,15 +903,15 @@ ko: tag_servers_measure: 다른 서버들 tag_uses_measure: 총 사용 description_html: 현재 서버에서 볼 수 있는 게시물에서 많이 공유되고 있는 해시태그들입니다. 현재 사람들이 무슨 이야기를 하고 있는지 사용자들이 파악할 수 있도록 도움이 됩니다. 승인하지 않는 한 해시태그는 공개적으로 게시되지 않습니다. - listable: 추천될 수 있습니다 + listable: 추천될 수 있음 no_tag_selected: 아무 것도 선택 되지 않아 어떤 태그도 바뀌지 않았습니다 - not_listable: 추천될 수 없습니다 - not_trendable: 유행 목록에 나타나지 않습니다 - not_usable: 사용불가 - peaked_on_and_decaying: '%{date}에 고점을 찍고, 떨어지고 있습니다' + not_listable: 추천하지 않음 + not_trendable: 유행 목록에 나타내지 않음 + not_usable: 이용할 수 없음 + peaked_on_and_decaying: "%{date}에 고점을 찍고, 떨어지고 있습니다" title: 유행하는 해시태그 - trendable: 유행 목록에 나타날 수 있습니다 - trending_rank: '#%{rank}위로 유행 중' + trendable: 유행 목록에 나타날 수 있음 + trending_rank: "#%{rank}위로 유행 중" usable: 사용 가능 usage_comparison: 오늘은 %{today}회 쓰였고, 어제는 %{yesterday}회 쓰임 used_by_over_week: diff --git a/config/locales/si.yml b/config/locales/si.yml index e9393e5c9a..7dd900f509 100644 --- a/config/locales/si.yml +++ b/config/locales/si.yml @@ -298,7 +298,7 @@ si: updated_msg: ඉමොජි සාර්ථකව යාවත්කාලීන කරන ලදී! upload: උඩුගත කරන්න dashboard: - active_users: ක්රියාකාරී පරිශීලකයන් + active_users: සක්‍රිය පරිශ්‍රීලකයින් interactions: අන්තර්ක්රියා media_storage: මාධ්‍ය ආචයනය new_users: නව පරිශ්‍රීලකයින් diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index aa17a42816..2b777aaa2b 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -87,6 +87,7 @@ en: filters: action: Chose which action to perform when a post matches the filter actions: + half_warn: Hide the filtered content (exclude account info) behind a warning mentioning the filter's title hide: Completely hide the filtered content, behaving as if it did not exist warn: Hide the filtered content behind a warning mentioning the filter's title form_admin_settings: @@ -96,6 +97,7 @@ en: closed_registrations_message: Displayed when sign-ups are closed content_cache_retention_period: All posts and boosts from other servers will be deleted after the specified number of days. Some posts may not be recoverable. All related bookmarks, favourites and boosts will also be lost and impossible to undo. custom_css: You can apply custom styles on the web version of Mastodon. + enable_public_unlisted_visibility: If true, your community maybe closed-minded. If turn it false, strongly recommend that you disclose that you have disabled this setting! mascot: Overrides the illustration in the advanced web interface. media_cache_retention_period: Downloaded media files will be deleted after the specified number of days when set to a positive value, and re-downloaded on demand. peers_api_enabled: A list of domain names this server has encountered in the fediverse. No data is included here about whether you federate with a given server, just that your server knows about it. This is used by services that collect statistics on federation in a general sense. @@ -225,6 +227,7 @@ en: phrase: Keyword or phrase setting_advanced_layout: Enable advanced web interface setting_aggregate_reblogs: Group boosts in timelines + setting_allow_quote: Allow quote your posts setting_always_send_emails: Always send e-mail notifications setting_auto_play_gif: Auto-play animated GIFs setting_bio_markdown: Enable profile markdown @@ -258,6 +261,7 @@ en: mutuals_only: Mutuals only outside_only: Followings or followers only setting_expand_spoilers: Always expand posts marked with content warnings + setting_hide_blocking_quote: Hide posts which have a quote written by the user you are blocking setting_hide_followers_count: Hide followers count setting_hide_following_count: Hide following count setting_hide_network: Hide your social graph @@ -268,11 +272,13 @@ en: setting_noai: Set noai meta tags setting_public_post_to_unlisted: Convert public post to public unlisted if not using Web app setting_reduce_motion: Reduce motion in animations - setting_reject_public_unlisted_subscription: Reject sending public unlisted posts to Misskey, Calckey - setting_reject_unlisted_subscription: Reject sending unlisted posts to Misskey, Calckey + setting_reject_public_unlisted_subscription: Reject sending public unlisted visibility/non-public searchability posts to Misskey, Calckey + setting_reject_unlisted_subscription: Reject sending unlisted visibility/non-public searchability posts to Misskey, Calckey setting_send_without_domain_blocks: Send your post to all server with administrator set as rejecting-post-server for protect you [DEPRECATED] setting_show_application: Disclose application used to send posts setting_show_emoji_reaction_on_timeline: Show all stamps on timeline + setting_show_quote_in_home: Show quotes in home, list or antenna timelines + setting_show_quote_in_public: Show quotes in public timelines setting_simple_timeline_menu: Reduce post menu on timeline setting_single_ref_to_quote: Deliver single reference to other server as quote setting_stay_privacy: Not change privacy after post @@ -307,11 +313,13 @@ en: name: Hashtag filters: actions: + half_warn: Half hide with a warning hide: Hide completely warn: Hide with a warning options: exclude_follows: Exclude following users exclude_localusers: Exclude local users + with_quote: Also check quote or references form_admin_settings: activity_api_enabled: Publish aggregate statistics about user activity in the API backups_retention_period: User archive retention period @@ -321,6 +329,7 @@ en: content_cache_retention_period: Content cache retention period custom_css: Custom CSS enable_emoji_reaction: Enable stamp function + enable_public_unlisted_visibility: Enable public-unlisted visibility mascot: Custom mascot (legacy) media_cache_retention_period: Media cache retention period peers_api_enabled: Publish list of discovered servers in the API @@ -345,6 +354,7 @@ en: trendable_by_default: Allow trends without prior review trends: Enable trends trends_as_landing_page: Use trends as the landing page + unlocked_friend: Accept all friend server follows automatically interactions: must_be_follower: Block notifications from non-followers must_be_following: Block notifications from people you don't follow @@ -369,6 +379,7 @@ en: follow_request: Someone requested to follow you mention: Someone mentioned you pending_account: New account needs review + pending_friend_server: New friend server needs review reblog: Someone boosted your post report: New report is submitted software_updates: diff --git a/config/locales/simple_form.fi.yml b/config/locales/simple_form.fi.yml index e69527c64e..0ff9ddd940 100644 --- a/config/locales/simple_form.fi.yml +++ b/config/locales/simple_form.fi.yml @@ -42,7 +42,7 @@ fi: bot: Tämä tili suorittaa enimmäkseen automaattisia toimintoja eikä sitä ehkä valvota context: Ainakin yksi konteksti, jossa suodattimen pitäisi olla voimassa current_password: Turvallisuussyistä kirjoita nykyisen tilin salasana - current_username: Vahvista kirjoittamalla nykyisen tilin käyttäjätunnus + current_username: Vahvista kirjoittamalla nykyisen tilin käyttäjänimi digest: Lähetetään vain pitkän poissaolon jälkeen ja vain, jos olet saanut suoria viestejä poissaolosi aikana email: Sinulle lähetetään vahvistussähköposti header: PNG, GIF tai JPG. Enintään %{size}. Skaalataan kokoon %{dimensions} px @@ -147,9 +147,9 @@ fi: show_collections: Näytä seuratut ja seuraajat profiilissa unlocked: Hyväksy uudet seuraajat automaattisesti account_alias: - acct: Vanhan tilin käyttäjätunnus + acct: Vanhan tilin käyttäjänimi account_migration: - acct: Uuden tilin käyttäjätunnus + acct: Uuden tilin käyttäjänimi account_warning_preset: text: Esiasetettu teksti title: Nimi @@ -226,7 +226,7 @@ fi: title: Nimi type: Tuontilaji username: Käyttäjänimi - username_or_email: Käyttäjänimi tai sähköposti + username_or_email: Käyttäjänimi tai sähköpostiosoite whole_word: Koko sana email_domain_block: with_dns_records: Sisällytä toimialueen MX tietueet ja IP-osoite @@ -252,7 +252,7 @@ fi: show_domain_blocks: Näytä domainestot show_domain_blocks_rationale: Näytä miksi verkkotunnukset on estetty site_contact_email: Ota yhteyttä sähköpostilla - site_contact_username: Kontaktin käyttäjänimi + site_contact_username: Yhteyshenkilön käyttäjänimi site_extended_description: Laajennettu kuvaus site_short_description: Palvelimen kuvaus site_terms: Tietosuojakäytäntö diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index e255c43cd4..41057cbed1 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -81,7 +81,7 @@ ja: setting_link_preview: プレビュー生成を停止することは、センシティブなサイトへのリンクを頻繁に投稿する人にも有効かもしれません setting_noai: AI学習への利用を禁止するメタタグをプロフィールページに追加します。ただし実効性があるとは限りません setting_public_post_to_unlisted: 未対応のサードパーティアプリからもローカル公開で投稿できますが、公開投稿はWeb以外できなくなります - setting_reject_unlisted_subscription: Misskeyやそのフォーク(Calckeyなど)は、フォローしていないアカウントの「未収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに未収載として配信されること、ご理解ください + setting_reject_unlisted_subscription: Misskeyやそのフォークは、フォローしていないアカウントの「未収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに未収載として配信されること、ご理解ください setting_show_application: 投稿するのに使用したアプリが投稿の詳細ビューに表示されるようになります setting_single_ref_to_quote: 当サーバーがまだ対象投稿を取り込んでいない場合、引用が相手に正常に認識されない場合があります setting_stop_emoji_reaction_streaming: 通信容量の節約に役立ちます @@ -100,6 +100,7 @@ ja: filters: action: 投稿がフィルタに一致したときに実行するアクションを選択 actions: + half_warn: フィルターに一致した投稿の本文のみを非表示にし、フィルターのタイトルを含む警告を表示します hide: フィルタに一致した投稿を完全に非表示にします warn: フィルタに一致した投稿を非表示にし、フィルタのタイトルを含む警告を表示します form_admin_settings: @@ -109,6 +110,7 @@ ja: closed_registrations_message: アカウント作成を停止している時に表示されます content_cache_retention_period: 指定した日数が経過した他のサーバーの投稿とブーストを削除します。削除された投稿は再取得できない場合があります。削除された投稿についたブックマークやお気に入り、ブーストも失われ、元に戻せません。 custom_css: ウェブ版のMastodonでカスタムスタイルを適用できます。 + enable_public_unlisted_visibility: 有効にするとあなたのコミュニティは閉鎖的になるかもしれません。この設定はkmyblueの主要機能の1つであり、無効にする場合は概要などに記載することを強くおすすめします。 mascot: 上級者向けWebインターフェースのイラストを上書きします。 media_cache_retention_period: 正の値に設定されている場合、ダウンロードされたメディアファイルは指定された日数の後に削除され、リクエストに応じて再ダウンロードされます。 peers_api_enabled: このサーバーが Fediverse で遭遇したドメイン名のリストです。このサーバーが知っているだけで、特定のサーバーと連合しているかのデータは含まれません。これは一般的に Fediverse に関する統計情報を収集するサービスによって使用されます。 @@ -238,6 +240,7 @@ ja: phrase: キーワードまたはフレーズ setting_advanced_layout: 上級者向けUIを有効にする setting_aggregate_reblogs: ブーストをまとめる + setting_allow_quote: 引用を許可する setting_always_send_emails: 常にメール通知を送信する setting_auto_play_gif: アニメーションGIFを自動再生する setting_bio_markdown: プロフィールのMarkdownを有効にする @@ -272,6 +275,7 @@ ja: setting_emoji_reaction_streaming_notify_impl2: Nyastodon, Catstodon, glitch-soc互換のスタンプ機能を有効にする setting_enable_emoji_reaction: スタンプ機能を使用する setting_expand_spoilers: 閲覧注意としてマークされた投稿を常に展開する + setting_hide_blocking_quote: ブロックしたユーザーの投稿を引用した投稿を隠す setting_hide_followers_count: フォロワー数を隠す setting_hide_following_count: フォロー数を隠す setting_hide_network: 繋がりを隠す @@ -279,12 +283,14 @@ ja: setting_hide_statuses_count: 投稿数を隠す setting_link_preview: リンクのプレビューを生成する setting_lock_follow_from_bot: botからのフォローを承認制にする + setting_show_quote_in_home: ホーム・リスト・アンテナなどで引用を表示する + setting_show_quote_in_public: 公開タイムライン(ローカル・連合)で引用を表示する setting_stay_privacy: 投稿時に公開範囲を保存する setting_noai: 自分のコンテンツのAI学習利用に対して不快感を表明する setting_public_post_to_unlisted: サードパーティから公開範囲「公開」で投稿した場合、「ローカル公開」に変更する setting_reduce_motion: アニメーションの動きを減らす - setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」投稿を「フォロワーのみ」に変換して配送する - setting_reject_unlisted_subscription: Misskey系サーバーに「未収載」投稿を「フォロワーのみ」に変換して配送する + setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」かつ検索許可「誰でも以外」の投稿を「フォロワーのみ」に変換して配送する + setting_reject_unlisted_subscription: Misskey系サーバーに「未収載」かつ検索許可「誰でも以外」の投稿を「フォロワーのみ」に変換して配送する setting_send_without_domain_blocks: 管理人の設定した配送停止設定を拒否する (非推奨) setting_show_application: 送信したアプリを開示する setting_show_emoji_reaction_on_timeline: タイムライン上に他の人のつけたスタンプを表示する @@ -322,11 +328,13 @@ ja: name: ハッシュタグ filters: actions: + half_warn: アカウント名だけを出し、本文は警告で隠す hide: 完全に隠す warn: 警告付きで隠す options: exclude_follows: フォロー中のユーザーをフィルターの対象にしない exclude_localusers: ローカルユーザーをフィルターの対象にしない + with_quote: 引用・参照の内容をフィルターの対象に含める form_admin_settings: activity_api_enabled: APIでユーザーアクティビティに関する集計統計を公開する backups_retention_period: ユーザーアーカイブの保持期間 @@ -336,6 +344,7 @@ ja: content_cache_retention_period: コンテンツキャッシュの保持期間 custom_css: カスタムCSS enable_emoji_reaction: スタンプ機能を有効にする + enable_public_unlisted_visibility: 公開範囲「ローカル公開」を有効にする mascot: カスタムマスコット(レガシー) media_cache_retention_period: メディアキャッシュの保持期間 peers_api_enabled: 発見したサーバーのリストをAPIで公開する @@ -360,6 +369,7 @@ ja: trendable_by_default: 審査前のトレンドの掲載を許可する trends: トレンドを有効にする trends_as_landing_page: 新規登録画面にトレンドを表示する + unlocked_friend: 全てのフレンドサーバー申請を自動承認する interactions: must_be_follower: フォロワー以外からの通知をブロック must_be_following: フォローしていないユーザーからの通知をブロック @@ -384,6 +394,7 @@ ja: follow_request: フォローリクエストを受けた時 mention: 返信が来た時 pending_account: 新しいアカウントの承認が必要な時 + pending_friend_server: 新しいフレンドサーバーの承認が必要な時 reblog: 投稿がブーストされた時 report: 新しい通報が送信された時 software_updates: diff --git a/config/navigation.rb b/config/navigation.rb index e9552f4f7a..25bc5ecd43 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -66,6 +66,7 @@ SimpleNavigation::Configuration.run do |navigation| s.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_path, highlights_on: %r{/admin/custom_emojis}, if: -> { current_user.can?(:manage_custom_emojis) } s.item :webhooks, safe_join([fa_icon('inbox fw'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}, if: -> { current_user.can?(:manage_webhooks) } s.item :relays, safe_join([fa_icon('exchange fw'), t('admin.relays.title')]), admin_relays_path, highlights_on: %r{/admin/relays}, if: -> { !limited_federation_mode? && current_user.can?(:manage_federation) } + s.item :friend_servers, safe_join([fa_icon('users fw'), t('admin.friend_servers.title')]), admin_friend_servers_path, highlights_on: %r{/admin/friend_servers}, if: -> { current_user.can?(:manage_federation) } end n.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_path, link_html: { target: 'sidekiq' }, if: -> { current_user.can?(:view_devops) } diff --git a/config/routes.rb b/config/routes.rb index 3db26c7f8c..1910e3427a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,7 @@ Rails.application.routes.draw do /lists/(*any) /antennasw/(*any) /antennast/(*any) - /circles + /circles/(*any) /notifications /favourites /emoji_reactions @@ -91,10 +91,10 @@ Rails.application.routes.draw do devise_for :users, path: 'auth', format: false, controllers: { omniauth_callbacks: 'auth/omniauth_callbacks', - sessions: 'auth/sessions', - registrations: 'auth/registrations', - passwords: 'auth/passwords', - confirmations: 'auth/confirmations', + sessions: 'auth/sessions', + registrations: 'auth/registrations', + passwords: 'auth/passwords', + confirmations: 'auth/confirmations', } get '/users/:username', to: redirect('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? } diff --git a/config/routes/admin.rb b/config/routes/admin.rb index c3ae2efa93..8c10f5935b 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -69,6 +69,15 @@ namespace :admin do end end + resources :friend_servers, only: [:index, :new, :edit, :create, :update, :destroy] do + member do + post :follow + post :unfollow + post :accept + post :reject + end + end + resources :instances, only: [:index, :show, :destroy], constraints: { id: %r{[^/]+} }, format: 'html' do member do post :clear_delivery_errors diff --git a/config/routes/api.rb b/config/routes/api.rb index 9bf466ddab..005d8f6839 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -12,6 +12,7 @@ namespace :api, format: false do resources :favourited_by, controller: :favourited_by_accounts, only: :index resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index resources :referred_by, controller: :referred_by_statuses, only: :index + resources :mentioned_by, controller: :mentioned_accounts, only: :index resources :bookmark_categories, only: :index resource :reblog, only: :create post :unreblog, to: 'reblogs#destroy' @@ -226,6 +227,7 @@ namespace :api, format: false do resources :circles, only: [:index, :create, :show, :update, :destroy] do resource :accounts, only: [:show, :create, :destroy], controller: 'circles/accounts' + resource :statuses, only: [:show], controller: 'circles/statuses' end resources :bookmark_categories, only: [:index, :create, :show, :update, :destroy] do diff --git a/config/settings.yml b/config/settings.yml index 69b3ed1ee3..1fb106e680 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -42,6 +42,8 @@ defaults: &defaults streaming_other_servers_emoji_reaction: false enable_emoji_reaction: true check_lts_version_only: true + enable_public_unlisted_visibility: true + unlocked_friend: false development: <<: *defaults diff --git a/db/migrate/20230923103430_create_circle_statuses.rb b/db/migrate/20230923103430_create_circle_statuses.rb new file mode 100644 index 0000000000..9c14bb808a --- /dev/null +++ b/db/migrate/20230923103430_create_circle_statuses.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class CreateCircleStatuses < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def change + safety_assured do + create_table :circle_statuses do |t| + t.belongs_to :circle, null: true, foreign_key: { on_delete: :cascade } + t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade } + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + + add_index :circle_statuses, [:circle_id, :status_id], unique: true + end + end +end diff --git a/db/migrate/20230930233930_add_quote_to_status_references.rb b/db/migrate/20230930233930_add_quote_to_status_references.rb new file mode 100644 index 0000000000..f2bd6cd48d --- /dev/null +++ b/db/migrate/20230930233930_add_quote_to_status_references.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddQuoteToStatusReferences < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + class StatusReference < ApplicationRecord; end + + def up + safety_assured do + add_column_with_default :status_references, :quote, :boolean, default: false, allow_null: false + StatusReference.where(attribute_type: 'QT').update_all(quote: true) # rubocop:disable Rails/SkipsModelValidations + end + end + + def down + safety_assured do + remove_column :status_references, :quote + end + end +end diff --git a/db/migrate/20231001031337_add_quote_to_statuses.rb b/db/migrate/20231001031337_add_quote_to_statuses.rb new file mode 100644 index 0000000000..c60ec58ecf --- /dev/null +++ b/db/migrate/20231001031337_add_quote_to_statuses.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddQuoteToStatuses < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + class StatusReference < ApplicationRecord + belongs_to :status + belongs_to :target_status, class_name: 'Status' + end + + def up + safety_assured do + add_column_with_default :statuses, :quote_of_id, :bigint, default: nil, allow_null: true + + StatusReference.transaction do + StatusReference.where(quote: true).includes(:status).each do |ref| + ref.status.update(quote_of_id: ref.target_status_id) + end + end + end + end + + def down + safety_assured do + remove_column :statuses, :quote_of_id + end + end +end diff --git a/db/migrate/20231001050733_add_with_quote_to_custom_filters.rb b/db/migrate/20231001050733_add_with_quote_to_custom_filters.rb new file mode 100644 index 0000000000..074f552482 --- /dev/null +++ b/db/migrate/20231001050733_add_with_quote_to_custom_filters.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddWithQuoteToCustomFilters < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def change + safety_assured do + add_column_with_default :custom_filters, :with_quote, :boolean, default: true, allow_null: false + end + end +end diff --git a/db/migrate/20231005074832_create_friend_domains.rb b/db/migrate/20231005074832_create_friend_domains.rb new file mode 100644 index 0000000000..95eaf8a74c --- /dev/null +++ b/db/migrate/20231005074832_create_friend_domains.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class CreateFriendDomains < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def change + create_table :friend_domains do |t| + t.string :domain, null: false, default: '', index: { unique: true } + t.string :inbox_url, null: false, default: '', index: { unique: true } + t.integer :active_state, null: false, default: 0 + t.integer :passive_state, null: false, default: 0 + t.string :active_follow_activity_id, null: true + t.string :passive_follow_activity_id, null: true + t.boolean :available, null: false, default: true + t.boolean :pseudo_relay, null: false, default: false + t.boolean :unlocked, null: false, default: false + t.boolean :allow_all_posts, null: false, default: true + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + end +end diff --git a/db/migrate/20231006030102_add_reject_friend_to_domain_blocks.rb b/db/migrate/20231006030102_add_reject_friend_to_domain_blocks.rb new file mode 100644 index 0000000000..01204db5f1 --- /dev/null +++ b/db/migrate/20231006030102_add_reject_friend_to_domain_blocks.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddRejectFriendToDomainBlocks < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def change + safety_assured do + add_column_with_default :domain_blocks, :reject_friend, :boolean, default: false, allow_null: false + end + end +end diff --git a/db/migrate/20231009235215_add_delivery_local_to_friend_domains.rb b/db/migrate/20231009235215_add_delivery_local_to_friend_domains.rb new file mode 100644 index 0000000000..a42b016663 --- /dev/null +++ b/db/migrate/20231009235215_add_delivery_local_to_friend_domains.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddDeliveryLocalToFriendDomains < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured do + add_column_with_default :friend_domains, :delivery_local, :boolean, default: true, allow_null: false + remove_column :friend_domains, :unlocked + end + end + + def down + safety_assured do + remove_column :friend_domains, :delivery_local + add_column_with_default :friend_domains, :unlocked, :boolean, default: false, allow_null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 24dc5450e1..7a4ee11608 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do +ActiveRecord::Schema[7.0].define(version: 2023_10_09_235215) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -447,6 +447,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do t.index ["follow_id"], name: "index_circle_accounts_on_follow_id" end + create_table "circle_statuses", force: :cascade do |t| + t.bigint "circle_id" + t.bigint "status_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["circle_id", "status_id"], name: "index_circle_statuses_on_circle_id_and_status_id", unique: true + t.index ["circle_id"], name: "index_circle_statuses_on_circle_id" + t.index ["status_id"], name: "index_circle_statuses_on_status_id" + end + create_table "circles", force: :cascade do |t| t.bigint "account_id", null: false t.string "title", default: "", null: false @@ -526,6 +536,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do t.integer "action", default: 0, null: false t.boolean "exclude_follows", default: false, null: false t.boolean "exclude_localusers", default: false, null: false + t.boolean "with_quote", default: true, null: false t.index ["account_id"], name: "index_custom_filters_on_account_id" end @@ -573,6 +584,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do t.boolean "hidden_anonymous", default: false, null: false t.boolean "detect_invalid_subscription", default: false, null: false t.boolean "reject_reply_exclude_followers", default: false, null: false + t.boolean "reject_friend", default: false, null: false t.index ["domain"], name: "index_domain_blocks_on_domain", unique: true end @@ -665,6 +677,23 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do t.index ["target_account_id"], name: "index_follows_on_target_account_id" end + create_table "friend_domains", force: :cascade do |t| + t.string "domain", default: "", null: false + t.string "inbox_url", default: "", null: false + t.integer "active_state", default: 0, null: false + t.integer "passive_state", default: 0, null: false + t.string "active_follow_activity_id" + t.string "passive_follow_activity_id" + t.boolean "available", default: true, null: false + t.boolean "pseudo_relay", default: false, null: false + t.boolean "allow_all_posts", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "delivery_local", default: true, null: false + t.index ["domain"], name: "index_friend_domains_on_domain", unique: true + t.index ["inbox_url"], name: "index_friend_domains_on_inbox_url", unique: true + end + create_table "identities", force: :cascade do |t| t.string "provider", default: "", null: false t.string "uid", default: "", null: false @@ -1133,6 +1162,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.string "attribute_type" + t.boolean "quote", default: false, null: false t.index ["status_id"], name: "index_status_references_on_status_id" t.index ["target_status_id"], name: "index_status_references_on_target_status_id" end @@ -1189,6 +1219,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do t.integer "searchability" t.boolean "markdown", default: false t.integer "limited_scope" + t.bigint "quote_of_id" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["account_id", "reblog_of_id", "deleted_at", "searchability"], name: "index_statuses_for_get_following_accounts_to_search", where: "((deleted_at IS NULL) AND (reblog_of_id IS NULL) AND (searchability = ANY (ARRAY[0, 10, 1])))" t.index ["account_id"], name: "index_statuses_on_account_id" @@ -1415,6 +1446,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_090808) do add_foreign_key "circle_accounts", "accounts", on_delete: :cascade add_foreign_key "circle_accounts", "circles", on_delete: :cascade add_foreign_key "circle_accounts", "follows", on_delete: :cascade + add_foreign_key "circle_statuses", "circles", on_delete: :cascade + add_foreign_key "circle_statuses", "statuses", on_delete: :cascade add_foreign_key "circles", "accounts", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade diff --git a/lib/mastodon/cli/upgrade.rb b/lib/mastodon/cli/upgrade.rb index 52b5540c40..cf83986844 100644 --- a/lib/mastodon/cli/upgrade.rb +++ b/lib/mastodon/cli/upgrade.rb @@ -125,27 +125,12 @@ module Mastodon::CLI progress.log("Moving #{previous_path} to #{upgraded_path}") if options[:verbose] begin - unless dry_run? - FileUtils.mkdir_p(File.dirname(upgraded_path)) - FileUtils.mv(previous_path, upgraded_path) - - begin - FileUtils.rmdir(File.dirname(previous_path), parents: true) - rescue Errno::ENOTEMPTY - # OK - end - end + move_previous_to_upgraded rescue => e progress.log(pastel.red("Error processing #{previous_path}: #{e}")) success = false - unless dry_run? - begin - FileUtils.rmdir(File.dirname(upgraded_path), parents: true) - rescue Errno::ENOTEMPTY - # OK - end - end + remove_directory end end @@ -155,5 +140,28 @@ module Mastodon::CLI attachment.instance_write(:storage_schema_version, previous_storage_schema_version) success end + + def move_previous_to_upgraded(previous_path, upgraded_path) + return if dry_run? + + FileUtils.mkdir_p(File.dirname(upgraded_path)) + FileUtils.mv(previous_path, upgraded_path) + + begin + FileUtils.rmdir(File.dirname(previous_path), parents: true) + rescue Errno::ENOTEMPTY + # OK + end + end + + def remove_directory(path) + return if dry_run? + + begin + FileUtils.rmdir(File.dirname(path), parents: true) + rescue Errno::ENOTEMPTY + # OK + end + end end end diff --git a/lib/mastodon/migration_helpers.rb b/lib/mastodon/migration_helpers.rb index c382b5fbd5..a92a8767ce 100644 --- a/lib/mastodon/migration_helpers.rb +++ b/lib/mastodon/migration_helpers.rb @@ -37,7 +37,6 @@ # This is bad form, but there are enough differences that it's impractical to do # otherwise: -# rubocop:disable all module Mastodon module MigrationHelpers @@ -989,5 +988,3 @@ into similar problems in the future (e.g. when new tables are created). end end end - -# rubocop:enable all diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 63ddbb0a5b..e1f1ed87dc 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -5,15 +5,15 @@ module Mastodon module_function def kmyblue_major - 5 + 7 end def kmyblue_minor - 5 + 0 end def kmyblue_flag - 'LTS' + nil # 'LTS' end def major diff --git a/lib/tasks/tests.rake b/lib/tasks/tests.rake index 7f8e72dd8f..980823731e 100644 --- a/lib/tasks/tests.rake +++ b/lib/tasks/tests.rake @@ -136,7 +136,7 @@ namespace :tests do INSERT INTO "settings" (id, thing_type, thing_id, var, value, created_at, updated_at) VALUES - (3, 'User', 1, 'notification_emails', E'--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nfollow: false\nreblog: true\nfavourite: true\nmention: false\nfollow_request: true\ndigest: true\nreport: true\npending_account: false\ntrending_tag: true\nappeal: true\n', now(), now()), + (3, 'User', 1, 'notification_emails', E'--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nfollow: false\nreblog: true\nfavourite: true\nmention: false\nfollow_request: true\ndigest: true\nreport: true\npending_account: false\npending_friend_server: true\ntrending_tag: true\nappeal: true\n', now(), now()), (4, 'User', 1, 'trends', E'--- false\n', now(), now()); INSERT INTO "accounts" diff --git a/package.json b/package.json index 5de05c53b6..902dd8e878 100644 --- a/package.json +++ b/package.json @@ -173,7 +173,7 @@ "@types/react-dom": "^18.2.4", "@types/react-helmet": "^6.1.6", "@types/react-immutable-proptypes": "^2.1.0", - "@types/react-motion": "^0.0.34", + "@types/react-motion": "^0.0.35", "@types/react-overlays": "^3.1.0", "@types/react-router-dom": "^5.3.3", "@types/react-select": "^5.0.1", diff --git a/spec/controllers/api/v1/circles/statuses_controller_spec.rb b/spec/controllers/api/v1/circles/statuses_controller_spec.rb new file mode 100644 index 0000000000..2a323aa0f8 --- /dev/null +++ b/spec/controllers/api/v1/circles/statuses_controller_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::V1::Circles::StatusesController do + render_views + + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:lists') } + let(:circle) { Fabricate(:circle, account: user.account) } + let(:status) { Fabricate(:status, account: user.account, visibility: 'limited', limited_scope: 'circle') } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + Fabricate(:circle_status, status: status, circle: circle) + other_circle = Fabricate(:circle) + Fabricate(:circle_status, status: Fabricate(:status, visibility: 'limited', limited_scope: 'circle', account: other_circle.account), circle: other_circle) + end + + describe 'GET #index' do + it 'returns http success' do + get :show, params: { circle_id: circle.id, limit: 5 } + + expect(response).to have_http_status(200) + json = body_as_json + expect(json.map { |item| item[:id].to_i }).to eq [status.id] + end + + context "with someone else's statuses" do + let(:other_account) { Fabricate(:account) } + let(:other_circle) { Fabricate(:circle, account: other_account) } + + before do + Fabricate(:circle_status, circle: other_circle, status: Fabricate(:status, account: other_account, visibility: 'limited', limited_scope: 'circle')) + end + + it 'returns http failed' do + get :show, params: { circle_id: other_circle.id } + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/controllers/api/v1/statuses/mentioned_accounts_controller_spec.rb b/spec/controllers/api/v1/statuses/mentioned_accounts_controller_spec.rb new file mode 100644 index 0000000000..2299791344 --- /dev/null +++ b/spec/controllers/api/v1/statuses/mentioned_accounts_controller_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::Statuses::MentionedAccountsController do + render_views + + let(:user) { Fabricate(:user) } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: app, scopes: 'read:accounts') } + let(:alice) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + let(:ohagi) { Fabricate(:account) } + + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #index' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + Mention.create!(account: bob, status: status) + Mention.create!(account: ohagi, status: status) + end + + it 'returns http success' do + get :index, params: { status_id: status.id, limit: 2 } + expect(response).to have_http_status(200) + expect(response.headers['Link'].links.size).to eq(2) + end + + it 'returns accounts who favorited the status' do + get :index, params: { status_id: status.id, limit: 2 } + expect(body_as_json.size).to eq 2 + expect([body_as_json[0][:id], body_as_json[1][:id]]).to contain_exactly(bob.id.to_s, ohagi.id.to_s) + end + + it 'does not return blocked users' do + user.account.block!(ohagi) + get :index, params: { status_id: status.id, limit: 2 } + expect(body_as_json.size).to eq 1 + expect(body_as_json[0][:id]).to eq bob.id.to_s + end + + context 'when other accounts status' do + let(:status) { Fabricate(:status, account: alice) } + + it 'returns http unauthorized' do + get :index, params: { status_id: status.id } + expect(response).to have_http_status(404) + end + end + end + end + + context 'without an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token).and_return(nil) + end + + context 'with a public status' do + let(:status) { Fabricate(:status, account: user.account, visibility: :public) } + + describe 'GET #index' do + before do + Mention.create!(account: bob, status: status) + end + + it 'returns http unauthorized' do + get :index, params: { status_id: status.id } + expect(response).to have_http_status(404) + end + end + end + end +end diff --git a/spec/fabricators/circle_status_fabricator.rb b/spec/fabricators/circle_status_fabricator.rb new file mode 100644 index 0000000000..649d4f4438 --- /dev/null +++ b/spec/fabricators/circle_status_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:circle_status) do + circle + status + before_create { |circle_status, _| circle_status.status.account = circle.account } +end diff --git a/spec/fabricators/friend_domain_fabricator.rb b/spec/fabricators/friend_domain_fabricator.rb new file mode 100644 index 0000000000..840f79ea3e --- /dev/null +++ b/spec/fabricators/friend_domain_fabricator.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +Fabricator(:friend_domain) do + domain 'example.com' + inbox_url 'https://example.com/inbox' + active_state :idle + passive_state :idle + available true + before_create { |friend_domain, _| friend_domain.inbox_url = "https://#{friend_domain.domain}/inbox" if friend_domain.inbox_url.blank? } +end diff --git a/spec/fabricators/status_reference_fabricator.rb b/spec/fabricators/status_reference_fabricator.rb new file mode 100644 index 0000000000..0eff89c14b --- /dev/null +++ b/spec/fabricators/status_reference_fabricator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Fabricator(:status_reference) do + status { Fabricate.build(:status) } + target_status { Fabricate.build(:status) } + attribute_type 'BT' + quote false +end diff --git a/spec/helpers/languages_helper_spec.rb b/spec/helpers/languages_helper_spec.rb index 98c8064a33..99461b293b 100644 --- a/spec/helpers/languages_helper_spec.rb +++ b/spec/helpers/languages_helper_spec.rb @@ -60,4 +60,30 @@ describe LanguagesHelper do end end end + + describe 'sorted_locales' do + context 'when sorting with native name' do + it 'returns Suomi after Gàidhlig' do + expect(described_class.sorted_locale_keys(%w(fi gd))).to eq(%w(gd fi)) + end + end + + context 'when sorting with diacritics' do + it 'returns Íslensk before Suomi' do + expect(described_class.sorted_locale_keys(%w(fi is))).to eq(%w(is fi)) + end + end + + context 'when sorting with non-Latin' do + it 'returns Suomi before Amharic' do + expect(described_class.sorted_locale_keys(%w(am fi))).to eq(%w(fi am)) + end + end + + context 'when sorting with local variants' do + it 'returns variant in-line' do + expect(described_class.sorted_locale_keys(%w(en eo en-GB))).to eq(%w(en en-GB eo)) + end + end + end end diff --git a/spec/lib/activitypub/activity/accept_spec.rb b/spec/lib/activitypub/activity/accept_spec.rb index d6b6071279..24dbcbff7c 100644 --- a/spec/lib/activitypub/activity/accept_spec.rb +++ b/spec/lib/activitypub/activity/accept_spec.rb @@ -43,6 +43,35 @@ RSpec.describe ActivityPub::Activity::Accept do end end + context 'when sender is from friend server' do + subject { described_class.new(json, sender) } + + let(:sender) { Fabricate(:account, domain: 'abc.com', url: 'https://abc.com/#actor') } + let!(:friend) { Fabricate(:friend_domain, domain: 'abc.com', active_state: :pending, active_follow_activity_id: 'https://abc-123/456') } + + before do + allow(RemoteAccountRefreshWorker).to receive(:perform_async) + Fabricate(:follow_request, account: recipient, target_account: sender) + subject.perform + end + + it 'creates a follow relationship' do + expect(recipient.following?(sender)).to be true + end + + it 'removes the follow request' do + expect(recipient.requested?(sender)).to be false + end + + it 'queues a refresh' do + expect(RemoteAccountRefreshWorker).to have_received(:perform_async).with(sender.id) + end + + it 'friend server is not changed' do + expect(friend.reload.i_am_pending?).to be true + end + end + context 'when given a relay' do subject { described_class.new(json, sender) } @@ -68,4 +97,47 @@ RSpec.describe ActivityPub::Activity::Accept do expect(relay.reload.accepted?).to be true end end + + context 'when given a friend server' do + subject { described_class.new(json, sender) } + + let(:sender) { Fabricate(:account, domain: 'abc.com', url: 'https://abc.com/#actor') } + let!(:friend) { Fabricate(:friend_domain, domain: 'abc.com', active_state: :pending, active_follow_activity_id: 'https://abc-123/456') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Accept', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: 'https://abc-123/456', + }.with_indifferent_access + end + + it 'marks the friend as accepted' do + subject.perform + expect(friend.reload.i_am_accepted?).to be true + end + + it 'when the friend server is pending' do + friend.update(passive_state: :pending) + subject.perform + expect(friend.reload.they_are_idle?).to be true + expect(friend.i_am_accepted?).to be true + end + + it 'when the friend server is accepted' do + friend.update(passive_state: :accepted) + subject.perform + expect(friend.reload.they_are_idle?).to be true + expect(friend.i_am_accepted?).to be true + end + + it 'when my server is not pending' do + friend.update(active_state: :idle) + subject.perform + expect(friend.reload.i_am_idle?).to be true + expect(friend.they_are_idle?).to be true + end + end end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index bb41e3065a..7659ed82a0 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -30,9 +30,11 @@ RSpec.describe ActivityPub::Activity::Create do let(:sender_software) { 'mastodon' } let(:custom_before) { false } + let(:active_friend) { false } before do Fabricate(:instance_info, domain: 'example.com', software: sender_software) + Fabricate(:friend_domain, domain: 'example.com', active_state: :accepted) if active_friend subject.perform unless custom_before end @@ -234,6 +236,45 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'when public_unlisted with kmyblue:LocalPublic' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: ['http://example.com/followers', 'kmyblue:LocalPublic'], + cc: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'unlisted' + end + end + + context 'when public_unlisted with kmyblue:LocalPublic from friend-server' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: ['http://example.com/followers', 'kmyblue:LocalPublic'], + cc: 'https://www.w3.org/ns/activitystreams#Public', + } + end + let(:active_friend) { true } + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'public_unlisted' + end + end + context 'when private' do let(:object_json) do { @@ -411,6 +452,29 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'with public_unlisted with kmyblue:LocalPublic' do + let(:searchable_by) { ['http://example.com/followers', 'kmyblue:LocalPublic'] } + + it 'create status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.searchability).to eq 'private' + end + end + + context 'with public_unlisted with kmyblue:LocalPublic from friend-server' do + let(:searchable_by) { ['http://example.com/followers', 'kmyblue:LocalPublic'] } + let(:active_friend) { true } + + it 'create status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.searchability).to eq 'public_unlisted' + end + end + context 'with private' do let(:searchable_by) { 'http://example.com/followers' } @@ -1099,6 +1163,177 @@ RSpec.describe ActivityPub::Activity::Create do expect(poll.votes.first).to be_nil end end + + context 'with references' do + let(:recipient) { Fabricate(:account) } + let!(:target_status) { Fabricate(:status, account: Fabricate(:account, domain: nil)) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + references: { + id: 'target_status', + type: 'Collection', + first: { + type: 'CollectionPage', + next: nil, + partOf: 'target_status', + items: [ + ActivityPub::TagManager.instance.uri_for(target_status), + ], + }, + }, + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.quote).to be_nil + expect(status.references.pluck(:id)).to eq [target_status.id] + end + end + + context 'with quote' do + let(:recipient) { Fabricate(:account) } + let!(:target_status) { Fabricate(:status, account: Fabricate(:account, domain: nil)) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + quote: ActivityPub::TagManager.instance.uri_for(target_status), + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.references.pluck(:id)).to eq [target_status.id] + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + end + end + + context 'with references and quote' do + let(:recipient) { Fabricate(:account) } + let!(:target_status) { Fabricate(:status, account: Fabricate(:account, domain: nil)) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + quote: ActivityPub::TagManager.instance.uri_for(target_status), + references: { + id: 'target_status', + type: 'Collection', + first: { + type: 'CollectionPage', + next: nil, + partOf: 'target_status', + items: [ + ActivityPub::TagManager.instance.uri_for(target_status), + ], + }, + }, + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.references.pluck(:id)).to eq [target_status.id] + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + end + end + + context 'with language' do + let(:to) { 'https://www.w3.org/ns/activitystreams#Public' } + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: to, + contentMap: { ja: 'Lorem ipsum' }, + } + end + + it 'create status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.language).to eq 'ja' + end + end + + context 'without language' do + let(:to) { 'https://www.w3.org/ns/activitystreams#Public' } + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: to, + } + end + + it 'create status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.language).to be_nil + end + end + + context 'without language when misskey server' do + let(:sender_software) { 'misskey' } + let(:to) { 'https://www.w3.org/ns/activitystreams#Public' } + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: to, + } + end + + it 'create status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.language).to eq 'ja' + end + end + + context 'with language when misskey server' do + let(:sender_software) { 'misskey' } + let(:to) { 'https://www.w3.org/ns/activitystreams#Public' } + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: to, + contentMap: { 'en-US': 'Lorem ipsum' }, + } + end + + it 'create status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.language).to eq 'en-US' + end + end end context 'with an encrypted message' do @@ -1205,6 +1440,53 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'when sender quotes to local status' do + subject { described_class.new(json, sender, delivery: true) } + + let!(:local_status) { Fabricate(:status) } + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + quote: ActivityPub::TagManager.instance.uri_for(local_status), + } + end + + before do + subject.perform + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + end + + context 'when sender quotes to non-local status' do + subject { described_class.new(json, sender, delivery: true) } + + let!(:remote_status) { Fabricate(:status, uri: 'https://foo.bar/among', account: Fabricate(:account, domain: 'foo.bar', uri: 'https://foo.bar/account')) } + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + quote: ActivityPub::TagManager.instance.uri_for(remote_status), + } + end + + before do + subject.perform + end + + it 'creates status' do + expect(sender.statuses.count).to eq 0 + end + end + context 'when sender targets a local user' do subject { described_class.new(json, sender, delivery: true) } @@ -1255,6 +1537,35 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'when sender is in friend server' do + subject { described_class.new(json, sender, delivery: true) } + + let!(:friend) { Fabricate(:friend_domain, domain: sender.domain, active_state: :accepted) } + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + } + end + + it 'creates status' do + subject.perform + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + + it 'whey no-relay not creates status' do + friend.update(allow_all_posts: false) + subject.perform + status = sender.statuses.first + + expect(status).to be_nil + end + end + context 'when the sender has no relevance to local activity' do subject { described_class.new(json, sender, delivery: true) } diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb index 3a73b3726c..f0c957c8a1 100644 --- a/spec/lib/activitypub/activity/delete_spec.rb +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -73,4 +73,30 @@ RSpec.describe ActivityPub::Activity::Delete do end end end + + context 'when given a friend server' do + subject { described_class.new(json, sender) } + + before do + Fabricate(:friend_domain, domain: 'abc.com', inbox_url: 'https://abc.com/inbox', passive_state: :accepted) + stub_request(:post, 'https://abc.com/inbox') + end + + let(:sender) { Fabricate(:account, domain: 'abc.com', url: 'https://abc.com/#actor') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Delete', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: 'https://www.w3.org/ns/activitystreams#Public', + }.with_indifferent_access + end + + it 'marks the friend as deleted' do + subject.perform + expect(FriendDomain.find_by(domain: 'abc.com')).to be_nil + end + end end diff --git a/spec/lib/activitypub/activity/follow_spec.rb b/spec/lib/activitypub/activity/follow_spec.rb index 890ebe2750..53ce9a29a6 100644 --- a/spec/lib/activitypub/activity/follow_spec.rb +++ b/spec/lib/activitypub/activity/follow_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Follow do let(:actor_type) { 'Person' } - let(:sender) { Fabricate(:account, domain: 'example.com', inbox_url: 'https://example.com/inbox', actor_type: actor_type) } + let(:display_name) { '' } + let(:sender) { Fabricate(:account, domain: 'example.com', inbox_url: 'https://example.com/inbox', actor_type: actor_type, display_name: display_name) } let(:recipient) { Fabricate(:account) } let(:json) do @@ -36,6 +37,23 @@ RSpec.describe ActivityPub::Activity::Follow do end end + context 'with an unlocked account from friend server' do + let!(:friend) { Fabricate(:friend_domain, domain: sender.domain, passive_state: :idle) } + + before do + subject.perform + end + + it 'creates a follow from sender to recipient' do + expect(sender.following?(recipient)).to be true + expect(sender.active_relationships.find_by(target_account: recipient).uri).to eq 'foo' + end + + it 'does not change friend server passive status' do + expect(friend.they_are_idle?).to be true + end + end + context 'when silenced account following an unlocked account' do before do sender.touch(:silenced_at) @@ -103,6 +121,54 @@ RSpec.describe ActivityPub::Activity::Follow do end end + context 'when unlocked misskey proxy account but locked from bot' do + let(:display_name) { 'i am proxy.' } + + before do + Fabricate(:instance_info, domain: 'example.com', software: 'misskey') + recipient.user.settings['lock_follow_from_bot'] = true + recipient.user.save! + subject.perform + end + + it 'does not create a follow from sender to recipient' do + expect(sender.following?(recipient)).to be false + end + + it 'creates a follow request' do + expect(sender.requested?(recipient)).to be true + expect(sender.follow_requests.find_by(target_account: recipient).uri).to eq 'foo' + end + end + + context 'when unlocked mastodon proxy account but locked from bot' do + let(:display_name) { 'i am proxy.' } + + before do + Fabricate(:instance_info, domain: 'example.com', software: 'mastodon') + recipient.user.settings['lock_follow_from_bot'] = true + recipient.user.save! + subject.perform + end + + it 'does not create a follow from sender to recipient' do + expect(sender.following?(recipient)).to be true + end + end + + context 'when unlocked misskey normal account but locked from bot' do + before do + Fabricate(:instance_info, domain: 'example.com', software: 'misskey') + recipient.user.settings['lock_follow_from_bot'] = true + recipient.user.save! + subject.perform + end + + it 'does not create a follow from sender to recipient' do + expect(sender.following?(recipient)).to be true + end + end + context 'when domain block reject_straight_follow' do before do Fabricate(:domain_block, domain: 'example.com', reject_straight_follow: true) @@ -236,4 +302,179 @@ RSpec.describe ActivityPub::Activity::Follow do end end end + + context 'when given a friend server' do + subject { described_class.new(json, sender) } + + let(:sender) { Fabricate(:account, domain: 'abc.com', url: 'https://abc.com/#actor') } + let!(:friend) { Fabricate(:friend_domain, domain: 'abc.com', passive_state: :idle) } + let!(:owner_user) { Fabricate(:user, role: UserRole.find_by(name: 'Owner')) } + let!(:patch_user) { Fabricate(:user, role: Fabricate(:user_role, name: 'OhagiOps', permissions: UserRole::FLAGS[:manage_federation])) } + let(:inbox_url) { nil } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: 'https://www.w3.org/ns/activitystreams#Public', + inboxUrl: inbox_url, + }.with_indifferent_access + end + + it 'marks the friend as pending' do + subject.perform + expect(friend.reload.they_are_pending?).to be true + expect(friend.passive_follow_activity_id).to eq 'foo' + end + + context 'when no record' do + before do + friend.update(domain: 'def.com') + end + + it 'marks the friend as pending' do + subject.perform + + friend = FriendDomain.find_by(domain: 'abc.com') + expect(friend).to_not be_nil + expect(friend.they_are_pending?).to be true + expect(friend.passive_follow_activity_id).to eq 'foo' + expect(friend.inbox_url).to eq 'https://abc.com/inbox' + end + end + + context 'when no record and inbox_url is specified' do + let(:inbox_url) { 'https://ohagi.com/inbox' } + + before do + friend.update(domain: 'def.com') + end + + it 'marks the friend as pending' do + subject.perform + + friend = FriendDomain.find_by(domain: 'abc.com') + expect(friend).to_not be_nil + expect(friend.they_are_pending?).to be true + expect(friend.passive_follow_activity_id).to eq 'foo' + expect(friend.inbox_url).to eq 'https://ohagi.com/inbox' + end + end + + context 'when my server is pending' do + before do + friend.update(active_state: :pending) + end + + it 'marks me as idle' do + subject.perform + expect(friend.reload.they_are_pending?).to be true + expect(friend.i_am_idle?).to be true + end + end + + context 'when my server is already accepted' do + before do + friend.update(active_state: :accepted) + stub_request(:post, 'https://example.com/inbox') + end + + it 'marks me as idle and the friend as accepted' do + subject.perform + expect(friend.reload.they_are_accepted?).to be true + expect(friend.i_am_idle?).to be true + expect(a_request(:post, 'https://example.com/inbox').with(body: hash_including({ + id: 'foo#accepts/friends', + type: 'Accept', + object: 'foo', + }))).to have_been_made.once + end + end + + context 'with sending email' do + around do |example| + queue_adapter = ActiveJob::Base.queue_adapter + ActiveJob::Base.queue_adapter = :test + + example.run + + ActiveJob::Base.queue_adapter = queue_adapter + end + + it 'perform' do + expect { subject.perform }.to have_enqueued_mail(AdminMailer, :new_pending_friend_server) + .with(hash_including(params: { recipient: owner_user.account })).once + .and(have_enqueued_mail(AdminMailer, :new_pending_friend_server).with(hash_including(params: { recipient: patch_user.account })).once) + .and(have_enqueued_mail.at_most(2)) + end + end + + context 'when after rejected' do + before do + friend.update(passive_state: :rejected) + end + + it 'marks the friend as pending' do + subject.perform + expect(friend.reload.they_are_pending?).to be true + expect(friend.passive_follow_activity_id).to eq 'foo' + end + end + + context 'when unlocked on admin settings' do + before do + Form::AdminSettings.new(unlocked_friend: '1').save + stub_request(:post, 'https://example.com/inbox') + end + + it 'marks the friend as accepted' do + subject.perform + + friend = FriendDomain.find_by(domain: 'abc.com') + expect(friend).to_not be_nil + expect(friend.they_are_accepted?).to be true + expect(a_request(:post, 'https://example.com/inbox').with(body: hash_including({ + id: 'foo#accepts/friends', + type: 'Accept', + object: 'foo', + }))).to have_been_made.once + end + end + + context 'when already accepted' do + before do + friend.update(passive_state: :accepted) + stub_request(:post, 'https://example.com/inbox') + end + + it 'marks the friend as accepted' do + subject.perform + + friend = FriendDomain.find_by(domain: 'abc.com') + expect(friend).to_not be_nil + expect(friend.they_are_accepted?).to be true + expect(a_request(:post, 'https://example.com/inbox').with(body: hash_including({ + id: 'foo#accepts/friends', + type: 'Accept', + object: 'foo', + }))).to have_been_made.once + end + end + + context 'when domain blocked' do + before do + friend.update(domain: 'def.com') + end + + it 'marks the friend rejected' do + Fabricate(:domain_block, domain: 'abc.com', reject_friend: true) + subject.perform + + friend = FriendDomain.find_by(domain: 'abc.com') + expect(friend).to be_nil + end + end + end end diff --git a/spec/lib/activitypub/activity/reject_spec.rb b/spec/lib/activitypub/activity/reject_spec.rb index 0a4243cd16..9ebbded42e 100644 --- a/spec/lib/activitypub/activity/reject_spec.rb +++ b/spec/lib/activitypub/activity/reject_spec.rb @@ -122,6 +122,30 @@ RSpec.describe ActivityPub::Activity::Reject do end end + context 'when sender is from friend server' do + subject { described_class.new(json, sender) } + + let(:sender) { Fabricate(:account, domain: 'abc.com', url: 'https://abc.com/#actor') } + let!(:friend) { Fabricate(:friend_domain, domain: 'abc.com', active_state: :pending, active_follow_activity_id: 'https://abc-123/456') } + + before do + Fabricate(:follow_request, account: recipient, target_account: sender) + subject.perform + end + + it 'does not create a follow relationship' do + expect(recipient.following?(sender)).to be false + end + + it 'removes the follow request' do + expect(recipient.requested?(sender)).to be false + end + + it 'friend server is not changed' do + expect(friend.reload.i_am_pending?).to be true + end + end + context 'when given a relay' do subject { described_class.new(json, sender) } @@ -147,4 +171,40 @@ RSpec.describe ActivityPub::Activity::Reject do expect(relay.reload.rejected?).to be true end end + + context 'when given a friend' do + subject { described_class.new(json, sender) } + + let(:sender) { Fabricate(:account, domain: 'abc.com', url: 'https://abc.com/#actor') } + let!(:friend) { Fabricate(:friend_domain, domain: 'abc.com', active_state: :pending, active_follow_activity_id: 'https://abc-123/456') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Reject', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: 'https://abc-123/456', + }.with_indifferent_access + end + + it 'marks the friend as rejected' do + subject.perform + expect(friend.reload.i_am_rejected?).to be true + end + + it 'when the friend server is pending' do + friend.update(passive_state: :pending) + subject.perform + expect(friend.reload.they_are_idle?).to be true + expect(friend.i_am_rejected?).to be true + end + + it 'when the friend server is accepted' do + friend.update(passive_state: :accepted) + subject.perform + expect(friend.reload.they_are_idle?).to be true + expect(friend.i_am_rejected?).to be true + end + end end diff --git a/spec/lib/activitypub/activity/undo_spec.rb b/spec/lib/activitypub/activity/undo_spec.rb index 58e71fc4e8..feda725654 100644 --- a/spec/lib/activitypub/activity/undo_spec.rb +++ b/spec/lib/activitypub/activity/undo_spec.rb @@ -145,6 +145,13 @@ RSpec.describe ActivityPub::Activity::Undo do expect(sender.following?(recipient)).to be false end + it 'deletes follow from sender to recipient when has friend' do + friend = Fabricate(:friend_domain, domain: sender.domain, passive_state: :accepted) + subject.perform + expect(sender.following?(recipient)).to be false + expect(friend.reload.they_are_accepted?).to be true + end + context 'with only object uri' do let(:object_json) { 'bar' } @@ -153,6 +160,36 @@ RSpec.describe ActivityPub::Activity::Undo do expect(sender.following?(recipient)).to be false end end + + context 'when for a friend' do + let(:sender) { Fabricate(:account, domain: 'abc.com', url: 'https://abc.com/#actor') } + let!(:friend) { Fabricate(:friend_domain, domain: 'abc.com', passive_state: :accepted, passive_follow_activity_id: 'bar') } + let(:object_json) do + { + id: 'bar', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'deletes follow from this server to friend' do + subject.perform + expect(FriendDomain.exists?(domain: 'abc.com')).to be false + end + + it 'when my server is pending' do + friend.update(active_state: :pending) + subject.perform + expect(FriendDomain.exists?(domain: 'abc.com')).to be false + end + + it 'when my server is accepted' do + friend.update(active_state: :accepted) + subject.perform + expect(FriendDomain.exists?(domain: 'abc.com')).to be false + end + end end context 'with Like' do @@ -176,5 +213,32 @@ RSpec.describe ActivityPub::Activity::Undo do expect(sender.favourited?(status)).to be false end end + + context 'with EmojiReact' do + let(:status) { Fabricate(:status) } + + let(:content) { '😀' } + let(:object_json) do + { + id: 'bar', + type: 'Like', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + content: content, + } + end + + before do + Fabricate(:favourite, account: sender, status: status) + Fabricate(:emoji_reaction, account: sender, status: status, name: content) + end + + it 'delete emoji reaction' do + subject.perform + reaction = EmojiReaction.find_by(account: sender, status: status) + expect(reaction).to be_nil + expect(sender.favourited?(status)).to be true + end + end end end diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index 2bff125a6a..9878952f05 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -27,6 +27,11 @@ RSpec.describe ActivityPub::TagManager do expect(subject.to(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] end + it 'returns followers collection for public_unlisted status' do + status = Fabricate(:status, visibility: :public_unlisted) + expect(subject.to(status)).to eq [account_followers_url(status.account)] + end + it 'returns followers collection for unlisted status' do status = Fabricate(:status, visibility: :unlisted) expect(subject.to(status)).to eq [account_followers_url(status.account)] @@ -69,12 +74,34 @@ RSpec.describe ActivityPub::TagManager do end end + describe '#to_for_friend' do + it 'returns followers collection for public_unlisted status' do + status = Fabricate(:status, visibility: :public_unlisted) + expect(subject.to_for_friend(status)).to eq [account_followers_url(status.account), 'kmyblue:LocalPublic'] + end + + it 'returns followers collection for unlisted status' do + status = Fabricate(:status, visibility: :unlisted) + expect(subject.to_for_friend(status)).to eq [account_followers_url(status.account)] + end + + it 'returns followers collection for private status' do + status = Fabricate(:status, visibility: :private) + expect(subject.to_for_friend(status)).to eq [account_followers_url(status.account)] + end + end + describe '#cc' do it 'returns followers collection for public status' do status = Fabricate(:status, visibility: :public) expect(subject.cc(status)).to eq [account_followers_url(status.account)] end + it 'returns public collection for public_unlisted status' do + status = Fabricate(:status, visibility: :public_unlisted) + expect(subject.cc(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] + end + it 'returns public collection for unlisted status' do status = Fabricate(:status, visibility: :unlisted) expect(subject.cc(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] @@ -114,6 +141,74 @@ RSpec.describe ActivityPub::TagManager do end end + describe '#cc_for_misskey' do + let(:user) { Fabricate(:user) } + + before do + user.settings.update(reject_unlisted_subscription: true, reject_public_unlisted_subscription: true) + user.save + end + + it 'returns public collection for public status' do + status = Fabricate(:status, visibility: :public) + expect(subject.cc_for_misskey(status)).to eq [account_followers_url(status.account)] + end + + it 'returns empty array for public_unlisted status' do + status = Fabricate(:status, account: user.account, visibility: :public_unlisted) + expect(subject.cc_for_misskey(status)).to eq [] + end + + it 'returns empty array for unlisted status' do + status = Fabricate(:status, account: user.account, visibility: :unlisted) + expect(subject.cc_for_misskey(status)).to eq [] + end + end + + describe '#searchable_by' do + it 'returns public collection for public status' do + status = Fabricate(:status, searchability: :public) + expect(subject.searchable_by(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] + end + + it 'returns followers collection for public_unlisted status' do + status = Fabricate(:status, searchability: :public_unlisted) + expect(subject.searchable_by(status)).to eq [account_followers_url(status.account)] + end + + it 'returns followers collection for private status' do + status = Fabricate(:status, searchability: :private) + expect(subject.searchable_by(status)).to eq [account_followers_url(status.account)] + end + + it 'returns empty array for direct status' do + status = Fabricate(:status, searchability: :direct) + expect(subject.searchable_by(status)).to eq [] + end + + it 'returns as:Limited array for limited status' do + status = Fabricate(:status, searchability: :limited) + expect(subject.searchable_by(status)).to eq ['as:Limited', 'kmyblue:Limited'] + end + end + + describe '#searchable_by_for_friend' do + it 'returns public collection for public status' do + status = Fabricate(:status, account: Fabricate(:account, searchability: :public), searchability: :public) + expect(subject.searchable_by_for_friend(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] + end + + it 'returns public collection for public_unlisted status' do + status = Fabricate(:status, account: Fabricate(:account, searchability: :public), searchability: :public_unlisted) + expect(subject.searchable_by_for_friend(status)).to eq [account_followers_url(status.account), 'kmyblue:LocalPublic'] + end + + it 'returns followers collection for private status' do + status = Fabricate(:status, account: Fabricate(:account, searchability: :public), searchability: :private) + expect(subject.searchable_by_for_friend(status)).to eq [account_followers_url(status.account)] + end + end + describe '#local_uri?' do it 'returns false for non-local URI' do expect(subject.local_uri?('http://example.com/123')).to be false diff --git a/spec/lib/cache_buster_spec.rb b/spec/lib/cache_buster_spec.rb index 84085608e8..3dc62a8154 100644 --- a/spec/lib/cache_buster_spec.rb +++ b/spec/lib/cache_buster_spec.rb @@ -28,6 +28,14 @@ describe CacheBuster do end context 'when using default options' do + around do |example| + # Disables the CacheBuster.new deprecation warning about default arguments. + # Remove this `silence` block when default arg support is removed from CacheBuster + ActiveSupport::Deprecation.silence do + example.run + end + end + include_examples 'makes_request' end diff --git a/spec/lib/status_reach_finder_spec.rb b/spec/lib/status_reach_finder_spec.rb index 57946d3a70..8b333de0c4 100644 --- a/spec/lib/status_reach_finder_spec.rb +++ b/spec/lib/status_reach_finder_spec.rb @@ -8,9 +8,11 @@ describe StatusReachFinder do subject { described_class.new(status) } let(:parent_status) { nil } + let(:quoted_status) { nil } let(:visibility) { :public } + let(:searchability) { :public } let(:alice) { Fabricate(:account, username: 'alice') } - let(:status) { Fabricate(:status, account: alice, thread: parent_status, visibility: visibility) } + let(:status) { Fabricate(:status, account: alice, thread: parent_status, quote_of_id: quoted_status&.id, visibility: visibility, searchability: searchability) } context 'with a simple case' do let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } @@ -22,12 +24,14 @@ describe StatusReachFinder do it 'send status' do expect(subject.inboxes).to include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to_not include 'https://foo.bar/inbox' end end context 'with non-follower' do it 'send status' do expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to_not include 'https://foo.bar/inbox' end end end @@ -49,8 +53,9 @@ describe StatusReachFinder do end end - context 'when misskey' do + context 'when misskey with private searchability' do let(:sender_software) { 'misskey' } + let(:searchability) { :private } it 'send status without setting' do expect(subject.inboxes).to include 'https://foo.bar/inbox' @@ -63,6 +68,201 @@ describe StatusReachFinder do expect(subject.inboxes_for_misskey).to include 'https://foo.bar/inbox' end end + + context 'when misskey with public_unlisted searchability' do + let(:sender_software) { 'misskey' } + let(:searchability) { :public_unlisted } + + it 'send status without setting' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + expect(subject.inboxes_for_misskey).to_not include 'https://foo.bar/inbox' + end + + it 'send status with setting' do + alice.user.settings.update(reject_unlisted_subscription: 'true') + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_misskey).to include 'https://foo.bar/inbox' + end + end + + context 'when misskey with public searchability' do + let(:sender_software) { 'misskey' } + + it 'send status with setting' do + alice.user.settings.update(reject_unlisted_subscription: 'true') + expect(subject.inboxes).to include 'https://foo.bar/inbox' + expect(subject.inboxes_for_misskey).to_not include 'https://foo.bar/inbox' + end + end + + context 'when has distributable friend server' do + let(:sender_software) { 'misskey' } + let(:searchability) { :public } + + before { Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', available: true, active_state: :accepted, pseudo_relay: true) } + + it 'send status without friend server' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_misskey).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to include 'https://foo.bar/inbox' + end + end + end + + context 'when this server has a friend' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } + + context 'with follower' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', active_state: :accepted) + bob.follow!(alice) + end + + it 'send status' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to include 'https://foo.bar/inbox' + end + end + + context 'with follower but not local-distributable' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', active_state: :accepted, delivery_local: false) + bob.follow!(alice) + end + + it 'send status' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to_not include 'https://foo.bar/inbox' + end + end + + context 'with non-follower and non-relay' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', active_state: :accepted) + end + + it 'send status' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to_not include 'https://foo.bar/inbox' + end + end + + context 'with pending' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', active_state: :pending) + bob.follow!(alice) + end + + it 'send status' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to_not include 'https://foo.bar/inbox' + end + end + + context 'with unidirection from them' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', active_state: :idle, passive_state: :accepted) + bob.follow!(alice) + end + + it 'send status' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to include 'https://foo.bar/inbox' + end + end + + context 'when unavailable' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', active_state: :accepted, available: false) + bob.follow!(alice) + end + + it 'send status' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to_not include 'https://foo.bar/inbox' + end + end + + context 'when distributable' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', passive_state: :accepted, pseudo_relay: true) + end + + it 'send status' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to include 'https://foo.bar/inbox' + end + end + + context 'when distributable and following' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', passive_state: :accepted, pseudo_relay: true) + bob.follow!(alice) + end + + it 'send status' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to include 'https://foo.bar/inbox' + end + end + + context 'when distributable reverse' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', active_state: :accepted, pseudo_relay: true) + end + + it 'send status' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to include 'https://foo.bar/inbox' + end + end + + context 'when distributable but not local distributable' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', passive_state: :accepted, pseudo_relay: true, delivery_local: false) + end + + it 'send status' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to_not include 'https://foo.bar/inbox' + end + end + + context 'when distributable and following but not local distributable' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', passive_state: :accepted, pseudo_relay: true, delivery_local: false) + bob.follow!(alice) + end + + it 'send status' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to_not include 'https://foo.bar/inbox' + end + end + + context 'when distributable but domain blocked by account' do + before do + Fabricate(:account_domain_block, account: alice, domain: 'foo.bar') + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', passive_state: :accepted, pseudo_relay: true) + end + + it 'send status' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to_not include 'https://foo.bar/inbox' + end + end + end + + context 'when it contains distributable friend server' do + before do + Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox', passive_state: :accepted, pseudo_relay: true) + end + + it 'includes the inbox of the mentioned account' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_misskey).to_not include 'https://foo.bar/inbox' + expect(subject.inboxes_for_friend).to include 'https://foo.bar/inbox' + end end context 'when it contains mentions of remote accounts' do @@ -153,6 +353,15 @@ describe StatusReachFinder do end end end + + context 'when it is a quote to a remote account' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } + let(:quoted_status) { Fabricate(:status, account: bob) } + + it 'includes the inbox of the quoted-to account' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + end + end end context 'with extended domain block' do @@ -217,4 +426,106 @@ describe StatusReachFinder do end end end + + describe '#inboxes_for_friend and distributables' do + subject { described_class.new(status).inboxes_for_friend } + + let(:visibility) { :public } + let(:searchability) { :public } + let(:alice) { Fabricate(:account, username: 'alice') } + let(:status) { Fabricate(:status, account: alice, visibility: visibility, searchability: searchability) } + + context 'when a simple case' do + before do + Fabricate(:friend_domain, domain: 'abc.com', inbox_url: 'https://abc.com/inbox', active_state: :accepted, passive_state: :accepted, pseudo_relay: true, available: true) + Fabricate(:friend_domain, domain: 'def.com', inbox_url: 'https://def.com/inbox', active_state: :accepted, passive_state: :accepted, pseudo_relay: true, available: true) + Fabricate(:friend_domain, domain: 'ghi.com', inbox_url: 'https://ghi.com/inbox', active_state: :accepted, passive_state: :accepted, pseudo_relay: true, available: false) + Fabricate(:friend_domain, domain: 'jkl.com', inbox_url: 'https://jkl.com/inbox', active_state: :accepted, passive_state: :accepted, pseudo_relay: false, available: true) + Fabricate(:friend_domain, domain: 'mno.com', inbox_url: 'https://mno.com/inbox', active_state: :accepted, passive_state: :idle, pseudo_relay: true, available: true) + Fabricate(:friend_domain, domain: 'pqr.com', inbox_url: 'https://pqr.com/inbox', active_state: :accepted, passive_state: :accepted, pseudo_relay: true, available: true) + Fabricate(:unavailable_domain, domain: 'pqr.com') + Fabricate(:friend_domain, domain: 'stu.com', inbox_url: 'https://stu.com/inbox', active_state: :idle, passive_state: :accepted, pseudo_relay: true, available: true) + Fabricate(:friend_domain, domain: 'vwx.com', inbox_url: 'https://vwx.com/inbox', active_state: :idle, passive_state: :accepted, pseudo_relay: true, available: true, delivery_local: false) + end + + it 'returns friend servers' do + expect(subject).to include 'https://abc.com/inbox' + expect(subject).to include 'https://def.com/inbox' + end + + it 'not contains unavailable friends' do + expect(subject).to_not include 'https://ghi.com/inbox' + end + + it 'not contains no-relay friends' do + expect(subject).to_not include 'https://jkl.com/inbox' + end + + it 'contains no-mutual friends' do + expect(subject).to include 'https://mno.com/inbox' + expect(subject).to include 'https://stu.com/inbox' + end + + it 'not contains un local distable' do + expect(subject).to_not include 'https://vwx.com/inbox' + end + + it 'not contains unavailable domain friends' do + expect(subject).to_not include 'https://pqr.com/inbox' + end + + context 'when public visibility' do + let(:visibility) { :public } + let(:searchability) { :direct } + + it 'returns friend servers' do + expect(subject).to_not eq [] + end + end + + context 'when public_unlsited visibility' do + let(:visibility) { :public_unlisted } + let(:searchability) { :direct } + + it 'returns friend servers' do + expect(subject).to_not eq [] + end + end + + context 'when unlsited visibility with public searchability' do + let(:visibility) { :unlisted } + let(:searchability) { :public } + + it 'returns friend servers' do + expect(subject).to_not eq [] + end + end + + context 'when unlsited visibility with public_unlisted searchability' do + let(:visibility) { :unlisted } + let(:searchability) { :public_unlisted } + + it 'returns friend servers' do + expect(subject).to_not eq [] + end + end + + context 'when unlsited visibility with private searchability' do + let(:visibility) { :unlisted } + let(:searchability) { :private } + + it 'returns empty servers' do + expect(subject).to eq [] + end + end + + context 'when private visibility' do + let(:visibility) { :private } + + it 'returns friend servers' do + expect(subject).to eq [] + end + end + end + end end diff --git a/spec/mailers/admin_mailer_spec.rb b/spec/mailers/admin_mailer_spec.rb index 423dce88ab..23b99a68cc 100644 --- a/spec/mailers/admin_mailer_spec.rb +++ b/spec/mailers/admin_mailer_spec.rb @@ -64,6 +64,26 @@ RSpec.describe AdminMailer do end end + describe '.new_pending_friend_server' do + let(:recipient) { Fabricate(:account, username: 'Barklums') } + let(:friend) { Fabricate(:friend_domain, passive_state: :pending, domain: 'abc.com') } + let(:mail) { described_class.with(recipient: recipient).new_pending_friend_server(friend) } + + before do + recipient.user.update(locale: :en) + end + + it 'renders the headers' do + expect(mail.subject).to eq('New friend server up for review on cb6e6126.ngrok.io (abc.com)') + expect(mail.to).to eq [recipient.user_email] + expect(mail.from).to eq ['notifications@localhost'] + end + + it 'renders the body' do + expect(mail.body.encoded).to match 'The new friend server abc.com is waiting for your review. You can approve or reject this application.' + end + end + describe '.new_trends' do let(:recipient) { Fabricate(:account, username: 'Snurf') } let(:links) { [] } diff --git a/spec/mailers/previews/admin_mailer_preview.rb b/spec/mailers/previews/admin_mailer_preview.rb index bc8f0193b9..7ba6f08239 100644 --- a/spec/mailers/previews/admin_mailer_preview.rb +++ b/spec/mailers/previews/admin_mailer_preview.rb @@ -8,6 +8,11 @@ class AdminMailerPreview < ActionMailer::Preview AdminMailer.with(recipient: Account.first).new_pending_account(User.pending.first) end + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_pending_friend_server + def new_pending_friend_server + AdminMailer.with(recipient: Account.first).new_pending_friend_server(User.pending.first) + end + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends def new_trends AdminMailer.with(recipient: Account.first).new_trends(PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3)) diff --git a/spec/models/concerns/status_threading_concern_spec.rb b/spec/models/concerns/status_threading_concern_spec.rb index 2eac1ca6e5..d0f5179550 100644 --- a/spec/models/concerns/status_threading_concern_spec.rb +++ b/spec/models/concerns/status_threading_concern_spec.rb @@ -129,4 +129,56 @@ describe StatusThreadingConcern do expect(a.descendants(20)).to eq [c, d, e, f] end end + + describe '#readable_references' do + subject { status.readable_references(account).pluck(:id) } + + let(:visibility) { :public } + let(:alice) { Fabricate(:account) } + let(:referred_account) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: account) } + let(:referred_status) { Fabricate(:status, account: referred_account, visibility: visibility) } + let(:referred_follower) { Fabricate(:account) } + let(:follower) { Fabricate(:account) } + let(:third_account) { Fabricate(:account) } + let(:account) { third_account } + + before do + referred_follower.follow!(referred_account) + follower.follow!(alice) + Fabricate(:status_reference, status: status, target_status: referred_status) + end + + it 'with a simple case' do + expect(subject).to include referred_status.id + end + + context 'when private post' do + let(:visibility) { :private } + + context 'with referred post follower' do + let(:account) { referred_follower } + + it 'can show' do + expect(subject).to include referred_status.id + end + end + + context 'with original post follower' do + let(:account) { follower } + + it 'can show' do + expect(subject).to_not include referred_status.id + end + end + + context 'with other account' do + let(:account) { third_account } + + it 'can show' do + expect(subject).to_not include referred_status.id + end + end + end + end end diff --git a/spec/models/friend_domain_spec.rb b/spec/models/friend_domain_spec.rb new file mode 100644 index 0000000000..65d090f9a7 --- /dev/null +++ b/spec/models/friend_domain_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe FriendDomain do + let(:friend) { Fabricate(:friend_domain, domain: 'foo.bar', inbox_url: 'https://foo.bar/inbox') } + + before do + stub_request(:post, 'https://foo.bar/inbox') + end + + describe '#follow!' do + it 'call inbox' do + friend.update(active_state: :accepted, passive_state: :accepted) + friend.follow! + expect(friend.active_follow_activity_id).to_not be_nil + expect(friend.i_am_pending?).to be true + expect(friend.they_are_idle?).to be true + expect(a_request(:post, 'https://foo.bar/inbox').with(body: hash_including({ + id: friend.active_follow_activity_id, + type: 'Follow', + actor: 'https://cb6e6126.ngrok.io/actor', + object: 'https://www.w3.org/ns/activitystreams#Public', + inboxUrl: 'https://cb6e6126.ngrok.io/inbox', + }))).to have_been_made.once + end + end + + describe '#unfollow!' do + it 'call inbox' do + friend.update(active_follow_activity_id: 'ohagi', active_state: :accepted, passive_state: :accepted) + friend.unfollow! + expect(friend.active_follow_activity_id).to be_nil + expect(friend.i_am_idle?).to be true + expect(friend.they_are_idle?).to be true + expect(a_request(:post, 'https://foo.bar/inbox').with(body: hash_including({ + type: 'Undo', + object: { + id: 'ohagi', + type: 'Follow', + actor: 'https://cb6e6126.ngrok.io/actor', + object: 'https://www.w3.org/ns/activitystreams#Public', + }, + }))).to have_been_made.once + end + end + + describe '#accept!' do + it 'call inbox' do + friend.update(passive_follow_activity_id: 'ohagi', active_state: :accepted, passive_state: :pending) + friend.accept! + expect(friend.they_are_accepted?).to be true + expect(friend.i_am_idle?).to be true + expect(a_request(:post, 'https://foo.bar/inbox').with(body: hash_including({ + id: 'ohagi#accepts/friends', + type: 'Accept', + actor: 'https://cb6e6126.ngrok.io/actor', + object: 'ohagi', + }))).to have_been_made.once + end + end + + describe '#reject!' do + it 'call inbox' do + friend.update(passive_follow_activity_id: 'ohagi', active_state: :accepted, passive_state: :pending) + friend.reject! + expect(friend.they_are_rejected?).to be true + expect(friend.i_am_idle?).to be true + expect(a_request(:post, 'https://foo.bar/inbox').with(body: hash_including({ + id: 'ohagi#rejects/friends', + type: 'Reject', + actor: 'https://cb6e6126.ngrok.io/actor', + object: 'ohagi', + }))).to have_been_made.once + end + end + + describe '#delete!' do + it 'call inbox' do + friend.update(active_state: :pending) + friend.destroy + expect(a_request(:post, 'https://foo.bar/inbox').with(body: hash_including({ + type: 'Delete', + actor: 'https://cb6e6126.ngrok.io/actor', + object: 'https://www.w3.org/ns/activitystreams#Public', + }))).to have_been_made.once + end + end +end diff --git a/spec/models/public_feed_spec.rb b/spec/models/public_feed_spec.rb index 965e293c5e..3d13783a50 100644 --- a/spec/models/public_feed_spec.rb +++ b/spec/models/public_feed_spec.rb @@ -89,7 +89,7 @@ RSpec.describe PublicFeed do end it 'excludes public_unlisted statuses' do - expect(subject).to_not include(public_unlisted_status.id) + expect(subject).to include(public_unlisted_status.id) end end @@ -105,7 +105,7 @@ RSpec.describe PublicFeed do end it 'excludes public_unlisted statuses' do - expect(subject).to_not include(public_unlisted_status.id) + expect(subject).to include(public_unlisted_status.id) end end end diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 6462307594..136b301933 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -114,7 +114,7 @@ RSpec.describe Status do end end - describe '#searchability' do + describe '#compute_searchability' do subject { Fabricate(:status, account: account, searchability: status_searchability) } let(:account_searchability) { :public } @@ -137,6 +137,27 @@ RSpec.describe Status do end end + context 'when public-public_unlisted but silenced' do + let(:silenced_at) { Time.now.utc } + let(:status_searchability) { :public_unlisted } + + it 'returns private' do + expect(subject.compute_searchability).to eq 'private' + end + end + + context 'when public-public_unlisted' do + let(:status_searchability) { :public_unlisted } + + it 'returns public' do + expect(subject.compute_searchability).to eq 'public' + end + + it 'returns public_unlisted for local' do + expect(subject.compute_searchability_local).to eq 'public_unlisted' + end + end + context 'when public-private' do let(:status_searchability) { :private } @@ -215,6 +236,57 @@ RSpec.describe Status do expect(subject.compute_searchability).to eq 'public' end end + + context 'when public-public_unlisted of local account' do + let(:account_searchability) { :public } + let(:account_domain) { nil } + let(:status_searchability) { :public_unlisted } + + it 'returns public' do + expect(subject.compute_searchability).to eq 'public' + end + + it 'returns public_unlisted for local' do + expect(subject.compute_searchability_local).to eq 'public_unlisted' + end + + it 'returns private for activitypub' do + expect(subject.compute_searchability_activitypub).to eq 'private' + end + end + end + + describe '#quote' do + let(:target_status) { Fabricate(:status) } + let(:quote) { true } + + before do + Fabricate(:status_reference, status: subject, target_status: target_status, quote: quote) + end + + context 'when quoting single' do + it 'get quote' do + expect(subject.quote).to_not be_nil + expect(subject.quote.id).to eq target_status.id + end + end + + context 'when multiple quotes' do + it 'get quote' do + target2 = Fabricate(:status) + Fabricate(:status_reference, status: subject, quote: quote) + expect(subject.quote).to_not be_nil + expect([target_status.id, target2.id].include?(subject.quote.id)).to be true + end + end + + context 'when no quote but reference' do + let(:quote) { false } + + it 'get quote' do + expect(subject.quote).to be_nil + end + end end describe '#content' do @@ -324,6 +396,38 @@ RSpec.describe Status do end end + describe '.blocks_map' do + subject { described_class.blocks_map([status.account.id], account) } + + let(:status) { Fabricate(:status) } + let(:account) { Fabricate(:account) } + + it 'returns a hash' do + expect(subject).to be_a Hash + end + + it 'contains true value' do + account.block!(status.account) + expect(subject[status.account.id]).to be true + end + end + + describe '.domain_blocks_map' do + subject { described_class.domain_blocks_map([status.account.domain], account) } + + let(:status) { Fabricate(:status, account: Fabricate(:account, domain: 'foo.bar', uri: 'https://foo.bar/status')) } + let(:account) { Fabricate(:account) } + + it 'returns a hash' do + expect(subject).to be_a Hash + end + + it 'contains true value' do + account.block_domain!(status.account.domain) + expect(subject[status.account.domain]).to be true + end + end + describe '.favourites_map' do subject { described_class.favourites_map([status], account) } diff --git a/spec/models/tag_feed_spec.rb b/spec/models/tag_feed_spec.rb index 270797ccd8..5206e7cede 100644 --- a/spec/models/tag_feed_spec.rb +++ b/spec/models/tag_feed_spec.rb @@ -91,18 +91,36 @@ describe TagFeed, type: :service do expect(results).to include status_tagged_with_cats end + it 'unlisted/public_unlisted_searchability post returns' do + status_tagged_with_cats.update(visibility: :unlisted, searchability: :public_unlisted) + results = described_class.new(tag_cats, nil).get(20) + expect(results).to include status_tagged_with_cats + end + it 'unlisted/public_searchability post returns with account' do status_tagged_with_cats.update(visibility: :unlisted, searchability: :public) results = described_class.new(tag_cats, account).get(20) expect(results).to include status_tagged_with_cats end + it 'unlisted/public_unlisted_searchability post returns with account' do + status_tagged_with_cats.update(visibility: :unlisted, searchability: :public_unlisted) + results = described_class.new(tag_cats, account).get(20) + expect(results).to include status_tagged_with_cats + end + it 'private post not returns' do status_tagged_with_cats.update(visibility: :private, searchability: :public) results = described_class.new(tag_cats, nil).get(20) expect(results).to_not include status_tagged_with_cats end + it 'private, public_unlisted post not returns' do + status_tagged_with_cats.update(visibility: :private, searchability: :public_unlisted) + results = described_class.new(tag_cats, nil).get(20) + expect(results).to_not include status_tagged_with_cats + end + it 'private post not returns with account' do status_tagged_with_cats.update(visibility: :private, searchability: :public) results = described_class.new(tag_cats, account).get(20) diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index 271c70804b..3bdc2084d8 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -167,6 +167,48 @@ RSpec.describe StatusPolicy, type: :model do end end + context 'with the permission of emoji_reaction?' do + permissions :emoji_reaction? do + it 'grants access when viewer is not blocked' do + follow = Fabricate(:follow) + status.account = follow.target_account + + expect(subject).to permit(follow.account, status) + end + + it 'denies when viewer is blocked' do + block = Fabricate(:block) + status.account = block.target_account + + expect(subject).to_not permit(block.account, status) + end + end + end + + context 'with the permission of quote?' do + permissions :quote? do + it 'grants access when viewer is not blocked' do + follow = Fabricate(:follow) + status.account = follow.target_account + + expect(subject).to permit(follow.account, status) + end + + it 'denies when viewer is blocked' do + block = Fabricate(:block) + status.account = block.target_account + + expect(subject).to_not permit(block.account, status) + end + + it 'denies when private visibility' do + status.visibility = :private + + expect(subject).to_not permit(Fabricate(:account), status) + end + end + end + context 'with the permission of update?' do permissions :update? do it 'grants access if owner' do diff --git a/spec/search/services/statuses_search_service_spec.rb b/spec/search/services/statuses_search_service_spec.rb index c0d4116ae0..51245f7354 100644 --- a/spec/search/services/statuses_search_service_spec.rb +++ b/spec/search/services/statuses_search_service_spec.rb @@ -63,6 +63,45 @@ describe StatusesSearchService do end end + context 'when public_unlisted searchability' do + let(:searchability) { :public_unlisted } + let(:account) { other } + + context 'with other account' do + it 'search status' do + expect(subject.count).to eq 1 + expect(subject).to include status.id + end + end + + context 'with follower' do + let(:account) { following } + + it 'search status' do + expect(subject.count).to eq 1 + expect(subject).to include status.id + end + end + + context 'with reacted user' do + let(:account) { reacted } + + it 'search status' do + expect(subject.count).to eq 1 + expect(subject).to include status.id + end + end + + context 'with self' do + let(:account) { alice } + + it 'search status' do + expect(subject.count).to eq 1 + expect(subject).to include status.id + end + end + end + context 'when private searchability' do let(:searchability) { :private } let(:account) { other } diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index 0425e2e66b..0c8c78e7a9 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -5,9 +5,11 @@ require 'rails_helper' describe ActivityPub::NoteSerializer do subject { JSON.parse(@serialization.to_json) } + let(:visibility) { :public } + let(:searchability) { :public } let!(:account) { Fabricate(:account) } let!(:other) { Fabricate(:account) } - let!(:parent) { Fabricate(:status, account: account, visibility: :public) } + let!(:parent) { Fabricate(:status, account: account, visibility: visibility, searchability: searchability, language: 'zh-TW') } let!(:reply_by_account_first) { Fabricate(:status, account: account, thread: parent, visibility: :public) } let!(:reply_by_account_next) { Fabricate(:status, account: account, thread: parent, visibility: :public) } let!(:reply_by_other_first) { Fabricate(:status, account: other, thread: parent, visibility: :public) } @@ -15,17 +17,22 @@ describe ActivityPub::NoteSerializer do let!(:reply_by_account_visibility_direct) { Fabricate(:status, account: account, thread: parent, visibility: :direct) } let!(:referred) { nil } let!(:referred2) { nil } - let(:convert_to_quote) { false } before(:each) do parent.references << referred if referred.present? parent.references << referred2 if referred2.present? - account.user&.settings&.[]=('single_ref_to_quote', true) if convert_to_quote @serialization = ActiveModelSerializers::SerializableResource.new(parent, serializer: described_class, adapter: ActivityPub::Adapter) end - it 'has a Note type' do - expect(subject['type']).to eql('Note') + it 'has the expected shape' do + expect(subject).to include({ + '@context' => include('https://www.w3.org/ns/activitystreams'), + 'type' => 'Note', + 'attributedTo' => ActivityPub::TagManager.instance.uri_for(account), + 'contentMap' => include({ + 'zh-TW' => a_kind_of(String), + }), + }) end it 'has a replies collection' do @@ -48,6 +55,30 @@ describe ActivityPub::NoteSerializer do expect(subject['replies']['first']['items']).to_not include(reply_by_account_visibility_direct.uri) end + it 'send as public visibility' do + expect(subject['to']).to include 'https://www.w3.org/ns/activitystreams#Public' + end + + context 'when public_unlisted visibility' do + let(:visibility) { :public_unlisted } + + it 'send as unlisted visibility' do + expect(subject['to']).to_not include 'https://www.w3.org/ns/activitystreams#Public' + end + end + + it 'send as public searchability' do + expect(subject['searchableBy']).to include 'https://www.w3.org/ns/activitystreams#Public' + end + + context 'when public_unlisted searchability' do + let(:searchability) { :public_unlisted } + + it 'send as private searchability' do + expect(subject['searchableBy']).to_not include 'https://www.w3.org/ns/activitystreams#Public' + end + end + context 'when has quote but no_convert setting' do let(:referred) { Fabricate(:status) } @@ -64,28 +95,4 @@ describe ActivityPub::NoteSerializer do expect(subject['references']['first']['items']).to include referred.uri end end - - context 'when has quote and convert setting' do - let(:referred) { Fabricate(:status) } - let(:convert_to_quote) { true } - - it 'has as quote' do - expect(subject['quoteUri']).to_not be_nil - expect(subject['quoteUri']).to eq referred.uri - expect(subject['_misskey_quote']).to eq referred.uri - expect(subject['references']['first']['items']).to include referred.uri - end - end - - context 'when has multiple references and convert setting' do - let(:referred) { Fabricate(:status) } - let(:referred2) { Fabricate(:status) } - let(:convert_to_quote) { true } - - it 'has as quote' do - expect(subject['quoteUri']).to be_nil - expect(subject['references']['first']['items']).to include referred.uri - expect(subject['references']['first']['items']).to include referred2.uri - end - end end diff --git a/spec/serializers/nodeinfo/serializer_spec.rb b/spec/serializers/nodeinfo/serializer_spec.rb index c43b0b569f..8352a44143 100644 --- a/spec/serializers/nodeinfo/serializer_spec.rb +++ b/spec/serializers/nodeinfo/serializer_spec.rb @@ -28,5 +28,10 @@ describe NodeInfo::Serializer do # rubocop:disable RSpec/FilePath it 'returns features' do expect(serialization['metadata']['features']).to include 'emoji_reaction' end + + it 'returns nodeinfo own features' do + expect(serialization['metadata']['features']).to include 'quote' + expect(serialization['metadata']['features']).to_not include 'kmyblue_markdown' + end end end diff --git a/spec/serializers/rest/instance_serializer_spec.rb b/spec/serializers/rest/instance_serializer_spec.rb index a9a3259aa8..4b33d15daa 100644 --- a/spec/serializers/rest/instance_serializer_spec.rb +++ b/spec/serializers/rest/instance_serializer_spec.rb @@ -22,5 +22,9 @@ describe REST::InstanceSerializer do it 'returns fedibird_capabilities' do expect(serialization['fedibird_capabilities']).to include 'emoji_reaction' end + + it 'returns api own fedibird_capabilities' do + expect(serialization['fedibird_capabilities']).to include 'kmyblue_markdown' + end end end diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb index 7810793559..8955084879 100644 --- a/spec/services/activitypub/process_account_service_spec.rb +++ b/spec/services/activitypub/process_account_service_spec.rb @@ -21,6 +21,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do searchableBy: searchable_by, indexable: indexable, summary: sender_bio, + actor_type: 'Person', }.with_indifferent_access end diff --git a/spec/services/block_domain_service_spec.rb b/spec/services/block_domain_service_spec.rb index 36dce9d196..30fb760487 100644 --- a/spec/services/block_domain_service_spec.rb +++ b/spec/services/block_domain_service_spec.rb @@ -10,9 +10,13 @@ RSpec.describe BlockDomainService, type: :service do let!(:bad_status_with_attachment) { Fabricate(:status, account: bad_account, text: 'Hahaha') } let!(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status_with_attachment, file: attachment_fixture('attachment.jpg')) } let!(:already_banned_account) { Fabricate(:account, username: 'badguy', domain: 'evil.org', suspended: true, silenced: true) } + let!(:bad_friend) { Fabricate(:friend_domain, domain: 'evil.org', inbox_url: 'https://evil.org/inbox', active_state: :accepted, passive_state: :accepted) } describe 'for a suspension' do before do + stub_request(:post, 'https://evil.org/inbox').with(body: hash_including({ + type: 'Delete', + })) subject.call(DomainBlock.create!(domain: 'evil.org', severity: :suspend)) end @@ -41,6 +45,21 @@ RSpec.describe BlockDomainService, type: :service do expect { bad_status_with_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound expect { bad_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound end + + it 'removes remote friend from that domain' do + expect(FriendDomain.find_by(domain: 'evil.org')).to be_nil + end + end + + describe 'for rejecting friend only' do + before do + stub_request(:post, 'https://evil.org/inbox') + subject.call(DomainBlock.create!(domain: 'evil.org', severity: :noop, reject_friend: true)) + end + + it 'removes remote friend from that domain' do + expect(FriendDomain.find_by(domain: 'evil.org')).to be_nil + end end describe 'for a silence with reject media' do diff --git a/spec/services/delivery_antenna_service_spec.rb b/spec/services/delivery_antenna_service_spec.rb index 224d448b1a..684a5da9dd 100644 --- a/spec/services/delivery_antenna_service_spec.rb +++ b/spec/services/delivery_antenna_service_spec.rb @@ -295,6 +295,18 @@ RSpec.describe DeliveryAntennaService, type: :service do end end + context 'when public_unlisted searchability' do + let(:searchability) { :public_unlisted } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + end + + it 'not detecting antenna' do + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + context 'when private searchability' do let(:searchability) { :private } @@ -317,6 +329,15 @@ RSpec.describe DeliveryAntennaService, type: :service do end end + context 'when public_unlisted searchability' do + let(:searchability) { :public_unlisted } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + expect(antenna_feed_of(empty_antenna)).to include status.id + end + end + context 'when private searchability' do let(:searchability) { :private } diff --git a/spec/services/fan_out_on_write_service_spec.rb b/spec/services/fan_out_on_write_service_spec.rb index 65db23214a..17d9f91252 100644 --- a/spec/services/fan_out_on_write_service_spec.rb +++ b/spec/services/fan_out_on_write_service_spec.rb @@ -278,7 +278,7 @@ RSpec.describe FanOutOnWriteService, type: :service do it 'is broadcast publicly' do expect(redis).to have_received(:publish).with('timeline:hashtag:hoge', anything) expect(redis).to have_received(:publish).with('timeline:public:local', anything) - expect(redis).to_not have_received(:publish).with('timeline:public', anything) + expect(redis).to have_received(:publish).with('timeline:public', anything) end context 'with list' do @@ -363,6 +363,15 @@ RSpec.describe FanOutOnWriteService, type: :service do expect(redis).to_not have_received(:publish).with('timeline:public', anything) end + context 'with searchability public_unlisted' do + let(:searchability) { 'public_unlisted' } + + it 'is not broadcast to the hashtag stream' do + expect(redis).to have_received(:publish).with('timeline:hashtag:hoge', anything) + expect(redis).to have_received(:publish).with('timeline:hashtag:hoge:local', anything) + end + end + context 'with searchability private' do let(:searchability) { 'private' } diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index a2cf6fbfb0..529ab09969 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -127,6 +127,13 @@ RSpec.describe PostStatusService, type: :service do expect(status.searchability).to eq 'private' end + it 'creates a status with limited searchability for silenced users with public_unlisted searchability' do + status = subject.call(Fabricate(:account, silenced: true), text: 'test', searchability: :public_unlisted, visibility: :public) + + expect(status).to be_persisted + expect(status.searchability).to eq 'private' + end + it 'creates a status with the given searchability=public / visibility=unlisted' do status = create_status_with_options(searchability: :public, visibility: :unlisted) @@ -134,6 +141,13 @@ RSpec.describe PostStatusService, type: :service do expect(status.searchability).to eq 'public' end + it 'creates a status with the given searchability=public_unlisted / visibility=unlisted' do + status = create_status_with_options(searchability: :public_unlisted, visibility: :unlisted) + + expect(status).to be_persisted + expect(status.searchability).to eq 'public_unlisted' + end + it 'creates a status with the given searchability=public / visibility=private' do status = create_status_with_options(searchability: :public, visibility: :private) @@ -141,6 +155,13 @@ RSpec.describe PostStatusService, type: :service do expect(status.searchability).to eq 'private' end + it 'creates a status with the given searchability=public_unlisted / visibility=private' do + status = create_status_with_options(searchability: :public_unlisted, visibility: :private) + + expect(status).to be_persisted + expect(status.searchability).to eq 'private' + end + it 'creates a status for the given application' do application = Fabricate(:application) @@ -188,6 +209,17 @@ RSpec.describe PostStatusService, type: :service do expect(status.mentioned_accounts.first.id).to eq mutual_account.id end + it 'personal visibility with mutual' do + account = Fabricate(:account) + text = 'This is an English text.' + + status = subject.call(account, text: text, visibility: 'mutual') + + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'personal' + expect(status.mentioned_accounts.count).to eq 0 + end + it 'circle visibility' do account = Fabricate(:account) circle_account = Fabricate(:account) @@ -227,6 +259,31 @@ RSpec.describe PostStatusService, type: :service do expect { subject.call(account, text: text, visibility: 'limited') }.to raise_exception ActiveRecord::RecordInvalid end + it 'personal visibility with circle' do + account = Fabricate(:account) + circle = Fabricate(:circle, account: account) + text = 'This is an English text.' + + status = subject.call(account, text: text, visibility: 'circle', circle_id: circle.id) + + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'personal' + expect(status.mentioned_accounts.count).to eq 0 + end + + it 'using empty circle but with mention' do + account = Fabricate(:account) + Fabricate(:account, username: 'bob', domain: nil) + circle = Fabricate(:circle, account: account) + text = 'This is an English text. @bob' + + status = subject.call(account, text: text, visibility: 'circle', circle_id: circle.id) + + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'circle' + expect(status.mentioned_accounts.count).to eq 1 + end + it 'safeguards mentions' do account = Fabricate(:account) mentioned_account = Fabricate(:account, username: 'alice') @@ -270,6 +327,19 @@ RSpec.describe PostStatusService, type: :service do expect(ActivityPub::DistributionWorker).to have_received(:perform_async).with(status.id) end + it 'gets distributed when personal post' do + allow(DistributionWorker).to receive(:perform_async) + allow(ActivityPub::DistributionWorker).to receive(:perform_async) + + account = Fabricate(:account) + + empty_circle = Fabricate(:circle, account: account) + status = subject.call(account, text: 'test status update', visibility: 'circle', circle_id: empty_circle.id) + + expect(DistributionWorker).to have_received(:perform_async).with(status.id) + expect(ActivityPub::DistributionWorker).to_not have_received(:perform_async).with(status.id) + end + it 'crawls links' do allow(LinkCrawlWorker).to receive(:perform_async) account = Fabricate(:account) diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb index 0db73c41fa..39bb355577 100644 --- a/spec/services/process_mentions_service_spec.rb +++ b/spec/services/process_mentions_service_spec.rb @@ -103,4 +103,27 @@ RSpec.describe ProcessMentionsService, type: :service do end end end + + context 'with circle post' do + let(:status) { Fabricate(:status, account: account) } + let(:circle) { Fabricate(:circle, account: account) } + let(:follower) { Fabricate(:account) } + let(:other) { Fabricate(:account) } + + before do + follower.follow!(account) + other.follow!(account) + circle.accounts << follower + described_class.new.call(status, limited_type: :circle, circle: circle) + end + + it 'remains circle post on history' do + expect(CircleStatus.exists?(circle_id: circle.id, status_id: status.id)).to be true + end + + it 'post is delivered to circle members' do + expect(status.mentioned_accounts.count).to eq 1 + expect(status.mentioned_accounts.first.id).to eq follower.id + end + end end diff --git a/spec/services/process_references_service_spec.rb b/spec/services/process_references_service_spec.rb index c41144a2aa..37818b2d45 100644 --- a/spec/services/process_references_service_spec.rb +++ b/spec/services/process_references_service_spec.rb @@ -10,6 +10,8 @@ RSpec.describe ProcessReferencesService, type: :service do let(:status) { Fabricate(:status, account: account, text: text, visibility: visibility) } let(:target_status) { Fabricate(:status, account: Fabricate(:user).account, visibility: target_status_visibility) } let(:target_status_uri) { ActivityPub::TagManager.instance.uri_for(target_status) } + let(:quote_urls) { nil } + let(:allow_quote) { true } def notify?(target_status_id = nil) target_status_id ||= target_status.id @@ -18,7 +20,10 @@ RSpec.describe ProcessReferencesService, type: :service do describe 'posting new status' do subject do - described_class.new.call(status, reference_parameters, urls: urls, fetch_remote: fetch_remote) + target_status.account.user.settings['allow_quote'] = false unless allow_quote + target_status.account.user&.save + + described_class.new.call(status, reference_parameters, urls: urls, fetch_remote: fetch_remote, quote_urls: quote_urls) status.reference_objects.pluck(:target_status_id, :attribute_type) end @@ -35,6 +40,10 @@ RSpec.describe ProcessReferencesService, type: :service do expect(subject.pluck(1)).to include 'RT' expect(notify?).to be true end + + it 'not quote' do + expect(status.quote).to be_nil + end end context 'when multiple references' do @@ -86,6 +95,61 @@ RSpec.describe ProcessReferencesService, type: :service do end end + context 'with quote as parameter only' do + let(:text) { 'Hello' } + let(:quote_urls) { [ActivityPub::TagManager.instance.uri_for(target_status)] } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject.pluck(0)).to include target_status.id + expect(subject.pluck(1)).to include 'QT' + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + expect(notify?).to be true + end + end + + context 'with quote as parameter and embed' do + let(:text) { "Hello QT #{target_status_uri}" } + let(:quote_urls) { [ActivityPub::TagManager.instance.uri_for(target_status)] } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject.pluck(0)).to include target_status.id + expect(subject.pluck(1)).to include 'QT' + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + expect(notify?).to be true + end + end + + context 'with quote as parameter but embed is not quote' do + let(:text) { "Hello RE #{target_status_uri}" } + let(:quote_urls) { [ActivityPub::TagManager.instance.uri_for(target_status)] } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject.pluck(0)).to include target_status.id + expect(subject.pluck(1)).to include 'QT' + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + expect(notify?).to be true + end + end + + context 'when quote is rejected' do + let(:text) { "Hello QT #{target_status_uri}" } + let(:allow_quote) { false } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject.pluck(0)).to include target_status.id + expect(subject.pluck(1)).to include 'BT' + expect(status.quote).to be_nil + expect(notify?).to be true + end + end + context 'with quote and reference' do let(:target_status2) { Fabricate(:status) } let(:target_status2_uri) { ActivityPub::TagManager.instance.uri_for(target_status2) } @@ -240,6 +304,17 @@ RSpec.describe ProcessReferencesService, type: :service do end end + context 'when remove quote' do + let(:text) { "QT #{target_status_uri}" } + let(:new_text) { 'Hello' } + + it 'post status' do + expect(subject.size).to eq 0 + expect(status.quote).to be_nil + expect(notify?).to be false + end + end + context 'when change reference' do let(:text) { "BT #{target_status_uri}" } let(:new_text) { "BT #{target_status2_uri}" } @@ -250,5 +325,43 @@ RSpec.describe ProcessReferencesService, type: :service do expect(notify?(target_status2.id)).to be true end end + + context 'when change quote' do + let(:text) { "QT #{target_status_uri}" } + let(:new_text) { "QT #{target_status2_uri}" } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject).to include target_status2.id + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status2.id + expect(notify?(target_status2.id)).to be true + end + end + + context 'when change quote to reference', pending: 'Will fix later' do + let(:text) { "QT #{target_status_uri}" } + let(:new_text) { "RT #{target_status_uri}" } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject).to include target_status.id + expect(status.quote).to be_nil + expect(notify?(target_status.id)).to be true + end + end + + context 'when change reference to quote', pending: 'Will fix later' do + let(:text) { "RT #{target_status_uri}" } + let(:new_text) { "QT #{target_status_uri}" } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject).to include target_status.id + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + expect(notify?(target_status.id)).to be true + end + end end end diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb index 9c53ebb2fd..288466bdeb 100644 --- a/spec/services/update_status_service_spec.rb +++ b/spec/services/update_status_service_spec.rb @@ -166,6 +166,39 @@ RSpec.describe UpdateStatusService, type: :service do end end + context 'when personal_limited mentions in text change' do + let!(:account) { Fabricate(:account) } + let!(:bob) { Fabricate(:account, username: 'bob') } + let!(:status) { PostStatusService.new.call(account, text: 'Hello', visibility: 'circle', circle_id: Fabricate(:circle, account: account).id) } + + before do + subject.call(status, status.account_id, text: 'Hello @bob') + end + + it 'changes mentions' do + expect(status.active_mentions.pluck(:account_id)).to eq [bob.id] + end + + it 'changes visibilities' do + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'circle' + end + end + + context 'when personal_limited in text change' do + let!(:account) { Fabricate(:account) } + let!(:status) { PostStatusService.new.call(account, text: 'Hello', visibility: 'circle', circle_id: Fabricate(:circle, account: account).id) } + + before do + subject.call(status, status.account_id, text: 'AAA') + end + + it 'not changing visibilities' do + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'personal' + end + end + context 'when hashtags in text change' do let!(:account) { Fabricate(:account) } let!(:status) { PostStatusService.new.call(account, text: 'Hello #foo') } diff --git a/streaming/index.js b/streaming/index.js index e164298149..b02fedfccc 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -256,7 +256,7 @@ const startServer = async () => { CHANNEL_NAMES.forEach(( channel ) => { connectedChannels.set({ type: 'websocket', channel }, 0); connectedChannels.set({ type: 'eventsource', channel }, 0); - }) + }); // Prime the counters so that we don't loose metrics between restarts. // Unfortunately counters don't support the set() API, so instead I'm using @@ -864,7 +864,7 @@ const startServer = async () => { } if (!payload.filtered && !req.cachedFilters) { - queries.push(client.query('SELECT filter.id AS id, filter.phrase AS title, filter.context AS context, filter.expires_at AS expires_at, filter.action AS filter_action, keyword.keyword AS keyword, keyword.whole_word AS whole_word, filter.exclude_follows AS exclude_follows, filter.exclude_localusers AS exclude_localusers FROM custom_filter_keywords keyword JOIN custom_filters filter ON keyword.custom_filter_id = filter.id WHERE filter.account_id = $1 AND (filter.expires_at IS NULL OR filter.expires_at > NOW())', [req.accountId])); + queries.push(client.query('SELECT filter.id AS id, filter.phrase AS title, filter.context AS context, filter.expires_at AS expires_at, filter.action AS filter_action, filter.with_quote AS with_quote, keyword.keyword AS keyword, keyword.whole_word AS whole_word, filter.exclude_follows AS exclude_follows, filter.exclude_localusers AS exclude_localusers FROM custom_filter_keywords keyword JOIN custom_filters filter ON keyword.custom_filter_id = filter.id WHERE filter.account_id = $1 AND (filter.expires_at IS NULL OR filter.expires_at > NOW())', [req.accountId])); } if (!payload.filtered) { queries.push(client.query(`SELECT 1 @@ -913,7 +913,8 @@ const startServer = async () => { // representing a value in an enum defined by Ruby on Rails: // // enum { warn: 0, hide: 1 } - filter_action: ['warn', 'hide'][filter.filter_action], + filter_action: ['warn', 'hide', 'half_warn'][filter.filter_action], + with_quote: filter.with_quote, excludeFollows: filter.exclude_follows, excludeLocalusers: filter.exclude_localusers, }, @@ -1362,7 +1363,7 @@ const startServer = async () => { log.verbose(request.requestId, 'Subscription error:', err.toString()); socket.send(JSON.stringify({ error: err.toString() })); }); - } + }; const removeSubscription = (subscriptions, channelIds, request) => { @@ -1382,7 +1383,7 @@ const startServer = async () => { subscription.stopHeartbeat(); delete subscriptions[channelIds.join(';')]; - } + }; /** * @param {WebSocketSession} session @@ -1402,7 +1403,7 @@ const startServer = async () => { socket.send(JSON.stringify({ error: "Error unsubscribing from channel" })); } }); - } + }; /** * @param {WebSocketSession} session @@ -1480,7 +1481,7 @@ const startServer = async () => { const subscriptions = Object.keys(session.subscriptions); subscriptions.forEach(channelIds => { - removeSubscription(session.subscriptions, channelIds.split(';'), req) + removeSubscription(session.subscriptions, channelIds.split(';'), req); }); // Decrement the metrics for connected clients: diff --git a/yarn.lock b/yarn.lock index 34a7b4d0ab..71c06628c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1096,13 +1096,20 @@ dependencies: regenerator-runtime "^0.12.0" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.22.3", "@babel/runtime@^7.22.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.22.3", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8" integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA== dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.12.1", "@babel/runtime@^7.22.5", "@babel/runtime@^7.9.2": + version "7.23.1" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.1.tgz#72741dc4d413338a91dcb044a86f3c0bc402646d" + integrity sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.22.15", "@babel/template@^7.22.5": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38" @@ -1298,15 +1305,10 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint-community/regexpp@^4.5.1": - version "4.8.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.1.tgz#8c4bb756cc2aa7eaf13cfa5e69c83afb3260c20c" - integrity sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ== - -"@eslint-community/regexpp@^4.6.1": - version "4.8.0" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.0.tgz#11195513186f68d42fbf449f9a7136b2c0c92005" - integrity sha512-JylOEEzDiOryeUnFbQz+oViCXS0KsvR1mvHkoMiu5+UiBvy+RYX7tzlIIIEstF/gVa2tj9AQXk3dgnxv6KxhFg== +"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.9.1.tgz#449dfa81a57a1d755b09aa58d826c1262e4283b4" + integrity sha512-Y27x+MBLjXa+0JWDhykM3+JE+il3kHKAEqabfEWq3SDhZjLYb6/BHL/JKFnH3fe207JaXkyDo685Oc2Glt6ifA== "@eslint/eslintrc@^2.1.2": version "2.1.2" @@ -1323,10 +1325,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.49.0": - version "8.49.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.49.0.tgz#86f79756004a97fa4df866835093f1df3d03c333" - integrity sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w== +"@eslint/js@8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.51.0.tgz#6d419c240cfb2b66da37df230f7e7eef801c32fa" + integrity sha512-HxjQ8Qn+4SI3/AFv6sOrDB+g6PpUTDwSJiQqOrnneEk8L71161srI9gjzzZvYVbzHiVg/BvcH95+cK/zfIt4pg== "@floating-ui/core@^1.3.1": version "1.3.1" @@ -1757,9 +1759,9 @@ "@jridgewell/sourcemap-codec" "^1.4.14" "@material-design-icons/svg@^0.14.10": - version "0.14.12" - resolved "https://registry.yarnpkg.com/@material-design-icons/svg/-/svg-0.14.12.tgz#b3dd27b4c2a93e0310f51acfb311846b0212f987" - integrity sha512-hVEMICFvG26SKDXatPmz+vY5BAqLPCDiyXnw+KN46FXOtY4PcpeAfzFZvwt6D9ywNnVJd4EvmLdlWgLmtOWxbA== + version "0.14.13" + resolved "https://registry.yarnpkg.com/@material-design-icons/svg/-/svg-0.14.13.tgz#de5a79038cf8b281f4b47d79c07399b2b92fcfb7" + integrity sha512-nCExGZOtoLoFeeqShEOM4XA9DkkLzLlQdk/ZxHxps0//dz6e1Lw3fvQbZ2X/+0Dz2O+udiEukfZ4Nd4KpHg8aA== "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -1831,9 +1833,9 @@ integrity sha512-tOQQBVH8LsUpGXqDnk+kaOGVsgZ8maHAhEiw3Git3p88q+c0Slgu47HuDnL6sVxeCfz24zbq7dOjsVYDiTpDIA== "@reduxjs/toolkit@^1.9.5": - version "1.9.5" - resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.5.tgz#d3987849c24189ca483baa7aa59386c8e52077c4" - integrity sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ== + version "1.9.7" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.7.tgz#7fc07c0b0ebec52043f8cb43510cf346405f78a6" + integrity sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ== dependencies: immer "^9.0.21" redux "^4.2.1" @@ -2127,24 +2129,24 @@ "@babel/types" "^7.20.7" "@types/body-parser@*": - version "1.19.2" - resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" - integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g== + version "1.19.3" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.3.tgz#fb558014374f7d9e56c8f34bab2042a3a07d25cd" + integrity sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ== dependencies: "@types/connect" "*" "@types/node" "*" "@types/connect@*": - version "3.4.35" - resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" - integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ== + version "3.4.36" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab" + integrity sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w== dependencies: "@types/node" "*" "@types/emoji-mart@^3.0.9": - version "3.0.9" - resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-3.0.9.tgz#2f7ef5d9ec194f28029c46c81a5fc1e5b0efa73c" - integrity sha512-qdBo/2Y8MXaJ/2spKjDZocuq79GpnOhkwMHnK2GnVFa8WYFgfA+ei6sil3aeWQPCreOKIx9ogPpR5+7MaOqYAA== + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-3.0.10.tgz#d2248c08758094377bd8f438cf13b1364b8b6649" + integrity sha512-WP5Vw1CLsTQpPT/Hj+shIMC5TB4pyoJourYQe01ceYtJVEopTwuXbCTE6f7aHOKj26E/Y+oZaPtKBtnG1S4d2Q== dependencies: "@types/react" "*" @@ -2172,9 +2174,9 @@ integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== "@types/express-serve-static-core@^4.17.33": - version "4.17.35" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz#c95dd4424f0d32e525d23812aa8ab8e4d3906c4f" - integrity sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg== + version "4.17.37" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.37.tgz#7e4b7b59da9142138a2aaa7621f5abedce8c7320" + integrity sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg== dependencies: "@types/node" "*" "@types/qs" "*" @@ -2182,9 +2184,9 @@ "@types/send" "*" "@types/express@^4.17.17": - version "4.17.17" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4" - integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q== + version "4.17.18" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.18.tgz#efabf5c4495c1880df1bdffee604b143b29c4a95" + integrity sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ== dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "^4.17.33" @@ -2219,6 +2221,11 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" +"@types/http-errors@*": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.2.tgz#a86e00bbde8950364f8e7846687259ffcd96e8c2" + integrity sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg== + "@types/http-link-header@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@types/http-link-header/-/http-link-header-1.0.3.tgz#899adf1d8d2036074514f3dbd148fb901ceff920" @@ -2293,9 +2300,9 @@ integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== "@types/lodash@^4.14.195": - version "4.14.198" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.198.tgz#4d27465257011aedc741a809f1269941fa2c5d4c" - integrity sha512-trNJ/vtMZYMLhfN45uLq4ShQSw0/S7xCTLLVM+WM1rmFpba/VS42jVUgaO3w/NOLiWR/09lnYk0yMaA/atdIsg== + version "4.14.199" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.199.tgz#c3edb5650149d847a277a8961a7ad360c474e9bf" + integrity sha512-Vrjz5N5Ia4SEzWWgIVwnHNEnb1UE1XMkvY5DGXrAeOGE9imk0hgTHh5GyDjLDJi9OTCn9oo9dXH1uToK1VRfrg== "@types/mime@*": version "3.0.1" @@ -2318,9 +2325,9 @@ integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== "@types/node@*": - version "20.6.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.2.tgz#a065925409f59657022e9063275cd0b9bd7e1b12" - integrity sha512-Y+/1vGBHV/cYk6OI1Na/LHzwnlNCAfU3ZNGrc1LdRe/LAIbdDPTTv/HU3M7yXN448aTVDq3eKRm2cg7iKLb8gw== + version "20.7.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.7.1.tgz#06d732ead0bd5ad978ef0ea9cbdeb24dc8717514" + integrity sha512-LT+OIXpp2kj4E2S/p91BMe+VgGX2+lfO+XTpfXhh+bCk2LkQtHZSub8ewFBMGP5ClysPjTDFa4sMI8Q3n4T0wg== "@types/node@14 || 16 || 17": version "17.0.45" @@ -2328,9 +2335,9 @@ integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== "@types/normalize-package-data@^2.4.0": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" - integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== + version "2.4.2" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.2.tgz#9b0e3e8533fe5024ad32d6637eb9589988b6fdca" + integrity sha512-lqa4UEhhv/2sjjIQgjX8B+RBjj47eo0mzGasklVJ78UKGQY1r0VpB9XHDaZZO9qzEFDdy4MrXLuEaSmPrPSe/A== "@types/npmlog@^4.1.4": version "4.1.4" @@ -2348,9 +2355,9 @@ integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== "@types/pg@^8.6.6": - version "8.10.2" - resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.10.2.tgz#7814d1ca02c8071f4d0864c1b17c589b061dba43" - integrity sha512-MKFs9P6nJ+LAeHLU3V0cODEOgyThJ3OAnmOlsZsxux6sfQs3HRXR5bBn7xG5DjckEFhTAxsXi7k7cd0pCMxpJw== + version "8.10.3" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.10.3.tgz#39b3acba4f313a65c8fbb4b241fcb21cc1ba4126" + integrity sha512-BACzsw64lCZesclRpZGu55tnqgFAYcrCBP92xLh1KLypZLCOsvJTSTgaoFVTy3lCys/aZTQzfeDxtjwrvdzL2g== dependencies: "@types/node" "*" pg-protocol "*" @@ -2362,9 +2369,9 @@ integrity sha512-O397rnSS9iQI4OirieAtsDqvCj4+3eY1J+EPdNTKuHuRWIfUoGyzX294o8C4KJYaLqgSrd2o60c5EqCU8Zv02g== "@types/prop-types@*", "@types/prop-types@^15.7.5": - version "15.7.6" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.6.tgz#bbf819813d6be21011b8f5801058498bec555572" - integrity sha512-RK/kBbYOQQHLYj9Z95eh7S6t7gq4Ojt/NT8HTk8bWVhA5DaF+5SMnxHKkP4gPNN3wAZkKP+VjAf0ebtYzf+fxg== + version "15.7.8" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.8.tgz#805eae6e8f41bd19e88917d2ea200dc992f405d3" + integrity sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ== "@types/punycode@^2.1.0": version "2.1.0" @@ -2382,36 +2389,36 @@ integrity sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg== "@types/range-parser@*": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" - integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== + version "1.2.5" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.5.tgz#38bd1733ae299620771bd414837ade2e57757498" + integrity sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA== "@types/react-dom@^18.0.0", "@types/react-dom@^18.2.4": - version "18.2.7" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.7.tgz#67222a08c0a6ae0a0da33c3532348277c70abb63" - integrity sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA== + version "18.2.11" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.11.tgz#4332c315544698a0875dfdb6e320dda59e1b3d58" + integrity sha512-zq6Dy0EiCuF9pWFW6I6k6W2LdpUixLE4P6XjXU1QHLfak3GPACQfLwEuHzY5pOYa4hzj1d0GxX/P141aFjZsyg== dependencies: "@types/react" "*" "@types/react-helmet@^6.1.6": - version "6.1.6" - resolved "https://registry.yarnpkg.com/@types/react-helmet/-/react-helmet-6.1.6.tgz#7d1afd8cbf099616894e8240e9ef70e3c6d7506d" - integrity sha512-ZKcoOdW/Tg+kiUbkFCBtvDw0k3nD4HJ/h/B9yWxN4uDO8OkRksWTO+EL+z/Qu3aHTeTll3Ro0Cc/8UhwBCMG5A== + version "6.1.7" + resolved "https://registry.yarnpkg.com/@types/react-helmet/-/react-helmet-6.1.7.tgz#4cecc03165084727408d29d92d8fdd4a7e267403" + integrity sha512-mUFOrdR3AIvHE8BEaqzfPEnR62xq5PHQJehhgNtj78x0d5NOxUCQ0j+r9OZ4RvB+prNZx9wvQnVW8ApFBX+fig== dependencies: "@types/react" "*" "@types/react-immutable-proptypes@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@types/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#c045fb48ba28c34c9d759abc3a51a04b5321b77a" - integrity sha512-NRH4W4mgymzyM2gnAG+i2VoOdWIBOQlJlSyAgnFiBTdJ0l8IVeyCtdWP8g6Lra59sUBj2XUO/+DkfmrRAxj6UA== + version "2.1.1" + resolved "https://registry.yarnpkg.com/@types/react-immutable-proptypes/-/react-immutable-proptypes-2.1.1.tgz#9327e09b07ea61c95a8c99869a5e9f0fc1690cc4" + integrity sha512-PiVos0qCotGqI+J0kOeFhbeg7zK8StPMTSDGaTtgYkX6UPjuVVS7lJMedyt4kAMFbM/2QE0bbP5jh22WqtspQg== dependencies: "@types/prop-types" "*" immutable "^3.8.2" -"@types/react-motion@^0.0.34": - version "0.0.34" - resolved "https://registry.yarnpkg.com/@types/react-motion/-/react-motion-0.0.34.tgz#789ff2063e2f7fbb6085d291135c442e8b35291a" - integrity sha512-/rFI22Vg4Xzb47hXtS06WkzUGRu+Vb3yDleuxiqzGj0JbXYXQUCgwSa2ZU12K7ubKi4C8xsdIN3xt4Z4fjSdPw== +"@types/react-motion@^0.0.35": + version "0.0.35" + resolved "https://registry.yarnpkg.com/@types/react-motion/-/react-motion-0.0.35.tgz#63002d85791dc9c7c212044c7b89dfff99cd0b51" + integrity sha512-7e0rlyG4wgvL1F5G8FMpgEYgPF54i7tmOy3b9KSp6kPjU2hEAV6BiRrl3qMPGrJXZ9soH/OlUYx1Ae5C9AJLGA== dependencies: "@types/react" "*" @@ -2447,23 +2454,23 @@ react-select "*" "@types/react-sparklines@^1.7.2": - version "1.7.2" - resolved "https://registry.yarnpkg.com/@types/react-sparklines/-/react-sparklines-1.7.2.tgz#c14e80623abd3669a10f18d13f6fb9fbdc322f70" - integrity sha512-N1GwO7Ri5C5fE8+CxhiDntuSw1qYdGytBuedKrCxWpaojXm4WnfygbdBdc5sXGX7feMxDXBy9MNhxoUTwrMl4A== + version "1.7.3" + resolved "https://registry.yarnpkg.com/@types/react-sparklines/-/react-sparklines-1.7.3.tgz#cdcbeea734bae191011d8f42ef20d794e55b9064" + integrity sha512-fU88CytKUp/aT8CsaWCNOgcvto5OoINj717z9l0a6OiLiz4EhojRW3c6dSwawM9VjKOIJZMXobSRbbJt7MCtsw== dependencies: "@types/react" "*" "@types/react-swipeable-views@^0.13.1": - version "0.13.2" - resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.2.tgz#c37cc8978ae60ab0dff209ef3eb1f77185aef330" - integrity sha512-FiszBm9M0JicAgzO/IwDqpfHQRUEjPZA88UexYsVD6qHJBf5LrbGjR5Mw4+yZbf8ZxJneNqOsZbe4WGjOYG7iQ== + version "0.13.3" + resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.3.tgz#a4c545b7b722c2622806e5853a96ec5826b0bafb" + integrity sha512-gVAQb5AxZTSLVTrJ/Fxwsk0axdBqGzXC8NxAD8MNwEf+qZynsb+15KL9TpNCaGGk4SCE2iyU/JNi6nGNB61AyA== dependencies: "@types/react" "*" "@types/react-test-renderer@^18.0.0": - version "18.0.2" - resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-18.0.2.tgz#44243977eec18ab8cda88d8977437f47a0d3fdbe" - integrity sha512-tJzMn+9GHDrdrLe0O4rwJELDfTrmdJbCn/UdYyzjlnPiXYXDl5FBNzdw4PVk2R3hJvSHKFjZcRgvZc12lV0p5Q== + version "18.0.3" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-18.0.3.tgz#67922bf5e5f0096581b1efd67dcdeabdd400cfea" + integrity sha512-4wcNLnY6nIT+L6g94CpzL4CXX2P18JvKPU9CDlaHr3DnbP3GiaQLhDotJqjWlVqOcE4UhLRjp0MtxqwuNKONnA== dependencies: "@types/react" "*" @@ -2489,18 +2496,18 @@ "@types/react" "*" "@types/react@*", "@types/react@16 || 17 || 18", "@types/react@>=16.9.11", "@types/react@^18.0.26", "@types/react@^18.2.7": - version "18.2.22" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.22.tgz#abe778a1c95a07fa70df40a52d7300a40b949ccb" - integrity sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA== + version "18.2.25" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.25.tgz#99fa44154132979e870ff409dc5b6e67f06f0199" + integrity sha512-24xqse6+VByVLIr+xWaQ9muX1B4bXJKXBbjszbld/UEDslGLY53+ZucF44HCmLbMPejTzGG9XgR+3m2/Wqu1kw== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" csstype "^3.0.2" "@types/redux-immutable@^4.0.3": - version "4.0.3" - resolved "https://registry.yarnpkg.com/@types/redux-immutable/-/redux-immutable-4.0.3.tgz#db92a281aa9a55a7b63bc1f20a233790305a1f06" - integrity sha512-wXUApt9ib9MGUqoHUMbQmQhqCkvykMHBW3z/P7DISMigFGpGRQ0kkbv7we0XNiv5sYEtEiZzNCEDm+W6ei04DA== + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/redux-immutable/-/redux-immutable-4.0.4.tgz#091641dea366ceed4f8b228a08a0de5f380b9bfd" + integrity sha512-qPFra/qd6HX7+bxayxwM9bsGdMoF7QhTGW/ZJFiaoBZVDZEnQEQCrDhsbSW8Xpuihe6xJ0TRHG3/WTvQpHLGaQ== dependencies: immutable "^4.0.0-rc.1" redux "^4.0.0" @@ -2518,14 +2525,14 @@ "@types/node" "*" "@types/scheduler@*": - version "0.16.3" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" - integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ== + version "0.16.4" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.4.tgz#fedc3e5b15c26dc18faae96bf1317487cb3658cf" + integrity sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ== "@types/semver@^7.5.0": - version "7.5.2" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.2.tgz#31f6eec1ed7ec23f4f05608d3a2d381df041f564" - integrity sha512-7aqorHYgdNO4DM36stTiGO3DvKoex9TQRwsJU6vMaFGyqpBA1MNZkz+PG3gaNUPpTAOYhT1WR7M1JyA3fbS9Cw== + version "7.5.3" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.3.tgz#9a726e116beb26c24f1ccd6850201e1246122e04" + integrity sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw== "@types/send@*": version "0.17.1" @@ -2536,17 +2543,18 @@ "@types/node" "*" "@types/serve-static@*": - version "1.15.1" - resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.1.tgz#86b1753f0be4f9a1bee68d459fcda5be4ea52b5d" - integrity sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ== + version "1.15.2" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.2.tgz#3e5419ecd1e40e7405d34093f10befb43f63381a" + integrity sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw== dependencies: + "@types/http-errors" "*" "@types/mime" "*" "@types/node" "*" "@types/source-list-map@*": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" - integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== + version "0.1.3" + resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.3.tgz#077e15c87fe06520e30396a533bd9848e735ce9b" + integrity sha512-I9R/7fUjzUOyDy6AFkehCK711wWoAXEaBi80AfjZt1lIkbe6AcXKd3ckQc3liMvQExWvfOeh/8CtKzrfUFN5gA== "@types/stack-utils@^2.0.0": version "2.0.1" @@ -2554,9 +2562,9 @@ integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== "@types/tapable@^1": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.8.tgz#b94a4391c85666c7b73299fd3ad79d4faa435310" - integrity sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ== + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.9.tgz#1481a4236267dd2d0ca2a637adb90f0ccb3d69c4" + integrity sha512-fOHIwZua0sRltqWzODGUM6b4ffZrf/vzGUmNXdR+4DzuJP42PMbM5dLKcdzlYvv8bMJ3GALOzkk1q7cDm2zPyA== "@types/tough-cookie@*": version "4.0.2" @@ -2569,9 +2577,9 @@ integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g== "@types/uglify-js@*": - version "3.17.1" - resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.17.1.tgz#e0ffcef756476410e5bce2cb01384ed878a195b5" - integrity sha512-GkewRA4i5oXacU/n4MA9+bLgt5/L3F1mKrYvFGm7r2ouLXhRKjuWwo9XHNnbx6WF3vlGW21S3fCvgqxvxXXc5g== + version "3.17.2" + resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.17.2.tgz#a2ba86fd524f6281a7655463338c546f845b29c3" + integrity sha512-9SjrHO54LINgC/6Ehr81NjAxAYvwEZqjUHLjJYvC4Nmr9jbLQCIZbWSvl4vXQkkmR1UAuaKDycau3O1kWGFyXQ== dependencies: source-map "^0.6.1" @@ -2581,28 +2589,28 @@ integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== "@types/uuid@^9.0.0": - version "9.0.4" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.4.tgz#e884a59338da907bda8d2ed03e01c5c49d036f1c" - integrity sha512-zAuJWQflfx6dYJM62vna+Sn5aeSWhh3OB+wfUEACNcqUSc0AGc5JKl+ycL1vrH7frGTXhJchYjE1Hak8L819dA== + version "9.0.5" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.5.tgz#25a71eb73eba95ac0e559ff3dd018fc08294acf6" + integrity sha512-xfHdwa1FMJ082prjSJpoEI57GZITiQz10r3vEJCHa2khEFQjKy91aWKz6+zybzssCvXUwE1LQWgWVwZ4nYUvHQ== "@types/warning@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52" - integrity sha512-t/Tvs5qR47OLOr+4E9ckN8AmP2Tf16gWq+/qA4iUGS/OOyHVO8wv2vjJuX8SNOUTJyWb+2t7wJm6cXILFnOROA== + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.1.tgz#a62d1d2b7f34376da84ee0afe0145152e62b9699" + integrity sha512-ywJmriP+nvjBKNBEMaNZgj2irZHoxcKeYcyMLbqhYKbDVn8yCIULy2Ol/tvIb37O3IBeZj3RU4tXqQTtGwoAMg== "@types/webpack-sources@*": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-3.2.0.tgz#16d759ba096c289034b26553d2df1bf45248d38b" - integrity sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg== + version "3.2.1" + resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-3.2.1.tgz#98670b35fa799c44ac235910f3fda9bfdcdbc2c6" + integrity sha512-iLC3Fsx62ejm3ST3PQ8vBMC54Rb3EoCprZjeJGI5q+9QjfDLGt9jeg/k245qz1G9AQnORGk0vqPicJFPT1QODQ== dependencies: "@types/node" "*" "@types/source-list-map" "*" source-map "^0.7.3" "@types/webpack@^4.41.33": - version "4.41.33" - resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.33.tgz#16164845a5be6a306bcbe554a8e67f9cac215ffc" - integrity sha512-PPajH64Ft2vWevkerISMtnZ8rTs4YmRbs+23c402J0INmxDKCrhZNvwZYtzx96gY2wAtXdrK1BS2fiC8MlLr3g== + version "4.41.34" + resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.34.tgz#8cf616be84b39c8fb26f9459d4470a5514df2477" + integrity sha512-CN2aOGrR3zbMc2v+cKqzaClYP1ldkpPOgtdNvgX+RmlWCSWxHxpzz6WSCVQZRkF8D60ROlkRzAoEpgjWQ+bd2g== dependencies: "@types/node" "*" "@types/tapable" "^1" @@ -2612,27 +2620,27 @@ source-map "^0.6.0" "@types/yargs-parser@*": - version "21.0.0" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" - integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== + version "21.0.1" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.1.tgz#07773d7160494d56aa882d7531aac7319ea67c3b" + integrity sha512-axdPBuLuEJt0c4yI5OZssC19K2Mq1uKdrfZBzuxLvaztgqUtFYZUNw7lETExPYJR9jdEoIg4mb7RQKRQzOkeGQ== "@types/yargs@^17.0.24", "@types/yargs@^17.0.8": - version "17.0.24" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" - integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== + version "17.0.28" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.28.tgz#d106e4301fbacde3d1796ab27374dd16588ec851" + integrity sha512-N3e3fkS86hNhtk6BEnc0rj3zcehaxx8QWhCROJkqpl5Zaoi7nAic3jH8q94jVD3zu5LGk+PUB6KAiDmimYOEQw== dependencies: "@types/yargs-parser" "*" "@typescript-eslint/eslint-plugin@^6.0.0": - version "6.7.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.7.2.tgz#f18cc75c9cceac8080a9dc2e7d166008c5207b9f" - integrity sha512-ooaHxlmSgZTM6CHYAFRlifqh1OAr3PAQEwi7lhYhaegbnXrnh7CDcHmc3+ihhbQC7H0i4JF0psI5ehzkF6Yl6Q== + version "6.7.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.7.4.tgz#057338df21b6062c2f2fc5999fbea8af9973ac6d" + integrity sha512-DAbgDXwtX+pDkAHwiGhqP3zWUGpW49B7eqmgpPtg+BKJXwdct79ut9+ifqOFPJGClGKSHXn2PTBatCnldJRUoA== dependencies: "@eslint-community/regexpp" "^4.5.1" - "@typescript-eslint/scope-manager" "6.7.2" - "@typescript-eslint/type-utils" "6.7.2" - "@typescript-eslint/utils" "6.7.2" - "@typescript-eslint/visitor-keys" "6.7.2" + "@typescript-eslint/scope-manager" "6.7.4" + "@typescript-eslint/type-utils" "6.7.4" + "@typescript-eslint/utils" "6.7.4" + "@typescript-eslint/visitor-keys" "6.7.4" debug "^4.3.4" graphemer "^1.4.0" ignore "^5.2.4" @@ -2641,31 +2649,31 @@ ts-api-utils "^1.0.1" "@typescript-eslint/parser@^6.0.0": - version "6.7.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.7.2.tgz#e0ae93771441b9518e67d0660c79e3a105497af4" - integrity sha512-KA3E4ox0ws+SPyxQf9iSI25R6b4Ne78ORhNHeVKrPQnoYsb9UhieoiRoJgrzgEeKGOXhcY1i8YtOeCHHTDa6Fw== + version "6.7.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.7.4.tgz#23d1dd4fe5d295c7fa2ab651f5406cd9ad0bd435" + integrity sha512-I5zVZFY+cw4IMZUeNCU7Sh2PO5O57F7Lr0uyhgCJmhN/BuTlnc55KxPonR4+EM3GBdfiCyGZye6DgMjtubQkmA== dependencies: - "@typescript-eslint/scope-manager" "6.7.2" - "@typescript-eslint/types" "6.7.2" - "@typescript-eslint/typescript-estree" "6.7.2" - "@typescript-eslint/visitor-keys" "6.7.2" + "@typescript-eslint/scope-manager" "6.7.4" + "@typescript-eslint/types" "6.7.4" + "@typescript-eslint/typescript-estree" "6.7.4" + "@typescript-eslint/visitor-keys" "6.7.4" debug "^4.3.4" -"@typescript-eslint/scope-manager@6.7.2": - version "6.7.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.7.2.tgz#cf59a2095d2f894770c94be489648ad1c78dc689" - integrity sha512-bgi6plgyZjEqapr7u2mhxGR6E8WCzKNUFWNh6fkpVe9+yzRZeYtDTbsIBzKbcxI+r1qVWt6VIoMSNZ4r2A+6Yw== +"@typescript-eslint/scope-manager@6.7.4": + version "6.7.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.7.4.tgz#a484a17aa219e96044db40813429eb7214d7b386" + integrity sha512-SdGqSLUPTXAXi7c3Ob7peAGVnmMoGzZ361VswK2Mqf8UOYcODiYvs8rs5ILqEdfvX1lE7wEZbLyELCW+Yrql1A== dependencies: - "@typescript-eslint/types" "6.7.2" - "@typescript-eslint/visitor-keys" "6.7.2" + "@typescript-eslint/types" "6.7.4" + "@typescript-eslint/visitor-keys" "6.7.4" -"@typescript-eslint/type-utils@6.7.2": - version "6.7.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.7.2.tgz#ed921c9db87d72fa2939fee242d700561454f367" - integrity sha512-36F4fOYIROYRl0qj95dYKx6kybddLtsbmPIYNK0OBeXv2j9L5nZ17j9jmfy+bIDHKQgn2EZX+cofsqi8NPATBQ== +"@typescript-eslint/type-utils@6.7.4": + version "6.7.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.7.4.tgz#847cd3b59baf948984499be3e0a12ff07373e321" + integrity sha512-n+g3zi1QzpcAdHFP9KQF+rEFxMb2KxtnJGID3teA/nxKHOVi3ylKovaqEzGBbVY2pBttU6z85gp0D00ufLzViQ== dependencies: - "@typescript-eslint/typescript-estree" "6.7.2" - "@typescript-eslint/utils" "6.7.2" + "@typescript-eslint/typescript-estree" "6.7.4" + "@typescript-eslint/utils" "6.7.4" debug "^4.3.4" ts-api-utils "^1.0.1" @@ -2674,10 +2682,10 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== -"@typescript-eslint/types@6.7.2": - version "6.7.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.7.2.tgz#75a615a6dbeca09cafd102fe7f465da1d8a3c066" - integrity sha512-flJYwMYgnUNDAN9/GAI3l8+wTmvTYdv64fcH8aoJK76Y+1FCZ08RtI5zDerM/FYT5DMkAc+19E4aLmd5KqdFyg== +"@typescript-eslint/types@6.7.4": + version "6.7.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.7.4.tgz#5d358484d2be986980c039de68e9f1eb62ea7897" + integrity sha512-o9XWK2FLW6eSS/0r/tgjAGsYasLAnOWg7hvZ/dGYSSNjCh+49k5ocPN8OmG5aZcSJ8pclSOyVKP2x03Sj+RrCA== "@typescript-eslint/typescript-estree@5.62.0": version "5.62.0" @@ -2692,30 +2700,30 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/typescript-estree@6.7.2": - version "6.7.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.7.2.tgz#ce5883c23b581a5caf878af641e49dd0349238c7" - integrity sha512-kiJKVMLkoSciGyFU0TOY0fRxnp9qq1AzVOHNeN1+B9erKFCJ4Z8WdjAkKQPP+b1pWStGFqezMLltxO+308dJTQ== +"@typescript-eslint/typescript-estree@6.7.4": + version "6.7.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.7.4.tgz#f2baece09f7bb1df9296e32638b2e1130014ef1a" + integrity sha512-ty8b5qHKatlNYd9vmpHooQz3Vki3gG+3PchmtsA4TgrZBKWHNjWfkQid7K7xQogBqqc7/BhGazxMD5vr6Ha+iQ== dependencies: - "@typescript-eslint/types" "6.7.2" - "@typescript-eslint/visitor-keys" "6.7.2" + "@typescript-eslint/types" "6.7.4" + "@typescript-eslint/visitor-keys" "6.7.4" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.5.4" ts-api-utils "^1.0.1" -"@typescript-eslint/utils@6.7.2": - version "6.7.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.7.2.tgz#b9ef0da6f04932167a9222cb4ac59cb187165ebf" - integrity sha512-ZCcBJug/TS6fXRTsoTkgnsvyWSiXwMNiPzBUani7hDidBdj1779qwM1FIAmpH4lvlOZNF3EScsxxuGifjpLSWQ== +"@typescript-eslint/utils@6.7.4": + version "6.7.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.7.4.tgz#2236f72b10e38277ee05ef06142522e1de470ff2" + integrity sha512-PRQAs+HUn85Qdk+khAxsVV+oULy3VkbH3hQ8hxLRJXWBEd7iI+GbQxH5SEUSH7kbEoTp6oT1bOwyga24ELALTA== dependencies: "@eslint-community/eslint-utils" "^4.4.0" "@types/json-schema" "^7.0.12" "@types/semver" "^7.5.0" - "@typescript-eslint/scope-manager" "6.7.2" - "@typescript-eslint/types" "6.7.2" - "@typescript-eslint/typescript-estree" "6.7.2" + "@typescript-eslint/scope-manager" "6.7.4" + "@typescript-eslint/types" "6.7.4" + "@typescript-eslint/typescript-estree" "6.7.4" semver "^7.5.4" "@typescript-eslint/visitor-keys@5.62.0": @@ -2726,12 +2734,12 @@ "@typescript-eslint/types" "5.62.0" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@6.7.2": - version "6.7.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.2.tgz#4cb2bd786f1f459731b0ad1584c9f73e1c7a4d5c" - integrity sha512-uVw9VIMFBUTz8rIeaUT3fFe8xIUx8r4ywAdlQv1ifH+6acn/XF8Y6rwJ7XNmkNMDrTW+7+vxFFPIF40nJCVsMQ== +"@typescript-eslint/visitor-keys@6.7.4": + version "6.7.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.4.tgz#80dfecf820fc67574012375859085f91a4dff043" + integrity sha512-pOW37DUhlTZbvph50x5zZCkFn3xzwkGtNoJHzIM3svpiSkJzwOYr/kVBaXmf+RAQiUDs1AHEZVNPg6UJCJpwRA== dependencies: - "@typescript-eslint/types" "6.7.2" + "@typescript-eslint/types" "6.7.4" eslint-visitor-keys "^3.4.1" "@webassemblyjs/ast@1.9.0": @@ -3363,9 +3371,9 @@ axe-core@^4.6.2: integrity sha512-zIURGIS1E1Q4pcrMjp+nnEh+16G56eG/MUllJH8yEvw7asDo7Ac9uhC9KIH5jzpITueEZolfYglnCGIuSBz39g== axios@^1.4.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.0.tgz#f02e4af823e2e46a9768cfc74691fdd0517ea267" - integrity sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ== + version "1.5.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.1.tgz#11fbaa11fc35f431193a9564109c88c1f27b585f" + integrity sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A== dependencies: follow-redirects "^1.15.0" form-data "^4.0.0" @@ -4350,9 +4358,9 @@ core-js@^2.5.0: integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-js@^3.30.2: - version "3.32.2" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.32.2.tgz#172fb5949ef468f93b4be7841af6ab1f21992db7" - integrity sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ== + version "3.33.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.33.0.tgz#70366dbf737134761edb017990cf5ce6c6369c40" + integrity sha512-HoZr92+ZjFEKar5HS6MC776gYslNOKHt75mEBKWKnPeFDpZ6nH5OeF3S6HFT1mUAUZKrzkez05VboaX8myjSuw== core-util-is@~1.0.0: version "1.0.3" @@ -5377,9 +5385,9 @@ eslint-import-resolver-node@^0.3.7: resolve "^1.22.4" eslint-import-resolver-typescript@^3.5.5: - version "3.6.0" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.0.tgz#36f93e1eb65a635e688e16cae4bead54552e3bbd" - integrity sha512-QTHR9ddNnn35RTxlaEnx2gCxqFlF2SEN0SE2d17SqwyM7YOSI2GHWRYp5BiRkObTUNYPupC/3Fq2a0PpT+EKpg== + version "3.6.1" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz#7b983680edd3f1c5bce1a5829ae0bc2d57fe9efa" + integrity sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg== dependencies: debug "^4.3.4" enhanced-resolve "^5.12.0" @@ -5437,9 +5445,9 @@ eslint-plugin-import@~2.28.0: tsconfig-paths "^3.14.2" eslint-plugin-jsdoc@^46.1.0: - version "46.8.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.8.1.tgz#cfc649c15d460903fe8e86eda582023bb97f283a" - integrity sha512-uTce7IBluPKXIQMWJkIwFsI1gv7sZRmLjctca2K5DIxPi8fSBj9f4iru42XmGwuiMyH2f3nfc60sFmnSGv4Z/A== + version "46.8.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.8.2.tgz#3e6b1c93e91e38fe01874d45da121b56393c54a5" + integrity sha512-5TSnD018f3tUJNne4s4gDWQflbsgOycIKEUBoCLn6XtBMgNHxQFmV8vVxUtiPxAQq8lrX85OaSG/2gnctxw9uQ== dependencies: "@es-joy/jsdoccomment" "~0.40.1" are-docs-informative "^0.0.2" @@ -5535,14 +5543,14 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== eslint@^8.41.0: - version "8.49.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.49.0.tgz#09d80a89bdb4edee2efcf6964623af1054bf6d42" - integrity sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ== + version "8.51.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.51.0.tgz#4a82dae60d209ac89a5cff1604fea978ba4950f3" + integrity sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.6.1" "@eslint/eslintrc" "^2.1.2" - "@eslint/js" "8.49.0" + "@eslint/js" "8.51.0" "@humanwhocodes/config-array" "^0.11.11" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" @@ -6008,24 +6016,29 @@ findup-sync@^3.0.0: resolve-dir "^1.0.1" flat-cache@^3.0.4: - version "3.1.0" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.1.0.tgz#0e54ab4a1a60fe87e2946b6b00657f1c99e1af3f" - integrity sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew== + version "3.1.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.1.1.tgz#a02a15fdec25a8f844ff7cc658f03dd99eb4609b" + integrity sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q== dependencies: - flatted "^3.2.7" + flatted "^3.2.9" keyv "^4.5.3" rimraf "^3.0.2" -flatted@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" - integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== +flatted@^3.2.9: + version "3.2.9" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" + integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== -follow-redirects@^1.0.0, follow-redirects@^1.15.0: +follow-redirects@^1.0.0: version "1.15.2" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== +follow-redirects@^1.15.0: + version "1.15.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" + integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== + font-awesome@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" @@ -6212,9 +6225,9 @@ get-symbol-description@^1.0.0: get-intrinsic "^1.1.1" get-tsconfig@^4.5.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.7.0.tgz#06ce112a1463e93196aa90320c35df5039147e34" - integrity sha512-pmjiZ7xtB8URYm74PlGJozDNyhvsVLUcpBa8DZBG3bWHwaHa9bPiRpiSfovw+fjhwONSCWKRyk+JQHEGZmMrzw== + version "4.7.2" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.7.2.tgz#0dcd6fb330391d46332f4c6c1bf89a6514c2ddce" + integrity sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A== dependencies: resolve-pkg-maps "^1.0.0" @@ -6246,12 +6259,12 @@ glob-parent@^6.0.2: is-glob "^4.0.3" glob@^10.2.5, glob@^10.2.6: - version "10.3.5" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.5.tgz#4c0e46b5bccd78ac42b06a7eaaeb9ee34062968e" - integrity sha512-bYUpUD7XDEHI4Q2O5a7PXGvyw4deKR70kHiDxzQbe925wbZknhOzUt2xBgTkYL6RBcVeXYuD9iNYeqoWbBZQnA== + version "10.3.10" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b" + integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== dependencies: foreground-child "^3.1.0" - jackspeak "^2.0.3" + jackspeak "^2.3.5" minimatch "^9.0.1" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" @@ -6310,9 +6323,9 @@ globals@^11.1.0: integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== globals@^13.19.0: - version "13.21.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.21.0.tgz#163aae12f34ef502f5153cfbdd3600f36c63c571" - integrity sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg== + version "13.23.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.23.0.tgz#ef31673c926a0976e1f61dab4dca57e0c0a8af02" + integrity sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA== dependencies: type-fest "^0.20.2" @@ -7365,10 +7378,10 @@ iterator.prototype@^1.1.0: has-tostringtag "^1.0.0" reflect.getprototypeof "^1.0.3" -jackspeak@^2.0.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.3.tgz#95e4cbcc03b3eb357bf6bcce14a903fb3d1151e1" - integrity sha512-R2bUw+kVZFS/h1AZqBKrSgDmdmjApzgY0AlCPumopFiAlbUxE2gf+SCuBzQ0cP5hHmUmFYF5yw55T97Th5Kstg== +jackspeak@^2.3.5: + version "2.3.5" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.5.tgz#443f237f9eeeb0d7c6ec34835ef5289bb4acb068" + integrity sha512-Ratx+B8WeXLAtRJn26hrhY8S1+Jz6pxPMrkrdkgb/NstTNiqMhX0/oFVu5wX+g5n6JlEu2LPsDJmY8nRP4+alw== dependencies: "@isaacs/cliui" "^8.0.2" optionalDependencies: @@ -8021,9 +8034,9 @@ keycode@^2.1.7: integrity sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg== keyv@^4.5.3: - version "4.5.3" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.3.tgz#00873d2b046df737963157bd04f294ca818c9c25" - integrity sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug== + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== dependencies: json-buffer "3.0.1" @@ -9737,9 +9750,9 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== postcss@^8.2.15, postcss@^8.4.24, postcss@^8.4.25: - version "8.4.30" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.30.tgz#0e0648d551a606ef2192a26da4cabafcc09c1aa7" - integrity sha512-7ZEao1g4kd68l97aWG/etQKPKq07us0ieSZ2TnFDk11i0ZfDW2AwKHYU8qv4MZKqN2fdBfg+7q0ES06UA73C1g== + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== dependencies: nanoid "^3.3.6" picocolors "^1.0.0" @@ -10151,9 +10164,9 @@ react-redux-loading-bar@^5.0.4: react-lifecycles-compat "^3.0.4" react-redux@^8.0.4, react-redux@^8.1.1: - version "8.1.2" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.1.2.tgz#9076bbc6b60f746659ad6d51cb05de9c5e1e9188" - integrity sha512-xJKYI189VwfsFc4CJvHqHlDrzyFTY/3vZACbE+rr/zQ34Xx1wQfB4OTOSeOSNrF6BDVe8OOdxIrAnMGXA3ggfw== + version "8.1.3" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.1.3.tgz#4fdc0462d0acb59af29a13c27ffef6f49ab4df46" + integrity sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw== dependencies: "@babel/runtime" "^7.12.1" "@types/hoist-non-react-statics" "^3.3.1" @@ -11231,9 +11244,9 @@ spdx-expression-parse@^3.0.0, spdx-expression-parse@^3.0.1: spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: - version "3.0.13" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz#7189a474c46f8d47c7b0da4b987bb45e908bd2d5" - integrity sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w== + version "3.0.15" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.15.tgz#142460aabaca062bc7cd4cc87b7d50725ed6a4ba" + integrity sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ== spdy-transport@^3.0.0: version "3.0.0"