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)}
>
+
@@ -267,12 +270,18 @@ class ComposeForm extends ImmutablePureComponent {
+
+
+
+
+
-
);
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: (
+
+ ),
+
+ 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/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
@@ -1257,6 +1257,23 @@
],
"path": "app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.json"
},
+ {
+ "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": [
{
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..3a84cafdb 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",
"redux": "^4.2.0",
"redux-immutable": "^4.0.0",
"redux-thunk": "^2.4.1",
diff --git a/yarn.lock b/yarn.lock
index 9850da020..e0532070c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5264,6 +5264,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,7 +9446,7 @@ redent@^3.0.0:
indent-string "^4.0.0"
strip-indent "^3.0.0"
-redis@^4.1.0:
+redis@^4.0.6:
version "4.1.0"
resolved "https://registry.yarnpkg.com/redis/-/redis-4.1.0.tgz#6e400e8edf219e39281afe95e66a3d5f7dcf7289"
integrity sha512-5hvJ8wbzpCCiuN1ges6tx2SAh2XXCY0ayresBmu40/SGusWHFW86TAlIPpbimMX2DFHOX7RN34G2XlPA1Z43zg==