about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json1
-rw-r--r--src/components/AppLanguageDropdown.tsx2
-rw-r--r--src/components/AppLanguageDropdown.web.tsx2
-rw-r--r--src/locale/deviceLocales.ts53
-rw-r--r--src/locale/helpers.ts24
-rw-r--r--src/platform/detection.ts10
-rw-r--r--src/state/persisted/index.ts10
-rw-r--r--src/state/persisted/index.web.ts13
-rw-r--r--src/state/persisted/schema.ts52
-rw-r--r--src/state/persisted/util.ts51
-rw-r--r--src/state/session/__tests__/session-test.ts4
-rw-r--r--src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx21
-rw-r--r--src/view/com/modals/lang-settings/PostLanguagesSettings.tsx23
-rw-r--r--yarn.lock27
14 files changed, 240 insertions, 53 deletions
diff --git a/package.json b/package.json
index 09985de02..b2356eb75 100644
--- a/package.json
+++ b/package.json
@@ -110,6 +110,7 @@
     "await-lock": "^2.2.2",
     "babel-plugin-transform-remove-console": "^6.9.4",
     "base64-js": "^1.5.1",
+    "bcp-47": "^2.1.0",
     "bcp-47-match": "^2.0.3",
     "date-fns": "^2.30.0",
     "deprecated-react-native-prop-types": "^5.0.0",
diff --git a/src/components/AppLanguageDropdown.tsx b/src/components/AppLanguageDropdown.tsx
index 02cd0ce2d..6170ab2e2 100644
--- a/src/components/AppLanguageDropdown.tsx
+++ b/src/components/AppLanguageDropdown.tsx
@@ -24,8 +24,6 @@ export function AppLanguageDropdown() {
       if (sanitizedLang !== value) {
         setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
       }
-      setLangPrefs.setPrimaryLanguage(value)
-      setLangPrefs.setContentLanguage(value)
 
       // reset feeds to refetch content
       resetPostsFeedQueries(queryClient)
diff --git a/src/components/AppLanguageDropdown.web.tsx b/src/components/AppLanguageDropdown.web.tsx
index a106d9966..00a7b5301 100644
--- a/src/components/AppLanguageDropdown.web.tsx
+++ b/src/components/AppLanguageDropdown.web.tsx
@@ -27,8 +27,6 @@ export function AppLanguageDropdown() {
       if (sanitizedLang !== value) {
         setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
       }
-      setLangPrefs.setPrimaryLanguage(value)
-      setLangPrefs.setContentLanguage(value)
 
       // reset feeds to refetch content
       resetPostsFeedQueries(queryClient)
diff --git a/src/locale/deviceLocales.ts b/src/locale/deviceLocales.ts
new file mode 100644
index 000000000..9e19e372b
--- /dev/null
+++ b/src/locale/deviceLocales.ts
@@ -0,0 +1,53 @@
+import {getLocales as defaultGetLocales, Locale} from 'expo-localization'
+
+import {dedupArray} from '#/lib/functions'
+
+type LocalWithLanguageCode = Locale & {
+  languageCode: string
+}
+
+/**
+ * Normalized locales
+ *
+ * Handles legacy migration for Java devices.
+ *
+ * {@link https://github.com/bluesky-social/social-app/pull/4461}
+ * {@link https://xml.coverpages.org/iso639a.html}
+ */
+export function getLocales() {
+  const locales = defaultGetLocales?.() ?? []
+  const output: LocalWithLanguageCode[] = []
+
+  for (const locale of locales) {
+    if (typeof locale.languageCode === 'string') {
+      if (locale.languageCode === 'in') {
+        // indonesian
+        locale.languageCode = 'id'
+      }
+      if (locale.languageCode === 'iw') {
+        // hebrew
+        locale.languageCode = 'he'
+      }
+      if (locale.languageCode === 'ji') {
+        // yiddish
+        locale.languageCode = 'yi'
+      }
+
+      // @ts-ignore checked above
+      output.push(locale)
+    }
+  }
+
+  return output
+}
+
+export const deviceLocales = getLocales()
+
+/**
+ * BCP-47 language tag without region e.g. array of 2-char lang codes
+ *
+ * {@link https://docs.expo.dev/versions/latest/sdk/localization/#locale}
+ */
+export const deviceLanguageCodes = dedupArray(
+  deviceLocales.map(l => l.languageCode),
+)
diff --git a/src/locale/helpers.ts b/src/locale/helpers.ts
index 3bae45214..a7517eae9 100644
--- a/src/locale/helpers.ts
+++ b/src/locale/helpers.ts
@@ -160,8 +160,13 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
   return AppLanguage.en
 }
 
+/**
+ * Handles legacy migration for Java devices.
+ *
+ * {@link https://github.com/bluesky-social/social-app/pull/4461}
+ * {@link https://xml.coverpages.org/iso639a.html}
+ */
 export function fixLegacyLanguageCode(code: string | null): string | null {
-  // handle some legacy code conversions, see https://xml.coverpages.org/iso639a.html
   if (code === 'in') {
     // indonesian
     return 'id'
@@ -176,3 +181,20 @@ export function fixLegacyLanguageCode(code: string | null): string | null {
   }
   return code
 }
+
+/**
+ * Find the first language supported by our translation infra. Values should be
+ * in order of preference, and match the values of {@link AppLanguage}.
+ *
+ * If no match, returns `en`.
+ */
+export function findSupportedAppLanguage(languageTags: (string | undefined)[]) {
+  const supported = new Set(Object.values(AppLanguage))
+  for (const tag of languageTags) {
+    if (!tag) continue
+    if (supported.has(tag as AppLanguage)) {
+      return tag
+    }
+  }
+  return AppLanguage.en
+}
diff --git a/src/platform/detection.ts b/src/platform/detection.ts
index c62ae71aa..dc30c2fd3 100644
--- a/src/platform/detection.ts
+++ b/src/platform/detection.ts
@@ -1,8 +1,4 @@
 import {Platform} from 'react-native'
-import {getLocales} from 'expo-localization'
-
-import {fixLegacyLanguageCode} from '#/locale/helpers'
-import {dedupArray} from 'lib/functions'
 
 export const isIOS = Platform.OS === 'ios'
 export const isAndroid = Platform.OS === 'android'
@@ -15,9 +11,3 @@ export const isMobileWeb =
   // @ts-ignore we know window exists -prf
   global.window.matchMedia(isMobileWebMediaQuery)?.matches
 export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
-
-export const deviceLocales = dedupArray(
-  getLocales?.()
-    .map?.(locale => fixLegacyLanguageCode(locale.languageCode))
-    .filter(code => typeof code === 'string'),
-) as string[]
diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts
index 6f4beae2c..51d757ad8 100644
--- a/src/state/persisted/index.ts
+++ b/src/state/persisted/index.ts
@@ -8,6 +8,7 @@ import {
   tryStringify,
 } from '#/state/persisted/schema'
 import {PersistedApi} from './types'
+import {normalizeData} from './util'
 
 export type {PersistedAccount, Schema} from '#/state/persisted/schema'
 export {defaults} from '#/state/persisted/schema'
@@ -33,10 +34,10 @@ export async function write<K extends keyof Schema>(
   key: K,
   value: Schema[K],
 ): Promise<void> {
-  _state = {
+  _state = normalizeData({
     ..._state,
     [key]: value,
-  }
+  })
   await writeToStorage(_state)
 }
 write satisfies PersistedApi['write']
@@ -81,6 +82,9 @@ async function readFromStorage(): Promise<Schema | undefined> {
     })
   }
   if (rawData) {
-    return tryParse(rawData)
+    const parsed = tryParse(rawData)
+    if (parsed) {
+      return normalizeData(parsed)
+    }
   }
 }
diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts
index 7521776bc..4cfc87cdb 100644
--- a/src/state/persisted/index.web.ts
+++ b/src/state/persisted/index.web.ts
@@ -9,6 +9,7 @@ import {
   tryStringify,
 } from '#/state/persisted/schema'
 import {PersistedApi} from './types'
+import {normalizeData} from './util'
 
 export type {PersistedAccount, Schema} from '#/state/persisted/schema'
 export {defaults} from '#/state/persisted/schema'
@@ -56,10 +57,10 @@ export async function write<K extends keyof Schema>(
   } catch (e) {
     // Ignore and go through the normal path.
   }
-  _state = {
+  _state = normalizeData({
     ..._state,
     [key]: value,
-  }
+  })
   writeToStorage(_state)
   broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
   broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
@@ -140,9 +141,11 @@ function readFromStorage(): Schema | undefined {
       return lastResult
     } else {
       const result = tryParse(rawData)
-      lastRawData = rawData
-      lastResult = result
-      return result
+      if (result) {
+        lastRawData = rawData
+        lastResult = normalizeData(result)
+        return lastResult
+      }
     }
   }
 }
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 331a111a2..804017949 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -1,7 +1,8 @@
 import {z} from 'zod'
 
+import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales'
+import {findSupportedAppLanguage} from '#/locale/helpers'
 import {logger} from '#/logger'
