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

Fix language switching and update date-fns to v4 #2788

Merged
merged 6 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion boilerplate/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import * as storage from "./utils/storage"
import { customFontsToLoad } from "./theme"
import Config from "./config"
import { KeyboardProvider } from "react-native-keyboard-controller"
import { loadDateFnsLocale } from "./utils/formatDate"

export const NAVIGATION_PERSISTENCE_KEY = "NAVIGATION_STATE"

Expand Down Expand Up @@ -75,7 +76,9 @@ function App(props: AppProps) {
const [isI18nInitialized, setIsI18nInitialized] = useState(false)

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

// @mst replace-next-line React.useEffect(() => {
Expand Down
65 changes: 30 additions & 35 deletions boilerplate/app/i18n/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Localization from "expo-localization"
import { I18nManager } from "react-native"
import * as i18next from "i18next"
import i18n from "i18next"
import { initReactI18next } from "react-i18next"
import "intl-pluralrules"

Expand All @@ -13,56 +13,51 @@ import fr from "./fr"
import ja from "./ja"
import hi from "./hi"

// to use regional locales use { "en-US": enUS } etc
const fallbackLocale = "en-US"

export let i18n: i18next.i18n
const systemLocales = Localization.getLocales()

const systemLocale = Localization.getLocales()[0]
const systemLocaleTag = systemLocale?.languageTag ?? fallbackLocale
const resources = { ar, en, ko, es, fr, ja, hi }
const supportedTags = Object.keys(resources)

// Checks to see if the device locale matches any of the supported locales
// Device locale may be more specific and still match (e.g., en-US matches en)
const systemTagMatchesSupportedTags = (deviceTag: string) => {
const primaryTag = deviceTag.split("-")[0]
return supportedTags.includes(primaryTag)
}
Comment on lines +25 to +28
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What it be worth writing a test for this one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking splitting on - and checking includes were simple enough it didn't need a test, if we did something more complex maybe then?

Most of the complexity here comes in with third party systems, so an E2E test to check that we can switch languages makes the most sense to me.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I agree this is not complex, but I'm also thinking of future Felipe doing an i18 migration again. It's all good; not a blocker.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For sure. I still think in that case what is most useful is an E2E test instead of unit tests.


const pickSupportedLocale: () => Localization.Locale | undefined = () => {
return systemLocales.find((locale) => systemTagMatchesSupportedTags(locale.languageTag))
}

const locale = pickSupportedLocale()

export let isRTL = false

// Need to set RTL ASAP to ensure the app is rendered correctly. Waiting for i18n to init is too late.
if (locale?.languageTag && locale?.textDirection === "rtl") {
I18nManager.allowRTL(true)
isRTL = true
} else {
I18nManager.allowRTL(false)
}

export const initI18n = async () => {
i18n = i18next.use(initReactI18next)
i18n.use(initReactI18next)

await i18n.init({
resources: {
ar,
en,
"en-US": en,
ko,
es,
fr,
ja,
hi,
},
lng: fallbackLocale,
resources,
lng: locale?.languageTag ?? 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 {
// 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
export const isRTL = systemLocale?.textDirection === "rtl"
I18nManager.allowRTL(isRTL)
I18nManager.forceRTL(isRTL)

/**
* Builds up valid keypaths for translations.
*/
Expand Down
7 changes: 4 additions & 3 deletions boilerplate/app/i18n/translate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { TOptions } from "i18next"
import { i18n, TxKeyPath } from "./i18n"
import i18n from "i18next"
import type { TOptions } from "i18next"
import { TxKeyPath } from "./i18n"

/**
* Translates text.
* @param {TxKeyPath} key - The i18n key.
* @param {i18n.TOptions} options - The i18n options.
* @param {TOptions} options - The i18n options.
* @returns {string} - The translated text.
* @example
* Translations:
Expand Down
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",
Copy link
Collaborator

@fpena fpena Sep 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain please why are we removing the date? Reading from the PR description, it seems like we still want to improve this test later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really understand why this changed, but I also didn't want to block this on this test, because the behavior is correct when I actually test it in the app.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single quotes changing to double quotes was required by the linter, and I think might be related to tests having a separate TS config? but not sure, I thought that would be handled by prettier.

Actually I'm even more confused by this because apparently it failed on the CLI project's prettier check (https://app.circleci.com/pipelines/github/infinitered/ignite/3631/workflows/6e7e16e3-e4c1-4a6b-949b-d1fe26da1665/jobs/4375), but the inner project isn't complaining about the double-quotes.

That config just isn't unified, so we get weird things like this sometimes.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My question was not related to quotes but related to deleting the date within it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, for that part see my first comment. I'm not sure why it changed, but I'd also like to not block this PR on that since the logic is working correctly on the real app, this is just a test that I'm not sure is testing anything useful the way it's currently set up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed issue for this: #2789

)
})

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",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as here.

)
})

Expand Down
46 changes: 34 additions & 12 deletions boilerplate/app/utils/formatDate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,48 @@
// If you import with the syntax: import { format } from "date-fns" the ENTIRE library
// will be included in your production bundle (even if you only use one function).
// This is because react-native does not support tree-shaking.
import type { Locale } from "date-fns"
import format from "date-fns/format"
import parseISO from "date-fns/parseISO"
import ar from "date-fns/locale/ar-SA"
import ko from "date-fns/locale/ko"
import en from "date-fns/locale/en-US"
import { i18n } from "@/i18n"
import { type Locale } from "date-fns/locale"
import { format } from "date-fns/format"
import { parseISO } from "date-fns/parseISO"
import i18n from "i18next"

type Options = Parameters<typeof format>[2]

const getLocale = (): Locale => {
const locale = i18n.language.split("-")[0]
return locale === "ar" ? ar : locale === "ko" ? ko : en
let dateFnsLocale: Locale
export const loadDateFnsLocale = () => {
const primaryTag = i18n.language.split("-")[0]
switch (primaryTag) {
case "en":
dateFnsLocale = require("date-fns/locale/en-US").default
break
case "ar":
dateFnsLocale = require("date-fns/locale/ar").default
break
case "ko":
dateFnsLocale = require("date-fns/locale/ko").default
break
case "es":
dateFnsLocale = require("date-fns/locale/es").default
break
case "fr":
dateFnsLocale = require("date-fns/locale/fr").default
break
case "hi":
dateFnsLocale = require("date-fns/locale/hi").default
break
case "ja":
dateFnsLocale = require("date-fns/locale/ja").default
break
default:
dateFnsLocale = require("date-fns/locale/en-US").default
break
}
}

export const formatDate = (date: string, dateFormat?: string, options?: Options) => {
const locale = getLocale()
const dateOptions = {
...options,
locale,
locale: dateFnsLocale,
}
return format(parseISO(date), dateFormat ?? "MMM dd, yyyy", dateOptions)
}
2 changes: 1 addition & 1 deletion boilerplate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@react-navigation/native-stack": "^6.0.2",
"@shopify/flash-list": "^1.6.4",
"apisauce": "3.0.1",
"date-fns": "^2.30.0",
"date-fns": "^4.1.0",
"expo": "~51.0.8",
"expo-application": "~5.9.1",
"expo-build-properties": "~0.12.1",
Expand Down