Bluesky app fork with some witchin' additions 馃挮
at c906fea77adb2daad28a521f06e68d5bbc4bce4d 347 lines 10 kB view raw
1import {useCallback, useMemo, useState} from 'react' 2import {useWindowDimensions, View} from 'react-native' 3import {useSafeAreaInsets} from 'react-native-safe-area-context' 4import {msg, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6 7import {languageName} from '#/locale/helpers' 8import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages' 9import {useLanguagePrefs} from '#/state/preferences/languages' 10import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 11import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 12import {atoms as a, useTheme, web} from '#/alf' 13import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14import * as Dialog from '#/components/Dialog' 15import {SearchInput} from '#/components/forms/SearchInput' 16import * as Toggle from '#/components/forms/Toggle' 17import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 18import {Text} from '#/components/Typography' 19import {IS_NATIVE, IS_WEB} from '#/env' 20 21export function LanguageSelectDialog({ 22 titleText, 23 subtitleText, 24 control, 25 /** 26 * Optionally can be passed to show different values than what is saved in 27 * langPrefs. 28 */ 29 currentLanguages, 30 onSelectLanguages, 31 maxLanguages, 32}: { 33 control: Dialog.DialogControlProps 34 titleText?: React.ReactNode 35 subtitleText?: React.ReactNode 36 /** 37 * Defaults to the primary language 38 */ 39 currentLanguages?: string[] 40 onSelectLanguages: (languages: string[]) => void 41 maxLanguages?: number 42}) { 43 const {height} = useWindowDimensions() 44 const insets = useSafeAreaInsets() 45 46 const renderErrorBoundary = useCallback( 47 (error: any) => <DialogError details={String(error)} />, 48 [], 49 ) 50 51 return ( 52 <Dialog.Outer 53 control={control} 54 nativeOptions={{minHeight: height - insets.top}}> 55 <Dialog.Handle /> 56 <ErrorBoundary renderError={renderErrorBoundary}> 57 <DialogInner 58 titleText={titleText} 59 subtitleText={subtitleText} 60 currentLanguages={currentLanguages} 61 onSelectLanguages={onSelectLanguages} 62 maxLanguages={maxLanguages} 63 /> 64 </ErrorBoundary> 65 </Dialog.Outer> 66 ) 67} 68 69export function DialogInner({ 70 titleText, 71 subtitleText, 72 currentLanguages, 73 onSelectLanguages, 74 maxLanguages, 75}: { 76 titleText?: React.ReactNode 77 subtitleText?: React.ReactNode 78 currentLanguages?: string[] 79 onSelectLanguages?: (languages: string[]) => void 80 maxLanguages?: number 81}) { 82 const control = Dialog.useDialogContext() 83 const [headerHeight, setHeaderHeight] = useState(0) 84 85 const allowedLanguages = useMemo(() => { 86 const uniqueLanguagesMap = LANGUAGES.filter(lang => !!lang.code2).reduce( 87 (acc, lang) => { 88 acc[lang.code2] = lang 89 return acc 90 }, 91 {} as Record<string, Language>, 92 ) 93 94 return Object.values(uniqueLanguagesMap) 95 }, []) 96 97 const langPrefs = useLanguagePrefs() 98 const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>( 99 currentLanguages || [langPrefs.primaryLanguage], 100 ) 101 const [search, setSearch] = useState('') 102 103 const t = useTheme() 104 const {_} = useLingui() 105 106 const handleClose = () => { 107 control.close(() => { 108 onSelectLanguages?.(checkedLanguagesCode2) 109 }) 110 } 111 112 // NOTE(@elijaharita): Displayed languages are split into 3 lists for 113 // ordering. 114 const displayedLanguages = useMemo(() => { 115 function mapCode2List(code2List: string[]) { 116 return code2List.map(code2 => LANGUAGES_MAP_CODE2[code2]).filter(Boolean) 117 } 118 119 // NOTE(@elijaharita): Get recent language codes and map them to language 120 // objects. Both the user account's saved language history and the current 121 // checked languages are displayed here. 122 const recentLanguagesCode2 = 123 Array.from( 124 new Set([...checkedLanguagesCode2, ...langPrefs.postLanguageHistory]), 125 ).slice(0, 5) || [] 126 const recentLanguages = mapCode2List(recentLanguagesCode2) 127 128 // NOTE(@elijaharita): helper functions 129 const matchesSearch = (lang: Language) => 130 lang.name.toLowerCase().includes(search.toLowerCase()) 131 const isChecked = (lang: Language) => 132 checkedLanguagesCode2.includes(lang.code2) 133 const isInRecents = (lang: Language) => 134 recentLanguagesCode2.includes(lang.code2) 135 136 const checkedRecent = recentLanguages.filter(isChecked) 137 138 if (search) { 139 // NOTE(@elijaharita): if a search is active, we ALWAYS show checked 140 // items, as well as any items that match the search. 141 const uncheckedRecent = recentLanguages 142 .filter(lang => !isChecked(lang)) 143 .filter(matchesSearch) 144 const unchecked = allowedLanguages.filter(lang => !isChecked(lang)) 145 const all = unchecked 146 .filter(matchesSearch) 147 .filter(lang => !isInRecents(lang)) 148 149 return { 150 all, 151 checkedRecent, 152 uncheckedRecent, 153 } 154 } else { 155 // NOTE(@elijaharita): if no search is active, we show everything. 156 const uncheckedRecent = recentLanguages.filter(lang => !isChecked(lang)) 157 const all = allowedLanguages 158 .filter(lang => !recentLanguagesCode2.includes(lang.code2)) 159 .filter(lang => !isInRecents(lang)) 160 161 return { 162 all, 163 checkedRecent, 164 uncheckedRecent, 165 } 166 } 167 }, [ 168 allowedLanguages, 169 search, 170 langPrefs.postLanguageHistory, 171 checkedLanguagesCode2, 172 ]) 173 174 const listHeader = ( 175 <View 176 style={[a.pb_xs, t.atoms.bg, IS_NATIVE && a.pt_2xl]} 177 onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}> 178 <View style={[a.flex_row, a.w_full, a.justify_between]}> 179 <View> 180 <Text 181 nativeID="dialog-title" 182 style={[ 183 t.atoms.text, 184 a.text_left, 185 a.font_semi_bold, 186 a.text_xl, 187 a.mb_sm, 188 ]}> 189 {titleText ?? <Trans>Choose languages</Trans>} 190 </Text> 191 {subtitleText && ( 192 <Text 193 nativeID="dialog-description" 194 style={[ 195 t.atoms.text_contrast_medium, 196 a.text_left, 197 a.text_md, 198 a.mb_lg, 199 ]}> 200 {subtitleText} 201 </Text> 202 )} 203 </View> 204 205 {IS_WEB && ( 206 <Button 207 variant="ghost" 208 size="small" 209 color="secondary" 210 shape="round" 211 label={_(msg`Close dialog`)} 212 onPress={handleClose}> 213 <ButtonIcon icon={XIcon} /> 214 </Button> 215 )} 216 </View> 217 218 <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs, a.pb_0]}> 219 <SearchInput 220 value={search} 221 onChangeText={setSearch} 222 placeholder={_(msg`Search languages`)} 223 label={_(msg`Search languages`)} 224 maxLength={50} 225 onClearText={() => setSearch('')} 226 /> 227 </View> 228 </View> 229 ) 230 231 const isCheckedRecentEmpty = 232 displayedLanguages.checkedRecent.length > 0 || 233 displayedLanguages.uncheckedRecent.length > 0 234 235 const isDisplayedLanguagesEmpty = displayedLanguages.all.length === 0 236 237 const flatListData = [ 238 ...(isCheckedRecentEmpty 239 ? [{type: 'header', label: _(msg`Recently used`)}] 240 : []), 241 ...displayedLanguages.checkedRecent.map(lang => ({type: 'item', lang})), 242 ...displayedLanguages.uncheckedRecent.map(lang => ({type: 'item', lang})), 243 ...(isDisplayedLanguagesEmpty 244 ? [] 245 : [{type: 'header', label: _(msg`All languages`)}]), 246 ...displayedLanguages.all.map(lang => ({type: 'item', lang})), 247 ] 248 249 return ( 250 <Toggle.Group 251 values={checkedLanguagesCode2} 252 onChange={setCheckedLanguagesCode2} 253 type="checkbox" 254 maxSelections={maxLanguages} 255 label={_(msg`Select languages`)} 256 style={web([a.contents])}> 257 <Dialog.InnerFlatList 258 data={flatListData} 259 ListHeaderComponent={listHeader} 260 stickyHeaderIndices={[0]} 261 contentContainerStyle={[a.gap_0]} 262 style={[IS_NATIVE && a.px_lg, web({paddingBottom: 120})]} 263 scrollIndicatorInsets={{top: headerHeight}} 264 renderItem={({item, index}) => { 265 if (item.type === 'header') { 266 return ( 267 <Text 268 key={index} 269 style={[ 270 a.px_0, 271 a.py_md, 272 a.font_semi_bold, 273 a.text_xs, 274 t.atoms.text_contrast_low, 275 a.pt_3xl, 276 ]}> 277 {item.label} 278 </Text> 279 ) 280 } 281 const lang = item.lang 282 283 return ( 284 <Toggle.Item 285 key={lang.code2} 286 name={lang.code2} 287 label={languageName(lang, langPrefs.appLanguage)} 288 style={[ 289 t.atoms.border_contrast_low, 290 a.border_b, 291 a.rounded_0, 292 a.px_0, 293 a.py_md, 294 ]}> 295 <Toggle.LabelText style={[a.flex_1]}> 296 {languageName(lang, langPrefs.appLanguage)} 297 </Toggle.LabelText> 298 <Toggle.Checkbox /> 299 </Toggle.Item> 300 ) 301 }} 302 footer={ 303 <Dialog.FlatListFooter> 304 <Button 305 label={_(msg`Close dialog`)} 306 onPress={handleClose} 307 color="primary" 308 size="large"> 309 <ButtonText> 310 <Trans>Done</Trans> 311 </ButtonText> 312 </Button> 313 </Dialog.FlatListFooter> 314 } 315 /> 316 </Toggle.Group> 317 ) 318} 319 320function DialogError({details}: {details?: string}) { 321 const {_} = useLingui() 322 const control = Dialog.useDialogContext() 323 324 return ( 325 <Dialog.ScrollableInner 326 style={a.gap_md} 327 label={_(msg`An error has occurred`)}> 328 <Dialog.Close /> 329 <ErrorScreen 330 title={_(msg`Oh no!`)} 331 message={_( 332 msg`There was an unexpected issue in the application. Please let us know if this happened to you!`, 333 )} 334 details={details} 335 /> 336 <Button 337 label={_(msg`Close dialog`)} 338 onPress={() => control.close()} 339 color="primary" 340 size="large"> 341 <ButtonText> 342 <Trans>Close</Trans> 343 </ButtonText> 344 </Button> 345 </Dialog.ScrollableInner> 346 ) 347}