diff --git a/docusaurus/docs/reactnative/basics/internationalization.mdx b/docusaurus/docs/reactnative/basics/internationalization.mdx index a0b8345ff5..0f3d41b192 100644 --- a/docusaurus/docs/reactnative/basics/internationalization.mdx +++ b/docusaurus/docs/reactnative/basics/internationalization.mdx @@ -106,8 +106,8 @@ streami18n.registerTranslation('nl', { [`react-native-localize`](https://github.com/zoontek/react-native-localize#-react-native-localize) package provides a toolbox for React Native app localization. You can use this package to access user preferred locale, and use it to set language for chat components: ```tsx -import *as RNLocalizefrom 'react-native-localize'; -const streami18n =new Streami18n(); +import * as RNLocalize from 'react-native-localize'; +const streami18n = new Streami18n(); const userPreferredLocales = RNLocalize.getLocales(); @@ -176,14 +176,14 @@ const i18n =new Streami18n({ Or by providing your own [Day.js](https://day.js.org/docs/en/installation/installation) object: ```tsx -import Dayjsfrom 'dayjs'; +import Dayjs from 'dayjs'; import 'dayjs/locale/nl'; import 'dayjs/locale/it'; // or if you want to include all locales import 'dayjs/min/locales'; -const i18n =new Streami18n({ +const i18n = new Streami18n({ language: 'nl', DateTimeParser: Dayjs, }); @@ -195,6 +195,28 @@ If you would like to stick with English language for date-times in Stream compon If your application has a user-base that speaks more than one language, Stream's Chat Client provides the option to automatically translate messages. For more information on using automatic machine translation for messages, see the [Chat Client Guide on Translation](https://getstream.io/chat/docs/react-native/translation/?language=javascript). +### Timezone location + +To display date and time in different than machine's local timezone, you can provide the timezone parameter to the `Streami18n` constructor. The timezone value has to be a valid [timezone identifier string](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). If no timezone parameter is provided, then the machine's local timezone is applied. + +:::note +On our React Native SDK, the timezone is only supported through `moment-timezone` and not through the default `Dayjs`. This is because of the [following issue](https://github.com/iamkun/dayjs/issues/1377). + +So, to ensure this please pass the `moment-timezone` object to the `DateTimeParser` key of the `Streami18n` constructor. +::: + +```tsx +import { Streami18n } from 'stream-chat-react'; +import momentTimezone from 'moment-timezone'; + +const streami18n = new Streami18n({ + DateTimeParser: momentTimezone, + timezone: 'Europe/Budapest', +}); +``` + +Moment Timezone will automatically load and extend the moment module, then return the modified instance. This will also prevent multiple versions of moment being installed in a project. + ## Options `options` are the first optional parameter passed to `Streami18n`, it is an object with all keys being optional. diff --git a/examples/TypeScriptMessaging/package.json b/examples/TypeScriptMessaging/package.json index 20646e82c9..3a38233174 100644 --- a/examples/TypeScriptMessaging/package.json +++ b/examples/TypeScriptMessaging/package.json @@ -20,8 +20,8 @@ "@react-navigation/stack": "^6.2.0", "@stream-io/flat-list-mvcp": "0.10.3", "react": "18.2.0", - "react-native-audio-recorder-player": "3.6.6", "react-native": "^0.73.6", + "react-native-audio-recorder-player": "3.6.6", "react-native-document-picker": "^9.0.1", "react-native-fs": "^2.18.0", "react-native-gesture-handler": "^2.14.0", diff --git a/examples/TypeScriptMessaging/yarn.lock b/examples/TypeScriptMessaging/yarn.lock index 0cfc80204b..151065a92d 100644 --- a/examples/TypeScriptMessaging/yarn.lock +++ b/examples/TypeScriptMessaging/yarn.lock @@ -6899,10 +6899,10 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat-react-native-core@5.33.0: - version "5.33.0" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-5.33.0.tgz#14f04de90cbc8db011bab8db3fa84abe2dc2eaec" - integrity sha512-V9OJA9MrHzaCw5q16ZRbEktA1HamITbXPOkVZOjpDbb0OBcmedmOnD9C2NFIprc770lhllS/1MKBDr0GdQ9NXQ== +stream-chat-react-native-core@5.33.1: + version "5.33.1" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-5.33.1.tgz#d9e7847469d3ffb6e7fd35fbb7b720f2e25d172e" + integrity sha512-TCDmChJe07cYyL3sErc6qycRFMA+HbflCKRGrFvVvpU0RdWJljaqiOo3avFSauciSnQxx9WxzTkMism8YsFHcQ== dependencies: "@gorhom/bottom-sheet" "4.4.8" dayjs "1.10.5" diff --git a/package/jest-global-setup.js b/package/jest-global-setup.js new file mode 100644 index 0000000000..1895a06077 --- /dev/null +++ b/package/jest-global-setup.js @@ -0,0 +1,4 @@ +/* eslint-disable require-await */ +module.exports = async () => { + process.env.TZ = 'UTC'; +}; diff --git a/package/jest.config.js b/package/jest.config.js index cfe0a33857..a016e3d429 100644 --- a/package/jest.config.js +++ b/package/jest.config.js @@ -1,6 +1,7 @@ /* global require */ // eslint-disable-next-line no-undef module.exports = { + globalSetup: './jest-global-setup.js', moduleNameMapper: { 'mock-builders(.*)$': '/src/mock-builders$1', }, diff --git a/package/package.json b/package/package.json index 243fcba226..fa91ac82a0 100644 --- a/package/package.json +++ b/package/package.json @@ -135,7 +135,7 @@ "eslint-plugin-typescript-sort-keys": "3.2.0", "i18next-parser": "^9.0.0", "jest": "29.6.3", - "moment": "2.29.2", + "moment-timezone": "^0.5.45", "prettier": "2.8.8", "react": "18.2.0", "react-docgen-typescript": "1.22.0", diff --git a/package/src/contexts/translationContext/TranslationContext.tsx b/package/src/contexts/translationContext/TranslationContext.tsx index b3a295dd0a..98da3624b3 100644 --- a/package/src/contexts/translationContext/TranslationContext.tsx +++ b/package/src/contexts/translationContext/TranslationContext.tsx @@ -3,7 +3,7 @@ import React, { useContext } from 'react'; import Dayjs from 'dayjs'; import type { TFunction } from 'i18next'; -import type { Moment } from 'moment'; +import type { Moment } from 'moment-timezone'; import type { TranslationLanguages } from 'stream-chat'; diff --git a/package/src/utils/__tests__/Streami18n.test.js b/package/src/utils/__tests__/Streami18n.test.js index f84f40599d..c1ab95e74c 100644 --- a/package/src/utils/__tests__/Streami18n.test.js +++ b/package/src/utils/__tests__/Streami18n.test.js @@ -1,6 +1,7 @@ import { default as Dayjs } from 'dayjs'; import 'dayjs/locale/nl'; import localeData from 'dayjs/plugin/localeData'; +import moment from 'moment-timezone'; import frTranslations from '../../i18n/fr.json'; import nlTranslations from '../../i18n/nl.json'; @@ -18,6 +19,12 @@ const customDayjsLocaleConfig = { weekdaysShort: 'sun_mán_týs_mik_hós_frí_ley'.split('_'), }; +describe('Jest Timezone', () => { + it('global config should set the timezone to UTC', () => { + expect(new Date().getTimezoneOffset()).toBe(0); + }); +}); + describe('Streami18n instance - default', () => { const streami18nOptions = { logger: () => {} }; const streami18n = new Streami18n(streami18nOptions); @@ -184,24 +191,53 @@ describe('setLanguage - switch to french', () => { }); }); -describe('formatters property', () => { - it('contains the default timestampFormatter', () => { - expect(new Streami18n().formatters.timestampFormatter).toBeDefined(); - }); - it('allows to override the default timestampFormatter', async () => { - const i18n = new Streami18n({ - formatters: { timestampFormatter: () => () => 'custom' }, - translationsForLanguage: { abc: '{{ value | timestampFormatter }}' }, +describe('Streami18n timezone', () => { + describe.each([['moment', moment]])('%s', (moduleName, module) => { + it('is by default the local timezone', () => { + const streamI18n = new Streami18n({ DateTimeParser: module }); + const date = new Date(); + expect(streamI18n.tDateTimeParser(date).format('H')).toBe(date.getHours().toString()); }); - await i18n.init(); - expect(i18n.t('abc')).toBe('custom'); - }); - it('allows to add new custom formatter', async () => { - const i18n = new Streami18n({ - formatters: { customFormatter: () => () => 'custom' }, - translationsForLanguage: { abc: '{{ value | customFormatter }}' }, + + it('can be set to different timezone on init', () => { + const streamI18n = new Streami18n({ DateTimeParser: module, timezone: 'Europe/Prague' }); + const date = new Date(); + expect(streamI18n.tDateTimeParser(date).format('H')).not.toBe(date.getHours().toString()); + expect(streamI18n.tDateTimeParser(date).format('H')).not.toBe( + (date.getUTCHours() - 2).toString(), + ); + }); + + it('is ignored if datetime parser does not support timezones', () => { + const tz = module.tz; + delete module.tz; + + const streamI18n = new Streami18n({ DateTimeParser: module, timezone: 'Europe/Prague' }); + const date = new Date(); + expect(streamI18n.tDateTimeParser(date).format('H')).toBe(date.getHours().toString()); + + module.tz = tz; + }); + describe('formatters property', () => { + it('contains the default timestampFormatter', () => { + expect(new Streami18n().formatters.timestampFormatter).toBeDefined(); + }); + it('allows to override the default timestampFormatter', async () => { + const i18n = new Streami18n({ + formatters: { timestampFormatter: () => () => 'custom' }, + translationsForLanguage: { abc: '{{ value | timestampFormatter }}' }, + }); + await i18n.init(); + expect(i18n.t('abc')).toBe('custom'); + }); + it('allows to add new custom formatter', async () => { + const i18n = new Streami18n({ + formatters: { customFormatter: () => () => 'custom' }, + translationsForLanguage: { abc: '{{ value | customFormatter }}' }, + }); + await i18n.init(); + expect(i18n.t('abc')).toBe('custom'); + }); }); - await i18n.init(); - expect(i18n.t('abc')).toBe('custom'); }); }); diff --git a/package/src/utils/i18n/Streami18n.ts b/package/src/utils/i18n/Streami18n.ts index 5b162ba70e..3980dc0a7e 100644 --- a/package/src/utils/i18n/Streami18n.ts +++ b/package/src/utils/i18n/Streami18n.ts @@ -4,9 +4,10 @@ import localeData from 'dayjs/plugin/localeData'; import LocalizedFormat from 'dayjs/plugin/localizedFormat'; import relativeTime from 'dayjs/plugin/relativeTime'; import updateLocale from 'dayjs/plugin/updateLocale'; +import utc from 'dayjs/plugin/utc'; import i18n, { FallbackLng, TFunction } from 'i18next'; -import type moment from 'moment'; +import type momentTimezone from 'moment-timezone'; import { calendarFormats } from './calendarFormats'; import { @@ -54,6 +55,7 @@ const defaultNS = 'translation'; const defaultLng = 'en'; Dayjs.extend(updateLocale); +Dayjs.extend(utc); Dayjs.updateLocale('en', { calendar: calendarFormats.en, @@ -147,18 +149,28 @@ const en_locale = { weekdays: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], }; +type DateTimeParserModule = typeof Dayjs | typeof momentTimezone; + // Type guards to check DayJs -const isDayJs = (dateTimeParser: typeof Dayjs | typeof moment): dateTimeParser is typeof Dayjs => +const isDayJs = (dateTimeParser: DateTimeParserModule): dateTimeParser is typeof Dayjs => (dateTimeParser as typeof Dayjs).extend !== undefined; +type TimezoneParser = { + tz: momentTimezone.MomentTimezone | Dayjs.Dayjs; +}; + +const supportsTz = (dateTimeParser: unknown): dateTimeParser is TimezoneParser => + (dateTimeParser as TimezoneParser).tz !== undefined; + type Streami18nOptions = { - DateTimeParser?: typeof Dayjs | typeof moment; + DateTimeParser?: DateTimeParserModule; dayjsLocaleConfigForLanguage?: Partial; debug?: boolean; disableDateTimeTranslations?: boolean; formatters?: Partial & CustomFormatters; language?: string; logger?: (msg?: string) => void; + timezone?: string; translationsForLanguage?: Partial; }; @@ -385,10 +397,14 @@ export class Streami18n { */ logger: (msg?: string) => void; currentLanguage: string; - DateTimeParser: typeof Dayjs | typeof moment; + DateTimeParser: DateTimeParserModule; formatters: PredefinedFormatters & CustomFormatters = predefinedFormatters; isCustomDateTimeParser: boolean; i18nextConfig: I18NextConfig; + /** + * A valid TZ identifier string (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) + */ + timezone?: string; /** * Constructor accepts following options: @@ -427,6 +443,7 @@ export class Streami18n { this.currentLanguage = finalOptions.language; this.DateTimeParser = finalOptions.DateTimeParser; + this.timezone = finalOptions.timezone; this.formatters = { ...predefinedFormatters, ...options?.formatters }; try { @@ -504,19 +521,19 @@ export class Streami18n { } this.tDateTimeParser = (timestamp) => { - if (finalOptions.disableDateTimeTranslations || !this.localeExists(this.currentLanguage)) { - /** - * TS needs to know which is being called to accept the chain call - */ - if (isDayJs(this.DateTimeParser)) { - return this.DateTimeParser(timestamp).locale(defaultLng); - } - return this.DateTimeParser(timestamp).locale(defaultLng); + const language = + finalOptions.disableDateTimeTranslations || !this.localeExists(this.currentLanguage) + ? defaultLng + : this.currentLanguage; + + // If the DateTimeParser is not a Dayjs instance, we assume it is a Moment instance. + if (!isDayJs(this.DateTimeParser)) { + return supportsTz(this.DateTimeParser) && this.timezone + ? this.DateTimeParser(timestamp).tz(this.timezone).locale(language) + : this.DateTimeParser(timestamp).locale(language); } - if (isDayJs(this.DateTimeParser)) { - return this.DateTimeParser(timestamp).locale(this.currentLanguage); - } - return this.DateTimeParser(timestamp).locale(this.currentLanguage); + + return this.DateTimeParser(timestamp).locale(language); }; } diff --git a/package/yarn.lock b/package/yarn.lock index 6ccecd67fa..063949b1a6 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -8765,10 +8765,17 @@ mktemp@~0.4.0: resolved "https://registry.yarnpkg.com/mktemp/-/mktemp-0.4.0.tgz#6d0515611c8a8c84e484aa2000129b98e981ff0b" integrity sha512-IXnMcJ6ZyTuhRmJSjzvHSRhlVPiN9Jwc6e59V0bEJ0ba6OBeX2L0E+mRN1QseeOF4mM+F1Rit6Nh7o+rl2Yn/A== -moment@2.29.2: - version "2.29.2" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4" - integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg== +moment-timezone@^0.5.45: + version "0.5.45" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.45.tgz#cb685acd56bac10e69d93c536366eb65aa6bcf5c" + integrity sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ== + dependencies: + moment "^2.29.4" + +moment@^2.29.4: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== move-concurrently@^1.0.1: version "1.0.1"