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