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