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