Language select final tweaks (#8914)
* [APP-1303] Redesign/refactor post language select (#8884) * Nightly source-language update * Nightly source-language update * [APP-1303] Redesign/refactor post language select * update: stylesheets.create to use the latest structure * update styles to modern structure * update: dialog breakpoints on web and delete depricated language modals * remove unused post languages settings dialog * restructure Post languages dialog * place the Dialog.Close inside the Dialog.ScrollableInner * add: language search * update search and language variables for clarity * fix: memoize language state lists * chore: add comments * update proper colors to the background * add back older error boundary * add: tweaks to the mobile and web responsiveness * add tweaks to center the container * update labels * update button and border * added translation updates * Update: text input to reuse search input * remove unused file * update: web breakpoints * run eslint and prettier --------- Co-authored-by: Elijah Seed-Arita <elijaharita@gmail.com> Co-authored-by: Anastasiya Uraleva <anastasiya@Anastasiyas-MacBook-Pro.local> Co-authored-by: Anastasiya Uraleva <anastasiya@Mac.localdomain> * rm old file * sort out styles, add FlatListFooter component * rm cancel button in favor of search input X * get dialog height working on iOS * delete `DropdownButton` * hide scroll indicators on android * ios scroll indicator insets * get footer sorta working on android * change button color on press * rm empty file --------- Co-authored-by: Anastasiya Uraleva <anastasiyauraleva@gmail.com> Co-authored-by: Elijah Seed-Arita <elijaharita@gmail.com> Co-authored-by: Anastasiya Uraleva <anastasiya@Anastasiyas-MacBook-Pro.local> Co-authored-by: Anastasiya Uraleva <anastasiya@Mac.localdomain>
This commit is contained in:
@@ -110,7 +110,7 @@ import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
|
||||
import {Gallery} from '#/view/com/composer/photos/Gallery'
|
||||
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
|
||||
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
|
||||
import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
|
||||
import {SelectPostLanguagesBtn} from '#/view/com/composer/select-language/SelectPostLanguagesDialog'
|
||||
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
||||
// TODO: Prevent naming components that coincide with RN primitives
|
||||
// due to linting false positives
|
||||
@@ -1453,7 +1453,7 @@ function ComposerFooter({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<SelectLangBtn />
|
||||
<SelectPostLanguagesBtn />
|
||||
<CharProgress
|
||||
count={post.shortenedGraphemeLength}
|
||||
style={{width: 65}}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {Keyboard, StyleSheet} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {
|
||||
hasPostLanguage,
|
||||
toPostLanguages,
|
||||
useLanguagePrefs,
|
||||
useLanguagePrefsApi,
|
||||
} from '#/state/preferences/languages'
|
||||
import {
|
||||
DropdownButton,
|
||||
DropdownItem,
|
||||
DropdownItemButton,
|
||||
} from '#/view/com/util/forms/DropdownButton'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {codeToLanguageName} from '../../../../locale/helpers'
|
||||
|
||||
export function SelectLangBtn() {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
|
||||
const onPressMore = useCallback(async () => {
|
||||
if (isNative) {
|
||||
if (Keyboard.isVisible()) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
}
|
||||
openModal({name: 'post-languages-settings'})
|
||||
}, [openModal])
|
||||
|
||||
const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
|
||||
const items: DropdownItem[] = useMemo(() => {
|
||||
let arr: DropdownItemButton[] = []
|
||||
|
||||
function add(commaSeparatedLangCodes: string) {
|
||||
const langCodes = commaSeparatedLangCodes.split(',')
|
||||
const langName = langCodes
|
||||
.map(code => codeToLanguageName(code, langPrefs.appLanguage))
|
||||
.join(' + ')
|
||||
|
||||
/*
|
||||
* Filter out any duplicates
|
||||
*/
|
||||
if (arr.find((item: DropdownItemButton) => item.label === langName)) {
|
||||
return
|
||||
}
|
||||
|
||||
arr.push({
|
||||
icon:
|
||||
langCodes.every(code =>
|
||||
hasPostLanguage(langPrefs.postLanguage, code),
|
||||
) && langCodes.length === postLanguagesPref.length
|
||||
? ['fas', 'circle-dot']
|
||||
: ['far', 'circle'],
|
||||
label: langName,
|
||||
onPress() {
|
||||
setLangPrefs.setPostLanguage(commaSeparatedLangCodes)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (postLanguagesPref.length) {
|
||||
/*
|
||||
* Re-join here after sanitization bc postLanguageHistory is an array of
|
||||
* comma-separated strings too
|
||||
*/
|
||||
add(langPrefs.postLanguage)
|
||||
}
|
||||
|
||||
// comma-separted strings of lang codes that have been used in the past
|
||||
for (const lang of langPrefs.postLanguageHistory) {
|
||||
add(lang)
|
||||
}
|
||||
|
||||
return [
|
||||
{heading: true, label: _(msg`Post language`)},
|
||||
...arr.slice(0, 6),
|
||||
{sep: true},
|
||||
{
|
||||
label: _(msg`Other...`),
|
||||
onPress: onPressMore,
|
||||
},
|
||||
]
|
||||
}, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref, _])
|
||||
|
||||
return (
|
||||
<DropdownButton
|
||||
type="bare"
|
||||
testID="selectLangBtn"
|
||||
items={items}
|
||||
openUpwards
|
||||
style={styles.button}
|
||||
hitSlop={LANG_DROPDOWN_HITSLOP}
|
||||
accessibilityLabel={_(msg`Language selection`)}
|
||||
accessibilityHint="">
|
||||
{postLanguagesPref.length > 0 ? (
|
||||
<Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}>
|
||||
{postLanguagesPref
|
||||
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
|
||||
.join(', ')}
|
||||
</Text>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon="language"
|
||||
style={pal.link as FontAwesomeIconStyle}
|
||||
size={26}
|
||||
/>
|
||||
)}
|
||||
</DropdownButton>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
marginHorizontal: 15,
|
||||
},
|
||||
label: {
|
||||
maxWidth: 100,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,382 @@
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {Keyboard, useWindowDimensions, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants'
|
||||
import {languageName} from '#/locale/helpers'
|
||||
import {codeToLanguageName} from '#/locale/helpers'
|
||||
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
toPostLanguages,
|
||||
useLanguagePrefs,
|
||||
useLanguagePrefsApi,
|
||||
} from '#/state/preferences/languages'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {atoms as a, 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 {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function SelectPostLanguagesBtn() {
|
||||
const {_} = useLingui()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const t = useTheme()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
const onPressMore = useCallback(async () => {
|
||||
if (isNative) {
|
||||
if (Keyboard.isVisible()) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
}
|
||||
control.open()
|
||||
}, [control])
|
||||
|
||||
const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
testID="selectLangBtn"
|
||||
onPress={onPressMore}
|
||||
size="small"
|
||||
hitSlop={LANG_DROPDOWN_HITSLOP}
|
||||
label={_(
|
||||
msg({
|
||||
message: `Post language selection`,
|
||||
comment: `Accessibility label for button that opens dialog to choose post language settings`,
|
||||
}),
|
||||
)}
|
||||
accessibilityHint={_(msg`Opens post language settings`)}
|
||||
style={[a.mx_md]}>
|
||||
{({pressed, hovered, focused}) => {
|
||||
const color =
|
||||
pressed || hovered || focused
|
||||
? t.palette.primary_300
|
||||
: t.palette.primary_500
|
||||
if (postLanguagesPref.length > 0) {
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{color},
|
||||
a.font_bold,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
{maxWidth: 100},
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{postLanguagesPref
|
||||
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
|
||||
.join(', ')}
|
||||
</Text>
|
||||
)
|
||||
} else {
|
||||
return <GlobeIcon size="xs" style={{color}} />
|
||||
}
|
||||
}}
|
||||
</Button>
|
||||
|
||||
<LanguageDialog control={control} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LanguageDialog({control}: {control: Dialog.DialogControlProps}) {
|
||||
const {height} = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const renderErrorBoundary = useCallback(
|
||||
(error: any) => <DialogError details={String(error)} />,
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={{minHeight: height - insets.top}}>
|
||||
<Dialog.Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
<PostLanguagesSettingsDialogInner />
|
||||
</ErrorBoundary>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostLanguagesSettingsDialogInner() {
|
||||
const control = Dialog.useDialogContext()
|
||||
const [headerHeight, setHeaderHeight] = 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[]>(
|
||||
langPrefs.postLanguage.split(',') || [langPrefs.primaryLanguage],
|
||||
)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const handleClose = () => {
|
||||
control.close(() => {
|
||||
let langsString = checkedLanguagesCode2.join(',')
|
||||
if (!langsString) {
|
||||
langsString = langPrefs.primaryLanguage
|
||||
}
|
||||
setLangPrefs.setPostLanguage(langsString)
|
||||
})
|
||||
}
|
||||
|
||||
// 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 matchesSearch = (lang: Language) =>
|
||||
lang.name.toLowerCase().includes(search.toLowerCase())
|
||||
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,
|
||||
])
|
||||
|
||||
const listHeader = (
|
||||
<View
|
||||
style={[a.pb_xs, t.atoms.bg, isNative && 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_bold,
|
||||
a.text_xl,
|
||||
a.mb_sm,
|
||||
]}>
|
||||
<Trans>Choose Post Languages</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
nativeID="dialog-description"
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_left,
|
||||
a.text_md,
|
||||
a.mb_lg,
|
||||
]}>
|
||||
<Trans>Select up to 3 languages used in this post</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isWeb && (
|
||||
<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})),
|
||||
]
|
||||
|
||||
return (
|
||||
<Toggle.Group
|
||||
values={checkedLanguagesCode2}
|
||||
onChange={setCheckedLanguagesCode2}
|
||||
type="checkbox"
|
||||
maxSelections={3}
|
||||
label={_(msg`Select languages`)}
|
||||
style={web([a.contents])}>
|
||||
<Dialog.InnerFlatList
|
||||
data={flatListData}
|
||||
ListHeaderComponent={listHeader}
|
||||
stickyHeaderIndices={[0]}
|
||||
contentContainerStyle={[a.gap_0]}
|
||||
style={[isNative && a.px_lg, web({paddingBottom: 120})]}
|
||||
scrollIndicatorInsets={{top: headerHeight}}
|
||||
renderItem={({item, index}) => {
|
||||
if (item.type === 'header') {
|
||||
return (
|
||||
<Text
|
||||
key={index}
|
||||
style={[
|
||||
a.px_0,
|
||||
a.py_md,
|
||||
a.font_bold,
|
||||
a.text_xs,
|
||||
t.atoms.text_contrast_low,
|
||||
a.pt_3xl,
|
||||
]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
const lang = item.lang
|
||||
|
||||
return (
|
||||
<Toggle.Item
|
||||
key={lang.code2}
|
||||
name={lang.code2}
|
||||
label={languageName(lang, langPrefs.appLanguage)}
|
||||
style={[
|
||||
t.atoms.border_contrast_low,
|
||||
a.border_b,
|
||||
a.rounded_0,
|
||||
a.px_0,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Toggle.LabelText style={[a.flex_1]}>
|
||||
{languageName(lang, langPrefs.appLanguage)}
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Checkbox />
|
||||
</Toggle.Item>
|
||||
)
|
||||
}}
|
||||
footer={
|
||||
<Dialog.FlatListFooter>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import * as CreateOrEditListModal from './CreateOrEditList'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
import * as InviteCodesModal from './InviteCodes'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
|
||||
|
||||
const DEFAULT_SNAPPOINTS = ['90%']
|
||||
@@ -60,9 +59,6 @@ export function ModalsContainer() {
|
||||
} else if (activeModal?.name === 'content-languages-settings') {
|
||||
snapPoints = ContentLanguagesSettingsModal.snapPoints
|
||||
element = <ContentLanguagesSettingsModal.Component />
|
||||
} else if (activeModal?.name === 'post-languages-settings') {
|
||||
snapPoints = PostLanguagesSettingsModal.snapPoints
|
||||
element = <PostLanguagesSettingsModal.Component />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import * as CreateOrEditListModal from './CreateOrEditList'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
import * as InviteCodesModal from './InviteCodes'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||
import * as UserAddRemoveLists from './UserAddRemoveLists'
|
||||
|
||||
export function ModalsContainer() {
|
||||
@@ -59,8 +58,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
element = <InviteCodesModal.Component />
|
||||
} else if (modal.name === 'content-languages-settings') {
|
||||
element = <ContentLanguagesSettingsModal.Component />
|
||||
} else if (modal.name === 'post-languages-settings') {
|
||||
element = <PostLanguagesSettingsModal.Component />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {deviceLanguageCodes} from '#/locale/deviceLocales'
|
||||
import {languageName} from '#/locale/helpers'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {
|
||||
hasPostLanguage,
|
||||
useLanguagePrefs,
|
||||
useLanguagePrefsApi,
|
||||
} from '#/state/preferences/languages'
|
||||
import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
|
||||
import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
|
||||
import {Text} from '../../util/text/Text'
|
||||
import {ScrollView} from '../util'
|
||||
import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
|
||||
|
||||
export const snapPoints = ['100%']
|
||||
|
||||
export function Component() {
|
||||
const {closeModal} = useModalControls()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const pal = usePalette('default')
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const onPressDone = React.useCallback(() => {
|
||||
closeModal()
|
||||
}, [closeModal])
|
||||
|
||||
const languages = React.useMemo(() => {
|
||||
const langs = LANGUAGES.filter(
|
||||
lang =>
|
||||
!!lang.code2.trim() &&
|
||||
LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3,
|
||||
)
|
||||
// sort so that device & selected languages are on top, then alphabetically
|
||||
langs.sort((a, b) => {
|
||||
const hasA =
|
||||
hasPostLanguage(langPrefs.postLanguage, a.code2) ||
|
||||
deviceLanguageCodes.includes(a.code2)
|
||||
const hasB =
|
||||
hasPostLanguage(langPrefs.postLanguage, b.code2) ||
|
||||
deviceLanguageCodes.includes(b.code2)
|
||||
if (hasA === hasB) return a.name.localeCompare(b.name)
|
||||
if (hasA) return -1
|
||||
return 1
|
||||
})
|
||||
return langs
|
||||
}, [langPrefs])
|
||||
|
||||
const onPress = React.useCallback(
|
||||
(code2: string) => {
|
||||
setLangPrefs.togglePostLanguage(code2)
|
||||
},
|
||||
[setLangPrefs],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="postLanguagesModal"
|
||||
style={[
|
||||
pal.view,
|
||||
styles.container,
|
||||
// @ts-ignore vh is on web only
|
||||
isMobile
|
||||
? {
|
||||
paddingTop: 20,
|
||||
}
|
||||
: {
|
||||
maxHeight: '90vh',
|
||||
},
|
||||
]}>
|
||||
<Text style={[pal.text, styles.title]}>
|
||||
<Trans>Post Languages</Trans>
|
||||
</Text>
|
||||
<Text style={[pal.text, styles.description]}>
|
||||
<Trans>Which languages are used in this post?</Trans>
|
||||
</Text>
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
{languages.map(lang => {
|
||||
const isSelected = hasPostLanguage(langPrefs.postLanguage, lang.code2)
|
||||
|
||||
// enforce a max of 3 selections for post languages
|
||||
let isDisabled = false
|
||||
if (langPrefs.postLanguage.split(',').length >= 3 && !isSelected) {
|
||||
isDisabled = true
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
key={lang.code2}
|
||||
label={languageName(lang, langPrefs.appLanguage)}
|
||||
isSelected={isSelected}
|
||||
onPress={() => (isDisabled ? undefined : onPress(lang.code2))}
|
||||
style={[
|
||||
pal.border,
|
||||
styles.languageToggle,
|
||||
isDisabled && styles.dimmed,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<View
|
||||
style={{
|
||||
height: isMobile ? 60 : 0,
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
<ConfirmLanguagesButton onPress={onPressDone} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
fontSize: 24,
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 10,
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
languageToggle: {
|
||||
borderTopWidth: 1,
|
||||
borderRadius: 0,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
dimmed: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
})
|
||||
@@ -1,397 +0,0 @@
|
||||
import {type PropsWithChildren} from 'react'
|
||||
import {useMemo, useRef} from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
type GestureResponderEvent,
|
||||
type Insets,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {FadeIn, FadeInDown, FadeInUp} from 'react-native-reanimated'
|
||||
import RootSiblings from 'react-native-root-siblings'
|
||||
import {type IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type React from 'react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {colors} from '#/lib/styles'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {native} from '#/alf'
|
||||
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
||||
import {Text} from '../text/Text'
|
||||
import {Button, type ButtonType} from './Button'
|
||||
|
||||
const ESTIMATED_BTN_HEIGHT = 50
|
||||
const ESTIMATED_SEP_HEIGHT = 16
|
||||
const ESTIMATED_HEADING_HEIGHT = 60
|
||||
|
||||
export interface DropdownItemButton {
|
||||
testID?: string
|
||||
icon?: IconProp
|
||||
label: string
|
||||
onPress: () => void
|
||||
}
|
||||
export interface DropdownItemSeparator {
|
||||
sep: true
|
||||
}
|
||||
export interface DropdownItemHeading {
|
||||
heading: true
|
||||
label: string
|
||||
}
|
||||
export type DropdownItem =
|
||||
| DropdownItemButton
|
||||
| DropdownItemSeparator
|
||||
| DropdownItemHeading
|
||||
type MaybeDropdownItem = DropdownItem | false | undefined
|
||||
|
||||
export type DropdownButtonType = ButtonType | 'bare'
|
||||
|
||||
interface DropdownButtonProps {
|
||||
testID?: string
|
||||
type?: DropdownButtonType
|
||||
style?: StyleProp<ViewStyle>
|
||||
items: MaybeDropdownItem[]
|
||||
label?: string
|
||||
menuWidth?: number
|
||||
children?: React.ReactNode
|
||||
openToRight?: boolean
|
||||
openUpwards?: boolean
|
||||
rightOffset?: number
|
||||
bottomOffset?: number
|
||||
hitSlop?: Insets
|
||||
accessibilityLabel?: string
|
||||
accessibilityHint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use Menu from `#/components/Menu.tsx` instead
|
||||
*/
|
||||
export function DropdownButton({
|
||||
testID,
|
||||
type = 'bare',
|
||||
style,
|
||||
items,
|
||||
label,
|
||||
menuWidth,
|
||||
children,
|
||||
openToRight = false,
|
||||
openUpwards = false,
|
||||
rightOffset = 0,
|
||||
bottomOffset = 0,
|
||||
hitSlop = HITSLOP_10,
|
||||
accessibilityLabel,
|
||||
}: PropsWithChildren<DropdownButtonProps>) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const ref1 = useRef<View>(null)
|
||||
const ref2 = useRef<View>(null)
|
||||
|
||||
const onPress = (e: GestureResponderEvent) => {
|
||||
const ref = ref1.current || ref2.current
|
||||
const {height: winHeight} = Dimensions.get('window')
|
||||
const pressY = e.nativeEvent.pageY
|
||||
ref?.measure(
|
||||
(
|
||||
_x: number,
|
||||
_y: number,
|
||||
width: number,
|
||||
_height: number,
|
||||
pageX: number,
|
||||
pageY: number,
|
||||
) => {
|
||||
if (!menuWidth) {
|
||||
menuWidth = 200
|
||||
}
|
||||
let estimatedMenuHeight = 0
|
||||
for (const item of items) {
|
||||
if (item && isSep(item)) {
|
||||
estimatedMenuHeight += ESTIMATED_SEP_HEIGHT
|
||||
} else if (item && isBtn(item)) {
|
||||
estimatedMenuHeight += ESTIMATED_BTN_HEIGHT
|
||||
} else if (item && isHeading(item)) {
|
||||
estimatedMenuHeight += ESTIMATED_HEADING_HEIGHT
|
||||
}
|
||||
}
|
||||
const newX = openToRight
|
||||
? pageX + width + rightOffset
|
||||
: pageX + width - menuWidth
|
||||
|
||||
// Add a bit of additional room
|
||||
let newY = pressY + bottomOffset + 20
|
||||
if (openUpwards || newY + estimatedMenuHeight > winHeight) {
|
||||
newY -= estimatedMenuHeight
|
||||
}
|
||||
createDropdownMenu(
|
||||
newX,
|
||||
newY,
|
||||
pageY,
|
||||
menuWidth,
|
||||
items.filter(v => !!v) as DropdownItem[],
|
||||
openUpwards,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const numItems = useMemo(
|
||||
() =>
|
||||
items.filter(item => {
|
||||
if (item === undefined || item === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isBtn(item)
|
||||
}).length,
|
||||
[items],
|
||||
)
|
||||
|
||||
if (type === 'bare') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID={testID}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
hitSlop={hitSlop}
|
||||
ref={ref1}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
accessibilityLabel || _(msg`Opens ${numItems} options`)
|
||||
}
|
||||
accessibilityHint="">
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View ref={ref2}>
|
||||
<Button
|
||||
type={type}
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
style={style}
|
||||
label={label}>
|
||||
{children}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function createDropdownMenu(
|
||||
x: number,
|
||||
y: number,
|
||||
pageY: number,
|
||||
width: number,
|
||||
items: DropdownItem[],
|
||||
opensUpwards = false,
|
||||
): RootSiblings {
|
||||
const onPressItem = (index: number) => {
|
||||
sibling.destroy()
|
||||
const item = items[index]
|
||||
if (isBtn(item)) {
|
||||
item.onPress()
|
||||
}
|
||||
}
|
||||
const onOuterPress = () => sibling.destroy()
|
||||
const sibling = new RootSiblings(
|
||||
(
|
||||
<DropdownItems
|
||||
onOuterPress={onOuterPress}
|
||||
x={x}
|
||||
y={y}
|
||||
pageY={pageY}
|
||||
width={width}
|
||||
items={items}
|
||||
onPressItem={onPressItem}
|
||||
openUpwards={opensUpwards}
|
||||
/>
|
||||
),
|
||||
)
|
||||
return sibling
|
||||
}
|
||||
|
||||
type DropDownItemProps = {
|
||||
onOuterPress: () => void
|
||||
x: number
|
||||
y: number
|
||||
pageY: number
|
||||
width: number
|
||||
items: DropdownItem[]
|
||||
onPressItem: (index: number) => void
|
||||
openUpwards: boolean
|
||||
}
|
||||
|
||||
const DropdownItems = ({
|
||||
onOuterPress,
|
||||
x,
|
||||
y,
|
||||
pageY,
|
||||
width,
|
||||
items,
|
||||
onPressItem,
|
||||
openUpwards,
|
||||
}: DropDownItemProps) => {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
const dropDownBackgroundColor =
|
||||
theme.colorScheme === 'dark' ? pal.btn : pal.view
|
||||
const separatorColor =
|
||||
theme.colorScheme === 'dark' ? pal.borderDark : pal.border
|
||||
|
||||
const numItems = items.filter(isBtn).length
|
||||
|
||||
// TODO: Refactor dropdown components to:
|
||||
// - (On web, if not handled by React Native) use semantic <select />
|
||||
// and <option /> elements for keyboard navigation out of the box
|
||||
// - (On mobile) be buttons by default, accept `label` and `nativeID`
|
||||
// props, and always have an explicit label
|
||||
return (
|
||||
<FullWindowOverlay>
|
||||
{/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */}
|
||||
<TouchableWithoutFeedback
|
||||
onPress={onOuterPress}
|
||||
accessibilityLabel={_(msg`Toggle dropdown`)}
|
||||
accessibilityHint="">
|
||||
<Animated.View
|
||||
entering={FadeIn}
|
||||
style={[
|
||||
styles.bg,
|
||||
// On web we need to adjust the top and bottom relative to the scroll position
|
||||
isWeb
|
||||
? {
|
||||
top: -pageY,
|
||||
bottom: pageY - screenHeight,
|
||||
}
|
||||
: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TouchableWithoutFeedback>
|
||||
<Animated.View
|
||||
entering={native(
|
||||
openUpwards ? FadeInDown.springify(1000) : FadeInUp.springify(1000),
|
||||
)}
|
||||
style={[
|
||||
styles.menu,
|
||||
{left: x, top: y, width},
|
||||
dropDownBackgroundColor,
|
||||
]}>
|
||||
{items.map((item, index) => {
|
||||
if (isBtn(item)) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID={item.testID}
|
||||
key={index}
|
||||
style={[styles.menuItem]}
|
||||
onPress={() => onPressItem(index)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={item.label}
|
||||
accessibilityHint={_(
|
||||
msg`Selects option ${index + 1} of ${numItems}`,
|
||||
)}>
|
||||
{item.icon && (
|
||||
<FontAwesomeIcon
|
||||
style={styles.icon}
|
||||
icon={item.icon}
|
||||
color={pal.text.color as string}
|
||||
/>
|
||||
)}
|
||||
<Text style={[styles.label, pal.text]}>{item.label}</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
} else if (isSep(item)) {
|
||||
return (
|
||||
<View key={index} style={[styles.separator, separatorColor]} />
|
||||
)
|
||||
} else if (isHeading(item)) {
|
||||
return (
|
||||
<View style={[styles.heading, pal.border]} key={index}>
|
||||
<Text style={[pal.text, styles.headingLabel]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Animated.View>
|
||||
</FullWindowOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
function isSep(item: DropdownItem): item is DropdownItemSeparator {
|
||||
return 'sep' in item && item.sep
|
||||
}
|
||||
function isHeading(item: DropdownItem): item is DropdownItemHeading {
|
||||
return 'heading' in item && item.heading
|
||||
}
|
||||
function isBtn(item: DropdownItem): item is DropdownItemButton {
|
||||
return !isSep(item) && !isHeading(item)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bg: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
width: '100%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
menu: {
|
||||
position: 'absolute',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingLeft: 15,
|
||||
paddingRight: 40,
|
||||
},
|
||||
menuItemBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.gray1,
|
||||
marginTop: 4,
|
||||
paddingTop: 12,
|
||||
},
|
||||
icon: {
|
||||
marginLeft: 2,
|
||||
marginRight: 8,
|
||||
flexShrink: 0,
|
||||
},
|
||||
label: {
|
||||
fontSize: 18,
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
},
|
||||
separator: {
|
||||
borderTopWidth: 1,
|
||||
marginVertical: 8,
|
||||
},
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingLeft: 15,
|
||||
paddingRight: 20,
|
||||
borderBottomWidth: 1,
|
||||
marginBottom: 6,
|
||||
},
|
||||
headingLabel: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user