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