about summary refs log tree commit diff
path: root/src/view/com/composer/select-language
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/composer/select-language')
-rw-r--r--src/view/com/composer/select-language/SelectLangBtn.tsx133
-rw-r--r--src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx382
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>
+  )
+}