Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[2754] Swap out i18n-js for react-18next #2770

Merged
merged 27 commits into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions boilerplate/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ if (__DEV__) {
require("./devtools/ReactotronConfig.ts")
}
import "./utils/gestureHandler"
import "./i18n"
import { initI18n } from "./i18n"
import "./utils/ignoreWarnings"
import { useFonts } from "expo-font"
import { useEffect, useState } from "react"
import { initialWindowMetrics, SafeAreaProvider } from "react-native-safe-area-context"
import * as Linking from "expo-linking"
import { useInitialRootStore } from "./models" // @mst remove-current-line
Expand Down Expand Up @@ -71,6 +72,11 @@ function App(props: AppProps) {
} = useNavigationPersistence(storage, NAVIGATION_PERSISTENCE_KEY)

const [areFontsLoaded, fontLoadError] = useFonts(customFontsToLoad)
const [isI18nInitialized, setIsI18nInitialized] = useState(false)

useEffect(() => {
initI18n().then(() => setIsI18nInitialized(true))
}, [])

// @mst replace-next-line React.useEffect(() => {
const { rehydrated } = useInitialRootStore(() => {
Expand All @@ -93,7 +99,12 @@ function App(props: AppProps) {
// In Android: https://stackoverflow.com/a/45838109/204044
// You can replace with your own loading component if you wish.
// @mst replace-next-line if (!isNavigationStateRestored || (!areFontsLoaded && !fontLoadError)) {
if (!rehydrated || !isNavigationStateRestored || (!areFontsLoaded && !fontLoadError)) {
if (
!rehydrated ||
!isNavigationStateRestored ||
!isI18nInitialized ||
(!areFontsLoaded && !fontLoadError)
) {
return null
}

Expand Down
2 changes: 1 addition & 1 deletion boilerplate/app/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export interface ButtonProps extends PressableProps {
* @returns {JSX.Element} The rendered `Button` component.
* @example
* <Button
* tx="common.ok"
* tx="common:ok"
* style={styles.button}
* textStyle={styles.buttonText}
* onPress={handleButtonPress}
Expand Down
6 changes: 3 additions & 3 deletions boilerplate/app/components/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,9 @@ export function EmptyState(props: EmptyStateProps) {
const EmptyStatePresets = {
generic: {
imageSource: sadFace,
heading: translate("emptyStateComponent.generic.heading"),
content: translate("emptyStateComponent.generic.content"),
button: translate("emptyStateComponent.generic.button"),
heading: translate("emptyStateComponent:generic.heading"),
content: translate("emptyStateComponent:generic.content"),
button: translate("emptyStateComponent:generic.button"),
} as EmptyStatePresetItem,
} as const

Expand Down
4 changes: 2 additions & 2 deletions boilerplate/app/components/Text.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import i18n from "i18n-js"
import { TOptions } from "i18next"
import { StyleProp, Text as RNText, TextProps as RNTextProps, TextStyle } from "react-native"
import { isRTL, translate, TxKeyPath } from "../i18n"
import type { ThemedStyle, ThemedStyleArray } from "@/theme"
Expand All @@ -23,7 +23,7 @@ export interface TextProps extends RNTextProps {
* Optional options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
txOptions?: i18n.TranslateOptions
txOptions?: TOptions
/**
* An optional style override useful for padding & margin.
*/
Expand Down
73 changes: 48 additions & 25 deletions boilerplate/app/i18n/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as Localization from "expo-localization"
import { I18n } from "i18n-js"
import { I18nManager } from "react-native"
import * as i18next from "i18next"
import { initReactI18next } from "react-i18next"
import "intl-pluralrules"

// if English isn't your default language, move Translations to the appropriate language file.
import en, { Translations } from "./en"
Expand All @@ -10,31 +12,48 @@ import fr from "./fr"
import ja from "./ja"
import hi from "./hi"

// Migration guide from i18n 3.x -> 4.x:
// https://github.com/fnando/i18n-js/blob/main/MIGRATING_FROM_V3_TO_V4.md
// https://github.com/fnando/i18n/discussions/24

// to use regional locales use { "en-US": enUS } etc
const fallbackLocale = "en-US"
export const i18n = new I18n(
{ ar, en, "en-US": en, ko, fr, ja, hi },
{ locale: fallbackLocale, defaultLocale: fallbackLocale, enableFallback: true },
)

export let i18n: i18next.i18n

const systemLocale = Localization.getLocales()[0]
const systemLocaleTag = systemLocale?.languageTag ?? fallbackLocale

if (Object.prototype.hasOwnProperty.call(i18n.translations, systemLocaleTag)) {
// if specific locales like en-FI or en-US is available, set it
i18n.locale = systemLocaleTag
} else {
// otherwise try to fallback to the general locale (dropping the -XX suffix)
const generalLocale = systemLocaleTag.split("-")[0]
if (Object.prototype.hasOwnProperty.call(i18n.translations, generalLocale)) {
i18n.locale = generalLocale
export const initI18n = async () => {
i18n = i18next.use(initReactI18next)

await i18n.init({
resources: {
ar,
en,
"en-US": en,
ko,
fr,
ja,
hi,
},
lng: fallbackLocale,
fallbackLng: fallbackLocale,
interpolation: {
escapeValue: false,
},
})

if (Object.prototype.hasOwnProperty.call(i18n.languages, systemLocaleTag)) {
// if specific locales like en-FI or en-US is available, set it
await i18n.changeLanguage(systemLocaleTag)
} else {
i18n.locale = fallbackLocale
// otherwise try to fallback to the general locale (dropping the -XX suffix)
const generalLocale = systemLocaleTag.split("-")[0]
if (Object.prototype.hasOwnProperty.call(i18n.languages, generalLocale)) {
await i18n.changeLanguage(generalLocale)
} else {
await i18n.changeLanguage(fallbackLocale)
}
}

return i18n
}

// handle RTL languages
Expand All @@ -45,22 +64,26 @@ I18nManager.forceRTL(isRTL)
/**
* Builds up valid keypaths for translations.
*/

export type TxKeyPath = RecursiveKeyOf<Translations>

// via: https://stackoverflow.com/a/65333050
type RecursiveKeyOf<TObj extends object> = {
[TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<TObj[TKey], `${TKey}`>
[TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<TObj[TKey], `${TKey}`, true>
}[keyof TObj & (string | number)]

type RecursiveKeyOfInner<TObj extends object> = {
[TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<
TObj[TKey],
`['${TKey}']` | `.${TKey}`
>
[TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<TObj[TKey], `${TKey}`, false>
}[keyof TObj & (string | number)]

type RecursiveKeyOfHandleValue<TValue, Text extends string> = TValue extends any[]
type RecursiveKeyOfHandleValue<
TValue,
Text extends string,
IsFirstLevel extends boolean,
> = TValue extends any[]
? Text
: TValue extends object
? Text | `${Text}${RecursiveKeyOfInner<TValue>}`
? IsFirstLevel extends true
? Text | `${Text}:${RecursiveKeyOfInner<TValue>}`
: Text | `${Text}.${RecursiveKeyOfInner<TValue>}`
: Text
15 changes: 9 additions & 6 deletions boilerplate/app/i18n/translate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { TranslateOptions } from "i18n-js"
import { TOptions } from "i18next"
import { i18n, TxKeyPath } from "./i18n"

/**
* Translates text.
* @param {TxKeyPath} key - The i18n key.
* @param {i18n.TranslateOptions} options - The i18n options.
* @param {i18n.TOptions} options - The i18n options.
* @returns {string} - The translated text.
* @example
* Translations:
Expand All @@ -17,12 +17,15 @@ import { i18n, TxKeyPath } from "./i18n"
*
* Usage:
* ```ts
* import { translate } from "i18n-js"
* import { translate } from "./i18n"
*
* translate("common.ok", { name: "world" })
* translate("common:ok", { name: "world" })
* // => "Hello world!"
* ```
*/
export function translate(key: TxKeyPath, options?: TranslateOptions): string {
return i18n.t(key, options)
export function translate(key: TxKeyPath, options?: TOptions): string {
if (i18n.isInitialized) {
return i18n.t(key, options)
}
return key
}
4 changes: 2 additions & 2 deletions boilerplate/app/models/Episode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ const episode = EpisodeModel.create(data)
test("publish date format", () => {
expect(episode.datePublished.textLabel).toBe("Jan 20, 2022")
expect(episode.datePublished.accessibilityLabel).toBe(
'demoPodcastListScreen.accessibility.publishLabel {"date":"Jan 20, 2022"}',
'demoPodcastListScreen:accessibility.publishLabel {"date":"Jan 20, 2022"}',
)
})

test("duration format", () => {
expect(episode.duration.textLabel).toBe("42:58")
expect(episode.duration.accessibilityLabel).toBe(
'demoPodcastListScreen.accessibility.durationLabel {"hours":0,"minutes":42,"seconds":58}',
'demoPodcastListScreen:accessibility.durationLabel {"hours":0,"minutes":42,"seconds":58}',
)
})

Expand Down
4 changes: 2 additions & 2 deletions boilerplate/app/models/Episode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const EpisodeModel = types
const formatted = formatDate(episode.pubDate)
return {
textLabel: formatted,
accessibilityLabel: translate("demoPodcastListScreen.accessibility.publishLabel", {
accessibilityLabel: translate("demoPodcastListScreen:accessibility.publishLabel", {
date: formatted,
}),
}
Expand All @@ -65,7 +65,7 @@ export const EpisodeModel = types
const sDisplay = s > 0 ? s : ""
return {
textLabel: hDisplay + mDisplay + sDisplay,
accessibilityLabel: translate("demoPodcastListScreen.accessibility.durationLabel", {
accessibilityLabel: translate("demoPodcastListScreen:accessibility.durationLabel", {
hours: h,
minutes: m,
seconds: s,
Expand Down
10 changes: 5 additions & 5 deletions boilerplate/app/navigators/DemoNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function DemoNavigator() {
name="DemoShowroom"
component={DemoShowroomScreen}
options={{
tabBarLabel: translate("demoNavigator.componentsTab"),
tabBarLabel: translate("demoNavigator:componentsTab"),
tabBarIcon: ({ focused }) => (
<Icon icon="components" color={focused ? colors.tint : colors.tintInactive} size={30} />
),
Expand All @@ -70,7 +70,7 @@ export function DemoNavigator() {
name="DemoCommunity"
component={DemoCommunityScreen}
options={{
tabBarLabel: translate("demoNavigator.communityTab"),
tabBarLabel: translate("demoNavigator:communityTab"),
tabBarIcon: ({ focused }) => (
<Icon icon="community" color={focused ? colors.tint : colors.tintInactive} size={30} />
),
Expand All @@ -81,8 +81,8 @@ export function DemoNavigator() {
name="DemoPodcastList"
component={DemoPodcastListScreen}
options={{
tabBarAccessibilityLabel: translate("demoNavigator.podcastListTab"),
tabBarLabel: translate("demoNavigator.podcastListTab"),
tabBarAccessibilityLabel: translate("demoNavigator:podcastListTab"),
tabBarLabel: translate("demoNavigator:podcastListTab"),
tabBarIcon: ({ focused }) => (
<Icon icon="podcast" color={focused ? colors.tint : colors.tintInactive} size={30} />
),
Expand All @@ -93,7 +93,7 @@ export function DemoNavigator() {
name="DemoDebug"
component={DemoDebugScreen}
options={{
tabBarLabel: translate("demoNavigator.debugTab"),
tabBarLabel: translate("demoNavigator:debugTab"),
tabBarIcon: ({ focused }) => (
<Icon icon="debug" color={focused ? colors.tint : colors.tintInactive} size={30} />
),
Expand Down
34 changes: 17 additions & 17 deletions boilerplate/app/screens/DemoCommunityScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,38 @@ export const DemoCommunityScreen: FC<DemoTabScreenProps<"DemoCommunity">> =
const { themed } = useAppTheme()
return (
<Screen preset="scroll" contentContainerStyle={$styles.container} safeAreaEdges={["top"]}>
<Text preset="heading" tx="demoCommunityScreen.title" style={themed($title)} />
<Text tx="demoCommunityScreen.tagLine" style={themed($tagline)} />
<Text preset="heading" tx="demoCommunityScreen:title" style={themed($title)} />
<Text tx="demoCommunityScreen:tagLine" style={themed($tagline)} />

<Text preset="subheading" tx="demoCommunityScreen.joinUsOnSlackTitle" />
<Text tx="demoCommunityScreen.joinUsOnSlack" style={themed($description)} />
<Text preset="subheading" tx="demoCommunityScreen:joinUsOnSlackTitle" />
<Text tx="demoCommunityScreen:joinUsOnSlack" style={themed($description)} />
<ListItem
tx="demoCommunityScreen.joinSlackLink"
tx="demoCommunityScreen:joinSlackLink"
leftIcon="slack"
rightIcon={isRTL ? "caretLeft" : "caretRight"}
onPress={() => openLinkInBrowser("https://community.infinite.red/")}
/>
<Text
preset="subheading"
tx="demoCommunityScreen.makeIgniteEvenBetterTitle"
tx="demoCommunityScreen:makeIgniteEvenBetterTitle"
style={themed($sectionTitle)}
/>
<Text tx="demoCommunityScreen.makeIgniteEvenBetter" style={themed($description)} />
<Text tx="demoCommunityScreen:makeIgniteEvenBetter" style={themed($description)} />
<ListItem
tx="demoCommunityScreen.contributeToIgniteLink"
tx="demoCommunityScreen:contributeToIgniteLink"
leftIcon="github"
rightIcon={isRTL ? "caretLeft" : "caretRight"}
onPress={() => openLinkInBrowser("https://github.com/infinitered/ignite")}
/>

<Text
preset="subheading"
tx="demoCommunityScreen.theLatestInReactNativeTitle"
tx="demoCommunityScreen:theLatestInReactNativeTitle"
style={themed($sectionTitle)}
/>
<Text tx="demoCommunityScreen.theLatestInReactNative" style={themed($description)} />
<Text tx="demoCommunityScreen:theLatestInReactNative" style={themed($description)} />
<ListItem
tx="demoCommunityScreen.reactNativeRadioLink"
tx="demoCommunityScreen:reactNativeRadioLink"
bottomSeparator
rightIcon={isRTL ? "caretLeft" : "caretRight"}
LeftComponent={
Expand All @@ -60,7 +60,7 @@ export const DemoCommunityScreen: FC<DemoTabScreenProps<"DemoCommunity">> =
onPress={() => openLinkInBrowser("https://reactnativeradio.com/")}
/>
<ListItem
tx="demoCommunityScreen.reactNativeNewsletterLink"
tx="demoCommunityScreen:reactNativeNewsletterLink"
bottomSeparator
rightIcon={isRTL ? "caretLeft" : "caretRight"}
LeftComponent={
Expand All @@ -71,7 +71,7 @@ export const DemoCommunityScreen: FC<DemoTabScreenProps<"DemoCommunity">> =
onPress={() => openLinkInBrowser("https://reactnativenewsletter.com/")}
/>
<ListItem
tx="demoCommunityScreen.reactNativeLiveLink"
tx="demoCommunityScreen:reactNativeLiveLink"
bottomSeparator
rightIcon={isRTL ? "caretLeft" : "caretRight"}
LeftComponent={
Expand All @@ -82,7 +82,7 @@ export const DemoCommunityScreen: FC<DemoTabScreenProps<"DemoCommunity">> =
onPress={() => openLinkInBrowser("https://rn.live/")}
/>
<ListItem
tx="demoCommunityScreen.chainReactConferenceLink"
tx="demoCommunityScreen:chainReactConferenceLink"
rightIcon={isRTL ? "caretLeft" : "caretRight"}
LeftComponent={
<View style={[$styles.row, themed($logoContainer)]}>
Expand All @@ -93,12 +93,12 @@ export const DemoCommunityScreen: FC<DemoTabScreenProps<"DemoCommunity">> =
/>
<Text
preset="subheading"
tx="demoCommunityScreen.hireUsTitle"
tx="demoCommunityScreen:hireUsTitle"
style={themed($sectionTitle)}
/>
<Text tx="demoCommunityScreen.hireUs" style={themed($description)} />
<Text tx="demoCommunityScreen:hireUs" style={themed($description)} />
<ListItem
tx="demoCommunityScreen.hireUsLink"
tx="demoCommunityScreen:hireUsLink"
leftIcon="clap"
rightIcon={isRTL ? "caretLeft" : "caretRight"}
onPress={() => openLinkInBrowser("https://infinite.red/contact")}
Expand Down
Loading