about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js15
-rw-r--r--app/javascript/flavours/glitch/actions/languages.js12
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/language_dropdown.js332
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/text_icon_button.js7
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js34
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js9
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js5
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss65
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js1
-rw-r--r--app/javascript/mastodon/actions/compose.js16
-rw-r--r--app/javascript/mastodon/actions/languages.js12
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js13
-rw-r--r--app/javascript/mastodon/features/compose/components/language_dropdown.js332
-rw-r--r--app/javascript/mastodon/features/compose/components/text_icon_button.js7
-rw-r--r--app/javascript/mastodon/features/compose/containers/language_dropdown_container.js34
-rw-r--r--app/javascript/mastodon/initial_state.js1
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json17
-rw-r--r--app/javascript/mastodon/locales/en.json3
-rw-r--r--app/javascript/mastodon/reducers/compose.js9
-rw-r--r--app/javascript/mastodon/reducers/settings.js5
-rw-r--r--app/javascript/styles/mastodon/components.scss66
-rw-r--r--app/models/user.rb3
-rw-r--r--app/serializers/initial_state_serializer.rb8
-rw-r--r--app/serializers/rest/preferences_serializer.rb2
-rw-r--r--package.json3
-rw-r--r--yarn.lock108
27 files changed, 1046 insertions, 75 deletions
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: (
+    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
+      <path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
+    </svg>
+  ),
+
+  delete: (
+    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
+      <path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
+    </svg>
+  ),
+};
+
+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 (
+      <div key={lang[0]} role='option' tabIndex='0' data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}>
+        <span className='language-dropdown__dropdown__results__item__native-name'>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span>
+      </div>
+    );
+  }
+
+  render () {
+    const { style, placement, intl } = this.props;
+    const { mounted, searchValue } = this.state;
+    const isSearching = searchValue !== '';
+    const results = this.search();
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ 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
+          <div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
+            <div className='emoji-mart-search'>
+              <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
+              <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? icons.loupe : icons.delete}</button>
+            </div>
+
+            <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
+              {results.map(this.renderItem)}
+            </div>
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
+
+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 (
+      <div className={classNames('privacy-dropdown', { active: open })}>
+        <div className='privacy-dropdown__value'>
+          <TextIconButton
+            className='privacy-dropdown__value-icon'
+            label={value && value.toUpperCase()}
+            title={intl.formatMessage(messages.changeLanguage)}
+            active={open}
+            onClick={this.handleToggle}
+          />
+        </div>
+
+        <Overlay show={open} placement={placement} target={this}>
+          <LanguageDropdownMenu
+            value={value}
+            frequentlyUsedLanguages={frequentlyUsedLanguages}
+            onClose={this.handleClose}
+            onChange={this.handleChange}
+            placement={placement}
+            intl={intl}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
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)}
           />
         )}
