373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import {useCallback, useMemo, useState} from 'react'
|
|
import {useWindowDimensions, View} from 'react-native'
|
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|
import {msg} from '@lingui/core/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import {Trans} from '@lingui/react/macro'
|
|
|
|
import {languageName} from '#/locale/helpers'
|
|
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
|
|
import {useLanguagePrefs} from '#/state/preferences/languages'
|
|
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
|
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
|
import {atoms as a, tokens, useTheme, web} from '#/alf'
|
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
|
import * as Dialog from '#/components/Dialog'
|
|
import {SearchInput} from '#/components/forms/SearchInput'
|
|
import * as Toggle from '#/components/forms/Toggle'
|
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
|
import {Text} from '#/components/Typography'
|
|
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
|
|
|
type FlatListItem =
|
|
| {
|
|
type: 'header'
|
|
label: string
|
|
}
|
|
| {
|
|
type: 'item'
|
|
lang: Language
|
|
}
|
|
|
|
export function LanguageSelectDialog({
|
|
titleText,
|
|
subtitleText,
|
|
control,
|
|
/**
|
|
* Optionally can be passed to show different values than what is saved in
|
|
* langPrefs.
|
|
*/
|
|
currentLanguages,
|
|
onSelectLanguages,
|
|
maxLanguages,
|
|
}: {
|
|
control: Dialog.DialogControlProps
|
|
titleText?: React.ReactNode
|
|
subtitleText?: React.ReactNode
|
|
/**
|
|
* Defaults to the primary language
|
|
*/
|
|
currentLanguages?: string[]
|
|
onSelectLanguages: (languages: string[]) => void
|
|
maxLanguages?: number
|
|
}) {
|
|
const {height} = useWindowDimensions()
|
|
const insets = useSafeAreaInsets()
|
|
|
|
const renderErrorBoundary = useCallback(
|
|
(error: any) => <DialogError details={String(error)} />,
|
|
[],
|
|
)
|
|
|
|
return (
|
|
<Dialog.Outer
|
|
control={control}
|
|
nativeOptions={{
|
|
minHeight: IS_LIQUID_GLASS ? height : height - insets.top,
|
|
}}>
|
|
<Dialog.Handle />
|
|
<ErrorBoundary renderError={renderErrorBoundary}>
|
|
<DialogInner
|
|
titleText={titleText}
|
|
subtitleText={subtitleText}
|
|
currentLanguages={currentLanguages}
|
|
onSelectLanguages={onSelectLanguages}
|
|
maxLanguages={maxLanguages}
|
|
/>
|
|
</ErrorBoundary>
|
|
</Dialog.Outer>
|
|
)
|
|
}
|
|
|
|
export function DialogInner({
|
|
titleText,
|
|
subtitleText,
|
|
currentLanguages,
|
|
onSelectLanguages,
|
|
maxLanguages,
|
|
}: {
|
|
titleText?: React.ReactNode
|
|
subtitleText?: React.ReactNode
|
|
currentLanguages?: string[]
|
|
onSelectLanguages?: (languages: string[]) => void
|
|
maxLanguages?: number
|
|
}) {
|
|
const control = Dialog.useDialogContext()
|
|
const [headerHeight, setHeaderHeight] = useState(0)
|
|
const [footerHeight, setFooterHeight] = useState(0)
|
|
|
|
const allowedLanguages = useMemo(() => {
|
|
const uniqueLanguagesMap = LANGUAGES.filter(lang => !!lang.code2).reduce(
|
|
(acc, lang) => {
|
|
acc[lang.code2] = lang
|
|
return acc
|
|
},
|
|
{} as Record<string, Language>,
|
|
)
|
|
|
|
return Object.values(uniqueLanguagesMap)
|
|
}, [])
|
|
|
|
const langPrefs = useLanguagePrefs()
|
|
const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>(
|
|
currentLanguages || [langPrefs.primaryLanguage],
|
|
)
|
|
const [search, setSearch] = useState('')
|
|
|
|
const t = useTheme()
|
|
const {_} = useLingui()
|
|
|
|
const handleClose = () => {
|
|
control.close(() => {
|
|
onSelectLanguages?.(checkedLanguagesCode2)
|
|
})
|
|
}
|
|
|
|
// NOTE(@elijaharita): Displayed languages are split into 3 lists for
|
|
// ordering.
|
|
const displayedLanguages = useMemo(() => {
|
|
function mapCode2List(code2List: string[]) {
|
|
return code2List.map(code2 => LANGUAGES_MAP_CODE2[code2]).filter(Boolean)
|
|
}
|
|
|
|
// NOTE(@elijaharita): Get recent language codes and map them to language
|
|
// objects. Both the user account's saved language history and the current
|
|
// checked languages are displayed here.
|
|
const recentLanguagesCode2 =
|
|
Array.from(
|
|
new Set([...checkedLanguagesCode2, ...langPrefs.postLanguageHistory]),
|
|
).slice(0, 5) || []
|
|
const recentLanguages = mapCode2List(recentLanguagesCode2)
|
|
|
|
// NOTE(@elijaharita): helper functions
|
|
const searchLower = search.toLowerCase()
|
|
const matchesSearch = (lang: Language) =>
|
|
languageName(lang, langPrefs.appLanguage)
|
|
.toLowerCase()
|
|
.includes(searchLower) || lang.name.toLowerCase().includes(searchLower)
|
|
const isChecked = (lang: Language) =>
|
|
checkedLanguagesCode2.includes(lang.code2)
|
|
const isInRecents = (lang: Language) =>
|
|
recentLanguagesCode2.includes(lang.code2)
|
|
|
|
const checkedRecent = recentLanguages.filter(isChecked)
|
|
|
|
if (search) {
|
|
// NOTE(@elijaharita): if a search is active, we ALWAYS show checked
|
|
// items, as well as any items that match the search.
|
|
const uncheckedRecent = recentLanguages
|
|
.filter(lang => !isChecked(lang))
|
|
.filter(matchesSearch)
|
|
const unchecked = allowedLanguages.filter(lang => !isChecked(lang))
|
|
const all = unchecked
|
|
.filter(matchesSearch)
|
|
.filter(lang => !isInRecents(lang))
|
|
|
|
return {
|
|
all,
|
|
checkedRecent,
|
|
uncheckedRecent,
|
|
}
|
|
} else {
|
|
// NOTE(@elijaharita): if no search is active, we show everything.
|
|
const uncheckedRecent = recentLanguages.filter(lang => !isChecked(lang))
|
|
const all = allowedLanguages
|
|
.filter(lang => !recentLanguagesCode2.includes(lang.code2))
|
|
.filter(lang => !isInRecents(lang))
|
|
|
|
return {
|
|
all,
|
|
checkedRecent,
|
|
uncheckedRecent,
|
|
}
|
|
}
|
|
}, [
|
|
allowedLanguages,
|
|
search,
|
|
langPrefs.postLanguageHistory,
|
|
checkedLanguagesCode2,
|
|
langPrefs.appLanguage,
|
|
])
|
|
|
|
const listHeader = (
|
|
<View
|
|
style={[a.pb_xs, t.atoms.bg, IS_NATIVE && a.pt_2xl]}
|
|
onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
|
|
<View style={[a.flex_row, a.w_full, a.justify_between]}>
|
|
<View>
|
|
<Text
|
|
nativeID="dialog-title"
|
|
style={[
|
|
t.atoms.text,
|
|
a.text_left,
|
|
a.font_semi_bold,
|
|
a.text_xl,
|
|
a.mb_sm,
|
|
]}>
|
|
{titleText ?? <Trans>Choose languages</Trans>}
|
|
</Text>
|
|
{subtitleText && (
|
|
<Text
|
|
nativeID="dialog-description"
|
|
style={[
|
|
t.atoms.text_contrast_medium,
|
|
a.text_left,
|
|
a.text_md,
|
|
a.mb_lg,
|
|
]}>
|
|
{subtitleText}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{IS_WEB && (
|
|
<Button
|
|
variant="ghost"
|
|
size="small"
|
|
color="secondary"
|
|
shape="round"
|
|
label={_(msg`Close dialog`)}
|
|
onPress={handleClose}>
|
|
<ButtonIcon icon={XIcon} />
|
|
</Button>
|
|
)}
|
|
</View>
|
|
|
|
<View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs, a.pb_0]}>
|
|
<SearchInput
|
|
value={search}
|
|
onChangeText={setSearch}
|
|
placeholder={_(msg`Search languages`)}
|
|
label={_(msg`Search languages`)}
|
|
maxLength={50}
|
|
onClearText={() => setSearch('')}
|
|
/>
|
|
</View>
|
|
</View>
|
|
)
|
|
|
|
const isCheckedRecentEmpty =
|
|
displayedLanguages.checkedRecent.length > 0 ||
|
|
displayedLanguages.uncheckedRecent.length > 0
|
|
|
|
const isDisplayedLanguagesEmpty = displayedLanguages.all.length === 0
|
|
|
|
const flatListData = [
|
|
...(isCheckedRecentEmpty
|
|
? [{type: 'header', label: _(msg`Recently used`)}]
|
|
: []),
|
|
...displayedLanguages.checkedRecent.map(lang => ({type: 'item', lang})),
|
|
...displayedLanguages.uncheckedRecent.map(lang => ({type: 'item', lang})),
|
|
...(isDisplayedLanguagesEmpty
|
|
? []
|
|
: [{type: 'header', label: _(msg`All languages`)}]),
|
|
...displayedLanguages.all.map(lang => ({type: 'item', lang})),
|
|
]
|
|
|
|
const numItems = flatListData.length
|
|
|
|
return (
|
|
<Toggle.Group
|
|
values={checkedLanguagesCode2}
|
|
onChange={setCheckedLanguagesCode2}
|
|
type="checkbox"
|
|
maxSelections={maxLanguages}
|
|
label={_(msg`Select languages`)}
|
|
style={web([a.contents])}>
|
|
<Dialog.InnerFlatList
|
|
data={flatListData}
|
|
ListHeaderComponent={listHeader}
|
|
stickyHeaderIndices={[0]}
|
|
contentContainerStyle={[
|
|
a.gap_0,
|
|
IS_NATIVE && {paddingBottom: footerHeight + tokens.space.xl},
|
|
]}
|
|
style={[IS_NATIVE && a.px_lg, IS_WEB && {paddingBottom: 120}]}
|
|
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
|
|
renderItem={({item, index}: {item: FlatListItem; index: number}) => {
|
|
if (item.type === 'header') {
|
|
return (
|
|
<Text
|
|
key={index}
|
|
style={[
|
|
a.px_0,
|
|
a.py_md,
|
|
a.font_semi_bold,
|
|
a.text_xs,
|
|
t.atoms.text_contrast_low,
|
|
a.pt_3xl,
|
|
]}>
|
|
{item.label}
|
|
</Text>
|
|
)
|
|
}
|
|
const lang = item.lang
|
|
const name = languageName(lang, langPrefs.appLanguage)
|
|
|
|
const isLastItem = index === numItems - 1
|
|
|
|
return (
|
|
<Toggle.Item
|
|
key={lang.code2}
|
|
name={lang.code2}
|
|
label={name}
|
|
style={[
|
|
t.atoms.border_contrast_low,
|
|
!isLastItem && a.border_b,
|
|
a.rounded_0,
|
|
a.px_0,
|
|
a.py_md,
|
|
]}>
|
|
<Toggle.LabelText style={[a.flex_1]}>{name}</Toggle.LabelText>
|
|
<Toggle.Checkbox />
|
|
</Toggle.Item>
|
|
)
|
|
}}
|
|
footer={
|
|
<Dialog.FlatListFooter
|
|
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
|
|
<Button
|
|
label={_(msg`Close dialog`)}
|
|
onPress={handleClose}
|
|
color="primary"
|
|
size="large">
|
|
<ButtonText>
|
|
<Trans>Done</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
</Dialog.FlatListFooter>
|
|
}
|
|
/>
|
|
</Toggle.Group>
|
|
)
|
|
}
|
|
|
|
function DialogError({details}: {details?: string}) {
|
|
const {_} = useLingui()
|
|
const control = Dialog.useDialogContext()
|
|
|
|
return (
|
|
<Dialog.ScrollableInner
|
|
style={a.gap_md}
|
|
label={_(msg`An error has occurred`)}>
|
|
<Dialog.Close />
|
|
<ErrorScreen
|
|
title={_(msg`Oh no!`)}
|
|
message={_(
|
|
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
|
)}
|
|
details={details}
|
|
/>
|
|
<Button
|
|
label={_(msg`Close dialog`)}
|
|
onPress={() => control.close()}
|
|
color="primary"
|
|
size="large">
|
|
<ButtonText>
|
|
<Trans>Close</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
</Dialog.ScrollableInner>
|
|
)
|
|
}
|