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