index.jsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import PropTypes from 'prop-types';
  2. import React from 'react';
  3. import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
  4. import classNames from 'classnames';
  5. import { connect } from 'react-redux';
  6. import { throttle, escapeRegExp } from 'lodash';
  7. import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
  8. import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
  9. import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
  10. import StarIcon from '@/material-icons/400-24px/star.svg?react';
  11. import { openModal, closeModal } from 'mastodon/actions/modal';
  12. import api from 'mastodon/api';
  13. import { Button } from 'mastodon/components/button';
  14. import { Icon } from 'mastodon/components/icon';
  15. import { registrationsOpen, sso_redirect } from 'mastodon/initial_state';
  16. const messages = defineMessages({
  17. loginPrompt: { id: 'interaction_modal.login.prompt', defaultMessage: 'Domain of your home server, e.g. mastodon.social' },
  18. });
  19. const mapStateToProps = (state, { accountId }) => ({
  20. displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']),
  21. signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up',
  22. });
  23. const mapDispatchToProps = (dispatch) => ({
  24. onSignupClick() {
  25. dispatch(closeModal({
  26. modalType: undefined,
  27. ignoreFocus: false,
  28. }));
  29. dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
  30. },
  31. });
  32. const PERSISTENCE_KEY = 'mastodon_home';
  33. const isValidDomain = value => {
  34. const url = new URL('https:///path');
  35. url.hostname = value;
  36. return url.hostname === value;
  37. };
  38. const valueToDomain = value => {
  39. // If the user starts typing an URL
  40. if (/^https?:\/\//.test(value)) {
  41. try {
  42. const url = new URL(value);
  43. // Consider that if there is a path, the URL is more meaningful than a bare domain
  44. if (url.pathname.length > 1) {
  45. return '';
  46. }
  47. return url.host;
  48. } catch {
  49. return undefined;
  50. }
  51. // If the user writes their full handle including username
  52. } else if (value.includes('@')) {
  53. if (value.replace(/^@/, '').split('@').length > 2) {
  54. return undefined;
  55. }
  56. return '';
  57. }
  58. return value;
  59. };
  60. const addInputToOptions = (value, options) => {
  61. value = value.trim();
  62. if (value.includes('.') && isValidDomain(value)) {
  63. return [value].concat(options.filter((x) => x !== value));
  64. }
  65. return options;
  66. };
  67. class LoginForm extends React.PureComponent {
  68. static propTypes = {
  69. resourceUrl: PropTypes.string,
  70. intl: PropTypes.object.isRequired,
  71. };
  72. state = {
  73. value: localStorage ? (localStorage.getItem(PERSISTENCE_KEY) || '') : '',
  74. expanded: false,
  75. selectedOption: -1,
  76. isLoading: false,
  77. isSubmitting: false,
  78. error: false,
  79. options: [],
  80. networkOptions: [],
  81. };
  82. setRef = c => {
  83. this.input = c;
  84. };
  85. isValueValid = (value) => {
  86. let likelyAcct = false;
  87. let url = null;
  88. if (value.startsWith('/')) {
  89. return false;
  90. }
  91. if (value.startsWith('@')) {
  92. value = value.slice(1);
  93. likelyAcct = true;
  94. }
  95. // The user is in the middle of typing something, do not error out
  96. if (value === '') {
  97. return true;
  98. }
  99. if (/^https?:\/\//.test(value) && !likelyAcct) {
  100. url = value;
  101. } else {
  102. url = `https://${value}`;
  103. }
  104. try {
  105. new URL(url);
  106. return true;
  107. } catch(_) {
  108. return false;
  109. }
  110. };
  111. handleChange = ({ target }) => {
  112. const error = !this.isValueValid(target.value);
  113. this.setState(state => ({ error, value: target.value, isLoading: true, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions());
  114. };
  115. handleMessage = (event) => {
  116. const { resourceUrl } = this.props;
  117. if (event.origin !== window.origin || event.source !== this.iframeRef.contentWindow) {
  118. return;
  119. }
  120. if (event.data?.type === 'fetchInteractionURL-failure') {
  121. this.setState({ isSubmitting: false, error: true });
  122. } else if (event.data?.type === 'fetchInteractionURL-success') {
  123. if (/^https?:\/\//.test(event.data.template)) {
  124. try {
  125. const url = new URL(event.data.template.replace('{uri}', encodeURIComponent(resourceUrl)));
  126. if (localStorage) {
  127. localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain);
  128. }
  129. window.location.href = url;
  130. } catch (e) {
  131. console.error(e);
  132. this.setState({ isSubmitting: false, error: true });
  133. }
  134. } else {
  135. this.setState({ isSubmitting: false, error: true });
  136. }
  137. }
  138. };
  139. componentDidMount () {
  140. window.addEventListener('message', this.handleMessage);
  141. }
  142. componentWillUnmount () {
  143. window.removeEventListener('message', this.handleMessage);
  144. }
  145. handleSubmit = () => {
  146. const { value } = this.state;
  147. this.setState({ isSubmitting: true });
  148. this.iframeRef.contentWindow.postMessage({
  149. type: 'fetchInteractionURL',
  150. uri_or_domain: value.trim(),
  151. }, window.origin);
  152. };
  153. setIFrameRef = (iframe) => {
  154. this.iframeRef = iframe;
  155. };
  156. handleFocus = () => {
  157. this.setState({ expanded: true });
  158. };
  159. handleBlur = () => {
  160. this.setState({ expanded: false });
  161. };
  162. handleKeyDown = (e) => {
  163. const { options, selectedOption } = this.state;
  164. switch(e.key) {
  165. case 'ArrowDown':
  166. e.preventDefault();
  167. if (options.length > 0) {
  168. this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) });
  169. }
  170. break;
  171. case 'ArrowUp':
  172. e.preventDefault();
  173. if (options.length > 0) {
  174. this.setState({ selectedOption: Math.max(selectedOption - 1, -1) });
  175. }
  176. break;
  177. case 'Enter':
  178. e.preventDefault();
  179. if (selectedOption === -1) {
  180. this.handleSubmit();
  181. } else if (options.length > 0) {
  182. this.setState({ value: options[selectedOption], error: false }, () => this.handleSubmit());
  183. }
  184. break;
  185. }
  186. };
  187. handleOptionClick = e => {
  188. const index = Number(e.currentTarget.getAttribute('data-index'));
  189. const option = this.state.options[index];
  190. e.preventDefault();
  191. this.setState({ selectedOption: index, value: option, error: false }, () => this.handleSubmit());
  192. };
  193. _loadOptions = throttle(() => {
  194. const { value } = this.state;
  195. const domain = valueToDomain(value.trim());
  196. if (typeof domain === 'undefined') {
  197. this.setState({ options: [], networkOptions: [], isLoading: false, error: true });
  198. return;
  199. }
  200. if (domain.length === 0) {
  201. this.setState({ options: [], networkOptions: [], isLoading: false });
  202. return;
  203. }
  204. api().get('/api/v1/peers/search', { params: { q: domain } }).then(({ data }) => {
  205. if (!data) {
  206. data = [];
  207. }
  208. this.setState((state) => ({ networkOptions: data, options: addInputToOptions(state.value, data), isLoading: false }));
  209. }).catch(() => {
  210. this.setState({ isLoading: false });
  211. });
  212. }, 200, { leading: true, trailing: true });
  213. render () {
  214. const { intl } = this.props;
  215. const { value, expanded, options, selectedOption, error, isSubmitting } = this.state;
  216. const domain = (valueToDomain(value) || '').trim();
  217. const domainRegExp = new RegExp(`(${escapeRegExp(domain)})`, 'gi');
  218. const hasPopOut = domain.length > 0 && options.length > 0;
  219. return (
  220. <div className={classNames('interaction-modal__login', { focused: expanded, expanded: hasPopOut, invalid: error })}>
  221. <iframe
  222. ref={this.setIFrameRef}
  223. style={{display: 'none'}}
  224. src='/remote_interaction_helper'
  225. sandbox='allow-scripts allow-same-origin'
  226. title='remote interaction helper'
  227. />
  228. <div className='interaction-modal__login__input'>
  229. <input
  230. ref={this.setRef}
  231. type='text'
  232. value={value}
  233. placeholder={intl.formatMessage(messages.loginPrompt)}
  234. aria-label={intl.formatMessage(messages.loginPrompt)}
  235. autoFocus
  236. onChange={this.handleChange}
  237. onFocus={this.handleFocus}
  238. onBlur={this.handleBlur}
  239. onKeyDown={this.handleKeyDown}
  240. autoComplete='off'
  241. autoCapitalize='off'
  242. spellCheck='false'
  243. />
  244. <Button onClick={this.handleSubmit} disabled={isSubmitting || error}><FormattedMessage id='interaction_modal.login.action' defaultMessage='Take me home' /></Button>
  245. </div>
  246. {hasPopOut && (
  247. <div className='search__popout'>
  248. <div className='search__popout__menu'>
  249. {options.map((option, i) => (
  250. <button key={option} onMouseDown={this.handleOptionClick} data-index={i} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}>
  251. {option.split(domainRegExp).map((part, i) => (
  252. part.toLowerCase() === domain.toLowerCase() ? (
  253. <mark key={i}>
  254. {part}
  255. </mark>
  256. ) : (
  257. <span key={i}>
  258. {part}
  259. </span>
  260. )
  261. ))}
  262. </button>
  263. ))}
  264. </div>
  265. </div>
  266. )}
  267. </div>
  268. );
  269. }
  270. }
  271. const IntlLoginForm = injectIntl(LoginForm);
  272. class InteractionModal extends React.PureComponent {
  273. static propTypes = {
  274. displayNameHtml: PropTypes.string,
  275. url: PropTypes.string,
  276. type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']),
  277. onSignupClick: PropTypes.func.isRequired,
  278. signupUrl: PropTypes.string.isRequired,
  279. };
  280. handleSignupClick = () => {
  281. this.props.onSignupClick();
  282. };
  283. render () {
  284. const { url, type, displayNameHtml, signupUrl } = this.props;
  285. const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />;
  286. let title, actionDescription, icon;
  287. switch(type) {
  288. case 'reply':
  289. icon = <Icon id='reply' icon={ReplyIcon} />;
  290. title = <FormattedMessage id='interaction_modal.title.reply' defaultMessage="Reply to {name}'s post" values={{ name }} />;
  291. actionDescription = <FormattedMessage id='interaction_modal.description.reply' defaultMessage='With an account on Mastodon, you can respond to this post.' />;
  292. break;
  293. case 'reblog':
  294. icon = <Icon id='retweet' icon={RepeatIcon} />;
  295. title = <FormattedMessage id='interaction_modal.title.reblog' defaultMessage="Boost {name}'s post" values={{ name }} />;
  296. 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.' />;
  297. break;
  298. case 'favourite':
  299. icon = <Icon id='star' icon={StarIcon} />;
  300. title = <FormattedMessage id='interaction_modal.title.favourite' defaultMessage="Favorite {name}'s post" values={{ name }} />;
  301. 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.' />;
  302. break;
  303. case 'follow':
  304. icon = <Icon id='user-plus' icon={PersonAddIcon} />;
  305. title = <FormattedMessage id='interaction_modal.title.follow' defaultMessage='Follow {name}' values={{ name }} />;
  306. 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 }} />;
  307. break;
  308. }
  309. let signupButton;
  310. if (sso_redirect) {
  311. signupButton = (
  312. <a href={sso_redirect} data-method='post' className='link-button'>
  313. <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
  314. </a>
  315. );
  316. } else if (registrationsOpen) {
  317. signupButton = (
  318. <a href={signupUrl} className='link-button'>
  319. <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
  320. </a>
  321. );
  322. } else {
  323. signupButton = (
  324. <button className='link-button' onClick={this.handleSignupClick}>
  325. <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
  326. </button>
  327. );
  328. }
  329. return (
  330. <div className='modal-root__modal interaction-modal'>
  331. <div className='interaction-modal__lead'>
  332. <h3><span className='interaction-modal__icon'>{icon}</span> {title}</h3>
  333. <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>
  334. </div>
  335. <IntlLoginForm resourceUrl={url} />
  336. <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>
  337. <p><FormattedMessage id='interaction_modal.no_account_yet' defaultMessage='Not on Mastodon?' /> {signupButton}</p>
  338. </div>
  339. );
  340. }
  341. }
  342. export default connect(mapStateToProps, mapDispatchToProps)(InteractionModal);