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