Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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