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