Skip to content

Commit f68cad3

Browse files
authored
refactor(i18n)!: adopt the shared i18n layer from stream-chat/i18n (#3271)
Adopts the shared i18n layer from `stream-chat/i18n`, deleting ~1,100 lines of runtime this package no longer needs to own: `Streami18n`, the formatter/date half of `i18n/utils.ts`, `TranslationBuilder/TranslationBuilder.ts`, `externalStrings.ts`, and most of the codegen script (249 lines → ~40, over the generator core now ships). What stays here is what is genuinely this SDK's: the generated key catalog, `runtimeDefaults`, and the notification translation topic. **Behaviour changes** - Notification copy is a `Record<CoreNotificationType, Translator>`, so a new identifier in `stream-chat` is a compile error until mapped. Dead rows for identifiers this SDK never emits are gone; three previously-unmapped core identifiers now translate instead of rendering untranslated English. - The 57 `language.*` entries move to core, generated from `TranslationLanguage`, so `MessageTranslationIndicator` drops its `asDynamicKey` + string-compare miss detection. - Reactivity is core's `StateStore`. `setLanguage()` returns `void`; `getTranslators()` is now `init()`. **No deprecated aliases** — `Streami18n` keeps its name, so integrator code is unchanged there. - Two timestamp edge cases render differently; both documented in `ai-docs/i18n-v15-migration.md`. - Drops `i18next` / `dayjs` / `moment-timezone` from `dependencies` — core supplies the first two, and the third's type leak into the published `.d.ts` is replaced by core's structural `DateTimeLike`. Also adds a `catalogRenders` test (this package had no equivalent of RN's regression net) and puts `release-v15` in `size.yml`'s branch filter, which was only running on `master`. **Verified** against a locally packed core: 2,829 tests, `validate-esm`, `validate-cjs`, lint. Adopting the shared layer surfaced seven real defects in it, all fixed in the core PR with regression tests. ⚠️ **Blocked:** needs `stream-chat@10.0.0-rc.3` (GetStream/stream-chat-js#1830). The lockfile is deliberately untouched and must be regenerated once that publishes — until then `yarn install --immutable` fails, hence draft. Pre-existing and not from this PR: `yarn build`'s `tsc` step fails on 3 imports (`APIErrorResponse`, `EventAPIResponse`) removed from core after rc.2. Needs fixing when this package bumps its core range.
1 parent 04a31b1 commit f68cad3

44 files changed

Lines changed: 1653 additions & 3303 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/size.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ on:
44
pull_request:
55
branches:
66
- master
7+
# The v15 release branch. Without it this workflow does not run on any PR stacked onto it, so the
8+
# i18n consolidation's central size claim -- that moving the runtime into `stream-chat/i18n`
9+
# shrinks the root bundle -- goes unmeasured for the whole release.
10+
- release-v15
711
paths-ignore:
812
- '**.test.*'
913
- '**.md'

ai-docs/i18n-v15-migration.md

Lines changed: 215 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,32 @@
11
# i18n changes in v15
22

3-
Two breaking changes, both in v15:
3+
Three breaking changes, all in v15:
44

55
1. **English is the only bundled language.** The `de`, `es`, `fr`, `hi`, `it`, `ja`, `ko`, `nl`,
66
`pt`, `ru` and `tr` dictionaries are gone, along with their `dayjs` locale data.
77
2. **Translation keys are namespaced identifiers**, not the English text. `t('Send Message')`
88
became `t('messageComposer.sendButton.send.ariaLabel', 'Send')`.
9+
3. **The translation runtime moved into `stream-chat`**, shared with the React Native SDK. The class
10+
keeps its name, two of its methods changed shape, and two timestamp edge cases render differently
11+
— see [The shared runtime](#the-shared-runtime).
912

1013
Together these cut ~112 KB gzip (27%) from the bundle: the 11 dictionaries were statically
1114
imported and copied into `Streami18n` at construction, so they shipped even if you never set
1215
`language`.
1316

1417
## Do I need to do anything?
1518

16-
| If you… | Action |
17-
| --------------------------------------------- | ---------------------------------------------- |
18-
| use the SDK in English and never touched i18n | **Nothing.** |
19-
| passed `translationsForLanguage` | Rename your keys — see below |
20-
| called `registerTranslation()` | Rename your keys — see below |
21-
| used a built-in non-English language | Supply the dictionary yourself — see below |
22-
| relied on non-English date formats | Import the `dayjs` locale yourself — see below |
23-
| imported `deTranslations``trTranslations` | Those exports are removed |
19+
| If you… | Action |
20+
| ------------------------------------------------ | ---------------------------------------------- |
21+
| use the SDK in English and never touched i18n | **Nothing.** |
22+
| passed `translationsForLanguage` | Rename your keys — see below |
23+
| called `registerTranslation()` | Rename your keys — see below |
24+
| used a built-in non-English language | Supply the dictionary yourself — see below |
25+
| relied on non-English date formats | Import the `dayjs` locale yourself — see below |
26+
| imported `deTranslations``trTranslations` | Those exports are removed |
27+
| construct `new Streami18n(...)` | **Nothing** — same name, same options object |
28+
| assign `i18n.t` or read `setLanguage()`'s return | Both changed — see below |
29+
| declared `i18next` or `dayjs` yourself | You can drop them; `stream-chat` supplies both |
2430

2531
## Renaming your keys
2632

@@ -191,10 +197,19 @@ export const de = {
191197
'common.back.label': 'Zurück',
192198
} as const satisfies TranslationDictionary;
193199

200+
/** Formatter keys hold `dayjs` / `i18next` expressions, not copy, so they are not "translated". */
201+
type TranslatableKey = Exclude<
202+
keyof TranslationCatalog,
203+
`duration.${string}` | `timestamp.${string}` | `translationBuilderTopic.${string}`
204+
>;
205+
194206
/** Every key still needing German. Hover it to read the list. */
195-
type Untranslated = Exclude<keyof TranslationCatalog, keyof typeof de>;
207+
type Untranslated = Exclude<TranslatableKey, keyof typeof de>;
196208
```
197209

210+
`language.*` (ISO language names) and `relativeTime.*` are ordinary copy and stay in the diff — they
211+
render in the UI like anything else, so a complete language translates them too.
212+
198213
Hovering `Untranslated` in your editor lists the missing keys, and it shrinks as you add them. To
199214
turn "am I complete?" into a build failure — useful in CI after a dependency bump — assert the diff
200215
is empty:
@@ -228,6 +243,139 @@ git show v14.11.0:src/i18n/de.json > de.json
228243
Then rename its keys with the mapping table above and register it. Note the old file's keys are the
229244
_old_ natural-language keys, so it needs the same rename as your own overrides.
230245

246+
## The shared runtime
247+
248+
`Streami18n` used to live in this package. It now lives in `stream-chat` and is shared with
249+
`stream-chat-react-native`, so both SDKs behave identically and a fix reaches both at once. You still
250+
import it from here, and it still carries this SDK's own key catalog and copy.
251+
252+
### `getTranslators()` is now `init()`
253+
254+
Same return value; the old name was a getter that initialized, which is what made it worth renaming.
255+
256+
```ts
257+
// v14
258+
const { t, tDateTimeParser } = await i18n.getTranslators();
259+
260+
// v15
261+
const { t, tDateTimeParser } = await i18n.init();
262+
```
263+
264+
`init()` is idempotent and safe to call concurrently — the promise is memoized, which closes a
265+
re-entry window the old implementation left open.
266+
267+
### `t` is read-only, and `setLanguage()` returns nothing
268+
269+
`t` is published through a reactive store rather than being a mutable field, which is what lets
270+
`<Chat>` pick up a language change without remounting. Two consequences:
271+
272+
```ts
273+
// v14 — assigning `t` directly
274+
(i18n as any).t = myTranslator;
275+
276+
// v15 — publish it, and every subscriber updates
277+
i18n.overrideTFunction(myTranslator);
278+
```
279+
280+
```ts
281+
// v14 — setLanguage returned a translator (sometimes; it had three return shapes)
282+
const t = await i18n.setLanguage('de');
283+
284+
// v15 — it returns void. Read the current `t` from the instance, or let <Chat> re-render.
285+
await i18n.setLanguage('de');
286+
const { t } = i18n.state.getLatestValue();
287+
```
288+
289+
The returned translator was removed deliberately: it went stale on the next language change, so
290+
holding onto it was always a latent bug.
291+
292+
### `getTranslations()` and `getAvailableLanguages()` are gone
293+
294+
Both were public in v14, both leaked internal bookkeeping, and neither had a consumer in this SDK.
295+
296+
```ts
297+
// v14 — reading the raw i18next resource map
298+
i18n.getTranslations().en.translation['some.key'];
299+
300+
// v15 — render the key instead; that is the thing you actually wanted to know
301+
i18n.t('some.key');
302+
```
303+
304+
`getTranslations()` never held this SDK's English copy in the first place: prose renders from the
305+
inline `defaultValue` at each call site, so the resource map only ever contained the bundled formatter
306+
expressions plus whatever had been registered.
307+
308+
```ts
309+
// v14 — "available" included languages created only to carry the bundled defaults,
310+
// so a language nobody registered showed up here
311+
i18n.getAvailableLanguages().includes('de');
312+
313+
// v15
314+
i18n.registeredLanguages.has('de');
315+
```
316+
317+
`registeredLanguages` is now a `ReadonlySet<string>`. Reading it is unchanged; `.add()` no longer
318+
compiles — use `registerTranslation()`, since adding to the set would claim a language is registered
319+
with no dictionary behind it.
320+
321+
Also now internal, none of them documented before: `translations`, `dayjsLocales`,
322+
`isCustomDateTimeParser`, `localeExists()`, `addOrUpdateLocale()`, `validateCurrentLanguage()`. To
323+
register a dayjs locale directly, `stream-chat/i18n` exports `addOrUpdateDayjsLocale()`.
324+
325+
### `useChat` no longer returns `translators`
326+
327+
The i18n wiring moved out of `useChat` into a dedicated `useStreami18n`, matching the hook
328+
`stream-chat-react-native` already had. `useChat` was doing five unrelated jobs — user-agent stamping,
329+
subsystem subscriptions, mutes, i18n and latest-message bookkeeping — and only held the translators to
330+
hand them straight to a provider.
331+
332+
`useChat` is exported, so if you called it directly:
333+
334+
```ts
335+
// v14
336+
const { translators } = useChat({ client, defaultLanguage, i18nInstance });
337+
338+
// v15
339+
const { getAppSettings, latestMessageDatesByChannels, mutes } = useChat({ client });
340+
const translators = useStreami18n({ client, i18nInstance });
341+
```
342+
343+
`useChat` no longer takes `i18nInstance`, which moved to `useStreami18n`. `defaultLanguage` is gone
344+
from both, and from `<Chat>` — see below.
345+
346+
### `defaultLanguage` is removed, and so is browser detection
347+
348+
`<Chat defaultLanguage>` read as a fallback for UI translations, but it never drove them: the
349+
`Streami18n` instance does. All it fed was `userLanguage`, which is the key the SDK reads
350+
`message.i18n[<lang>_text]` with — and a fallback there cannot help, because with no
351+
`client.user.language` the API is not translating at all, so `message.i18n` is absent and the text
352+
falls through to `message.text` regardless.
353+
354+
For the same reason `userLanguage` no longer falls back to the two-letter browser language when that
355+
language happens to have a registered dictionary. Having German UI copy says nothing about whether the
356+
API produces `message.i18n.de_text`, so that branch only ever produced lookups that missed.
357+
`stream-chat-react-native` never had it.
358+
359+
`userLanguage` is now `client.user.language` and nothing else, which is what every one of its consumers
360+
already assumed. A non-English UI comes from registering a dictionary and setting `language` on the
361+
instance; translated messages come from `language` in `connectUser`. The two are independent.
362+
363+
One behavioural improvement comes with it. `userLanguage` tracks `client.user.language` reactively, so
364+
a language changed after connect now reaches the message components — it used to be read as a `useMemo`
365+
dependency with no subscription, so it only refreshed if something else re-rendered. Passing a value
366+
that is not a `Streami18n` warns and falls back to a default instance rather than throwing at render.
367+
368+
### You no longer need `i18next` or `dayjs` in your own dependencies
369+
370+
`stream-chat` depends on both, so they arrive transitively. If you declared them only for this SDK,
371+
remove them — and if you keep them, **match `stream-chat`'s ranges**. Two copies of `dayjs` means
372+
your `import 'dayjs/locale/de'` registers the locale on a different instance than the one formatting
373+
dates, and dates silently stay English:
374+
375+
```bash
376+
find . -maxdepth 4 -name dayjs -type d -path '*node_modules*' # expect exactly one
377+
```
378+
231379
## Date and time
232380

233381
Only the `en` dayjs locale is bundled, and the per-language `calendar` formats the SDK used to ship
@@ -253,6 +401,46 @@ const i18n = new Streami18n({
253401

254402
Or pass your own preconfigured `DateTimeParser` (dayjs or moment).
255403

404+
### Two edge cases render differently
405+
406+
Both are confined to a `timestamp.*` key that specifies **no** format. Every key the SDK ships
407+
specifies one (`format: HH:mm`, `calendar: true`, and so on), so you only see these if you overrode a
408+
timestamp key with an expression that formats nothing.
409+
410+
**A `null` or unparseable timestamp renders as empty**, where v14 rendered the value stringified —
411+
which for `null` was the literal text `null`:
412+
413+
```ts
414+
// a key with no format
415+
'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(calendar: false) }}'
416+
417+
// t('timestamp.MessageTimestamp', { timestamp: null })
418+
// v14 → "null"
419+
// v15 → ""
420+
```
421+
422+
The same applies when you call `predefinedFormatters.timestampFormatter` yourself: it returns `''`
423+
rather than the stringified value. If you relied on that to spot a missing timestamp during
424+
development, check for the empty string instead — rendering the word `null` into a message list was
425+
never intentional.
426+
427+
Note this is specifically about a value that _reaches_ the formatter. Passing no `timestamp` at all
428+
leaves i18next with nothing to interpolate, so the raw expression comes through unchanged — that was
429+
true in v14 too, and is a sign the option name is misspelled at the call site.
430+
431+
**Unformatted output carries a numeric offset rather than `Z`:**
432+
433+
```ts
434+
// v14 → 2019-04-03T14:42:47Z
435+
// v15 → 2019-04-03T14:42:47+00:00
436+
```
437+
438+
Same instant, different ISO spelling. v14 called dayjs's `.tz()` on every parse even when no
439+
`timezone` was configured, which marks the instance as zoned and changes how `.format()` with no
440+
template renders. v15 applies `.tz()` only when you actually set `timezone`, matching what the React
441+
Native SDK already did. Configure a `format` on the key if you need a specific shape — relying on
442+
dayjs's default is fragile either way.
443+
256444
## Why keys changed at all
257445

258446
The old keys _were_ the English copy, which meant:
@@ -266,8 +454,20 @@ Keys are now stable, and the English copy travels inline at the call site as i18
266454
`defaultValue`. That keeps the copy readable where it is used, and means a key you do not supply
267455
still renders English rather than a raw key path.
268456

269-
The exception is the ~71 keys that carry no inline copy — `timestamp.*` and `duration.*` (formatter
270-
expressions), `language.*` (built from a runtime language code), and the postProcessor directive.
271-
Those are bundled in `runtimeDefaults` instead, and both `registerTranslation()` and
272-
`translationsForLanguage` merge your dictionary over them, so you inherit the working defaults
273-
without listing them. You only need to supply one if you want a different date format.
457+
The exception is the 15 keys that carry no inline copy — `timestamp.*` and `duration.*` (formatter
458+
expressions) and the postProcessor directive. Those are bundled in `runtimeDefaults` instead, and both
459+
`registerTranslation()` and `translationsForLanguage` merge your dictionary over them, so you inherit
460+
the working defaults without listing them. You only need to supply one if you want a different date
461+
format.
462+
463+
Two more sets are still overridable but now come from `stream-chat`, because it owns the code that
464+
renders them:
465+
466+
- **`language.*`** — the 57 language names used to say "Translated from German" on an auto-translated
467+
message. They are derived from the same language union the API uses, so the set can no longer drift
468+
out of sync with it.
469+
- **`relativeTime.*`**`Today`, `Yesterday`, `{{ count }}d ago`, `{{ count }}w ago`, used by
470+
`timestampFormatter(relativeCompact: true)`.
471+
472+
Both are part of your catalog's types, so you override them exactly as before — `t('language.de')` is
473+
a checked key, and a typo in either is still a compile error.

examples/tutorial/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"emoji-mart": "^5.6.0",
1717
"react": "^19.2.6",
1818
"react-dom": "^19.2.6",
19-
"stream-chat": "10.0.0-rc.4",
19+
"stream-chat": "10.0.0-rc.5",
2020
"stream-chat-react": "workspace:^"
2121
},
2222
"devDependencies": {

examples/vite/docs-playwright/take-tier1-screenshots.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,9 @@ async function run() {
159159

160160
console.log('\n✅ Done!');
161161
console.log('\n⚠ Not automated (need app-level config):');
162-
console.log(' - Localization2.png — needs defaultLanguage="it" on Chat component');
162+
console.log(
163+
' - Localization2.png — needs an it dictionary registered and language: "it" on the Streami18n instance',
164+
);
163165
console.log(' - Diacritics.png — needs user with diacritical name in the channel');
164166
console.log(
165167
' - Transliteration.png — needs useMentionsTransliteration prop + Cyrillic user',

examples/vite/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"modern-normalize": "^3.0.1",
1818
"react": "^19.2.6",
1919
"react-dom": "^19.2.6",
20-
"stream-chat": "10.0.0-rc.4",
20+
"stream-chat": "10.0.0-rc.5",
2121
"stream-chat-react": "workspace:^"
2222
},
2323
"devDependencies": {

examples/vite/src/i18n/de.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -594,11 +594,13 @@ export const deTranslations = {
594594
'notification.commandDisabled': 'Befehl nicht verfügbar',
595595
'notification.commandDisabledWhileEditing': 'Befehl beim Bearbeiten nicht verfügbar',
596596
'notification.commandDisabledWhileReplying': 'Befehl beim Antworten nicht verfügbar',
597+
'notification.commandNotReady': 'Befehl kann noch nicht gesendet werden',
597598
'notification.dismissNotification.ariaLabel': 'Benachrichtigung schließen',
598-
'notification.jumpToFirstUnreadFailed':
599-
'Sprung zur ersten ungelesenen Nachricht fehlgeschlagen',
600599
'notification.list.notifications.ariaLabel': 'Benachrichtigungen',
601600
'notification.locationGetFailed': 'Standort konnte nicht ermittelt werden',
601+
'notification.messageJumpFailed': 'Sprung zur Nachricht fehlgeschlagen',
602+
'notification.messageJumpToLatestFailed':
603+
'Sprung zur neuesten Nachricht fehlgeschlagen',
602604
'notification.locationShareFailed': 'Standort konnte nicht geteilt werden',
603605
'notification.pollCreateFailed': 'Umfrage konnte nicht erstellt werden',
604606
'notification.pollCreateFailedWithReason':
@@ -659,7 +661,6 @@ export const deTranslations = {
659661
'Mehr als eine Option auswählen',
660662
'poll.multipleAnswersField.typeNumber210.label': 'Gib eine Zahl von 2 bis 10 ein',
661663
'poll.nameField.askQuestion.placeholder': 'Stelle eine Frage',
662-
'poll.nameField.error.text': 'Fehler',
663664
'poll.nameField.questionRequired.label': 'Eine Frage ist erforderlich',
664665
'poll.optionFieldSet.addOption.placeholder': 'Option hinzufügen',
665666
'poll.optionFieldSet.option.ariaLabel': 'Option {{ position }}',

examples/vite/src/i18n/it.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -592,12 +592,14 @@ export const itTranslations = {
592592
'Comando non disponibile durante la modifica',
593593
'notification.commandDisabledWhileReplying':
594594
'Comando non disponibile durante la risposta',
595+
'notification.commandNotReady': "Comando non pronto per l'invio",
595596
'notification.dismissNotification.ariaLabel': 'Chiudi la notifica',
596-
'notification.jumpToFirstUnreadFailed':
597-
'Impossibile passare al primo messaggio non letto',
598597
'notification.list.notifications.ariaLabel': 'Notifiche',
599598
'notification.locationGetFailed': 'Impossibile recuperare la posizione',
600599
'notification.locationShareFailed': 'Impossibile condividere la posizione',
600+
'notification.messageJumpFailed': 'Impossibile passare al messaggio',
601+
'notification.messageJumpToLatestFailed':
602+
'Impossibile passare al messaggio più recente',
601603
'notification.pollCreateFailed': 'Impossibile creare il sondaggio',
602604
'notification.pollCreateFailedWithReason':
603605
'Impossibile creare il sondaggio a causa di {{reason}}',
@@ -655,7 +657,6 @@ export const itTranslations = {
655657
'Seleziona più di una opzione',
656658
'poll.multipleAnswersField.typeNumber210.label': 'Inserisci un numero da 2 a 10',
657659
'poll.nameField.askQuestion.placeholder': 'Fai una domanda',
658-
'poll.nameField.error.text': 'Errore',
659660
'poll.nameField.questionRequired.label': 'La domanda è obbligatoria',
660661
'poll.optionFieldSet.addOption.placeholder': 'Aggiungi un’opzione',
661662
'poll.optionFieldSet.option.ariaLabel': 'Opzione {{ position }}',

0 commit comments

Comments
 (0)