Add language dropdown to compose in web UI (#18420)
This commit is contained in:
		| @@ -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, | ||||
|   | ||||
							
								
								
									
										12
									
								
								app/javascript/mastodon/actions/languages.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/javascript/mastodon/actions/languages.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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()); | ||||
| }; | ||||
| @@ -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)} | ||||
|         > | ||||
|           <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> | ||||
|  | ||||
|           <div className='compose-form__modifiers'> | ||||
|             <UploadFormContainer /> | ||||
|             <PollFormContainer /> | ||||
| @@ -266,12 +269,18 @@ class ComposeForm extends ImmutablePureComponent { | ||||
|             <PollButtonContainer /> | ||||
|             <PrivacyDropdownContainer disabled={this.props.isEditing} /> | ||||
|             <SpoilerButtonContainer /> | ||||
|             <LanguageDropdown /> | ||||
|           </div> | ||||
|  | ||||
|           <div className='character-counter__wrapper'> | ||||
|             <CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} /> | ||||
|           </div> | ||||
|           <div className='character-counter__wrapper'><CharacterCounter max={500} 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> | ||||
|     ); | ||||
|   | ||||
| @@ -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> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -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} | ||||
|   | ||||
| @@ -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); | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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": [ | ||||
|       { | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user