422 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			422 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
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);
 |