diff --git a/.eslintrc.js b/.eslintrc.js index 93ff1d7b59..480b274fad 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -109,7 +109,7 @@ module.exports = defineConfig({ 'react/jsx-equals-spacing': 'error', 'react/jsx-no-bind': 'error', 'react/jsx-no-useless-fragment': 'error', - 'react/jsx-no-target-blank': 'off', + 'react/jsx-no-target-blank': ['error', { allowReferrer: true }], 'react/jsx-tag-spacing': 'error', 'react/jsx-uses-react': 'off', // not needed with new JSX transform 'react/jsx-wrap-multilines': 'error', diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index 2341d6e67f..fa28d28f74 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -36,4 +36,4 @@ jobs: bundler-cache: true - name: Run bundler-audit - run: bundle exec bundler-audit check --update + run: bin/bundler-audit check --update diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index 7c1004329c..4f87f0fe5f 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -35,18 +35,18 @@ jobs: git diff --exit-code - name: Check locale file normalization - run: bundle exec i18n-tasks check-normalized + run: bin/i18n-tasks check-normalized - name: Check for unused strings - run: bundle exec i18n-tasks unused + run: bin/i18n-tasks unused - name: Check for missing strings in English YML run: | - bundle exec i18n-tasks add-missing -l en + bin/i18n-tasks add-missing -l en git diff --exit-code - name: Check for wrong string interpolations - run: bundle exec i18n-tasks check-consistent-interpolations + run: bin/i18n-tasks check-consistent-interpolations - name: Check that all required locale files exist - run: bundle exec rake repo:check_locales_files + run: bin/rake repo:check_locales_files diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index de21e2e58f..ef28258cca 100644 --- a/.github/workflows/crowdin-download-stable.yml +++ b/.github/workflows/crowdin-download-stable.yml @@ -46,7 +46,7 @@ jobs: uses: ./.github/actions/setup-ruby - name: Run i18n normalize task - run: bundle exec i18n-tasks normalize + run: bin/i18n-tasks normalize # Create or update the pull request - name: Create Pull Request diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index 900899dd52..e9b909b9e0 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -48,7 +48,7 @@ jobs: uses: ./.github/actions/setup-ruby - name: Run i18n normalize task - run: bundle exec i18n-tasks normalize + run: bin/i18n-tasks normalize # Create or update the pull request - name: Create Pull Request diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 95fcd56942..c1385bf789 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -40,4 +40,4 @@ jobs: uses: ./.github/actions/setup-javascript - name: Stylelint - run: yarn lint:css -f github + run: yarn lint:css --custom-formatter @csstools/stylelint-formatter-github diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index a1a9e99c90..499be2010a 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -43,4 +43,4 @@ jobs: - name: Run haml-lint run: | echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" - bundle exec haml-lint --reporter github + bin/haml-lint --reporter github diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index 277e456146..87f8aee24e 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -9,6 +9,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' + - 'bin/rubocop' - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' @@ -19,6 +20,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' + - 'bin/rubocop' - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 5b80fef037..306191fb8e 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -12,6 +12,7 @@ on: - '**/*.rb' - '.github/workflows/test-migrations.yml' - 'lib/tasks/tests.rake' + - 'lib/tasks/db.rake' pull_request: paths: @@ -90,6 +91,11 @@ jobs: bin/rails db:drop bin/rails db:create SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database + + # Migrate up to v4.2.0 breakpoint + bin/rails db:migrate VERSION=20230907150100 + + # Migrate the rest SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate bin/rails db:migrate bin/rails tests:migrations:check_database diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 770cd72a1b..08b50e2680 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -166,7 +166,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage/lcov/*.lcov env: @@ -252,7 +252,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage/lcov/mastodon.lcov env: diff --git a/.nvmrc b/.nvmrc index 8b84b727be..35d2d08ea1 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.11 +22.12 diff --git a/.rubocop/style.yml b/.rubocop/style.yml index 03e35a70ac..7dd4299c3e 100644 --- a/.rubocop/style.yml +++ b/.rubocop/style.yml @@ -1,4 +1,7 @@ --- +Style/ArrayIntersect: + Enabled: false + Style/ClassAndModuleChildren: Enabled: false @@ -19,6 +22,16 @@ Style/HashSyntax: EnforcedShorthandSyntax: either EnforcedStyle: ruby19_no_mixed_keys +Style/IfUnlessModifier: + Exclude: + - '**/*.haml' + +Style/KeywordArgumentsMerging: + Enabled: false + +Style/MultipleComparison: + Enabled: false + Style/NumericLiterals: AllowedPatterns: - \d{4}_\d{2}_\d{2}_\d{6} @@ -37,6 +50,9 @@ Style/RedundantFetchBlock: Style/RescueStandardError: EnforcedStyle: implicit +Style/SafeNavigationChainLength: + Enabled: false + Style/SymbolArray: Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index a6e51d6aee..552054898e 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.66.1. +# using RuboCop version 1.69.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -35,7 +35,6 @@ Rails/OutputSafety: # Configuration parameters: AllowedVars. Style/FetchEnvVar: Exclude: - - 'app/lib/translation_service.rb' - 'config/environments/production.rb' - 'config/initializers/2_limited_federation_mode.rb' - 'config/initializers/3_omniauth.rb' diff --git a/CHANGELOG.md b/CHANGELOG.md index 0696f0b31c..743cc36ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ All notable changes to this project will be documented in this file. +## [4.3.2] - 2024-12-03 + +### Added + +- Add `tootctl feeds vacuum` (#33065 by @ClearlyClaire) +- Add error message when user tries to follow their own account (#31910 by @lenikadali) +- Add client_secret_expires_at to OAuth Applications (#30317 by @ThisIsMissEm) + +### Changed + +- Change design of Content Warnings and filters (#32543 by @ClearlyClaire) + +### Fixed + +- Fix processing incoming post edits with mentions to unresolvable accounts (#33129 by @ClearlyClaire) +- Fix error when including multiple instances of `embed.js` (#33107 by @YKWeyer) +- Fix inactive users' timelines being backfilled on follow and unsuspend (#33094 by @ClearlyClaire) +- Fix direct inbox delivery pushing posts into inactive followers' timelines (#33067 by @ClearlyClaire) +- Fix `TagFollow` records not being correctly handled in account operations (#33063 by @ClearlyClaire) +- Fix pushing hashtag-followed posts to feeds of inactive users (#33018 by @Gargron) +- Fix duplicate notifications in notification groups when using slow mode (#33014 by @ClearlyClaire) +- Fix posts made in the future being allowed to trend (#32996 by @ClearlyClaire) +- Fix uploading higher-than-wide GIF profile picture with libvips enabled (#32911 by @ClearlyClaire) +- Fix domain attribution field having autocorrect and autocapitalize enabled (#32903 by @ClearlyClaire) +- Fix titles being escaped twice (#32889 by @ClearlyClaire) +- Fix list creation limit check (#32869 by @ClearlyClaire) +- Fix error in `tootctl email_domain_blocks` when supplying `--with-dns-records` (#32863 by @mjankowski) +- Fix `min_id` and `max_id` causing error in search API (#32857 by @Gargron) +- Fix inefficiencies when processing removal of posts that use featured tags (#32787 by @ClearlyClaire) +- Fix alt-text pop-in not using the translated description (#32766 by @ClearlyClaire) +- Fix preview cards with long titles erroneously causing layout changes (#32678 by @ClearlyClaire) +- Fix embed modal layout on mobile (#32641 by @DismalShadowX) +- Fix and improve batch attachment deletion handling when using OpenStack Swift (#32637 by @hugogameiro) +- Fix blocks not being applied on link timeline (#32625 by @tribela) +- Fix follow counters being incorrectly changed (#32622 by @oneiros) +- Fix 'unknown' media attachment type rendering (#32613 and #32713 by @ThisIsMissEm and @renatolond) +- Fix tl language native name (#32606 by @seav) + +### Security + +- Update dependencies + ## [4.3.1] - 2024-10-21 ### Added @@ -68,7 +110,7 @@ The following changelog entries focus on changes visible to users, administrator - `GET /api/v2/notifications`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped - `GET /api/v2/notifications/:group_key`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group - `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts - - `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group + - `POST /api/v2/notifications/:group_key/dismiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group - `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count - **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, #31723, #32062 and #32281 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\ The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\ @@ -399,7 +441,7 @@ The following changelog entries focus on changes visible to users, administrator - Fix empty environment variables not using default nil value (#27400 by @renchap) - Fix language sorting in settings (#27158 by @gunchleoc) -## |4.2.11] - 2024-08-16 +## [4.2.11] - 2024-08-16 ### Added diff --git a/Dockerfile b/Dockerfile index c91f10de0f..d80a4e1555 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.10 +# syntax=docker/dockerfile:1.12 # This file is designed for production server deployment, not local development work # For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker diff --git a/Gemfile b/Gemfile index 6abb075c1c..b626b5511c 100644 --- a/Gemfile +++ b/Gemfile @@ -105,7 +105,7 @@ gem 'opentelemetry-api', '~> 1.4.0' group :opentelemetry do gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false - gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false + gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.21.0', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false gem 'opentelemetry-instrumentation-excon', '~> 0.22.0', require: false gem 'opentelemetry-instrumentation-faraday', '~> 0.24.1', require: false @@ -114,7 +114,7 @@ group :opentelemetry do gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false gem 'opentelemetry-instrumentation-rack', '~> 0.25.0', require: false - gem 'opentelemetry-instrumentation-rails', '~> 0.33.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.34.0', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false diff --git a/Gemfile.lock b/Gemfile.lock index d005c744cc..3367e04685 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,29 +10,29 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.2.2) - actionpack (= 7.2.2) - activesupport (= 7.2.2) + actioncable (7.2.2.1) + actionpack (= 7.2.2.1) + activesupport (= 7.2.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.2) - actionpack (= 7.2.2) - activejob (= 7.2.2) - activerecord (= 7.2.2) - activestorage (= 7.2.2) - activesupport (= 7.2.2) + actionmailbox (7.2.2.1) + actionpack (= 7.2.2.1) + activejob (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) mail (>= 2.8.0) - actionmailer (7.2.2) - actionpack (= 7.2.2) - actionview (= 7.2.2) - activejob (= 7.2.2) - activesupport (= 7.2.2) + actionmailer (7.2.2.1) + actionpack (= 7.2.2.1) + actionview (= 7.2.2.1) + activejob (= 7.2.2.1) + activesupport (= 7.2.2.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.2) - actionview (= 7.2.2) - activesupport (= 7.2.2) + actionpack (7.2.2.1) + actionview (= 7.2.2.1) + activesupport (= 7.2.2.1) nokogiri (>= 1.8.5) racc rack (>= 2.2.4, < 3.2) @@ -41,40 +41,40 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.2) - actionpack (= 7.2.2) - activerecord (= 7.2.2) - activestorage (= 7.2.2) - activesupport (= 7.2.2) + actiontext (7.2.2.1) + actionpack (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.2) - activesupport (= 7.2.2) + actionview (7.2.2.1) + activesupport (= 7.2.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - active_model_serializers (0.10.14) + active_model_serializers (0.10.15) actionpack (>= 4.1) activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (7.2.2) - activesupport (= 7.2.2) + activejob (7.2.2.1) + activesupport (= 7.2.2.1) globalid (>= 0.3.6) - activemodel (7.2.2) - activesupport (= 7.2.2) - activerecord (7.2.2) - activemodel (= 7.2.2) - activesupport (= 7.2.2) + activemodel (7.2.2.1) + activesupport (= 7.2.2.1) + activerecord (7.2.2.1) + activemodel (= 7.2.2.1) + activesupport (= 7.2.2.1) timeout (>= 0.4.0) - activestorage (7.2.2) - actionpack (= 7.2.2) - activejob (= 7.2.2) - activerecord (= 7.2.2) - activesupport (= 7.2.2) + activestorage (7.2.2.1) + actionpack (= 7.2.2.1) + activejob (= 7.2.2.1) + activerecord (= 7.2.2.1) + activesupport (= 7.2.2.1) marcel (~> 1.0) - activesupport (7.2.2) + activesupport (7.2.2.1) base64 benchmark (>= 0.3) bigdecimal @@ -93,10 +93,9 @@ GEM annotaterb (4.13.0) ast (2.4.2) attr_required (1.0.2) - awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.1009.0) - aws-sdk-core (3.213.0) + aws-partitions (1.1025.0) + aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -104,13 +103,13 @@ GEM aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.172.0) + aws-sdk-s3 (1.176.1) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.10.1) aws-eventstream (~> 1, >= 1.0.2) - azure-blob (0.5.3) + azure-blob (0.5.4) rexml base64 (0.2.0) bcp47_spec (0.2.1) @@ -129,7 +128,7 @@ GEM msgpack (~> 1.2) brakeman (6.2.2) racc - browser (6.1.0) + browser (6.2.0) brpoplpush-redis_script (0.1.3) concurrent-ruby (~> 1.0, >= 1.0.5) redis (>= 1.0, < 6) @@ -169,15 +168,15 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (1.19.1) + css_parser (1.21.0) addressable - csv (3.3.0) + csv (3.3.1) database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.4.0) - debug (1.9.2) + date (3.4.1) + debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) debug_inspector (1.2.0) @@ -200,9 +199,9 @@ GEM activerecord (>= 4.2, < 9.0) docile (1.4.1) domain_name (0.6.20240107) - doorkeeper (5.8.0) + doorkeeper (5.8.1) railties (>= 5) - dotenv (3.1.4) + dotenv (3.1.7) drb (2.2.1) elasticsearch (7.17.11) elasticsearch-api (= 7.17.11) @@ -225,14 +224,14 @@ GEM fabrication (2.31.0) faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.12.0) - faraday-net_http (>= 2.0, < 3.4) + faraday (2.12.2) + faraday-net_http (>= 2.0, < 3.5) json logger faraday-httpclient (2.0.1) httpclient (>= 2.2) - faraday-net_http (3.3.0) - net-http + faraday-net_http (3.4.0) + net-http (>= 0.5.0) fast_blank (1.0.1) fastimage (2.3.1) ffi (1.17.0) @@ -280,7 +279,7 @@ GEM rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hashdiff (1.1.1) + hashdiff (1.1.2) hashie (5.0.0) hcaptcha (7.1.0) json @@ -295,7 +294,7 @@ GEM http-cookie (~> 1.0) http-form_data (~> 2.2) llhttp-ffi (~> 0.5.0) - http-cookie (1.0.5) + http-cookie (1.0.8) domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) @@ -319,8 +318,8 @@ GEM inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.7.2) - irb (1.14.1) + io-console (0.8.0) + irb (1.14.3) rdoc (>= 4.0.0) reline (>= 0.4.2) jd-paperclip-azure (3.0.0) @@ -328,7 +327,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.8.1) + json (2.9.1) json-canonicalization (1.0.0) json-jwt (1.15.3.1) activesupport (>= 4.2) @@ -346,10 +345,12 @@ GEM json-ld-preloaded (3.3.1) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (5.1.0) + json-schema (5.1.1) addressable (~> 2.8) + bigdecimal (~> 3.1) jsonapi-renderer (0.2.2) - jwt (2.7.1) + jwt (2.9.3) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -383,7 +384,7 @@ GEM llhttp-ffi (0.5.0) ffi-compiler (~> 1.0) rake (~> 13.0) - logger (1.6.1) + logger (1.6.3) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) @@ -405,16 +406,16 @@ GEM mime-types (3.6.0) logger mime-types-data (~> 3.2015) - mime-types-data (3.2024.1105) + mime-types-data (3.2024.1203) mini_mime (1.1.5) - mini_portile2 (2.8.7) - minitest (5.25.1) + mini_portile2 (2.8.8) + minitest (5.25.4) msgpack (1.7.5) multi_json (1.15.0) - mutex_m (0.2.0) + mutex_m (0.3.0) net-http (0.5.0) uri - net-imap (0.5.1) + net-imap (0.5.2) date net-protocol net-ldap (0.19.0) @@ -424,11 +425,11 @@ GEM timeout net-smtp (0.5.0) net-protocol - nio4r (2.7.3) - nokogiri (1.16.7) + nio4r (2.7.4) + nokogiri (1.17.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.7) + oj (3.16.8) bigdecimal (>= 3.0) ostruct (>= 0.2) omniauth (2.1.2) @@ -459,43 +460,44 @@ GEM validate_email validate_url webfinger (~> 1.2) - openssl (3.2.0) + openssl (3.2.1) openssl-signature_algorithm (1.3.0) openssl (> 2.0) opentelemetry-api (1.4.0) opentelemetry-common (0.21.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.29.0) + opentelemetry-exporter-otlp (0.29.1) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-sdk (~> 1.2) opentelemetry-semantic_conventions - opentelemetry-helpers-sql-obfuscation (0.2.0) + opentelemetry-helpers-sql-obfuscation (0.2.1) opentelemetry-common (~> 0.21) - opentelemetry-instrumentation-action_mailer (0.2.0) + opentelemetry-instrumentation-action_mailer (0.3.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.1) + opentelemetry-instrumentation-active_support (~> 0.7) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-action_pack (0.10.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.7.3) + opentelemetry-instrumentation-action_view (0.8.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.6) + opentelemetry-instrumentation-active_support (~> 0.7) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-active_job (0.7.8) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_model_serializers (0.20.2) + opentelemetry-instrumentation-active_model_serializers (0.21.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (>= 0.7.0) + opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-active_record (0.8.1) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_record (0.8.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_support (0.6.0) + opentelemetry-instrumentation-active_support (0.7.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (0.22.6) @@ -505,36 +507,36 @@ GEM opentelemetry-instrumentation-concurrent_ruby (0.21.4) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-excon (0.22.4) + opentelemetry-instrumentation-excon (0.22.5) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-faraday (0.24.6) + opentelemetry-instrumentation-faraday (0.24.8) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http (0.23.4) + opentelemetry-instrumentation-http (0.23.5) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http_client (0.22.7) + opentelemetry-instrumentation-http_client (0.22.8) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-net_http (0.22.7) + opentelemetry-instrumentation-net_http (0.22.8) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-pg (0.29.0) + opentelemetry-instrumentation-pg (0.29.1) opentelemetry-api (~> 1.0) opentelemetry-helpers-sql-obfuscation opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-rack (0.25.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rails (0.33.0) + opentelemetry-instrumentation-rails (0.34.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-action_mailer (~> 0.2.0) + opentelemetry-instrumentation-action_mailer (~> 0.3.0) opentelemetry-instrumentation-action_pack (~> 0.10.0) - opentelemetry-instrumentation-action_view (~> 0.7.0) + opentelemetry-instrumentation-action_view (~> 0.8.0) opentelemetry-instrumentation-active_job (~> 0.7.0) opentelemetry-instrumentation-active_record (~> 0.8.0) - opentelemetry-instrumentation-active_support (~> 0.6.0) + opentelemetry-instrumentation-active_support (~> 0.7.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-redis (0.25.7) opentelemetry-api (~> 1.0) @@ -544,7 +546,7 @@ GEM opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-registry (0.3.1) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.5.0) + opentelemetry-sdk (1.6.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) @@ -577,10 +579,11 @@ GEM activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.2.0) + psych (5.2.2) + date stringio public_suffix (6.0.1) - puma (6.4.3) + puma (6.5.0) nio4r (~> 2.0) pundit (2.4.0) activesupport (>= 3.0.0) @@ -606,23 +609,23 @@ GEM rack (< 3) rack-test (2.1.0) rack (>= 1.3) - rackup (1.0.0) + rackup (1.0.1) rack (< 3) webrick - rails (7.2.2) - actioncable (= 7.2.2) - actionmailbox (= 7.2.2) - actionmailer (= 7.2.2) - actionpack (= 7.2.2) - actiontext (= 7.2.2) - actionview (= 7.2.2) - activejob (= 7.2.2) - activemodel (= 7.2.2) - activerecord (= 7.2.2) - activestorage (= 7.2.2) - activesupport (= 7.2.2) + rails (7.2.2.1) + actioncable (= 7.2.2.1) + actionmailbox (= 7.2.2.1) + actionmailer (= 7.2.2.1) + actionpack (= 7.2.2.1) + actiontext (= 7.2.2.1) + actionview (= 7.2.2.1) + activejob (= 7.2.2.1) + activemodel (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) bundler (>= 1.15.0) - railties (= 7.2.2) + railties (= 7.2.2.1) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -631,15 +634,15 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.2.2) - actionpack (= 7.2.2) - activesupport (= 7.2.2) + railties (7.2.2.1) + actionpack (= 7.2.2.1) + activesupport (= 7.2.2.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -653,7 +656,7 @@ GEM link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.7.0) + rdoc (6.10.0) psych (>= 4.0.0) redcarpet (3.6.0) redis (4.8.1) @@ -661,15 +664,15 @@ GEM redis (>= 4) redlock (1.3.2) redis (>= 3.0.0, < 6.0) - regexp_parser (2.9.2) - reline (0.5.11) + regexp_parser (2.9.3) + reline (0.6.0) io-console (~> 0.5) - request_store (1.6.0) + request_store (1.7.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.9) + rexml (3.4.0) rotp (6.3.0) rouge (4.5.1) rpam2 (4.0.2) @@ -704,22 +707,22 @@ GEM rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 8) - rspec-support (3.13.1) - rubocop (1.66.1) + rspec-support (3.13.2) + rubocop (1.69.2) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.3) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.37.0) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) - rubocop-performance (1.22.1) + rubocop-performance (1.23.0) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) rubocop-rails (2.27.0) @@ -727,7 +730,7 @@ GEM rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.2.0) + rubocop-rspec (3.3.0) rubocop (~> 1.61) rubocop-rspec_rails (2.30.0) rubocop (~> 1.61) @@ -741,8 +744,8 @@ GEM ffi (~> 1.12) logger rubyzip (2.3.2) - rufus-scheduler (3.9.1) - fugit (~> 1.1, >= 1.1.6) + rufus-scheduler (3.9.2) + fugit (~> 1.1, >= 1.11.1) safety_net_attestation (0.4.0) jwt (~> 2.0) sanitize (6.1.3) @@ -751,14 +754,14 @@ GEM scenic (1.8.0) activerecord (>= 4.0.0) railties (>= 4.0.0) - securerandom (0.3.2) - selenium-webdriver (4.26.0) + securerandom (0.4.1) + selenium-webdriver (4.27.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - semantic_range (3.0.0) + semantic_range (3.1.0) shoulda-matchers (6.4.0) activesupport (>= 5.2.0) sidekiq (6.5.12) @@ -805,10 +808,10 @@ GEM unicode-display_width (>= 1.1.1, < 3) terrapin (1.0.1) climate_control - test-prof (1.4.2) + test-prof (1.4.3) thor (1.3.2) tilt (2.4.0) - timeout (0.4.2) + timeout (0.4.3) tpm-key_attestation (0.12.1) bindata (~> 2.4) openssl (> 2.0) @@ -834,8 +837,8 @@ GEM unf_ext unf_ext (0.0.9.1) unicode-display_width (2.6.0) - uri (0.13.1) - useragent (0.16.10) + uri (1.0.2) + useragent (0.16.11) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -844,9 +847,8 @@ GEM public_suffix warden (1.2.9) rack (>= 2.0.9) - webauthn (3.1.0) + webauthn (3.2.2) android_key_attestation (~> 0.3.0) - awrence (~> 1.1) bindata (~> 2.4) cbor (~> 0.5.9) cose (~> 1.1) @@ -865,7 +867,7 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - webrick (1.9.0) + webrick (1.9.1) websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) @@ -957,7 +959,7 @@ DEPENDENCIES opentelemetry-api (~> 1.4.0) opentelemetry-exporter-otlp (~> 0.29.0) opentelemetry-instrumentation-active_job (~> 0.7.1) - opentelemetry-instrumentation-active_model_serializers (~> 0.20.1) + opentelemetry-instrumentation-active_model_serializers (~> 0.21.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2) opentelemetry-instrumentation-excon (~> 0.22.0) opentelemetry-instrumentation-faraday (~> 0.24.1) @@ -966,7 +968,7 @@ DEPENDENCIES opentelemetry-instrumentation-net_http (~> 0.22.4) opentelemetry-instrumentation-pg (~> 0.29.0) opentelemetry-instrumentation-rack (~> 0.25.0) - opentelemetry-instrumentation-rails (~> 0.33.0) + opentelemetry-instrumentation-rails (~> 0.34.0) opentelemetry-instrumentation-redis (~> 0.25.3) opentelemetry-instrumentation-sidekiq (~> 0.25.2) opentelemetry-sdk (~> 1.4) @@ -1031,7 +1033,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.3.5p100 + ruby 3.3.6p108 BUNDLED WITH - 2.5.22 + 2.6.1 diff --git a/Vagrantfile b/Vagrantfile index 89f5536edc..ce456060cd 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -174,7 +174,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| if config.vm.networks.any? { |type, options| type == :private_network } config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'actimeo=1'] else - config.vm.synced_folder ".", "/vagrant" + config.vm.synced_folder ".", "/vagrant", type: "rsync", create: true, rsync__args: ["--verbose", "--archive", "--delete", "-z"] end # Otherwise, you can access the site at http://localhost:3000 and http://localhost:4000 , http://localhost:8080 diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 48685db17a..3dca3a9614 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -8,6 +8,7 @@ module Admin layout 'admin' before_action :set_cache_headers + before_action :set_referrer_policy_header after_action :verify_authorized @@ -17,6 +18,10 @@ module Admin response.cache_control.replace(private: true, no_store: true) end + def set_referrer_policy_header + response.headers['Referrer-Policy'] = 'same-origin' + end + def set_user @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) end diff --git a/app/controllers/admin/terms_of_service/distributions_controller.rb b/app/controllers/admin/terms_of_service/distributions_controller.rb new file mode 100644 index 0000000000..c639b083dd --- /dev/null +++ b/app/controllers/admin/terms_of_service/distributions_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::DistributionsController < Admin::BaseController + before_action :set_terms_of_service + + def create + authorize @terms_of_service, :distribute? + @terms_of_service.touch(:notification_sent_at) + Admin::DistributeTermsOfServiceNotificationWorker.perform_async(@terms_of_service.id) + redirect_to admin_terms_of_service_index_path + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.find(params[:terms_of_service_id]) + end +end diff --git a/app/controllers/admin/terms_of_service/drafts_controller.rb b/app/controllers/admin/terms_of_service/drafts_controller.rb new file mode 100644 index 0000000000..5d32c0bd83 --- /dev/null +++ b/app/controllers/admin/terms_of_service/drafts_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::DraftsController < Admin::BaseController + before_action :set_terms_of_service + + def show + authorize :terms_of_service, :create? + end + + def update + authorize @terms_of_service, :update? + + @terms_of_service.published_at = Time.now.utc if params[:action_type] == 'publish' + + if @terms_of_service.update(resource_params) + log_action(:publish, @terms_of_service) if @terms_of_service.published? + redirect_to @terms_of_service.published? ? admin_terms_of_service_index_path : admin_terms_of_service_draft_path + else + render :show + end + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text) + end + + def current_terms_of_service + TermsOfService.live.first + end + + def resource_params + params.require(:terms_of_service).permit(:text, :changelog) + end +end diff --git a/app/controllers/admin/terms_of_service/generates_controller.rb b/app/controllers/admin/terms_of_service/generates_controller.rb new file mode 100644 index 0000000000..28037674a3 --- /dev/null +++ b/app/controllers/admin/terms_of_service/generates_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::GeneratesController < Admin::BaseController + before_action :set_instance_presenter + + def show + authorize :terms_of_service, :create? + + @generator = TermsOfService::Generator.new( + domain: @instance_presenter.domain, + admin_email: @instance_presenter.contact.email + ) + end + + def create + authorize :terms_of_service, :create? + + @generator = TermsOfService::Generator.new(resource_params) + + if @generator.valid? + TermsOfService.create!(text: @generator.render) + redirect_to admin_terms_of_service_draft_path + else + render :show + end + end + + private + + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + + def resource_params + params.require(:terms_of_service_generator).permit(*TermsOfService::Generator::VARIABLES) + end +end diff --git a/app/controllers/admin/terms_of_service/histories_controller.rb b/app/controllers/admin/terms_of_service/histories_controller.rb new file mode 100644 index 0000000000..8f12341aea --- /dev/null +++ b/app/controllers/admin/terms_of_service/histories_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::HistoriesController < Admin::BaseController + def show + authorize :terms_of_service, :index? + @terms_of_service = TermsOfService.published.all + end +end diff --git a/app/controllers/admin/terms_of_service/previews_controller.rb b/app/controllers/admin/terms_of_service/previews_controller.rb new file mode 100644 index 0000000000..0a1a966751 --- /dev/null +++ b/app/controllers/admin/terms_of_service/previews_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::PreviewsController < Admin::BaseController + before_action :set_terms_of_service + + def show + authorize @terms_of_service, :distribute? + @user_count = @terms_of_service.scope_for_notification.count + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.find(params[:terms_of_service_id]) + end +end diff --git a/app/controllers/admin/terms_of_service/tests_controller.rb b/app/controllers/admin/terms_of_service/tests_controller.rb new file mode 100644 index 0000000000..e2483c1005 --- /dev/null +++ b/app/controllers/admin/terms_of_service/tests_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::TestsController < Admin::BaseController + before_action :set_terms_of_service + + def create + authorize @terms_of_service, :distribute? + UserMailer.terms_of_service_changed(current_user, @terms_of_service).deliver_later! + redirect_to admin_terms_of_service_preview_path(@terms_of_service) + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.find(params[:terms_of_service_id]) + end +end diff --git a/app/controllers/admin/terms_of_service_controller.rb b/app/controllers/admin/terms_of_service_controller.rb new file mode 100644 index 0000000000..f70bfd2071 --- /dev/null +++ b/app/controllers/admin/terms_of_service_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Admin::TermsOfServiceController < Admin::BaseController + def index + authorize :terms_of_service, :index? + @terms_of_service = TermsOfService.live.first + end +end diff --git a/app/controllers/api/v1/instances/terms_of_services_controller.rb b/app/controllers/api/v1/instances/terms_of_services_controller.rb new file mode 100644 index 0000000000..e9e8e8ef55 --- /dev/null +++ b/app/controllers/api/v1/instances/terms_of_services_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseController + before_action :set_terms_of_service + + def show + cache_even_if_authenticated! + render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.live.first! + end +end diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index ad1b82cb52..2833687a38 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Polls::VotesController < Api::BaseController private def set_poll - @poll = Poll.attached.find(params[:poll_id]) + @poll = Poll.find(params[:poll_id]) authorize @poll.status, :show? rescue Mastodon::NotPermittedError not_found diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index ffc70a8496..b4c25476e8 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -15,7 +15,7 @@ class Api::V1::PollsController < Api::BaseController private def set_poll - @poll = Poll.attached.find(params[:id]) + @poll = Poll.find(params[:id]) authorize @poll.status, :show? rescue Mastodon::NotPermittedError not_found diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index b15dd50131..10a3442344 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -27,7 +27,9 @@ class Api::V1::Trends::TagsController < Api::BaseController end def tags_from_trends - Trends.tags.query.allowed + scope = Trends.tags.query.allowed.in_locale(content_locale) + scope = scope.filtered_for(current_account) if user_signed_in? + scope end def next_path diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d493bd43bf..1b071e8655 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,7 +22,6 @@ class ApplicationController < ActionController::Base helper_method :use_seamless_external_login? helper_method :sso_account_settings helper_method :limited_federation_mode? - helper_method :body_class_string helper_method :skip_csrf_meta_tags? rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request @@ -71,7 +70,13 @@ class ApplicationController < ActionController::Base end def require_functional! - redirect_to edit_user_registration_path unless current_user.functional? + return if current_user.functional? + + if current_user.confirmed? + redirect_to edit_user_registration_path + else + redirect_to auth_setup_path + end end def skip_csrf_meta_tags? @@ -158,10 +163,6 @@ class ApplicationController < ActionController::Base current_user.setting_theme end - def body_class_string - @body_classes || '' - end - def respond_with_error(code) respond_to do |format| format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 4d94c80158..34c7599553 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -142,4 +142,12 @@ class Auth::RegistrationsController < Devise::RegistrationsController def set_cache_headers response.cache_control.replace(private: true, no_store: true) end + + def is_flashing_format? # rubocop:disable Naming/PredicateName + if params[:action] == 'create' + false # Disable flash messages for sign-up + else + super + end + end end diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index 1823b5b8ed..b1b09f2aab 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -28,7 +28,7 @@ module CacheConcern def render_with_cache(**options) raise ArgumentError, 'Only JSON render calls are supported' unless options.key?(:json) || block_given? - key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':') + key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields]&.join(',')].compact.join(':') expires_in = options.delete(:expires_in) || 3.minutes body = Rails.cache.read(key, raw: true) diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb index 249bb20a25..1d8ee43507 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -7,6 +7,7 @@ module WebAppControllerConcern vary_by 'Accept, Accept-Language, Cookie' before_action :redirect_unauthenticated_to_permalinks! + before_action :set_referer_header content_security_policy do |p| policy = ContentSecurityPolicy.new @@ -41,4 +42,10 @@ module WebAppControllerConcern end end end + + protected + + def set_referer_header + response.set_header('Referrer-Policy', Setting.allow_referrer_origin ? 'origin' : 'same-origin') + end end diff --git a/app/controllers/terms_of_service_controller.rb b/app/controllers/terms_of_service_controller.rb new file mode 100644 index 0000000000..672fb07915 --- /dev/null +++ b/app/controllers/terms_of_service_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class TermsOfServiceController < ApplicationController + include WebAppControllerConcern + + skip_before_action :require_functional! + + def show + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3d5025724f..e1ca536c7d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -143,10 +143,11 @@ module ApplicationHelper end def body_classes - output = body_class_string.split + output = [] output << content_for(:body_classes) output << "theme-#{current_theme.parameterize}" output << 'system-font' if current_account&.user&.setting_system_font_ui + output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion') output << 'rtl' if locale_direction == 'rtl' output.compact_blank.join(' ') diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index 9d5a2e2478..e827834975 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -64,6 +64,10 @@ module FormattingHelper end end + def markdown(text) + Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true).render(text).html_safe # rubocop:disable Rails/OutputSafety + end + private def wrapped_status_content_format(status) diff --git a/app/javascript/entrypoints/embed.tsx b/app/javascript/entrypoints/embed.tsx index f8c824d287..cb62727563 100644 --- a/app/javascript/entrypoints/embed.tsx +++ b/app/javascript/entrypoints/embed.tsx @@ -60,6 +60,10 @@ window.addEventListener('message', (e) => { const data = e.data; + // Only set overflow to `hidden` once we got the expected `message` so the post can still be scrolled if + // embedded without parent Javascript support + document.body.style.overflow = 'hidden'; + // We use a timeout to allow for the React page to render before calculating the height afterInitialRender(() => { window.parent.postMessage( diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index c1e8418014..9e8ff9caa1 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -230,62 +230,6 @@ function loaded() { } }, ); - - Rails.delegate( - document, - 'button.status__content__spoiler-link', - 'click', - function () { - if (!(this instanceof HTMLButtonElement)) return; - - const statusEl = this.parentNode?.parentNode; - - if ( - !( - statusEl instanceof HTMLDivElement && - statusEl.classList.contains('.status__content') - ) - ) - return; - - if (statusEl.dataset.spoiler === 'expanded') { - statusEl.dataset.spoiler = 'folded'; - this.textContent = new IntlMessageFormat( - localeData['status.show_more'] ?? 'Show more', - locale, - ).format() as string; - } else { - statusEl.dataset.spoiler = 'expanded'; - this.textContent = new IntlMessageFormat( - localeData['status.show_less'] ?? 'Show less', - locale, - ).format() as string; - } - }, - ); - - document - .querySelectorAll('button.status__content__spoiler-link') - .forEach((spoilerLink) => { - const statusEl = spoilerLink.parentNode?.parentNode; - - if ( - !( - statusEl instanceof HTMLDivElement && - statusEl.classList.contains('.status__content') - ) - ) - return; - - const message = - statusEl.dataset.spoiler === 'expanded' - ? (localeData['status.show_less'] ?? 'Show less') - : (localeData['status.show_more'] ?? 'Show more'); - spoilerLink.textContent = new IntlMessageFormat( - message, - locale, - ).format() as string; - }); } Rails.delegate( @@ -439,6 +383,24 @@ Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { }); }); +Rails.delegate(document, '.rules-list button', 'click', ({ target }) => { + if (!(target instanceof HTMLElement)) { + return; + } + + const button = target.closest('button'); + + if (!button) { + return; + } + + if (button.ariaExpanded === 'true') { + button.ariaExpanded = 'false'; + } else { + button.ariaExpanded = 'true'; + } +}); + function main() { ready(loaded).catch((error: unknown) => { console.error(error); diff --git a/app/javascript/hooks/useLinks.ts b/app/javascript/hooks/useLinks.ts index f08b9500da..c99f3f4199 100644 --- a/app/javascript/hooks/useLinks.ts +++ b/app/javascript/hooks/useLinks.ts @@ -2,6 +2,8 @@ import { useCallback } from 'react'; import { useHistory } from 'react-router-dom'; +import { isFulfilled, isRejected } from '@reduxjs/toolkit'; + import { openURL } from 'mastodon/actions/search'; import { useAppDispatch } from 'mastodon/store'; @@ -28,12 +30,22 @@ export const useLinks = () => { ); const handleMentionClick = useCallback( - (element: HTMLAnchorElement) => { - dispatch( - openURL(element.href, history, () => { + async (element: HTMLAnchorElement) => { + const result = await dispatch(openURL({ url: element.href })); + + if (isFulfilled(result)) { + if (result.payload.accounts[0]) { + history.push(`/@${result.payload.accounts[0].acct}`); + } else if (result.payload.statuses[0]) { + history.push( + `/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`, + ); + } else { window.location.href = element.href; - }), - ); + } + } else if (isRejected(result)) { + window.location.href = element.href; + } }, [dispatch, history], ); @@ -48,7 +60,7 @@ export const useLinks = () => { if (isMentionClick(target)) { e.preventDefault(); - handleMentionClick(target); + void handleMentionClick(target); } else if (isHashtagClick(target)) { e.preventDefault(); handleHashtagClick(target); diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js deleted file mode 100644 index 48dee2587f..0000000000 --- a/app/javascript/mastodon/actions/alerts.js +++ /dev/null @@ -1,66 +0,0 @@ -import { defineMessages } from 'react-intl'; - -import { AxiosError } from 'axios'; - -const messages = defineMessages({ - unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, - unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, - rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' }, - rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' }, -}); - -export const ALERT_SHOW = 'ALERT_SHOW'; -export const ALERT_DISMISS = 'ALERT_DISMISS'; -export const ALERT_CLEAR = 'ALERT_CLEAR'; -export const ALERT_NOOP = 'ALERT_NOOP'; - -export const dismissAlert = alert => ({ - type: ALERT_DISMISS, - alert, -}); - -export const clearAlert = () => ({ - type: ALERT_CLEAR, -}); - -export const showAlert = alert => ({ - type: ALERT_SHOW, - alert, -}); - -export const showAlertForError = (error, skipNotFound = false) => { - if (error.response) { - const { data, status, statusText, headers } = error.response; - - // Skip these errors as they are reflected in the UI - if (skipNotFound && (status === 404 || status === 410)) { - return { type: ALERT_NOOP }; - } - - // Rate limit errors - if (status === 429 && headers['x-ratelimit-reset']) { - return showAlert({ - title: messages.rateLimitedTitle, - message: messages.rateLimitedMessage, - values: { 'retry_time': new Date(headers['x-ratelimit-reset']) }, - }); - } - - return showAlert({ - title: `${status}`, - message: data.error || statusText, - }); - } - - // An aborted request, e.g. due to reloading the browser window, it not really error - if (error.code === AxiosError.ECONNABORTED) { - return { type: ALERT_NOOP }; - } - - console.error(error); - - return showAlert({ - title: messages.unexpectedTitle, - message: messages.unexpectedMessage, - }); -}; diff --git a/app/javascript/mastodon/actions/alerts.ts b/app/javascript/mastodon/actions/alerts.ts new file mode 100644 index 0000000000..a521f3ef35 --- /dev/null +++ b/app/javascript/mastodon/actions/alerts.ts @@ -0,0 +1,90 @@ +import { defineMessages } from 'react-intl'; +import type { MessageDescriptor } from 'react-intl'; + +import { AxiosError } from 'axios'; +import type { AxiosResponse } from 'axios'; + +interface Alert { + title: string | MessageDescriptor; + message: string | MessageDescriptor; + values?: Record; +} + +interface ApiErrorResponse { + error?: string; +} + +const messages = defineMessages({ + unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, + unexpectedMessage: { + id: 'alert.unexpected.message', + defaultMessage: 'An unexpected error occurred.', + }, + rateLimitedTitle: { + id: 'alert.rate_limited.title', + defaultMessage: 'Rate limited', + }, + rateLimitedMessage: { + id: 'alert.rate_limited.message', + defaultMessage: 'Please retry after {retry_time, time, medium}.', + }, +}); + +export const ALERT_SHOW = 'ALERT_SHOW'; +export const ALERT_DISMISS = 'ALERT_DISMISS'; +export const ALERT_CLEAR = 'ALERT_CLEAR'; +export const ALERT_NOOP = 'ALERT_NOOP'; + +export const dismissAlert = (alert: Alert) => ({ + type: ALERT_DISMISS, + alert, +}); + +export const clearAlert = () => ({ + type: ALERT_CLEAR, +}); + +export const showAlert = (alert: Alert) => ({ + type: ALERT_SHOW, + alert, +}); + +export const showAlertForError = (error: unknown, skipNotFound = false) => { + if (error instanceof AxiosError && error.response) { + const { status, statusText, headers } = error.response; + const { data } = error.response as AxiosResponse; + + // Skip these errors as they are reflected in the UI + if (skipNotFound && (status === 404 || status === 410)) { + return { type: ALERT_NOOP }; + } + + // Rate limit errors + if (status === 429 && headers['x-ratelimit-reset']) { + return showAlert({ + title: messages.rateLimitedTitle, + message: messages.rateLimitedMessage, + values: { + retry_time: new Date(headers['x-ratelimit-reset'] as string), + }, + }); + } + + return showAlert({ + title: `${status}`, + message: data.error ?? statusText, + }); + } + + // An aborted request, e.g. due to reloading the browser window, it not really error + if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) { + return { type: ALERT_NOOP }; + } + + console.error(error); + + return showAlert({ + title: messages.unexpectedTitle, + message: messages.unexpectedMessage, + }); +}; diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 516a7a7973..047cf11910 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -1,10 +1,12 @@ +import { createPollFromServerJSON } from 'mastodon/models/poll'; + import { importAccounts } from '../accounts_typed'; -import { normalizeStatus, normalizePoll } from './normalizer'; +import { normalizeStatus } from './normalizer'; +import { importPolls } from './polls'; export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; -export const POLLS_IMPORT = 'POLLS_IMPORT'; export const FILTERS_IMPORT = 'FILTERS_IMPORT'; function pushUnique(array, object) { @@ -25,10 +27,6 @@ export function importFilters(filters) { return { type: FILTERS_IMPORT, filters }; } -export function importPolls(polls) { - return { type: POLLS_IMPORT, polls }; -} - export function importFetchedAccount(account) { return importFetchedAccounts([account]); } @@ -73,7 +71,7 @@ export function importFetchedStatuses(statuses) { } if (status.poll?.id) { - pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); + pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id))); } if (status.card) { @@ -83,15 +81,9 @@ export function importFetchedStatuses(statuses) { statuses.forEach(processStatus); - dispatch(importPolls(polls)); + dispatch(importPolls({ polls })); dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); dispatch(importFilters(filters)); }; } - -export function importFetchedPoll(poll) { - return (dispatch, getState) => { - dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))])); - }; -} diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index c09a3f442c..c2918ef8d5 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -1,15 +1,12 @@ import escapeTextContentForBrowser from 'escape-html'; +import { makeEmojiMap } from 'mastodon/models/custom_emoji'; + import emojify from '../../features/emoji/emoji'; import { expandSpoilers } from '../../initial_state'; const domParser = new DOMParser(); -const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => { - obj[`:${emoji.shortcode}:`] = emoji; - return obj; -}, {}); - export function searchTextFromRawStatus (status) { const spoilerText = status.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); @@ -112,38 +109,6 @@ export function normalizeStatusTranslation(translation, status) { return normalTranslation; } -export function normalizePoll(poll, normalOldPoll) { - const normalPoll = { ...poll }; - const emojiMap = makeEmojiMap(poll.emojis); - - normalPoll.options = poll.options.map((option, index) => { - const normalOption = { - ...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 normalPoll; -} - -export function normalizePollOptionTranslation(translation, poll) { - const emojiMap = makeEmojiMap(poll.get('emojis').toJS()); - - const normalTranslation = { - ...translation, - titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap), - }; - - return normalTranslation; -} - export function normalizeAnnouncement(announcement) { const normalAnnouncement = { ...announcement }; const emojiMap = makeEmojiMap(normalAnnouncement.emojis); diff --git a/app/javascript/mastodon/actions/importer/polls.ts b/app/javascript/mastodon/actions/importer/polls.ts new file mode 100644 index 0000000000..5bbe7d57d6 --- /dev/null +++ b/app/javascript/mastodon/actions/importer/polls.ts @@ -0,0 +1,7 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { Poll } from 'mastodon/models/poll'; + +export const importPolls = createAction<{ polls: Poll[] }>( + 'poll/importMultiple', +); diff --git a/app/javascript/mastodon/actions/polls.js b/app/javascript/mastodon/actions/polls.js deleted file mode 100644 index aa49341444..0000000000 --- a/app/javascript/mastodon/actions/polls.js +++ /dev/null @@ -1,61 +0,0 @@ -import api from '../api'; - -import { importFetchedPoll } from './importer'; - -export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; -export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; -export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; - -export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; -export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; -export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; - -export const vote = (pollId, choices) => (dispatch) => { - dispatch(voteRequest()); - - api().post(`/api/v1/polls/${pollId}/votes`, { choices }) - .then(({ data }) => { - dispatch(importFetchedPoll(data)); - dispatch(voteSuccess(data)); - }) - .catch(err => dispatch(voteFail(err))); -}; - -export const fetchPoll = pollId => (dispatch) => { - dispatch(fetchPollRequest()); - - api().get(`/api/v1/polls/${pollId}`) - .then(({ data }) => { - dispatch(importFetchedPoll(data)); - dispatch(fetchPollSuccess(data)); - }) - .catch(err => dispatch(fetchPollFail(err))); -}; - -export const voteRequest = () => ({ - type: POLL_VOTE_REQUEST, -}); - -export const voteSuccess = poll => ({ - type: POLL_VOTE_SUCCESS, - poll, -}); - -export const voteFail = error => ({ - type: POLL_VOTE_FAIL, - error, -}); - -export const fetchPollRequest = () => ({ - type: POLL_FETCH_REQUEST, -}); - -export const fetchPollSuccess = poll => ({ - type: POLL_FETCH_SUCCESS, - poll, -}); - -export const fetchPollFail = error => ({ - type: POLL_FETCH_FAIL, - error, -}); diff --git a/app/javascript/mastodon/actions/polls.ts b/app/javascript/mastodon/actions/polls.ts new file mode 100644 index 0000000000..28f729394b --- /dev/null +++ b/app/javascript/mastodon/actions/polls.ts @@ -0,0 +1,40 @@ +import { apiGetPoll, apiPollVote } from 'mastodon/api/polls'; +import type { ApiPollJSON } from 'mastodon/api_types/polls'; +import { createPollFromServerJSON } from 'mastodon/models/poll'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from 'mastodon/store/typed_functions'; + +import { importPolls } from './importer/polls'; + +export const importFetchedPoll = createAppAsyncThunk( + 'poll/importFetched', + (args: { poll: ApiPollJSON }, { dispatch, getState }) => { + const { poll } = args; + + dispatch( + importPolls({ + polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))], + }), + ); + }, +); + +export const vote = createDataLoadingThunk( + 'poll/vote', + ({ pollId, choices }: { pollId: string; choices: string[] }) => + apiPollVote(pollId, choices), + async (poll, { dispatch, discardLoadData }) => { + await dispatch(importFetchedPoll({ poll })); + return discardLoadData; + }, +); + +export const fetchPoll = createDataLoadingThunk( + 'poll/fetch', + ({ pollId }: { pollId: string }) => apiGetPoll(pollId), + async (poll, { dispatch }) => { + await dispatch(importFetchedPoll({ poll })); + }, +); diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js deleted file mode 100644 index bde17ae0db..0000000000 --- a/app/javascript/mastodon/actions/search.js +++ /dev/null @@ -1,215 +0,0 @@ -import { fromJS } from 'immutable'; - -import { searchHistory } from 'mastodon/settings'; - -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts, importFetchedStatuses } from './importer'; - -export const SEARCH_CHANGE = 'SEARCH_CHANGE'; -export const SEARCH_CLEAR = 'SEARCH_CLEAR'; -export const SEARCH_SHOW = 'SEARCH_SHOW'; - -export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; -export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; -export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; - -export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; -export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; -export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; - -export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE'; - -export function changeSearch(value) { - return { - type: SEARCH_CHANGE, - value, - }; -} - -export function clearSearch() { - return { - type: SEARCH_CLEAR, - }; -} - -export function submitSearch(type) { - return (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const signedIn = !!getState().getIn(['meta', 'me']); - - if (value.length === 0) { - dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type)); - return; - } - - dispatch(fetchSearchRequest(type)); - - api().get('/api/v2/search', { - params: { - q: value, - resolve: signedIn, - limit: 11, - type, - }, - }).then(response => { - if (response.data.accounts) { - dispatch(importFetchedAccounts(response.data.accounts)); - } - - if (response.data.statuses) { - dispatch(importFetchedStatuses(response.data.statuses)); - } - - dispatch(fetchSearchSuccess(response.data, value, type)); - dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(fetchSearchFail(error)); - }); - }; -} - -export function fetchSearchRequest(searchType) { - return { - type: SEARCH_FETCH_REQUEST, - searchType, - }; -} - -export function fetchSearchSuccess(results, searchTerm, searchType) { - return { - type: SEARCH_FETCH_SUCCESS, - results, - searchType, - searchTerm, - }; -} - -export function fetchSearchFail(error) { - return { - type: SEARCH_FETCH_FAIL, - error, - }; -} - -export const expandSearch = type => (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const offset = getState().getIn(['search', 'results', type]).size - 1; - - dispatch(expandSearchRequest(type)); - - api().get('/api/v2/search', { - params: { - q: value, - type, - offset, - limit: 11, - }, - }).then(({ data }) => { - if (data.accounts) { - dispatch(importFetchedAccounts(data.accounts)); - } - - if (data.statuses) { - dispatch(importFetchedStatuses(data.statuses)); - } - - dispatch(expandSearchSuccess(data, value, type)); - dispatch(fetchRelationships(data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(expandSearchFail(error)); - }); -}; - -export const expandSearchRequest = (searchType) => ({ - type: SEARCH_EXPAND_REQUEST, - searchType, -}); - -export const expandSearchSuccess = (results, searchTerm, searchType) => ({ - type: SEARCH_EXPAND_SUCCESS, - results, - searchTerm, - searchType, -}); - -export const expandSearchFail = error => ({ - type: SEARCH_EXPAND_FAIL, - error, -}); - -export const showSearch = () => ({ - type: SEARCH_SHOW, -}); - -export const openURL = (value, history, onFailure) => (dispatch, getState) => { - const signedIn = !!getState().getIn(['meta', 'me']); - - if (!signedIn) { - if (onFailure) { - onFailure(); - } - - return; - } - - dispatch(fetchSearchRequest()); - - api().get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => { - if (response.data.accounts?.length > 0) { - dispatch(importFetchedAccounts(response.data.accounts)); - history.push(`/@${response.data.accounts[0].acct}`); - } else if (response.data.statuses?.length > 0) { - dispatch(importFetchedStatuses(response.data.statuses)); - history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); - } else if (onFailure) { - onFailure(); - } - - dispatch(fetchSearchSuccess(response.data, value)); - }).catch(err => { - dispatch(fetchSearchFail(err)); - - if (onFailure) { - onFailure(); - } - }); -}; - -export const clickSearchResult = (q, type) => (dispatch, getState) => { - const previous = getState().getIn(['search', 'recent']); - - if (previous.some(x => x.get('q') === q && x.get('type') === type)) { - return; - } - - const me = getState().getIn(['meta', 'me']); - const current = previous.add(fromJS({ type, q })).takeLast(4); - - searchHistory.set(me, current.toJS()); - dispatch(updateSearchHistory(current)); -}; - -export const forgetSearchResult = q => (dispatch, getState) => { - const previous = getState().getIn(['search', 'recent']); - const me = getState().getIn(['meta', 'me']); - const current = previous.filterNot(result => result.get('q') === q); - - searchHistory.set(me, current.toJS()); - dispatch(updateSearchHistory(current)); -}; - -export const updateSearchHistory = recent => ({ - type: SEARCH_HISTORY_UPDATE, - recent, -}); - -export const hydrateSearch = () => (dispatch, getState) => { - const me = getState().getIn(['meta', 'me']); - const history = searchHistory.get(me); - - if (history !== null) { - dispatch(updateSearchHistory(history)); - } -}; diff --git a/app/javascript/mastodon/actions/search.ts b/app/javascript/mastodon/actions/search.ts new file mode 100644 index 0000000000..7dd174e202 --- /dev/null +++ b/app/javascript/mastodon/actions/search.ts @@ -0,0 +1,148 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { apiGetSearch } from 'mastodon/api/search'; +import type { ApiSearchType } from 'mastodon/api_types/search'; +import type { + RecentSearch, + SearchType as RecentSearchType, +} from 'mastodon/models/search'; +import { searchHistory } from 'mastodon/settings'; +import { + createDataLoadingThunk, + createAppAsyncThunk, +} from 'mastodon/store/typed_functions'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; + +export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE'; + +export const submitSearch = createDataLoadingThunk( + 'search/submit', + async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => { + const signedIn = !!getState().meta.get('me'); + + return apiGetSearch({ + q, + type, + resolve: signedIn, + limit: 11, + }); + }, + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + dispatch(fetchRelationships(data.accounts.map((account) => account.id))); + } + + if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: false, + }, +); + +export const expandSearch = createDataLoadingThunk( + 'search/expand', + async ({ type }: { type: ApiSearchType }, { getState }) => { + const q = getState().search.q; + const results = getState().search.results; + const offset = results?.[type].length; + + return apiGetSearch({ + q, + type, + limit: 11, + offset, + }); + }, + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + dispatch(fetchRelationships(data.accounts.map((account) => account.id))); + } + + if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: true, + }, +); + +export const openURL = createDataLoadingThunk( + 'search/openURL', + ({ url }: { url: string }) => + apiGetSearch({ + q: url, + resolve: true, + limit: 1, + }), + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + } else if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: true, + }, +); + +export const clickSearchResult = createAppAsyncThunk( + 'search/clickResult', + ( + { q, type }: { q: string; type?: RecentSearchType }, + { dispatch, getState }, + ) => { + const previous = getState().search.recent; + + if (previous.some((x) => x.q === q && x.type === type)) { + return; + } + + const me = getState().meta.get('me') as string; + const current = [{ type, q }, ...previous].slice(0, 4); + + searchHistory.set(me, current); + dispatch(updateSearchHistory(current)); + }, +); + +export const forgetSearchResult = createAppAsyncThunk( + 'search/forgetResult', + (q: string, { dispatch, getState }) => { + const previous = getState().search.recent; + const me = getState().meta.get('me') as string; + const current = previous.filter((result) => result.q !== q); + + searchHistory.set(me, current); + dispatch(updateSearchHistory(current)); + }, +); + +export const updateSearchHistory = createAction( + 'search/updateHistory', +); + +export const hydrateSearch = createAppAsyncThunk( + 'search/hydrate', + (_args, { dispatch, getState }) => { + const me = getState().meta.get('me') as string; + const history = searchHistory.get(me) as RecentSearch[] | null; + + if (history !== null) { + dispatch(updateSearchHistory(history)); + } + }, +); diff --git a/app/javascript/mastodon/actions/suggestions.js b/app/javascript/mastodon/actions/suggestions.js deleted file mode 100644 index 258ffa901d..0000000000 --- a/app/javascript/mastodon/actions/suggestions.js +++ /dev/null @@ -1,58 +0,0 @@ -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; - -export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; -export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; -export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; - -export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; - -export function fetchSuggestions(withRelationships = false) { - return (dispatch) => { - dispatch(fetchSuggestionsRequest()); - - api().get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => { - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(fetchSuggestionsSuccess(response.data)); - - if (withRelationships) { - dispatch(fetchRelationships(response.data.map(item => item.account.id))); - } - }).catch(error => dispatch(fetchSuggestionsFail(error))); - }; -} - -export function fetchSuggestionsRequest() { - return { - type: SUGGESTIONS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchSuggestionsSuccess(suggestions) { - return { - type: SUGGESTIONS_FETCH_SUCCESS, - suggestions, - skipLoading: true, - }; -} - -export function fetchSuggestionsFail(error) { - return { - type: SUGGESTIONS_FETCH_FAIL, - error, - skipLoading: true, - skipAlert: true, - }; -} - -export const dismissSuggestion = accountId => (dispatch) => { - dispatch({ - type: SUGGESTIONS_DISMISS, - id: accountId, - }); - - api().delete(`/api/v1/suggestions/${accountId}`).catch(() => {}); -}; diff --git a/app/javascript/mastodon/actions/suggestions.ts b/app/javascript/mastodon/actions/suggestions.ts new file mode 100644 index 0000000000..0eadfa6b47 --- /dev/null +++ b/app/javascript/mastodon/actions/suggestions.ts @@ -0,0 +1,24 @@ +import { + apiGetSuggestions, + apiDeleteSuggestion, +} from 'mastodon/api/suggestions'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +export const fetchSuggestions = createDataLoadingThunk( + 'suggestions/fetch', + () => apiGetSuggestions(20), + (data, { dispatch }) => { + dispatch(importFetchedAccounts(data.map((x) => x.account))); + dispatch(fetchRelationships(data.map((x) => x.account.id))); + + return data; + }, +); + +export const dismissSuggestion = createDataLoadingThunk( + 'suggestions/dismiss', + ({ accountId }: { accountId: string }) => apiDeleteSuggestion(accountId), +); diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js index d18d7e514f..6e0c95288a 100644 --- a/app/javascript/mastodon/actions/tags.js +++ b/app/javascript/mastodon/actions/tags.js @@ -1,9 +1,5 @@ import api, { getLinks } from '../api'; -export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; -export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; -export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; - export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST'; export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS'; export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL'; @@ -12,39 +8,6 @@ export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUES export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS'; export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL'; -export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; -export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; -export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; - -export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; -export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; -export const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; - -export const fetchHashtag = name => (dispatch) => { - dispatch(fetchHashtagRequest()); - - api().get(`/api/v1/tags/${name}`).then(({ data }) => { - dispatch(fetchHashtagSuccess(name, data)); - }).catch(err => { - dispatch(fetchHashtagFail(err)); - }); -}; - -export const fetchHashtagRequest = () => ({ - type: HASHTAG_FETCH_REQUEST, -}); - -export const fetchHashtagSuccess = (name, tag) => ({ - type: HASHTAG_FETCH_SUCCESS, - name, - tag, -}); - -export const fetchHashtagFail = error => ({ - type: HASHTAG_FETCH_FAIL, - error, -}); - export const fetchFollowedHashtags = () => (dispatch) => { dispatch(fetchFollowedHashtagsRequest()); @@ -116,57 +79,3 @@ export function expandFollowedHashtagsFail(error) { error, }; } - -export const followHashtag = name => (dispatch) => { - dispatch(followHashtagRequest(name)); - - api().post(`/api/v1/tags/${name}/follow`).then(({ data }) => { - dispatch(followHashtagSuccess(name, data)); - }).catch(err => { - dispatch(followHashtagFail(name, err)); - }); -}; - -export const followHashtagRequest = name => ({ - type: HASHTAG_FOLLOW_REQUEST, - name, -}); - -export const followHashtagSuccess = (name, tag) => ({ - type: HASHTAG_FOLLOW_SUCCESS, - name, - tag, -}); - -export const followHashtagFail = (name, error) => ({ - type: HASHTAG_FOLLOW_FAIL, - name, - error, -}); - -export const unfollowHashtag = name => (dispatch) => { - dispatch(unfollowHashtagRequest(name)); - - api().post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { - dispatch(unfollowHashtagSuccess(name, data)); - }).catch(err => { - dispatch(unfollowHashtagFail(name, err)); - }); -}; - -export const unfollowHashtagRequest = name => ({ - type: HASHTAG_UNFOLLOW_REQUEST, - name, -}); - -export const unfollowHashtagSuccess = (name, tag) => ({ - type: HASHTAG_UNFOLLOW_SUCCESS, - name, - tag, -}); - -export const unfollowHashtagFail = (name, error) => ({ - type: HASHTAG_UNFOLLOW_FAIL, - name, - error, -}); diff --git a/app/javascript/mastodon/actions/tags_typed.ts b/app/javascript/mastodon/actions/tags_typed.ts new file mode 100644 index 0000000000..6dca32fd84 --- /dev/null +++ b/app/javascript/mastodon/actions/tags_typed.ts @@ -0,0 +1,17 @@ +import { apiGetTag, apiFollowTag, apiUnfollowTag } from 'mastodon/api/tags'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const fetchHashtag = createDataLoadingThunk( + 'tags/fetch', + ({ tagId }: { tagId: string }) => apiGetTag(tagId), +); + +export const followHashtag = createDataLoadingThunk( + 'tags/follow', + ({ tagId }: { tagId: string }) => apiFollowTag(tagId), +); + +export const unfollowHashtag = createDataLoadingThunk( + 'tags/unfollow', + ({ tagId }: { tagId: string }) => apiUnfollowTag(tagId), +); diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index bd1757e827..717010ba74 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -5,3 +5,16 @@ export const apiSubmitAccountNote = (id: string, value: string) => apiRequestPost(`v1/accounts/${id}/note`, { comment: value, }); + +export const apiFollowAccount = ( + id: string, + params?: { + reblogs: boolean; + }, +) => + apiRequestPost(`v1/accounts/${id}/follow`, { + ...params, + }); + +export const apiUnfollowAccount = (id: string) => + apiRequestPost(`v1/accounts/${id}/unfollow`); diff --git a/app/javascript/mastodon/api/instance.ts b/app/javascript/mastodon/api/instance.ts new file mode 100644 index 0000000000..ec9146fb34 --- /dev/null +++ b/app/javascript/mastodon/api/instance.ts @@ -0,0 +1,11 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { + ApiTermsOfServiceJSON, + ApiPrivacyPolicyJSON, +} from 'mastodon/api_types/instance'; + +export const apiGetTermsOfService = () => + apiRequestGet('v1/instance/terms_of_service'); + +export const apiGetPrivacyPolicy = () => + apiRequestGet('v1/instance/privacy_policy'); diff --git a/app/javascript/mastodon/api/polls.ts b/app/javascript/mastodon/api/polls.ts new file mode 100644 index 0000000000..cb659986f5 --- /dev/null +++ b/app/javascript/mastodon/api/polls.ts @@ -0,0 +1,10 @@ +import { apiRequestGet, apiRequestPost } from 'mastodon/api'; +import type { ApiPollJSON } from 'mastodon/api_types/polls'; + +export const apiGetPoll = (pollId: string) => + apiRequestGet(`/v1/polls/${pollId}`); + +export const apiPollVote = (pollId: string, choices: string[]) => + apiRequestPost(`/v1/polls/${pollId}/votes`, { + choices, + }); diff --git a/app/javascript/mastodon/api/search.ts b/app/javascript/mastodon/api/search.ts new file mode 100644 index 0000000000..79b0385fe8 --- /dev/null +++ b/app/javascript/mastodon/api/search.ts @@ -0,0 +1,16 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { + ApiSearchType, + ApiSearchResultsJSON, +} from 'mastodon/api_types/search'; + +export const apiGetSearch = (params: { + q: string; + resolve?: boolean; + type?: ApiSearchType; + limit?: number; + offset?: number; +}) => + apiRequestGet('v2/search', { + ...params, + }); diff --git a/app/javascript/mastodon/api/suggestions.ts b/app/javascript/mastodon/api/suggestions.ts new file mode 100644 index 0000000000..d4817698cc --- /dev/null +++ b/app/javascript/mastodon/api/suggestions.ts @@ -0,0 +1,8 @@ +import { apiRequestGet, apiRequestDelete } from 'mastodon/api'; +import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions'; + +export const apiGetSuggestions = (limit: number) => + apiRequestGet('v2/suggestions', { limit }); + +export const apiDeleteSuggestion = (accountId: string) => + apiRequestDelete(`v1/suggestions/${accountId}`); diff --git a/app/javascript/mastodon/api/tags.ts b/app/javascript/mastodon/api/tags.ts new file mode 100644 index 0000000000..2cb802800c --- /dev/null +++ b/app/javascript/mastodon/api/tags.ts @@ -0,0 +1,11 @@ +import { apiRequestPost, apiRequestGet } from 'mastodon/api'; +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; + +export const apiGetTag = (tagId: string) => + apiRequestGet(`v1/tags/${tagId}`); + +export const apiFollowTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/follow`); + +export const apiUnfollowTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/unfollow`); diff --git a/app/javascript/mastodon/api_types/instance.ts b/app/javascript/mastodon/api_types/instance.ts new file mode 100644 index 0000000000..ead9774515 --- /dev/null +++ b/app/javascript/mastodon/api_types/instance.ts @@ -0,0 +1,9 @@ +export interface ApiTermsOfServiceJSON { + updated_at: string; + content: string; +} + +export interface ApiPrivacyPolicyJSON { + updated_at: string; + content: string; +} diff --git a/app/javascript/mastodon/api_types/polls.ts b/app/javascript/mastodon/api_types/polls.ts index 8181f7b813..275ca29fd7 100644 --- a/app/javascript/mastodon/api_types/polls.ts +++ b/app/javascript/mastodon/api_types/polls.ts @@ -18,6 +18,6 @@ export interface ApiPollJSON { options: ApiPollOptionJSON[]; emojis: ApiCustomEmojiJSON[]; - voted: boolean; - own_votes: number[]; + voted?: boolean; + own_votes?: number[]; } diff --git a/app/javascript/mastodon/api_types/search.ts b/app/javascript/mastodon/api_types/search.ts new file mode 100644 index 0000000000..795cbb2b41 --- /dev/null +++ b/app/javascript/mastodon/api_types/search.ts @@ -0,0 +1,11 @@ +import type { ApiAccountJSON } from './accounts'; +import type { ApiStatusJSON } from './statuses'; +import type { ApiHashtagJSON } from './tags'; + +export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags'; + +export interface ApiSearchResultsJSON { + accounts: ApiAccountJSON[]; + statuses: ApiStatusJSON[]; + hashtags: ApiHashtagJSON[]; +} diff --git a/app/javascript/mastodon/api_types/suggestions.ts b/app/javascript/mastodon/api_types/suggestions.ts new file mode 100644 index 0000000000..7d91daf901 --- /dev/null +++ b/app/javascript/mastodon/api_types/suggestions.ts @@ -0,0 +1,13 @@ +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; + +export type ApiSuggestionSourceJSON = + | 'featured' + | 'most_followed' + | 'most_interactions' + | 'similar_to_recently_followed' + | 'friends_of_friends'; + +export interface ApiSuggestionJSON { + sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]]; + account: ApiAccountJSON; +} diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts new file mode 100644 index 0000000000..0c16c8bd28 --- /dev/null +++ b/app/javascript/mastodon/api_types/tags.ts @@ -0,0 +1,13 @@ +interface ApiHistoryJSON { + day: string; + accounts: string; + uses: string; +} + +export interface ApiHashtagJSON { + id: string; + name: string; + url: string; + history: [ApiHistoryJSON, ...ApiHistoryJSON[]]; + following?: boolean; +} diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx deleted file mode 100644 index 265c68697b..0000000000 --- a/app/javascript/mastodon/components/account.jsx +++ /dev/null @@ -1,181 +0,0 @@ -import PropTypes from 'prop-types'; -import { useCallback } from 'react'; - -import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; -import { Link } from 'react-router-dom'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import { EmptyAccount } from 'mastodon/components/empty_account'; -import { ShortNumber } from 'mastodon/components/short_number'; -import { VerifiedBadge } from 'mastodon/components/verified_badge'; - -import DropdownMenuContainer from '../containers/dropdown_menu_container'; -import { me } from '../initial_state'; - -import { Avatar } from './avatar'; -import { Button } from './button'; -import { FollowersCounter } from './counters'; -import { DisplayName } from './display_name'; -import { RelativeTimestamp } from './relative_timestamp'; - -const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' }, - unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, - unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, - mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' }, - unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' }, - mute: { id: 'account.mute_short', defaultMessage: 'Mute' }, - block: { id: 'account.block_short', defaultMessage: 'Block' }, - more: { id: 'status.more', defaultMessage: 'More' }, -}); - -const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => { - const intl = useIntl(); - - const handleFollow = useCallback(() => { - onFollow(account); - }, [onFollow, account]); - - const handleBlock = useCallback(() => { - onBlock(account); - }, [onBlock, account]); - - const handleMute = useCallback(() => { - onMute(account); - }, [onMute, account]); - - const handleMuteNotifications = useCallback(() => { - onMuteNotifications(account, true); - }, [onMuteNotifications, account]); - - const handleUnmuteNotifications = useCallback(() => { - onMuteNotifications(account, false); - }, [onMuteNotifications, account]); - - if (!account) { - return ; - } - - if (hidden) { - return ( - <> - {account.get('display_name')} - {account.get('username')} - - ); - } - - let buttons; - - if (account.get('id') !== me && account.get('relationship', null) !== null) { - const following = account.getIn(['relationship', 'following']); - const requested = account.getIn(['relationship', 'requested']); - const blocking = account.getIn(['relationship', 'blocking']); - const muting = account.getIn(['relationship', 'muting']); - - if (requested) { - buttons = + )} + + ); +}; diff --git a/app/javascript/mastodon/components/dropdown_menu.jsx b/app/javascript/mastodon/components/dropdown_menu.jsx index d731a8e2d1..df0be8bc12 100644 --- a/app/javascript/mastodon/components/dropdown_menu.jsx +++ b/app/javascript/mastodon/components/dropdown_menu.jsx @@ -124,7 +124,7 @@ class DropdownMenu extends PureComponent { return (

  • - + {text}
  • diff --git a/app/javascript/mastodon/components/empty_account.tsx b/app/javascript/mastodon/components/empty_account.tsx deleted file mode 100644 index a4a6b7f823..0000000000 --- a/app/javascript/mastodon/components/empty_account.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; - -import classNames from 'classnames'; - -import { DisplayName } from 'mastodon/components/display_name'; -import { Skeleton } from 'mastodon/components/skeleton'; - -interface Props { - size?: number; - minimal?: boolean; -} - -export const EmptyAccount: React.FC = ({ - size = 46, - minimal = false, -}) => { - return ( -
    -
    -
    -
    - -
    - -
    - - -
    -
    -
    -
    - ); -}; diff --git a/app/javascript/mastodon/components/error_boundary.jsx b/app/javascript/mastodon/components/error_boundary.jsx index 392a3ad61e..ca2f017f3b 100644 --- a/app/javascript/mastodon/components/error_boundary.jsx +++ b/app/javascript/mastodon/components/error_boundary.jsx @@ -98,7 +98,7 @@ export default class ErrorBoundary extends PureComponent { )}

    -

    Mastodon v{version} · ·

    +

    Mastodon v{version} · ·

    diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index 46314af309..c62e76d4b5 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -88,7 +88,7 @@ export const FollowButton: React.FC<{ {label} @@ -99,7 +99,12 @@ export const FollowButton: React.FC<{ return ( - - )) : ( -
    - -
    - )} - - - )} - - {options.length > 0 && ( - <> -

    - -
    - {options.map(({ key, label, action }, i) => ( - - ))} -
    - - )} - -

    - - {searchEnabled && signedIn ? ( -
    - {this.defaultOptions.map(({ key, label, action }, i) => ( - - ))} -
    - ) : ( -
    - {searchEnabled ? ( - - ) : ( - - )} -
    - )} - - - ); - } - -} - -export default withRouter(withIdentity(injectIntl(Search))); diff --git a/app/javascript/mastodon/features/compose/components/search.tsx b/app/javascript/mastodon/features/compose/components/search.tsx new file mode 100644 index 0000000000..84e11e44b5 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/search.tsx @@ -0,0 +1,593 @@ +import { useCallback, useState, useRef } from 'react'; + +import { + defineMessages, + useIntl, + FormattedMessage, + FormattedList, +} from 'react-intl'; + +import classNames from 'classnames'; +import { useHistory } from 'react-router-dom'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import SearchIcon from '@/material-icons/400-24px/search.svg?react'; +import { + clickSearchResult, + forgetSearchResult, + openURL, +} from 'mastodon/actions/search'; +import { Icon } from 'mastodon/components/icon'; +import { useIdentity } from 'mastodon/identity_context'; +import { domain, searchEnabled } from 'mastodon/initial_state'; +import type { RecentSearch, SearchType } from 'mastodon/models/search'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { HASHTAG_REGEX } from 'mastodon/utils/hashtags'; + +const messages = defineMessages({ + placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, + placeholderSignedIn: { + id: 'search.search_or_paste', + defaultMessage: 'Search or paste URL', + }, +}); + +const labelForRecentSearch = (search: RecentSearch) => { + switch (search.type) { + case 'account': + return `@${search.q}`; + case 'hashtag': + return `#${search.q}`; + default: + return search.q; + } +}; + +const unfocus = () => { + document.querySelector('.ui')?.parentElement?.focus(); +}; + +interface SearchOption { + key: string; + label: React.ReactNode; + action: (e: React.MouseEvent | React.KeyboardEvent) => void; + forget?: (e: React.MouseEvent | React.KeyboardEvent) => void; +} + +export const Search: React.FC<{ + singleColumn: boolean; + initialValue?: string; +}> = ({ singleColumn, initialValue }) => { + const intl = useIntl(); + const recent = useAppSelector((state) => state.search.recent); + const { signedIn } = useIdentity(); + const dispatch = useAppDispatch(); + const history = useHistory(); + const searchInputRef = useRef(null); + const [value, setValue] = useState(initialValue ?? ''); + const hasValue = value.length > 0; + const [expanded, setExpanded] = useState(false); + const [selectedOption, setSelectedOption] = useState(-1); + const [quickActions, setQuickActions] = useState([]); + const searchOptions: SearchOption[] = []; + + if (searchEnabled) { + searchOptions.push( + { + key: 'prompt-has', + label: ( + <> + has:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('has:'); + }, + }, + { + key: 'prompt-is', + label: ( + <> + is:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('is:'); + }, + }, + { + key: 'prompt-language', + label: ( + <> + language:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('language:'); + }, + }, + { + key: 'prompt-from', + label: ( + <> + from:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('from:'); + }, + }, + { + key: 'prompt-before', + label: ( + <> + before:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('before:'); + }, + }, + { + key: 'prompt-during', + label: ( + <> + during:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('during:'); + }, + }, + { + key: 'prompt-after', + label: ( + <> + after:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('after:'); + }, + }, + { + key: 'prompt-in', + label: ( + <> + in:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('in:'); + }, + }, + ); + } + + const recentOptions: SearchOption[] = recent.map((search) => ({ + key: `${search.type}/${search.q}`, + label: labelForRecentSearch(search), + action: () => { + setValue(search.q); + + if (search.type === 'account') { + history.push(`/@${search.q}`); + } else if (search.type === 'hashtag') { + history.push(`/tags/${search.q}`); + } else { + const queryParams = new URLSearchParams({ q: search.q }); + if (search.type) queryParams.set('type', search.type); + history.push({ pathname: '/search', search: queryParams.toString() }); + } + + unfocus(); + }, + forget: (e) => { + e.stopPropagation(); + void dispatch(forgetSearchResult(search.q)); + }, + })); + + const navigableOptions = hasValue + ? quickActions.concat(searchOptions) + : recentOptions.concat(quickActions, searchOptions); + + const insertText = (text: string) => { + setValue((currentValue) => { + if (currentValue === '') { + return text; + } else if (currentValue.endsWith(' ')) { + return `${currentValue}${text}`; + } else { + return `${currentValue} ${text}`; + } + }); + }; + + const submit = useCallback( + (q: string, type?: SearchType) => { + void dispatch(clickSearchResult({ q, type })); + const queryParams = new URLSearchParams({ q }); + if (type) queryParams.set('type', type); + history.push({ pathname: '/search', search: queryParams.toString() }); + unfocus(); + }, + [dispatch, history], + ); + + const handleChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setValue(value); + + const trimmedValue = value.trim(); + const newQuickActions = []; + + if (trimmedValue.length > 0) { + const couldBeURL = + trimmedValue.startsWith('https://') && !trimmedValue.includes(' '); + + if (couldBeURL) { + newQuickActions.push({ + key: 'open-url', + label: ( + + ), + action: async () => { + const result = await dispatch(openURL({ url: trimmedValue })); + + if (isFulfilled(result)) { + if (result.payload.accounts[0]) { + history.push(`/@${result.payload.accounts[0].acct}`); + } else if (result.payload.statuses[0]) { + history.push( + `/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`, + ); + } + } + + unfocus(); + }, + }); + } + + const couldBeHashtag = + (trimmedValue.startsWith('#') && trimmedValue.length > 1) || + trimmedValue.match(HASHTAG_REGEX); + + if (couldBeHashtag) { + newQuickActions.push({ + key: 'go-to-hashtag', + label: ( + #{trimmedValue.replace(/^#/, '')} }} + /> + ), + action: () => { + const query = trimmedValue.replace(/^#/, ''); + history.push(`/tags/${query}`); + void dispatch(clickSearchResult({ q: query, type: 'hashtag' })); + unfocus(); + }, + }); + } + + const couldBeUsername = /^@?[a-z0-9_-]+(@[^\s]+)?$/i.exec(trimmedValue); + + if (couldBeUsername) { + newQuickActions.push({ + key: 'go-to-account', + label: ( + @{trimmedValue.replace(/^@/, '')} }} + /> + ), + action: () => { + const query = trimmedValue.replace(/^@/, ''); + history.push(`/@${query}`); + void dispatch(clickSearchResult({ q: query, type: 'account' })); + unfocus(); + }, + }); + } + + const couldBeStatusSearch = searchEnabled; + + if (couldBeStatusSearch && signedIn) { + newQuickActions.push({ + key: 'status-search', + label: ( + {trimmedValue} }} + /> + ), + action: () => { + submit(trimmedValue, 'statuses'); + }, + }); + } + + newQuickActions.push({ + key: 'account-search', + label: ( + {trimmedValue} }} + /> + ), + action: () => { + submit(trimmedValue, 'accounts'); + }, + }); + } + + setQuickActions(newQuickActions); + }, + [dispatch, history, signedIn, setValue, setQuickActions, submit], + ); + + const handleClear = useCallback(() => { + setValue(''); + setQuickActions([]); + setSelectedOption(-1); + }, [setValue, setQuickActions, setSelectedOption]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'Escape': + e.preventDefault(); + unfocus(); + + break; + case 'ArrowDown': + e.preventDefault(); + + if (navigableOptions.length > 0) { + setSelectedOption( + Math.min(selectedOption + 1, navigableOptions.length - 1), + ); + } + + break; + case 'ArrowUp': + e.preventDefault(); + + if (navigableOptions.length > 0) { + setSelectedOption(Math.max(selectedOption - 1, -1)); + } + + break; + case 'Enter': + e.preventDefault(); + + if (selectedOption === -1) { + submit(value); + } else if (navigableOptions.length > 0) { + navigableOptions[selectedOption]?.action(e); + } + + break; + case 'Delete': + if (selectedOption > -1 && navigableOptions.length > 0) { + const search = navigableOptions[selectedOption]; + + if (typeof search?.forget === 'function') { + e.preventDefault(); + search.forget(e); + } + } + + break; + } + }, + [navigableOptions, value, selectedOption, setSelectedOption, submit], + ); + + const handleFocus = useCallback(() => { + setExpanded(true); + setSelectedOption(-1); + + if (searchInputRef.current && !singleColumn) { + const { left, right } = searchInputRef.current.getBoundingClientRect(); + + if ( + left < 0 || + right > (window.innerWidth || document.documentElement.clientWidth) + ) { + searchInputRef.current.scrollIntoView(); + } + } + }, [setExpanded, setSelectedOption, singleColumn]); + + const handleBlur = useCallback(() => { + setExpanded(false); + setSelectedOption(-1); + }, [setExpanded, setSelectedOption]); + + return ( +
    + + + + +
    + {!hasValue && ( + <> +

    + +

    + +
    + {recentOptions.length > 0 ? ( + recentOptions.map(({ label, key, action, forget }, i) => ( + + + )) + ) : ( +
    + +
    + )} +
    + + )} + + {quickActions.length > 0 && ( + <> +

    + +

    + +
    + {quickActions.map(({ key, label, action }, i) => ( + + ))} +
    + + )} + +

    + +

    + + {searchEnabled && signedIn ? ( +
    + {searchOptions.map(({ key, label, action }, i) => ( + + ))} +
    + ) : ( +
    + {searchEnabled ? ( + + ) : ( + + )} +
    + )} +
    +
    + ); +}; diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx deleted file mode 100644 index 6a482c8ec2..0000000000 --- a/app/javascript/mastodon/features/compose/components/search_results.jsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useCallback } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; -import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; -import TagIcon from '@/material-icons/400-24px/tag.svg?react'; -import { expandSearch } from 'mastodon/actions/search'; -import { Icon } from 'mastodon/components/icon'; -import { LoadMore } from 'mastodon/components/load_more'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { SearchSection } from 'mastodon/features/explore/components/search_section'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; - -import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; -import AccountContainer from '../../../containers/account_container'; -import StatusContainer from '../../../containers/status_container'; - -const INITIAL_PAGE_LIMIT = 10; - -const withoutLastResult = list => { - if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) { - return list.skipLast(1); - } else { - return list; - } -}; - -export const SearchResults = () => { - const results = useAppSelector((state) => state.getIn(['search', 'results'])); - const isLoading = useAppSelector((state) => state.getIn(['search', 'isLoading'])); - - const dispatch = useAppDispatch(); - - const handleLoadMoreAccounts = useCallback(() => { - dispatch(expandSearch('accounts')); - }, [dispatch]); - - const handleLoadMoreStatuses = useCallback(() => { - dispatch(expandSearch('statuses')); - }, [dispatch]); - - const handleLoadMoreHashtags = useCallback(() => { - dispatch(expandSearch('hashtags')); - }, [dispatch]); - - let accounts, statuses, hashtags; - - if (results.get('accounts') && results.get('accounts').size > 0) { - accounts = ( - }> - {withoutLastResult(results.get('accounts')).map(accountId => )} - {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && } - - ); - } - - if (results.get('hashtags') && results.get('hashtags').size > 0) { - hashtags = ( - }> - {withoutLastResult(results.get('hashtags')).map(hashtag => )} - {(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && } - - ); - } - - if (results.get('statuses') && results.get('statuses').size > 0) { - statuses = ( - }> - {withoutLastResult(results.get('statuses')).map(statusId => )} - {(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && } - - ); - } - - return ( -
    - {!accounts && !hashtags && !statuses && ( - isLoading ? ( - - ) : ( -
    - -
    - ) - )} - {accounts} - {hashtags} - {statuses} -
    - ); - -}; diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js deleted file mode 100644 index 616b91369c..0000000000 --- a/app/javascript/mastodon/features/compose/containers/search_container.js +++ /dev/null @@ -1,59 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { connect } from 'react-redux'; - -import { - changeSearch, - clearSearch, - submitSearch, - showSearch, - openURL, - clickSearchResult, - forgetSearchResult, -} from 'mastodon/actions/search'; - -import Search from '../components/search'; - -const getRecentSearches = createSelector( - state => state.getIn(['search', 'recent']), - recent => recent.reverse(), -); - -const mapStateToProps = state => ({ - value: state.getIn(['search', 'value']), - submitted: state.getIn(['search', 'submitted']), - recent: getRecentSearches(state), -}); - -const mapDispatchToProps = dispatch => ({ - - onChange (value) { - dispatch(changeSearch(value)); - }, - - onClear () { - dispatch(clearSearch()); - }, - - onSubmit (type) { - dispatch(submitSearch(type)); - }, - - onShow () { - dispatch(showSearch()); - }, - - onOpenURL (q, routerHistory) { - dispatch(openURL(q, routerHistory)); - }, - - onClickSearchResult (q, type) { - dispatch(clickSearchResult(q, type)); - }, - - onForgetSearchResult (q) { - dispatch(forgetSearchResult(q)); - }, - -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/app/javascript/mastodon/features/compose/index.jsx b/app/javascript/mastodon/features/compose/index.jsx index 3a96ab49c3..660f08615b 100644 --- a/app/javascript/mastodon/features/compose/index.jsx +++ b/app/javascript/mastodon/features/compose/index.jsx @@ -9,8 +9,6 @@ import { Link } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import spring from 'react-motion/lib/spring'; - import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; import LogoutIcon from '@/material-icons/400-24px/logout.svg?react'; @@ -26,11 +24,9 @@ import elephantUIPlane from '../../../images/elephant_ui_plane.svg'; import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose'; import { mascot } from '../../initial_state'; import { isMobile } from '../../is_mobile'; -import Motion from '../ui/util/optional_motion'; -import { SearchResults } from './components/search_results'; +import { Search } from './components/search'; import ComposeFormContainer from './containers/compose_form_container'; -import SearchContainer from './containers/search_container'; const messages = defineMessages({ start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, @@ -43,9 +39,8 @@ const messages = defineMessages({ compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, }); -const mapStateToProps = (state, ownProps) => ({ +const mapStateToProps = (state) => ({ columns: state.getIn(['settings', 'columns']), - showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false, }); class Compose extends PureComponent { @@ -54,7 +49,6 @@ class Compose extends PureComponent { dispatch: PropTypes.func.isRequired, columns: ImmutablePropTypes.list.isRequired, multiColumn: PropTypes.bool, - showSearch: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -88,7 +82,7 @@ class Compose extends PureComponent { }; render () { - const { multiColumn, showSearch, intl } = this.props; + const { multiColumn, intl } = this.props; if (multiColumn) { const { columns } = this.props; @@ -113,7 +107,7 @@ class Compose extends PureComponent { - {multiColumn && } + {multiColumn && }
    @@ -123,14 +117,6 @@ class Compose extends PureComponent {
    - - - {({ x }) => ( -
    - -
    - )} -
    ); diff --git a/app/javascript/mastodon/features/directory/index.tsx b/app/javascript/mastodon/features/directory/index.tsx index d0e57600bb..ef2649a27f 100644 --- a/app/javascript/mastodon/features/directory/index.tsx +++ b/app/javascript/mastodon/features/directory/index.tsx @@ -15,7 +15,8 @@ import { changeColumnParams, } from 'mastodon/actions/columns'; import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory'; -import Column from 'mastodon/components/column'; +import { Column } from 'mastodon/components/column'; +import type { ColumnRef } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; import { LoadMore } from 'mastodon/components/load_more'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; @@ -49,7 +50,7 @@ export const Directory: React.FC<{ const intl = useIntl(); const dispatch = useAppDispatch(); - const column = useRef(null); + const column = useRef(null); const [orderParam, setOrderParam] = useSearchParam('order'); const [localParam, setLocalParam] = useSearchParam('local'); diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js index 9d6ff5226a..022c9baaf7 100644 --- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js +++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js @@ -90,8 +90,8 @@ describe('emoji', () => { }); it('keeps ordering as expected (issue fixed by PR 20677)', () => { - expect(emojify('

    💕 #foo test: foo.

    ')) - .toEqual('

    💕 #foo test: foo.

    '); + expect(emojify('

    💕 #foo test: foo.

    ')) + .toEqual('

    💕 #foo test: foo.

    '); }); }); }); diff --git a/app/javascript/mastodon/features/explore/components/search_section.jsx b/app/javascript/mastodon/features/explore/components/search_section.jsx deleted file mode 100644 index c84e3f7cef..0000000000 --- a/app/javascript/mastodon/features/explore/components/search_section.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -export const SearchSection = ({ title, onClickMore, children }) => ( -
    -
    -

    {title}

    - {onClickMore && } -
    - - {children} -
    -); - -SearchSection.propTypes = { - title: PropTypes.node.isRequired, - onClickMore: PropTypes.func, - children: PropTypes.children, -}; \ No newline at end of file diff --git a/app/javascript/mastodon/features/explore/index.jsx b/app/javascript/mastodon/features/explore/index.jsx deleted file mode 100644 index 83e5df22f8..0000000000 --- a/app/javascript/mastodon/features/explore/index.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; -import { NavLink, Switch, Route } from 'react-router-dom'; - -import { connect } from 'react-redux'; - -import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; -import SearchIcon from '@/material-icons/400-24px/search.svg?react'; -import Column from 'mastodon/components/column'; -import ColumnHeader from 'mastodon/components/column_header'; -import Search from 'mastodon/features/compose/containers/search_container'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { trendsEnabled } from 'mastodon/initial_state'; - -import Links from './links'; -import SearchResults from './results'; -import Statuses from './statuses'; -import Suggestions from './suggestions'; -import Tags from './tags'; - -const messages = defineMessages({ - title: { id: 'explore.title', defaultMessage: 'Explore' }, - searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' }, -}); - -const mapStateToProps = state => ({ - layout: state.getIn(['meta', 'layout']), - isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled, -}); - -class Explore extends PureComponent { - static propTypes = { - identity: identityContextPropShape, - intl: PropTypes.object.isRequired, - multiColumn: PropTypes.bool, - isSearching: PropTypes.bool, - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - setRef = c => { - this.column = c; - }; - - render() { - const { intl, multiColumn, isSearching } = this.props; - const { signedIn } = this.props.identity; - - return ( - - - -
    - -
    - - {isSearching ? ( - - ) : ( - <> -
    - - - - - - - - - {signedIn && ( - - - - )} - - - - -
    - - - - - - - - - - - - {intl.formatMessage(messages.title)} - - - - )} -
    - ); - } - -} - -export default withIdentity(connect(mapStateToProps)(injectIntl(Explore))); diff --git a/app/javascript/mastodon/features/explore/index.tsx b/app/javascript/mastodon/features/explore/index.tsx new file mode 100644 index 0000000000..671d92d6b4 --- /dev/null +++ b/app/javascript/mastodon/features/explore/index.tsx @@ -0,0 +1,105 @@ +import { useCallback, useRef } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { NavLink, Switch, Route } from 'react-router-dom'; + +import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; +import { Column } from 'mastodon/components/column'; +import type { ColumnRef } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { Search } from 'mastodon/features/compose/components/search'; +import { useIdentity } from 'mastodon/identity_context'; + +import Links from './links'; +import Statuses from './statuses'; +import Suggestions from './suggestions'; +import Tags from './tags'; + +const messages = defineMessages({ + title: { id: 'explore.title', defaultMessage: 'Explore' }, +}); + +const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + const { signedIn } = useIdentity(); + const intl = useIntl(); + const columnRef = useRef(null); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + return ( + + + +
    + +
    + +
    + + + + + + + + + {signedIn && ( + + + + )} + + + + +
    + + + + + + + + + + + + {intl.formatMessage(messages.title)} + + +
    + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Explore; diff --git a/app/javascript/mastodon/features/explore/links.jsx b/app/javascript/mastodon/features/explore/links.jsx index 035e5aaad8..68cf0283ea 100644 --- a/app/javascript/mastodon/features/explore/links.jsx +++ b/app/javascript/mastodon/features/explore/links.jsx @@ -45,7 +45,7 @@ class Links extends PureComponent { const banner = ( - + ); diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx deleted file mode 100644 index 355c0f1c4c..0000000000 --- a/app/javascript/mastodon/features/explore/results.jsx +++ /dev/null @@ -1,232 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import { List as ImmutableList } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; - -import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; -import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; -import TagIcon from '@/material-icons/400-24px/tag.svg?react'; -import { submitSearch, expandSearch } from 'mastodon/actions/search'; -import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; -import { Icon } from 'mastodon/components/icon'; -import ScrollableList from 'mastodon/components/scrollable_list'; -import Account from 'mastodon/containers/account_container'; -import Status from 'mastodon/containers/status_container'; - -import { SearchSection } from './components/search_section'; - -const messages = defineMessages({ - title: { id: 'search_results.title', defaultMessage: 'Search for {q}' }, -}); - -const mapStateToProps = state => ({ - isLoading: state.getIn(['search', 'isLoading']), - results: state.getIn(['search', 'results']), - q: state.getIn(['search', 'searchTerm']), - submittedType: state.getIn(['search', 'type']), -}); - -const INITIAL_PAGE_LIMIT = 10; -const INITIAL_DISPLAY = 4; - -const hidePeek = list => { - if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) { - return list.skipLast(1); - } else { - return list; - } -}; - -const renderAccounts = accounts => hidePeek(accounts).map(id => ( - -)); - -const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => ( - -)); - -const renderStatuses = statuses => hidePeek(statuses).map(id => ( - -)); - -class Results extends PureComponent { - - static propTypes = { - results: ImmutablePropTypes.contains({ - accounts: ImmutablePropTypes.orderedSet, - statuses: ImmutablePropTypes.orderedSet, - hashtags: ImmutablePropTypes.orderedSet, - }), - isLoading: PropTypes.bool, - multiColumn: PropTypes.bool, - dispatch: PropTypes.func.isRequired, - q: PropTypes.string, - intl: PropTypes.object, - submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']), - }; - - state = { - type: this.props.submittedType || 'all', - }; - - static getDerivedStateFromProps(props, state) { - if (props.submittedType !== state.type) { - return { - type: props.submittedType || 'all', - }; - } - - return null; - } - - handleSelectAll = () => { - const { submittedType, dispatch } = this.props; - - // If we originally searched for a specific type, we need to resubmit - // the query to get all types of results - if (submittedType) { - dispatch(submitSearch()); - } - - this.setState({ type: 'all' }); - }; - - handleSelectAccounts = () => { - const { submittedType, dispatch } = this.props; - - // If we originally searched for something else (but not everything), - // we need to resubmit the query for this specific type - if (submittedType !== 'accounts') { - dispatch(submitSearch('accounts')); - } - - this.setState({ type: 'accounts' }); - }; - - handleSelectHashtags = () => { - const { submittedType, dispatch } = this.props; - - // If we originally searched for something else (but not everything), - // we need to resubmit the query for this specific type - if (submittedType !== 'hashtags') { - dispatch(submitSearch('hashtags')); - } - - this.setState({ type: 'hashtags' }); - }; - - handleSelectStatuses = () => { - const { submittedType, dispatch } = this.props; - - // If we originally searched for something else (but not everything), - // we need to resubmit the query for this specific type - if (submittedType !== 'statuses') { - dispatch(submitSearch('statuses')); - } - - this.setState({ type: 'statuses' }); - }; - - handleLoadMoreAccounts = () => this._loadMore('accounts'); - handleLoadMoreStatuses = () => this._loadMore('statuses'); - handleLoadMoreHashtags = () => this._loadMore('hashtags'); - - _loadMore (type) { - const { dispatch } = this.props; - dispatch(expandSearch(type)); - } - - handleLoadMore = () => { - const { type } = this.state; - - if (type !== 'all') { - this._loadMore(type); - } - }; - - render () { - const { intl, isLoading, q, results } = this.props; - const { type } = this.state; - - // We request 1 more result than we display so we can tell if there'd be a next page - const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false; - - let filteredResults; - - const accounts = results.get('accounts', ImmutableList()); - const hashtags = results.get('hashtags', ImmutableList()); - const statuses = results.get('statuses', ImmutableList()); - - switch(type) { - case 'all': - filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? ( - <> - {accounts.size > 0 && ( - } onClickMore={this.handleLoadMoreAccounts}> - {accounts.take(INITIAL_DISPLAY).map(id => )} - - )} - - {hashtags.size > 0 && ( - } onClickMore={this.handleLoadMoreHashtags}> - {hashtags.take(INITIAL_DISPLAY).map(hashtag => )} - - )} - - {statuses.size > 0 && ( - } onClickMore={this.handleLoadMoreStatuses}> - {statuses.take(INITIAL_DISPLAY).map(id => )} - - )} - - ) : []; - break; - case 'accounts': - filteredResults = renderAccounts(accounts); - break; - case 'hashtags': - filteredResults = renderHashtags(hashtags); - break; - case 'statuses': - filteredResults = renderStatuses(statuses); - break; - } - - return ( - <> -
    - - - - -
    - -
    - } - bindToDocument - > - {filteredResults} - -
    - - - {intl.formatMessage(messages.title, { q })} - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(Results)); diff --git a/app/javascript/mastodon/features/explore/statuses.jsx b/app/javascript/mastodon/features/explore/statuses.jsx index 414b47fcdd..883f6906b6 100644 --- a/app/javascript/mastodon/features/explore/statuses.jsx +++ b/app/javascript/mastodon/features/explore/statuses.jsx @@ -58,7 +58,7 @@ class Statuses extends PureComponent { return ( } + prepend={} alwaysPrepend timelineId='explore' statusIds={statusIds} diff --git a/app/javascript/mastodon/features/explore/suggestions.jsx b/app/javascript/mastodon/features/explore/suggestions.jsx index 101ec0d195..b469a15252 100644 --- a/app/javascript/mastodon/features/explore/suggestions.jsx +++ b/app/javascript/mastodon/features/explore/suggestions.jsx @@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl'; import { withRouter } from 'react-router-dom'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { fetchSuggestions } from 'mastodon/actions/suggestions'; @@ -15,15 +14,15 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { Card } from './components/card'; const mapStateToProps = state => ({ - suggestions: state.getIn(['suggestions', 'items']), - isLoading: state.getIn(['suggestions', 'isLoading']), + suggestions: state.suggestions.items, + isLoading: state.suggestions.isLoading, }); class Suggestions extends PureComponent { static propTypes = { isLoading: PropTypes.bool, - suggestions: ImmutablePropTypes.list, + suggestions: PropTypes.array, dispatch: PropTypes.func.isRequired, ...WithRouterPropTypes, }; @@ -32,17 +31,17 @@ class Suggestions extends PureComponent { const { dispatch, suggestions, history } = this.props; // If we're navigating back to the screen, do not trigger a reload - if (history.action === 'POP' && suggestions.size > 0) { + if (history.action === 'POP' && suggestions.length > 0) { return; } - dispatch(fetchSuggestions(true)); + dispatch(fetchSuggestions()); } render () { const { isLoading, suggestions } = this.props; - if (!isLoading && suggestions.isEmpty()) { + if (!isLoading && suggestions.length === 0) { return (
    @@ -56,9 +55,9 @@ class Suggestions extends PureComponent {
    {isLoading ? : suggestions.map(suggestion => ( ))}
    diff --git a/app/javascript/mastodon/features/explore/tags.jsx b/app/javascript/mastodon/features/explore/tags.jsx index 90cf3c32a7..b803d5fa1b 100644 --- a/app/javascript/mastodon/features/explore/tags.jsx +++ b/app/javascript/mastodon/features/explore/tags.jsx @@ -44,7 +44,7 @@ class Tags extends PureComponent { const banner = ( - + ); diff --git a/app/javascript/mastodon/features/favourites/index.jsx b/app/javascript/mastodon/features/favourites/index.jsx index 27ca169409..c5c6abe081 100644 --- a/app/javascript/mastodon/features/favourites/index.jsx +++ b/app/javascript/mastodon/features/favourites/index.jsx @@ -12,11 +12,11 @@ import { debounce } from 'lodash'; import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react'; import { fetchFavourites, expandFavourites } from 'mastodon/actions/interactions'; +import { Account } from 'mastodon/components/account'; import ColumnHeader from 'mastodon/components/column_header'; import { Icon } from 'mastodon/components/icon'; 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 messages = defineMessages({ @@ -87,7 +87,7 @@ class Favourites extends ImmutablePureComponent { bindToDocument={!multiColumn} > {accountIds.map(id => - , + , )} diff --git a/app/javascript/mastodon/features/firehose/index.jsx b/app/javascript/mastodon/features/firehose/index.jsx index f65bee45ec..ff80aac220 100644 --- a/app/javascript/mastodon/features/firehose/index.jsx +++ b/app/javascript/mastodon/features/firehose/index.jsx @@ -133,7 +133,7 @@ const Firehose = ({ feedType, multiColumn }) => { diff --git a/app/javascript/mastodon/features/followers/index.jsx b/app/javascript/mastodon/features/followers/index.jsx index 92fce79c35..c13033b289 100644 --- a/app/javascript/mastodon/features/followers/index.jsx +++ b/app/javascript/mastodon/features/followers/index.jsx @@ -8,6 +8,7 @@ import { connect } from 'react-redux'; import { debounce } from 'lodash'; +import { Account } from 'mastodon/components/account'; import { TimelineHint } from 'mastodon/components/timeline_hint'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; @@ -23,7 +24,6 @@ import { import { ColumnBackButton } from '../../components/column_back_button'; import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; -import AccountContainer from '../../containers/account_container'; import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; import HeaderContainer from '../account_timeline/containers/header_container'; import Column from '../ui/components/column'; @@ -175,7 +175,7 @@ class Followers extends ImmutablePureComponent { bindToDocument={!multiColumn} > {forceEmptyState ? [] : accountIds.map(id => - , + , )} diff --git a/app/javascript/mastodon/features/following/index.jsx b/app/javascript/mastodon/features/following/index.jsx index b53a016ff1..d37c0c30ef 100644 --- a/app/javascript/mastodon/features/following/index.jsx +++ b/app/javascript/mastodon/features/following/index.jsx @@ -8,6 +8,7 @@ import { connect } from 'react-redux'; import { debounce } from 'lodash'; +import { Account } from 'mastodon/components/account'; import { TimelineHint } from 'mastodon/components/timeline_hint'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; @@ -23,7 +24,6 @@ import { import { ColumnBackButton } from '../../components/column_back_button'; import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; -import AccountContainer from '../../containers/account_container'; import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; import HeaderContainer from '../account_timeline/containers/header_container'; import Column from '../ui/components/column'; @@ -175,7 +175,7 @@ class Following extends ImmutablePureComponent { bindToDocument={!multiColumn} > {forceEmptyState ? [] : accountIds.map(id => - , + , )} diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.jsx b/app/javascript/mastodon/features/getting_started/components/announcements.jsx index 3c0b53b9e7..713ad9f069 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.jsx +++ b/app/javascript/mastodon/features/getting_started/components/announcements.jsx @@ -85,7 +85,7 @@ class ContentWithRouter extends ImmutablePureComponent { } link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener noreferrer'); + link.setAttribute('rel', 'noopener'); } } diff --git a/app/javascript/mastodon/features/getting_started/index.jsx b/app/javascript/mastodon/features/getting_started/index.jsx index 8d26115dfa..ece06953ea 100644 --- a/app/javascript/mastodon/features/getting_started/index.jsx +++ b/app/javascript/mastodon/features/getting_started/index.jsx @@ -25,7 +25,7 @@ import StarIcon from '@/material-icons/400-24px/star.svg?react'; import { fetchFollowRequests } from 'mastodon/actions/accounts'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; -import LinkFooter from 'mastodon/features/ui/components/link_footer'; +import { LinkFooter } from 'mastodon/features/ui/components/link_footer'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions'; diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.jsx b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.jsx deleted file mode 100644 index d2fe8851f6..0000000000 --- a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import { Button } from 'mastodon/components/button'; -import { ShortNumber } from 'mastodon/components/short_number'; - -const messages = defineMessages({ - followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, - unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, -}); - -const usesRenderer = (displayNumber, pluralReady) => ( - {displayNumber}, - }} - /> -); - -const peopleRenderer = (displayNumber, pluralReady) => ( - {displayNumber}, - }} - /> -); - -const usesTodayRenderer = (displayNumber, pluralReady) => ( - {displayNumber}, - }} - /> -); - -export const HashtagHeader = injectIntl(({ tag, intl, disabled, onClick }) => { - if (!tag) { - return null; - } - - const [uses, people] = tag.get('history').reduce((arr, day) => [arr[0] + day.get('uses') * 1, arr[1] + day.get('accounts') * 1], [0, 0]); - const dividingCircle = {' · '}; - - return ( -
    -
    -

    #{tag.get('name')}

    -
    - -
    - - {dividingCircle} - - {dividingCircle} - -
    -
    - ); -}); - -HashtagHeader.propTypes = { - tag: ImmutablePropTypes.map, - disabled: PropTypes.bool, - onClick: PropTypes.func, - intl: PropTypes.object, -}; diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx new file mode 100644 index 0000000000..7372fe5284 --- /dev/null +++ b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx @@ -0,0 +1,188 @@ +import { useCallback, useMemo, useState, useEffect } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; +import { + fetchHashtag, + followHashtag, + unfollowHashtag, +} from 'mastodon/actions/tags_typed'; +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; +import { Button } from 'mastodon/components/button'; +import { ShortNumber } from 'mastodon/components/short_number'; +import DropdownMenu from 'mastodon/containers/dropdown_menu_container'; +import { useIdentity } from 'mastodon/identity_context'; +import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions'; +import { useAppDispatch } from 'mastodon/store'; + +const messages = defineMessages({ + followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, + unfollowHashtag: { + id: 'hashtag.unfollow', + defaultMessage: 'Unfollow hashtag', + }, + adminModeration: { + id: 'hashtag.admin_moderation', + defaultMessage: 'Open moderation interface for #{name}', + }, +}); + +const usesRenderer = (displayNumber: React.ReactNode, pluralReady: number) => ( + {displayNumber}, + }} + /> +); + +const peopleRenderer = ( + displayNumber: React.ReactNode, + pluralReady: number, +) => ( + {displayNumber}, + }} + /> +); + +const usesTodayRenderer = ( + displayNumber: React.ReactNode, + pluralReady: number, +) => ( + {displayNumber}, + }} + /> +); + +export const HashtagHeader: React.FC<{ + tagId: string; +}> = ({ tagId }) => { + const intl = useIntl(); + const { signedIn, permissions } = useIdentity(); + const dispatch = useAppDispatch(); + const [tag, setTag] = useState(); + + useEffect(() => { + void dispatch(fetchHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + }, [dispatch, tagId, setTag]); + + const menu = useMemo(() => { + const tmp = []; + + if ( + tag && + signedIn && + (permissions & PERMISSION_MANAGE_TAXONOMIES) === + PERMISSION_MANAGE_TAXONOMIES + ) { + tmp.push({ + text: intl.formatMessage(messages.adminModeration, { name: tag.id }), + href: `/admin/tags/${tag.id}`, + }); + } + + return tmp; + }, [signedIn, permissions, intl, tag]); + + const handleFollow = useCallback(() => { + if (!signedIn || !tag) { + return; + } + + if (tag.following) { + setTag((hashtag) => hashtag && { ...hashtag, following: false }); + + void dispatch(unfollowHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + } else { + setTag((hashtag) => hashtag && { ...hashtag, following: true }); + + void dispatch(followHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + } + }, [dispatch, setTag, signedIn, tag, tagId]); + + if (!tag) { + return null; + } + + const [uses, people] = tag.history.reduce( + (arr, day) => [ + arr[0] + parseInt(day.uses), + arr[1] + parseInt(day.accounts), + ], + [0, 0], + ); + const dividingCircle = {' · '}; + + return ( +
    +
    +

    #{tag.name}

    + +
    + {menu.length > 0 && ( + + )} + +
    +
    + +
    + + {dividingCircle} + + {dividingCircle} + +
    +
    + ); +}; diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.jsx b/app/javascript/mastodon/features/hashtag_timeline/index.jsx index 42a668859e..ab3c32e6a7 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.jsx +++ b/app/javascript/mastodon/features/hashtag_timeline/index.jsx @@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl'; import { Helmet } from 'react-helmet'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { isEqual } from 'lodash'; @@ -13,7 +12,6 @@ import { isEqual } from 'lodash'; import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import { connectHashtagStream } from 'mastodon/actions/streaming'; -import { fetchHashtag, followHashtag, unfollowHashtag } from 'mastodon/actions/tags'; import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; @@ -26,7 +24,6 @@ import ColumnSettingsContainer from './containers/column_settings_container'; const mapStateToProps = (state, props) => ({ hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0, - tag: state.getIn(['tags', props.params.id]), }); class HashtagTimeline extends PureComponent { @@ -38,7 +35,6 @@ class HashtagTimeline extends PureComponent { columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, hasUnread: PropTypes.bool, - tag: ImmutablePropTypes.map, multiColumn: PropTypes.bool, }; @@ -130,7 +126,6 @@ class HashtagTimeline extends PureComponent { this._subscribe(dispatch, id, tags, local); dispatch(expandHashtagTimeline(id, { tags, local })); - dispatch(fetchHashtag(id)); } componentDidMount () { @@ -162,27 +157,10 @@ class HashtagTimeline extends PureComponent { dispatch(expandHashtagTimeline(id, { maxId, tags, local })); }; - handleFollow = () => { - const { dispatch, params, tag } = this.props; - const { id } = params; - const { signedIn } = this.props.identity; - - if (!signedIn) { - return; - } - - if (tag.get('following')) { - dispatch(unfollowHashtag(id)); - } else { - dispatch(followHashtag(id)); - } - }; - render () { - const { hasUnread, columnId, multiColumn, tag } = this.props; + const { hasUnread, columnId, multiColumn } = this.props; const { id, local } = this.props.params; const pinned = !!columnId; - const { signedIn } = this.props.identity; return ( @@ -202,7 +180,7 @@ class HashtagTimeline extends PureComponent { } + prepend={pinned ? null : } alwaysPrepend trackScroll={!pinned} scrollKey={`hashtag_timeline-${columnId}`} diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx deleted file mode 100644 index 3269b5a497..0000000000 --- a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx +++ /dev/null @@ -1,217 +0,0 @@ -import PropTypes from 'prop-types'; -import { useEffect, useCallback, useRef, useState } from 'react'; - -import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; - -import { Link } from 'react-router-dom'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { useDispatch, useSelector } from 'react-redux'; - -import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; -import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; -import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import InfoIcon from '@/material-icons/400-24px/info.svg?react'; -import { changeSetting } from 'mastodon/actions/settings'; -import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions'; -import { Avatar } from 'mastodon/components/avatar'; -import { DisplayName } from 'mastodon/components/display_name'; -import { FollowButton } from 'mastodon/components/follow_button'; -import { Icon } from 'mastodon/components/icon'; -import { IconButton } from 'mastodon/components/icon_button'; -import { VerifiedBadge } from 'mastodon/components/verified_badge'; -import { domain } from 'mastodon/initial_state'; - -const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, - next: { id: 'lightbox.next', defaultMessage: 'Next' }, - dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" }, - friendsOfFriendsHint: { id: 'follow_suggestions.hints.friends_of_friends', defaultMessage: 'This profile is popular among the people you follow.' }, - similarToRecentlyFollowedHint: { id: 'follow_suggestions.hints.similar_to_recently_followed', defaultMessage: 'This profile is similar to the profiles you have most recently followed.' }, - featuredHint: { id: 'follow_suggestions.hints.featured', defaultMessage: 'This profile has been hand-picked by the {domain} team.' }, - mostFollowedHint: { id: 'follow_suggestions.hints.most_followed', defaultMessage: 'This profile is one of the most followed on {domain}.'}, - mostInteractionsHint: { id: 'follow_suggestions.hints.most_interactions', defaultMessage: 'This profile has been recently getting a lot of attention on {domain}.' }, -}); - -const Source = ({ id }) => { - const intl = useIntl(); - - let label, hint; - - switch (id) { - case 'friends_of_friends': - hint = intl.formatMessage(messages.friendsOfFriendsHint); - label = ; - break; - case 'similar_to_recently_followed': - hint = intl.formatMessage(messages.similarToRecentlyFollowedHint); - label = ; - break; - case 'featured': - hint = intl.formatMessage(messages.featuredHint, { domain }); - label = ; - break; - case 'most_followed': - hint = intl.formatMessage(messages.mostFollowedHint, { domain }); - label = ; - break; - case 'most_interactions': - hint = intl.formatMessage(messages.mostInteractionsHint, { domain }); - label = ; - break; - } - - return ( -
    - - {label} -
    - ); -}; - -Source.propTypes = { - id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']), -}; - -const Card = ({ id, sources }) => { - const intl = useIntl(); - const account = useSelector(state => state.getIn(['accounts', id])); - const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at')); - const dispatch = useDispatch(); - - const handleDismiss = useCallback(() => { - dispatch(dismissSuggestion(id)); - }, [id, dispatch]); - - return ( -
    - - -
    - -
    - -
    - - {firstVerifiedField ? : } -
    - - -
    - ); -}; - -Card.propTypes = { - id: PropTypes.string.isRequired, - sources: ImmutablePropTypes.list, -}; - -const DISMISSIBLE_ID = 'home/follow-suggestions'; - -export const InlineFollowSuggestions = ({ hidden }) => { - const intl = useIntl(); - const dispatch = useDispatch(); - const suggestions = useSelector(state => state.getIn(['suggestions', 'items'])); - const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading'])); - const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID])); - const bodyRef = useRef(); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(true); - - useEffect(() => { - dispatch(fetchSuggestions()); - }, [dispatch]); - - useEffect(() => { - if (!bodyRef.current) { - return; - } - - if (getComputedStyle(bodyRef.current).direction === 'rtl') { - setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth); - setCanScrollRight(bodyRef.current.scrollLeft < 0); - } else { - setCanScrollLeft(bodyRef.current.scrollLeft > 0); - setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth); - } - }, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]); - - const handleLeftNav = useCallback(() => { - bodyRef.current.scrollLeft -= 200; - }, [bodyRef]); - - const handleRightNav = useCallback(() => { - bodyRef.current.scrollLeft += 200; - }, [bodyRef]); - - const handleScroll = useCallback(() => { - if (!bodyRef.current) { - return; - } - - if (getComputedStyle(bodyRef.current).direction === 'rtl') { - setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth); - setCanScrollRight(bodyRef.current.scrollLeft < 0); - } else { - setCanScrollLeft(bodyRef.current.scrollLeft > 0); - setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth); - } - }, [setCanScrollRight, setCanScrollLeft, bodyRef]); - - const handleDismiss = useCallback(() => { - dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true)); - }, [dispatch]); - - if (dismissed || (!isLoading && suggestions.isEmpty())) { - return null; - } - - if (hidden) { - return ( -
    - ); - } - - return ( -
    -
    -

    - -
    - - -
    -
    - -
    -
    - {suggestions.map(suggestion => ( - - ))} -
    - - {canScrollLeft && ( - - )} - - {canScrollRight && ( - - )} -
    -
    - ); -}; - -InlineFollowSuggestions.propTypes = { - hidden: PropTypes.bool, -}; diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx new file mode 100644 index 0000000000..5fd47443d9 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx @@ -0,0 +1,326 @@ +import { useEffect, useCallback, useRef, useState } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import { changeSetting } from 'mastodon/actions/settings'; +import { + fetchSuggestions, + dismissSuggestion, +} from 'mastodon/actions/suggestions'; +import type { ApiSuggestionSourceJSON } from 'mastodon/api_types/suggestions'; +import { Avatar } from 'mastodon/components/avatar'; +import { DisplayName } from 'mastodon/components/display_name'; +import { FollowButton } from 'mastodon/components/follow_button'; +import { Icon } from 'mastodon/components/icon'; +import { IconButton } from 'mastodon/components/icon_button'; +import { VerifiedBadge } from 'mastodon/components/verified_badge'; +import { domain } from 'mastodon/initial_state'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, + dismiss: { + id: 'follow_suggestions.dismiss', + defaultMessage: "Don't show again", + }, + friendsOfFriendsHint: { + id: 'follow_suggestions.hints.friends_of_friends', + defaultMessage: 'This profile is popular among the people you follow.', + }, + similarToRecentlyFollowedHint: { + id: 'follow_suggestions.hints.similar_to_recently_followed', + defaultMessage: + 'This profile is similar to the profiles you have most recently followed.', + }, + featuredHint: { + id: 'follow_suggestions.hints.featured', + defaultMessage: 'This profile has been hand-picked by the {domain} team.', + }, + mostFollowedHint: { + id: 'follow_suggestions.hints.most_followed', + defaultMessage: 'This profile is one of the most followed on {domain}.', + }, + mostInteractionsHint: { + id: 'follow_suggestions.hints.most_interactions', + defaultMessage: + 'This profile has been recently getting a lot of attention on {domain}.', + }, +}); + +const Source: React.FC<{ + id: ApiSuggestionSourceJSON; +}> = ({ id }) => { + const intl = useIntl(); + + let label, hint; + + switch (id) { + case 'friends_of_friends': + hint = intl.formatMessage(messages.friendsOfFriendsHint); + label = ( + + ); + break; + case 'similar_to_recently_followed': + hint = intl.formatMessage(messages.similarToRecentlyFollowedHint); + label = ( + + ); + break; + case 'featured': + hint = intl.formatMessage(messages.featuredHint, { domain }); + label = ( + + ); + break; + case 'most_followed': + hint = intl.formatMessage(messages.mostFollowedHint, { domain }); + label = ( + + ); + break; + case 'most_interactions': + hint = intl.formatMessage(messages.mostInteractionsHint, { domain }); + label = ( + + ); + break; + } + + return ( +
    + + {label} +
    + ); +}; + +const Card: React.FC<{ + id: string; + sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]]; +}> = ({ id, sources }) => { + const intl = useIntl(); + const account = useAppSelector((state) => state.accounts.get(id)); + const firstVerifiedField = account?.fields.find((item) => !!item.verified_at); + const dispatch = useAppDispatch(); + + const handleDismiss = useCallback(() => { + void dispatch(dismissSuggestion({ accountId: id })); + }, [id, dispatch]); + + return ( +
    + + +
    + + + +
    + +
    + + + + {firstVerifiedField ? ( + + ) : ( + + )} +
    + + +
    + ); +}; + +const DISMISSIBLE_ID = 'home/follow-suggestions'; + +export const InlineFollowSuggestions: React.FC<{ + hidden?: boolean; +}> = ({ hidden }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const suggestions = useAppSelector((state) => state.suggestions.items); + const isLoading = useAppSelector((state) => state.suggestions.isLoading); + const dismissed = useAppSelector( + (state) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean, + ); + const bodyRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + + useEffect(() => { + void dispatch(fetchSuggestions()); + }, [dispatch]); + + useEffect(() => { + if (!bodyRef.current) { + return; + } + + if (getComputedStyle(bodyRef.current).direction === 'rtl') { + setCanScrollLeft( + bodyRef.current.clientWidth - bodyRef.current.scrollLeft < + bodyRef.current.scrollWidth, + ); + setCanScrollRight(bodyRef.current.scrollLeft < 0); + } else { + setCanScrollLeft(bodyRef.current.scrollLeft > 0); + setCanScrollRight( + bodyRef.current.scrollLeft + bodyRef.current.clientWidth < + bodyRef.current.scrollWidth, + ); + } + }, [setCanScrollRight, setCanScrollLeft, suggestions]); + + const handleLeftNav = useCallback(() => { + if (!bodyRef.current) { + return; + } + + bodyRef.current.scrollLeft -= 200; + }, []); + + const handleRightNav = useCallback(() => { + if (!bodyRef.current) { + return; + } + + bodyRef.current.scrollLeft += 200; + }, []); + + const handleScroll = useCallback(() => { + if (!bodyRef.current) { + return; + } + + if (getComputedStyle(bodyRef.current).direction === 'rtl') { + setCanScrollLeft( + bodyRef.current.clientWidth - bodyRef.current.scrollLeft < + bodyRef.current.scrollWidth, + ); + setCanScrollRight(bodyRef.current.scrollLeft < 0); + } else { + setCanScrollLeft(bodyRef.current.scrollLeft > 0); + setCanScrollRight( + bodyRef.current.scrollLeft + bodyRef.current.clientWidth < + bodyRef.current.scrollWidth, + ); + } + }, [setCanScrollRight, setCanScrollLeft]); + + const handleDismiss = useCallback(() => { + dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true)); + }, [dispatch]); + + if (dismissed || (!isLoading && suggestions.length === 0)) { + return null; + } + + if (hidden) { + return
    ; + } + + return ( +
    +
    +

    + +

    + +
    + + + + +
    +
    + +
    +
    + {suggestions.map((suggestion) => ( + + ))} +
    + + {canScrollLeft && ( + + )} + + {canScrollRight && ( + + )} +
    +
    + ); +}; diff --git a/app/javascript/mastodon/features/interaction_modal/index.jsx b/app/javascript/mastodon/features/interaction_modal/index.jsx deleted file mode 100644 index 446cc2586a..0000000000 --- a/app/javascript/mastodon/features/interaction_modal/index.jsx +++ /dev/null @@ -1,427 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - -import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; - -import classNames from 'classnames'; - -import { connect } from 'react-redux'; - -import { throttle, escapeRegExp } from 'lodash'; - -import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; -import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; -import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; -import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; -import StarIcon from '@/material-icons/400-24px/star.svg?react'; -import { openModal, closeModal } from 'mastodon/actions/modal'; -import api from 'mastodon/api'; -import { Button } from 'mastodon/components/button'; -import { Icon } from 'mastodon/components/icon'; -import { registrationsOpen, sso_redirect } from 'mastodon/initial_state'; - -const messages = defineMessages({ - loginPrompt: { id: 'interaction_modal.login.prompt', defaultMessage: 'Domain of your home server, e.g. mastodon.social' }, -}); - -const mapStateToProps = (state, { accountId }) => ({ - displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']), - signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up', -}); - -const mapDispatchToProps = (dispatch) => ({ - onSignupClick() { - dispatch(closeModal({ - modalType: undefined, - ignoreFocus: false, - })); - dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); - }, -}); - -const PERSISTENCE_KEY = 'mastodon_home'; - -const isValidDomain = value => { - const url = new URL('https:///path'); - url.hostname = value; - return url.hostname === value; -}; - -const valueToDomain = value => { - // If the user starts typing an URL - if (/^https?:\/\//.test(value)) { - try { - const url = new URL(value); - - // Consider that if there is a path, the URL is more meaningful than a bare domain - if (url.pathname.length > 1) { - return ''; - } - - return url.host; - } catch { - return undefined; - } - // If the user writes their full handle including username - } else if (value.includes('@')) { - if (value.replace(/^@/, '').split('@').length > 2) { - return undefined; - } - return ''; - } - - return value; -}; - -const addInputToOptions = (value, options) => { - value = value.trim(); - - if (value.includes('.') && isValidDomain(value)) { - return [value].concat(options.filter((x) => x !== value)); - } - - return options; -}; - -class LoginForm extends React.PureComponent { - - static propTypes = { - resourceUrl: PropTypes.string, - intl: PropTypes.object.isRequired, - }; - - state = { - value: localStorage ? (localStorage.getItem(PERSISTENCE_KEY) || '') : '', - expanded: false, - selectedOption: -1, - isLoading: false, - isSubmitting: false, - error: false, - options: [], - networkOptions: [], - }; - - setRef = c => { - this.input = c; - }; - - isValueValid = (value) => { - let likelyAcct = false; - let url = null; - - if (value.startsWith('/')) { - return false; - } - - if (value.startsWith('@')) { - value = value.slice(1); - likelyAcct = true; - } - - // The user is in the middle of typing something, do not error out - if (value === '') { - return true; - } - - if (/^https?:\/\//.test(value) && !likelyAcct) { - url = value; - } else { - url = `https://${value}`; - } - - try { - new URL(url); - return true; - } catch { - return false; - } - }; - - handleChange = ({ target }) => { - const error = !this.isValueValid(target.value); - this.setState(state => ({ error, value: target.value, isLoading: true, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions()); - }; - - handleMessage = (event) => { - const { resourceUrl } = this.props; - - if (event.origin !== window.origin || event.source !== this.iframeRef.contentWindow) { - return; - } - - if (event.data?.type === 'fetchInteractionURL-failure') { - this.setState({ isSubmitting: false, error: true }); - } else if (event.data?.type === 'fetchInteractionURL-success') { - if (/^https?:\/\//.test(event.data.template)) { - try { - const url = new URL(event.data.template.replace('{uri}', encodeURIComponent(resourceUrl))); - - if (localStorage) { - localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain); - } - - window.location.href = url; - } catch (e) { - console.error(e); - this.setState({ isSubmitting: false, error: true }); - } - } else { - this.setState({ isSubmitting: false, error: true }); - } - } - }; - - componentDidMount () { - window.addEventListener('message', this.handleMessage); - } - - componentWillUnmount () { - window.removeEventListener('message', this.handleMessage); - } - - handleSubmit = () => { - const { value } = this.state; - - this.setState({ isSubmitting: true }); - - this.iframeRef.contentWindow.postMessage({ - type: 'fetchInteractionURL', - uri_or_domain: value.trim(), - }, window.origin); - }; - - setIFrameRef = (iframe) => { - this.iframeRef = iframe; - }; - - handleFocus = () => { - this.setState({ expanded: true }); - }; - - handleBlur = () => { - this.setState({ expanded: false }); - }; - - handleKeyDown = (e) => { - const { options, selectedOption } = this.state; - - switch(e.key) { - case 'ArrowDown': - e.preventDefault(); - - if (options.length > 0) { - this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) }); - } - - break; - case 'ArrowUp': - e.preventDefault(); - - if (options.length > 0) { - this.setState({ selectedOption: Math.max(selectedOption - 1, -1) }); - } - - break; - case 'Enter': - e.preventDefault(); - - if (selectedOption === -1) { - this.handleSubmit(); - } else if (options.length > 0) { - this.setState({ value: options[selectedOption], error: false }, () => this.handleSubmit()); - } - - break; - } - }; - - handleOptionClick = e => { - const index = Number(e.currentTarget.getAttribute('data-index')); - const option = this.state.options[index]; - - e.preventDefault(); - this.setState({ selectedOption: index, value: option, error: false }, () => this.handleSubmit()); - }; - - _loadOptions = throttle(() => { - const { value } = this.state; - - const domain = valueToDomain(value.trim()); - - if (typeof domain === 'undefined') { - this.setState({ options: [], networkOptions: [], isLoading: false, error: true }); - return; - } - - if (domain.length === 0) { - this.setState({ options: [], networkOptions: [], isLoading: false }); - return; - } - - api().get('/api/v1/peers/search', { params: { q: domain } }).then(({ data }) => { - if (!data) { - data = []; - } - - this.setState((state) => ({ networkOptions: data, options: addInputToOptions(state.value, data), isLoading: false })); - }).catch(() => { - this.setState({ isLoading: false }); - }); - }, 200, { leading: true, trailing: true }); - - render () { - const { intl } = this.props; - const { value, expanded, options, selectedOption, error, isSubmitting } = this.state; - const domain = (valueToDomain(value) || '').trim(); - const domainRegExp = new RegExp(`(${escapeRegExp(domain)})`, 'gi'); - const hasPopOut = domain.length > 0 && options.length > 0; - - return ( -
    - -