From b4e739ff0f64c601973762ac986c0e63092d2d7e Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 27 Jul 2023 16:11:17 +0200 Subject: [PATCH] Change interaction modal in web UI (#26075) Co-authored-by: Eugen Rochko --- app/chewy/instances_index.rb | 12 + .../api/v1/instances/peers_controller.rb | 2 +- .../api/v1/peers/search_controller.rb | 45 +++ .../authorize_interactions_controller.rb | 21 +- .../remote_interaction_helper_controller.rb | 43 +++ .../well_known/webfinger_controller.rb | 1 + .../mastodon/containers/status_container.jsx | 2 +- .../containers/header_container.jsx | 2 +- .../features/compose/components/search.jsx | 4 - .../features/interaction_modal/index.jsx | 298 ++++++++++++++---- .../picture_in_picture/components/footer.jsx | 6 +- .../mastodon/features/status/index.jsx | 6 +- app/javascript/mastodon/locales/en.json | 8 +- .../packs/remote_interaction_helper.ts | 172 ++++++++++ .../styles/mastodon/components.scss | 111 ++++--- app/javascript/styles/mastodon/variables.scss | 2 + app/javascript/types/resources.ts | 1 + app/lib/importer/instances_index_importer.rb | 26 ++ app/lib/webfinger_resource.rb | 9 + app/models/instance.rb | 1 + app/serializers/rest/account_serializer.rb | 6 +- app/serializers/webfinger_serializer.rb | 1 + .../_post_follow_actions.html.haml | 4 - .../authorize_interactions/error.html.haml | 3 - .../authorize_interactions/show.html.haml | 24 -- .../authorize_interactions/success.html.haml | 13 - app/views/layouts/helper_frame.html.haml | 8 + .../remote_interaction_helper/index.html.haml | 4 + .../scheduler/instance_refresh_scheduler.rb | 1 + config/locales/an.yml | 12 - config/locales/ar.yml | 12 - config/locales/ast.yml | 9 - config/locales/be.yml | 12 - config/locales/bg.yml | 12 - config/locales/br.yml | 5 - config/locales/ca.yml | 12 - config/locales/ckb.yml | 12 - config/locales/co.yml | 12 - config/locales/cs.yml | 12 - config/locales/cy.yml | 12 - config/locales/da.yml | 12 - config/locales/de.yml | 12 - config/locales/el.yml | 12 - config/locales/en-GB.yml | 12 - config/locales/en.yml | 12 - config/locales/eo.yml | 12 - config/locales/es-AR.yml | 12 - config/locales/es-MX.yml | 12 - config/locales/es.yml | 12 - config/locales/et.yml | 12 - config/locales/eu.yml | 12 - config/locales/fa.yml | 12 - config/locales/fi.yml | 12 - config/locales/fo.yml | 12 - config/locales/fr-QC.yml | 12 - config/locales/fr.yml | 12 - config/locales/fy.yml | 12 - config/locales/ga.yml | 5 - config/locales/gd.yml | 12 - config/locales/gl.yml | 12 - config/locales/he.yml | 12 - config/locales/hr.yml | 4 - config/locales/hu.yml | 12 - config/locales/hy.yml | 11 - config/locales/id.yml | 12 - config/locales/io.yml | 12 - config/locales/is.yml | 12 - config/locales/it.yml | 12 - config/locales/ja.yml | 12 - config/locales/ka.yml | 11 - config/locales/kab.yml | 8 - config/locales/kk.yml | 11 - config/locales/ko.yml | 12 - config/locales/ku.yml | 12 - config/locales/lt.yml | 11 - config/locales/lv.yml | 12 - config/locales/ml.yml | 10 - config/locales/ms.yml | 8 - config/locales/my.yml | 12 - config/locales/nl.yml | 12 - config/locales/nn.yml | 12 - config/locales/no.yml | 12 - config/locales/oc.yml | 11 - config/locales/pl.yml | 12 - config/locales/pt-BR.yml | 12 - config/locales/pt-PT.yml | 12 - config/locales/ro.yml | 12 - config/locales/ru.yml | 12 - config/locales/sc.yml | 12 - config/locales/sco.yml | 12 - config/locales/si.yml | 12 - config/locales/sk.yml | 11 - config/locales/sl.yml | 12 - config/locales/sq.yml | 12 - config/locales/sr-Latn.yml | 12 - config/locales/sr.yml | 12 - config/locales/sv.yml | 12 - config/locales/ta.yml | 2 - config/locales/th.yml | 12 - config/locales/tr.yml | 12 - config/locales/tt.yml | 6 - config/locales/uk.yml | 12 - config/locales/vi.yml | 12 - config/locales/zgh.yml | 3 - config/locales/zh-CN.yml | 12 - config/locales/zh-HK.yml | 12 - config/locales/zh-TW.yml | 12 - config/routes.rb | 5 +- config/routes/api.rb | 4 + lib/mastodon/cli/search.rb | 1 + .../authorize_interactions_controller_spec.rb | 51 +-- 111 files changed, 679 insertions(+), 1088 deletions(-) create mode 100644 app/chewy/instances_index.rb create mode 100644 app/controllers/api/v1/peers/search_controller.rb create mode 100644 app/controllers/remote_interaction_helper_controller.rb create mode 100644 app/javascript/packs/remote_interaction_helper.ts create mode 100644 app/lib/importer/instances_index_importer.rb delete mode 100644 app/views/authorize_interactions/_post_follow_actions.html.haml delete mode 100644 app/views/authorize_interactions/error.html.haml delete mode 100644 app/views/authorize_interactions/show.html.haml delete mode 100644 app/views/authorize_interactions/success.html.haml create mode 100644 app/views/layouts/helper_frame.html.haml create mode 100644 app/views/remote_interaction_helper/index.html.haml 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/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index bf28d1842..99eed018b 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -3,32 +3,19 @@ class AuthorizeInteractionsController < ApplicationController include Authorization - layout 'modal' - before_action :authenticate_user! - before_action :set_body_classes before_action :set_resource 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 @@ -61,8 +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 end 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/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 536765e13..7a7cd9880 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -278,7 +278,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/mastodon/features/account_timeline/containers/header_container.jsx b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx index 2b3a66c55..df5427c30 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx +++ b/app/javascript/mastodon/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/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx index 7badb0774..682f8d3c8 100644 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ b/app/javascript/mastodon/features/compose/components/search.jsx @@ -139,10 +139,6 @@ class Search extends PureComponent { this.setState({ expanded: false, selectedOption: -1 }); }; - findTarget = () => { - return this.searchForm; - }; - handleHashtagClick = () => { const { router } = this.context; const { value, onClickSearchResult } = this.props; diff --git a/app/javascript/mastodon/features/interaction_modal/index.jsx b/app/javascript/mastodon/features/interaction_modal/index.jsx index 4722c130e..6e17ab019 100644 --- a/app/javascript/mastodon/features/interaction_modal/index.jsx +++ b/app/javascript/mastodon/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 'mastodon/actions/modal'; +import api from 'mastodon/api'; +import Button from 'mastodon/components/button'; import { Icon } from 'mastodon/components/icon'; import { registrationsOpen } 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' })); + dispatch(closeModal()); + dispatch(openModal('CLOSED_REGISTRATIONS')); }, }); -class Copypaste extends PureComponent { +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 = { - 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 }); + } + } }; + componentDidMount () { + window.addEventListener('message', this.handleMessage); + } + componentWillUnmount () { - if (this.timeout) clearTimeout(this.timeout); + 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 ( -
- + +