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