+        <LanguageDropdown />
         <Dropdown
           active={advancedOptions && advancedOptions.some(value => !!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;
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index f3129f8d9..878011fc0 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -45,12 +45,12 @@ export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
 export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT';
 export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
 
-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_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
+export const COMPOSE_VISIBILITY_CHANGE   = 'COMPOSE_VISIBILITY_CHANGE';
+export const COMPOSE_COMPOSING_CHANGE    = 'COMPOSE_COMPOSING_CHANGE';
+export const COMPOSE_LANGUAGE_CHANGE     = 'COMPOSE_LANGUAGE_CHANGE';
 
 export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
 
@@ -169,6 +169,7 @@ export function submitCompose(routerHistory) {
         spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
         visibility: getState().getIn(['compose', 'privacy']),
         poll: getState().getIn(['compose', 'poll'], null),
+        language: getState().getIn(['compose', 'language']),
       },
       headers: {
         'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@@ -637,6 +638,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/mastodon/actions/languages.js b/app/javascript/mastodon/actions/languages.js
new file mode 100644
index 000000000..ad186ba0c
--- /dev/null
+++ b/app/javascript/mastodon/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/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 826d9f504..a6fea42b7 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -15,6 +15,7 @@ import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
 import PollFormContainer from '../containers/poll_form_container';
 import UploadFormContainer from '../containers/upload_form_container';
 import WarningContainer from '../containers/warning_container';
+import LanguageDropdown from '../containers/language_dropdown_container';
 import { isMobile } from '../../../is_mobile';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { length } from 'stringz';
@@ -205,6 +206,7 @@ class ComposeForm extends ImmutablePureComponent {
   render () {
     const { intl, onPaste, showSearch } = this.props;
     const disabled = this.props.isSubmitting;
+
     let publishText = '';
 
     if (this.props.isEditing) {
@@ -255,6 +257,7 @@ class ComposeForm extends ImmutablePureComponent {
           autoFocus={!showSearch && !isMobile(window.innerWidth)}
         >
           <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+
           <div className='compose-form__modifiers'>
             <UploadFormContainer />
             <PollFormContainer />
@@ -267,12 +270,18 @@ class ComposeForm extends ImmutablePureComponent {
             <PollButtonContainer />
             <PrivacyDropdownContainer disabled={this.props.isEditing} />
             <SpoilerButtonContainer />
+            <LanguageDropdown />
+          </div>
+
+          <div className='character-counter__wrapper'>
+            <CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
           </div>
-          <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} /></div>
         </div>
 
         <div className='compose-form__publish'>
-          <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block /></div>
+          <div className='compose-form__publish-button-wrapper'>
+            <Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block />
+          </div>
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.js b/app/javascript/mastodon/features/compose/components/language_dropdown.js
new file mode 100644
index 000000000..d76490c77
--- /dev/null
+++ b/app/javascript/mastodon/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 'mastodon/features/ui/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 'mastodon/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: (
+    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
+      <path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
+    </svg>
+  ),
+
+  delete: (
+    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
+      <path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
+    </svg>
+  ),
+};
+
+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 (
+      <div key={lang[0]} role='option' tabIndex='0' data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}>
+        <span className='language-dropdown__dropdown__results__item__native-name'>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span>
+      </div>
+    );
+  }
+
+  render () {
+    const { style, placement, intl } = this.props;
+    const { mounted, searchValue } = this.state;
+    const isSearching = searchValue !== '';
+    const results = this.search();
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ 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
+          <div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
+            <div className='emoji-mart-search'>
+              <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
+              <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? icons.loupe : icons.delete}</button>
+            </div>
+
+            <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
+              {results.map(this.renderItem)}
+            </div>
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
+
+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 (
+      <div className={classNames('privacy-dropdown', { active: open })}>
+        <div className='privacy-dropdown__value'>
+          <TextIconButton
+            className='privacy-dropdown__value-icon'
+            label={value && value.toUpperCase()}
+            title={intl.formatMessage(messages.changeLanguage)}
+            active={open}
+            onClick={this.handleToggle}
+          />
+        </div>
+
+        <Overlay show={open} placement={placement} target={this}>
+          <LanguageDropdownMenu
+            value={value}
+            frequentlyUsedLanguages={frequentlyUsedLanguages}
+            onClose={this.handleClose}
+            onChange={this.handleChange}
+            placement={placement}
+            intl={intl}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.js b/app/javascript/mastodon/features/compose/components/text_icon_button.js
index f0b133538..cd644b680 100644
--- a/app/javascript/mastodon/features/compose/components/text_icon_button.js
+++ b/app/javascript/mastodon/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}
       >
         {label}
diff --git a/app/javascript/mastodon/features/compose/containers/language_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/language_dropdown_container.js
new file mode 100644
index 000000000..2a040a13f
--- /dev/null
+++ b/app/javascript/mastodon/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 'mastodon/actions/compose';
+import { useLanguage } from 'mastodon/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/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 1307bf23e..51d023c8e 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -28,5 +28,6 @@ export const showTrends = getMeta('trends');
 export const title = getMeta('title');
 export const cropImages = getMeta('crop_images');
 export const disableSwiping = getMeta('disable_swiping');
+export const languages = initialState && initialState.languages;
 
 export default initialState;
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 49e0d8a38..ffaf05605 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -1260,6 +1260,23 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Change language",
+        "id": "compose.language.change"
+      },
+      {
+        "defaultMessage": "Search languages...",
+        "id": "compose.language.search"
+      },
+      {
+        "defaultMessage": "Clear",
+        "id": "emoji_button.clear"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/language_dropdown.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Edit profile",
         "id": "navigation_bar.edit_profile"
       }
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 7de11dc51..1efa93967 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -96,6 +96,8 @@
   "community.column_settings.local_only": "Local only",
   "community.column_settings.media_only": "Media Only",
   "community.column_settings.remote_only": "Remote only",
+  "compose.language.change": "Change language",
+  "compose.language.search": "Search languages...",
   "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.",
   "compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
@@ -151,6 +153,7 @@
   "embed.instructions": "Embed this post on your website by copying the code below.",
   "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
+  "emoji_button.clear": "Clear",
   "emoji_button.custom": "Custom",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index ea882a71f..d7478c33d 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -28,6 +28,7 @@ import {
   COMPOSE_SPOILERNESS_CHANGE,
   COMPOSE_SPOILER_TEXT_CHANGE,
   COMPOSE_VISIBILITY_CHANGE,
+  COMPOSE_LANGUAGE_CHANGE,
   COMPOSE_COMPOSING_CHANGE,
   COMPOSE_EMOJI_INSERT,
   COMPOSE_UPLOAD_CHANGE_REQUEST,
@@ -79,6 +80,7 @@ const initialState = ImmutableMap({
   suggestions: ImmutableList(),
   default_privacy: 'public',
   default_sensitive: false,
+  default_language: 'en',
   resetFileKey: Math.floor((Math.random() * 0x10000)),
   idempotencyKey: null,
   tagHistory: ImmutableList(),
@@ -117,7 +119,8 @@ function clearAll(state) {
     map.set('is_changing_upload', false);
     map.set('in_reply_to', null);
     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());
@@ -440,6 +443,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.status.get('spoiler_text').length > 0) {
         map.set('spoiler', true);
@@ -468,6 +472,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);
@@ -497,6 +502,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/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 39639f3dc..afffce917 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -3,6 +3,7 @@ import { NOTIFICATIONS_FILTER_SET } from '../actions/notifications';
 import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from '../actions/columns';
 import { STORE_HYDRATE } from '../actions/store';
 import { EMOJI_USE } from '../actions/emojis';
+import { LANGUAGE_USE } from '../actions/languages';
 import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 import uuid from '../uuid';
@@ -129,6 +130,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) {
@@ -154,6 +157,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/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index e6133ee32..71d6653d0 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -4349,7 +4349,6 @@ a.status-card.compact:hover {
   background: $simple-background-color;
   box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
   border-radius: 4px;
-  margin-left: 40px;
   overflow: hidden;
   z-index: 2;
 
@@ -4450,6 +4449,71 @@ a.status-card.compact:hover {
   }
 }
 
+.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%);
+          }
+        }
+      }
+    }
+  }
+}
+
 .search {
   position: relative;
 }
