From f19fd0b88991f9728a74fcfe05e22f01ca62d6bc Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 17 Dec 2024 13:36:32 +0100 Subject: [PATCH] Change design of interaction modal in web UI (#33278) Co-authored-by: Claire --- .../features/interaction_modal/index.jsx | 427 ------------- .../features/interaction_modal/index.tsx | 581 ++++++++++++++++++ app/javascript/mastodon/locales/en.json | 18 +- .../styles/mastodon/components.scss | 6 +- 4 files changed, 590 insertions(+), 442 deletions(-) delete mode 100644 app/javascript/mastodon/features/interaction_modal/index.jsx create mode 100644 app/javascript/mastodon/features/interaction_modal/index.tsx 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 ( -
- -