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:
Samuel Newman
2025-08-27 19:00:36 +03:00
committed by GitHub
parent eac0290143
commit 39d460db51
14 changed files with 489 additions and 747 deletions
+2 -2
View File
@@ -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>
)
}