diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml deleted file mode 100644 index 22f51f7bd..000000000 --- a/.github/ISSUE_TEMPLATE/1.bug_report.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Bug Report -description: If something isn't working as expected -labels: [bug] -body: - - type: markdown - attributes: - value: | - Make sure that you are submitting a new bug that was not previously reported or already fixed. - - Please use a concise and distinct title for the issue. - - type: textarea - attributes: - label: Steps to reproduce the problem - description: What were you trying to do? - value: | - 1. - 2. - 3. - ... - validations: - required: true - - type: input - attributes: - label: Expected behaviour - description: What should have happened? - validations: - required: true - - type: input - attributes: - label: Actual behaviour - description: What happened? - validations: - required: true - - type: textarea - attributes: - label: Detailed description - validations: - required: false - - type: textarea - attributes: - label: Specifications - description: | - What version or commit hash of Mastodon did you find this bug in? - - If a front-end issue, what browser and operating systems were you using? - placeholder: | - Mastodon 3.5.3 (or Edge) - Ruby 2.7.6 (or v3.1.2) - Node.js 16.18.0 - - Google Chrome 106.0.5249.119 - Firefox 105.0.3 - - etc... - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml new file mode 100644 index 000000000..20e27d103 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml @@ -0,0 +1,76 @@ +name: Bug Report (Web Interface) +description: If you are using Mastodon's web interface and something is not working as expected +labels: [bug, 'status/to triage', 'area/web interface'] +body: + - type: markdown + attributes: + value: | + Make sure that you are submitting a new bug that was not previously reported or already fixed. + + Please use a concise and distinct title for the issue. + - type: textarea + attributes: + label: Steps to reproduce the problem + description: What were you trying to do? + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: input + attributes: + label: Expected behaviour + description: What should have happened? + validations: + required: true + - type: input + attributes: + label: Actual behaviour + description: What happened? + validations: + required: true + - type: textarea + attributes: + label: Detailed description + validations: + required: false + - type: input + attributes: + label: Mastodon instance + description: The address of the Mastodon instance where you experienced the issue + placeholder: mastodon.social + validations: + required: true + - type: input + attributes: + label: Mastodon version + description: | + This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` + placeholder: v4.1.2 + validations: + required: true + - type: input + attributes: + label: Browser name and version + description: | + What browser are you using when getting this bug? Please specify the version as well. + placeholder: Firefox 105.0.3 + validations: + required: true + - type: input + attributes: + label: Operating system + description: | + What OS are you running? Please specify the version as well. + placeholder: macOS 13.4.1 + validations: + required: true + - type: textarea + attributes: + label: Technical details + description: | + Any additional technical details you may have. This can include the full error log, inspector's output… + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml new file mode 100644 index 000000000..49d5f5720 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml @@ -0,0 +1,65 @@ +name: Bug Report (server / API) +description: | + If something is not working as expected, but is not from using the web interface. +labels: [bug, 'status/to triage'] +body: + - type: markdown + attributes: + value: | + Make sure that you are submitting a new bug that was not previously reported or already fixed. + + Please use a concise and distinct title for the issue. + - type: textarea + attributes: + label: Steps to reproduce the problem + description: What were you trying to do? + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: input + attributes: + label: Expected behaviour + description: What should have happened? + validations: + required: true + - type: input + attributes: + label: Actual behaviour + description: What happened? + validations: + required: true + - type: textarea + attributes: + label: Detailed description + validations: + required: false + - type: input + attributes: + label: Mastodon instance + description: The address of the Mastodon instance where you experienced the issue + placeholder: mastodon.social + validations: + required: false + - type: input + attributes: + label: Mastodon version + description: | + This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` + placeholder: v4.1.2 + validations: + required: false + - type: textarea + attributes: + label: Technical details + description: | + Any additional technical details you may have, like logs or error traces + value: | + If this is happening on your own Mastodon server, please fill out those: + - Ruby version: (from `ruby --version`, eg. v3.1.2) + - Node.js version: (from `node --version`, eg. v18.16.0) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2.feature_request.yml b/.github/ISSUE_TEMPLATE/3.feature_request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/2.feature_request.yml rename to .github/ISSUE_TEMPLATE/3.feature_request.yml diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 07cb1d41f..ee9eefd45 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -107,6 +107,10 @@ jobs: PAM_ENABLED: true PAM_DEFAULT_SERVICE: pam_test PAM_CONTROLLED_SERVICE: pam_test_controlled + OIDC_ENABLED: true + OIDC_SCOPE: read + SAML_ENABLED: true + CAS_ENABLED: true BUNDLE_WITH: 'pam_authentication test' CI_JOBS: ${{ matrix.ci_job }}/4 diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml index 26599fcc7..6d2aa0641 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -1,13 +1,13 @@ # This configuration was generated by # `haml-lint --auto-gen-config` -# on 2023-07-18 11:56:05 -0400 using Haml-Lint version 0.48.0. +# on 2023-07-20 09:47:50 -0400 using Haml-Lint version 0.48.0. # The point is for the user to remove these configuration records # one by one as the lints are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of Haml-Lint, may require this file to be generated again. linters: - # Offense count: 959 + # Offense count: 951 LineLength: enabled: false @@ -15,7 +15,7 @@ linters: UnnecessaryStringOutput: enabled: false - # Offense count: 63 + # Offense count: 57 RuboCop: enabled: false @@ -26,7 +26,7 @@ linters: - 'app/views/admin/reports/show.html.haml' - 'app/views/disputes/strikes/show.html.haml' - # Offense count: 40 + # Offense count: 32 InstanceVariables: exclude: - 'app/views/admin/reports/_actions.html.haml' @@ -38,7 +38,6 @@ linters: - 'app/views/invites/_form.html.haml' - 'app/views/relationships/_account.html.haml' - 'app/views/shared/_og.html.haml' - - 'app/views/statuses/_status.html.haml' # Offense count: 3 IdNames: diff --git a/.rubocop.yml b/.rubocop.yml index 597dfcb9e..9ebb0219b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -131,12 +131,6 @@ RSpec/FilePath: Exclude: - 'spec/config/initializers/rack_attack_spec.rb' # namespaces usually have separate folder - 'spec/lib/sanitize_config_spec.rb' # namespaces usually have separate folder - - 'spec/controllers/concerns/account_controller_concern_spec.rb' # Concerns describe ApplicationController and don't fit naming - - 'spec/controllers/concerns/export_controller_concern_spec.rb' - - 'spec/controllers/concerns/localized_spec.rb' - - 'spec/controllers/concerns/rate_limit_headers_spec.rb' - - 'spec/controllers/concerns/signature_verification_spec.rb' - - 'spec/controllers/concerns/user_tracking_concern_spec.rb' # Reason: # https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnamedsubject diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 62f837a4d..55709313f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -127,12 +127,6 @@ Lint/UselessAssignment: - 'spec/services/resolve_url_service_spec.rb' - 'spec/views/statuses/show.html.haml_spec.rb' -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: CheckForMethodsWithNoSideEffects. -Lint/Void: - Exclude: - - 'spec/services/resolve_account_service_spec.rb' - # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 150 @@ -152,13 +146,6 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 27 -# Configuration parameters: ExpectMatchingDefinition, CheckDefinitionPathHierarchy, CheckDefinitionPathHierarchyRoots, Regex, IgnoreExecutableScripts, AllowedAcronyms. -# CheckDefinitionPathHierarchyRoots: lib, spec, test, src -# AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS -Naming/FileName: - Exclude: - - 'config/locales/sr-Latn.rb' - # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. # SupportedStyles: snake_case, normalcase, non_integer # AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 @@ -287,7 +274,6 @@ RSpec/LetSetup: - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb' - 'spec/controllers/api/v2/filters/keywords_controller_spec.rb' - 'spec/controllers/api/v2/filters/statuses_controller_spec.rb' - - 'spec/controllers/api/v2/filters_controller_spec.rb' - 'spec/controllers/auth/confirmations_controller_spec.rb' - 'spec/controllers/auth/passwords_controller_spec.rb' - 'spec/controllers/auth/sessions_controller_spec.rb' @@ -297,6 +283,7 @@ RSpec/LetSetup: - 'spec/controllers/oauth/tokens_controller_spec.rb' - 'spec/controllers/settings/imports_controller_spec.rb' - 'spec/lib/activitypub/activity/delete_spec.rb' + - 'spec/lib/vacuum/applications_vacuum_spec.rb' - 'spec/lib/vacuum/preview_cards_vacuum_spec.rb' - 'spec/models/account_spec.rb' - 'spec/models/account_statuses_cleanup_policy_spec.rb' @@ -841,8 +828,6 @@ Style/RedundantConstantBase: Exclude: - 'config/environments/production.rb' - 'config/initializers/sidekiq.rb' - - 'config/locales/sr-Latn.rb' - - 'config/locales/sr.rb' # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: SafeForConstants. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5383d426b..c49b19273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project will be documented in this file. +## [4.1.5] - 2023-07-21 + +### Added + +- Add check preventing Sidekiq workers from running with Makara configured ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25850)) + +### Changed + +- Change request timeout handling to use a longer deadline ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26055)) + +### Fixed + +- Fix moderation interface for remote instances with a .zip TLD ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25885)) +- Fix remote accounts being possibly persisted to database with incomplete protocol values ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25886)) +- Fix trending publishers table not rendering correctly on narrow screens ([vmstan](https://github.com/mastodon/mastodon/pull/25945)) + +### Security + +- Fix CSP headers being unintentionally wide ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26105)) + ## [4.1.4] - 2023-07-07 ### Fixed diff --git a/Gemfile b/Gemfile index 8fcc88c51..a8920d877 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,7 @@ gem 'aws-sdk-s3', '~> 1.123', require: false gem 'fog-core', '<= 2.4.0' gem 'fog-openstack', '~> 0.3', require: false gem 'kt-paperclip', '~> 7.2' +gem 'md-paperclip-azure', '~> 2.2', require: false gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' diff --git a/Gemfile.lock b/Gemfile.lock index a75746355..5b1c62a69 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -103,7 +103,7 @@ GEM attr_required (1.0.1) awrence (1.2.1) aws-eventstream (1.2.0) - aws-partitions (1.786.0) + aws-partitions (1.791.0) aws-sdk-core (3.178.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) @@ -112,12 +112,20 @@ GEM aws-sdk-kms (1.71.0) aws-sdk-core (~> 3, >= 3.177.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.130.0) + aws-sdk-s3 (1.131.0) aws-sdk-core (~> 3, >= 3.177.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) aws-sigv4 (1.6.0) aws-eventstream (~> 1, >= 1.0.2) + azure-storage-blob (2.0.3) + azure-storage-common (~> 2.0) + nokogiri (~> 1, >= 1.10.8) + azure-storage-common (2.0.4) + faraday (~> 1.0) + faraday_middleware (~> 1.0, >= 1.0.0.rc1) + net-http-persistent (~> 4.0) + nokogiri (~> 1, >= 1.10.8) bcrypt (3.1.18) better_errors (2.10.1) erubi (>= 1.0.0) @@ -136,7 +144,7 @@ GEM blurhash (0.1.7) bootsnap (1.16.0) msgpack (~> 1.2) - brakeman (6.0.0) + brakeman (6.0.1) browser (5.3.1) brpoplpush-redis_script (0.1.3) concurrent-ruby (~> 1.0, >= 1.0.5) @@ -261,6 +269,8 @@ GEM faraday-patron (1.0.0) faraday-rack (1.0.0) faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) fast_blank (1.0.1) fastimage (2.2.7) ffi (1.15.5) @@ -297,7 +307,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.48.0) + haml_lint (0.49.2) haml (>= 4.0, < 6.2) parallel (~> 1.10) rainbow @@ -410,6 +420,10 @@ GEM mario-redis-lock (1.2.1) redis (>= 3.0.5) matrix (0.4.2) + md-paperclip-azure (2.2.0) + addressable (~> 2.5) + azure-storage-blob (~> 2.0.1) + hashie (~> 5.0) memory_profiler (1.0.1) method_source (1.0.0) mime-types (3.4.1) @@ -423,6 +437,8 @@ GEM multipart-post (2.3.0) net-http (0.3.2) uri + net-http-persistent (4.0.2) + connection_pool (~> 2.2) net-imap (0.3.6) date net-protocol @@ -553,7 +569,7 @@ GEM rake (13.0.6) rdf (3.2.11) link_header (~> 0.0, >= 0.0.8) - rdf-normalize (0.6.0) + rdf-normalize (0.6.1) rdf (~> 3.2) redcarpet (3.6.0) redis (4.8.1) @@ -567,7 +583,7 @@ GEM responders (3.1.0) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.5) + rexml (3.2.6) rotp (6.2.2) rouge (4.1.2) rpam2 (4.0.2) @@ -822,6 +838,7 @@ DEPENDENCIES link_header (~> 0.0) lograge (~> 0.12) mario-redis-lock (~> 1.2) + md-paperclip-azure (~> 2.2) memory_profiler mime-types (~> 3.4.1) net-http (~> 0.3.2) diff --git a/app/chewy/instances_index.rb b/app/chewy/instances_index.rb new file mode 100644 index 000000000..2bce4043c --- /dev/null +++ b/app/chewy/instances_index.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class InstancesIndex < Chewy::Index + settings index: { refresh_interval: '30s' } + + index_scope ::Instance.searchable + + root date_detection: false do + field :domain, type: 'text', index_prefixes: { min_chars: 1 } + field :accounts_count, type: 'long' + end +end diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index 70281362a..23096650e 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Instances::PeersController < Api::BaseController def index cache_even_if_authenticated! - render_with_cache(expires_in: 1.day) { Instance.where.not(domain: DomainBlock.select(:domain)).pluck(:domain) } + render_with_cache(expires_in: 1.day) { Instance.searchable.pluck(:domain) } end private diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb new file mode 100644 index 000000000..50a342cde --- /dev/null +++ b/app/controllers/api/v1/peers/search_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Api::V1::Peers::SearchController < Api::BaseController + before_action :require_enabled_api! + before_action :set_domains + + skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + skip_around_action :set_locale + + vary_by '' + + def index + cache_even_if_authenticated! + render json: @domains + end + + private + + def require_enabled_api! + head 404 unless Setting.peers_api_enabled && !whitelist_mode? + end + + def set_domains + return if params[:q].blank? + + if Chewy.enabled? + @domains = InstancesIndex.query(function_score: { + query: { + prefix: { + domain: params[:q], + }, + }, + + field_value_factor: { + field: 'accounts_count', + modifier: 'log2p', + }, + }).limit(10).pluck(:domain) + else + domain = params[:q].strip + domain = TagManager.instance.normalize_domain(domain) + @domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain) + end + end +end diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index 2e21ce6a0..f3428e3df 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -17,13 +17,16 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController if fav @status = fav.status + count = [@status.favourites_count - 1, 0].max UnfavouriteWorker.perform_async(current_account.id, @status.id) else @status = Status.find(params[:status_id]) + count = @status.favourites_count authorize @status, :show? end - render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }) + relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } }) + render json: @status, serializer: REST::StatusSerializer, relationships: relationships rescue Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index e3769437b..3ca623117 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -24,15 +24,18 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController if @status authorize @status, :unreblog? + @reblog = @status.reblog + count = [@reblog.reblogs_count - 1, 0].max @status.discard RemovalWorker.perform_async(@status.id) - @reblog = @status.reblog else @reblog = Status.find(params[:status_id]) + count = @reblog.reblogs_count authorize @reblog, :show? end - render json: @reblog, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }) + relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } }) + render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships rescue Mastodon::NotPermittedError not_found end diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb index 9e0fb942a..4723806b9 100644 --- a/app/controllers/auth/omniauth_callbacks_controller.rb +++ b/app/controllers/auth/omniauth_callbacks_controller.rb @@ -5,21 +5,13 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController def self.provides_callback_for(provider) define_method provider do + @provider = provider @user = User.find_for_oauth(request.env['omniauth.auth'], current_user) if @user.persisted? - LoginActivity.create( - user: @user, - success: true, - authentication_method: :omniauth, - provider: provider, - ip: request.remote_ip, - user_agent: request.user_agent - ) - + record_login_activity sign_in_and_redirect @user, event: :authentication - label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize) - set_flash_message(:notice, :success, kind: label) if is_navigational_format? + set_flash_message(:notice, :success, kind: label_for_provider) if is_navigational_format? else session["devise.#{provider}_data"] = request.env['omniauth.auth'] redirect_to new_user_registration_url @@ -38,4 +30,29 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController auth_setup_path(missing_email: '1') end end + + private + + def record_login_activity + LoginActivity.create( + user: @user, + success: true, + authentication_method: :omniauth, + provider: @provider, + ip: request.remote_ip, + user_agent: request.user_agent + ) + end + + def label_for_provider + provider_display_name || configured_provider_name + end + + def provider_display_name + Devise.omniauth_configs[@provider]&.strategy&.display_name.presence + end + + def configured_provider_name + I18n.t("auth.providers.#{@provider}", default: @provider.to_s.chomp('_oauth2').capitalize) + end end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index dc8893367..d59250b31 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -113,7 +113,7 @@ class Auth::SessionsController < Devise::SessionsController end def home_paths(resource) - paths = [about_path] + paths = [about_path, '/explore'] paths << short_account_path(username: resource.account) if single_user_mode? && resource.is_a?(User) diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index 73f0f2b88..99eed018b 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -3,33 +3,19 @@ class AuthorizeInteractionsController < ApplicationController include Authorization - layout 'modal' - before_action :authenticate_user! - before_action :set_body_classes before_action :set_resource - before_action :set_pack def show if @resource.is_a?(Account) - render :show + redirect_to web_url("@#{@resource.pretty_acct}") elsif @resource.is_a?(Status) redirect_to web_url("@#{@resource.account.pretty_acct}/#{@resource.id}") else - render :error + not_found end end - def create - if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource, with_rate_limit: true) - render :success - else - render :error - end - rescue ActiveRecord::RecordNotFound - render :error - end - private def set_resource @@ -62,12 +48,4 @@ class AuthorizeInteractionsController < ApplicationController def uri_param params[:uri] || params.fetch(:acct, '').delete_prefix('acct:') end - - def set_body_classes - @body_classes = 'modal-layout' - end - - def set_pack - use_pack 'modal' - end end diff --git a/app/controllers/backups_controller.rb b/app/controllers/backups_controller.rb index 205df48d4..db23fefbb 100644 --- a/app/controllers/backups_controller.rb +++ b/app/controllers/backups_controller.rb @@ -10,7 +10,7 @@ class BackupsController < ApplicationController def download case Paperclip::Attachment.default_options[:storage] - when :s3 + when :s3, :azure redirect_to @backup.dump.expiring_url(10), allow_other_host: true when :fog if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present? diff --git a/app/controllers/remote_interaction_helper_controller.rb b/app/controllers/remote_interaction_helper_controller.rb new file mode 100644 index 000000000..90c853f47 --- /dev/null +++ b/app/controllers/remote_interaction_helper_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class RemoteInteractionHelperController < ApplicationController + vary_by '' + + skip_before_action :require_functional! + skip_around_action :set_locale + skip_before_action :update_user_sign_in + + content_security_policy do |p| + # We inherit the normal `script-src` + + # Set every directive that does not have a fallback + p.default_src :none + p.form_action :none + p.base_uri :none + + # Disable every directive with a fallback to cut on response size + p.base_uri false + p.font_src false + p.img_src false + p.style_src false + p.media_src false + p.frame_src false + p.manifest_src false + p.connect_src false + p.child_src false + p.worker_src false + + # Widen the directives that we do need + p.frame_ancestors :self + p.connect_src :https + end + + def index + expires_in(5.minutes, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) + + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + response.headers['Referrer-Policy'] = 'no-referrer' + + render layout: 'helper_frame' + end +end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 0d897e8e2..4748940f7 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -19,6 +19,7 @@ module WellKnown def set_account username = username_from_resource + @account = begin if username == Rails.configuration.x.local_domain Account.representative diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3148756b7..b85c8fe84 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -236,6 +236,6 @@ module ApplicationHelper private def storage_host_var - ENV.fetch('S3_ALIAS_HOST', nil) || ENV.fetch('S3_CLOUDFRONT_HOST', nil) + ENV.fetch('S3_ALIAS_HOST', nil) || ENV.fetch('S3_CLOUDFRONT_HOST', nil) || ENV.fetch('AZURE_ALIAS_HOST', nil) end end diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 2f5fecaae..7e7398bde 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -22,7 +22,14 @@ module ContextHelper blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' }, discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, - olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' }, + olm: { + 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', + 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, + 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, + 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, + 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, + 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' + }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, }.freeze diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 18b3bf064..53b0706ca 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -65,33 +65,6 @@ module StatusesHelper embedded_view? ? '_blank' : nil end - def style_classes(status, is_predecessor, is_successor, include_threads) - classes = ['entry'] - classes << 'entry-predecessor' if is_predecessor - classes << 'entry-reblog' if status.reblog? - classes << 'entry-successor' if is_successor - classes << 'entry-center' if include_threads - classes.join(' ') - end - - def microformats_classes(status, is_direct_parent, is_direct_child) - classes = [] - classes << 'p-in-reply-to' if is_direct_parent - classes << 'p-repost-of' if status.reblog? && is_direct_parent - classes << 'p-comment' if is_direct_child - classes.join(' ') - end - - def microformats_h_class(status, is_predecessor, is_successor, include_threads) - if is_predecessor || status.reblog? || is_successor - 'h-cite' - elsif include_threads - '' - else - 'h-entry' - end - end - def fa_visibility_icon(status) case status.visibility when 'public' diff --git a/app/javascript/core/remote_interaction_helper.ts b/app/javascript/core/remote_interaction_helper.ts new file mode 100644 index 000000000..53d95b5db --- /dev/null +++ b/app/javascript/core/remote_interaction_helper.ts @@ -0,0 +1,172 @@ +/* + +This script is meant to to be used in an `iframe` with the sole purpose of doing webfinger queries +client-side without being restricted by a strict `connect-src` Content-Security-Policy directive. + +It communicates with the parent window through message events that are authenticated by origin, +and performs no other task. + +*/ + +import 'packs/public-path'; + +import axios from 'axios'; + +interface JRDLink { + rel: string; + template?: string; + href?: string; +} + +const isJRDLink = (link: unknown): link is JRDLink => + typeof link === 'object' && + link !== null && + 'rel' in link && + typeof link.rel === 'string' && + (!('template' in link) || typeof link.template === 'string') && + (!('href' in link) || typeof link.href === 'string'); + +const findLink = (rel: string, data: unknown): JRDLink | undefined => { + if ( + typeof data === 'object' && + data !== null && + 'links' in data && + data.links instanceof Array + ) { + return data.links.find( + (link): link is JRDLink => isJRDLink(link) && link.rel === rel, + ); + } else { + return undefined; + } +}; + +const findTemplateLink = (data: unknown) => + findLink('http://ostatus.org/schema/1.0/subscribe', data)?.template; + +const fetchInteractionURLSuccess = ( + uri_or_domain: string, + template: string, +) => { + window.parent.postMessage( + { + type: 'fetchInteractionURL-success', + uri_or_domain, + template, + }, + window.origin, + ); +}; + +const fetchInteractionURLFailure = () => { + window.parent.postMessage( + { + type: 'fetchInteractionURL-failure', + }, + window.origin, + ); +}; + +const isValidDomain = (value: string) => { + const url = new URL('https:///path'); + url.hostname = value; + return url.hostname === value; +}; + +// Attempt to find a remote interaction URL from a domain +const fromDomain = (domain: string) => { + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: `https://${domain}` }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(domain, template ?? fallbackTemplate); + return; + }) + .catch(() => { + fetchInteractionURLSuccess(domain, fallbackTemplate); + }); +}; + +// Attempt to find a remote interaction URL from an arbitrary URL +const fromURL = (url: string) => { + const domain = new URL(url).host; + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: url }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(url, template ?? fallbackTemplate); + return; + }) + .catch(() => { + fromDomain(domain); + }); +}; + +// Attempt to find a remote interaction URL from a `user@domain` string +const fromAcct = (acct: string) => { + acct = acct.replace(/^@/, ''); + + const segments = acct.split('@'); + + if (segments.length !== 2 || !segments[0] || !isValidDomain(segments[1])) { + fetchInteractionURLFailure(); + return; + } + + const domain = segments[1]; + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: `acct:${acct}` }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(acct, template ?? fallbackTemplate); + return; + }) + .catch(() => { + // TODO: handle host-meta? + fromDomain(domain); + }); +}; + +const fetchInteractionURL = (uri_or_domain: string) => { + if (/^https?:\/\//.test(uri_or_domain)) { + fromURL(uri_or_domain); + } else if (uri_or_domain.includes('@')) { + fromAcct(uri_or_domain); + } else { + fromDomain(uri_or_domain); + } +}; + +window.addEventListener('message', (event: MessageEvent) => { + // Check message origin + if ( + !window.origin || + window.parent !== event.source || + event.origin !== window.origin + ) { + return; + } + + if ( + event.data && + typeof event.data === 'object' && + 'type' in event.data && + event.data.type === 'fetchInteractionURL' && + 'uri_or_domain' in event.data && + typeof event.data.uri_or_domain === 'string' + ) { + fetchInteractionURL(event.data.uri_or_domain); + } +}); diff --git a/app/javascript/core/theme.yml b/app/javascript/core/theme.yml index 30676dcf5..20912e28d 100644 --- a/app/javascript/core/theme.yml +++ b/app/javascript/core/theme.yml @@ -18,3 +18,4 @@ pack: settings: settings.js sign_up: share: + remote_interaction_helper: remote_interaction_helper.ts diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js index 94d133b5f..3d01a96dd 100644 --- a/app/javascript/flavours/glitch/actions/importer/index.js +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -81,7 +81,7 @@ export function importFetchedStatuses(statuses) { } if (status.poll && status.poll.id) { - pushUnique(polls, normalizePoll(status.poll)); + pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); } } @@ -95,7 +95,7 @@ export function importFetchedStatuses(statuses) { } export function importFetchedPoll(poll) { - return dispatch => { - dispatch(importPolls([normalizePoll(poll)])); + return (dispatch, getState) => { + dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))])); }; } diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index 540e6cba7..f58f27517 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -75,6 +75,7 @@ export function normalizeStatus(status, normalOldStatus, settings) { normalStatus.contentHtml = normalOldStatus.get('contentHtml'); normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.hidden = normalOldStatus.get('hidden'); + normalStatus.translation = normalOldStatus.get('translation'); } else { const spoilerText = normalStatus.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); @@ -86,6 +87,18 @@ export function normalizeStatus(status, normalOldStatus, settings) { normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText); } + if (normalOldStatus) { + const list = normalOldStatus.get('media_attachments'); + if (normalStatus.media_attachments && list) { + normalStatus.media_attachments.forEach(item => { + const oldItem = list.find(i => i.get('id') === item.id); + if (oldItem && oldItem.get('description') === item.description) { + item.translation = oldItem.get('translation') + } + }); + } + } + return normalStatus; } @@ -104,15 +117,23 @@ export function normalizeStatusTranslation(translation, status) { return normalTranslation; } -export function normalizePoll(poll) { +export function normalizePoll(poll, normalOldPoll) { const normalPoll = { ...poll }; const emojiMap = makeEmojiMap(poll.emojis); - normalPoll.options = poll.options.map((option, index) => ({ - ...option, - voted: poll.own_votes && poll.own_votes.includes(index), - titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap), - })); + 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; } diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js index d5154c6a8..33cf376a2 100644 --- a/app/javascript/flavours/glitch/actions/search.js +++ b/app/javascript/flavours/glitch/actions/search.js @@ -15,6 +15,9 @@ 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_RESULT_CLICK = 'SEARCH_RESULT_CLICK'; +export const SEARCH_RESULT_FORGET = 'SEARCH_RESULT_FORGET'; + export function changeSearch(value) { return { type: SEARCH_CHANGE, @@ -28,7 +31,7 @@ export function clearSearch() { }; } -export function submitSearch() { +export function submitSearch(type) { return (dispatch, getState) => { const value = getState().getIn(['search', 'value']); const signedIn = !!getState().getIn(['meta', 'me']); @@ -45,6 +48,7 @@ export function submitSearch() { q: value, resolve: signedIn, limit: 10, + type, }, }).then(response => { if (response.data.accounts) { @@ -131,3 +135,42 @@ export const expandSearchFail = error => ({ export const showSearch = () => ({ type: SEARCH_SHOW, }); + +export const openURL = routerHistory => (dispatch, getState) => { + const value = getState().getIn(['search', 'value']); + const signedIn = !!getState().getIn(['meta', 'me']); + + if (!signedIn) { + return; + } + + dispatch(fetchSearchRequest()); + + api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => { + if (response.data.accounts?.length > 0) { + dispatch(importFetchedAccounts(response.data.accounts)); + routerHistory.push(`/@${response.data.accounts[0].acct}`); + } else if (response.data.statuses?.length > 0) { + dispatch(importFetchedStatuses(response.data.statuses)); + routerHistory.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); + } + + dispatch(fetchSearchSuccess(response.data, value)); + }).catch(err => { + dispatch(fetchSearchFail(err)); + }); +}; + +export const clickSearchResult = (q, type) => ({ + type: SEARCH_RESULT_CLICK, + + result: { + type, + q, + }, +}); + +export const forgetSearchResult = q => ({ + type: SEARCH_RESULT_FORGET, + q, +}); diff --git a/app/javascript/flavours/glitch/components/status_action_bar.jsx b/app/javascript/flavours/glitch/components/status_action_bar.jsx index 16a8e4430..8d32c6b5d 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.jsx +++ b/app/javascript/flavours/glitch/components/status_action_bar.jsx @@ -31,7 +31,7 @@ const messages = defineMessages({ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, - favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, diff --git a/app/javascript/flavours/glitch/components/status_prepend.jsx b/app/javascript/flavours/glitch/components/status_prepend.jsx index 9b2bcc8ea..31e84c6e1 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.jsx +++ b/app/javascript/flavours/glitch/components/status_prepend.jsx @@ -55,7 +55,7 @@ export default class StatusPrepend extends PureComponent { return ( ); diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 1f4b982ad..f6bdf9400 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -47,7 +47,7 @@ const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, - redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, + redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned.' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' }, @@ -293,7 +293,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ modalProps: { type, accountId: status.getIn(['account', 'id']), - url: status.get('url'), + url: status.get('uri'), }, })); }, diff --git a/app/javascript/flavours/glitch/features/account/components/header.jsx b/app/javascript/flavours/glitch/features/account/components/header.jsx index 05a4e3844..c95683477 100644 --- a/app/javascript/flavours/glitch/features/account/components/header.jsx +++ b/app/javascript/flavours/glitch/features/account/components/header.jsx @@ -46,7 +46,7 @@ const messages = defineMessages({ pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, - favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx index 4453b557d..aa3146839 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx @@ -130,7 +130,11 @@ export default class MediaItem extends ImmutablePureComponent {

