diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx index 460f7eea0c5c1e..e1fc87da778cf7 100644 --- a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { useCallback, useRef, useState, useEffect, PureComponent } from 'react'; +import { useCallback, useRef, useState, useEffect, useMemo } from 'react'; import { useIntl, defineMessages } from 'react-intl'; @@ -30,109 +30,37 @@ const messages = defineMessages({ const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; -class LanguageDropdownMenu extends PureComponent { - - static propTypes = { - value: PropTypes.string.isRequired, - guess: PropTypes.string, - frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired, - onClose: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), - intl: PropTypes.object, - }; - - static defaultProps = { - languages: preloadedLanguages, - }; - - state = { - searchValue: '', - }; - - handleDocumentClick = e => { - if (this.node && !this.node.contains(e.target)) { - this.props.onClose(); - e.stopPropagation(); - } - }; - - componentDidMount () { - document.addEventListener('click', this.handleDocumentClick, { capture: true }); - document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - - // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need - // to wait for a frame before focusing - requestAnimationFrame(() => { - if (this.node) { - const element = this.node.querySelector('input[type="search"]'); - if (element) element.focus(); - } - }); - } - - componentWillUnmount () { - document.removeEventListener('click', this.handleDocumentClick, { capture: true }); - document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - setRef = c => { - this.node = c; - }; - - setListRef = c => { - this.listNode = c; - }; - - handleSearchChange = ({ target }) => { - this.setState({ searchValue: target.value }); - }; - - search () { - const { languages, value, frequentlyUsedLanguages, guess } = this.props; - const { searchValue } = this.state; - - 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 getFrequentlyUsedLanguages = createSelector([ + state => state.getIn(['settings', 'frequentlyUsedLanguages'], ImmutableMap()), +], languageCounters => ( + languageCounters.keySeq() + .sort((a, b) => languageCounters.get(a) - languageCounters.get(b)) + .reverse() + .toArray() +)); - const indexOfA = frequentlyUsedLanguages.indexOf(a[0]); - const indexOfB = frequentlyUsedLanguages.indexOf(b[0]); +const LanguageDropdownMenu = ({ value, guess, onClose, onChange, languages = preloadedLanguages, intl }) => { + const [searchValue, setSearchValue] = useState(''); + const nodeRef = useRef(null); + const listNodeRef = useRef(null); - return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity)); - } - }); - } + const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages); - return fuzzysort.go(searchValue, languages, { - keys: ['0', '1', '2'], - limit: 5, - threshold: -10000, - }).map(result => result.obj); - } + const handleSearchChange = useCallback(({ target }) => { + setSearchValue(target.value); + }, [setSearchValue]); - handleClick = e => { + const handleClick = useCallback((e) => { const value = e.currentTarget.getAttribute('data-index'); e.preventDefault(); - this.props.onClose(); - this.props.onChange(value); - }; + onClose(); + onChange(value); + }, [onClose, onChange]); - handleKeyDown = e => { - const { onClose } = this.props; - const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget); + const handleKeyDown = useCallback(e => { + const index = Array.from(listNodeRef.current.childNodes).findIndex(node => node === e.currentTarget); let element = null; @@ -142,26 +70,26 @@ class LanguageDropdownMenu extends PureComponent { break; case ' ': case 'Enter': - this.handleClick(e); + handleClick(e); break; case 'ArrowDown': - element = this.listNode.childNodes[index + 1] || this.listNode.firstChild; + element = listNodeRef.current.childNodes[index + 1] || listNodeRef.current.firstChild; break; case 'ArrowUp': - element = this.listNode.childNodes[index - 1] || this.listNode.lastChild; + element = listNodeRef.current.childNodes[index - 1] || listNodeRef.current.lastChild; break; case 'Tab': if (e.shiftKey) { - element = this.listNode.childNodes[index - 1] || this.listNode.lastChild; + element = listNodeRef.current.childNodes[index - 1] || listNodeRef.current.lastChild; } else { - element = this.listNode.childNodes[index + 1] || this.listNode.firstChild; + element = listNodeRef.current.childNodes[index + 1] || listNodeRef.current.firstChild; } break; case 'Home': - element = this.listNode.firstChild; + element = listNodeRef.current.firstChild; break; case 'End': - element = this.listNode.lastChild; + element = listNodeRef.current.lastChild; break; } @@ -170,18 +98,15 @@ class LanguageDropdownMenu extends PureComponent { e.preventDefault(); e.stopPropagation(); } - }; - - handleSearchKeyDown = e => { - const { onChange, onClose } = this.props; - const { searchValue } = this.state; + }, [onClose, handleClick]); + const handleSearchKeyDown = useCallback(e => { let element = null; switch(e.key) { case 'Tab': case 'ArrowDown': - element = this.listNode.firstChild; + element = listNodeRef.current.firstChild; if (element) { element.focus(); @@ -191,7 +116,7 @@ class LanguageDropdownMenu extends PureComponent { break; case 'Enter': - element = this.listNode.firstChild; + element = listNodeRef.current.firstChild; if (element) { onChange(element.getAttribute('data-index')); @@ -206,52 +131,96 @@ class LanguageDropdownMenu extends PureComponent { break; } - }; + }, [onChange, onClose, searchValue]); - handleClear = () => { - this.setState({ searchValue: '' }); - }; + const handleClear = useCallback(() => { + setSearchValue(''); + }, [setSearchValue]); - renderItem = lang => { - const { value } = this.props; + const isSearching = searchValue !== ''; - return ( -
- {lang[2]} ({lang[1]}) -
- ); - }; - - render () { - const { intl } = this.props; - const { searchValue } = this.state; - const isSearching = searchValue !== ''; - const results = this.search(); - - return ( -
-
- - -
- -
- {results.map(this.renderItem)} -
+ useEffect(() => { + const handleDocumentClick = (e) => { + if (nodeRef.current && !nodeRef.current.contains(e.target)) { + onClose(); + e.stopPropagation(); + } + }; + + document.addEventListener('click', handleDocumentClick, { capture: true }); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + + // 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, { capture: true }); + document.removeEventListener('touchend', handleDocumentClick, listenerOptions); + }; + }, [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]}) +
+ ))} +
+
+ ); +}; -const getFrequentlyUsedLanguages = createSelector([ - state => state.getIn(['settings', 'frequentlyUsedLanguages'], ImmutableMap()), -], languageCounters => ( - languageCounters.keySeq() - .sort((a, b) => languageCounters.get(a) - languageCounters.get(b)) - .reverse() - .toArray() -)); +LanguageDropdownMenu.propTypes = { + value: PropTypes.string.isRequired, + guess: PropTypes.string, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), + intl: PropTypes.object, +}; export const LanguageDropdown = () => { const [open, setOpen] = useState(false); @@ -263,7 +232,6 @@ export const LanguageDropdown = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages); const value = useAppSelector((state) => state.compose.get('language')); const text = useAppSelector((state) => state.compose.get('text')); @@ -319,10 +287,8 @@ export const LanguageDropdown = () => {