app/javascript/flavours/glitch/features/interaction_modal/index.tsx
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 'flavours/glitch/actions/modal';
import { apiRequest } from 'flavours/glitch/api';
import { Button } from 'flavours/glitch/components/button';
import { Icon } from 'flavours/glitch/components/icon';
import {
domain as localDomain,
registrationsOpen,
sso_redirect,
} from 'flavours/glitch/initial_state';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/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;