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