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