diff --git a/app/models/user.rb b/app/models/user.rb
index b38de74b8..f7a35eeb5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -53,6 +53,7 @@ class User < ApplicationRecord
   include Settings::Extend
   include UserRoles
   include Redisable
+  include LanguagesHelper
 
   # The home and list feeds will be stored in Redis for this amount
   # of time, and status fan-out to followers will include only people
@@ -248,7 +249,7 @@ class User < ApplicationRecord
   end
 
   def preferred_posting_language
-    settings.default_language || locale
+    valid_locale_cascade(settings.default_language, locale)
   end
 
   def setting_default_privacy
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index a05164641..5eab02dbc 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -3,7 +3,8 @@
 class InitialStateSerializer < ActiveModel::Serializer
   attributes :meta, :compose, :accounts,
              :media_attachments, :settings,
-             :max_toot_chars, :poll_limits
+             :max_toot_chars, :poll_limits,
+             :languages
 
   has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
 
@@ -76,6 +77,7 @@ class InitialStateSerializer < ActiveModel::Serializer
       store[:me]                = object.current_account.id.to_s
       store[:default_privacy]   = object.visibility || object.current_account.user.setting_default_privacy
       store[:default_sensitive] = object.current_account.user.setting_default_sensitive
+      store[:default_language]  = object.current_account.user.preferred_posting_language
     end
 
     store[:text] = object.text if object.text
@@ -94,6 +96,10 @@ class InitialStateSerializer < ActiveModel::Serializer
     { accept_content_types: MediaAttachment.supported_file_extensions + MediaAttachment.supported_mime_types }
   end
 
+  def languages
+    LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] }
+  end
+
   private
 
   def instance_presenter
diff --git a/app/serializers/rest/preferences_serializer.rb b/app/serializers/rest/preferences_serializer.rb
index 119f0e06d..874bd990d 100644
--- a/app/serializers/rest/preferences_serializer.rb
+++ b/app/serializers/rest/preferences_serializer.rb
@@ -17,7 +17,7 @@ class REST::PreferencesSerializer < ActiveModel::Serializer
   end
 
   def posting_default_language
-    object.user.setting_default_language.presence
+    object.user.preferred_posting_language
   end
 
   def reading_default_sensitive_media
diff --git a/package.json b/package.json
index c3481d08f..cc44a9247 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
     "favico.js": "^0.3.10",
     "file-loader": "^6.2.0",
     "font-awesome": "^4.7.0",
