glitch-soc/mastodon

View on GitHub
app/javascript/flavours/glitch/features/interaction_modal/index.jsx

Summary

Maintainability
F
3 wks
Test Coverage
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 '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 = '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', 'vote']),
    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;
    case 'vote':
      icon = <Icon id='tasks' icon={InsertChartIcon} />;
      title = <FormattedMessage id='interaction_modal.title.vote' defaultMessage="Vote in {name}'s poll" values={{ name }} />;
      actionDescription = <FormattedMessage id='interaction_modal.description.vote' defaultMessage='With an account on Mastodon, you can vote in this poll.' />;
      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);