diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index baa98e98f..ab74fb303 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 000000000..ad186ba0c
--- /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 000000000..c8c503e58
--- /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 3a31e214d..f005dbdd1 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 7f2005060..a35bd4ff5 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 000000000..828d08cf5
--- /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 f97c799e7..d0aeaa1f0 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 676a1ccc1..0c28b2959 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 3137b2dea..6d45c110c 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 5cbf8f6c3..b6eab0c87 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;