Change design of interaction modal in web UI (#33278)

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Eugen Rochko 2024-12-17 13:36:32 +01:00 committed by GitHub
parent 1712543c68
commit f19fd0b889
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 590 additions and 442 deletions

View file

@ -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);

View 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;

View file

@ -407,23 +407,21 @@
"ignore_notifications_modal.not_followers_title": "Ignore notifications from people not following you?", "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.not_following_title": "Ignore notifications from people you don't follow?",
"ignore_notifications_modal.private_mentions_title": "Ignore notifications from unsolicited Private Mentions?", "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.action.favourite": "To continue, you need to favorite from your account.",
"interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.", "interaction_modal.action.follow": "To continue, you need to follow from your account.",
"interaction_modal.description.reblog": "With an account on Mastodon, you can boost this post to share it with your own followers.", "interaction_modal.action.reblog": "To continue, you need to reblog from your account.",
"interaction_modal.description.reply": "With an account on Mastodon, you can respond to this post.", "interaction_modal.action.reply": "To continue, you need to reply from your account.",
"interaction_modal.description.vote": "With an account on Mastodon, you can vote in this poll.", "interaction_modal.action.vote": "To continue, you need to vote from your account.",
"interaction_modal.login.action": "Take me home", "interaction_modal.go": "Go",
"interaction_modal.login.prompt": "Domain of your home server, e.g. mastodon.social", "interaction_modal.no_account_yet": "Don't have an account yet?",
"interaction_modal.no_account_yet": "Not on Mastodon?",
"interaction_modal.on_another_server": "On a different server", "interaction_modal.on_another_server": "On a different server",
"interaction_modal.on_this_server": "On this 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.favourite": "Favorite {name}'s post",
"interaction_modal.title.follow": "Follow {name}", "interaction_modal.title.follow": "Follow {name}",
"interaction_modal.title.reblog": "Boost {name}'s post", "interaction_modal.title.reblog": "Boost {name}'s post",
"interaction_modal.title.reply": "Reply to {name}'s post", "interaction_modal.title.reply": "Reply to {name}'s post",
"interaction_modal.title.vote": "Vote in {name}'s poll", "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.days": "{number, plural, one {# day} other {# days}}",
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",

View file

@ -8955,6 +8955,7 @@ noscript {
} }
p { p {
text-align: center;
font-size: 17px; font-size: 17px;
line-height: 22px; line-height: 22px;
color: $darker-text-color; color: $darker-text-color;
@ -9030,11 +9031,6 @@ noscript {
border: 1px solid var(--background-border-color); border: 1px solid var(--background-border-color);
} }
&.focused &__input {
border-color: $highlight-text-color;
background: lighten($ui-base-color, 4%);
}
&.invalid &__input { &.invalid &__input {
border-color: $error-red; border-color: $error-red;
} }