Skip to content

Commit a7387a2

Browse files
authored
feat: add timezone option to Streami18n instance using moment-timezone (#2595)
* feat: add timezone option to Streami18n instance * fix: timezone using moment-timezone * fix: remove moment timezone from TSMessagingApp * fix: tests * docs: add timezone Streami18n docs * fix: remove dayjs timezone plugin
1 parent ef677b8 commit a7387a2

File tree

10 files changed

+135
-48
lines changed

10 files changed

+135
-48
lines changed

docusaurus/docs/reactnative/basics/internationalization.mdx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ streami18n.registerTranslation('nl', {
106106
[`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:
107107

108108
```tsx
109-
import *as RNLocalizefrom 'react-native-localize';
110-
const streami18n =new Streami18n();
109+
import * as RNLocalize from 'react-native-localize';
110+
const streami18n = new Streami18n();
111111

112112
const userPreferredLocales = RNLocalize.getLocales();
113113

@@ -176,14 +176,14 @@ const i18n =new Streami18n({
176176
Or by providing your own [Day.js](https://day.js.org/docs/en/installation/installation) object:
177177

178178
```tsx
179-
import Dayjsfrom 'dayjs';
179+
import Dayjs from 'dayjs';
180180

181181
import 'dayjs/locale/nl';
182182
import 'dayjs/locale/it';
183183
// or if you want to include all locales
184184
import 'dayjs/min/locales';
185185

186-
const i18n =new Streami18n({
186+
const i18n = new Streami18n({
187187
language: 'nl',
188188
DateTimeParser: Dayjs,
189189
});
@@ -195,6 +195,28 @@ If you would like to stick with English language for date-times in Stream compon
195195

196196
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).
197197

198+
### Timezone location
199+
200+
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.
201+
202+
:::note
203+
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).
204+
205+
So, to ensure this please pass the `moment-timezone` object to the `DateTimeParser` key of the `Streami18n` constructor.
206+
:::
207+
208+
```tsx
209+
import { Streami18n } from 'stream-chat-react';
210+
import momentTimezone from 'moment-timezone';
211+
212+
const streami18n = new Streami18n({
213+
DateTimeParser: momentTimezone,
214+
timezone: 'Europe/Budapest',
215+
});
216+
```
217+
218+
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.
219+
198220
## Options
199221

200222
`options` are the first optional parameter passed to `Streami18n`, it is an object with all keys being optional.

examples/TypeScriptMessaging/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
"@react-navigation/stack": "^6.2.0",
2121
"@stream-io/flat-list-mvcp": "0.10.3",
2222
"react": "18.2.0",
23-
"react-native-audio-recorder-player": "3.6.6",
2423
"react-native": "^0.73.6",
24+
"react-native-audio-recorder-player": "3.6.6",
2525
"react-native-document-picker": "^9.0.1",
2626
"react-native-fs": "^2.18.0",
2727
"react-native-gesture-handler": "^2.14.0",

examples/TypeScriptMessaging/yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6899,10 +6899,10 @@ statuses@~1.5.0:
68996899
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
69006900
integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
69016901

6902-
6903-
version "5.33.0"
6904-
resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-5.33.0.tgz#14f04de90cbc8db011bab8db3fa84abe2dc2eaec"
6905-
integrity sha512-V9OJA9MrHzaCw5q16ZRbEktA1HamITbXPOkVZOjpDbb0OBcmedmOnD9C2NFIprc770lhllS/1MKBDr0GdQ9NXQ==
6902+
6903+
version "5.33.1"
6904+
resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-5.33.1.tgz#d9e7847469d3ffb6e7fd35fbb7b720f2e25d172e"
6905+
integrity sha512-TCDmChJe07cYyL3sErc6qycRFMA+HbflCKRGrFvVvpU0RdWJljaqiOo3avFSauciSnQxx9WxzTkMism8YsFHcQ==
69066906
dependencies:
69076907
"@gorhom/bottom-sheet" "4.4.8"
69086908
dayjs "1.10.5"

package/jest-global-setup.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/* eslint-disable require-await */
2+
module.exports = async () => {
3+
process.env.TZ = 'UTC';
4+
};

package/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* global require */
22
// eslint-disable-next-line no-undef
33
module.exports = {
4+
globalSetup: './jest-global-setup.js',
45
moduleNameMapper: {
56
'mock-builders(.*)$': '<rootDir>/src/mock-builders$1',
67
},

package/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@
135135
"eslint-plugin-typescript-sort-keys": "3.2.0",
136136
"i18next-parser": "^9.0.0",
137137
"jest": "29.6.3",
138-
"moment": "2.29.2",
138+
"moment-timezone": "^0.5.45",
139139
"prettier": "2.8.8",
140140
"react": "18.2.0",
141141
"react-docgen-typescript": "1.22.0",

package/src/contexts/translationContext/TranslationContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React, { useContext } from 'react';
33
import Dayjs from 'dayjs';
44

55
import type { TFunction } from 'i18next';
6-
import type { Moment } from 'moment';
6+
import type { Moment } from 'moment-timezone';
77

88
import type { TranslationLanguages } from 'stream-chat';
99

package/src/utils/__tests__/Streami18n.test.js

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { default as Dayjs } from 'dayjs';
22
import 'dayjs/locale/nl';
33
import localeData from 'dayjs/plugin/localeData';
4+
import moment from 'moment-timezone';
45

56
import frTranslations from '../../i18n/fr.json';
67
import nlTranslations from '../../i18n/nl.json';
@@ -18,6 +19,12 @@ const customDayjsLocaleConfig = {
1819
weekdaysShort: 'sun_mán_týs_mik_hós_frí_ley'.split('_'),
1920
};
2021

22+
describe('Jest Timezone', () => {
23+
it('global config should set the timezone to UTC', () => {
24+
expect(new Date().getTimezoneOffset()).toBe(0);
25+
});
26+
});
27+
2128
describe('Streami18n instance - default', () => {
2229
const streami18nOptions = { logger: () => {} };
2330
const streami18n = new Streami18n(streami18nOptions);
@@ -184,24 +191,53 @@ describe('setLanguage - switch to french', () => {
184191
});
185192
});
186193

187-
describe('formatters property', () => {
188-
it('contains the default timestampFormatter', () => {
189-
expect(new Streami18n().formatters.timestampFormatter).toBeDefined();
190-
});
191-
it('allows to override the default timestampFormatter', async () => {
192-
const i18n = new Streami18n({
193-
formatters: { timestampFormatter: () => () => 'custom' },
194-
translationsForLanguage: { abc: '{{ value | timestampFormatter }}' },
194+
describe('Streami18n timezone', () => {
195+
describe.each([['moment', moment]])('%s', (moduleName, module) => {
196+
it('is by default the local timezone', () => {
197+
const streamI18n = new Streami18n({ DateTimeParser: module });
198+
const date = new Date();
199+
expect(streamI18n.tDateTimeParser(date).format('H')).toBe(date.getHours().toString());
195200
});
196-
await i18n.init();
197-
expect(i18n.t('abc')).toBe('custom');
198-
});
199-
it('allows to add new custom formatter', async () => {
200-
const i18n = new Streami18n({
201-
formatters: { customFormatter: () => () => 'custom' },
202-
translationsForLanguage: { abc: '{{ value | customFormatter }}' },
201+
202+
it('can be set to different timezone on init', () => {
203+
const streamI18n = new Streami18n({ DateTimeParser: module, timezone: 'Europe/Prague' });
204+
const date = new Date();
205+
expect(streamI18n.tDateTimeParser(date).format('H')).not.toBe(date.getHours().toString());
206+
expect(streamI18n.tDateTimeParser(date).format('H')).not.toBe(
207+
(date.getUTCHours() - 2).toString(),
208+
);
209+
});
210+
211+
it('is ignored if datetime parser does not support timezones', () => {
212+
const tz = module.tz;
213+
delete module.tz;
214+
215+
const streamI18n = new Streami18n({ DateTimeParser: module, timezone: 'Europe/Prague' });
216+
const date = new Date();
217+
expect(streamI18n.tDateTimeParser(date).format('H')).toBe(date.getHours().toString());
218+
219+
module.tz = tz;
220+
});
221+
describe('formatters property', () => {
222+
it('contains the default timestampFormatter', () => {
223+
expect(new Streami18n().formatters.timestampFormatter).toBeDefined();
224+
});
225+
it('allows to override the default timestampFormatter', async () => {
226+
const i18n = new Streami18n({
227+
formatters: { timestampFormatter: () => () => 'custom' },
228+
translationsForLanguage: { abc: '{{ value | timestampFormatter }}' },
229+
});
230+
await i18n.init();
231+
expect(i18n.t('abc')).toBe('custom');
232+
});
233+
it('allows to add new custom formatter', async () => {
234+
const i18n = new Streami18n({
235+
formatters: { customFormatter: () => () => 'custom' },
236+
translationsForLanguage: { abc: '{{ value | customFormatter }}' },
237+
});
238+
await i18n.init();
239+
expect(i18n.t('abc')).toBe('custom');
240+
});
203241
});
204-
await i18n.init();
205-
expect(i18n.t('abc')).toBe('custom');
206242
});
207243
});

package/src/utils/i18n/Streami18n.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import localeData from 'dayjs/plugin/localeData';
44
import LocalizedFormat from 'dayjs/plugin/localizedFormat';
55
import relativeTime from 'dayjs/plugin/relativeTime';
66
import updateLocale from 'dayjs/plugin/updateLocale';
7+
import utc from 'dayjs/plugin/utc';
78
import i18n, { FallbackLng, TFunction } from 'i18next';
89

9-
import type moment from 'moment';
10+
import type momentTimezone from 'moment-timezone';
1011

1112
import { calendarFormats } from './calendarFormats';
1213
import {
@@ -54,6 +55,7 @@ const defaultNS = 'translation';
5455
const defaultLng = 'en';
5556

5657
Dayjs.extend(updateLocale);
58+
Dayjs.extend(utc);
5759

5860
Dayjs.updateLocale('en', {
5961
calendar: calendarFormats.en,
@@ -147,18 +149,28 @@ const en_locale = {
147149
weekdays: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
148150
};
149151

152+
type DateTimeParserModule = typeof Dayjs | typeof momentTimezone;
153+
150154
// Type guards to check DayJs
151-
const isDayJs = (dateTimeParser: typeof Dayjs | typeof moment): dateTimeParser is typeof Dayjs =>
155+
const isDayJs = (dateTimeParser: DateTimeParserModule): dateTimeParser is typeof Dayjs =>
152156
(dateTimeParser as typeof Dayjs).extend !== undefined;
153157

158+
type TimezoneParser = {
159+
tz: momentTimezone.MomentTimezone | Dayjs.Dayjs;
160+
};
161+
162+
const supportsTz = (dateTimeParser: unknown): dateTimeParser is TimezoneParser =>
163+
(dateTimeParser as TimezoneParser).tz !== undefined;
164+
154165
type Streami18nOptions = {
155-
DateTimeParser?: typeof Dayjs | typeof moment;
166+
DateTimeParser?: DateTimeParserModule;
156167
dayjsLocaleConfigForLanguage?: Partial<ILocale>;
157168
debug?: boolean;
158169
disableDateTimeTranslations?: boolean;
159170
formatters?: Partial<PredefinedFormatters> & CustomFormatters;
160171
language?: string;
161172
logger?: (msg?: string) => void;
173+
timezone?: string;
162174
translationsForLanguage?: Partial<typeof enTranslations>;
163175
};
164176

@@ -385,10 +397,14 @@ export class Streami18n {
385397
*/
386398
logger: (msg?: string) => void;
387399
currentLanguage: string;
388-
DateTimeParser: typeof Dayjs | typeof moment;
400+
DateTimeParser: DateTimeParserModule;
389401
formatters: PredefinedFormatters & CustomFormatters = predefinedFormatters;
390402
isCustomDateTimeParser: boolean;
391403
i18nextConfig: I18NextConfig;
404+
/**
405+
* A valid TZ identifier string (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
406+
*/
407+
timezone?: string;
392408

393409
/**
394410
* Constructor accepts following options:
@@ -427,6 +443,7 @@ export class Streami18n {
427443

428444
this.currentLanguage = finalOptions.language;
429445
this.DateTimeParser = finalOptions.DateTimeParser;
446+
this.timezone = finalOptions.timezone;
430447
this.formatters = { ...predefinedFormatters, ...options?.formatters };
431448

432449
try {
@@ -504,19 +521,19 @@ export class Streami18n {
504521
}
505522

506523
this.tDateTimeParser = (timestamp) => {
507-
if (finalOptions.disableDateTimeTranslations || !this.localeExists(this.currentLanguage)) {
508-
/**
509-
* TS needs to know which is being called to accept the chain call
510-
*/
511-
if (isDayJs(this.DateTimeParser)) {
512-
return this.DateTimeParser(timestamp).locale(defaultLng);
513-
}
514-
return this.DateTimeParser(timestamp).locale(defaultLng);
524+
const language =
525+
finalOptions.disableDateTimeTranslations || !this.localeExists(this.currentLanguage)
526+
? defaultLng
527+
: this.currentLanguage;
528+
529+
// If the DateTimeParser is not a Dayjs instance, we assume it is a Moment instance.
530+
if (!isDayJs(this.DateTimeParser)) {
531+
return supportsTz(this.DateTimeParser) && this.timezone
532+
? this.DateTimeParser(timestamp).tz(this.timezone).locale(language)
533+
: this.DateTimeParser(timestamp).locale(language);
515534
}
516-
if (isDayJs(this.DateTimeParser)) {
517-
return this.DateTimeParser(timestamp).locale(this.currentLanguage);
518-
}
519-
return this.DateTimeParser(timestamp).locale(this.currentLanguage);
535+
536+
return this.DateTimeParser(timestamp).locale(language);
520537
};
521538
}
522539

package/yarn.lock

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8765,10 +8765,17 @@ mktemp@~0.4.0:
87658765
resolved "https://registry.yarnpkg.com/mktemp/-/mktemp-0.4.0.tgz#6d0515611c8a8c84e484aa2000129b98e981ff0b"
87668766
integrity sha512-IXnMcJ6ZyTuhRmJSjzvHSRhlVPiN9Jwc6e59V0bEJ0ba6OBeX2L0E+mRN1QseeOF4mM+F1Rit6Nh7o+rl2Yn/A==
87678767

8768-
8769-
version "2.29.2"
8770-
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4"
8771-
integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==
8768+
moment-timezone@^0.5.45:
8769+
version "0.5.45"
8770+
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.45.tgz#cb685acd56bac10e69d93c536366eb65aa6bcf5c"
8771+
integrity sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==
8772+
dependencies:
8773+
moment "^2.29.4"
8774+
8775+
moment@^2.29.4:
8776+
version "2.30.1"
8777+
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
8778+
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
87728779

87738780
move-concurrently@^1.0.1:
87748781
version "1.0.1"

0 commit comments

Comments
 (0)