about summary refs log tree commit diff
path: root/app/javascript/flavours
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2022-05-16 11:18:35 +0200
committerClaire <claire.github-309c@sitedethib.com>2022-05-16 23:06:29 +0200
commit2e5fb45be574493475e998b94a399b787f5ea877 (patch)
treea803bc3b8718dc3829d38ff5e3568fbb9615da0a /app/javascript/flavours
parent4415005de73579110a5bbc31bdd55dc89bf726e1 (diff)
[Glitch] Add language dropdown to compose in web UI
Port 0cdb07757050825725cac76f1c9cf11cf64acc0a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
Diffstat (limited to 'app/javascript/flavours')
-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
10 files changed, 471 insertions, 11 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;