mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Language fixes (#5384)

* Add some comments

* Decouple language settings

* Normalize on read/write

* Refactor

* Support device locale on app startup

* Cleanup, port to web

* Clean up comments

* Comment

* Try not to mutate

* Protect util handling, update test

* Dedupe array values

authored by

Eric Bailey and committed by
GitHub
fa6f6f9e cd88cbea

+240 -53
+1
package.json
··· 110 110 "await-lock": "^2.2.2", 111 111 "babel-plugin-transform-remove-console": "^6.9.4", 112 112 "base64-js": "^1.5.1", 113 + "bcp-47": "^2.1.0", 113 114 "bcp-47-match": "^2.0.3", 114 115 "date-fns": "^2.30.0", 115 116 "deprecated-react-native-prop-types": "^5.0.0",
-2
src/components/AppLanguageDropdown.tsx
··· 24 24 if (sanitizedLang !== value) { 25 25 setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value)) 26 26 } 27 - setLangPrefs.setPrimaryLanguage(value) 28 - setLangPrefs.setContentLanguage(value) 29 27 30 28 // reset feeds to refetch content 31 29 resetPostsFeedQueries(queryClient)
-2
src/components/AppLanguageDropdown.web.tsx
··· 27 27 if (sanitizedLang !== value) { 28 28 setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value)) 29 29 } 30 - setLangPrefs.setPrimaryLanguage(value) 31 - setLangPrefs.setContentLanguage(value) 32 30 33 31 // reset feeds to refetch content 34 32 resetPostsFeedQueries(queryClient)
+53
src/locale/deviceLocales.ts
··· 1 + import {getLocales as defaultGetLocales, Locale} from 'expo-localization' 2 + 3 + import {dedupArray} from '#/lib/functions' 4 + 5 + type LocalWithLanguageCode = Locale & { 6 + languageCode: string 7 + } 8 + 9 + /** 10 + * Normalized locales 11 + * 12 + * Handles legacy migration for Java devices. 13 + * 14 + * {@link https://github.com/bluesky-social/social-app/pull/4461} 15 + * {@link https://xml.coverpages.org/iso639a.html} 16 + */ 17 + export function getLocales() { 18 + const locales = defaultGetLocales?.() ?? [] 19 + const output: LocalWithLanguageCode[] = [] 20 + 21 + for (const locale of locales) { 22 + if (typeof locale.languageCode === 'string') { 23 + if (locale.languageCode === 'in') { 24 + // indonesian 25 + locale.languageCode = 'id' 26 + } 27 + if (locale.languageCode === 'iw') { 28 + // hebrew 29 + locale.languageCode = 'he' 30 + } 31 + if (locale.languageCode === 'ji') { 32 + // yiddish 33 + locale.languageCode = 'yi' 34 + } 35 + 36 + // @ts-ignore checked above 37 + output.push(locale) 38 + } 39 + } 40 + 41 + return output 42 + } 43 + 44 + export const deviceLocales = getLocales() 45 + 46 + /** 47 + * BCP-47 language tag without region e.g. array of 2-char lang codes 48 + * 49 + * {@link https://docs.expo.dev/versions/latest/sdk/localization/#locale} 50 + */ 51 + export const deviceLanguageCodes = dedupArray( 52 + deviceLocales.map(l => l.languageCode), 53 + )
+23 -1
src/locale/helpers.ts
··· 160 160 return AppLanguage.en 161 161 } 162 162 163 + /** 164 + * Handles legacy migration for Java devices. 165 + * 166 + * {@link https://github.com/bluesky-social/social-app/pull/4461} 167 + * {@link https://xml.coverpages.org/iso639a.html} 168 + */ 163 169 export function fixLegacyLanguageCode(code: string | null): string | null { 164 - // handle some legacy code conversions, see https://xml.coverpages.org/iso639a.html 165 170 if (code === 'in') { 166 171 // indonesian 167 172 return 'id' ··· 176 181 } 177 182 return code 178 183 } 184 + 185 + /** 186 + * Find the first language supported by our translation infra. Values should be 187 + * in order of preference, and match the values of {@link AppLanguage}. 188 + * 189 + * If no match, returns `en`. 190 + */ 191 + export function findSupportedAppLanguage(languageTags: (string | undefined)[]) { 192 + const supported = new Set(Object.values(AppLanguage)) 193 + for (const tag of languageTags) { 194 + if (!tag) continue 195 + if (supported.has(tag as AppLanguage)) { 196 + return tag 197 + } 198 + } 199 + return AppLanguage.en 200 + }
-10
src/platform/detection.ts
··· 1 1 import {Platform} from 'react-native' 2 - import {getLocales} from 'expo-localization' 3 - 4 - import {fixLegacyLanguageCode} from '#/locale/helpers' 5 - import {dedupArray} from 'lib/functions' 6 2 7 3 export const isIOS = Platform.OS === 'ios' 8 4 export const isAndroid = Platform.OS === 'android' ··· 15 11 // @ts-ignore we know window exists -prf 16 12 global.window.matchMedia(isMobileWebMediaQuery)?.matches 17 13 export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent) 18 - 19 - export const deviceLocales = dedupArray( 20 - getLocales?.() 21 - .map?.(locale => fixLegacyLanguageCode(locale.languageCode)) 22 - .filter(code => typeof code === 'string'), 23 - ) as string[]
+7 -3
src/state/persisted/index.ts
··· 8 8 tryStringify, 9 9 } from '#/state/persisted/schema' 10 10 import {PersistedApi} from './types' 11 + import {normalizeData} from './util' 11 12 12 13 export type {PersistedAccount, Schema} from '#/state/persisted/schema' 13 14 export {defaults} from '#/state/persisted/schema' ··· 33 34 key: K, 34 35 value: Schema[K], 35 36 ): Promise<void> { 36 - _state = { 37 + _state = normalizeData({ 37 38 ..._state, 38 39 [key]: value, 39 - } 40 + }) 40 41 await writeToStorage(_state) 41 42 } 42 43 write satisfies PersistedApi['write'] ··· 81 82 }) 82 83 } 83 84 if (rawData) { 84 - return tryParse(rawData) 85 + const parsed = tryParse(rawData) 86 + if (parsed) { 87 + return normalizeData(parsed) 88 + } 85 89 } 86 90 }
+8 -5
src/state/persisted/index.web.ts
··· 9 9 tryStringify, 10 10 } from '#/state/persisted/schema' 11 11 import {PersistedApi} from './types' 12 + import {normalizeData} from './util' 12 13 13 14 export type {PersistedAccount, Schema} from '#/state/persisted/schema' 14 15 export {defaults} from '#/state/persisted/schema' ··· 56 57 } catch (e) { 57 58 // Ignore and go through the normal path. 58 59 } 59 - _state = { 60 + _state = normalizeData({ 60 61 ..._state, 61 62 [key]: value, 62 - } 63 + }) 63 64 writeToStorage(_state) 64 65 broadcast.postMessage({event: {type: UPDATE_EVENT, key}}) 65 66 broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading ··· 140 141 return lastResult 141 142 } else { 142 143 const result = tryParse(rawData) 143 - lastRawData = rawData 144 - lastResult = result 145 - return result 144 + if (result) { 145 + lastRawData = rawData 146 + lastResult = normalizeData(result) 147 + return lastResult 148 + } 146 149 } 147 150 } 148 151 }
+43 -9
src/state/persisted/schema.ts
··· 1 1 import {z} from 'zod' 2 2 3 + import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' 4 + import {findSupportedAppLanguage} from '#/locale/helpers' 3 5 import {logger} from '#/logger' 4 - import {deviceLocales} from '#/platform/detection' 5 6 import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army' 6 7 7 8 const externalEmbedOptions = ['show', 'hide'] as const ··· 55 56 lastEmailConfirm: z.string().optional(), 56 57 }), 57 58 languagePrefs: z.object({ 58 - primaryLanguage: z.string(), // should move to server 59 - contentLanguages: z.array(z.string()), // should move to server 60 - postLanguage: z.string(), // should move to server 59 + /** 60 + * The target language for translating posts. 61 + * 62 + * BCP-47 2-letter language code without region. 63 + */ 64 + primaryLanguage: z.string(), 65 + /** 66 + * The languages the user can read, passed to feeds. 67 + * 68 + * BCP-47 2-letter language codes without region. 69 + */ 70 + contentLanguages: z.array(z.string()), 71 + /** 72 + * The language(s) the user is currently posting in, configured within the 73 + * composer. Multiple languages are psearate by commas. 74 + * 75 + * BCP-47 2-letter language code without region. 76 + */ 77 + postLanguage: z.string(), 78 + /** 79 + * The user's post language history, used to pre-populate the post language 80 + * selector in the composer. Within each value, multiple languages are 81 + * separated by values. 82 + * 83 + * BCP-47 2-letter language codes without region. 84 + */ 61 85 postLanguageHistory: z.array(z.string()), 86 + /** 87 + * The language for UI translations in the app. 88 + * 89 + * BCP-47 2-letter language code with or without region, 90 + * to match with {@link AppLanguage}. 91 + */ 62 92 appLanguage: z.string(), 63 93 }), 64 94 requireAltTextEnabled: z.boolean(), // should move to server ··· 108 138 lastEmailConfirm: undefined, 109 139 }, 110 140 languagePrefs: { 111 - primaryLanguage: deviceLocales[0] || 'en', 112 - contentLanguages: deviceLocales || [], 113 - postLanguage: deviceLocales[0] || 'en', 114 - postLanguageHistory: (deviceLocales || []) 141 + primaryLanguage: deviceLanguageCodes[0] || 'en', 142 + contentLanguages: deviceLanguageCodes || [], 143 + postLanguage: deviceLanguageCodes[0] || 'en', 144 + postLanguageHistory: (deviceLanguageCodes || []) 115 145 .concat(['en', 'ja', 'pt', 'de']) 116 146 .slice(0, 6), 117 - appLanguage: deviceLocales[0] || 'en', 147 + // try full language tag first, then fallback to language code 148 + appLanguage: findSupportedAppLanguage([ 149 + deviceLocales.at(0)?.languageTag, 150 + deviceLanguageCodes[0], 151 + ]), 118 152 }, 119 153 requireAltTextEnabled: false, 120 154 largeAltBadgeEnabled: false,
+51
src/state/persisted/util.ts
··· 1 + import {parse} from 'bcp-47' 2 + 3 + import {dedupArray} from '#/lib/functions' 4 + import {logger} from '#/logger' 5 + import {Schema} from '#/state/persisted/schema' 6 + 7 + export function normalizeData(data: Schema) { 8 + const next = {...data} 9 + 10 + /** 11 + * Normalize language prefs to ensure that these values only contain 2-letter 12 + * country codes without region. 13 + */ 14 + try { 15 + const langPrefs = {...next.languagePrefs} 16 + langPrefs.primaryLanguage = normalizeLanguageTagToTwoLetterCode( 17 + langPrefs.primaryLanguage, 18 + ) 19 + langPrefs.contentLanguages = dedupArray( 20 + langPrefs.contentLanguages.map(lang => 21 + normalizeLanguageTagToTwoLetterCode(lang), 22 + ), 23 + ) 24 + langPrefs.postLanguage = langPrefs.postLanguage 25 + .split(',') 26 + .map(lang => normalizeLanguageTagToTwoLetterCode(lang)) 27 + .filter(Boolean) 28 + .join(',') 29 + langPrefs.postLanguageHistory = dedupArray( 30 + langPrefs.postLanguageHistory.map(postLanguage => { 31 + return postLanguage 32 + .split(',') 33 + .map(lang => normalizeLanguageTagToTwoLetterCode(lang)) 34 + .filter(Boolean) 35 + .join(',') 36 + }), 37 + ) 38 + next.languagePrefs = langPrefs 39 + } catch (e: any) { 40 + logger.error(`persisted state: failed to normalize language prefs`, { 41 + safeMessage: e.message, 42 + }) 43 + } 44 + 45 + return next 46 + } 47 + 48 + export function normalizeLanguageTagToTwoLetterCode(lang: string) { 49 + const result = parse(lang).language 50 + return result ?? lang 51 + }
+4
src/state/session/__tests__/session-test.ts
··· 10 10 }, 11 11 })) 12 12 13 + jest.mock('expo-localization', () => ({ 14 + getLocales: () => [], 15 + })) 16 + 13 17 describe('session', () => { 14 18 it('can log in and out', () => { 15 19 let state = getInitialState([])
+11 -10
src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
··· 1 1 import React from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 - import {ScrollView} from '../util' 4 - import {Text} from '../../util/text/Text' 5 - import {usePalette} from 'lib/hooks/usePalette' 6 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 7 - import {deviceLocales} from 'platform/detection' 8 - import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' 9 - import {LanguageToggle} from './LanguageToggle' 10 - import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' 11 3 import {Trans} from '@lingui/macro' 4 + 5 + import {deviceLanguageCodes} from '#/locale/deviceLocales' 12 6 import {useModalControls} from '#/state/modals' 13 7 import { 14 8 useLanguagePrefs, 15 9 useLanguagePrefsApi, 16 10 } from '#/state/preferences/languages' 11 + import {usePalette} from 'lib/hooks/usePalette' 12 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 13 + import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' 14 + import {Text} from '../../util/text/Text' 15 + import {ScrollView} from '../util' 16 + import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' 17 + import {LanguageToggle} from './LanguageToggle' 17 18 18 19 export const snapPoints = ['100%'] 19 20 ··· 37 38 langs.sort((a, b) => { 38 39 const hasA = 39 40 langPrefs.contentLanguages.includes(a.code2) || 40 - deviceLocales.includes(a.code2) 41 + deviceLanguageCodes.includes(a.code2) 41 42 const hasB = 42 43 langPrefs.contentLanguages.includes(b.code2) || 43 - deviceLocales.includes(b.code2) 44 + deviceLanguageCodes.includes(b.code2) 44 45 if (hasA === hasB) return a.name.localeCompare(b.name) 45 46 if (hasA) return -1 46 47 return 1
+12 -11
src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
··· 1 1 import React from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 - import {ScrollView} from '../util' 4 - import {Text} from '../../util/text/Text' 5 - import {usePalette} from 'lib/hooks/usePalette' 6 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 7 - import {deviceLocales} from 'platform/detection' 8 - import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' 9 - import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' 10 - import {ToggleButton} from 'view/com/util/forms/ToggleButton' 11 3 import {Trans} from '@lingui/macro' 4 + 5 + import {deviceLanguageCodes} from '#/locale/deviceLocales' 12 6 import {useModalControls} from '#/state/modals' 13 7 import { 8 + hasPostLanguage, 14 9 useLanguagePrefs, 15 10 useLanguagePrefsApi, 16 - hasPostLanguage, 17 11 } from '#/state/preferences/languages' 12 + import {usePalette} from 'lib/hooks/usePalette' 13 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 14 + import {ToggleButton} from 'view/com/util/forms/ToggleButton' 15 + import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' 16 + import {Text} from '../../util/text/Text' 17 + import {ScrollView} from '../util' 18 + import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' 18 19 19 20 export const snapPoints = ['100%'] 20 21 ··· 38 39 langs.sort((a, b) => { 39 40 const hasA = 40 41 hasPostLanguage(langPrefs.postLanguage, a.code2) || 41 - deviceLocales.includes(a.code2) 42 + deviceLanguageCodes.includes(a.code2) 42 43 const hasB = 43 44 hasPostLanguage(langPrefs.postLanguage, b.code2) || 44 - deviceLocales.includes(b.code2) 45 + deviceLanguageCodes.includes(b.code2) 45 46 if (hasA === hasB) return a.name.localeCompare(b.name) 46 47 if (hasA) return -1 47 48 return 1
+27
yarn.lock
··· 9572 9572 resolved "https://registry.yarnpkg.com/bcp-47-match/-/bcp-47-match-2.0.3.tgz#603226f6e5d3914a581408be33b28a53144b09d0" 9573 9573 integrity sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ== 9574 9574 9575 + bcp-47@^2.1.0: 9576 + version "2.1.0" 9577 + resolved "https://registry.yarnpkg.com/bcp-47/-/bcp-47-2.1.0.tgz#7e80734c3338fe8320894981dccf4968c3092df6" 9578 + integrity sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w== 9579 + dependencies: 9580 + is-alphabetical "^2.0.0" 9581 + is-alphanumerical "^2.0.0" 9582 + is-decimal "^2.0.0" 9583 + 9575 9584 better-opn@~3.0.2: 9576 9585 version "3.0.2" 9577 9586 resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817" ··· 13893 13902 resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" 13894 13903 integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== 13895 13904 13905 + is-alphabetical@^2.0.0: 13906 + version "2.0.1" 13907 + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz#01072053ea7c1036df3c7d19a6daaec7f19e789b" 13908 + integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ== 13909 + 13910 + is-alphanumerical@^2.0.0: 13911 + version "2.0.1" 13912 + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz#7c03fbe96e3e931113e57f964b0a368cc2dfd875" 13913 + integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw== 13914 + dependencies: 13915 + is-alphabetical "^2.0.0" 13916 + is-decimal "^2.0.0" 13917 + 13896 13918 is-arguments@^1.0.4: 13897 13919 version "1.1.1" 13898 13920 resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" ··· 13986 14008 integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== 13987 14009 dependencies: 13988 14010 has-tostringtag "^1.0.0" 14011 + 14012 + is-decimal@^2.0.0: 14013 + version "2.0.1" 14014 + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-2.0.1.tgz#9469d2dc190d0214fd87d78b78caecc0cc14eef7" 14015 + integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A== 13989 14016 13990 14017 is-directory@^0.3.1: 13991 14018 version "0.3.1"