582 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			582 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
| 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;
 |