+    "fuzzysort": "^1.9.0",
     "glob": "^8.0.1",
     "history": "^4.10.1",
     "http-link-header": "^1.0.4",
@@ -115,7 +116,7 @@
     "react-swipeable-views": "^0.14.0",
     "react-textarea-autosize": "^8.3.3",
     "react-toggle": "^4.1.2",
-    "redis": "^4.1.0",
+    "redis": "^4.0.6 <4.1.0",
     "redux": "^4.2.0",
     "redux-immutable": "^4.0.0",
     "redux-thunk": "^2.4.1",
diff --git a/yarn.lock b/yarn.lock
index 9850da020..8293dbc1f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1500,6 +1500,41 @@
     "@jridgewell/resolve-uri" "^3.0.3"
     "@jridgewell/sourcemap-codec" "^1.4.10"
 
+"@node-redis/bloom@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@node-redis/bloom/-/bloom-1.0.1.tgz#144474a0b7dc4a4b91badea2cfa9538ce0a1854e"
+  integrity sha512-mXEBvEIgF4tUzdIN89LiYsbi6//EdpFA7L8M+DHCvePXg+bfHWi+ct5VI6nHUFQE5+ohm/9wmgihCH3HSkeKsw==
+
+"@node-redis/client@1.0.5":
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/@node-redis/client/-/client-1.0.5.tgz#ebac5e2bbf12214042a37621604973a954ede755"
+  integrity sha512-ESZ3bd1f+od62h4MaBLKum+klVJfA4wAeLHcVQBkoXa1l0viFesOWnakLQqKg+UyrlJhZmXJWtu0Y9v7iTMrig==
+  dependencies:
+    cluster-key-slot "1.1.0"
+    generic-pool "3.8.2"
+    redis-parser "3.0.0"
+    yallist "4.0.0"
+
+"@node-redis/graph@1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@node-redis/graph/-/graph-1.0.0.tgz#baf8eaac4a400f86ea04d65ec3d65715fd7951ab"
+  integrity sha512-mRSo8jEGC0cf+Rm7q8mWMKKKqkn6EAnA9IA2S3JvUv/gaWW/73vil7GLNwion2ihTptAm05I9LkepzfIXUKX5g==
+
+"@node-redis/json@1.0.2":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@node-redis/json/-/json-1.0.2.tgz#8ad2d0f026698dc1a4238cc3d1eb099a3bee5ab8"
+  integrity sha512-qVRgn8WfG46QQ08CghSbY4VhHFgaTY71WjpwRBGEuqGPfWwfRcIf3OqSpR7Q/45X+v3xd8mvYjywqh0wqJ8T+g==
+
+"@node-redis/search@1.0.5":
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/@node-redis/search/-/search-1.0.5.tgz#96050007eb7c50a7e47080320b4f12aca8cf94c4"
+  integrity sha512-MCOL8iCKq4v+3HgEQv8zGlSkZyXSXtERgrAJ4TSryIG/eLFy84b57KmNNa/V7M1Q2Wd2hgn2nPCGNcQtk1R1OQ==
+
+"@node-redis/time-series@1.0.2":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@node-redis/time-series/-/time-series-1.0.2.tgz#5dd3638374edd85ebe0aa6b0e87addc88fb9df69"
+  integrity sha512-HGQ8YooJ8Mx7l28tD7XjtB3ImLEjlUxG1wC1PAjxu6hPJqjPshUZxAICzDqDjtIbhDTf48WXXUcx8TQJB1XTKA==
+
 "@npmcli/move-file@^1.0.1":
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.0.1.tgz#de103070dac0f48ce49cf6693c23af59c0f70464"
@@ -1517,40 +1552,6 @@
   resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-6.1.6.tgz#de486ae0a663e1bed637a012cbb2739bfcfa2031"
   integrity sha512-2M4zlthYmOC6X/tcPcFd//sIL26a7JbCpGNl8uIrQf+pR1Z47uhYt9cOwVqJTJZPurdy2k+YY3Pn64pqruAPEA==
 
