mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-22 08:15:35 +01:00
Change design of interaction modal in web UI (#33278)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
1712543c68
commit
f19fd0b889
4 changed files with 590 additions and 442 deletions
|
@ -1,427 +0,0 @@
|
|||
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 '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', '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);
|
581
app/javascript/mastodon/features/interaction_modal/index.tsx
Normal file
581
app/javascript/mastodon/features/interaction_modal/index.tsx
Normal file
|
@ -0,0 +1,581 @@
|
|||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { escapeRegExp } from 'lodash';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
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 'mastodon/actions/modal';
|
||||
import { apiRequest } from 'mastodon/api';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import {
|
||||
domain as localDomain,
|
||||
registrationsOpen,
|
||||
sso_redirect,
|
||||
} from 'mastodon/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
loginPrompt: {
|
||||
id: 'interaction_modal.username_prompt',
|
||||
defaultMessage: 'E.g. {example}',
|
||||
},
|
||||
});
|
||||
|
||||
interface LoginFormMessage {
|
||||
type:
|
||||
| 'fetchInteractionURL'
|
||||
| 'fetchInteractionURL-failure'
|
||||
| 'fetchInteractionURL-success';
|
||||
uri_or_domain: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
const PERSISTENCE_KEY = 'mastodon_home';
|
||||
|
||||
const EXAMPLE_VALUE = 'username@mastodon.social';
|
||||
|
||||
const isValidDomain = (value: string) => {
|
||||
const url = new URL('https:///path');
|
||||
url.hostname = value;
|
||||
return url.hostname === value;
|
||||
};
|
||||
|
||||
const valueToDomain = (value: string): string | null => {
|
||||
// If the user starts typing an URL
|
||||
if (/^https?:\/\//.test(value)) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
|
||||
return url.host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// If the user writes their full handle including username
|
||||
} else if (value.includes('@')) {
|
||||
const [_, domain, ...other] = value.replace(/^@/, '').split('@');
|
||||
|
||||
if (!domain || other.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return valueToDomain(domain);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const addInputToOptions = (value: string, options: string[]) => {
|
||||
value = value.trim();
|
||||
|
||||
if (value.includes('.') && isValidDomain(value)) {
|
||||
return [value].concat(options.filter((x) => x !== value));
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const isValueValid = (value: string) => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const sendToFrame = (frame: HTMLIFrameElement | null, value: string): void => {
|
||||
if (valueToDomain(value.trim()) === localDomain) {
|
||||
window.location.href = '/auth/sign_in';
|
||||
return;
|
||||
}
|
||||
|
||||
frame?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'fetchInteractionURL',
|
||||
uri_or_domain: value.trim(),
|
||||
},
|
||||
window.origin,
|
||||
);
|
||||
};
|
||||
|
||||
const LoginForm: React.FC<{
|
||||
resourceUrl: string;
|
||||
}> = ({ resourceUrl }) => {
|
||||
const intl = useIntl();
|
||||
const [value, setValue] = useState(
|
||||
localStorage.getItem(PERSISTENCE_KEY) ?? '',
|
||||
);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedOption, setSelectedOption] = useState(-1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const [networkOptions, setNetworkOptions] = useState<string[]>([]);
|
||||
const [valueChanged, setValueChanged] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const searchRequestRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<LoginFormMessage>) => {
|
||||
if (
|
||||
event.origin !== window.origin ||
|
||||
event.source !== iframeRef.current?.contentWindow
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === 'fetchInteractionURL-failure') {
|
||||
setIsSubmitting(false);
|
||||
setError(true);
|
||||
} else if (event.data.type === 'fetchInteractionURL-success') {
|
||||
if (event.data.template && /^https?:\/\//.test(event.data.template)) {
|
||||
try {
|
||||
const url = new URL(
|
||||
event.data.template.replace(
|
||||
'{uri}',
|
||||
encodeURIComponent(resourceUrl),
|
||||
),
|
||||
);
|
||||
|
||||
localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain);
|
||||
|
||||
window.location.href = url.toString();
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
setError(true);
|
||||
}
|
||||
} else {
|
||||
setIsSubmitting(false);
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [resourceUrl, setIsSubmitting, setError]);
|
||||
|
||||
const handleSearch = useDebouncedCallback(
|
||||
(value: string) => {
|
||||
if (searchRequestRef.current) {
|
||||
searchRequestRef.current.abort();
|
||||
}
|
||||
|
||||
const domain = valueToDomain(value.trim());
|
||||
|
||||
if (domain === null || domain.length === 0) {
|
||||
setOptions([]);
|
||||
setNetworkOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
searchRequestRef.current = new AbortController();
|
||||
|
||||
void apiRequest<string[] | null>('GET', 'v1/peers/search', {
|
||||
signal: searchRequestRef.current.signal,
|
||||
params: {
|
||||
q: domain,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
setNetworkOptions(data ?? []);
|
||||
setOptions(addInputToOptions(value, data ?? []));
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
// Nothing
|
||||
});
|
||||
},
|
||||
500,
|
||||
{ leading: true, trailing: true },
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(value);
|
||||
setValueChanged(true);
|
||||
setError(!isValueValid(value));
|
||||
setOptions(addInputToOptions(value, networkOptions));
|
||||
handleSearch(value);
|
||||
},
|
||||
[
|
||||
setError,
|
||||
setValue,
|
||||
setValueChanged,
|
||||
setOptions,
|
||||
networkOptions,
|
||||
handleSearch,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setIsSubmitting(true);
|
||||
sendToFrame(iframeRef.current, value);
|
||||
}, [setIsSubmitting, value]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, [setExpanded]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setExpanded(false);
|
||||
}, [setExpanded]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const selectedOptionValue = options[selectedOption];
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
setSelectedOption((selectedOption) =>
|
||||
Math.min(selectedOption + 1, options.length - 1),
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
setSelectedOption((selectedOption) =>
|
||||
Math.max(selectedOption - 1, -1),
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedOption === -1) {
|
||||
handleSubmit();
|
||||
} else if (options.length > 0 && selectedOptionValue) {
|
||||
setError(false);
|
||||
setValue(selectedOptionValue);
|
||||
setIsSubmitting(true);
|
||||
sendToFrame(iframeRef.current, selectedOptionValue);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
handleSubmit,
|
||||
setSelectedOption,
|
||||
setError,
|
||||
setValue,
|
||||
selectedOption,
|
||||
options,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOptionClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const index = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const option = options[index];
|
||||
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedOption(index);
|
||||
setValue(option);
|
||||
setError(false);
|
||||
setIsSubmitting(true);
|
||||
sendToFrame(iframeRef.current, option);
|
||||
},
|
||||
[options, setSelectedOption, setValue, setError],
|
||||
);
|
||||
|
||||
const domain = (valueToDomain(value) ?? '').trim();
|
||||
const domainRegExp = new RegExp(`(${escapeRegExp(domain)})`, 'gi');
|
||||
const hasPopOut = valueChanged && domain.length > 0 && options.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('interaction-modal__login', {
|
||||
focused: expanded,
|
||||
expanded: hasPopOut,
|
||||
invalid: error,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
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={inputRef}
|
||||
type='text'
|
||||
value={value}
|
||||
placeholder={intl.formatMessage(messages.loginPrompt, {
|
||||
example: EXAMPLE_VALUE,
|
||||
})}
|
||||
aria-label={intl.formatMessage(messages.loginPrompt, {
|
||||
example: EXAMPLE_VALUE,
|
||||
})}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || error}>
|
||||
<FormattedMessage id='interaction_modal.go' defaultMessage='Go' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasPopOut && (
|
||||
<div className='search__popout'>
|
||||
<div className='search__popout__menu'>
|
||||
{options.map((option, i) => (
|
||||
<button
|
||||
key={option}
|
||||
onMouseDown={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 InteractionModal: React.FC<{
|
||||
accountId: string;
|
||||
url: string;
|
||||
type: 'reply' | 'reblog' | 'favourite' | 'follow' | 'vote';
|
||||
}> = ({ accountId, url, type }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const displayNameHtml = useAppSelector(
|
||||
(state) => state.accounts.get(accountId)?.display_name_html ?? '',
|
||||
);
|
||||
const signupUrl = useAppSelector(
|
||||
(state) =>
|
||||
(state.server.getIn(['server', 'registrations', 'url'], null) ||
|
||||
'/auth/sign_up') as string,
|
||||
);
|
||||
const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />;
|
||||
|
||||
const handleSignupClick = useCallback(() => {
|
||||
dispatch(
|
||||
closeModal({
|
||||
modalType: undefined,
|
||||
ignoreFocus: false,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CLOSED_REGISTRATIONS',
|
||||
modalProps: {},
|
||||
}),
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
let title: React.ReactNode,
|
||||
icon: React.ReactNode,
|
||||
actionPrompt: React.ReactNode;
|
||||
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.reply'
|
||||
defaultMessage='To continue, you need to reply from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'reblog':
|
||||
icon = <Icon id='retweet' icon={RepeatIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.reblog'
|
||||
defaultMessage="Boost {name}'s post"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.reblog'
|
||||
defaultMessage='To continue, you need to reblog from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'favourite':
|
||||
icon = <Icon id='star' icon={StarIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.favourite'
|
||||
defaultMessage="Favorite {name}'s post"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.favourite'
|
||||
defaultMessage='To continue, you need to favorite from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'follow':
|
||||
icon = <Icon id='user-plus' icon={PersonAddIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.follow'
|
||||
defaultMessage='Follow {name}'
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.follow'
|
||||
defaultMessage='To continue, you need to follow from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'vote':
|
||||
icon = <Icon id='tasks' icon={InsertChartIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.vote'
|
||||
defaultMessage="Vote in {name}'s poll"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.vote'
|
||||
defaultMessage='To continue, you need to vote from your account.'
|
||||
/>
|
||||
);
|
||||
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={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>{actionPrompt}</p>
|
||||
</div>
|
||||
|
||||
<LoginForm resourceUrl={url} />
|
||||
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='interaction_modal.no_account_yet'
|
||||
defaultMessage="Don't have an account yet?"
|
||||
/>{' '}
|
||||
{signupButton}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default InteractionModal;
|
|
@ -407,23 +407,21 @@
|
|||
"ignore_notifications_modal.not_followers_title": "Ignore notifications from people not following you?",
|
||||
"ignore_notifications_modal.not_following_title": "Ignore notifications from people you don't follow?",
|
||||
"ignore_notifications_modal.private_mentions_title": "Ignore notifications from unsolicited Private Mentions?",
|
||||
"interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.",
|
||||
"interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.",
|
||||
"interaction_modal.description.reblog": "With an account on Mastodon, you can boost this post to share it with your own followers.",
|
||||
"interaction_modal.description.reply": "With an account on Mastodon, you can respond to this post.",
|
||||
"interaction_modal.description.vote": "With an account on Mastodon, you can vote in this poll.",
|
||||
"interaction_modal.login.action": "Take me home",
|
||||
"interaction_modal.login.prompt": "Domain of your home server, e.g. mastodon.social",
|
||||
"interaction_modal.no_account_yet": "Not on Mastodon?",
|
||||
"interaction_modal.action.favourite": "To continue, you need to favorite from your account.",
|
||||
"interaction_modal.action.follow": "To continue, you need to follow from your account.",
|
||||
"interaction_modal.action.reblog": "To continue, you need to reblog from your account.",
|
||||
"interaction_modal.action.reply": "To continue, you need to reply from your account.",
|
||||
"interaction_modal.action.vote": "To continue, you need to vote from your account.",
|
||||
"interaction_modal.go": "Go",
|
||||
"interaction_modal.no_account_yet": "Don't have an account yet?",
|
||||
"interaction_modal.on_another_server": "On a different server",
|
||||
"interaction_modal.on_this_server": "On this server",
|
||||
"interaction_modal.sign_in": "You are not logged in to this server. Where is your account hosted?",
|
||||
"interaction_modal.sign_in_hint": "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)",
|
||||
"interaction_modal.title.favourite": "Favorite {name}'s post",
|
||||
"interaction_modal.title.follow": "Follow {name}",
|
||||
"interaction_modal.title.reblog": "Boost {name}'s post",
|
||||
"interaction_modal.title.reply": "Reply to {name}'s post",
|
||||
"interaction_modal.title.vote": "Vote in {name}'s poll",
|
||||
"interaction_modal.username_prompt": "E.g. {example}",
|
||||
"intervals.full.days": "{number, plural, one {# day} other {# days}}",
|
||||
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
|
||||
|
|
|
@ -8955,6 +8955,7 @@ noscript {
|
|||
}
|
||||
|
||||
p {
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
color: $darker-text-color;
|
||||
|
@ -9030,11 +9031,6 @@ noscript {
|
|||
border: 1px solid var(--background-border-color);
|
||||
}
|
||||
|
||||
&.focused &__input {
|
||||
border-color: $highlight-text-color;
|
||||
background: lighten($ui-base-color, 4%);
|
||||
}
|
||||
|
||||
&.invalid &__input {
|
||||
border-color: $error-red;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue