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/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-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-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/.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 ebc166a48a..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 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 8cbceae214..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,15 +41,15 @@ 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) @@ -59,22 +59,22 @@ GEM 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 @@ -94,7 +94,7 @@ GEM ast (2.4.2) attr_required (1.0.2) aws-eventstream (1.3.0) - aws-partitions (1.1017.0) + aws-partitions (1.1025.0) aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -103,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.176.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) @@ -168,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) @@ -199,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) @@ -224,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) @@ -279,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 @@ -294,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) @@ -318,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) @@ -327,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) @@ -384,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) @@ -406,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.8) - minitest (5.25.2) + minitest (5.25.4) msgpack (1.7.5) multi_json (1.15.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) @@ -426,10 +426,10 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.16.8) + 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) @@ -460,7 +460,7 @@ 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) @@ -475,29 +475,29 @@ GEM opentelemetry-semantic_conventions 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.3) + opentelemetry-instrumentation-active_model_serializers (0.21.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (>= 0.6.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_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) @@ -510,7 +510,7 @@ GEM opentelemetry-instrumentation-excon (0.22.5) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-faraday (0.24.7) + opentelemetry-instrumentation-faraday (0.24.8) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-http (0.23.5) @@ -529,14 +529,14 @@ GEM opentelemetry-instrumentation-rack (0.25.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rails (0.33.1) + 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) @@ -579,7 +579,8 @@ 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.5.0) @@ -608,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) @@ -633,15 +634,15 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.1) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) 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) @@ -655,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) @@ -663,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) @@ -706,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) @@ -729,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) @@ -743,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) @@ -753,14 +754,14 @@ GEM scenic (1.8.0) activerecord (>= 4.0.0) railties (>= 4.0.0) - securerandom (0.3.2) + 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) @@ -807,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) @@ -836,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) @@ -866,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) @@ -958,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) @@ -967,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) @@ -1035,4 +1036,4 @@ RUBY VERSION ruby 3.3.6p108 BUNDLED WITH - 2.5.23 + 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/application_controller.rb b/app/controllers/application_controller.rb index 7a858ed059..1b071e8655 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -70,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? 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/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/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/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/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/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_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/components/attachment_list.jsx b/app/javascript/mastodon/components/attachment_list.jsx index c5ac046751..f97e22f2d4 100644 --- a/app/javascript/mastodon/components/attachment_list.jsx +++ b/app/javascript/mastodon/components/attachment_list.jsx @@ -36,7 +36,7 @@ export default class AttachmentList extends ImmutablePureComponent { return (

  • - + {compact && } {compact && ' ' } {displayUrl ? filename(displayUrl) : } 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/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 faf9d8bdb8..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} diff --git a/app/javascript/mastodon/components/hashtag.tsx b/app/javascript/mastodon/components/hashtag.tsx index 8963e4a40d..f3d5cc1f2e 100644 --- a/app/javascript/mastodon/components/hashtag.tsx +++ b/app/javascript/mastodon/components/hashtag.tsx @@ -12,6 +12,7 @@ import { Sparklines, SparklinesCurve } from 'react-sparklines'; import { ShortNumber } from 'mastodon/components/short_number'; import { Skeleton } from 'mastodon/components/skeleton'; +import type { Hashtag as HashtagType } from 'mastodon/models/tags'; interface SilentErrorBoundaryProps { children: React.ReactNode; @@ -80,6 +81,22 @@ export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => ( /> ); +export const CompatibilityHashtag: React.FC<{ + hashtag: HashtagType; +}> = ({ hashtag }) => ( + (day.uses as unknown as number) * 1) + .reverse()} + /> +); + export interface HashtagProps { className?: string; description?: React.ReactNode; diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx index 59963a0a9f..95b06abc54 100644 --- a/app/javascript/mastodon/components/media_gallery.jsx +++ b/app/javascript/mastodon/components/media_gallery.jsx @@ -106,7 +106,7 @@ class Item extends PureComponent { if (attachment.get('type') === 'unknown') { return (
    - + record.get('emojis').reduce((obj, emoji) => { - obj[`:${emoji.get('shortcode')}:`] = emoji.toJS(); - return obj; -}, {}); - class Poll extends ImmutablePureComponent { static propTypes = { identity: identityContextPropShape, - poll: ImmutablePropTypes.map.isRequired, + poll: ImmutablePropTypes.record.isRequired, status: ImmutablePropTypes.map.isRequired, lang: PropTypes.string, intl: PropTypes.object.isRequired, @@ -150,7 +145,7 @@ class Poll extends ImmutablePureComponent { let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml'); if (!titleHtml) { - const emojiMap = makeEmojiMap(poll); + const emojiMap = emojiMap(poll); titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); } diff --git a/app/javascript/mastodon/components/regeneration_indicator.jsx b/app/javascript/mastodon/components/regeneration_indicator.jsx deleted file mode 100644 index d42a7d7c72..0000000000 --- a/app/javascript/mastodon/components/regeneration_indicator.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { FormattedMessage } from 'react-intl'; - -import illustration from '@/images/elephant_ui_working.svg'; - -const RegenerationIndicator = () => ( -
    -
    - -
    - -
    - - -
    -
    -); - -export default RegenerationIndicator; diff --git a/app/javascript/mastodon/components/regeneration_indicator.tsx b/app/javascript/mastodon/components/regeneration_indicator.tsx new file mode 100644 index 0000000000..e26b93eb4f --- /dev/null +++ b/app/javascript/mastodon/components/regeneration_indicator.tsx @@ -0,0 +1,26 @@ +import { FormattedMessage } from 'react-intl'; + +import { GIF } from './gif'; + +export const RegenerationIndicator: React.FC = () => ( +
    + + +
    + + + + +
    +
    +); diff --git a/app/javascript/mastodon/components/server_banner.jsx b/app/javascript/mastodon/components/server_banner.jsx index b6ea01997b..989ac7f006 100644 --- a/app/javascript/mastodon/components/server_banner.jsx +++ b/app/javascript/mastodon/components/server_banner.jsx @@ -42,7 +42,7 @@ class ServerBanner extends PureComponent { return (
    - {domain}, mastodon: Mastodon }} /> + {domain}, mastodon: Mastodon }} />
    diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index cf6fe86c3d..9f57629807 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -293,7 +293,7 @@ class Status extends ImmutablePureComponent { if (e?.button === 0 && !(e?.ctrlKey || e?.metaKey)) { history.push(path); } else if (e?.button === 1 || (e?.button === 0 && (e?.ctrlKey || e?.metaKey))) { - window.open(path, '_blank', 'noreferrer noopener'); + window.open(path, '_blank', 'noopener'); } }; diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index c6cacbd2b2..3091e2a2a0 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -6,7 +6,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { debounce } from 'lodash'; import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines'; -import RegenerationIndicator from 'mastodon/components/regeneration_indicator'; +import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator'; import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions'; import StatusContainer from '../containers/status_container'; diff --git a/app/javascript/mastodon/containers/poll_container.js b/app/javascript/mastodon/containers/poll_container.js index db378cba7c..7ca840138d 100644 --- a/app/javascript/mastodon/containers/poll_container.js +++ b/app/javascript/mastodon/containers/poll_container.js @@ -9,14 +9,14 @@ import Poll from 'mastodon/components/poll'; const mapDispatchToProps = (dispatch, { pollId }) => ({ refresh: debounce( () => { - dispatch(fetchPoll(pollId)); + dispatch(fetchPoll({ pollId })); }, 1000, { leading: true }, ), onVote (choices) { - dispatch(vote(pollId, choices)); + dispatch(vote({ pollId, choices })); }, onInteractionModal (type, status) { @@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { pollId }) => ({ }); const mapStateToProps = (state, { pollId }) => ({ - poll: state.getIn(['polls', pollId]), + poll: state.polls.get(pollId), }); export default connect(mapStateToProps, mapDispatchToProps)(Poll); diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 3b24a76368..34e84506f0 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -18,7 +18,7 @@ import Column from 'mastodon/components/column'; import { Icon } from 'mastodon/components/icon'; import { ServerHeroImage } from 'mastodon/components/server_hero_image'; import { Skeleton } from 'mastodon/components/skeleton'; -import LinkFooter from 'mastodon/features/ui/components/link_footer'; +import { LinkFooter} from 'mastodon/features/ui/components/link_footer'; const messages = defineMessages({ title: { id: 'column.about', defaultMessage: 'About' }, @@ -123,7 +123,7 @@ class About extends PureComponent {
    `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />

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

    -

    Mastodon }} />

    +

    Mastodon }} />

    diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index 6583c1f604..003845c323 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { Helmet } from 'react-helmet'; import { NavLink, withRouter } from 'react-router-dom'; +import { isFulfilled, isRejected } from '@reduxjs/toolkit'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -215,8 +216,20 @@ class Header extends ImmutablePureComponent { const link = e.currentTarget; - onOpenURL(link.href, history, () => { - window.location = link.href; + onOpenURL(link.href).then((result) => { + 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 = link.href; + } + } else if (isRejected(result)) { + window.location = link.href; + } + }).catch(() => { + // Nothing }); } }; @@ -421,7 +434,7 @@ class Header extends ImmutablePureComponent {
    - + diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx index 8df06bd920..14050c25d1 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx @@ -144,8 +144,8 @@ const mapDispatchToProps = (dispatch) => ({ })); }, - onOpenURL (url, routerHistory, onFailure) { - dispatch(openURL(url, routerHistory, onFailure)); + onOpenURL (url) { + return dispatch(openURL({ url })); }, }); diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx deleted file mode 100644 index 7fa7ad248b..0000000000 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ /dev/null @@ -1,402 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl'; - -import classNames from 'classnames'; -import { withRouter } from 'react-router-dom'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; - -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 { Icon } from 'mastodon/components/icon'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { domain, searchEnabled } from 'mastodon/initial_state'; -import { HASHTAG_REGEX } from 'mastodon/utils/hashtags'; -import { WithRouterPropTypes } from 'mastodon/utils/react_router'; - -const messages = defineMessages({ - placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, - placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' }, -}); - -const labelForRecentSearch = search => { - switch(search.get('type')) { - case 'account': - return `@${search.get('q')}`; - case 'hashtag': - return `#${search.get('q')}`; - default: - return search.get('q'); - } -}; - -class Search extends PureComponent { - static propTypes = { - identity: identityContextPropShape, - value: PropTypes.string.isRequired, - recent: ImmutablePropTypes.orderedSet, - submitted: PropTypes.bool, - onChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - onOpenURL: PropTypes.func.isRequired, - onClickSearchResult: PropTypes.func.isRequired, - onForgetSearchResult: PropTypes.func.isRequired, - onClear: PropTypes.func.isRequired, - onShow: PropTypes.func.isRequired, - openInRoute: PropTypes.bool, - intl: PropTypes.object.isRequired, - singleColumn: PropTypes.bool, - ...WithRouterPropTypes, - }; - - state = { - expanded: false, - selectedOption: -1, - options: [], - }; - - defaultOptions = [ - { key: 'prompt-has', label: <>has: , action: e => { e.preventDefault(); this._insertText('has:'); } }, - { key: 'prompt-is', label: <>is: , action: e => { e.preventDefault(); this._insertText('is:'); } }, - { key: 'prompt-language', label: <>language: , action: e => { e.preventDefault(); this._insertText('language:'); } }, - { key: 'prompt-from', label: <>from: , action: e => { e.preventDefault(); this._insertText('from:'); } }, - { key: 'prompt-before', label: <>before: , action: e => { e.preventDefault(); this._insertText('before:'); } }, - { key: 'prompt-during', label: <>during: , action: e => { e.preventDefault(); this._insertText('during:'); } }, - { key: 'prompt-after', label: <>after: , action: e => { e.preventDefault(); this._insertText('after:'); } }, - { key: 'prompt-in', label: <>in: , action: e => { e.preventDefault(); this._insertText('in:'); } } - ]; - - setRef = c => { - this.searchForm = c; - }; - - handleChange = ({ target }) => { - const { onChange } = this.props; - - onChange(target.value); - - this._calculateOptions(target.value); - }; - - handleClear = e => { - const { value, submitted, onClear } = this.props; - - e.preventDefault(); - - if (value.length > 0 || submitted) { - onClear(); - this.setState({ options: [], selectedOption: -1 }); - } - }; - - handleKeyDown = (e) => { - const { selectedOption } = this.state; - const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions(); - - switch(e.key) { - case 'Escape': - e.preventDefault(); - this._unfocus(); - - break; - 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._submit(); - } else if (options.length > 0) { - options[selectedOption].action(e); - } - - break; - case 'Delete': - if (selectedOption > -1 && options.length > 0) { - const search = options[selectedOption]; - - if (typeof search.forget === 'function') { - e.preventDefault(); - search.forget(e); - } - } - - break; - } - }; - - handleFocus = () => { - const { onShow, singleColumn } = this.props; - - this.setState({ expanded: true, selectedOption: -1 }); - onShow(); - - if (this.searchForm && !singleColumn) { - const { left, right } = this.searchForm.getBoundingClientRect(); - - if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { - this.searchForm.scrollIntoView(); - } - } - }; - - handleBlur = () => { - this.setState({ expanded: false, selectedOption: -1 }); - }; - - handleHashtagClick = () => { - const { value, onClickSearchResult, history } = this.props; - - const query = value.trim().replace(/^#/, ''); - - history.push(`/tags/${query}`); - onClickSearchResult(query, 'hashtag'); - this._unfocus(); - }; - - handleAccountClick = () => { - const { value, onClickSearchResult, history } = this.props; - - const query = value.trim().replace(/^@/, ''); - - history.push(`/@${query}`); - onClickSearchResult(query, 'account'); - this._unfocus(); - }; - - handleURLClick = () => { - const { value, onOpenURL, history } = this.props; - - onOpenURL(value, history); - this._unfocus(); - }; - - handleStatusSearch = () => { - this._submit('statuses'); - }; - - handleAccountSearch = () => { - this._submit('accounts'); - }; - - handleRecentSearchClick = search => { - const { onChange, history } = this.props; - - if (search.get('type') === 'account') { - history.push(`/@${search.get('q')}`); - } else if (search.get('type') === 'hashtag') { - history.push(`/tags/${search.get('q')}`); - } else { - onChange(search.get('q')); - this._submit(search.get('type')); - } - - this._unfocus(); - }; - - handleForgetRecentSearchClick = search => { - const { onForgetSearchResult } = this.props; - - onForgetSearchResult(search.get('q')); - }; - - _unfocus () { - document.querySelector('.ui').parentElement.focus(); - } - - _insertText (text) { - const { value, onChange } = this.props; - - if (value === '') { - onChange(text); - } else if (value[value.length - 1] === ' ') { - onChange(`${value}${text}`); - } else { - onChange(`${value} ${text}`); - } - } - - _submit (type) { - const { onSubmit, openInRoute, value, onClickSearchResult, history } = this.props; - - onSubmit(type); - - if (value) { - onClickSearchResult(value, type); - } - - if (openInRoute) { - history.push('/search'); - } - - this._unfocus(); - } - - _getOptions () { - const { options } = this.state; - - if (options.length > 0) { - return options; - } - - const { recent } = this.props; - - return recent.toArray().map(search => ({ - key: `${search.get('type')}/${search.get('q')}`, - - label: labelForRecentSearch(search), - - action: () => this.handleRecentSearchClick(search), - - forget: e => { - e.stopPropagation(); - this.handleForgetRecentSearchClick(search); - }, - })); - } - - _calculateOptions (value) { - const { signedIn } = this.props.identity; - const trimmedValue = value.trim(); - const options = []; - - if (trimmedValue.length > 0) { - const couldBeURL = trimmedValue.startsWith('https://') && !trimmedValue.includes(' '); - - if (couldBeURL) { - options.push({ key: 'open-url', label: , action: this.handleURLClick }); - } - - const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX); - - if (couldBeHashtag) { - options.push({ key: 'go-to-hashtag', label: #{trimmedValue.replace(/^#/, '')} }} />, action: this.handleHashtagClick }); - } - - const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i); - - if (couldBeUsername) { - options.push({ key: 'go-to-account', label: @{trimmedValue.replace(/^@/, '')} }} />, action: this.handleAccountClick }); - } - - const couldBeStatusSearch = searchEnabled; - - if (couldBeStatusSearch && signedIn) { - options.push({ key: 'status-search', label: {trimmedValue} }} />, action: this.handleStatusSearch }); - } - - const couldBeUserSearch = true; - - if (couldBeUserSearch) { - options.push({ key: 'account-search', label: {trimmedValue} }} />, action: this.handleAccountSearch }); - } - } - - this.setState({ options }); - } - - render () { - const { intl, value, submitted, recent } = this.props; - const { expanded, options, selectedOption } = this.state; - const { signedIn } = this.props.identity; - - const hasValue = value.length > 0 || submitted; - - return ( -
    - - -
    - - -
    - -
    - {options.length === 0 && ( - <> -

    - -
    - {recent.size > 0 ? this._getOptions().map(({ label, key, action, forget }, i) => ( - - - )) : ( -
    - -
    - )} -
    - - )} - - {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 986d78e8b1..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 { Account } from 'mastodon/components/account'; -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 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/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/results.jsx b/app/javascript/mastodon/features/explore/results.jsx deleted file mode 100644 index ad70d459d8..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 { Account } from 'mastodon/components/account'; -import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; -import { Icon } from 'mastodon/components/icon'; -import ScrollableList from 'mastodon/components/scrollable_list'; -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/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/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 ( -
    - -