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