{content} - {label && {label}} + {label && ( +
+ {label} +
+ )}
); } diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx index 270865df4..75b254b09 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx +++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx @@ -83,7 +83,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ modalProps: { type: 'follow', accountId: account.get('id'), - url: account.get('url'), + url: account.get('uri'), }, })); }, diff --git a/app/javascript/flavours/glitch/features/audio/index.jsx b/app/javascript/flavours/glitch/features/audio/index.jsx index 1c4130666..ceb9775b5 100644 --- a/app/javascript/flavours/glitch/features/audio/index.jsx +++ b/app/javascript/flavours/glitch/features/audio/index.jsx @@ -477,6 +477,7 @@ class Audio extends PureComponent { const progress = Math.min((currentTime / duration) * 100, 100); let warning; + if (sensitive) { warning = ; } else { @@ -522,7 +523,10 @@ class Audio extends PureComponent {
diff --git a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx index f155979ef..ff7d4d03d 100644 --- a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx @@ -14,7 +14,7 @@ const messages = defineMessages({ pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, - favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, diff --git a/app/javascript/flavours/glitch/features/compose/components/search.jsx b/app/javascript/flavours/glitch/features/compose/components/search.jsx index 4b655db1c..2f1b46e5d 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/search.jsx @@ -7,39 +7,21 @@ import { defineMessages, } from 'react-intl'; -import Overlay from 'react-overlays/Overlay'; +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + import { Icon } from 'flavours/glitch/components/icon'; import { searchEnabled } from 'flavours/glitch/initial_state'; import { focusRoot } from 'flavours/glitch/utils/dom_helpers'; +import { HASHTAG_REGEX } from 'flavours/glitch/utils/hashtags'; const messages = defineMessages({ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' }, }); -class SearchPopout extends PureComponent { - - render () { - const extraInformation = searchEnabled ? : ; - return ( -
-

- -
    -
  • #example
  • -
  • @username@domain
  • -
  • URL
  • -
  • URL
  • -
- - {extraInformation} -
- ); - } - -} - // The component. class Search extends PureComponent { @@ -50,9 +32,13 @@ class Search extends PureComponent { static propTypes = { 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, @@ -62,59 +48,104 @@ class Search extends PureComponent { state = { expanded: false, + selectedOption: -1, + options: [], }; setRef = c => { this.searchForm = c; }; - handleChange = (e) => { + handleChange = ({ target }) => { const { onChange } = this.props; - if (onChange) { - onChange(e.target.value); - } + + onChange(target.value); + + this._calculateOptions(target.value); }; - handleClear = (e) => { + handleClear = e => { const { onClear, submitted, value, } = this.props; + e.preventDefault(); // Prevents focus change ?? - if (onClear && (submitted || value && value.length)) { + + if (value.length > 0 || submitted) { onClear(); + this.setState({ options: [], selectedOption: -1 }) } }; handleBlur = () => { - this.setState({ expanded: false }); + this.setState({ expanded: false, selectedOption: -1 }); }; handleFocus = () => { - this.setState({ expanded: true }); - this.props.onShow(); + const { onShow, singleColumn } = this.props; - if (this.searchForm && !this.props.singleColumn) { + 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(); } } }; - handleKeyUp = (e) => { - const { onSubmit } = this.props; - switch (e.key) { - case 'Enter': - onSubmit(); + handleKeyDown = (e) => { + const { selectedOption } = this.state; + const options = this._getOptions(); - if (this.props.openInRoute) { - this.context.router.history.push('/search'); + switch(e.key) { + case 'Escape': + e.preventDefault(); + + focusRoot(); + + 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(); + } + + this._unfocus(); + break; + case 'Delete': + if (selectedOption > -1 && options.length > 0) { + const search = options[selectedOption]; + + if (typeof search.forget === 'function') { + e.preventDefault(); + search.forget(e); + } } break; - case 'Escape': - focusRoot(); } }; @@ -122,14 +153,141 @@ class Search extends PureComponent { return this.searchForm; }; + handleHashtagClick = () => { + const { router } = this.context; + const { value, onClickSearchResult } = this.props; + + const query = value.trim().replace(/^#/, ''); + + router.history.push(`/tags/${query}`); + onClickSearchResult(query, 'hashtag'); + }; + + handleAccountClick = () => { + const { router } = this.context; + const { value, onClickSearchResult } = this.props; + + const query = value.trim().replace(/^@/, ''); + + router.history.push(`/@${query}`); + onClickSearchResult(query, 'account'); + }; + + handleURLClick = () => { + const { router } = this.context; + const { onOpenURL } = this.props; + + onOpenURL(router.history); + }; + + handleStatusSearch = () => { + this._submit('statuses'); + }; + + handleAccountSearch = () => { + this._submit('accounts'); + }; + + handleRecentSearchClick = search => { + const { router } = this.context; + + if (search.get('type') === 'account') { + router.history.push(`/@${search.get('q')}`); + } else if (search.get('type') === 'hashtag') { + router.history.push(`/tags/${search.get('q')}`); + } + }; + + handleForgetRecentSearchClick = search => { + const { onForgetSearchResult } = this.props; + + onForgetSearchResult(search.get('q')); + }; + + _unfocus () { + document.querySelector('.ui').parentElement.focus(); + } + + _submit (type) { + const { onSubmit, openInRoute } = this.props; + const { router } = this.context; + + onSubmit(type); + + if (openInRoute) { + router.history.push('/search'); + } + } + + _getOptions () { + const { options } = this.state; + + if (options.length > 0) { + return options; + } + + const { recent } = this.props; + + return recent.toArray().map(search => ({ + label: search.get('type') === 'account' ? `@${search.get('q')}` : `#${search.get('q')}`, + + action: () => this.handleRecentSearchClick(search), + + forget: e => { + e.stopPropagation(); + this.handleForgetRecentSearchClick(search); + }, + })); + } + + _calculateOptions (value) { + 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) { + 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 } = this.props; - const { expanded } = this.state; + const { intl, value, submitted, recent } = this.props; + const { expanded, options, selectedOption } = this.state; const { signedIn } = this.context.identity; + const hasValue = value.length > 0 || submitted; return ( -
+
@@ -147,15 +305,39 @@ class Search extends PureComponent {
- - {({ props, placement }) => ( -
-
- +
+ {options.length === 0 && ( + <> +

+ +
+ {recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => ( + + + )) : ( +
+ +
+ )}
-
+ )} - + {options.length > 0 && ( + <> +

+ +
+ {options.map(({ key, label, action }, i) => ( + + ))} +
+ + )} +
); } diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.jsx b/app/javascript/flavours/glitch/features/compose/components/search_results.jsx index 9763335f1..606dfd6fd 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search_results.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/search_results.jsx @@ -89,7 +89,7 @@ class SearchResults extends ImmutablePureComponent { count += results.get('accounts').size; accounts = (
-
+
{results.get('accounts').map(accountId => )} diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_container.js index 8280d962b..52dc65687 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/search_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/search_container.js @@ -5,6 +5,9 @@ import { clearSearch, submitSearch, showSearch, + openURL, + clickSearchResult, + forgetSearchResult, } from 'flavours/glitch/actions/search'; import Search from '../components/search'; @@ -12,6 +15,7 @@ import Search from '../components/search'; const mapStateToProps = state => ({ value: state.getIn(['search', 'value']), submitted: state.getIn(['search', 'submitted']), + recent: state.getIn(['search', 'recent']), }); const mapDispatchToProps = dispatch => ({ @@ -24,14 +28,26 @@ const mapDispatchToProps = dispatch => ({ dispatch(clearSearch()); }, - onSubmit () { - dispatch(submitSearch()); + onSubmit (type) { + dispatch(submitSearch(type)); }, onShow () { dispatch(showSearch()); }, + onOpenURL (routerHistory) { + dispatch(openURL(routerHistory)); + }, + + onClickSearchResult (q, type) { + dispatch(clickSearchResult(q, type)); + }, + + onForgetSearchResult (q) { + dispatch(forgetSearchResult(q)); + }, + }); export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx b/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx index f7c3fc3e1..16916ba9c 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx +++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx @@ -6,37 +6,14 @@ import { connect } from 'react-redux'; import { me } from 'flavours/glitch/initial_state'; import { profileLink, privacyPolicyLink } from 'flavours/glitch/utils/backend_links'; +import { HASHTAG_PATTERN_REGEX } from 'flavours/glitch/utils/hashtags'; import Warning from '../components/warning'; -const buildHashtagRE = () => { - try { - const HASHTAG_SEPARATORS = '_\\u00b7\\u200c'; - const ALPHA = '\\p{L}\\p{M}'; - const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}'; - return new RegExp( - '(?:^|[^\\/\\)\\w])#((' + - '[' + WORD + '_]' + - '[' + WORD + HASHTAG_SEPARATORS + ']*' + - '[' + ALPHA + HASHTAG_SEPARATORS + ']' + - '[' + WORD + HASHTAG_SEPARATORS +']*' + - '[' + WORD + '_]' + - ')|(' + - '[' + WORD + '_]*' + - '[' + ALPHA + ']' + - '[' + WORD + '_]*' + - '))', 'iu', - ); - } catch { - return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i; - } -}; - -const APPROX_HASHTAG_RE = buildHashtagRE(); const mapStateToProps = state => ({ needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), - hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])), + hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])), directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', }); diff --git a/app/javascript/flavours/glitch/features/explore/components/story.jsx b/app/javascript/flavours/glitch/features/explore/components/story.jsx index 134fa1108..9a4a221bc 100644 --- a/app/javascript/flavours/glitch/features/explore/components/story.jsx +++ b/app/javascript/flavours/glitch/features/explore/components/story.jsx @@ -1,10 +1,13 @@ import PropTypes from 'prop-types'; import { PureComponent } from 'react'; +import { FormattedMessage } from 'react-intl'; + import classNames from 'classnames'; import { Blurhash } from 'flavours/glitch/components/blurhash'; import { accountsCountRenderer } from 'flavours/glitch/components/hashtag'; +import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; import { ShortNumber } from 'flavours/glitch/components/short_number'; import { Skeleton } from 'flavours/glitch/components/skeleton'; @@ -14,10 +17,14 @@ export default class Story extends PureComponent { static propTypes = { url: PropTypes.string, title: PropTypes.string, + lang: PropTypes.string, publisher: PropTypes.string, + publishedAt: PropTypes.string, + author: PropTypes.string, sharedTimes: PropTypes.number, thumbnail: PropTypes.string, blurhash: PropTypes.string, + expanded: PropTypes.bool, }; state = { @@ -27,16 +34,16 @@ export default class Story extends PureComponent { handleImageLoad = () => this.setState({ thumbnailLoaded: true }); render () { - const { url, title, publisher, sharedTimes, thumbnail, blurhash } = this.props; + const { expanded, url, title, lang, publisher, author, publishedAt, sharedTimes, thumbnail, blurhash } = this.props; const { thumbnailLoaded } = this.state; return ( - +
-
{publisher ? publisher : }
-
{title ? title : }
-
{typeof sharedTimes === 'number' ? : }
+
{publisher ? {publisher} : }{publishedAt && <> · }
+
{title ? title : }
+
{author && <>{author} }} /> · }{typeof sharedTimes === 'number' ? : }
diff --git a/app/javascript/flavours/glitch/features/explore/links.jsx b/app/javascript/flavours/glitch/features/explore/links.jsx index 91a93935d..383f68a8b 100644 --- a/app/javascript/flavours/glitch/features/explore/links.jsx +++ b/app/javascript/flavours/glitch/features/explore/links.jsx @@ -55,12 +55,16 @@ class Links extends PureComponent {
{banner} - {isLoading ? () : links.map(link => ( + {isLoading ? () : links.map((link, i) => (
- +
diff --git a/app/javascript/flavours/glitch/features/explore/statuses.jsx b/app/javascript/flavours/glitch/features/explore/statuses.jsx index 33a00a50b..b6cb08307 100644 --- a/app/javascript/flavours/glitch/features/explore/statuses.jsx +++ b/app/javascript/flavours/glitch/features/explore/statuses.jsx @@ -47,7 +47,7 @@ class Statuses extends PureComponent { return ( <> - + ({ @@ -74,7 +74,7 @@ class Favourites extends ImmutablePureComponent { const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; const pinned = !!columnId; - const emptyMessage = ; + const emptyMessage = ; return ( diff --git a/app/javascript/flavours/glitch/features/favourites/index.jsx b/app/javascript/flavours/glitch/features/favourites/index.jsx index a5f21e577..2b36945ee 100644 --- a/app/javascript/flavours/glitch/features/favourites/index.jsx +++ b/app/javascript/flavours/glitch/features/favourites/index.jsx @@ -71,7 +71,7 @@ class Favourites extends ImmutablePureComponent { ); } - const emptyMessage = ; + const emptyMessage = ; return ( diff --git a/app/javascript/flavours/glitch/features/firehose/index.jsx b/app/javascript/flavours/glitch/features/firehose/index.jsx index 3c04ac630..71f5a1de6 100644 --- a/app/javascript/flavours/glitch/features/firehose/index.jsx +++ b/app/javascript/flavours/glitch/features/firehose/index.jsx @@ -76,12 +76,12 @@ const Firehose = ({ feedType, multiColumn }) => { const { signedIn } = useIdentity(); const columnRef = useRef(null); - const onlyMedia = useAppSelector((state) => state.getIn(['settings', 'firehose', 'onlyMedia'], false)); - const hasUnread = useAppSelector((state) => state.getIn(['timelines', `${feedType}${onlyMedia ? ':media' : ''}`, 'unread'], 0) > 0); - const allowLocalOnly = useAppSelector((state) => state.getIn(['settings', 'firehose', 'allowLocalOnly'])); const regex = useAppSelector((state) => state.getIn(['settings', 'firehose', 'regex', 'body'])); + const onlyMedia = useAppSelector((state) => state.getIn(['settings', 'firehose', 'onlyMedia'], false)); + const hasUnread = useAppSelector((state) => state.getIn(['timelines', `${feedType}${feedType === 'public' && allowLocalOnly ? ':allow_local_only' : ''}${onlyMedia ? ':media' : ''}`, 'unread'], 0) > 0); + const handlePin = useCallback( () => { switch(feedType) { diff --git a/app/javascript/flavours/glitch/features/getting_started_misc/index.jsx b/app/javascript/flavours/glitch/features/getting_started_misc/index.jsx index 20dfe80b4..d7500fd82 100644 --- a/app/javascript/flavours/glitch/features/getting_started_misc/index.jsx +++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.jsx @@ -15,7 +15,7 @@ import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subh const messages = defineMessages({ heading: { id: 'column.heading', defaultMessage: 'Misc' }, subheading: { id: 'column.subheading', defaultMessage: 'Miscellaneous options' }, - favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/explore_prompt.tsx b/app/javascript/flavours/glitch/features/home_timeline/components/explore_prompt.tsx index 250b2407d..f33135621 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/components/explore_prompt.tsx +++ b/app/javascript/flavours/glitch/features/home_timeline/components/explore_prompt.tsx @@ -22,7 +22,7 @@ export const ExplorePrompt = () => (

diff --git a/app/javascript/flavours/glitch/features/interaction_modal/index.jsx b/app/javascript/flavours/glitch/features/interaction_modal/index.jsx index c9bbecf1a..18e35de5a 100644 --- a/app/javascript/flavours/glitch/features/interaction_modal/index.jsx +++ b/app/javascript/flavours/glitch/features/interaction_modal/index.jsx @@ -1,95 +1,296 @@ import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; +import React from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import classNames from 'classnames'; import { connect } from 'react-redux'; +import { throttle, escapeRegExp } from 'lodash'; + import { openModal, closeModal } from 'flavours/glitch/actions/modal'; +import api from 'flavours/glitch/api'; +import Button from 'flavours/glitch/components/button'; import { Icon } from 'flavours/glitch/components/icon'; import { registrationsOpen } from 'flavours/glitch/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' })); + dispatch(closeModal()); + dispatch(openModal('CLOSED_REGISTRATIONS')); }, }); -class Copypaste extends PureComponent { +const PERSISTENCE_KEY = 'flavours/glitch_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 = { - value: PropTypes.string, + resourceUrl: PropTypes.string, + intl: PropTypes.object.isRequired, }; state = { - copied: false, + value: localStorage ? (localStorage.getItem(PERSISTENCE_KEY) || '') : '', + expanded: false, + selectedOption: -1, + isLoading: false, + isSubmitting: false, + error: false, + options: [], + networkOptions: [], }; setRef = c => { this.input = c; }; - handleInputClick = () => { - this.setState({ copied: false }); - this.input.focus(); - this.input.select(); - this.input.setSelectionRange(0, this.input.value.length); + handleChange = ({ target }) => { + this.setState(state => ({ value: target.value, isLoading: true, error: false, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions()); }; - handleButtonClick = () => { - const { value } = this.props; - navigator.clipboard.writeText(value); - this.input.blur(); - this.setState({ copied: true }); - this.timeout = setTimeout(() => this.setState({ copied: false }), 700); + 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)) { + if (localStorage) { + localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain); + } + + window.location.href = event.data.template.replace('{uri}', encodeURIComponent(resourceUrl)); + } else { + this.setState({ isSubmitting: false, error: true }); + } + } }; - componentWillUnmount () { - if (this.timeout) clearTimeout(this.timeout); + 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 { value } = this.props; - const { copied } = this.state; + 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 ( -
- + +