Bluesky app fork with some witchin' additions 💫

Improve handling of unselecting languanges in composer language menu (#1093)

* allow toggling off/on multiple from main composer lang menu

* fix dropdown styles for long labels

* udpate model to use new string field

* update language UI

* save langs to history on submit

* remove edit

* clean up use new fields

* default to deviceLocales

* fix default valu

* feedback

* use radio icon

authored by

Eric Bailey and committed by
GitHub
b6317d4c acad8cb4

+135 -47
+61 -14
src/state/models/ui/preferences.ts
··· 33 33 'impersonation', 34 34 ] 35 35 const VISIBILITY_VALUES = ['show', 'warn', 'hide'] 36 + const DEFAULT_LANG_CODES = (deviceLocales || []) 37 + .concat(['en', 'ja', 'pt', 'de']) 38 + .slice(0, 6) 36 39 37 40 export class LabelPreferencesModel { 38 41 nsfw: LabelPreference = 'hide' ··· 51 54 export class PreferencesModel { 52 55 adultContentEnabled = !isIOS 53 56 contentLanguages: string[] = deviceLocales || [] 54 - postLanguages: string[] = deviceLocales || [] 57 + postLanguage: string = deviceLocales[0] || 'en' 58 + postLanguageHistory: string[] = DEFAULT_LANG_CODES 55 59 contentLabels = new LabelPreferencesModel() 56 60 savedFeeds: string[] = [] 57 61 pinnedFeeds: string[] = [] ··· 71 75 serialize() { 72 76 return { 73 77 contentLanguages: this.contentLanguages, 74 - postLanguages: this.postLanguages, 78 + postLanguage: this.postLanguage, 79 + postLanguageHistory: this.postLanguageHistory, 75 80 contentLabels: this.contentLabels, 76 81 savedFeeds: this.savedFeeds, 77 82 pinnedFeeds: this.pinnedFeeds, ··· 101 106 // default to the device languages 102 107 this.contentLanguages = deviceLocales 103 108 } 104 - // check if post languages in preferences exist, otherwise default to device languages 109 + if (hasProp(v, 'postLanguage') && typeof v.postLanguage === 'string') { 110 + this.postLanguage = v.postLanguage 111 + } else { 112 + // default to the device languages 113 + this.postLanguage = deviceLocales[0] || 'en' 114 + } 105 115 if ( 106 - hasProp(v, 'postLanguages') && 107 - Array.isArray(v.postLanguages) && 108 - typeof v.postLanguages.every(item => typeof item === 'string') 116 + hasProp(v, 'postLanguageHistory') && 117 + Array.isArray(v.postLanguageHistory) && 118 + typeof v.postLanguageHistory.every(item => typeof item === 'string') 109 119 ) { 110 - this.postLanguages = v.postLanguages 120 + this.postLanguageHistory = v.postLanguageHistory 121 + .concat(DEFAULT_LANG_CODES) 122 + .slice(0, 6) 111 123 } else { 112 - // default to the device languages 113 - this.postLanguages = deviceLocales 124 + // default to a starter set 125 + this.postLanguageHistory = DEFAULT_LANG_CODES 114 126 } 115 127 // check if content labels in preferences exist, then hydrate 116 128 if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { ··· 279 291 runInAction(() => { 280 292 this.contentLabels = new LabelPreferencesModel() 281 293 this.contentLanguages = deviceLocales 282 - this.postLanguages = deviceLocales 294 + this.postLanguage = deviceLocales ? deviceLocales.join(',') : 'en' 295 + this.postLanguageHistory = DEFAULT_LANG_CODES 283 296 this.savedFeeds = [] 284 297 this.pinnedFeeds = [] 285 298 }) ··· 305 318 } 306 319 } 307 320 321 + /** 322 + * A getter that splits `this.postLanguage` into an array of strings. 323 + * 324 + * This was previously the main field on this model, but now we're 325 + * concatenating lang codes to make multi-selection a little better. 326 + */ 327 + get postLanguages() { 328 + // filter out empty strings if exist 329 + return this.postLanguage.split(',').filter(Boolean) 330 + } 331 + 308 332 hasPostLanguage(code2: string) { 309 333 return this.postLanguages.includes(code2) 310 334 } 311 335 312 336 togglePostLanguage(code2: string) { 313 337 if (this.hasPostLanguage(code2)) { 314 - this.postLanguages = this.postLanguages.filter(lang => lang !== code2) 338 + this.postLanguage = this.postLanguages 339 + .filter(lang => lang !== code2) 340 + .join(',') 315 341 } else { 316 - this.postLanguages = this.postLanguages.concat([code2]) 342 + // sort alphabetically for deterministic comparison in context menu 343 + this.postLanguage = this.postLanguages 344 + .concat([code2]) 345 + .sort((a, b) => a.localeCompare(b)) 346 + .join(',') 317 347 } 318 348 } 319 349 320 - setPostLanguage(code2: string) { 321 - this.postLanguages = [code2] 350 + setPostLanguage(commaSeparatedLangCodes: string) { 351 + this.postLanguage = commaSeparatedLangCodes 352 + } 353 + 354 + /** 355 + * Saves whatever language codes are currently selected into a history array, 356 + * which is then used to populate the language selector menu. 357 + */ 358 + savePostLanguageToHistory() { 359 + // filter out duplicate `this.postLanguage` if exists, and prepend 360 + // value to start of array 361 + this.postLanguageHistory = [this.postLanguage] 362 + .concat( 363 + this.postLanguageHistory.filter( 364 + commaSeparatedLangCodes => 365 + commaSeparatedLangCodes !== this.postLanguage, 366 + ), 367 + ) 368 + .slice(0, 6) 322 369 } 323 370 324 371 getReadablePostLanguages() {
+1
src/view/com/composer/Composer.tsx
··· 212 212 if (!replyTo) { 213 213 store.me.mainFeed.onPostCreated() 214 214 } 215 + store.preferences.savePostLanguageToHistory() 215 216 onPost?.() 216 217 onClose() 217 218 Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
+29 -19
src/view/com/composer/select-language/SelectLangBtn.tsx
··· 15 15 import {useStores} from 'state/index' 16 16 import {isNative} from 'platform/detection' 17 17 import {codeToLanguageName} from '../../../../locale/helpers' 18 - import {deviceLocales} from 'platform/detection' 19 18 20 19 export const SelectLangBtn = observer(function SelectLangBtn() { 21 20 const pal = usePalette('default') ··· 31 30 }, [store]) 32 31 33 32 const postLanguagesPref = store.preferences.postLanguages 33 + const postLanguagePref = store.preferences.postLanguage 34 34 const items: DropdownItem[] = useMemo(() => { 35 35 let arr: DropdownItemButton[] = [] 36 36 37 - const add = (langCode: string) => { 38 - const langName = codeToLanguageName(langCode) 37 + function add(commaSeparatedLangCodes: string) { 38 + const langCodes = commaSeparatedLangCodes.split(',') 39 + const langName = langCodes 40 + .map(code => codeToLanguageName(code)) 41 + .join(' + ') 42 + 43 + /* 44 + * Filter out any duplicates 45 + */ 39 46 if (arr.find((item: DropdownItemButton) => item.label === langName)) { 40 47 return 41 48 } 49 + 42 50 arr.push({ 43 - icon: store.preferences.hasPostLanguage(langCode) 44 - ? ['fas', 'circle-check'] 45 - : ['far', 'circle'], 51 + icon: 52 + langCodes.every(code => store.preferences.hasPostLanguage(code)) && 53 + langCodes.length === postLanguagesPref.length 54 + ? ['fas', 'circle-dot'] 55 + : ['far', 'circle'], 46 56 label: langName, 47 57 onPress() { 48 - store.preferences.setPostLanguage(langCode) 58 + store.preferences.setPostLanguage(commaSeparatedLangCodes) 49 59 }, 50 60 }) 51 61 } 52 62 53 - for (const lang of postLanguagesPref) { 54 - add(lang) 63 + if (postLanguagesPref.length) { 64 + /* 65 + * Re-join here after sanitization bc postLanguageHistory is an array of 66 + * comma-separated strings too 67 + */ 68 + add(postLanguagePref) 55 69 } 56 - for (const lang of deviceLocales) { 70 + 71 + // comma-separted strings of lang codes that have been used in the past 72 + for (const lang of store.preferences.postLanguageHistory) { 57 73 add(lang) 58 74 } 59 - add('en') // english 60 - add('ja') // japanese 61 - add('pt') // portugese 62 - add('de') // german 63 75 64 76 return [ 65 77 {heading: true, label: 'Post language'}, ··· 70 82 onPress: onPressMore, 71 83 }, 72 84 ] 73 - }, [store.preferences, postLanguagesPref, onPressMore]) 85 + }, [store.preferences, onPressMore, postLanguagePref, postLanguagesPref]) 74 86 75 87 return ( 76 88 <DropdownButton ··· 81 93 style={styles.button} 82 94 accessibilityLabel="Language selection" 83 95 accessibilityHint=""> 84 - {store.preferences.postLanguages.length > 0 ? ( 96 + {postLanguagesPref.length > 0 ? ( 85 97 <Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}> 86 - {store.preferences.postLanguages 87 - .map(lang => codeToLanguageName(lang)) 88 - .join(', ')} 98 + {postLanguagesPref.map(lang => codeToLanguageName(lang)).join(', ')} 89 99 </Text> 90 100 ) : ( 91 101 <FontAwesomeIcon
+39 -14
src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
··· 1 1 import React from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 + import {observer} from 'mobx-react-lite' 3 4 import {ScrollView} from '../util' 4 5 import {useStores} from 'state/index' 5 6 import {Text} from '../../util/text/Text' 6 7 import {usePalette} from 'lib/hooks/usePalette' 7 8 import {isDesktopWeb, deviceLocales} from 'platform/detection' 8 9 import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' 9 - import {LanguageToggle} from './LanguageToggle' 10 10 import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' 11 + import {ToggleButton} from 'view/com/util/forms/ToggleButton' 11 12 12 13 export const snapPoints = ['100%'] 13 14 14 - export function Component({}: {}) { 15 + export const Component = observer(() => { 15 16 const store = useStores() 16 17 const pal = usePalette('default') 17 18 const onPressDone = React.useCallback(() => { ··· 53 54 Which languages are used in this post? 54 55 </Text> 55 56 <ScrollView style={styles.scrollContainer}> 56 - {languages.map(lang => ( 57 - <LanguageToggle 58 - key={lang.code2} 59 - code2={lang.code2} 60 - langType="postLanguages" 61 - name={lang.name} 62 - onPress={() => { 63 - onPress(lang.code2) 64 - }} 65 - /> 66 - ))} 57 + {languages.map(lang => { 58 + const isSelected = store.preferences.hasPostLanguage(lang.code2) 59 + 60 + // enforce a max of 3 selections for post languages 61 + let isDisabled = false 62 + if ( 63 + store.preferences.postLanguage.split(',').length >= 3 && 64 + !isSelected 65 + ) { 66 + isDisabled = true 67 + } 68 + 69 + return ( 70 + <ToggleButton 71 + key={lang.code2} 72 + label={lang.name} 73 + isSelected={isSelected} 74 + onPress={() => (isDisabled ? undefined : onPress(lang.code2))} 75 + style={[ 76 + pal.border, 77 + styles.languageToggle, 78 + isDisabled && styles.dimmed, 79 + ]} 80 + /> 81 + ) 82 + })} 67 83 <View style={styles.bottomSpacer} /> 68 84 </ScrollView> 69 85 <ConfirmLanguagesButton onPress={onPressDone} /> 70 86 </View> 71 87 ) 72 - } 88 + }) 73 89 74 90 const styles = StyleSheet.create({ 75 91 container: { ··· 93 109 }, 94 110 bottomSpacer: { 95 111 height: isDesktopWeb ? 0 : 60, 112 + }, 113 + languageToggle: { 114 + borderTopWidth: 1, 115 + borderRadius: 0, 116 + paddingHorizontal: 6, 117 + paddingVertical: 12, 118 + }, 119 + dimmed: { 120 + opacity: 0.5, 96 121 }, 97 122 })
+3
src/view/com/util/forms/DropdownButton.tsx
··· 319 319 icon: { 320 320 marginLeft: 2, 321 321 marginRight: 8, 322 + flexShrink: 0, 322 323 }, 323 324 label: { 324 325 fontSize: 18, 326 + flexShrink: 1, 327 + flexGrow: 1, 325 328 }, 326 329 separator: { 327 330 borderTopWidth: 1,
+2
src/view/index.ts
··· 29 29 import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck' 30 30 import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation' 31 31 import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser' 32 + import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot' 32 33 import {faClone} from '@fortawesome/free-solid-svg-icons/faClone' 33 34 import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone' 34 35 import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' ··· 122 123 farCircleCheck, 123 124 faCircleExclamation, 124 125 faCircleUser, 126 + faCircleDot, 125 127 faClone, 126 128 farClone, 127 129 faComment,