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 { 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, sso_redirect } 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" })); }, }); 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 = { 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 (