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