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