123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421 |
- 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 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 (
- <div className={classNames('interaction-modal__login', { focused: expanded, expanded: hasPopOut, invalid: error })}>
- <iframe
- ref={this.setIFrameRef}
- style={{display: 'none'}}
- src='/remote_interaction_helper'
- sandbox='allow-scripts allow-same-origin'
- title='remote interaction helper'
- />
- <div className='interaction-modal__login__input'>
- <input
- ref={this.setRef}
- type='text'
- value={value}
- placeholder={intl.formatMessage(messages.loginPrompt)}
- aria-label={intl.formatMessage(messages.loginPrompt)}
- autoFocus
- onChange={this.handleChange}
- onFocus={this.handleFocus}
- onBlur={this.handleBlur}
- onKeyDown={this.handleKeyDown}
- autoComplete='off'
- autoCapitalize='off'
- spellCheck='false'
- />
- <Button onClick={this.handleSubmit} disabled={isSubmitting || error}><FormattedMessage id='interaction_modal.login.action' defaultMessage='Take me home' /></Button>
- </div>
- {hasPopOut && (
- <div className='search__popout'>
- <div className='search__popout__menu'>
- {options.map((option, i) => (
- <button key={option} onMouseDown={this.handleOptionClick} data-index={i} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}>
- {option.split(domainRegExp).map((part, i) => (
- part.toLowerCase() === domain.toLowerCase() ? (
- <mark key={i}>
- {part}
- </mark>
- ) : (
- <span key={i}>
- {part}
- </span>
- )
- ))}
- </button>
- ))}
- </div>
- </div>
- )}
- </div>
- );
- }
- }
- const IntlLoginForm = injectIntl(LoginForm);
- class InteractionModal extends React.PureComponent {
- static propTypes = {
- displayNameHtml: PropTypes.string,
- url: PropTypes.string,
- type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']),
- onSignupClick: PropTypes.func.isRequired,
- signupUrl: PropTypes.string.isRequired,
- };
- handleSignupClick = () => {
- this.props.onSignupClick();
- };
- render () {
- const { url, type, displayNameHtml, signupUrl } = this.props;
- const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />;
- let title, actionDescription, icon;
- switch(type) {
- case 'reply':
- icon = <Icon id='reply' icon={ReplyIcon} />;
- title = <FormattedMessage id='interaction_modal.title.reply' defaultMessage="Reply to {name}'s post" values={{ name }} />;
- actionDescription = <FormattedMessage id='interaction_modal.description.reply' defaultMessage='With an account on Mastodon, you can respond to this post.' />;
- break;
- case 'reblog':
- icon = <Icon id='retweet' icon={RepeatIcon} />;
- title = <FormattedMessage id='interaction_modal.title.reblog' defaultMessage="Boost {name}'s post" values={{ name }} />;
- actionDescription = <FormattedMessage id='interaction_modal.description.reblog' defaultMessage='With an account on Mastodon, you can boost this post to share it with your own followers.' />;
- break;
- case 'favourite':
- icon = <Icon id='star' icon={StarIcon} />;
- title = <FormattedMessage id='interaction_modal.title.favourite' defaultMessage="Favorite {name}'s post" values={{ name }} />;
- actionDescription = <FormattedMessage id='interaction_modal.description.favourite' defaultMessage='With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.' />;
- break;
- case 'follow':
- icon = <Icon id='user-plus' icon={PersonAddIcon} />;
- title = <FormattedMessage id='interaction_modal.title.follow' defaultMessage='Follow {name}' values={{ name }} />;
- actionDescription = <FormattedMessage id='interaction_modal.description.follow' defaultMessage='With an account on Mastodon, you can follow {name} to receive their posts in your home feed.' values={{ name }} />;
- break;
- }
- let signupButton;
- if (sso_redirect) {
- signupButton = (
- <a href={sso_redirect} data-method='post' className='link-button'>
- <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
- </a>
- );
- } else if (registrationsOpen) {
- signupButton = (
- <a href={signupUrl} className='link-button'>
- <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
- </a>
- );
- } else {
- signupButton = (
- <button className='link-button' onClick={this.handleSignupClick}>
- <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
- </button>
- );
- }
- return (
- <div className='modal-root__modal interaction-modal'>
- <div className='interaction-modal__lead'>
- <h3><span className='interaction-modal__icon'>{icon}</span> {title}</h3>
- <p>{actionDescription} <strong><FormattedMessage id='interaction_modal.sign_in' defaultMessage='You are not logged in to this server. Where is your account hosted?' /></strong></p>
- </div>
- <IntlLoginForm resourceUrl={url} />
- <p className='hint'><FormattedMessage id='interaction_modal.sign_in_hint' defaultMessage="Tip: That's the website where you signed up. If you don't remember, look for the welcome e-mail in your inbox. You can also enter your full username! (e.g. @Mastodon@mastodon.social)" /></p>
- <p><FormattedMessage id='interaction_modal.no_account_yet' defaultMessage='Not on Mastodon?' /> {signupButton}</p>
- </div>
- );
- }
- }
- export default connect(mapStateToProps, mapDispatchToProps)(InteractionModal);
|