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