421 lines
13 KiB
JavaScript
421 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 'mastodon/../material-icons/400-24px/person_add.svg?react';
|
|
import RepeatIcon from 'mastodon/../material-icons/400-24px/repeat.svg?react';
|
|
import ReplyIcon from 'mastodon/../material-icons/400-24px/reply.svg?react';
|
|
import StarIcon from 'mastodon/../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);
|