-"@redis/bloom@1.0.2":
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.0.2.tgz#42b82ec399a92db05e29fffcdfd9235a5fc15cdf"
-  integrity sha512-EBw7Ag1hPgFzdznK2PBblc1kdlj5B5Cw3XwI9/oG7tSn85/HKy3X9xHy/8tm/eNXJYHLXHJL/pkwBpFMVVefkw==
-
-"@redis/client@1.1.0":
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.1.0.tgz#e52a85aee802796ceb14bf27daf9550f51f238b8"
-  integrity sha512-xO9JDIgzsZYDl3EvFhl6LC52DP3q3GCMUer8zHgKV6qSYsq1zB+pZs9+T80VgcRogrlRYhi4ZlfX6A+bHiBAgA==
-  dependencies:
-    cluster-key-slot "1.1.0"
-    generic-pool "3.8.2"
-    yallist "4.0.0"
-
-"@redis/graph@1.0.1":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.0.1.tgz#eabc58ba99cd70d0c907169c02b55497e4ec8a99"
-  integrity sha512-oDE4myMCJOCVKYMygEMWuriBgqlS5FqdWerikMoJxzmmTUErnTRRgmIDa2VcgytACZMFqpAOWDzops4DOlnkfQ==
-
-"@redis/json@1.0.3":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@redis/json/-/json-1.0.3.tgz#a13fde1d22ebff0ae2805cd8e1e70522b08ea866"
-  integrity sha512-4X0Qv0BzD9Zlb0edkUoau5c1bInWSICqXAGrpwEltkncUwcxJIGEcVryZhLgb0p/3PkKaLIWkjhHRtLe9yiA7Q==
-
-"@redis/search@1.0.6":
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.0.6.tgz#53d7451c2783f011ebc48ec4c2891264e0b22f10"
-  integrity sha512-pP+ZQRis5P21SD6fjyCeLcQdps+LuTzp2wdUbzxEmNhleighDDTD5ck8+cYof+WLec4csZX7ks+BuoMw0RaZrA==
-
-"@redis/time-series@1.0.3":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.0.3.tgz#4cfca8e564228c0bddcdf4418cba60c20b224ac4"
-  integrity sha512-OFp0q4SGrTH0Mruf6oFsHGea58u8vS/iI5+NpYdicaM+7BgqBZH8FFvNZ8rYYLrUO/QRqMq72NpXmxLVNcdmjA==
-
 "@sinclair/typebox@^0.23.3":
   version "0.23.5"
   resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.23.5.tgz#93f7b9f4e3285a7a9ade7557d9a8d36809cbc47d"
@@ -5264,6 +5265,11 @@ functions-have-names@^1.2.2:
   resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
   integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
 
+fuzzysort@^1.9.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/fuzzysort/-/fuzzysort-1.9.0.tgz#d36d27949eae22340bb6f7ba30ea6751b92a181c"
+  integrity sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==
+
 gauge@^4.0.3:
   version "4.0.4"
   resolved "https://registry.yarnpkg.com/gauge/-/gauge-4.0.4.tgz#52ff0652f2bbf607a989793d53b751bef2328dce"
@@ -9441,17 +9447,29 @@ redent@^3.0.0:
     indent-string "^4.0.0"
     strip-indent "^3.0.0"
 
-redis@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/redis/-/redis-4.1.0.tgz#6e400e8edf219e39281afe95e66a3d5f7dcf7289"
-  integrity sha512-5hvJ8wbzpCCiuN1ges6tx2SAh2XXCY0ayresBmu40/SGusWHFW86TAlIPpbimMX2DFHOX7RN34G2XlPA1Z43zg==
-  dependencies:
-    "@redis/bloom" "1.0.2"
-    "@redis/client" "1.1.0"
-    "@redis/graph" "1.0.1"
-    "@redis/json" "1.0.3"
-    "@redis/search" "1.0.6"
-    "@redis/time-series" "1.0.3"
+redis-errors@^1.0.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
+  integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=
+
+redis-parser@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
+  integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=
+  dependencies:
+    redis-errors "^1.0.0"
+
+"redis@^4.0.6 <4.1.0":
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/redis/-/redis-4.0.6.tgz#a2ded4d9f4f4bad148e54781051618fc684cd858"
+  integrity sha512-IaPAxgF5dV0jx+A9l6yd6R9/PAChZIoAskDVRzUODeLDNhsMlq7OLLTmu0AwAr0xjrJ1bibW5xdpRwqIQ8Q0Xg==
+  dependencies:
+    "@node-redis/bloom" "1.0.1"
+    "@node-redis/client" "1.0.5"
+    "@node-redis/graph" "1.0.0"
+    "@node-redis/json" "1.0.2"
+    "@node-redis/search" "1.0.5"
+    "@node-redis/time-series" "1.0.2"
 
 redux-immutable@^4.0.0:
   version "4.0.0"