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 PersonAddIcon from '@material-symbols/svg-600/outlined/person_add.svg?react'; import RepeatIcon from '@material-symbols/svg-600/outlined/repeat.svg?react'; import ReplyIcon from '@material-symbols/svg-600/outlined/reply.svg?react'; import StarIcon from '@material-symbols/svg-600/outlined/star.svg?react'; 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, 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 (