diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index b0a217550..bd4c1d002 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']), @@ -635,6 +636,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 d7635da40..4620d1c43 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'; @@ -204,6 +205,7 @@ class ComposeForm extends ImmutablePureComponent { render () { const { intl, onPaste, showSearch } = this.props; const disabled = this.props.isSubmitting; + let publishText = ''; if (this.props.isEditing) { @@ -254,6 +256,7 @@ class ComposeForm extends ImmutablePureComponent { autoFocus={!showSearch && !isMobile(window.innerWidth)} > +
@@ -266,12 +269,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 1389a3c3d..a6d54f134 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -27,5 +27,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 0ea2a287c..4725d2d7c 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 b32005a23..d82f4cb0d 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -92,6 +92,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.", @@ -147,6 +149,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 1a1cec6db..b066d3abd 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/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index b3b913946..34190a91d 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -2,7 +2,8 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :accounts, - :media_attachments, :settings + :media_attachments, :settings, + :languages has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer @@ -59,6 +60,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 @@ -77,6 +79,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/package.json b/package.json index 3b24cd17b..d9d78a98f 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "express": "^4.18.1", "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", diff --git a/yarn.lock b/yarn.lock index 9cd6c481a..f13170e47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5254,6 +5254,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"