-import {deviceLocales} from '#/platform/detection'
 import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
 
 const externalEmbedOptions = ['show', 'hide'] as const
@@ -55,10 +56,39 @@ const schema = z.object({
     lastEmailConfirm: z.string().optional(),
   }),
   languagePrefs: z.object({
-    primaryLanguage: z.string(), // should move to server
-    contentLanguages: z.array(z.string()), // should move to server
-    postLanguage: z.string(), // should move to server
+    /**
+     * The target language for translating posts.
+     *
+     * BCP-47 2-letter language code without region.
+     */
+    primaryLanguage: z.string(),
+    /**
+     * The languages the user can read, passed to feeds.
+     *
+     * BCP-47 2-letter language codes without region.
+     */
+    contentLanguages: z.array(z.string()),
+    /**
+     * The language(s) the user is currently posting in, configured within the
+     * composer. Multiple languages are psearate by commas.
+     *
+     * BCP-47 2-letter language code without region.
+     */
+    postLanguage: z.string(),
+    /**
+     * The user's post language history, used to pre-populate the post language
+     * selector in the composer. Within each value, multiple languages are
+     * separated by values.
+     *
+     * BCP-47 2-letter language codes without region.
+     */
     postLanguageHistory: z.array(z.string()),
+    /**
+     * The language for UI translations in the app.
+     *
+     * BCP-47 2-letter language code with or without region,
+     * to match with {@link AppLanguage}.
+     */
     appLanguage: z.string(),
   }),
   requireAltTextEnabled: z.boolean(), // should move to server
@@ -108,13 +138,17 @@ export const defaults: Schema = {
     lastEmailConfirm: undefined,
   },
   languagePrefs: {
-    primaryLanguage: deviceLocales[0] || 'en',
-    contentLanguages: deviceLocales || [],
-    postLanguage: deviceLocales[0] || 'en',
-    postLanguageHistory: (deviceLocales || [])
+    primaryLanguage: deviceLanguageCodes[0] || 'en',
+    contentLanguages: deviceLanguageCodes || [],
+    postLanguage: deviceLanguageCodes[0] || 'en',
+    postLanguageHistory: (deviceLanguageCodes || [])
       .concat(['en', 'ja', 'pt', 'de'])
       .slice(0, 6),
-    appLanguage: deviceLocales[0] || 'en',
+    // try full language tag first, then fallback to language code
+    appLanguage: findSupportedAppLanguage([
+      deviceLocales.at(0)?.languageTag,
+      deviceLanguageCodes[0],
+    ]),
   },
   requireAltTextEnabled: false,
   largeAltBadgeEnabled: false,
diff --git a/src/state/persisted/util.ts b/src/state/persisted/util.ts
new file mode 100644
index 000000000..64a8bf945
--- /dev/null
+++ b/src/state/persisted/util.ts
@@ -0,0 +1,51 @@
+import {parse} from 'bcp-47'
+
+import {dedupArray} from '#/lib/functions'
+import {logger} from '#/logger'
+import {Schema} from '#/state/persisted/schema'
+
+export function normalizeData(data: Schema) {
+  const next = {...data}
+
+  /**
+   * Normalize language prefs to ensure that these values only contain 2-letter
+   * country codes without region.
+   */
+  try {
+    const langPrefs = {...next.languagePrefs}
+    langPrefs.primaryLanguage = normalizeLanguageTagToTwoLetterCode(
+      langPrefs.primaryLanguage,
+    )
+    langPrefs.contentLanguages = dedupArray(
+      langPrefs.contentLanguages.map(lang =>
+        normalizeLanguageTagToTwoLetterCode(lang),
+      ),
+    )
+    langPrefs.postLanguage = langPrefs.postLanguage
+      .split(',')
+      .map(lang => normalizeLanguageTagToTwoLetterCode(lang))
+      .filter(Boolean)
+      .join(',')
+    langPrefs.postLanguageHistory = dedupArray(
+      langPrefs.postLanguageHistory.map(postLanguage => {
+        return postLanguage
+          .split(',')
+          .map(lang => normalizeLanguageTagToTwoLetterCode(lang))
+          .filter(Boolean)
+          .join(',')
+      }),
+    )
+    next.languagePrefs = langPrefs
+  } catch (e: any) {
+    logger.error(`persisted state: failed to normalize language prefs`, {
+      safeMessage: e.message,
+    })
+  }
+
+  return next
+}
+
+export function normalizeLanguageTagToTwoLetterCode(lang: string) {
+  const result = parse(lang).language
+  return result ?? lang
+}
diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts
index 3e22c262c..44c5cf934 100644
--- a/src/state/session/__tests__/session-test.ts
+++ b/src/state/session/__tests__/session-test.ts
@@ -10,6 +10,10 @@ jest.mock('jwt-decode', () => ({
   },
 }))
 
