From 2e5fb45be574493475e998b94a399b787f5ea877 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 16 May 2022 11:18:35 +0200 Subject: [PATCH] [Glitch] Add language dropdown to compose in web UI Port 0cdb07757050825725cac76f1c9cf11cf64acc0a to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/actions/compose.js | 15 +- .../flavours/glitch/actions/languages.js | 12 + .../compose/components/language_dropdown.js | 332 ++++++++++++++++++ .../features/compose/components/options.js | 2 + .../compose/components/text_icon_button.js | 7 +- .../containers/language_dropdown_container.js | 34 ++ .../flavours/glitch/reducers/compose.js | 9 +- .../flavours/glitch/reducers/settings.js | 5 + .../glitch/styles/components/composer.scss | 65 ++++ .../flavours/glitch/util/initial_state.js | 1 + 10 files changed, 471 insertions(+), 11 deletions(-) create mode 100644 app/javascript/flavours/glitch/actions/languages.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/language_dropdown.js create mode 100644 app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index baa98e98fee..ab74fb30365 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -48,12 +48,13 @@ export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE'; -export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; -export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; +export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; +export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; -export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; -export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; +export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE'; +export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; @@ -189,6 +190,7 @@ export function submitCompose(routerHistory) { spoiler_text: spoilerText, visibility: getState().getIn(['compose', 'privacy']), poll: getState().getIn(['compose', 'poll'], null), + language: getState().getIn(['compose', 'language']), }, headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), @@ -675,6 +677,11 @@ export function changeComposeSensitivity() { }; }; +export const changeComposeLanguage = language => ({ + type: COMPOSE_LANGUAGE_CHANGE, + language, +}); + export function changeComposeSpoilerness() { return { type: COMPOSE_SPOILERNESS_CHANGE, diff --git a/app/javascript/flavours/glitch/actions/languages.js b/app/javascript/flavours/glitch/actions/languages.js new file mode 100644 index 00000000000..ad186ba0ccd --- /dev/null +++ b/app/javascript/flavours/glitch/actions/languages.js @@ -0,0 +1,12 @@ +import { saveSettings } from './settings'; + +export const LANGUAGE_USE = 'LANGUAGE_USE'; + +export const useLanguage = language => dispatch => { + dispatch({ + type: LANGUAGE_USE, + language, + }); + + dispatch(saveSettings()); +}; diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js new file mode 100644 index 00000000000..c8c503e582b --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js @@ -0,0 +1,332 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import TextIconButton from './text_icon_button'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import classNames from 'classnames'; +import { languages as preloadedLanguages } from 'flavours/glitch/util/initial_state'; +import fuzzysort from 'fuzzysort'; + +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' }, +}); + +// Copied from emoji-mart for consistency with emoji picker and since +// they don't export the icons in the package +const icons = { + loupe: ( + + + + ), + + delete: ( + + + + ), +}; + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +class LanguageDropdownMenu extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + value: PropTypes.string.isRequired, + frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired, + placement: 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 = { + mounted: false, + searchValue: '', + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + this.setState({ mounted: true }); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + 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 } = this.props; + const { searchValue } = this.state; + + if (searchValue === '') { + return [...languages].sort((a, b) => { + // Push current selection to the top of the list + + if (a[0] === value) { + 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); + } + + frequentlyUsed () { + const { languages, value } = this.props; + const current = languages.find(lang => lang[0] === value); + const results = []; + + if (current) { + results.push(current); + } + + return results; + } + + handleClick = e => { + const value = e.currentTarget.getAttribute('data-index'); + + e.preventDefault(); + + this.props.onClose(); + this.props.onChange(value); + } + + handleKeyDown = e => { + const { onClose } = this.props; + const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget); + + let element = null; + + switch(e.key) { + case 'Escape': + onClose(); + break; + case 'Enter': + this.handleClick(e); + break; + case 'ArrowDown': + element = this.listNode.childNodes[index + 1] || this.listNode.firstChild; + break; + case 'ArrowUp': + element = this.listNode.childNodes[index - 1] || this.listNode.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = this.listNode.childNodes[index - 1] || this.listNode.lastChild; + } else { + element = this.listNode.childNodes[index + 1] || this.listNode.firstChild; + } + break; + case 'Home': + element = this.listNode.firstChild; + break; + case 'End': + element = this.listNode.lastChild; + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + } + + handleSearchKeyDown = e => { + const { onChange, onClose } = this.props; + const { searchValue } = this.state; + + let element = null; + + switch(e.key) { + case 'Tab': + case 'ArrowDown': + element = this.listNode.firstChild; + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + + break; + case 'Enter': + element = this.listNode.firstChild; + + if (element) { + onChange(element.getAttribute('data-index')); + onClose(); + } + break; + case 'Escape': + if (searchValue !== '') { + e.preventDefault(); + this.handleClear(); + } + + break; + } + } + + handleClear = () => { + this.setState({ searchValue: '' }); + } + + renderItem = lang => { + const { value } = this.props; + + return ( +
+ {lang[2]} ({lang[1]}) +
+ ); + } + + render () { + const { style, placement, intl } = this.props; + const { mounted, searchValue } = this.state; + const isSearching = searchValue !== ''; + const results = this.search(); + + return ( + + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays +
+
+ + +
+ +
+ {results.map(this.renderItem)} +
+
+ )} +
+ ); + } + +} + +export default @injectIntl +class LanguageDropdown extends React.PureComponent { + + static propTypes = { + value: PropTypes.string, + frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string), + intl: PropTypes.object.isRequired, + onChange: PropTypes.func, + onClose: PropTypes.func, + }; + + state = { + open: false, + placement: 'bottom', + }; + + handleToggle = ({ target }) => { + const { top } = target.getBoundingClientRect(); + + if (this.state.open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + this.setState({ open: !this.state.open }); + } + + handleClose = () => { + const { value, onClose } = this.props; + + if (this.state.open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + + this.setState({ open: false }); + onClose(value); + } + + handleChange = value => { + const { onChange } = this.props; + onChange(value); + } + + render () { + const { value, intl, frequentlyUsedLanguages } = this.props; + const { open, placement } = this.state; + + return ( +
+
+ +
+ + + + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js index 3a31e214d8a..f005dbdd18b 100644 --- a/app/javascript/flavours/glitch/features/compose/components/options.js +++ b/app/javascript/flavours/glitch/features/compose/components/options.js @@ -12,6 +12,7 @@ import IconButton from 'flavours/glitch/components/icon_button'; import TextIconButton from './text_icon_button'; import Dropdown from './dropdown'; import PrivacyDropdown from './privacy_dropdown'; +import LanguageDropdown from '../containers/language_dropdown_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; // Utils. @@ -306,6 +307,7 @@ class ComposerOptions extends ImmutablePureComponent { title={formatMessage(messages.spoiler)} /> )} + !!value)} disabled={disabled || isEditing} diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js index 7f2005060c1..a35bd4ff52a 100644 --- a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js +++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js @@ -17,11 +17,6 @@ export default class TextIconButton extends React.PureComponent { ariaControls: PropTypes.string, }; - handleClick = (e) => { - e.preventDefault(); - this.props.onClick(); - } - render () { const { label, title, active, ariaControls } = this.props; @@ -31,7 +26,7 @@ export default class TextIconButton extends React.PureComponent { aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} - onClick={this.handleClick} + onClick={this.props.onClick} aria-controls={ariaControls} style={iconStyle} > diff --git a/app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js new file mode 100644 index 00000000000..828d08cf584 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js @@ -0,0 +1,34 @@ +import { connect } from 'react-redux'; +import LanguageDropdown from '../components/language_dropdown'; +import { changeComposeLanguage } from 'flavours/glitch/actions/compose'; +import { useLanguage } from 'flavours/glitch/actions/languages'; +import { createSelector } from 'reselect'; +import { Map as ImmutableMap } from 'immutable'; + +const getFrequentlyUsedLanguages = createSelector([ + state => state.getIn(['settings', 'frequentlyUsedLanguages'], ImmutableMap()), +], languageCounters => ( + languageCounters.keySeq() + .sort((a, b) => languageCounters.get(a) - languageCounters.get(b)) + .reverse() + .toArray() +)); + +const mapStateToProps = state => ({ + frequentlyUsedLanguages: getFrequentlyUsedLanguages(state), + value: state.getIn(['compose', 'language']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeLanguage(value)); + }, + + onClose (value) { + dispatch(useLanguage(value)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(LanguageDropdown); diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index f97c799e7f9..d0aeaa1f05b 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -30,6 +30,7 @@ import { COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_VISIBILITY_CHANGE, + COMPOSE_LANGUAGE_CHANGE, COMPOSE_CONTENT_TYPE_CHANGE, COMPOSE_EMOJI_INSERT, COMPOSE_UPLOAD_CHANGE_REQUEST, @@ -100,6 +101,7 @@ const initialState = ImmutableMap({ }), default_privacy: 'public', default_sensitive: false, + default_language: 'en', resetFileKey: Math.floor((Math.random() * 0x10000)), idempotencyKey: null, tagHistory: ImmutableList(), @@ -175,7 +177,8 @@ function clearAll(state) { map => map.mergeWith(overwrite, state.get('default_advanced_options')) ); map.set('privacy', state.get('default_privacy')); - map.set('sensitive', false); + map.set('sensitive', state.get('default_sensitive')); + map.set('language', state.get('default_language')); map.update('media_attachments', list => list.clear()); map.set('poll', null); map.set('idempotencyKey', uuid()); @@ -557,6 +560,7 @@ export default function compose(state = initialState, action) { map.set('caretPosition', null); map.set('idempotencyKey', uuid()); map.set('sensitive', action.status.get('sensitive')); + map.set('language', action.status.get('language')); map.update( 'advanced_options', map => map.merge(new ImmutableMap({ do_not_federate })) @@ -589,6 +593,7 @@ export default function compose(state = initialState, action) { map.set('caretPosition', null); map.set('idempotencyKey', uuid()); map.set('sensitive', action.status.get('sensitive')); + map.set('language', action.status.get('language')); if (action.spoiler_text.length > 0) { map.set('spoiler', true); @@ -618,6 +623,8 @@ export default function compose(state = initialState, action) { return state.updateIn(['poll', 'options'], options => options.delete(action.index)); case COMPOSE_POLL_SETTINGS_CHANGE: return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); + case COMPOSE_LANGUAGE_CHANGE: + return state.set('language', action.language); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index 676a1ccc154..0c28b295910 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -3,6 +3,7 @@ import { NOTIFICATIONS_FILTER_SET } from 'flavours/glitch/actions/notifications' import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from 'flavours/glitch/actions/columns'; import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; import { EMOJI_USE } from 'flavours/glitch/actions/emojis'; +import { LANGUAGE_USE } from 'flavours/glitch/actions/languages'; import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists'; import { Map as ImmutableMap, fromJS } from 'immutable'; import uuid from 'flavours/glitch/util/uuid'; @@ -134,6 +135,8 @@ const changeColumnParams = (state, uuid, path, value) => { const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false); +const updateFrequentLanguages = (state, language) => state.update('frequentlyUsedLanguages', ImmutableMap(), map => map.update(language, 0, count => count + 1)).set('saved', false); + const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId)); export default function settings(state = initialState, action) { @@ -159,6 +162,8 @@ export default function settings(state = initialState, action) { return changeColumnParams(state, action.uuid, action.path, action.value); case EMOJI_USE: return updateFrequentEmojis(state, action.emoji); + case LANGUAGE_USE: + return updateFrequentLanguages(state, action.language); case SETTING_SAVE: return state.set('saved', true); case LIST_FETCH_FAIL: diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index 3137b2dea53..6d45c110c5d 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -644,3 +644,68 @@ & > .count { color: $warning-red } } } + +.language-dropdown { + &__dropdown { + position: absolute; + background: $simple-background-color; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + border-radius: 4px; + overflow: hidden; + z-index: 2; + + &.top { + transform-origin: 50% 100%; + } + + &.bottom { + transform-origin: 50% 0; + } + + .emoji-mart-search { + padding-right: 10px; + } + + .emoji-mart-search-icon { + right: 10px + 5px; + } + + .emoji-mart-scroll { + padding: 0 10px 10px; + } + + &__results { + &__item { + cursor: pointer; + color: $inverted-text-color; + font-weight: 500; + padding: 10px; + border-radius: 4px; + + &:focus, + &:active, + &:hover { + background: $ui-secondary-color; + } + + &__common-name { + color: $darker-text-color; + } + + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + outline: 0; + + .language-dropdown__dropdown__results__item__common-name { + color: $secondary-text-color; + } + + &:hover { + background: lighten($ui-highlight-color, 4%); + } + } + } + } + } +} diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js index 5cbf8f6c360..b6eab0c871d 100644 --- a/app/javascript/flavours/glitch/util/initial_state.js +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -38,5 +38,6 @@ export const usePendingItems = getMeta('use_pending_items'); export const useSystemEmojiFont = getMeta('system_emoji_font'); export const showTrends = getMeta('trends'); export const disableSwiping = getMeta('disable_swiping'); +export const languages = initialState && initialState.languages; export default initialState;