diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-08-27 19:00:36 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-08-27 09:00:36 -0700 |
commit | 39d460db510d6545794f6acba8226fb52b506b40 (patch) | |
tree | b3ca00a3dfb4c790fb78369942c122cee79ba9d0 /src/view/com/composer/select-language | |
parent | eac02901435d7bc79a28e0bff665352b814f9508 (diff) | |
download | voidsky-39d460db510d6545794f6acba8226fb52b506b40.tar.zst |
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>
Diffstat (limited to 'src/view/com/composer/select-language')
-rw-r--r-- | src/view/com/composer/select-language/SelectLangBtn.tsx | 133 | ||||
-rw-r--r-- | src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx | 382 |
2 files changed, 382 insertions, 133 deletions
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx deleted file mode 100644 index f487b1244..000000000 --- a/src/view/com/composer/select-language/SelectLangBtn.tsx +++ /dev/null @@ -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, - }, -}) diff --git a/src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx b/src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx new file mode 100644 index 000000000..c8ecc2b89 --- /dev/null +++ b/src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx @@ -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> + ) +} |