import { useCallback, useRef, useState, useEffect, useMemo } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import classNames from 'classnames'; import { createSelector } from '@reduxjs/toolkit'; import { Map as ImmutableMap } from 'immutable'; import fuzzysort from 'fuzzysort'; import Overlay from 'react-overlays/Overlay'; import type { State, Placement } from 'react-overlays/usePopper'; import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import TranslateIcon from '@/material-icons/400-24px/translate.svg?react'; import { changeComposeLanguage } from 'mastodon/actions/compose'; import { Icon } from 'mastodon/components/icon'; import { languages as preloadedLanguages } from 'mastodon/initial_state'; import type { RootState } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { debouncedGuess } from '../util/language_detection'; const messages = defineMessages({ changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language', }, search: { id: 'compose.language.search', defaultMessage: 'Search languages...', }, clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' }, }); type Language = [string, string, string]; const getFrequentlyUsedLanguages = createSelector( [ (state: RootState) => (state.settings as ImmutableMap).get( 'frequentlyUsedLanguages', ImmutableMap(), ) as ImmutableMap, ], (languageCounters) => languageCounters .keySeq() .sort( (a, b) => (languageCounters.get(a) ?? 0) - (languageCounters.get(b) ?? 0), ) .reverse() .toArray(), ); const LanguageDropdownMenu: React.FC<{ value: string; guess?: string; onClose: () => void; onChange: (arg0: string) => void; }> = ({ value, guess, onClose, onChange }) => { const languages = preloadedLanguages as Language[]; const intl = useIntl(); const [searchValue, setSearchValue] = useState(''); const nodeRef = useRef(null); const listNodeRef = useRef(null); const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages); const handleSearchChange = useCallback( ({ target }: React.ChangeEvent) => { setSearchValue(target.value); }, [setSearchValue], ); const handleClick = useCallback( (e: React.MouseEvent | React.KeyboardEvent) => { const value = e.currentTarget.getAttribute('data-index'); if (!value) { return; } e.preventDefault(); onClose(); onChange(value); }, [onClose, onChange], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!listNodeRef.current) { return; } const index = Array.from(listNodeRef.current.childNodes).findIndex( (node) => node === e.currentTarget, ); let element = null; switch (e.key) { case 'Escape': onClose(); break; case ' ': case 'Enter': handleClick(e); break; case 'ArrowDown': element = listNodeRef.current.childNodes[index + 1] ?? listNodeRef.current.firstChild; break; case 'ArrowUp': element = listNodeRef.current.childNodes[index - 1] ?? listNodeRef.current.lastChild; break; case 'Tab': if (e.shiftKey) { element = listNodeRef.current.childNodes[index - 1] ?? listNodeRef.current.lastChild; } else { element = listNodeRef.current.childNodes[index + 1] ?? listNodeRef.current.firstChild; } break; case 'Home': element = listNodeRef.current.firstChild; break; case 'End': element = listNodeRef.current.lastChild; break; } if (element && element instanceof HTMLElement) { element.focus(); e.preventDefault(); e.stopPropagation(); } }, [onClose, handleClick], ); const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { let element = null; if (!listNodeRef.current) { return; } switch (e.key) { case 'Tab': case 'ArrowDown': element = listNodeRef.current.firstChild; if (element && element instanceof HTMLElement) { element.focus(); e.preventDefault(); e.stopPropagation(); } break; case 'Enter': element = listNodeRef.current.firstChild; if (element && element instanceof HTMLElement) { const value = element.getAttribute('data-index'); if (value) { onChange(value); onClose(); } } break; case 'Escape': if (searchValue !== '') { e.preventDefault(); setSearchValue(''); } break; } }, [setSearchValue, onChange, onClose, searchValue], ); const handleClear = useCallback(() => { setSearchValue(''); }, [setSearchValue]); const isSearching = searchValue !== ''; useEffect(() => { const handleDocumentClick = (e: MouseEvent) => { if ( nodeRef.current && e.target instanceof HTMLElement && !nodeRef.current.contains(e.target) ) { onClose(); e.stopPropagation(); } }; document.addEventListener('click', handleDocumentClick, { capture: true }); // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need // to wait for a frame before focusing requestAnimationFrame(() => { if (nodeRef.current) { const element = nodeRef.current.querySelector( 'input[type="search"]', ); if (element) element.focus(); } }); return () => { document.removeEventListener('click', handleDocumentClick); }; }, [onClose]); const results = useMemo(() => { if (searchValue === '') { return [...languages].sort((a, b) => { if (guess && a[0] === guess) { // Push guessed language higher than current selection return -1; } else if (guess && b[0] === guess) { return 1; } else if (a[0] === value) { // Push current selection to the top of the list return -1; } else if (b[0] === value) { return 1; } else { // Sort according to frequently used languages const indexOfA = frequentlyUsedLanguages.indexOf(a[0]); const indexOfB = frequentlyUsedLanguages.indexOf(b[0]); return ( (indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity) ); } }); } return fuzzysort .go(searchValue, languages, { keys: ['0', '1', '2'], limit: 5, threshold: -10000, }) .map((result) => result.obj); }, [searchValue, languages, guess, frequentlyUsedLanguages, value]); return (
{results.map((lang) => (
{lang[2]} {' '} ({lang[1]})
))}
); }; export const LanguageDropdown: React.FC = () => { const [open, setOpen] = useState(false); const [placement, setPlacement] = useState('bottom'); const [guess, setGuess] = useState(''); const activeElementRef = useRef(null); const targetRef = useRef(null); const intl = useIntl(); const dispatch = useAppDispatch(); const value = useAppSelector( (state) => state.compose.get('language') as string, ); const text = useAppSelector((state) => state.compose.get('text') as string); const current = (preloadedLanguages as Language[]).find((lang) => lang[0] === value) ?? []; const handleMouseDown = useCallback(() => { if (!open && document.activeElement instanceof HTMLElement) { activeElementRef.current = document.activeElement; } }, [open]); const handleToggle = useCallback(() => { if (open && activeElementRef.current) activeElementRef.current.focus({ preventScroll: true }); setOpen(!open); }, [open, setOpen]); const handleClose = useCallback(() => { if (open && activeElementRef.current) activeElementRef.current.focus({ preventScroll: true }); setOpen(false); }, [open, setOpen]); const handleChange = useCallback( (value: string) => { dispatch(changeComposeLanguage(value)); }, [dispatch], ); const handleOverlayEnter = useCallback( (state: Partial) => { setPlacement(state.placement); }, [setPlacement], ); useEffect(() => { if (text.length > 20) { debouncedGuess(text, setGuess); } else { setGuess(''); } }, [text, setGuess]); return (
{({ props, placement }) => (
)}
); };