+jest.mock('expo-localization', () => ({
+  getLocales: () => [],
+}))
+
 describe('session', () => {
   it('can log in and out', () => {
     let state = getInitialState([])
diff --git a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
index b8c125b65..017b59db9 100644
--- a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
+++ b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
@@ -1,19 +1,20 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {ScrollView} from '../util'
-import {Text} from '../../util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {deviceLocales} from 'platform/detection'
-import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
-import {LanguageToggle} from './LanguageToggle'
-import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
 import {Trans} from '@lingui/macro'
+
+import {deviceLanguageCodes} from '#/locale/deviceLocales'
 import {useModalControls} from '#/state/modals'
 import {
   useLanguagePrefs,
   useLanguagePrefsApi,
 } from '#/state/preferences/languages'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
+import {Text} from '../../util/text/Text'
+import {ScrollView} from '../util'
+import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
+import {LanguageToggle} from './LanguageToggle'
 
 export const snapPoints = ['100%']
 
@@ -37,10 +38,10 @@ export function Component({}: {}) {
     langs.sort((a, b) => {
       const hasA =
         langPrefs.contentLanguages.includes(a.code2) ||
-        deviceLocales.includes(a.code2)
+        deviceLanguageCodes.includes(a.code2)
       const hasB =
         langPrefs.contentLanguages.includes(b.code2) ||
-        deviceLocales.includes(b.code2)
+        deviceLanguageCodes.includes(b.code2)
       if (hasA === hasB) return a.name.localeCompare(b.name)
       if (hasA) return -1
       return 1
diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
index 05cfb8115..a20458702 100644
--- a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
+++ b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
@@ -1,20 +1,21 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {ScrollView} from '../util'
-import {Text} from '../../util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {deviceLocales} from 'platform/detection'
-import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
-import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
-import {ToggleButton} from 'view/com/util/forms/ToggleButton'
 import {Trans} from '@lingui/macro'
+
+import {deviceLanguageCodes} from '#/locale/deviceLocales'
 import {useModalControls} from '#/state/modals'
 import {
+  hasPostLanguage,
   useLanguagePrefs,
   useLanguagePrefsApi,
-  hasPostLanguage,
 } from '#/state/preferences/languages'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+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%']
 
@@ -38,10 +39,10 @@ export function Component() {
     langs.sort((a, b) => {
       const hasA =
         hasPostLanguage(langPrefs.postLanguage, a.code2) ||
-        deviceLocales.includes(a.code2)
+        deviceLanguageCodes.includes(a.code2)
       const hasB =
         hasPostLanguage(langPrefs.postLanguage, b.code2) ||
-        deviceLocales.includes(b.code2)
+        deviceLanguageCodes.includes(b.code2)
       if (hasA === hasB) return a.name.localeCompare(b.name)
       if (hasA) return -1
       return 1
diff --git a/yarn.lock b/yarn.lock
index 98479ba44..380e7e4f1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9572,6 +9572,15 @@ bcp-47-match@^2.0.3:
   resolved "https://registry.yarnpkg.com/bcp-47-match/-/bcp-47-match-2.0.3.tgz#603226f6e5d3914a581408be33b28a53144b09d0"
   integrity sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==
 
+bcp-47@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/bcp-47/-/bcp-47-2.1.0.tgz#7e80734c3338fe8320894981dccf4968c3092df6"
+  integrity sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==
+  dependencies:
+    is-alphabetical "^2.0.0"
+    is-alphanumerical "^2.0.0"
+    is-decimal "^2.0.0"
+
 better-opn@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817"
@@ -13893,6 +13902,19 @@ ipaddr.js@^2.1.0:
   resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8"
   integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==
 
+is-alphabetical@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz#01072053ea7c1036df3c7d19a6daaec7f19e789b"
+  integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==
+
+is-alphanumerical@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz#7c03fbe96e3e931113e57f964b0a368cc2dfd875"
+  integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==
+  dependencies:
+    is-alphabetical "^2.0.0"
+    is-decimal "^2.0.0"
+
 is-arguments@^1.0.4:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
@@ -13987,6 +14009,11 @@ is-date-object@^1.0.1, is-date-object@^1.0.5:
   dependencies:
     has-tostringtag "^1.0.0"
 
+is-decimal@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-2.0.1.tgz#9469d2dc190d0214fd87d78b78caecc0cc14eef7"
+  integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==
+
 is-directory@^0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1"