From 5cceac78f08a5d7111ce58b11e88e4596bad307d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 09:48:31 +0200 Subject: [PATCH 01/10] refactor(i18n)!: adopt the shared i18n layer from stream-chat/i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces this SDK's own translation runtime with the shared one in `stream-chat/i18n`, which the React Native SDK will adopt too. What stays here is the part that is genuinely this package's: its generated key catalog, its bundled data, and its notification translators. Requires `stream-chat@10.0.0-rc.3` for the `stream-chat/i18n` subpath, so this cannot merge before core publishes. The lockfile is deliberately untouched -- regenerate it once that release exists. Deleted (~1,100 lines): `Streami18n.ts`, the formatter half of `utils.ts`, `TranslationBuilder/TranslationBuilder.ts`, `externalStrings.ts`, and `scripts/i18n-call-sites.mts`. `src/i18n/utils.ts` shrinks to a re-export so the ~15 internal import sites keep working, and the codegen script goes from 352 lines across two files to ~40 lines of configuration. - `types.ts` is now an instantiation of core's catalog-generic helpers, and intersects `LanguageNameCatalog` and `RelativeTimeCatalog` -- keys core renders and therefore owns. That also cut the two imports blocking the move: `MessageContextValue` (a circular UI dependency) and `Moment` (a devDependency type leaking into the published `.d.ts`). - The 57 hand-maintained `language.*` names are gone; core derives them from the same `TranslationLanguage` union the call site reads, so the key is checked. `MessageTranslationIndicator` no longer needs `asDynamicKey` plus a string comparison to detect a name that has no entry. - `translatorsByNotificationType` is `Record`, so a core identifier that gains no translator fails to compile. Two entries went with it: `api:reply:search:failed` and `channel:jumpToFirstUnread:failed` were copied between the two UI SDKs and neither is emitted by this one. Three identifiers that *are* emitted and were unmapped now have translators. - Notification translation dispatches on `notification.type` only. The English-sentence table it fell back to is deleted -- prose matching could only ever mask a missing translator entry. - Poll field errors are keyed on `PollValidationError.code` rather than on the English sentence the LLC produced, so a copy edit upstream can no longer silently stop a translation from applying. - `useChat` subscribes to the i18n `StateStore` instead of registering a single callback that a second caller would clobber. It keeps its truthiness check on `i18nInstance` -- an `instanceof` check would silently discard an instance from a second copy of the package. - The module-scope `Dayjs.extend` calls are gone from `TranslationContext`; core's `defaultDateTimeParser` registers the plugins on first use, so the context default still formats dates. This is the edit most likely to be reverted by accident, and it fails silently -- as malformed dates, not a throw. - `dayjs` and `i18next` move out of `dependencies`: core supplies them. Their devDependency ranges now match core's exactly, because a second `dayjs` copy breaks `instanceof` and, worse, means an integrator's `dayjs/locale/xx` import lands on a different instance than the one formatting dates. That duplication was real here until the ranges were aligned. - The `sideEffects` entry for `./dist/i18n/Streami18n.js` is removed; vite never emitted that path, so it matched nothing. - New `catalogRenders` test, ported from the RN SDK: renders all 572 catalog entries and every plural at four counts, asserting none surfaces as its own dotted path or leaks a `{{ placeholder }}`. It is the only check that the declared copy actually resolves -- the codegen proves a key *has* copy, not that it comes out. Interpolation values are derived from each key's own copy so a leftover placeholder means a real failure. Two deliberate rendering changes, both confined to a key that specifies no format: an unparseable or missing timestamp renders as empty rather than the literal text `null`, and unformatted output is `2019-04-03T14:42:47+00:00` rather than `…Z` because `.tz()` is now applied only when a timezone is actually configured. BREAKING CHANGE: `Streami18n` is renamed `StreamI18n`, matching the shared class. The old name is exported as a deprecated alias for one release cycle. BREAKING CHANGE: `Streami18n.t` is a state-backed getter and can no longer be assigned. Use `overrideTFunction(t)`, which publishes to the store `` subscribes to. `setLanguage()` now returns `void` for the same reason. --- package.json | 11 +- scripts/generate-i18n-keys.mts | 268 +------- scripts/i18n-call-sites.mts | 103 --- src/components/Chat/__tests__/Chat.test.tsx | 31 +- src/components/Chat/hooks/useChat.ts | 33 +- .../Message/MessageTranslationIndicator.tsx | 11 +- .../__tests__/MessageTimestamp.test.tsx | 10 +- .../MultipleAnswersField.tsx | 25 +- .../Poll/PollCreationDialog/NameField.tsx | 10 +- .../PollCreationDialog/OptionFieldSet.tsx | 14 +- src/context/TranslationContext.tsx | 16 +- src/i18n/Streami18n.ts | 609 ++---------------- .../TranslationBuilder/TranslationBuilder.ts | 138 ---- src/i18n/TranslationBuilder/index.ts | 13 +- .../NotificationTranslationTopic.ts | 14 +- .../notifications/translators.ts | 10 +- .../translatorsByNotificationType.ts | 90 ++- .../NotificationTranslationBuilder.test.ts | 48 +- src/i18n/__tests__/catalog.fixture.json | 574 +++++++++++++++++ src/i18n/__tests__/catalogRenders.test.ts | 131 ++++ src/i18n/__tests__/utils.test.ts | 28 +- src/i18n/externalStrings.ts | 47 -- src/i18n/keys.ts | 72 +-- src/i18n/runtimeDefaults.ts | 57 -- src/i18n/types.ts | 343 ++-------- src/i18n/utils.ts | 311 +-------- 26 files changed, 1084 insertions(+), 1933 deletions(-) delete mode 100644 scripts/i18n-call-sites.mts delete mode 100644 src/i18n/TranslationBuilder/TranslationBuilder.ts create mode 100644 src/i18n/__tests__/catalog.fixture.json create mode 100644 src/i18n/__tests__/catalogRenders.test.ts delete mode 100644 src/i18n/externalStrings.ts diff --git a/package.json b/package.json index 5a401a92fb..7081bdb1be 100644 --- a/package.json +++ b/package.json @@ -79,8 +79,7 @@ } }, "sideEffects": [ - "*.css", - "./dist/i18n/Streami18n.js" + "*.css" ], "keywords": [ "chat", @@ -96,11 +95,9 @@ "@floating-ui/react": "^0.27.19", "@react-aria/focus": "^3.22.0", "clsx": "^2.1.1", - "dayjs": "^1.11.20", "emoji-regex": "^9.2.2", "fix-webm-duration": "^1.0.6", "hast-util-find-and-replace": "^5.0.1", - "i18next": "^26.3.6", "linkifyjs": "^4.3.3", "lodash.debounce": "^4.0.8", "lodash.mergewith": "^4.6.2", @@ -132,7 +129,7 @@ "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", - "stream-chat": "10.0.0-rc.2" + "stream-chat": "10.0.0-rc.3" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -186,6 +183,7 @@ "@vitest/eslint-plugin": "^1.6.20", "concurrently": "^9.2.1", "conventional-changelog-conventionalcommits": "^9.3.1", + "dayjs": "^1.11.13", "emoji-mart": "^5.6.0", "eslint": "^9.39.4", "eslint-plugin-import": "^2.32.0", @@ -194,6 +192,7 @@ "eslint-plugin-sort-destructure-keys": "^3.0.0", "globals": "^17.6.0", "husky": "^9.1.7", + "i18next": "^26.3.6", "jsdom": "^29.1.1", "lint-staged": "^17.0.5", "moment-timezone": "^0.5.48", @@ -202,7 +201,7 @@ "react-dom": "^19.2.6", "sass": "^1.100.0", "semantic-release": "^25.0.3", - "stream-chat": "10.0.0-rc.2", + "stream-chat": "10.0.0-rc.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vite": "^8.1.3", diff --git a/scripts/generate-i18n-keys.mts b/scripts/generate-i18n-keys.mts index f25c8f72b4..d3564e4ecb 100644 --- a/scripts/generate-i18n-keys.mts +++ b/scripts/generate-i18n-keys.mts @@ -1,249 +1,41 @@ -// Generates src/i18n/keys.ts — the type-only catalog of every translation key mapped to its -// English copy. `src/i18n/types.ts` derives `TranslationKey` / `StreamTFunction` from it, so a -// typo'd key is a compile error. +// Regenerates src/i18n/keys.ts — the type-only catalog of every translation key mapped to its English +// copy. The i18n types derive `TranslationKey` / `StreamTFunction` from it, so a typo'd key is a compile +// error rather than a string that silently stops rendering. // -// It is type-only on purpose: no runtime value is emitted, so it costs nothing in the bundle. -// (Deriving the type from `typeof import('./en.json')` would not work for consumers either — tsc -// does not copy JSON into dist/types.) +// The generator itself lives in `stream-chat/i18n/codegen`, shared with the React Native SDK. Only this +// package's paths and prefixes are configured here; the call-site reader, the four hard-fail guards and +// the emitter are all core's. // -// The catalog has exactly two sources, and both are the place the copy is actually used: -// -// 1. Inline defaults at the call sites — `t('message.status.sent.text', 'Sent')`. 562 keys. -// i18next renders these from the `defaultValue`, so they are never bundled as data. -// 2. src/i18n/runtimeDefaults.ts — hand-maintained, and the only translation data that ships. -// Just the keys with no inline copy to fall back on: `language.*` (built from a runtime -// code), `timestamp.*` / `duration.*` (formatter expressions passed around as prop values), -// and the postProcessor directive. 71 keys. -// -// There is deliberately no checked-in en.json. It was a third copy of strings that already exist -// in those two places, and keeping it in sync needed an extract pass plus a sync pass. Pass -// `--json ` to write the translatable keys out as JSON on demand, for a translator or a TMS, -// and add `--all` to include the formatter expressions. -// -// Run by `yarn build-translations`. -import fs from 'node:fs'; +// Run by `yarn build-translations`, from the package root — every path below is relative to it. +// `yarn validate-translations` runs it and fails on any diff, which is the drift gate. import ts from 'typescript'; -import { readCallSiteCopy } from './i18n-call-sites.mts'; - -const RUNTIME_DEFAULTS = 'src/i18n/runtimeDefaults.ts'; -const EXTERNAL_STRINGS = 'src/i18n/externalStrings.ts'; -const KEYS_OUT = 'src/i18n/keys.ts'; - -// Values under these prefixes are dayjs/i18next expressions, not copy. Mirrors `FormatterKey` in -// src/i18n/types.ts. Excluded from the JSON export, which is a translator-facing file. -const FORMATTER_PREFIXES = ['timestamp.', 'duration.', 'translationBuilderTopic.']; -const isFormatterKey = (key: string) => - FORMATTER_PREFIXES.some((prefix) => key.startsWith(prefix)); - -// Some formatter values embed English day words in their `calendarFormats` (dayjs escapes literal -// text in brackets), so excluding them from the export does drop translatable text. It is not -// translatable *as copy* — the format string has to be rewritten — so the guide routes it through a -// key override instead. Counted rather than hardcoded so the note below cannot go stale. -const hasEnglishWords = (value: string) => - [...value.matchAll(/\[([^\]]+)\]/g)].some(([, literal]) => /[A-Za-z]{2}/.test(literal)); - -// `EXTERNAL_STRING_KEYS` entries whose LLC wording deliberately differs from the SDK's own copy for -// the same concept. Everything else must match, so a copy edit cannot silently desynchronise the -// two. See src/i18n/externalStrings.ts. -const REPHRASED_EXTERNAL_STRINGS = new Set([ - 'Command not ready to be sent', // SDK: 'Command not available' - 'Failed to share the location', // SDK: 'Failed to share location' -]); +import { generateI18nKeys } from 'stream-chat/i18n/codegen'; const jsonFlag = process.argv.indexOf('--json'); -const JSON_OUT = jsonFlag === -1 ? null : process.argv[jsonFlag + 1]; -if (jsonFlag !== -1 && (!JSON_OUT || JSON_OUT.startsWith('--'))) { +const jsonOut = jsonFlag === -1 ? undefined : process.argv[jsonFlag + 1]; + +if (jsonFlag !== -1 && (!jsonOut || jsonOut.startsWith('--'))) { console.error('--json requires an output path'); process.exit(1); } -// Include the formatter expressions in the export. Off by default: they are not copy, and a TMS -// that "translates" them breaks date rendering and the notification postProcessor. -const INCLUDE_FORMATS = process.argv.includes('--all'); - -const fail = (message: string, lines: string[]) => { - console.error(`\n${message}`); - for (const line of lines) console.error(` ${line}`); - process.exit(1); -}; - -// --------------------------------------------------------------------------------------- -// Read the hand-maintained string maps -// --------------------------------------------------------------------------------------- -// Parsed rather than imported: `await import()` works under Node's type stripping but warns -// MODULE_TYPELESS_PACKAGE_JSON on every run, and the package cannot be `"type": "module"`. -const readStringMap = (file: string, exportName: string): Map => { - const source = ts.createSourceFile( - file, - fs.readFileSync(file, 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - const out = new Map(); - let found = false; - ts.forEachChild(source, (node) => { - if (!ts.isVariableStatement(node)) return; - for (const declaration of node.declarationList.declarations) { - if ( - !ts.isIdentifier(declaration.name) || - declaration.name.text !== exportName || - !declaration.initializer - ) { - continue; - } - // `= { … } as const` / `satisfies …` are both fine. - let initializer: ts.Expression = declaration.initializer; - while (ts.isAsExpression(initializer) || ts.isSatisfiesExpression(initializer)) { - initializer = initializer.expression; - } - if (!ts.isObjectLiteralExpression(initializer)) continue; - found = true; - for (const property of initializer.properties) { - if (!ts.isPropertyAssignment(property)) { - fail(`${exportName} in ${file} must be a flat object of string literals.`, [ - property.getText(source).slice(0, 80), - ]); - } - const assignment = property as ts.PropertyAssignment; - if ( - !ts.isStringLiteralLike(assignment.name) || - !ts.isStringLiteralLike(assignment.initializer) - ) { - fail(`${exportName} entries must be 'quoted.key': 'string literal'.`, [ - assignment.getText(source).slice(0, 80), - ]); - } - out.set( - (assignment.name as ts.StringLiteralLike).text, - (assignment.initializer as ts.StringLiteralLike).text, - ); - } - } +try { + generateI18nKeys({ + fixtureOut: 'src/i18n/__tests__/catalog.fixture.json', + // `language.*` names come from `stream-chat/i18n` rather than from this package's + // runtimeDefaults, so they are excluded from the translator export alongside the formatter + // expressions — a TMS should not be asked to translate the SDK's own language list. + extraFormatterPrefixes: ['translationBuilderTopic.', 'language.'], + json: jsonOut + ? { includeFormats: process.argv.includes('--all'), out: jsonOut } + : undefined, + keysOut: 'src/i18n/keys.ts', + migrationGuideRef: 'ai-docs/i18n-v15-migration.md#date-and-time', + runtimeDefaultsPath: 'src/i18n/runtimeDefaults.ts', + ts, }); - - if (!found) { - fail(`could not find an exported \`${exportName}\` object literal in`, [file]); - } - return out; -}; - -const runtimeDefaults = readStringMap(RUNTIME_DEFAULTS, 'runtimeDefaults'); -const { conflicts, copy: inlineCopy, withoutCopy } = readCallSiteCopy(); - -// --------------------------------------------------------------------------------------- -// Cross-check the two sources -// --------------------------------------------------------------------------------------- -if (conflicts.length) { - fail( - `${conflicts.length} key(s) used with conflicting inline copy — a key must render one thing:`, - conflicts.map( - (c) => - `${c.key}\n ${JSON.stringify(c.a)}\n ${JSON.stringify(c.b)} (${c.file})`, - ), - ); -} - -// A key called without inline copy resolves from the bundled resource or not at all — i18next -// would render the raw dotted key in the UI. -const unresolvable = [...withoutCopy].filter(([key]) => !runtimeDefaults.has(key)); -if (unresolvable.length) { - fail( - `${unresolvable.length} key(s) are called with no inline default and are missing from ${RUNTIME_DEFAULTS}.\n` + - `They would render as the raw key. Either pass the English copy inline — t('key', 'Copy') —\n` + - `or add an entry to ${RUNTIME_DEFAULTS}:`, - unresolvable.map(([key, file]) => `${key} (${file})`), - ); -} - -// The bundled resource wins over a `defaultValue`, so a key in both places silently renders the -// bundled string and ignores the copy at the call site. -const shadowed = [...runtimeDefaults.keys()].filter((key) => inlineCopy.has(key)); -if (shadowed.length) { - fail( - `${shadowed.length} key(s) are in both ${RUNTIME_DEFAULTS} and an inline default.\n` + - `The bundled value wins, so editing the call site would silently change nothing.\n` + - `Remove the runtimeDefaults entry:`, - shadowed.map( - (key) => - `${key}\n bundled: ${JSON.stringify(runtimeDefaults.get(key))}\n call site: ${JSON.stringify(inlineCopy.get(key))}`, - ), - ); -} - -// --------------------------------------------------------------------------------------- -// keys.ts -// --------------------------------------------------------------------------------------- -const catalog = new Map([...inlineCopy, ...runtimeDefaults]); -const keys = [...catalog.keys()].sort(); - -// `translateExternalString` passes the raw LLC sentence as the `defaultValue`, so that is what -// renders in English — not the key's catalog copy. When the two differ, `TranslationCatalog` and the -// JSON export advertise a string the external path never produces. Deliberate rephrasings are -// allowlisted above; anything else means a copy edit desynchronised the two. -const externalStrings = readStringMap(EXTERNAL_STRINGS, 'EXTERNAL_STRING_KEYS'); -const desynchronised = [...externalStrings] - .filter(([raw]) => !REPHRASED_EXTERNAL_STRINGS.has(raw)) - .filter(([raw, key]) => catalog.get(key) !== raw); -if (desynchronised.length) { - fail( - `${desynchronised.length} entr(ies) in ${EXTERNAL_STRINGS} map an external string onto a key\n` + - `whose catalog copy differs. English renders the external string, so the catalog would\n` + - `advertise copy that never appears. Align the two, or add the external string to\n` + - `REPHRASED_EXTERNAL_STRINGS in this script if the wording differs on purpose:`, - desynchronised.map( - ([raw, key]) => - `${key}\n catalog: ${JSON.stringify(catalog.get(key))}\n external: ${JSON.stringify(raw)}`, - ), - ); -} - -const lines: string[] = [ - '// AUTO-GENERATED by scripts/generate-i18n-keys.mts — do not edit by hand.', - '// Regenerate with `yarn build-translations`. CI fails if this file is out of sync.', - '//', - '// Type-only: no runtime value is emitted, so this adds nothing to the bundle.', - '', - '/**', - ' * Every translation entry shipped with the SDK, mapped to its English copy.', - ' *', - ' * Plural entries appear as `_one` / `_other`; call sites use the bare `` and', - ' * pass `count`. See {@link TranslationKey}.', - ' */', - 'export type TranslationCatalog = {', -]; -for (const key of keys) { - lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(catalog.get(key))};`); -} -lines.push('};', ''); -fs.writeFileSync(KEYS_OUT, lines.join('\n')); - -console.log( - `generated ${KEYS_OUT} (${keys.length} entries, type-only) — ` + - `${inlineCopy.size} from inline defaults, ${runtimeDefaults.size} bundled`, -); - -// --------------------------------------------------------------------------------------- -// Optional JSON export, for translators / a TMS -// --------------------------------------------------------------------------------------- -if (JSON_OUT) { - const exported = INCLUDE_FORMATS ? keys : keys.filter((key) => !isFormatterKey(key)); - const asObject: Record = {}; - for (const key of exported) asObject[key] = catalog.get(key)!; - fs.writeFileSync(JSON_OUT, `${JSON.stringify(asObject, null, 2)}\n`); - - const excludedKeys = keys.filter((key) => !exported.includes(key)); - console.log( - `wrote ${JSON_OUT} (${exported.length} ${INCLUDE_FORMATS ? 'entries, formatter expressions included' : 'translatable entries'})`, - ); - if (excludedKeys.length) { - const withEnglish = excludedKeys.filter((key) => hasEnglishWords(catalog.get(key)!)); - console.log( - ` excluded ${excludedKeys.length} formatter expressions (${FORMATTER_PREFIXES.join(', ')}) — ` + - `not copy, and\n a TMS that translates them breaks date rendering and notifications. ` + - `Pass --all to include them.\n ${withEnglish.length} of them do carry English day words; ` + - `those are translated by overriding the key —\n see ` + - `ai-docs/i18n-v15-migration.md#date-and-time.`, - ); - } +} catch (error) { + // The generator throws with every guard failure formatted; exit non-zero so CI fails. + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); } diff --git a/scripts/i18n-call-sites.mts b/scripts/i18n-call-sites.mts deleted file mode 100644 index d3ab917dfc..0000000000 --- a/scripts/i18n-call-sites.mts +++ /dev/null @@ -1,103 +0,0 @@ -// Reads every `t()` call in the library source and reports the translation keys it declares. -// -// The call sites are the source of truth for the catalog. A prose key exists because some -// component asks for it and passes its English copy inline; delete the call and the key is gone. -// That is what removed the need for a checked-in en.json and for `i18next-cli`'s -// extract/removeUnusedKeys pass. -// -// The only keys that cannot be described this way are the ones with no inline copy — a formatter -// expression or a key built from a runtime value. Those live in `src/i18n/runtimeDefaults.ts`, -// which is hand-maintained; `generate-i18n-keys.mts` joins the two and cross-checks them. -import ts from 'typescript'; -import fs from 'node:fs'; -import path from 'node:path'; - -export type CallSiteCopy = { - /** `key -> English copy` for every key written with an inline default. */ - copy: Map; - /** - * `key -> file` for keys called with no inline copy — `t('timestamp.MessageTimestamp', {…})`. - * These must be present in `runtimeDefaults.ts` or they render as the raw key. - */ - withoutCopy: Map; - /** Keys seen with two different inline copies — a key must render one thing. */ - conflicts: Array<{ key: string; a: string; b: string; file: string }>; -}; - -const isTCallee = (expr: ts.Expression): boolean => - (ts.isIdentifier(expr) && expr.text === 't') || - (ts.isPropertyAccessExpression(expr) && expr.name.text === 't'); - -export const sourceFiles = (root = 'src'): string[] => { - const out: string[] = []; - (function walk(dir: string) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === '__tests__' || entry.name === 'mock-builders') continue; - walk(full); - } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { - out.push(full); - } - } - })(root); - return out; -}; - -export const readCallSiteCopy = (root = 'src'): CallSiteCopy => { - const copy = new Map(); - const withoutCopy = new Map(); - const conflicts: CallSiteCopy['conflicts'] = []; - - const record = (key: string, value: string, file: string) => { - const existing = copy.get(key); - if (existing !== undefined && existing !== value) { - conflicts.push({ a: existing, b: value, file, key }); - return; - } - copy.set(key, value); - }; - - for (const file of sourceFiles(root)) { - const sourceFile = ts.createSourceFile( - file, - fs.readFileSync(file, 'utf8'), - ts.ScriptTarget.Latest, - true, - file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, - ); - - (function visit(node: ts.Node) { - if (ts.isCallExpression(node) && isTCallee(node.expression)) { - const [keyArg, second] = node.arguments; - if (keyArg && ts.isStringLiteralLike(keyArg)) { - const key = keyArg.text; - if (second && ts.isStringLiteralLike(second)) { - // t('key', 'Copy') - record(key, second.text, file); - } else if (second && ts.isObjectLiteralExpression(second)) { - // t('key', { count, defaultValue_one, defaultValue_other }) — the catalog holds the - // `_one` / `_other` forms, never the bare key. - let plurals = 0; - for (const prop of second.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - const name = prop.name.getText(sourceFile).replace(/['"]/g, ''); - const suffix = name.match(/^defaultValue_(\w+)$/)?.[1]; - if (suffix && ts.isStringLiteralLike(prop.initializer)) { - record(`${key}_${suffix}`, prop.initializer.text, file); - plurals++; - } - } - if (!plurals) withoutCopy.set(key, file); - } else { - // t('key') — carries no inline copy, so it has to resolve from runtimeDefaults. - withoutCopy.set(key, file); - } - } - } - ts.forEachChild(node, visit); - })(sourceFile); - } - - return { conflicts, copy, withoutCopy }; -}; diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index 459bc7ccdd..9e50d11ef7 100644 --- a/src/components/Chat/__tests__/Chat.test.tsx +++ b/src/components/Chat/__tests__/Chat.test.tsx @@ -455,9 +455,11 @@ describe('Chat', () => { it('should use i18n provided in props', async () => { const i18nInstance = new Streami18n(); - await i18nInstance.getTranslators(); - (i18nInstance as any).t = 't'; - (i18nInstance as any).tDateTimeParser = 'tDateTimeParser'; + await i18nInstance.init(); + // `t` is a state-backed getter now, so it cannot be assigned. Swapping the translator is what + // `overrideTFunction` is for -- it publishes to the store, which is what `` subscribes to. + const overridden = (() => 'overridden') as never; + i18nInstance.overrideTFunction(overridden); let context: ChatContextValue; render( @@ -471,16 +473,16 @@ describe('Chat', () => { ); await waitFor(() => { - expect(context.t).toBe(i18nInstance.t); + expect(context.t).toBe(overridden); expect(context.tDateTimeParser).toBe(i18nInstance.tDateTimeParser); }); }); it('props change should update the context', async () => { const i18nInstance = new Streami18n(); - await i18nInstance.getTranslators(); - (i18nInstance as any).t = 't'; - (i18nInstance as any).tDateTimeParser = 'tDateTimeParser'; + await i18nInstance.init(); + const firstT = (() => 'first') as never; + i18nInstance.overrideTFunction(firstT); let context: ChatContextValue; const { rerender } = render( @@ -494,14 +496,14 @@ describe('Chat', () => { ); await waitFor(() => { - expect(context.t).toBe(i18nInstance.t); + expect(context.t).toBe(firstT); expect(context.tDateTimeParser).toBe(i18nInstance.tDateTimeParser); }); const newI18nInstance = new Streami18n(); - await newI18nInstance.getTranslators(); - (newI18nInstance as any).t = 'newT'; - (newI18nInstance as any).tDateTimeParser = 'newtDateTimeParser'; + await newI18nInstance.init(); + const secondT = (() => 'second') as never; + newI18nInstance.overrideTFunction(secondT); rerender( @@ -513,10 +515,9 @@ describe('Chat', () => { , ); await waitFor(() => { - expect(context.t).toBe(newI18nInstance['t']); - expect(context.tDateTimeParser).toBe(newI18nInstance['tDateTimeParser']); - expect(context.t).not.toBe(i18nInstance['t']); - expect(context.tDateTimeParser).not.toBe(i18nInstance['tDateTimeParser']); + expect(context.t).toBe(secondT); + expect(context.t).not.toBe(firstT); + expect(context.tDateTimeParser).toBe(newI18nInstance.tDateTimeParser); }); }); }); diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index ed9f0c9082..45915e285a 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -4,7 +4,7 @@ import type { TranslationContextValue } from '../../../context/TranslationContex import { defaultDateTimeParser, defaultTranslatorFunction, - Streami18n, + StreamI18n, } from '../../../i18n'; import type { @@ -17,7 +17,7 @@ import type { export type UseChatParams = { client: StreamChat; defaultLanguage?: string; - i18nInstance?: Streami18n; + i18nInstance?: StreamI18n; }; export const useChat = ({ @@ -93,18 +93,27 @@ export const useChat = ({ : defaultLanguage; } - const streami18n = i18nInstance || new Streami18n({ language: userLanguage }); - - streami18n.registerSetLanguageCallback((t) => - setTranslators((prevTranslator) => ({ ...prevTranslator, t })), + // Truthiness, deliberately -- not `instanceof`. An instance coming from a second copy of the + // package would fail an identity check and be silently replaced by a fresh English default, + // discarding every dictionary and formatter the integrator registered. + const streamI18n = i18nInstance || new StreamI18n({ language: userLanguage }); + + // One subscription replaces the old `registerSetLanguageCallback`, which a second caller would + // clobber for everyone. `subscribe` fires synchronously with the current value, so there is no + // ordering to get right: whether this runs before or after `init()`, the live `t` arrives. + const unsubscribe = streamI18n.state.subscribeWithSelector( + ({ t, tDateTimeParser }) => ({ t, tDateTimeParser }), + ({ t, tDateTimeParser }) => + setTranslators({ + t, + tDateTimeParser, + userLanguage: userLanguage || defaultLanguage, + }), ); - streami18n.getTranslators().then((translator) => { - setTranslators({ - ...translator, - userLanguage: userLanguage || defaultLanguage, - }); - }); + streamI18n.init(); + + return unsubscribe; // eslint-disable-next-line react-hooks/exhaustive-deps }, [i18nInstance]); diff --git a/src/components/Message/MessageTranslationIndicator.tsx b/src/components/Message/MessageTranslationIndicator.tsx index ebb62f8aea..624b85775a 100644 --- a/src/components/Message/MessageTranslationIndicator.tsx +++ b/src/components/Message/MessageTranslationIndicator.tsx @@ -7,7 +7,6 @@ import { useTranslationContext, } from '../../context'; import { Button } from '../Button'; -import { asDynamicKey } from '../../i18n/utils'; export type TranslationIndicatorProps = { message?: LocalMessage; @@ -51,11 +50,11 @@ export const MessageTranslationIndicator = ({ const sourceLanguageName = useMemo(() => { const sourceLanguageCode = message?.i18n?.language; if (!sourceLanguageCode) return ''; - const languageKey = 'language.' + sourceLanguageCode; - const translatedName = t(asDynamicKey(languageKey)); - return translatedName && translatedName !== languageKey - ? translatedName - : sourceLanguageCode; + // `language.*` keys are part of the catalog now (core derives them from the same + // `TranslationLanguage` union this code is), so the key is checked at compile time rather than + // escaping through `asDynamicKey()`. That also retires the `translatedName !== languageKey` + // comparison this used to need to detect a name core had no entry for — the union guarantees one. + return t(`language.${sourceLanguageCode}`); }, [message?.i18n?.language, t]); if (!message?.i18n || !setTranslationView) return null; diff --git a/src/components/Message/__tests__/MessageTimestamp.test.tsx b/src/components/Message/__tests__/MessageTimestamp.test.tsx index 891d7949af..ecbaeb2530 100644 --- a/src/components/Message/__tests__/MessageTimestamp.test.tsx +++ b/src/components/Message/__tests__/MessageTimestamp.test.tsx @@ -127,6 +127,12 @@ describe('', () => { expect(container.children).toHaveLength(0); }); + // These two assert the *unformatted* fallback, which is the only place the shared i18n layer's + // timezone handling is visible. The web SDK used to call `.tz()` on every parse even with no + // timezone configured, which marks the dayjs instance as zoned and renders `…Z`; the shared + // implementation applies `.tz()` only when a timezone is actually set, so plain dayjs formatting + // (`…+00:00`) comes through. Every key the SDK ships specifies a format, so this is not reachable + // outside a key that deliberately disables formatting. it('should render with no format if provided i18n config disables formatting', async () => { const { container } = await renderComponent({ chatProps: { @@ -138,7 +144,7 @@ describe('', () => { }), }, }); - expect(container).toHaveTextContent('2019-04-03T14:42:47Z'); + expect(container).toHaveTextContent('2019-04-03T14:42:47+00:00'); }); it('should render with custom format provided via i18n service', async () => { @@ -193,7 +199,7 @@ describe('', () => { }, props: { calendarFormats }, }); - expect(container).toHaveTextContent('2019-04-03T14:42:47Z'); + expect(container).toHaveTextContent('2019-04-03T14:42:47+00:00'); }); it('should reflect the custom calendarFormats if calendar is enabled', async () => { diff --git a/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx index d24e30d1c3..d2bef6b58d 100644 --- a/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx +++ b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx @@ -1,3 +1,5 @@ +import { POLL_VALIDATION_CODE, pollValidationError } from 'stream-chat'; +import type { PollValidationCode } from 'stream-chat'; import React, { useMemo, useRef, useState } from 'react'; import { NumericInput } from '../../Form/NumericInput'; import { SwitchField, SwitchFieldLabel } from '../../Form/SwitchField'; @@ -22,22 +24,26 @@ export const MultipleAnswersField = () => { const [voteLimitEnabled, setVoteLimitEnabled] = useState(false); const maxVotesInputRef = useRef(null); - const knownValidationErrors = useMemo>( + const knownValidationErrors = useMemo>>( () => ({ - 'Enforce unique vote is enabled': t( - 'poll.multipleAnswersField.enforceUniqueVoteEnabled.label', - 'Enforce unique vote is enabled', + [POLL_VALIDATION_CODE.maxVotesNotNumeric]: t( + 'poll.multipleAnswersField.onlyNumbersAllowed.label', + 'Only numbers are allowed', ), - 'Type a number from 2 to 10': t( + [POLL_VALIDATION_CODE.maxVotesOutOfRange]: t( 'poll.multipleAnswersField.typeNumber210.label', 'Type a number from 2 to 10', ), + [POLL_VALIDATION_CODE.maxVotesUniqueVoteEnforced]: t( + 'poll.multipleAnswersField.enforceUniqueVoteEnabled.label', + 'Enforce unique vote is enabled', + ), }), [t], ); const multipleVotesEnabled = !enforce_unique_vote; - const errorText = error && knownValidationErrors[error]; + const errorText = error && (knownValidationErrors[error.code] ?? error.message); const voteLimitSwitchId = 'max_votes_allowed_enabled'; const voteLimitSwitchLabelId = `${voteLimitSwitchId}-label`; @@ -103,9 +109,10 @@ export const MultipleAnswersField = () => { const nativeFieldValidation = raw !== '' && !/^\d+$/.test(raw) ? { - max_votes_allowed: t( - 'poll.multipleAnswersField.onlyNumbersAllowed.label', - 'Only numbers are allowed', + // Injected field errors take the same shape core produces, so the render + // path is identical whether the error came from here or from the composer. + max_votes_allowed: pollValidationError( + POLL_VALIDATION_CODE.maxVotesNotNumeric, ), } : undefined; diff --git a/src/components/Poll/PollCreationDialog/NameField.tsx b/src/components/Poll/PollCreationDialog/NameField.tsx index 4abc88aa08..40256b7c6a 100644 --- a/src/components/Poll/PollCreationDialog/NameField.tsx +++ b/src/components/Poll/PollCreationDialog/NameField.tsx @@ -1,3 +1,5 @@ +import { POLL_VALIDATION_CODE } from 'stream-chat'; +import type { PollValidationCode } from 'stream-chat'; import React, { useMemo } from 'react'; import { TextInput } from '../../Form'; import { useTranslationContext } from '../../../context'; @@ -14,9 +16,11 @@ export const NameField = () => { const { t } = useTranslationContext(); const { pollComposer } = useMessageComposerController(); const { error, name } = useStateStore(pollComposer.state, pollComposerStateSelector); - const knownValidationErrors = useMemo>( + // Keyed on the stable validation code rather than on the English sentence `stream-chat` produced. + // Matching on prose meant a copy edit in the LLC silently stopped the translation from applying. + const knownValidationErrors = useMemo>>( () => ({ - 'Question is required': t( + [POLL_VALIDATION_CODE.nameRequired]: t( 'poll.nameField.questionRequired.label', 'Question is required', ), @@ -35,7 +39,7 @@ export const NameField = () => { errorMessage={ error ? ( - {knownValidationErrors[error] ?? t('poll.nameField.error.text', 'Error')} + {knownValidationErrors[error.code] ?? error.message} ) : undefined } diff --git a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx index f31b23f445..c841ca11dd 100644 --- a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx +++ b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx @@ -1,3 +1,5 @@ +import { POLL_VALIDATION_CODE } from 'stream-chat'; +import type { PollValidationCode } from 'stream-chat'; import clsx from 'clsx'; import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { TextInput } from '../../Form/TextInput'; @@ -43,13 +45,16 @@ export const OptionFieldSet = () => { const pendingFocusIndexRef = useRef(null); const [activeOptionId, setActiveOptionId] = useState(null); - const knownValidationErrors = useMemo>( + const knownValidationErrors = useMemo>>( () => ({ - 'Option already exists': t( + [POLL_VALIDATION_CODE.optionDuplicate]: t( 'poll.suggestPollOption.optionAlreadyExists.label', 'Option already exists', ), - 'Option is empty': t('poll.optionFieldSet.optionEmpty.label', 'Option is empty'), + [POLL_VALIDATION_CODE.optionEmpty]: t( + 'poll.optionFieldSet.optionEmpty.label', + 'Option is empty', + ), }), [t], ); @@ -239,8 +244,7 @@ export const OptionFieldSet = () => { message={ error ? ( - {knownValidationErrors[error] ?? - t('poll.nameField.error.text', 'Error')} + {knownValidationErrors[error.code] ?? error.message} ) : undefined } diff --git a/src/context/TranslationContext.tsx b/src/context/TranslationContext.tsx index bd4b38be3d..4e19501c64 100644 --- a/src/context/TranslationContext.tsx +++ b/src/context/TranslationContext.tsx @@ -1,14 +1,20 @@ import type { PropsWithChildren } from 'react'; import React, { useContext } from 'react'; -import Dayjs from 'dayjs'; -import calendar from 'dayjs/plugin/calendar.js'; -import localizedFormat from 'dayjs/plugin/localizedFormat.js'; import { defaultDateTimeParser, defaultTranslatorFunction } from '../i18n/utils'; import type { StreamTFunction, TDateTimeParser } from '../i18n/types'; -Dayjs.extend(calendar); -Dayjs.extend(localizedFormat); +/** + * The `Dayjs.extend(calendar)` / `extend(localizedFormat)` calls that used to sit here are gone. + * + * They existed so that the context *default* — used by a component rendered outside `` — could + * still call `.calendar()`. `defaultDateTimeParser` now comes from `stream-chat/i18n` and registers the + * plugins itself on first use, so the same guarantee holds without a module-scope side effect. That is + * what lets the package be marked side-effect-free. + * + * Worth knowing if this ever regresses: extending dayjs is not optional here, and forgetting it fails + * *silently* — `.calendar()` is simply absent, so timestamps render malformed rather than throwing. + */ export type TranslationContextValue = { t: StreamTFunction; diff --git a/src/i18n/Streami18n.ts b/src/i18n/Streami18n.ts index 218d180310..09bfdb5f2d 100644 --- a/src/i18n/Streami18n.ts +++ b/src/i18n/Streami18n.ts @@ -1,579 +1,86 @@ -import i18n from 'i18next'; -import Dayjs from 'dayjs'; -import calendar from 'dayjs/plugin/calendar.js'; -import updateLocale from 'dayjs/plugin/updateLocale.js'; -import LocalizedFormat from 'dayjs/plugin/localizedFormat.js'; -import localeData from 'dayjs/plugin/localeData.js'; -import relativeTime from 'dayjs/plugin/relativeTime.js'; -import duration from 'dayjs/plugin/duration.js'; -import utc from 'dayjs/plugin/utc.js'; -import timezone from 'dayjs/plugin/timezone.js'; -import { NotificationTranslationTopic, TranslationBuilder } from './TranslationBuilder'; -import { defaultTranslatorFunction, predefinedFormatters } from './utils'; - -import type { i18n as I18n } from 'i18next'; -import type momentTimezone from 'moment-timezone'; - -import type { TranslationTopicConstructor } from './TranslationBuilder'; -import type { UnknownType } from '../types/types'; -import type { - CustomFormatters, - LooseTranslationDictionary, - PredefinedFormatters, - StreamTFunction, - TDateTimeParser, - TranslationDictionary, -} from './types'; +import { StreamI18n as CoreStreamI18n, languageNameDefaults } from 'stream-chat/i18n'; +import type { StreamI18nOptions as CoreStreamI18nOptions } from 'stream-chat/i18n'; +import { NotificationTranslationTopic } from './TranslationBuilder'; import { runtimeDefaults } from './runtimeDefaults'; +import type { TranslationCatalog } from './types'; -import 'dayjs/locale/en.js'; - -const defaultNS = 'translation'; -const defaultLng = 'en'; - -type CalendarLocaleConfig = { - lastDay: string; - lastWeek: string; - nextDay: string; - nextWeek: string; - sameDay: string; - sameElse: string; -}; - -/** - * A dayjs locale config, as accepted by `dayjsLocaleConfigForLanguage` and by - * `registerTranslation`'s third argument. - * - * `calendar` is not part of dayjs's own `ILocale` — it comes from the calendar plugin — so it has to - * be added here. Supplying it is how relative wording ("heute um", "ieri alle") gets localized. - */ -export type DayjsLocaleConfig = Partial & { calendar?: CalendarLocaleConfig }; - -Dayjs.extend(updateLocale); -Dayjs.extend(utc); -Dayjs.extend(timezone); - -const en_locale = { - formats: {}, - months: [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ], - relativeTime: {}, - weekdays: [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ], -}; - -type DateTimeParserModule = typeof Dayjs | typeof momentTimezone; -// Type guards to check 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; - -export type Streami18nOptions = { - DateTimeParser?: DateTimeParserModule; - dayjsLocaleConfigForLanguage?: DayjsLocaleConfig; - debug?: boolean; - disableDateTimeTranslations?: boolean; - formatters?: Partial & CustomFormatters; - language?: string; - logger?: (message?: string) => void; - translationBuilderTopics?: Record; - parseMissingKeyHandler?: (key: string, defaultValue?: string) => string; - timezone?: string; - translationsForLanguage?: TranslationDictionary; -}; - -const defaultStreami18nOptions = { - DateTimeParser: Dayjs, - debug: false, - disableDateTimeTranslations: false, - language: 'en', - logger: (message?: string) => console.warn(message), - /** - * Key in the translationBuilderTopics has to match postProcessorName in the translation value. - * - * { - * "key": "{{value, postProcessorName}}" - * } - * - * At least the default topics will be supported. - */ - translationBuilderTopics: { - notification: NotificationTranslationTopic, - }, -}; +/** Keys resolved from bundled data rather than an inline default. Mirrors `types.ts`. */ +type BundledKey = `translationBuilderTopic.${string}` | `language.${string}`; /** - * Wraps an integrator's `parseMissingKeyHandler` so it only sees genuinely missing translations. + * Options for {@link StreamI18n}. * - * i18next counts every prose key as missing (they render from the inline `defaultValue`, not from - * the resource) and lets the handler's return value replace the rendered string — so an unguarded - * handler blanks out most of the UI. A resolved default arrives as the second argument, which is - * how the two cases are told apart. + * `runtimeDefaults` and `translationBuilderTopics` are both accepted and both *merged* over the SDK's + * own, so supplying either adds to rather than replaces what the SDK ships. */ -const guardMissingKeyHandler = - (handler: (key: string, defaultValue?: string) => string) => - (key: string, defaultValue?: string) => { - if (typeof defaultValue === 'string') return defaultValue; - return handler(key, defaultValue); - }; +export type StreamI18nOptions = CoreStreamI18nOptions; /** - * Wrapper around [i18next](https://www.i18next.com/) class for Stream related i18n. - * Instance of this class should be provided to Chat component to handle i18n. - * - * English (`en`) is the only built-in language. Every other language is supplied by the - * integrator via `registerTranslation()` or `translationsForLanguage`. Keys are stable, - * namespaced identifiers (e.g. `message.status.sent.text`); use the `TranslationKey` type for - * autocompletion, or `yarn i18n:export` for the whole catalog as JSON. + * Wrapper around [i18next](https://www.i18next.com/) for this SDK's translations. Pass an instance to + * `` to control language and copy. * - * Only the keys that cannot carry inline English copy are bundled (see `runtimeDefaults`); - * everything else renders from the copy passed inline at its call site. + * The implementation lives in `stream-chat/i18n`, shared with the React Native SDK. What is added here + * is the two things that are this SDK's own: its bundled translation data, and its notification + * translation topic. Core cannot import either — the key catalog is generated from *this* package's + * `t()` call sites. * - * Override built-in English copy — the UI updates automatically: + * ## Overriding some of the English copy * - * ``` - * const i18n = new Streami18n({ - * translationsForLanguage: { - * 'emptyState.indicator.noConversationsYet.label': 'Nothing here yet', - * } + * ```ts + * const i18n = new StreamI18n({ + * translationsForLanguage: { + * 'emptyState.indicator.noConversationsYet.label': 'Nothing here yet', + * }, * }); * ``` * - * Add a language with `registerTranslation`, as many as you want: + * ## Adding a language * - * ``` - * const i18n = new Streami18n({ language: 'nl' }); + * ```ts + * import 'dayjs/locale/nl'; * + * const i18n = new StreamI18n({ language: 'nl' }); * i18n.registerTranslation('nl', { - * 'emptyState.indicator.noConversationsYet.label': 'Nog niets...', - * 'typing.singleUser': '{{ typing }} is aan het typen', - * 'typing.twoUsers': '{{ typing }} zijn aan het typen', + * 'typing.singleUser': '{{ typing }} is aan het typen', * }); - * - * // setLanguage reflects the new language in the UI. - * i18n.setLanguage('nl'); - * ... * ``` * - * Keys you do not supply fall back to the English copy that ships inline with each component, so a - * partial dictionary is safe — as is no dictionary at all. Every language is layered over the - * bundled `runtimeDefaults`. - * * Type your dictionary as {@link TranslationDictionary} to turn a typo or a leftover v14 key into a - * compile error; it accepts every plural category, so Russian or Arabic stays checked too. Widen to - * {@link LooseTranslationDictionary} only for keys the SDK does not define. - * {@link TranslationCatalog} maps every key to its English copy. - * - * ## Datetime i18n + * compile error. A partial dictionary is safe: unsupplied keys render the English copy that ships inline + * with each component, never a raw dotted path. * - * Dates are formatted with [dayjs](https://day.js.org/docs/en/i18n/i18n) unless you pass your own - * `DateTimeParser` (dayjs or moment). Only the `en` dayjs locale is bundled: for any other - * language import the [locale](https://github.com/iamkun/dayjs/tree/dev/src/locale) and pass - * `dayjsLocaleConfigForLanguage`, including its `calendar` block. - * - * ``` - * import 'dayjs/locale/nl.js'; - * - * const i18n = new Streami18n({ - * language: 'nl', - * dayjsLocaleConfigForLanguage: { months: [...], calendar: { sameDay: '[vandaag om] LT', ... } }, - * }); - * ``` - * - * `registerTranslation(language, translation, customDayjsLocale)` takes the same config as its - * third argument. Set `disableDateTimeTranslations` to keep dates in English. - * - * That `calendar` block does not reach the four `timestamp.*` keys that pass their own - * `calendarFormats` (`DateSeparator`, `ReminderNotification`, `ChannelPreviewTimestamp`, - * `ChannelDetailPinnedMessageTimestamp`). Those carry English day words; translate them by - * overriding the keys — see `ai-docs/i18n-v15-migration.md`. + * Reactivity goes through `i18n.state`, a `StateStore`. `setLanguage()` returns nothing — the new `t` is + * published to that store, which `` subscribes to. */ -export class Streami18n { - i18nInstance: I18n = i18n.createInstance(); - translationBuilder: TranslationBuilder; - private translationBuilderTopics: Record = {}; - Dayjs = null; - setLanguageCallback: (t: StreamTFunction) => void = () => null; - initialized = false; - - /** Narrowed from i18next's `TFunction` to the shipped catalog; cast once, in `init()`. */ - t: StreamTFunction = defaultTranslatorFunction; - tDateTimeParser: TDateTimeParser; - - translations: { - [key: string]: { - [key: string]: LooseTranslationDictionary | UnknownType; - }; - } = { - en: { [defaultNS]: { ...runtimeDefaults } }, - }; - - /** - * Languages an integrator supplied a dictionary for. Narrower than - * `Object.keys(this.translations)`, which also holds languages seeded with `runtimeDefaults` - * alone. - */ - registeredLanguages = new Set([defaultLng]); - - /** - * dayjs.defineLanguage('nl') also changes the global locale. We don't want to do that - * when user calls registerTranslation() function. So instead we will store the locale configs - * given to registerTranslation() function in `dayjsLocales` object, and register the required locale - * with moment, when setLanguage is called. - * */ - dayjsLocales: { [key: string]: DayjsLocaleConfig } = {}; - // dayjsLocales = {}; - - /** - * Initialize properties used in constructor - */ - logger: (msg?: string) => void; - currentLanguage: string; - DateTimeParser: DateTimeParserModule; - formatters: PredefinedFormatters & CustomFormatters = predefinedFormatters; - isCustomDateTimeParser: boolean; - i18nextConfig: { - debug: boolean; - fallbackLng: false; - interpolation: { escapeValue: boolean; formatSeparator: string }; - keySeparator: false; - lng: string; - nsSeparator: false; - parseMissingKeyHandler?: (key: string, defaultValue?: string) => string; - postProcess?: string[]; - }; - /** - * A valid TZ identifier string (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) - */ - timezone?: string; - /** - * Constructor accepts following options: - * - language (String) default: 'en' - * Language code e.g., en, tr - * - * - translationsForLanguage (object) - * Translations object, keyed by `TranslationKey`, which is a union of every key. - * - * - disableDateTimeTranslations (boolean) default: false - * Disable translations for date-times - * - * - debug (boolean) default: false - * Enable debug mode in internal i18n class - * - * - logger (function) default: () => {} - * Logger function to log warnings/errors from this class - * - * - dayjsLocaleConfigForLanguage (object) default: 'enConfig' - * [Config object](https://momentjs.com/docs/#/i18n/changing-locale/) for internal moment object, - * corresponding to language (param) - * - * - DateTimeParser (function) Moment or Dayjs instance/function. - * Make sure to load all the required locales in this Moment or Dayjs instance that you will be provide to Streami18n - * - * @param {*} options - */ - constructor(options: Streami18nOptions = {}) { - const finalOptions = { - ...defaultStreami18nOptions, +export class StreamI18n extends CoreStreamI18n { + constructor(options: StreamI18nOptions = {}) { + super({ ...options, - }; - this.logger = finalOptions.logger; - this.currentLanguage = finalOptions.language; - const dateTimeParser = (this.DateTimeParser = finalOptions.DateTimeParser); - this.timezone = finalOptions.timezone; - this.formatters = { ...predefinedFormatters, ...options?.formatters }; - this.translationBuilder = new TranslationBuilder(this.i18nInstance); - this.translationBuilderTopics = { - ...defaultStreami18nOptions.translationBuilderTopics, - ...options.translationBuilderTopics, - }; - - if (dateTimeParser && isDayJs(dateTimeParser)) { - dateTimeParser.extend(LocalizedFormat); - dateTimeParser.extend(calendar); - dateTimeParser.extend(localeData); - dateTimeParser.extend(relativeTime); - dateTimeParser.extend(duration); - } - - this.isCustomDateTimeParser = !!options.DateTimeParser; - const translationsForLanguage = finalOptions.translationsForLanguage; - - if (translationsForLanguage) { - this.translations[this.currentLanguage] = { - [defaultNS]: this.mergeWithRuntimeDefaults( - this.currentLanguage, - translationsForLanguage, - ), - }; - this.registeredLanguages.add(this.currentLanguage); - } - - this.ensureLanguage(this.currentLanguage); - - this.i18nextConfig = { - debug: finalOptions.debug, - fallbackLng: false, - interpolation: { escapeValue: false, formatSeparator: '|' }, - keySeparator: false, - lng: this.currentLanguage, - nsSeparator: false, - }; - - const postProcess = Object.keys(this.translationBuilderTopics); - - if (postProcess.length > 0) { - this.i18nextConfig.postProcess = postProcess; - } - - if (finalOptions.parseMissingKeyHandler) { - this.i18nextConfig.parseMissingKeyHandler = guardMissingKeyHandler( - finalOptions.parseMissingKeyHandler, - ); - } - - const dayjsLocaleConfigForLanguage = finalOptions.dayjsLocaleConfigForLanguage; - - if (dayjsLocaleConfigForLanguage) { - this.addOrUpdateLocale(this.currentLanguage, { - ...dayjsLocaleConfigForLanguage, - }); - } else if (!this.localeExists(this.currentLanguage)) { - this.logger( - `Streami18n: Streami18n(...) - Locale config for ${this.currentLanguage} does not exist in momentjs.` + - `Please import the locale file using "import 'moment/locale/${this.currentLanguage}';" in your app or ` + - `register the locale config with Streami18n using registerTranslation(language, translation, customDayjsLocale)`, - ); - } - - this.tDateTimeParser = (timestamp) => { - const language = - finalOptions.disableDateTimeTranslations || - !this.localeExists(this.currentLanguage) - ? defaultLng - : this.currentLanguage; - - const dateTimeParser = this.DateTimeParser; - if (isDayJs(dateTimeParser)) { - return supportsTz(dateTimeParser) - ? dateTimeParser(timestamp).tz(this.timezone).locale(language) - : dateTimeParser(timestamp).locale(language); - } - - if (supportsTz(dateTimeParser) && this.timezone) { - return dateTimeParser(timestamp).tz(this.timezone).locale(language); - } - return dateTimeParser(timestamp).locale(language); - }; + // Core owns the `language.*` names, since it owns the `TranslationLanguage` union they describe. + // Merged under this SDK's own data so an integrator can still override an individual name. + runtimeDefaults: { + ...languageNameDefaults, + ...runtimeDefaults, + ...options.runtimeDefaults, + }, + // Merged, not replaced. Spreading `options` over a literal would let an integrator adding one + // topic silently drop the SDK's own `notification` topic, and notifications would then render + // untranslated with no error. + translationBuilderTopics: { + notification: NotificationTranslationTopic, + ...options.translationBuilderTopics, + }, + }); } +} - /** - * Initializes the i18next instance with configuration (which enables natural language as default keys) - */ - async init() { - this.validateCurrentLanguage(); - - try { - this.t = (await this.i18nInstance.init({ - ...this.i18nextConfig, - lng: this.currentLanguage, - resources: this.translations, - })) as unknown as StreamTFunction; - this.initialized = true; - if (this.formatters) { - Object.entries(this.formatters).forEach(([name, formatterFactory]) => { - if (!formatterFactory) return; - this.i18nInstance.services.formatter?.add(name, formatterFactory(this)); - }); - } - // Register post-processors after initialization - Object.entries(this.translationBuilderTopics).forEach( - ([topic, TranslationTopic]) => { - this.translationBuilder.registerTopic(topic, TranslationTopic); - }, - ); - } catch (error) { - this.logger(`Something went wrong with init: ${JSON.stringify(error)}`); - } - - return { - t: this.t, - tDateTimeParser: this.tDateTimeParser, - }; - } - - localeExists = (language: string) => { - if (this.isCustomDateTimeParser) return true; - - return Object.keys(Dayjs.Ls).indexOf(language) > -1; - }; - - /** - * A dictionary layered over `runtimeDefaults`. Every write into `this.translations` goes through - * here: those keys have no inline `defaultValue` and `fallbackLng` is false, so a language - * missing them renders raw `duration.*` keys and unformatted ISO timestamps. - */ - private mergeWithRuntimeDefaults = ( - language: string, - translation?: LooseTranslationDictionary, - ): LooseTranslationDictionary => ({ - ...runtimeDefaults, - ...this.translations[language]?.[defaultNS], - ...translation, - }); - - /** - * Guarantees `language` has a dictionary, so a language nobody registered still formats dates and - * durations and renders the SDK's copy in English. Writes into i18next's store too when already - * initialized — the only route for a language added after `init()`. - */ - private ensureLanguage = (language: string) => { - if (this.translations[language]) return; - - const translation = this.mergeWithRuntimeDefaults(language); - this.translations[language] = { [defaultNS]: translation }; - - if (this.initialized) { - this.i18nInstance.addResources(language, defaultNS, translation); - } - }; - - /** - * Warns when the current language has no registered dictionary. Not an error and not a reason to - * fall back to `en` — the language renders English copy with its own date formats. - */ - validateCurrentLanguage = () => { - if (this.registeredLanguages.has(this.currentLanguage)) return; - - this.logger( - `Streami18n: no translation dictionary is registered for '${this.currentLanguage}', so the ` + - `SDK's copy renders in English. Call ` + - `streami18n.registerTranslation('${this.currentLanguage}', {...}) to translate it. ` + - `Registered: ${[...this.registeredLanguages].join(', ')}`, - ); - }; - - /** Returns list of available languages. */ - getAvailableLanguages = () => Object.keys(this.translations); - - /** - * The resource dictionaries this instance hands to i18next, keyed by language. - * - * Not the full English catalog — prose keys are never bundled, so `en` holds `runtimeDefaults` - * plus whatever has been registered. To enumerate every key with its copy, use - * {@link TranslationCatalog} or `yarn i18n:export`. - */ - getTranslations = () => this.translations; - - /** - * Returns current version translator function. - */ - async getTranslators() { - if (!this.initialized) { - if (this.dayjsLocales[this.currentLanguage]) { - this.addOrUpdateLocale( - this.currentLanguage, - this.dayjsLocales[this.currentLanguage], - ); - } - - return await this.init(); - } - - return { - t: this.t, - tDateTimeParser: this.tDateTimeParser, - }; - } - - registerTranslation( - language: string, - translation: TranslationDictionary, - customDayjsLocale?: DayjsLocaleConfig, - ) { - // Merged, not replaced, so repeated calls for one language accumulate. - const merged = this.mergeWithRuntimeDefaults(language, translation); - this.translations[language] = { [defaultNS]: merged }; - this.registeredLanguages.add(language); - - if (customDayjsLocale) { - this.dayjsLocales[language] = { ...customDayjsLocale }; - } else if (!this.localeExists(language)) { - this.logger( - `Streami18n: registerTranslation - ` + - `Locale config for ${language} does not exist in Dayjs.` + - `Please import the locale file using "import 'dayjs/locale/${language}.js';" in your app or ` + - `register the locale config with Streami18n using registerTranslation(language, translation, customDayjsLocale)`, - ); - } - - if (this.initialized) { - // `merged`, not `translation`: for a language registered *after* init this is the only write - // into i18next's store, so passing the partial would leave `runtimeDefaults` absent there. - this.i18nInstance.addResources(language, defaultNS, merged); - } - } - - addOrUpdateLocale(key: string, config: DayjsLocaleConfig) { - if (this.localeExists(key)) { - Dayjs.updateLocale(key, { ...config }); - } else { - // Merging the custom locale config with en config, so missing keys can default to english. - Dayjs.locale({ name: key, ...en_locale, ...config }, undefined, true); - } - } - - async setLanguage(language: string) { - this.currentLanguage = language; - this.ensureLanguage(language); - - if (!this.initialized) return; - - this.validateCurrentLanguage(); - - try { - const t = await this.i18nInstance.changeLanguage(language); - if (this.dayjsLocales[language]) { - this.addOrUpdateLocale( - this.currentLanguage, - this.dayjsLocales[this.currentLanguage], - ); - } - - this.setLanguageCallback(t as unknown as StreamTFunction); - return t; - } catch (error) { - this.logger(`Failed to set language: ${JSON.stringify(error)}`); - return this.t; - } - } +/** + * @deprecated Renamed to {@link StreamI18n}, matching the class the SDKs now share. Kept for one + * release cycle. Exported via `export { X as Y }` rather than `const Y = X` so it remains usable as + * both a value and a type — `i18nInstance?: Streami18n` is the common form. + */ +export { StreamI18n as Streami18n }; - registerSetLanguageCallback(callback: (t: StreamTFunction) => void) { - this.setLanguageCallback = callback; - } -} +/** @deprecated Renamed to {@link StreamI18nOptions}. */ +export type Streami18nOptions = StreamI18nOptions; diff --git a/src/i18n/TranslationBuilder/TranslationBuilder.ts b/src/i18n/TranslationBuilder/TranslationBuilder.ts deleted file mode 100644 index 6923c244b0..0000000000 --- a/src/i18n/TranslationBuilder/TranslationBuilder.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { i18n } from 'i18next'; -import type { StreamTFunction } from '../types'; - -type TopicName = string; -type TranslatorName = string; - -export type Translator = Record> = - (params: { - key: string; - value: string; - t: StreamTFunction; - options: O; - }) => string | null; - -export type TranslationTopicOptions< - O extends Record = Record, -> = { - i18next: i18n; - translators?: Record>; -}; - -export abstract class TranslationTopic< - O extends Record = Record, -> { - protected translators: Map> = new Map(); - protected i18next: i18n; - - constructor(protected options: TranslationTopicOptions) { - this.i18next = options.i18next; - if (options.translators) { - Object.entries(options.translators).forEach(([name, translator]) => { - this.setTranslator(name, translator); - }); - } - } - - abstract translate(value: string, key: string, options: O): string; - - setTranslator = (name: string, translator: Translator) => { - this.translators.set(name, translator); - }; - - removeTranslator = (name: string) => { - this.translators.delete(name); - }; -} - -const forwardTranslation: Translator = ({ value }) => value; - -export type TranslationTopicConstructor = new ( - options: TranslationTopicOptions, -) => TranslationTopic; - -export class TranslationBuilder { - private topics = new Map(); - // need to keep a registration buffer so that translators can be registered once a topic is registered - // what does not happen when Streami18n is instantiated but rather once Streami18n.init() is invoked - private translatorRegistrationsBuffer: Record< - TopicName, - Record - > = {}; - - constructor(private i18next: i18n) {} - - registerTopic = (name: TopicName, Topic: TranslationTopicConstructor) => { - let topic = this.topics.get(name); - - if (!topic) { - topic = new Topic({ i18next: this.i18next }); - this.topics.set(name, topic); - this.i18next.use({ - name, - process: (value: string, key: string, options: Record) => { - const topic = this.topics.get(name); - if (!topic) return value; - return topic.translate(value, key, options); - }, - type: 'postProcessor' as const, - }); - } - - const additionalTranslatorsToRegister = this.translatorRegistrationsBuffer[name]; - if (additionalTranslatorsToRegister) { - Object.entries(additionalTranslatorsToRegister).forEach( - ([translatorName, translator]) => { - topic.setTranslator(translatorName, translator); - }, - ); - delete this.translatorRegistrationsBuffer[name]; - } - return topic; - }; - - disableTopic = (topicName: TopicName) => { - const topic = this.topics.get(topicName); - if (!topic) return; - this.i18next.use({ - name: topicName, - process: forwardTranslation, - type: 'postProcessor', - }); - this.topics.delete(topicName); - }; - - getTopic = (topicName: TopicName) => this.topics.get(topicName); - - registerTranslators( - topicName: TopicName, - translators: Record, - ) { - const topic = this.getTopic(topicName); - if (!topic) { - if (!this.translatorRegistrationsBuffer[topicName]) - this.translatorRegistrationsBuffer[topicName] = {}; - - Object.entries(translators).forEach(([translatorName, translator]) => { - this.translatorRegistrationsBuffer[topicName][translatorName] = translator; - }); - return; - } - Object.entries(translators).forEach(([name, translator]) => { - topic.setTranslator(name, translator); - }); - } - - removeTranslators(topicName: TopicName, translators: TranslatorName[]) { - const topic = this.getTopic(topicName); - if (this.translatorRegistrationsBuffer[topicName]) { - translators.forEach((translatorName) => { - delete this.translatorRegistrationsBuffer[topicName][translatorName]; - }); - } - if (!topic) return; - translators.forEach((name) => { - topic.removeTranslator(name); - }); - } -} diff --git a/src/i18n/TranslationBuilder/index.ts b/src/i18n/TranslationBuilder/index.ts index 972919e2ca..67bcd727de 100644 --- a/src/i18n/TranslationBuilder/index.ts +++ b/src/i18n/TranslationBuilder/index.ts @@ -1,2 +1,13 @@ -export * from './TranslationBuilder'; +/** + * The `TranslationBuilder` / `TranslationTopic` / `Translator` plumbing now lives in + * `stream-chat/i18n`, shared with the React Native SDK. Only the *topics* are this SDK's own, since + * they reference its key names. + */ +export { + TranslationBuilder, + TranslationTopic, + type TranslationTopicConstructor, + type TranslationTopicOptions, + type Translator, +} from 'stream-chat/i18n'; export * from './notifications'; diff --git a/src/i18n/TranslationBuilder/notifications/NotificationTranslationTopic.ts b/src/i18n/TranslationBuilder/notifications/NotificationTranslationTopic.ts index 4d1ed18494..a223905e5e 100644 --- a/src/i18n/TranslationBuilder/notifications/NotificationTranslationTopic.ts +++ b/src/i18n/TranslationBuilder/notifications/NotificationTranslationTopic.ts @@ -1,5 +1,4 @@ import { TranslationTopic } from '../../TranslationBuilder'; -import { translateExternalString } from '../../externalStrings'; import type { Notification } from 'stream-chat'; import type { NotificationTranslatorOptions } from './types'; import { translatorsByNotificationType } from './translatorsByNotificationType'; @@ -49,11 +48,12 @@ export class NotificationTranslationTopic extends TranslationTopic = export const translateBrowserAudioPlaybackError: Translator< NotificationTranslatorOptions -> = ({ options: { notification }, t }) => - notification?.message - ? translateExternalString(t, notification.message) - : t('notification.audioPlaybackError', 'Error reproducing the recording'); +> = ({ t }) => t('notification.audioPlaybackError', 'Error reproducing the recording'); export const translateCommandDisabled: Translator = ({ options: { notification }, @@ -96,7 +92,5 @@ export const translateCommandDisabled: Translator ); } - return notification?.message - ? translateExternalString(t, notification.message) - : t('notification.commandDisabled', 'Command not available'); + return t('notification.commandDisabled', 'Command not available'); }; diff --git a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts index c06a7ff778..79bca1718b 100644 --- a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts +++ b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts @@ -1,3 +1,6 @@ +import { CORE_NOTIFICATION_TYPE } from 'stream-chat'; +import type { CoreNotificationType } from 'stream-chat'; + import type { NotificationTranslatorOptions } from './types'; import { translateAttachmentUploadBlocked, @@ -9,42 +12,67 @@ import { } from './translators'; import type { Translator } from '../../index'; -export const translatorsByNotificationType: Record< - string, - Translator -> = { - 'api:attachment:upload:failed': translateAttachmentUploadFailed, - 'api:location:create:failed': ({ t }) => - t('notification.locationShareFailed', 'Failed to share location'), +type NotificationTranslator = Translator; + +/** + * A translator for every notification `stream-chat` itself emits. + * + * `Record` is the point: a new identifier in core fails to compile here until + * it is mapped, and an entry for one that no longer exists is rejected. Before core exported the union, + * this table and the React Native SDK's equivalent were hand-maintained copies of each other, and both + * had drifted — carrying entries nothing emits while missing identifiers that fell through to + * untranslated English. + */ +const coreNotificationTranslators: Record = + { + [CORE_NOTIFICATION_TYPE.attachmentFileMissing]: ({ t }) => + t('notification.attachmentFileMissing', 'File is required for upload attachment'), + [CORE_NOTIFICATION_TYPE.attachmentIdMissing]: ({ t }) => + t('notification.attachmentIdMissing', 'Local upload attachment missing local id'), + [CORE_NOTIFICATION_TYPE.attachmentUploadBlocked]: translateAttachmentUploadBlocked, + [CORE_NOTIFICATION_TYPE.attachmentUploadFailed]: translateAttachmentUploadFailed, + [CORE_NOTIFICATION_TYPE.attachmentUploadInProgress]: ({ t }) => + t( + 'notification.attachmentUploadInProgress', + 'Wait until all attachments have uploaded', + ), + [CORE_NOTIFICATION_TYPE.commandDisabled]: translateCommandDisabled, + [CORE_NOTIFICATION_TYPE.commandNotReady]: ({ t }) => + t('notification.commandNotReady', 'Command not ready to be sent'), + [CORE_NOTIFICATION_TYPE.locationCreateFailed]: ({ t }) => + t('notification.locationShareFailed', 'Failed to share location'), + // Previously unmapped, so these rendered untranslated English from `notification.message`. + [CORE_NOTIFICATION_TYPE.messageJumpFailed]: ({ t }) => + t('notification.messageJumpFailed', 'Failed to jump to the message'), + [CORE_NOTIFICATION_TYPE.messageJumpToLatestFailed]: ({ t }) => + t('notification.messageJumpToLatestFailed', 'Failed to jump to the latest message'), + [CORE_NOTIFICATION_TYPE.pollCastVoteLimit]: ({ t }) => + t( + 'notification.pollVoteLimit', + 'Reached the vote limit. Remove an existing vote first.', + ), + [CORE_NOTIFICATION_TYPE.pollCreateFailed]: translatePollCreateFailed, + }; + +/** + * Translators for notifications this SDK emits itself, which core knows nothing about. + * + * Deliberately not exhaustiveness-checked — there is no union to check against — so keep it to + * identifiers that are actually emitted. `api:reply:search:failed` and + * `channel:jumpToFirstUnread:failed` were removed here: both were copied between the two UI SDKs and + * neither is emitted by this one. + */ +const sdkNotificationTranslators: Record = { 'api:location:share:failed': ({ t }) => t('notification.locationShareFailed', 'Failed to share location'), - 'api:poll:create:failed': translatePollCreateFailed, 'api:poll:end:failed': translatePollEndFailed, 'api:poll:end:success': ({ t }) => t('notification.pollEndSuccess', 'Poll Ended'), - 'api:reply:search:failed': ({ t }) => - t('notification.replySearchFailed', 'Thread has not been found'), 'browser:audio:playback:error': translateBrowserAudioPlaybackError, 'browser:location:get:failed': ({ t }) => t('notification.locationGetFailed', 'Failed to retrieve location'), - 'channel:jumpToFirstUnread:failed': ({ t }) => - t( - 'notification.jumpToFirstUnreadFailed', - 'Failed to jump to the first unread message', - ), - 'validation:attachment:file:missing': ({ t }) => - t('notification.attachmentFileMissing', 'File is required for upload attachment'), - 'validation:attachment:id:missing': ({ t }) => - t('notification.attachmentIdMissing', 'Local upload attachment missing local id'), - 'validation:attachment:upload:blocked': translateAttachmentUploadBlocked, - 'validation:attachment:upload:in-progress': ({ t }) => - t( - 'notification.attachmentUploadInProgress', - 'Wait until all attachments have uploaded', - ), - 'validation:command:disabled': translateCommandDisabled, - 'validation:poll:castVote:limit': ({ t }) => - t( - 'notification.pollVoteLimit', - 'Reached the vote limit. Remove an existing vote first.', - ), +}; + +export const translatorsByNotificationType: Record = { + ...coreNotificationTranslators, + ...sdkNotificationTranslators, }; diff --git a/src/i18n/__tests__/NotificationTranslationBuilder.test.ts b/src/i18n/__tests__/NotificationTranslationBuilder.test.ts index 5883d91069..5bb2942559 100644 --- a/src/i18n/__tests__/NotificationTranslationBuilder.test.ts +++ b/src/i18n/__tests__/NotificationTranslationBuilder.test.ts @@ -73,27 +73,19 @@ describe('NotificationTranslationTopic', () => { }), }); - expect(output).toBe('translated/file-required'); - // Recognised stream-chat message -> stable key, with the raw English as the default. - expect(i18next.t).toHaveBeenCalledWith( - 'notification.attachmentFileMissing', - 'File is required for upload attachment', - { value: 'File is required for upload attachment' }, - ); + // An identifier no translator claims renders `notification.message` verbatim. It used to be run + // through a hand-maintained table of English sentences mapped onto keys; identifiers are the seam + // now, so prose matching would only mask a missing translator entry. + expect(output).toBe('File is required for upload attachment'); + expect(i18next.t).not.toHaveBeenCalled(); }); - it('passes notification metadata to i18next for message interpolation fallback', () => { + it('does not interpolate metadata into an unrecognised message', () => { const i18next = fromPartial({ ...mockI18Next, - t: vi.fn((key, _defaultValue, options) => - key === 'Attachment upload failed due to {{reason}}' - ? `translated/reason:${options.reason}` - : key, - ) as unknown as i18n['t'], - }); - const builder = new NotificationTranslationTopic({ - i18next, + t: vi.fn() as unknown as i18n['t'], }); + const builder = new NotificationTranslationTopic({ i18next }); const output = builder.translate('XXX', '', { notification: fromPartial({ @@ -103,15 +95,15 @@ describe('NotificationTranslationTopic', () => { }), }); - expect(output).toBe('translated/reason:network error'); - // Unrecognised message: passed through as its own key so it still renders verbatim. - expect(i18next.t).toHaveBeenCalledWith( - 'Attachment upload failed due to {{reason}}', - 'Attachment upload failed due to {{reason}}', - { reason: 'network error', value: 'Attachment upload failed due to {{reason}}' }, - ); + // Rendered verbatim, placeholder included. Interpolating into prose would require treating the + // sentence as a key, which is exactly what the identifier seam replaced. + expect(output).toBe('Attachment upload failed due to {{reason}}'); + expect(i18next.t).not.toHaveBeenCalled(); }); + // `api:reply:search:failed` and `channel:jumpToFirstUnread:failed` were removed from the registry: + // both were copied between the two UI SDKs and neither is emitted by this one. The registry is now + // exhaustiveness-checked against `CoreNotificationType`, so a core identifier cannot go missing. it.each([ [ 'api:location:create:failed', @@ -123,22 +115,12 @@ describe('NotificationTranslationTopic', () => { 'notification.locationShareFailed', 'Failed to share location', ], - [ - 'api:reply:search:failed', - 'notification.replySearchFailed', - 'Thread has not been found', - ], ['api:poll:end:success', 'notification.pollEndSuccess', 'Poll Ended'], [ 'browser:location:get:failed', 'notification.locationGetFailed', 'Failed to retrieve location', ], - [ - 'channel:jumpToFirstUnread:failed', - 'notification.jumpToFirstUnreadFailed', - 'Failed to jump to the first unread message', - ], [ 'validation:attachment:file:missing', 'notification.attachmentFileMissing', diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json new file mode 100644 index 0000000000..b1b22bc09f --- /dev/null +++ b/src/i18n/__tests__/catalog.fixture.json @@ -0,0 +1,574 @@ +{ + "a11y.accessibleLabel.active.ariaLabel": "Active", + "a11y.accessibleLabel.unreadMessage.ariaLabel_one": "{{ count }} unread message", + "a11y.accessibleLabel.unreadMessage.ariaLabel_other": "{{ count }} unread messages", + "a11y.incomingMessageAnnouncements.newMessage.label": "New message from {{user}}", + "a11y.interactionAnnouncements.commandActivated.ariaLabel": "Command activated: {{ command }}", + "a11y.interactionAnnouncements.droppedPosition.ariaLabel": "Dropped \"{{ option }}\" at position {{ position }}.", + "a11y.interactionAnnouncements.giphyCanceled.ariaLabel": "Giphy canceled", + "a11y.interactionAnnouncements.giphyImageChanged.ariaLabel": "Giphy image changed", + "a11y.interactionAnnouncements.giphyImageChanged.withTitle.ariaLabel": "Giphy image changed: {{ title }}", + "a11y.interactionAnnouncements.giphySent.ariaLabel": "Giphy sent", + "a11y.interactionAnnouncements.noSearchResultsFound.ariaLabel": "No search results found", + "a11y.interactionAnnouncements.openedChannel.ariaLabel": "Opened channel: {{ name }}", + "a11y.interactionAnnouncements.openedThread.ariaLabel": "Opened thread in {{ name }}", + "a11y.interactionAnnouncements.pickedUpUseArrow.ariaLabel": "Picked up \"{{ option }}\". Use arrow keys to reorder. Press Space or Tab to drop.", + "a11y.interactionAnnouncements.pollDialogOpened.ariaLabel": "Poll dialog opened", + "a11y.interactionAnnouncements.pollSent.ariaLabel": "Poll sent", + "a11y.interactionAnnouncements.pressEnterStartTyping.ariaLabel": "Press Enter to start typing", + "a11y.interactionAnnouncements.recordingPaused.ariaLabel": "Recording paused", + "a11y.interactionAnnouncements.recordingResumed.ariaLabel": "Recording resumed", + "a11y.interactionAnnouncements.recordingStarted.ariaLabel": "Recording started", + "a11y.interactionAnnouncements.removedOption.ariaLabel": "Removed option {{ option }}", + "a11y.interactionAnnouncements.searchCleared.ariaLabel": "Search cleared", + "a11y.interactionAnnouncements.searchResults.ariaLabel_one": "{{ count }} search result", + "a11y.interactionAnnouncements.searchResults.ariaLabel_other": "{{ count }} search results", + "a11y.interactionAnnouncements.suggestions.ariaLabel_one": "{{ count }} suggestion", + "a11y.interactionAnnouncements.suggestions.ariaLabel_other": "{{ count }} suggestions", + "a11y.interactionAnnouncements.suggestionsWithLabel.ariaLabel_one": "{{ count }} {{ suggestionsLabel }}", + "a11y.interactionAnnouncements.suggestionsWithLabel.ariaLabel_other": "{{ count }} {{ suggestionsLabel }}", + "a11y.interactionAnnouncements.userSelected.ariaLabel": "User selected: {{ user }}", + "a11y.interactionAnnouncements.voiceMessageSent.ariaLabel": "Voice message sent", + "a11y.interactionAnnouncements.voiceRecordingAttached.ariaLabel": "Voice recording attached", + "aiState.indicator.generating.label": "Generating...", + "aiState.indicator.thinking.label": "Thinking...", + "attachment.actions.giphyActions.ariaLabel": "Giphy actions", + "attachment.actions.giphyPreviewOnlyVisible.ariaLabel": "Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.", + "attachment.actions.shuffle.label": "Shuffle", + "attachment.geolocation.liveUntil.text": "Live until {{ timestamp }}", + "attachment.geolocation.locationSharingEnded.text": "Location sharing ended", + "attachment.geolocation.openLocationMap.ariaLabel": "Open location in a map", + "attachment.geolocation.stopSharing.text": "Stop sharing", + "attachment.giphy.animatedGif.ariaLabel": "Animated GIF", + "attachment.giphy.animatedGif.withTitle.ariaLabel": "Animated GIF: {{ title }}", + "attachment.modalGallery.openGalleryImage.label": "Open gallery at image {{ index }}", + "attachment.modalGallery.openImageGallery.label": "Open image in gallery", + "attachment.unableRenderCard.text": "this content could not be displayed", + "attachment.visibilityDisclaimer.onlyVisible.text": "Only visible to you", + "audioPlayback.audioPlayerNotifications.cannotSeekRecording.label": "Cannot seek in the recording", + "audioPlayback.audioPlayerNotifications.failedPlayRecording.label": "Failed to play the recording", + "audioPlayback.audioPlayerNotifications.recordingFormatNotSupported.label": "Recording format is not supported and cannot be reproduced", + "audioPlayback.progressBar.seekAudioPosition.ariaLabel": "Seek audio position", + "audioPlayback.progressBarA11y.audioPosition.ariaLabel": "Audio position {{ elapsed }} of {{ duration }}", + "audioPlayback.progressBarA11y.audioPositionPercent.ariaLabel": "Audio position {{ progress }} percent", + "baseImage.imagePlaceholder.imageFailedLoad.ariaLabel": "Image failed to load", + "channel.channelMissing.text": "Channel Missing", + "channelDetail.avatarChannelDetail.channelDetails.ariaLabel": "Channel details", + "channelDetail.avatarChannelDetail.openChannelDetails.ariaLabel": "Open channel details", + "channelDetail.channelFilesEmpty.noFiles.text": "No files", + "channelDetail.channelFilesEmpty.shareFileSee.text": "Share a file to see it here", + "channelDetail.channelFilesView.files.title": "Files", + "channelDetail.channelManagementActions.blockUser.title": "Block user", + "channelDetail.channelManagementActions.chatDeleted.text": "Chat deleted", + "channelDetail.channelManagementActions.deleteChat.title": "Delete chat", + "channelDetail.channelManagementActions.errorBlockingUser.text": "Error blocking user", + "channelDetail.channelManagementActions.errorDeletingChat.text": "Error deleting chat", + "channelDetail.channelManagementActions.errorMutingChannel.text": "Error muting channel", + "channelDetail.channelManagementActions.errorMutingUser.text": "Error muting user", + "channelDetail.channelManagementActions.errorUnblockingUser.text": "Error unblocking user", + "channelDetail.channelManagementActions.errorUnmutingChannel.text": "Error unmuting channel", + "channelDetail.channelManagementActions.errorUnmutingUser.text": "Error unmuting user", + "channelDetail.channelManagementActions.leaveChat.title": "Leave chat", + "channelDetail.channelManagementActions.muteChat.title": "Mute chat", + "channelDetail.channelManagementActions.muteUser.title": "Mute user", + "channelDetail.channelManagementActions.permanentlyDeletesMessageHistory.description": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "channelDetail.channelManagementActions.sureWantLeaveChannel.description": "Are you sure you want to leave this channel?", + "channelDetail.channelManagementActions.unmuteChat.title": "Unmute chat", + "channelDetail.channelManagementActions.unmuteUser.title": "Unmute user", + "channelDetail.channelManagementActions.userAbleMessageAgain.description": "This user will be able to message you again.", + "channelDetail.channelManagementActions.userMuted.text": "User muted", + "channelDetail.channelManagementActions.userUnmuted.text": "User unmuted", + "channelDetail.channelManagementActions.userWonTAble.description": "This user won't be able to message you anymore. You can unblock them anytime.", + "channelDetail.channelManagementView.changesSaved.text": "Changes saved", + "channelDetail.channelManagementView.contactInfo.label": "Contact info", + "channelDetail.channelManagementView.contactName.label": "Contact name", + "channelDetail.channelManagementView.edit.text": "Edit", + "channelDetail.channelManagementView.editChatData.ariaLabel": "Edit chat data", + "channelDetail.channelManagementView.editContact.label": "Edit contact", + "channelDetail.channelManagementView.editGroup.label": "Edit group", + "channelDetail.channelManagementView.failedSaveChanges.text": "Failed to save changes", + "channelDetail.channelManagementView.groupInfo.label": "Group info", + "channelDetail.channelManagementView.groupName.label": "Group name", + "channelDetail.channelManagementView.manageChannel.description": "Manage channel", + "channelDetail.channelManagementView.save.text": "Save", + "channelDetail.channelManagementView.uploadPicture.text": "Upload Picture", + "channelDetail.channelMediaEmpty.noPhotosVideos.text": "No photos or videos", + "channelDetail.channelMediaEmpty.sharePhotoVideoSee.text": "Share a photo or video to see it here", + "channelDetail.channelMediaView.next.text": "Next", + "channelDetail.channelMediaView.nextPage.ariaLabel": "Next page", + "channelDetail.channelMediaView.openImageShared.ariaLabel": "Open image shared by {{ name }}", + "channelDetail.channelMediaView.openVideoShared.ariaLabel": "Open video shared by {{ name }}", + "channelDetail.channelMediaView.photosVideos.title": "Photos & videos", + "channelDetail.channelMediaView.previous.text": "Previous", + "channelDetail.channelMediaView.previousPage.ariaLabel": "Previous page", + "channelDetail.channelMemberActions.ableMessageAgain.description": "{{ member }} will be able to message you again.", + "channelDetail.channelMemberActions.errorOpeningDirectMessage.text": "Error opening direct message", + "channelDetail.channelMemberActions.errorRemovingUser.text": "Error removing user", + "channelDetail.channelMemberActions.removeChannel.description": "Remove {{ member }} from this channel?", + "channelDetail.channelMemberActions.removeUser.title": "Remove user", + "channelDetail.channelMemberActions.sendDirectMessage.title": "Send direct message", + "channelDetail.channelMemberActions.unblockUser.title": "Unblock user", + "channelDetail.channelMemberActions.userRemoved.text": "User removed", + "channelDetail.channelMemberActions.wonTAbleMessage.description": "{{ member }} won't be able to message you anymore.", + "channelDetail.channelMemberDetail.lastSeen.label": "Last seen {{ timestamp }}", + "channelDetail.channelMemberDetail.memberDetail.title": "Member detail", + "channelDetail.channelMembersAdd.addMembers.text_one": "Add {{ count }} member", + "channelDetail.channelMembersAdd.addMembers.text_other": "Add {{ count }} members", + "channelDetail.channelMembersAdd.alreadyMember.label": "Already a member", + "channelDetail.channelMembersAdd.errorAddingMembers.text": "Error adding members", + "channelDetail.channelMembersAdd.membersAdded.text_one": "{{ count }} member added", + "channelDetail.channelMembersAdd.membersAdded.text_other": "{{ count }} members added", + "channelDetail.channelMembersAdd.noUserFound.text": "No user found", + "channelDetail.channelMembersBrowse.admin.label": "Admin", + "channelDetail.channelMembersBrowse.moderator.label": "Moderator", + "channelDetail.channelMembersBrowse.noMemberFound.text": "No member found", + "channelDetail.channelMembersBrowse.owner.label": "Owner", + "channelDetail.channelMembersBrowse.viewMemberDetails.ariaLabel": "View member details for {{ member }}", + "channelDetail.channelMembersHeader.actions.text": "Actions", + "channelDetail.channelMembersHeader.add.text": "Add", + "channelDetail.channelMembersHeader.addChannelMembers.ariaLabel": "Add channel members", + "channelDetail.channelMembersHeader.openMembersActions.ariaLabel": "Open members actions", + "channelDetail.channelMembersView.addMembers.label": "Add members", + "channelDetail.channelMembersView.browseChannelMembers.description": "Browse channel members", + "channelDetail.channelMembersView.members.title_one": "{{ count }} member", + "channelDetail.channelMembersView.members.title_other": "{{ count }} members", + "channelDetail.pinnedMessagesEmpty.noPinnedMessages.text": "No pinned messages", + "channelDetail.pinnedMessagesEmpty.pinMessageSee.text": "Pin a message to see it here", + "channelDetail.pinnedMessagesView.browsePinnedMessages.description": "Browse pinned messages", + "channelDetail.pinnedMessagesView.noMessagesFound.text": "No messages found", + "channelDetail.pinnedMessagesView.pinnedMessage.label": "Pinned message", + "channelDetail.pinnedMessagesView.pinnedMessages.title": "Pinned messages", + "channelDetail.sectionNavigatorHeader.openMenu.ariaLabel": "Open menu", + "channelHeader.online.members.label": "{{ memberCount }} members", + "channelHeader.online.online.label": "{{ watcherCount }} online", + "channelList.channelList.ariaLabel": "Channel list", + "channelList.header.chats.text": "Chats", + "channelListItem.archive.title": "Archive", + "channelListItem.attachment.ariaLabel": "Attachment", + "channelListItem.attachment.text": "🏙 Attachment...", + "channelListItem.attachment.withAttachmentType.ariaLabel": "Attachment {{ attachmentType }}", + "channelListItem.attachmentCount.ariaLabel_one": "{{ count }} attachment", + "channelListItem.attachmentCount.ariaLabel_other": "{{ count }} attachments", + "channelListItem.audio.ariaLabel": "audio", + "channelListItem.channelActions.ariaLabel": "Channel Actions", + "channelListItem.channelArchived.text": "Channel archived", + "channelListItem.channelDisplayName.directMessage.label": "Direct message", + "channelListItem.channelPinned.text": "Channel pinned", + "channelListItem.channelUnarchived.text": "Channel unarchived", + "channelListItem.channelUnpinned.text": "Channel unpinned", + "channelListItem.created.text": "📊 {{createdBy}} created: {{ pollName}}", + "channelListItem.delivered.ariaLabel": "Delivered", + "channelListItem.deliveryStatus.ariaLabel": "Delivery status: {{ deliveryStatus }}", + "channelListItem.failedBlockUser.text": "Failed to block user", + "channelListItem.failedUpdateChannelArchive.text": "Failed to update channel archive status", + "channelListItem.failedUpdateChannelMute.text": "Failed to update channel mute status", + "channelListItem.failedUpdateChannelPinned.text": "Failed to update channel pinned status", + "channelListItem.file.ariaLabel": "file", + "channelListItem.gif.ariaLabel": "GIF", + "channelListItem.image.ariaLabel": "image", + "channelListItem.lastMessage.withMessagePreview.ariaLabel": "Last message: {{ messagePreview }}", + "channelListItem.lastMessage.withSenderAndMessagePreview.ariaLabel": "Last message from {{ sender }}: {{ messagePreview }}", + "channelListItem.leaveChannel.title": "Leave Channel", + "channelListItem.messageAttachments.ariaLabel": "Message with attachments", + "channelListItem.noMessagesChat.ariaLabel": "There are no messages in this chat.", + "channelListItem.openChannelActionsMenu.ariaLabel": "Open Channel Actions Menu", + "channelListItem.poll.ariaLabel": "Poll: {{ pollName }}", + "channelListItem.read.ariaLabel": "Read", + "channelListItem.sent.ariaLabel": "Sent", + "channelListItem.sharedLink.ariaLabel": "Shared a link", + "channelListItem.sharedLinkTitle.ariaLabel": "Shared a link with title: {{ linkTitle }}", + "channelListItem.sharedLocation.ariaLabel": "Shared location", + "channelListItem.sharedLocation.text": "📍Shared location", + "channelListItem.unarchive.title": "Unarchive", + "channelListItem.unblockUser.title": "Unblock User", + "channelListItem.video.ariaLabel": "video", + "channelListItem.voiceMessage.ariaLabel": "voice message", + "channelListItem.voted.text": "📊 {{votedBy}} voted: {{pollOptionText}}", + "chat.reportLostConnection.waitingNetwork.text": "Waiting for network…", + "command.ban.args": "[@username] [text]", + "command.ban.description": "Ban a user", + "command.giphy.args": "[text]", + "command.giphy.description": "Post a random gif to the channel", + "command.mute.args": "[@username]", + "command.mute.description": "Mute a user", + "command.unban.args": "[@username]", + "command.unban.description": "Unban a user", + "command.unmute.args": "[@username]", + "command.unmute.description": "Unmute a user", + "common.addReaction.text": "Add reaction", + "common.anonymous.label": "Anonymous", + "common.back.label": "Back", + "common.blockUser.title": "Block User", + "common.cancel.label": "Cancel", + "common.channelMuted.text": "Channel muted", + "common.channelUnmuted.text": "Channel unmuted", + "common.close.ariaLabel": "Close", + "common.createQuestionAddOptions.label": "Create a question, add options, and configure poll settings", + "common.currentLocation.text": "Current location", + "common.delete.text": "Delete", + "common.downloadAttachment.ariaLabel": "Download attachment", + "common.downloadAttachment.title": "Download Attachment", + "common.editMessage.text": "Edit Message", + "common.emptyMessage.text": "Empty message...", + "common.errorDeletingMessage.label": "Error deleting message", + "common.errorMutingUser.label": "Error muting a user ...", + "common.errorPinningMessage.label": "Error pinning message", + "common.errorRemovingMessagePin.label": "Error removing message pin", + "common.errorUnmutingUser.label": "Error unmuting a user ...", + "common.failedLeaveChannel.text": "Failed to leave channel", + "common.lastActivity.ariaLabel": "Last activity: {{ time }}", + "common.leftChannel.text": "Left channel", + "common.liveLocation.text": "Live location", + "common.location.text": "Location", + "common.messageDeleted.text": "Message deleted", + "common.messagePinned.label": "Message pinned", + "common.mute.title": "Mute", + "common.muted.label": "{{ user }} has been muted", + "common.newMessages.label_one": "{{count}} new message", + "common.newMessages.label_other": "{{count}} new messages", + "common.nothingYet.text": "Nothing yet...", + "common.offline.label": "Offline", + "common.online.label": "Online", + "common.openReactionSelector.ariaLabel": "Open Reaction Selector", + "common.pause.ariaLabel": "Pause", + "common.pin.title": "Pin", + "common.play.ariaLabel": "Play", + "common.playbackSpeedX.label": "Playback speed {{ rate }}x", + "common.poll.label": "Poll", + "common.reminderSet.text": "Reminder set", + "common.replyCount.label_one": "1 reply", + "common.replyCount.label_other": "{{ count }} replies", + "common.resultsLoaded.label": "All results loaded", + "common.retryUpload.ariaLabel": "Retry upload", + "common.savedLater.text": "Saved for later", + "common.search.ariaLabel": "Search", + "common.send.label": "Send", + "common.threads.text": "Threads", + "common.unblock.ariaLabel": "Unblock", + "common.unmute.title": "Unmute", + "common.unmuted.label": "{{ user }} has been unmuted", + "common.unpin.title": "Unpin", + "common.unsupportedAttachment.text": "Unsupported attachment", + "common.userBlocked.text": "User blocked", + "common.userUnblocked.text": "User unblocked", + "common.userUploadedContent.label": "User uploaded content", + "common.voiceMessage.label": "Voice message", + "common.you.label": "You", + "dialog.callout.closeCalloutDialog.ariaLabel": "Close callout dialog", + "dialog.contextMenu.backParentMenuButton.ariaLabel": "Back to parent menu button", + "dialog.contextMenu.submenu.ariaLabel": "Submenu", + "dialog.prompt.goBack.ariaLabel": "Go back", + "dialog.viewer.closeDialog.ariaLabel": "Close dialog", + "duration.messageReminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration.remindMe": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration.shareLocation": "{{ milliseconds | durationFormatter }}", + "emojiPicker.emojiPicker.ariaLabel": "Emoji picker", + "emptyState.indicator.noConversationsYet.label": "No conversations yet", + "emptyState.indicator.noItemsExist.text": "No items exist", + "emptyState.indicator.startConversation.label": "Send a message to start the conversation", + "fileUpload.uploadButton.fileUpload.ariaLabel": "File upload", + "form.numericInput.decreaseValue.ariaLabel": "Decrease value", + "form.numericInput.increaseValue.ariaLabel": "Increase value", + "form.switchField.disabled.ariaLabel": "{{ setting }} disabled", + "form.switchField.enabled.ariaLabel": "{{ setting }} enabled", + "gallery.ui.nextImage.ariaLabel": "Next image", + "gallery.ui.previousImage.ariaLabel": "Previous image", + "loadMore.button.loadMore.label": "Load more", + "loading.errorIndicator.error.text": "Error: {{ errorMessage }}", + "loading.progressIndicators.percentComplete.ariaLabel": "{{percent}} percent complete", + "location.shareLocationDialog.attach.text": "Attach", + "location.shareLocationDialog.description": "Select your current location and optionally enable live location sharing", + "location.shareLocationDialog.share.text": "Share", + "location.shareLocationDialog.shareLiveLocation.title": "Share live location for", + "location.shareLocationDialog.shareLocation.title": "Share Location", + "mediaRecorder.audioRecorderRecording.cancelRecording.ariaLabel": "Cancel recording", + "mediaRecorder.audioRecorderRecording.completeRecording.ariaLabel": "Complete recording", + "mediaRecorder.audioRecorderRecording.pauseRecording.ariaLabel": "Pause recording", + "mediaRecorder.audioRecorderRecording.resumeRecording.ariaLabel": "Resume recording", + "mediaRecorder.audioRecorderRecording.voiceMessageDeleted.text": "Voice message deleted", + "mediaRecorder.audioRecordingButton.startRecordingAudio.ariaLabel": "Start recording audio", + "mediaRecorder.error.processing": "An error has occurred during the recording processing", + "mediaRecorder.error.recording": "An error has occurred during recording", + "mediaRecorder.error.start": "Error starting recording", + "mediaRecorder.permissionDenied.camera.body": "To start recording, allow the camera access in your browser", + "mediaRecorder.permissionDenied.camera.heading": "Allow access to camera", + "mediaRecorder.permissionDenied.microphone.body": "To start recording, allow the microphone access in your browser", + "mediaRecorder.permissionDenied.microphone.heading": "Allow access to microphone", + "mention.channel.description": "Notify everyone in this channel", + "mention.here.description": "Notify every online member in this channel", + "message.alsoSent.alsoSentChannel.text": "Also sent in channel", + "message.alsoSent.repliedThread.text": "Replied to a thread", + "message.alsoSent.view.text": "View", + "message.and.withCommaSeparatedUsersAndLastUser.label": "{{ commaSeparatedUsers }}, and {{ lastUser }}", + "message.and.withFirstUserAndSecondUser.label": "{{ firstUser }} and {{ secondUser }}", + "message.blocked.text": "Message was blocked by moderation policies", + "message.editedIndicator.edited.text": "Edited", + "message.more.label": "{{ commaSeparatedUsers }} and {{ moreCount }} more", + "message.pinIndicator.pinned.label": "Pinned by You", + "message.pinIndicator.pinned.withName.label": "Pinned by {{ name }}", + "message.reminderNotification.due.label": "Due {{ timeLeft }}", + "message.reminderNotification.dueSince.label": "Due since {{ dueSince }}", + "message.status.delivered.text": "Delivered", + "message.status.sending.text": "Sending...", + "message.status.sent.text": "Sent", + "message.text.message.ariaLabel": "Message,", + "message.text.message.withUser.ariaLabel": "Message from {{ user }},", + "message.translationIndicator.original.text": "Original", + "message.translationIndicator.translated.text": "Translated", + "message.translationIndicator.translated.withLanguage.text": "Translated from {{ language }}", + "message.translationIndicator.viewOriginal.text": "View original", + "message.translationIndicator.viewTranslation.text": "View translation", + "message.ui.reviewBouncedMessage.ariaLabel": "Review bounced message", + "messageActions.blockUser.ariaLabel": "Block User", + "messageActions.bookmarkMessage.ariaLabel": "Bookmark Message", + "messageActions.copyMessage.text": "Copy Message", + "messageActions.copyMessageText.ariaLabel": "Copy Message Text", + "messageActions.deleteMessage.ariaLabel": "Delete Message", + "messageActions.deleteMessageAlert.deleteMessage.title": "Delete message", + "messageActions.deleteMessageAlert.description": "Are you sure you want to delete this message?", + "messageActions.downloadSubmenu.download.label": "Download {{ fileName }}", + "messageActions.downloadSubmenu.download.text": "Download All", + "messageActions.downloadSubmenu.downloadAttachment.label": "Download attachment {{ number }}", + "messageActions.editMessage.ariaLabel": "Edit Message", + "messageActions.errorAddingFlag.text": "Error adding flag", + "messageActions.errorMarkingMessageUnread.text": "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.", + "messageActions.flag.text": "Flag", + "messageActions.flagMessage.ariaLabel": "Flag Message", + "messageActions.markMessageUnread.ariaLabel": "Mark Message Unread", + "messageActions.markUnread.text": "Mark as unread", + "messageActions.messageActions.ariaLabel": "Message Actions", + "messageActions.messageMarkedUnread.text": "Message marked as unread", + "messageActions.messageSuccessfullyFlagged.text": "Message has been successfully flagged", + "messageActions.messageUnpinned.text": "Message unpinned", + "messageActions.muteUser.ariaLabel": "Mute User", + "messageActions.openMessageActionsMenu.ariaLabel": "Open Message Actions Menu", + "messageActions.openThread.ariaLabel": "Open Thread", + "messageActions.pinMessage.ariaLabel": "Pin Message", + "messageActions.quoteMessage.ariaLabel": "Quote Message", + "messageActions.quoteReply.text": "Quote Reply", + "messageActions.remindMe.text": "Remind me", + "messageActions.remindMeMessage.ariaLabel": "Remind Me Message", + "messageActions.remindMeSubmenu.remindMe.text": "Remind Me", + "messageActions.removeReminder.ariaLabel": "Remove Reminder", + "messageActions.removeReminder.text": "Remove reminder", + "messageActions.removeSaveLater.ariaLabel": "Remove Save For Later", + "messageActions.removeSaveLater.text": "Remove save for later", + "messageActions.resend.text": "Resend", + "messageActions.resendMessage.ariaLabel": "Resend Message", + "messageActions.saveLater.text": "Save for later", + "messageActions.threadReply.text": "Thread Reply", + "messageActions.unmuteUser.ariaLabel": "Unmute User", + "messageActions.unpinMessage.ariaLabel": "Unpin Message", + "messageBounce.prompt.description": "Review this message and choose whether to delete it, edit it, or send it anyway", + "messageBounce.prompt.sendAnyway.text": "Send Anyway", + "messageBounce.prompt.title": "This message did not meet our content guidelines", + "messageComposer.attachmentPreviewRoot.showPreview.ariaLabel": "Show preview", + "messageComposer.attachmentSelector.attachmentActions.ariaLabel": "Attachment Actions", + "messageComposer.attachmentSelector.commands.text": "Commands", + "messageComposer.attachmentSelector.file.text": "File", + "messageComposer.attachmentSelector.openAttachmentSelector.ariaLabel": "Open Attachment Selector", + "messageComposer.audioAttachmentPreview.fileTooLarge.text": "File too large", + "messageComposer.audioAttachmentPreview.retryUpload.text": "Retry upload", + "messageComposer.audioAttachmentPreview.uploadBlocked.text": "Upload blocked", + "messageComposer.audioAttachmentPreview.uploadError.text": "Upload error", + "messageComposer.audioAttachmentPreview.uploadFailed.text": "Upload failed", + "messageComposer.commandChip.exitCommand.ariaLabel": "Exit command {{ command }}", + "messageComposer.commandsMenu.backAttachments.ariaLabel": "Back to attachments", + "messageComposer.commandsMenu.instantCommands.text": "Instant commands", + "messageComposer.dragDropUpload.dragFiles.text": "Drag your files here", + "messageComposer.dragDropUpload.someFilesNotAccepted.text": "Some of the files will not be accepted", + "messageComposer.geolocationPreview.live.text": "Live for {{duration}}", + "messageComposer.geolocationPreview.location.text": "Location: {{ coordinates }}", + "messageComposer.geolocationPreview.removeLocationAttachment.ariaLabel": "Remove location attachment", + "messageComposer.geolocationPreview.sharedLocation.title": "Shared location", + "messageComposer.icons.attachFiles.text": "Attach files", + "messageComposer.quotedMessagePreview.cancelReply.ariaLabel": "Cancel Reply", + "messageComposer.quotedMessagePreview.files.label_one": "{{ count }} file", + "messageComposer.quotedMessagePreview.files.label_other": "{{ count }} files", + "messageComposer.quotedMessagePreview.jumpQuotedMessage.ariaLabel": "Jump to quoted message", + "messageComposer.quotedMessagePreview.photo.label": "Photo", + "messageComposer.quotedMessagePreview.photos.label_one": "{{ count }} photo", + "messageComposer.quotedMessagePreview.photos.label_other": "{{ count }} photos", + "messageComposer.quotedMessagePreview.reply.text": "Reply", + "messageComposer.quotedMessagePreview.reply.withAuthorName.text": "Reply to {{ authorName }}", + "messageComposer.quotedMessagePreview.video.label": "Video", + "messageComposer.quotedMessagePreview.videos.label_one": "{{ count }} video", + "messageComposer.quotedMessagePreview.videos.label_other": "{{ count }} videos", + "messageComposer.quotedMessagePreview.voiceMessage.label": "Voice message {{ duration }}", + "messageComposer.removeAttachmentPreview.removeAttachment.ariaLabel": "Remove attachment", + "messageComposer.sendButton.send.ariaLabel": "Send", + "messageComposer.sendChannelCheckbox.alsoSendChannel.label": "Also send in channel", + "messageComposer.sendChannelCheckbox.alsoSendDirectMessage.label": "Also send as a direct message", + "messageComposer.sendMessageFn.sendMessageRequestFailed.text": "Send message request failed", + "messageComposer.stopAiGeneration.stopAiGeneration.ariaLabel": "Stop AI Generation", + "messageComposer.updateMessageFn.editMessageRequestFailed.text": "Edit message request failed", + "messageList.newMessageNotification.newMessages.label": "New Messages!", + "messageList.scrollLatestMessage.jumpLatestMessage.ariaLabel": "Jump to latest message", + "messageList.unreadMessagesNotification.markMessagesRead.ariaLabel": "Mark messages as read", + "messageList.unreadMessagesNotification.unread.text_one": "{{count}} unread", + "messageList.unreadMessagesNotification.unread.text_other": "{{count}} unread", + "messageList.unreadMessagesNotification.unreadMessages.text": "Unread messages", + "messagePreview.latestMessagePreview.fileCount.label_one": "File", + "messagePreview.latestMessagePreview.fileCount.label_other": "{{ count }} files", + "messagePreview.latestMessagePreview.imageCount.label_one": "Image", + "messagePreview.latestMessagePreview.imageCount.label_other": "{{ count }} images", + "messagePreview.latestMessagePreview.linkCount.label_one": "Link", + "messagePreview.latestMessagePreview.linkCount.label_other": "{{ count }} links", + "messagePreview.latestMessagePreview.messageFailedSend.text": "Message failed to send", + "messagePreview.latestMessagePreview.videoCount.label_one": "Video", + "messagePreview.latestMessagePreview.videoCount.label_other": "{{ count }} videos", + "messagePreview.latestMessagePreview.voiceMessageCount.label_one": "Voice message", + "messagePreview.latestMessagePreview.voiceMessageCount.label_other": "{{ count }} voice messages", + "notification.attachmentFileMissing": "File is required for upload attachment", + "notification.attachmentIdMissing": "Local upload attachment missing local id", + "notification.attachmentUploadBlockedWithReason": "Attachment upload blocked due to {{reason}}", + "notification.attachmentUploadFailed": "Error uploading attachment", + "notification.attachmentUploadFailedWithReason": "Attachment upload failed due to {{reason}}", + "notification.attachmentUploadInProgress": "Wait until all attachments have uploaded", + "notification.audioPlaybackError": "Error reproducing the recording", + "notification.commandDisabled": "Command not available", + "notification.commandDisabledWhileEditing": "Command not available while editing", + "notification.commandDisabledWhileReplying": "Command not available while replying", + "notification.commandNotReady": "Command not ready to be sent", + "notification.dismissNotification.ariaLabel": "Dismiss notification", + "notification.list.notifications.ariaLabel": "Notifications", + "notification.locationGetFailed": "Failed to retrieve location", + "notification.locationShareFailed": "Failed to share location", + "notification.messageJumpFailed": "Failed to jump to the message", + "notification.messageJumpToLatestFailed": "Failed to jump to the latest message", + "notification.pollCreateFailed": "Failed to create the poll", + "notification.pollCreateFailedWithReason": "Failed to create the poll due to {{reason}}", + "notification.pollEndFailed": "Failed to end the poll", + "notification.pollEndFailedWithReason": "Failed to end the poll due to {{reason}}", + "notification.pollEndSuccess": "Poll Ended", + "notification.pollVoteLimit": "Reached the vote limit. Remove an existing vote first.", + "notification.reason.sizeLimit": "size limit", + "notification.reason.unknownError": "unknown error", + "notification.reason.unsupportedFileType": "unsupported file type", + "notification.replySearchFailed": "Thread has not been found", + "poll.actions.suggestOption.label": "Suggest an Option", + "poll.actions.viewComments.label_one": "View {{count}} Comment", + "poll.actions.viewComments.label_other": "View {{count}} Comments", + "poll.actions.viewResults.label": "View Results", + "poll.addCommentPrompt.addComment.label": "Add a Comment", + "poll.addCommentPrompt.addCommentPollAnswer.label": "Add a comment to your poll answer", + "poll.addCommentPrompt.fieldCannotEmptyContain.label": "This field cannot be empty or contain only spaces", + "poll.addCommentPrompt.update.text": "Update", + "poll.addCommentPrompt.updateComment.label": "Update Your Comment", + "poll.addCommentPrompt.updateCommentAttachedPoll.label": "Update the comment attached to your poll answer", + "poll.answerList.description": "Review comments submitted with poll answers", + "poll.answerList.pollComments.title": "Poll Comments", + "poll.creationDialog.allowOthersAddComments.description": "Allow Others to Add Comments", + "poll.creationDialog.anonymousPoll.title": "Anonymous Poll", + "poll.creationDialog.createPoll.title": "Create Poll", + "poll.creationDialog.hideWhoVoted.description": "Hide Who Voted", + "poll.creationDialog.letOthersAddOptions.description": "Let Others Add Options", + "poll.creationDialog.pollSent.text": "Poll sent", + "poll.creationDialog.sendPoll.text": "Send Poll", + "poll.endPollAlert.description": "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.", + "poll.endPollAlert.endPoll.text": "End Poll", + "poll.endPollAlert.endPoll.title": "End this Poll?", + "poll.header.selectOne.label": "Select one", + "poll.header.selectOneMore.label": "Select one or more", + "poll.header.selectUp.label_one": "Select up to {{count}}", + "poll.header.selectUp.label_other": "Select up to {{count}}", + "poll.header.voteEnded.label": "Vote ended", + "poll.multipleAnswersField.chooseBetween210.description": "Choose Between 2 to 10 Options", + "poll.multipleAnswersField.enforceUniqueVoteEnabled.label": "Enforce unique vote is enabled", + "poll.multipleAnswersField.limitVotesPerPerson.title": "Limit Votes per Person", + "poll.multipleAnswersField.maximumVotesPerPerson.ariaLabel": "Maximum votes per person", + "poll.multipleAnswersField.multipleVotes.title": "Multiple Votes", + "poll.multipleAnswersField.onlyNumbersAllowed.label": "Only numbers are allowed", + "poll.multipleAnswersField.selectMoreThanOne.description": "Select More Than One Option", + "poll.multipleAnswersField.typeNumber210.label": "Type a number from 2 to 10", + "poll.nameField.askQuestion.placeholder": "Ask a Question", + "poll.nameField.questionRequired.label": "Question is required", + "poll.optionFieldSet.addOption.placeholder": "Add an Option", + "poll.optionFieldSet.option.ariaLabel": "Option {{ position }}", + "poll.optionFieldSet.optionCanReorderedRemoved.ariaLabel": "This option can be reordered and removed.", + "poll.optionFieldSet.optionEmpty.label": "Option is empty", + "poll.optionFieldSet.options.label": "Options", + "poll.optionFieldSet.optionsCanNowReordered.ariaLabel": "Options can now be reordered and removed.", + "poll.optionFieldSet.removeOption.ariaLabel": "Remove option: {{ option }}", + "poll.optionList.moreOptions.label_one": "+{{count}} more option", + "poll.optionList.moreOptions.label_other": "+{{count}} more options", + "poll.optionReorder.pressSpaceSelectOption.ariaLabel": "Press Space to select this option, use the Up and Down arrow keys to move it, then press Space again to deselect it.", + "poll.optionReorder.reorderOption.ariaLabel": "Reorder option {{ position }}", + "poll.optionReorder.reorderPosition.ariaLabel": "Reorder \"{{ option }}\" at position {{ position }} of {{ total }}", + "poll.optionVotes.question.text": "Question {{ optionOrderNumber}}", + "poll.optionVotes.view.text": "View all", + "poll.optionVotes.votes.text_one": "{{count}} vote", + "poll.optionVotes.votes.text_other": "{{count}} votes", + "poll.optionsFull.description": "Review all options available in this poll", + "poll.optionsFull.pollOptions.title": "Poll Options", + "poll.pollComment.placeholder": "Your comment", + "poll.pollOptionSuggestion.placeholder": "Enter a new option", + "poll.question.question.text": "Question", + "poll.results.pollResults.title": "Poll Results", + "poll.results.reviewPollResultsOpen.description": "Review poll results and open an option to see detailed votes", + "poll.results.reviewWhoVotedOption.description": "Review who voted for this option", + "poll.results.totalVoteCount.text_one": "1 vote total", + "poll.results.totalVoteCount.text_other": "{{ count }} votes total", + "poll.results.votes.title": "Votes", + "poll.suggestPollOption.description": "Suggest a new option to add to this poll", + "poll.suggestPollOption.optionAlreadyExists.label": "Option already exists", + "reactions.fetchReactions.errorFetchingReactions.text": "Error loading reactions", + "reactions.messageReactions.reactionList.ariaLabel": "Reaction list", + "reactions.messageReactions.selectReaction.ariaLabel": "Select Reaction: {{ reactionName }}", + "reactions.messageReactionsDetail.reactions.text_one": "{{ count }} reaction", + "reactions.messageReactionsDetail.reactions.text_other": "{{ count }} reactions", + "reactions.messageReactionsDetail.tapRemove.ariaLabel": "Tap to remove: {{ reactionName }}", + "reactions.messageReactionsDetail.tapRemove.text": "Tap to remove", + "search.bar.clearSearch.ariaLabel": "Clear search", + "search.bar.exitSearch.ariaLabel": "Exit search", + "search.resultItem.selectUserChannel.ariaLabel": "Select User Channel: {{ name }}", + "search.results.searchResults.ariaLabel": "Search results", + "search.resultsHeader.ariaLabel": "Search results header filter button for: {{ source }}", + "search.resultsHeader.filterSource.channels": "channels", + "search.resultsHeader.filterSource.messages": "messages", + "search.resultsHeader.filterSource.users": "users", + "search.resultsPresearch.startTypingSearch.text": "Start typing to search", + "search.sourceResults.noResultsFound.text": "No results found", + "search.sourceResults.searching.text": "Searching for {{ searchSourceType }}...", + "slotLayout.chatView.channels.text": "Channels", + "slotLayout.chatView.chatViewControls.ariaLabel": "Chat view controls", + "slotLayout.chatView.openChannelsView.ariaLabel": "Open channels view", + "slotLayout.chatView.openThreadsView.ariaLabel": "Open threads view", + "slotLayout.chatView.openThreadsViewUnread.ariaLabel_one": "Open threads view, {{ count }} unread thread", + "slotLayout.chatView.openThreadsViewUnread.ariaLabel_other": "Open threads view, {{ count }} unread threads", + "textareaComposer.messageInput.ariaLabel": "Message input", + "textareaComposer.roleItem.notifyMembers.label": "Notify all {{ role }} members", + "textareaComposer.suggestionList.commandSuggestions.ariaLabel": "Command Suggestions", + "textareaComposer.suggestionList.emojiSuggestions.ariaLabel": "Emoji Suggestions", + "textareaComposer.suggestionList.mentionSuggestions.ariaLabel": "Mention Suggestions", + "textareaComposer.suggestionList.suggestions.ariaLabel": "Suggestions", + "textareaComposer.textareaPlaceholder.searchGiFs.label": "Search GIFs", + "textareaComposer.textareaPlaceholder.sendMessage.label": "Send a message", + "textareaComposer.textareaPlaceholder.slowModeWaitS.label": "Slow mode, wait {{ seconds }}s...", + "thread.header.closeThread.ariaLabel": "Close thread", + "thread.header.thread.text": "Thread", + "threadList.chat.ariaLabel": "Chat: {{ channelName }}", + "threadList.empty.text": "Reply to a message to start a thread", + "threadList.thread.ariaLabel": "Thread: {{ messagePreview }}", + "threadList.threadList.ariaLabel": "Thread list", + "threadList.unseenBanner.loading": "Loading...", + "threadList.unseenBanner.unreadThreads_one": "{{ count }} unread thread", + "threadList.unseenBanner.unreadThreads_other": "{{ count }} unread threads", + "timestamp.ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp.ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", + "timestamp.ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", + "timestamp.DateSeparator": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp.GalleryTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp.LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp.MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: false; format: HH:mm) }}", + "timestamp.PollVote": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", + "timestamp.PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp.ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp.SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", + "translationBuilderTopic.notification": "{{value, notification}}", + "typing.manyUsers_one": "{{ count }} person is typing", + "typing.manyUsers_other": "{{ count }} people are typing", + "typing.singleUser": "{{ typing }} is typing", + "typing.twoUsers": "{{ typing }} are typing", + "videoPlayer.videoThumbnail.playVideo.ariaLabel": "Play video" +} diff --git a/src/i18n/__tests__/catalogRenders.test.ts b/src/i18n/__tests__/catalogRenders.test.ts new file mode 100644 index 0000000000..24b09d62c3 --- /dev/null +++ b/src/i18n/__tests__/catalogRenders.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; + +import { StreamI18n } from '../Streami18n'; +import catalog from './catalog.fixture.json'; + +/** + * Renders every key in the shipped catalog and asserts none of them surfaces as its own dotted path. + * + * This is the net for the one failure mode the codegen cannot catch statically: the generator proves a + * key *has* copy somewhere, but only actually resolving it through i18next proves the copy comes out. + * A key whose bundled value went missing, whose plural forms do not cover the categories it is called + * with, or whose interpolation names do not match what the call site passes, all render as a raw key or + * with a literal `{{ placeholder }}` — visible to a user, invisible to types. + * + * `keys.ts` is type-only, so a test cannot iterate it. `catalog.fixture.json` is its data twin, emitted + * by the same generator run and living under `__tests__` so it never reaches the published build. Ported + * from the React Native SDK, which had it first. + */ +const DOTTED_KEY = /^[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9_]+)+$/; + +/** + * Values for whichever variables a key's own copy declares. + * + * Derived from the copy rather than a fixed list, so a leftover `{{ placeholder }}` means i18next + * genuinely failed to interpolate something it was handed — not merely that this test forgot a name. + * `{{ x | formatter(...) }}` and `{{ x, formatter }}` both name the variable first. + */ +const interpolationValuesFor = (copy: string) => { + const values: Record = { + count: 2, + milliseconds: 60_000, + timestamp: '2026-03-13T14:32:00.000Z', + }; + for (const [, inner] of copy.matchAll(/\{\{([^}]*)\}\}/g)) { + const name = inner.split(/[|,]/)[0].trim(); + if (name && !(name in values)) values[name] = 'x'; + } + return values; +}; + +const entries = Object.entries(catalog as Record); + +const catalogOf = (key: string) => (catalog as Record)[key]; + +/** Plural entries live as `_one` / `_other`; call sites use the bare handle plus `count`. */ +const pluralBases = [ + ...new Set( + entries + .map(([key]) => key.match(/^(.*)_(?:zero|one|two|few|many|other)$/)?.[1]) + .filter((base): base is string => Boolean(base)), + ), +]; +/** + * `translationBuilderTopic.*` keys are post-processor *directives*, not copy. + * + * Their value (`{{value, notification}}`) names a post-processor, and the post-processor replaces the + * whole resolved string once it is handed the object it dispatches on. Rendered bare — with no + * `notification` in the options — the topic passes through and the placeholder legitimately remains, so + * they cannot be checked the way copy is. `NotificationTranslationBuilder.test.ts` covers them. + */ +const isDirective = (key: string) => key.startsWith('translationBuilderTopic.'); + +const singularKeys = entries + .map(([key]) => key) + .filter((key) => !/_(?:zero|one|two|few|many|other)$/.test(key) && !isDirective(key)); + +describe('translation catalog renders', () => { + it('has entries to check', () => { + expect(entries.length).toBeGreaterThan(400); + expect(pluralBases.length).toBeGreaterThan(0); + }); + + it('renders every singular key without leaking the key or a placeholder', async () => { + const { t } = await new StreamI18n({ logger: () => {} }).init(); + const render = t as unknown as ( + key: string, + d?: string | Record, + o?: Record, + ) => string; + + const offenders: string[] = []; + for (const key of singularKeys) { + // The catalog's own copy is passed as the inline default, because that is where prose copy comes + // from at runtime — only `runtimeDefaults` keys resolve from a bundled resource. What this proves + // is that the declared copy actually renders: interpolation names line up, and a bundled key is + // not missing. + const rendered = render( + key, + catalogOf(key), + interpolationValuesFor(catalogOf(key)), + ); + if (!rendered || rendered === key || DOTTED_KEY.test(rendered)) { + offenders.push(`${key} -> ${JSON.stringify(rendered)}`); + } else if (rendered.includes('{{')) { + offenders.push(`${key} left a placeholder -> ${JSON.stringify(rendered)}`); + } + } + + expect(offenders).toEqual([]); + }); + + it('renders every plural key at each count without leaking the key or a placeholder', async () => { + const { t } = await new StreamI18n({ logger: () => {} }).init(); + const render = t as unknown as (key: string, o: Record) => string; + + const offenders: string[] = []; + for (const base of pluralBases) { + for (const count of [0, 1, 2, 5]) { + const forms = [`${base}_one`, `${base}_other`, `${base}_few`, `${base}_many`] + .map(catalogOf) + .filter(Boolean) + .join(' '); + const rendered = render(base, { + ...interpolationValuesFor(forms), + count, + defaultValue_one: catalogOf(`${base}_one`), + defaultValue_other: catalogOf(`${base}_other`), + }); + if (!rendered || rendered === base || DOTTED_KEY.test(rendered)) { + offenders.push(`${base} @ ${count} -> ${JSON.stringify(rendered)}`); + } else if (rendered.includes('{{')) { + offenders.push( + `${base} @ ${count} left a placeholder -> ${JSON.stringify(rendered)}`, + ); + } + } + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/i18n/__tests__/utils.test.ts b/src/i18n/__tests__/utils.test.ts index 61c9ff6635..1061d17238 100644 --- a/src/i18n/__tests__/utils.test.ts +++ b/src/i18n/__tests__/utils.test.ts @@ -453,16 +453,20 @@ describe('predefinedFormatters', () => { }).startsWith('Yesterday'), ).toBeFalsy(); }); - it('should log error parsing invalid calendarFormats', () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementationOnce(() => null); - timestampFormatter(yesterday, 'en', { calendar: true, calendarFormats: '}' }); - expect(consoleErrorSpy.mock.calls[0][0]).toBe('[TIMESTAMP FORMATTER]'); - expect( - consoleErrorSpy.mock.calls[0][1].message.startsWith('Unexpected token'), - ).toBeTruthy(); - consoleErrorSpy.mockRestore(); + it('should report invalid calendarFormats through the instance logger', () => { + const logger = vi.fn(); + // The instance doubles as the formatter context here, matching the suite's existing style. + const formatter = predefinedFormatters.timestampFormatter( + new Streami18n({ logger }) as never, + ); + + formatter(yesterday, 'en', { calendar: true, calendarFormats: '}' }); + + // Reported through `logger`, not `console.error`: it respects the `logger` option, and a + // malformed formatter argument is a diagnostic rather than something to render. + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('calendarFormats is not valid JSON'), + ); }); it('should parse calendarFormats', () => { expect( @@ -499,7 +503,7 @@ describe('predefinedFormatters', () => { calendarFormats: { sameElse: 'dddd L' }, format: 'YYYY', }), - ).toBe('null'); + ).toBe(''); }); it('should handle undefined value', () => { expect( @@ -508,7 +512,7 @@ describe('predefinedFormatters', () => { calendarFormats: { sameElse: 'dddd L' }, format: 'YYYY', }), - ).toBeUndefined(); + ).toBe(''); }); describe('relativeCompact', () => { diff --git a/src/i18n/externalStrings.ts b/src/i18n/externalStrings.ts deleted file mode 100644 index 05c83cffa0..0000000000 --- a/src/i18n/externalStrings.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { TranslationContextValue } from '../context/TranslationContext'; -import type { TranslationKey } from './types'; -import { asDynamicKey } from './utils'; - -/** - * `notification.message` values emitted by `stream-chat` (the LLC) are English sentences that - * reach `t()` as a runtime value, so the extractor never sees them and they cannot be renamed - * from this repo. This table maps the ones we recognise onto the SDK's own keys. - * - * Anything not listed falls through unchanged — the same behaviour as before this map existed: - * the raw English string is displayed. - * - * Server-supplied strings keyed by a stable identifier rather than by their English text - * (slash-command `args`/`description` by command name, Giphy actions by action value) are - * deliberately *not* here: their components declare those keys in local lookup tables, which - * keeps them visible to the extractor. Renaming the notification messages at the source needs - * a `stream-chat` change; until then this table is the seam. - * - * The string on the left is what renders in English, so `yarn build-translations` requires it to - * match the key's catalog copy — two entries below say the same thing in different words and are - * allowlisted as `REPHRASED_EXTERNAL_STRINGS` in `scripts/generate-i18n-keys.mts`. - */ -export const EXTERNAL_STRING_KEYS: Record = { - 'Command not ready to be sent': 'notification.commandDisabled', - 'Error uploading attachment': 'notification.attachmentUploadFailed', - 'Failed to create the poll': 'notification.pollCreateFailed', - 'Failed to share the location': 'notification.locationShareFailed', - 'File is required for upload attachment': 'notification.attachmentFileMissing', - 'Local upload attachment missing local id': 'notification.attachmentIdMissing', - 'Reached the vote limit. Remove an existing vote first.': 'notification.pollVoteLimit', - 'Wait until all attachments have uploaded': 'notification.attachmentUploadInProgress', -}; - -/** - * Translate a string that originated outside the SDK. Known strings resolve through their - * stable key; unknown ones are returned as-is. - */ -export const translateExternalString = ( - t: TranslationContextValue['t'], - raw: string | undefined, - options?: Record, -): string => { - if (!raw) return ''; - const key = EXTERNAL_STRING_KEYS[raw]; - // `raw` doubles as the default so a mapped-but-untranslated key still renders English. - return key ? t(asDynamicKey(key), raw, options) : t(asDynamicKey(raw), raw, options); -}; diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts index 7fc68eb3ae..43ec7e5b15 100644 --- a/src/i18n/keys.ts +++ b/src/i18n/keys.ts @@ -1,4 +1,4 @@ -// AUTO-GENERATED by scripts/generate-i18n-keys.mts — do not edit by hand. +// AUTO-GENERATED — do not edit by hand. // Regenerate with `yarn build-translations`. CI fails if this file is out of sync. // // Type-only: no runtime value is emitted, so this adds nothing to the bundle. @@ -7,7 +7,7 @@ * Every translation entry shipped with the SDK, mapped to its English copy. * * Plural entries appear as `_one` / `_other`; call sites use the bare `` and - * pass `count`. See {@link TranslationKey}. + * pass `count`. */ export type TranslationCatalog = { 'a11y.accessibleLabel.active.ariaLabel': 'Active'; @@ -284,63 +284,6 @@ export type TranslationCatalog = { 'form.switchField.enabled.ariaLabel': '{{ setting }} enabled'; 'gallery.ui.nextImage.ariaLabel': 'Next image'; 'gallery.ui.previousImage.ariaLabel': 'Previous image'; - 'language.af': 'Afrikaans'; - 'language.am': 'Amharic'; - 'language.ar': 'Arabic'; - 'language.az': 'Azerbaijani'; - 'language.bg': 'Bulgarian'; - 'language.bn': 'Bengali'; - 'language.bs': 'Bosnian'; - 'language.cs': 'Czech'; - 'language.da': 'Danish'; - 'language.de': 'German'; - 'language.el': 'Greek'; - 'language.en': 'English'; - 'language.es': 'Spanish'; - 'language.es-MX': 'Spanish (Mexico)'; - 'language.et': 'Estonian'; - 'language.fa': 'Persian'; - 'language.fa-AF': 'Dari'; - 'language.fi': 'Finnish'; - 'language.fr': 'French'; - 'language.fr-CA': 'French (Canada)'; - 'language.ha': 'Hausa'; - 'language.he': 'Hebrew'; - 'language.hi': 'Hindi'; - 'language.hr': 'Croatian'; - 'language.ht': 'Haitian Creole'; - 'language.hu': 'Hungarian'; - 'language.id': 'Indonesian'; - 'language.it': 'Italian'; - 'language.ja': 'Japanese'; - 'language.ka': 'Georgian'; - 'language.ko': 'Korean'; - 'language.lt': 'Lithuanian'; - 'language.lv': 'Latvian'; - 'language.ms': 'Malay'; - 'language.nl': 'Dutch'; - 'language.no': 'Norwegian'; - 'language.pl': 'Polish'; - 'language.ps': 'Pashto'; - 'language.pt': 'Portuguese'; - 'language.ro': 'Romanian'; - 'language.ru': 'Russian'; - 'language.sk': 'Slovak'; - 'language.sl': 'Slovenian'; - 'language.so': 'Somali'; - 'language.sq': 'Albanian'; - 'language.sr': 'Serbian'; - 'language.sv': 'Swedish'; - 'language.sw': 'Swahili'; - 'language.ta': 'Tamil'; - 'language.th': 'Thai'; - 'language.tl': 'Tagalog'; - 'language.tr': 'Turkish'; - 'language.uk': 'Ukrainian'; - 'language.ur': 'Urdu'; - 'language.vi': 'Vietnamese'; - 'language.zh': 'Chinese (Simplified)'; - 'language.zh-TW': 'Chinese (Traditional)'; 'loadMore.button.loadMore.label': 'Load more'; 'loading.errorIndicator.error.text': 'Error: {{ errorMessage }}'; 'loading.progressIndicators.percentComplete.ariaLabel': '{{percent}} percent complete'; @@ -497,11 +440,13 @@ export type TranslationCatalog = { 'notification.commandDisabled': 'Command not available'; 'notification.commandDisabledWhileEditing': 'Command not available while editing'; 'notification.commandDisabledWhileReplying': 'Command not available while replying'; + 'notification.commandNotReady': 'Command not ready to be sent'; 'notification.dismissNotification.ariaLabel': 'Dismiss notification'; - 'notification.jumpToFirstUnreadFailed': 'Failed to jump to the first unread message'; 'notification.list.notifications.ariaLabel': 'Notifications'; 'notification.locationGetFailed': 'Failed to retrieve location'; 'notification.locationShareFailed': 'Failed to share location'; + 'notification.messageJumpFailed': 'Failed to jump to the message'; + 'notification.messageJumpToLatestFailed': 'Failed to jump to the latest message'; 'notification.pollCreateFailed': 'Failed to create the poll'; 'notification.pollCreateFailedWithReason': 'Failed to create the poll due to {{reason}}'; 'notification.pollEndFailed': 'Failed to end the poll'; @@ -548,7 +493,6 @@ export type TranslationCatalog = { 'poll.multipleAnswersField.selectMoreThanOne.description': 'Select More Than One Option'; 'poll.multipleAnswersField.typeNumber210.label': 'Type a number from 2 to 10'; 'poll.nameField.askQuestion.placeholder': 'Ask a Question'; - 'poll.nameField.error.text': 'Error'; 'poll.nameField.questionRequired.label': 'Question is required'; 'poll.optionFieldSet.addOption.placeholder': 'Add an Option'; 'poll.optionFieldSet.option.ariaLabel': 'Option {{ position }}'; @@ -586,12 +530,6 @@ export type TranslationCatalog = { 'reactions.messageReactionsDetail.reactions.text_other': '{{ count }} reactions'; 'reactions.messageReactionsDetail.tapRemove.ariaLabel': 'Tap to remove: {{ reactionName }}'; 'reactions.messageReactionsDetail.tapRemove.text': 'Tap to remove'; - 'relativeTime.daysAgo_one': '{{ count }}d ago'; - 'relativeTime.daysAgo_other': '{{ count }}d ago'; - 'relativeTime.today': 'Today'; - 'relativeTime.weeksAgo_one': '{{ count }}w ago'; - 'relativeTime.weeksAgo_other': '{{ count }}w ago'; - 'relativeTime.yesterday': 'Yesterday'; 'search.bar.clearSearch.ariaLabel': 'Clear search'; 'search.bar.exitSearch.ariaLabel': 'Exit search'; 'search.resultItem.selectUserChannel.ariaLabel': 'Select User Channel: {{ name }}'; diff --git a/src/i18n/runtimeDefaults.ts b/src/i18n/runtimeDefaults.ts index 8b436a7e9b..fa2b7e99fc 100644 --- a/src/i18n/runtimeDefaults.ts +++ b/src/i18n/runtimeDefaults.ts @@ -21,63 +21,6 @@ export const runtimeDefaults = { 'duration.messageReminder': '{{ milliseconds | durationFormatter(withSuffix: true) }}', 'duration.remindMe': '{{ milliseconds | durationFormatter(withSuffix: true) }}', 'duration.shareLocation': '{{ milliseconds | durationFormatter }}', - 'language.af': 'Afrikaans', - 'language.am': 'Amharic', - 'language.ar': 'Arabic', - 'language.az': 'Azerbaijani', - 'language.bg': 'Bulgarian', - 'language.bn': 'Bengali', - 'language.bs': 'Bosnian', - 'language.cs': 'Czech', - 'language.da': 'Danish', - 'language.de': 'German', - 'language.el': 'Greek', - 'language.en': 'English', - 'language.es': 'Spanish', - 'language.es-MX': 'Spanish (Mexico)', - 'language.et': 'Estonian', - 'language.fa': 'Persian', - 'language.fa-AF': 'Dari', - 'language.fi': 'Finnish', - 'language.fr': 'French', - 'language.fr-CA': 'French (Canada)', - 'language.ha': 'Hausa', - 'language.he': 'Hebrew', - 'language.hi': 'Hindi', - 'language.hr': 'Croatian', - 'language.ht': 'Haitian Creole', - 'language.hu': 'Hungarian', - 'language.id': 'Indonesian', - 'language.it': 'Italian', - 'language.ja': 'Japanese', - 'language.ka': 'Georgian', - 'language.ko': 'Korean', - 'language.lt': 'Lithuanian', - 'language.lv': 'Latvian', - 'language.ms': 'Malay', - 'language.nl': 'Dutch', - 'language.no': 'Norwegian', - 'language.pl': 'Polish', - 'language.ps': 'Pashto', - 'language.pt': 'Portuguese', - 'language.ro': 'Romanian', - 'language.ru': 'Russian', - 'language.sk': 'Slovak', - 'language.sl': 'Slovenian', - 'language.so': 'Somali', - 'language.sq': 'Albanian', - 'language.sr': 'Serbian', - 'language.sv': 'Swedish', - 'language.sw': 'Swahili', - 'language.ta': 'Tamil', - 'language.th': 'Thai', - 'language.tl': 'Tagalog', - 'language.tr': 'Turkish', - 'language.uk': 'Ukrainian', - 'language.ur': 'Urdu', - 'language.vi': 'Vietnamese', - 'language.zh': 'Chinese (Simplified)', - 'language.zh-TW': 'Chinese (Traditional)', 'timestamp.ChannelDetailPinnedMessageTimestamp': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "LT", "lastDay": "[Yesterday]", "lastWeek": "dddd", "sameElse": "L" }) }}', 'timestamp.ChannelMembersLastActive': diff --git a/src/i18n/types.ts b/src/i18n/types.ts index d0ef60d83e..b982377a47 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -1,311 +1,74 @@ -import type { Streami18n } from './Streami18n'; -import type Dayjs from 'dayjs'; -import type { Moment } from 'moment-timezone'; -import type { MessageContextValue } from '../context'; -import type { TOptions } from 'i18next'; -import type { TranslationCatalog } from './keys'; - -type Whitespace = ' ' | '\n' | '\t'; -type Trim = S extends `${Whitespace}${infer R}` - ? Trim - : S extends `${infer R}${Whitespace}` - ? Trim - : S; - -/** `{{ value, formatter }}` and `{{ value | formatter(...) }}` — the name is the leading part. */ -type VarName = Trim< - S extends `${infer Name},${string}` - ? Name - : S extends `${infer Name}|${string}` - ? Name - : S ->; - -/** - * The interpolation variables a copy string requires. - * - * i18next ships `InterpolationMap`, but it does not trim the placeholder, so `{{ setting }}` - * yields a property literally named `" setting "`. The SDK's copy uses spaced placeholders - * throughout, so we parse them ourselves. - */ -type InterpolationVars = - S extends `${string}{{${infer V}}}${infer Rest}` - ? (VarName extends '' ? never : VarName) | InterpolationVars - : never; - -type InterpolationArgs = [InterpolationVars] extends [never] - ? Record - : { [K in InterpolationVars]: number | string }; - -type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; -type CatalogKey = keyof TranslationCatalog & string; - -/** - * Keys whose catalog entries are plural forms (`_one` / `_other`). Call sites use the - * bare key and pass `count`; the suffixed forms are never referenced directly. - */ -export type PluralTranslationKey = CatalogKey extends infer K - ? K extends `${infer Base}_other` - ? Base - : never - : never; - -/** - * Every key the SDK's `t` accepts: the singular entries plus the bare handle for each plural. - * - * This is the *call-site* key set — use it to type a `t` parameter. It is deliberately **not** the - * right type for a dictionary: a plural lives in the catalog as `_one` / `_other` while - * `t()` takes the bare ``, so keying a dictionary on this rejects the very entries a - * translator has to supply. Use {@link TranslationDictionary} for that. - */ -export type TranslationKey = - | Exclude - | PluralTranslationKey; +import type { + LanguageNameCatalog, + LooseTranslationDictionaryOf, + PluralTranslationKeyOf, + RelativeTimeCatalog, + StreamTFunctionFor, + TDateTimeParser, + TimestampFormatterOptions, + TranslationDictionaryOf, + TranslationKeyOf, +} from 'stream-chat/i18n'; + +import type { TranslationCatalog as GeneratedCatalog } from './keys'; /** - * A translation dictionary for `Streami18n.registerTranslation()` / `translationsForLanguage`. - * - * Restricted to the SDK's own keys, so a typo or a leftover v14 key is a compile error rather than - * an override that silently never applies. Keyed on the catalog rather than on - * {@link TranslationKey}, so the `_one` / `_other` plural entries are accepted. - * - * The SDK's own copy only needs `_one` / `_other`, but a plural key accepts every category - * `Intl.PluralRules` can select, so Russian or Arabic can supply `_few`, `_many` and `_zero` and - * still have its keys checked. A plural suffix on a key that is not plural is rejected. + * The SDK's i18n types, instantiated from the generic helpers in `stream-chat/i18n`. * - * Widen to {@link LooseTranslationDictionary} only when you need keys the SDK does not define. + * The derivations live in core so both UI SDKs share one implementation; the *catalog* stays here, + * because it is generated from this SDK's own `t()` call sites. That split is why core's helpers are + * generic over the catalog rather than driven by module augmentation — two catalogs have to be able to + * coexist in one TypeScript program. * - * @example - * const de: TranslationDictionary = { - * 'common.cancel.label': 'Abbrechen', - * 'channelDetail.channelMembersView.members.title_one': '{{ count }} Mitglied', - * 'channelDetail.channelMembersView.members.title_other': '{{ count }} Mitglieder', - * }; - * - * @example - * const ru: TranslationDictionary = { - * 'channelDetail.channelMembersView.members.title_one': '{{ count }} участник', - * 'channelDetail.channelMembersView.members.title_few': '{{ count }} участника', - * 'channelDetail.channelMembersView.members.title_many': '{{ count }} участников', - * }; + * `language.*` keys come from core: `message.i18n.language` is typed `TranslationLanguage`, so core + * defines which languages exist and owns their display names. Intersecting them in is what makes + * `t('language.de')` a checked key instead of an `asDynamicKey()` escape. */ -export type TranslationDictionary = Partial> & - Partial>; +export type TranslationCatalog = GeneratedCatalog & + LanguageNameCatalog & + RelativeTimeCatalog; /** - * A translation dictionary that also admits keys the SDK does not define, so one `Streami18n` - * instance can carry an application's own copy alongside the SDK's. + * Keys resolved from bundled data rather than an inline `defaultValue`. * - * `registerTranslation()` and `translationsForLanguage` take the strict - * {@link TranslationDictionary}; annotate the variable you pass with this type to widen. Nothing - * catches a mistyped or stale SDK key here — it compiles, and then never matches at runtime. Note - * that {@link TranslationDictionary} already covers the extra plural categories, so a language - * needing `_few` / `_many` / `_zero` does not have to give up key checking. + * `timestamp.*` and `duration.*` are matched by prefix inside core. This adds the two prefixes specific + * to this SDK: the post-processor directives, and the language names, which are looked up by a runtime + * language code and so have no call site to carry a default. */ -export type LooseTranslationDictionary = Partial> & - Record; +type BundledKey = `translationBuilderTopic.${string}` | `language.${string}`; -/** The English copy for a key, used to infer that key's interpolation variables. */ -type CopyFor = K extends CatalogKey - ? TranslationCatalog[K] - : `${K}_other` extends CatalogKey - ? TranslationCatalog[`${K}_other` & CatalogKey] - : string; +export type PluralTranslationKey = PluralTranslationKeyOf; +export type TranslationKey = TranslationKeyOf; +export type TranslationDictionary = TranslationDictionaryOf; +export type LooseTranslationDictionary = LooseTranslationDictionaryOf; +export type StreamTFunction = StreamTFunctionFor; /** - * Keys whose value is a formatter expression or postProcessor directive rather than English copy. - * They resolve from the bundled `runtimeDefaults`, so call sites pass no inline default. Matched - * by prefix pattern - * rather than by enumerating the union, which keeps the overload resolution cheap. - */ -type FormatterKey = - | `timestamp.${string}` - | `duration.${string}` - | `translationBuilderTopic.${string}`; - -/** Keys whose value is English copy, passed inline as the `defaultValue`. */ -type ProseKey = Exclude; - -/** - * The SDK's translation function. - * - * Every call site passes its English copy inline as i18next's `defaultValue`, so the key stays - * stable across copy edits and a key missing from a custom dictionary still renders English. - * Interpolation variables are inferred from that copy, and plural keys require `count`. + * Options for `getDateString`. * - * Deliberately *not* installed via i18next's `CustomTypeOptions`: that augmentation is global and - * would force an integrator's own unrelated `t()` calls to satisfy the SDK's key union. + * Declared here rather than taken from core because `formatDate` is a component prop: core's own + * `GetDateStringParams` types it structurally as `(date: Date) => string`, which is the same shape, but + * keeping the alias local means the prop and this option cannot drift apart. */ -export type StreamTFunction = { - /** Plural key: `count` selects between the `_one` / `_other` copy. */ - ( - key: K, - options: TOptions & { count: number } & InterpolationArgs>, - ): string; - /** - * Formatter/plumbing key: resolves from the bundled `runtimeDefaults`, so no inline default. - * Options stay loose — - * the value is a formatter expression, so inferring its variables is neither useful nor cheap - * (`CopyFor` over a template-literal key pattern blows the union size limit). - */ - (key: FormatterKey, options?: TOptions & Record): string; - /** - * Prose key with its English copy inline. - * - * Neither `defaultValue` nor `options` is tied to the key's exact copy. Doing so means - * materialising `CopyFor` — the union of ~540 copy strings — which exceeds - * TypeScript's union size limit (TS2590). The two checks that would buy are covered elsewhere: - * the default matching the generated catalog is enforced by the drift gate, and missing - * interpolation variables surface as a literal `{{ placeholder }}` in the rendered output, - * which the test suite asserts on. - * - * Plural keys keep precise typing (see the first overload) because that union is small. - */ - ( - key: K, - defaultValue: string, - options?: TOptions & Record, - ): string; - /** - * Escape hatch for keys only known at runtime — a `notification.message` from `stream-chat`, - * slash-command metadata from the API, or an integrator-supplied prop. The raw string doubles - * as the default so it still renders verbatim when no translation exists. - */ - ( - key: DynamicTranslationKey, - defaultValueOrOptions?: string | (TOptions & Record), - options?: TOptions & Record, - ): string; -}; - -/** - * A translation key resolved from a runtime value rather than written literally. - * - * The brand is *required*, so a plain `string` is not assignable and the escape hatch has to be - * taken deliberately via `asDynamicKey()` — which also makes every such site greppable. - * - * @example t(asDynamicKey(command.description)) - */ -export type DynamicTranslationKey = string & { - readonly __dynamicTranslationKey: true; -}; - -export type FormatterFactory = ( - streamI18n: Streami18n, -) => (value: V, lng: string | undefined, options: Record) => string; - -export type TimestampFormatterOptions = { - /* If true, call the `Day.js` calendar function to get the date string to display (e.g. "Yesterday at 3:58 PM"). */ - calendar?: boolean; - /* Object specifying date display formats for dates formatted with calendar extension. Active only if calendar prop enabled. */ - calendarFormats?: Record; - /* Overrides the default timestamp format if calendar is disabled. */ - format?: string; - /** - * Show a short, friendly date instead of a full date and time. - * - Today shows as "Today" - * - Yesterday shows as "Yesterday" - * - A few days ago (2 up to relativeCompactMaxDays) show as "2d ago", "3d ago", etc. - * - A few weeks ago (if relativeCompactMaxWeeks is greater than 0) show as "1w ago", "2w ago", etc. - * - Older than that (or future dates) show as a calendar date like 19/02/25 - * You can change the words used (e.g. "Hoy" instead of "Today") by adding or overriding - * these keys in your locale JSON. Example (paste into your translation JSON): - * - * "relativeTime.today": "Today", - * "relativeTime.yesterday": "Yesterday", - * "relativeTime.daysAgo": "{{ count }}d ago", - * "relativeTime.weeksAgo": "{{ count }}w ago", - * "timestamp.PollVote": "{{ timestamp | timestampFormatter(relativeCompact: true) }}" - * - * Only days, no weeks (7+ days show as date): - * "timestamp.PollVote": "{{ timestamp | timestampFormatter(relativeCompact: true; relativeCompactMaxWeeks: 0) }}" - */ - relativeCompact?: boolean; - /** - * How many days in the past still show as "Xd ago" (e.g. 6 means 2d, 3d … 6d ago). - * After that, it shows weeks (if enabled) or a calendar date. - */ - relativeCompactMaxDays?: number; - /** - * How many weeks in the past show as "Xw ago" (e.g. 3 means 1w, 2w, 3w ago). - * Set to 0 if you don’t want "Xw ago" at all: anything older than relativeCompactMaxDays - * will show as a calendar date instead. - */ - relativeCompactMaxWeeks?: number; -}; - -/** - * import dayjs from 'dayjs'; - * import duration from 'dayjs/plugin/duration.js'; - * - * dayjs.extend(duration); - * - * // Basic formatting - * dayjs.duration(1000).format('HH:mm:ss'); // "00:00:01" - * dayjs.duration(3661000).format('HH:mm:ss'); // "01:01:01" - * - * // Different format tokens - * dayjs.duration(3661000).format('D[d] H[h] m[m] s[s]'); // "0d 1h 1m 1s" - * dayjs.duration(3661000).format('D [days] H [hours] m [minutes] s [seconds]'); // "0 days 1 hours 1 minutes 1 seconds" - * - * // Zero padding - * dayjs.duration(1000).format('HH:mm:ss'); // "00:00:01" - * dayjs.duration(1000).format('H:m:s'); // "0:0:1" - * - * // Different units - * dayjs.duration(3661000).format('D'); // "0" - * dayjs.duration(3661000).format('H'); // "1" - * dayjs.duration(3661000).format('m'); // "1" - * dayjs.duration(3661000).format('s'); // "1" - * - * // Complex examples - * dayjs.duration(3661000).format('DD:HH:mm:ss'); // "00:01:01:01" - * dayjs.duration(3661000).format('D [days] HH:mm:ss'); // "0 days 01:01:01" - * dayjs.duration(3661000).format('H[h] m[m] s[s]'); // "1h 1m 1s" - * - * // Negative durations - * dayjs.duration(-3661000).format('HH:mm:ss'); // "-01:01:01" - * - * // Long durations - * dayjs.duration(86400000).format('D [days]'); // "1 days" - * dayjs.duration(2592000000).format('M [months]'); // "30 months" - * - * - * Format tokens: - * D - days - * H - hours - * m - minutes - * s - seconds - * S - milliseconds - * M - months - * Y - years - * You can also use: - * HH, mm, ss for zero-padded numbers - * [text] for literal text - */ -export type DurationFormatterOptions = { - format?: string; - withSuffix?: boolean; -}; - -export type TDateTimeParserInput = string | number | Date; -export type TDateTimeParserOutput = string | number | Date | Dayjs.Dayjs | Moment; -export type TDateTimeParser = (input?: TDateTimeParserInput) => TDateTimeParserOutput; - export type DateFormatterOptions = TimestampFormatterOptions & { - formatDate?: MessageContextValue['formatDate']; + formatDate?: (date: Date) => string; messageCreatedAt?: string | Date; t?: StreamTFunction; tDateTimeParser?: TDateTimeParser; timestampTranslationKey?: string; }; -// Here is any used, because we do not want to enforce any specific rules and -// want to leave the type declaration to the integrator -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export type CustomFormatters = Record>; - -export type PredefinedFormatters = { - durationFormatter: FormatterFactory; - timestampFormatter: FormatterFactory; -}; +export type { + AnyTranslationCatalog, + CustomFormatters, + DurationFormatterOptions, + DynamicTranslationKey, + FormatterFactory, + LanguageNameCatalog, + PredefinedFormatters, + RelativeTimeCatalog, + TDateTimeParser, + TDateTimeParserInput, + TDateTimeParserOutput, + TimestampFormatterOptions, +} from 'stream-chat/i18n'; diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts index 85c298276e..6d1ebf2f15 100644 --- a/src/i18n/utils.ts +++ b/src/i18n/utils.ts @@ -1,295 +1,32 @@ -import Dayjs from 'dayjs'; -import type { Duration as DayjsDuration } from 'dayjs/plugin/duration.js'; +import { createDefaultTranslatorFunction } from 'stream-chat/i18n'; -import type { Moment } from 'moment-timezone'; -import type { - DateFormatterOptions, - DurationFormatterOptions, - DynamicTranslationKey, - PredefinedFormatters, - StreamTFunction, - TDateTimeParserInput, - TDateTimeParserOutput, - TimestampFormatterOptions, -} from './types'; - -export const isNumberOrString = ( - output: TDateTimeParserOutput, -): output is number | string => typeof output === 'string' || typeof output === 'number'; - -export const isDayOrMoment = ( - output: TDateTimeParserOutput, -): output is Dayjs.Dayjs | Moment => !!(output as Dayjs.Dayjs | Moment)?.isSame; - -export const isDate = (output: unknown): output is Date => - output !== null && - typeof output === 'object' && - typeof (output as Date).getTime === 'function'; - -const DEFAULT_RELATIVE_COMPACT_MAX_DAYS = 6; -const DEFAULT_RELATIVE_COMPACT_MAX_WEEKS = 3; +import type { StreamTFunction } from './types'; /** - * Turns a date into a short, readable label: "Today", "Yesterday", "2d ago", "1w ago", - * or a calendar date (DD/MM/YY) for older or future dates. - * - * What appears for each period: - * - Same day → "Today" - * - Yesterday → "Yesterday" - * - 2 to maxDays days ago → "2d ago", "3d ago", … "Nd ago" - * - If maxWeeks is greater than 0: 1 to maxWeeks weeks ago → "1w ago", "2w ago", … - * - Anything older (or in the future) → calendar date - * - * To change the wording or which label is used, add these to your locale JSON (example in English): - * - * "relativeTime.today": "Today", - * "relativeTime.yesterday": "Yesterday", - * "relativeTime.daysAgo": "{{ count }}d ago", - * "relativeTime.weeksAgo": "{{ count }}w ago", - * - * To use this style for a timestamp (e.g. poll votes), add for example: + * The `t` in force before i18next has initialized, and the default value of the translation context. * - * "timestamp.PollVote": "{{ timestamp | timestampFormatter(relativeCompact: true) }}" - * - * Only "Xd ago", no "Xw ago" (anything 7+ days ago shows as a date): - * - * "timestamp.PollVote": "{{ timestamp | timestampFormatter(relativeCompact: true; relativeCompactMaxWeeks: 0) }}" - * - * To change how far "days ago" and "weeks ago" go: use relativeCompactMaxDays and - * relativeCompactMaxWeeks in the formatter (e.g. relativeCompactMaxWeeks: 2 for only 1w and 2w ago). + * Core's factory, instantiated against this SDK's catalog so it is typed the same as the real `t`. It + * honours the inline `defaultValue` at each call site, which is what stops raw dotted keys flashing on + * the first frame — or rendering permanently for a component used outside ``. */ -function getRelativeCompactDateString( - messageCreatedAt: string | Date, - t: StreamTFunction, - tDateTimeParser: (input?: TDateTimeParserInput) => TDateTimeParserOutput, - maxDays: number = DEFAULT_RELATIVE_COMPACT_MAX_DAYS, - maxWeeks: number = DEFAULT_RELATIVE_COMPACT_MAX_WEEKS, -): string | null { - const then = tDateTimeParser(messageCreatedAt); - if (!isDayOrMoment(then)) return null; - const now = tDateTimeParser(new Date().toISOString()); - if (!isDayOrMoment(now)) return null; - const diffDays = (now as Dayjs.Dayjs) - .startOf('day') - .diff((then as Dayjs.Dayjs).startOf('day'), 'day'); - if (diffDays < 0) { - return (then as Dayjs.Dayjs).format('DD/MM/YY'); - } - if (diffDays === 0) return t('relativeTime.today', 'Today'); - if (diffDays === 1) return t('relativeTime.yesterday', 'Yesterday'); - if (diffDays >= 2 && diffDays <= maxDays) - return t('relativeTime.daysAgo', { - count: diffDays, - defaultValue_one: '{{ count }}d ago', - defaultValue_other: '{{ count }}d ago', - }); - if (maxWeeks > 0) { - const maxDaysForWeeks = maxWeeks * 7; - if (diffDays >= 7 && diffDays <= maxDaysForWeeks) { - const weeks = Math.ceil(diffDays / 7); - return t('relativeTime.weeksAgo', { - count: weeks, - defaultValue_one: '{{ count }}w ago', - defaultValue_other: '{{ count }}w ago', - }); - } - } - return (then as Dayjs.Dayjs).format('DD/MM/YY'); -} - -export function getDateString({ - calendar, - calendarFormats, - format, - formatDate, - messageCreatedAt, - relativeCompact, - relativeCompactMaxDays, - relativeCompactMaxWeeks, - t, - tDateTimeParser, - timestampTranslationKey, -}: DateFormatterOptions): string | number | null { - if ( - !messageCreatedAt || - (typeof messageCreatedAt === 'string' && !Date.parse(messageCreatedAt)) - ) { - // TODO: replace with proper logging (@stream-io/logger) - // console.warn(notValidDateWarning); - return null; - } - - if (typeof formatDate === 'function') { - return formatDate(new Date(messageCreatedAt)); - } - - if (relativeCompact && t && tDateTimeParser) { - const maxDays = - typeof relativeCompactMaxDays === 'number' - ? relativeCompactMaxDays - : typeof relativeCompactMaxDays === 'string' - ? parseInt(relativeCompactMaxDays, 10) - : DEFAULT_RELATIVE_COMPACT_MAX_DAYS; - const maxWeeks = - typeof relativeCompactMaxWeeks === 'number' - ? relativeCompactMaxWeeks - : typeof relativeCompactMaxWeeks === 'string' - ? parseInt(relativeCompactMaxWeeks, 10) - : DEFAULT_RELATIVE_COMPACT_MAX_WEEKS; - const result = getRelativeCompactDateString( - messageCreatedAt, - t, - tDateTimeParser, - Number.isNaN(maxDays) ? DEFAULT_RELATIVE_COMPACT_MAX_DAYS : maxDays, - Number.isNaN(maxWeeks) ? DEFAULT_RELATIVE_COMPACT_MAX_WEEKS : maxWeeks, - ); - if (result) return result; - } - - if (t && timestampTranslationKey) { - const options: TimestampFormatterOptions = {}; - if (typeof calendar !== 'undefined' && calendar !== null) options.calendar = calendar; - if (typeof calendarFormats !== 'undefined' && calendarFormats !== null) - options.calendarFormats = calendarFormats; - if (typeof format !== 'undefined' && format !== null) options.format = format; - - const translatedTimestamp = t(asDynamicKey(timestampTranslationKey), { - ...options, - timestamp: new Date(messageCreatedAt), - }); - const translationKeyFound = timestampTranslationKey !== translatedTimestamp; - if (translationKeyFound) return translatedTimestamp; - } - - if (!tDateTimeParser) { - // TODO: replace with proper logging (@stream-io/logger) - // console.warn(noParsingFunctionWarning); - return null; - } - - const parsedTime = tDateTimeParser(messageCreatedAt); - - if (isDayOrMoment(parsedTime)) { - /** - * parsedTime.calendar is guaranteed on the type but is only - * available when a user calls dayjs.extend(calendar) - */ - return calendar && parsedTime.calendar - ? parsedTime.calendar(undefined, calendarFormats || undefined) - : parsedTime.format(format || undefined); - } - - if (isDate(parsedTime)) { - return parsedTime.toDateString(); - } - - if (isNumberOrString(parsedTime)) { - return parsedTime; - } - - return null; -} - -export const predefinedFormatters: PredefinedFormatters = { - durationFormatter: - (streamI18n) => - (value, _, { format, withSuffix }: DurationFormatterOptions) => { - // NOTE: isDayjs is not exported in "dayjs" package for ESM, hence we access - // `isDayjs` from Dayjs instance - // dayjs's `.duration(value)` accepts both number and string at runtime, - // but its TS signature post-1.11 narrowed to string only — cast through - // unknown to keep callers passing a numeric value as before. - const durationValue = value as unknown as string; - if (format && Dayjs.isDayjs(streamI18n.DateTimeParser)) { - return ( - streamI18n.DateTimeParser.duration(durationValue) as DayjsDuration - ).format(format); - } - return streamI18n.DateTimeParser.duration(durationValue).humanize(!!withSuffix); - }, - timestampFormatter: - (streamI18n) => - ( - value, - _, - { - calendarFormats, - ...options - }: Pick< - TimestampFormatterOptions, - | 'calendar' - | 'format' - | 'relativeCompact' - | 'relativeCompactMaxDays' - | 'relativeCompactMaxWeeks' - > & { - calendarFormats?: Record | string; - }, - ) => { - let parsedCalendarFormats; - try { - if (!options.calendar) { - parsedCalendarFormats = {}; - } else if (typeof calendarFormats === 'string') { - parsedCalendarFormats = JSON.parse(calendarFormats); - } else if (typeof calendarFormats === 'object') { - parsedCalendarFormats = calendarFormats; - } - } catch (e) { - console.error('[TIMESTAMP FORMATTER]', e); - } - - const result = getDateString({ - ...options, - calendarFormats: parsedCalendarFormats, - messageCreatedAt: value, - t: streamI18n.t, - tDateTimeParser: streamI18n.tDateTimeParser, - }); - if (!result || typeof result === 'number') { - return JSON.stringify(value); - } - return result; - }, -}; +export const defaultTranslatorFunction: StreamTFunction = + createDefaultTranslatorFunction() as StreamTFunction; /** - * Used before a `Streami18n` instance has initialised, and as the `TranslationContext` default - * outside ``. Keys are opaque identifiers, so returning the key would render - * "messageComposer.sendButton.label" in the UI; the inline English `defaultValue` that every - * call site passes is rendered instead, with `{{ variable }}` placeholders interpolated. - */ -export const defaultTranslatorFunction = (( - key: string, - defaultValueOrOptions?: string | Record, - maybeOptions?: Record, -) => { - const defaultValue = - typeof defaultValueOrOptions === 'string' ? defaultValueOrOptions : undefined; - const options = - (typeof defaultValueOrOptions === 'object' ? defaultValueOrOptions : maybeOptions) ?? - {}; - - let template = defaultValue; - if (template === undefined && typeof options.count === 'number') { - template = ( - options.count === 1 ? options.defaultValue_one : options.defaultValue_other - ) as string | undefined; - } - template ??= options.defaultValue as string | undefined; - if (template === undefined) return key; - - return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { - const value = options[name]; - return value === undefined || value === null ? whole : String(value); - }); -}) as unknown as StreamTFunction; - -/** - * Marks a runtime-derived string as a translation key, for the small number of keys that are not - * known statically: `notification.message` from `stream-chat`, slash-command metadata from the - * API, language codes, and integrator-supplied props. See {@link DynamicTranslationKey}. + * The date/time and key helpers now live in `stream-chat/i18n`, shared with the React Native SDK. + * + * Re-exported from here rather than rewritten at ~15 call sites, so the internal module path stays + * stable. `getDateString` and the type guards behave identically; `predefinedFormatters` gains + * `fromNowFormatter` and `relativeCompactDateFormatter`, and its relative-compact wording now goes + * through `t()` rather than being hardcoded English. */ -export const asDynamicKey = (key: string) => key as DynamicTranslationKey; - -export const defaultDateTimeParser = (input?: TDateTimeParserInput) => Dayjs(input); +export { + asDynamicKey, + defaultDateTimeParser, + getDateString, + getDateStringForA11y, + isDate, + isDayOrMoment, + isNumberOrString, + predefinedFormatters, +} from 'stream-chat/i18n'; From dad272c86fa7f5880cbe2b0aea8e709429e75ff8 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 10:04:25 +0200 Subject: [PATCH 02/10] docs(i18n): document the shared runtime and its two rendering changes The v15 guide described the key rename and the dropped dictionaries but not the third breaking change in the same release: the runtime moved into `stream-chat`. An integrator following it would hit the class rename and two changed method shapes with nothing to explain them, and every example still used the deprecated name. Added a "shared runtime" section covering the `Streami18n` -> `StreamI18n` rename, `t` becoming read-only (use `overrideTFunction`), `setLanguage()` returning void, and dropping `i18next` / `dayjs` from your own dependencies -- with the one-command check for a duplicate `dayjs`, since a second copy means your `dayjs/locale/xx` import lands on a different instance than the one formatting dates and dates silently stay English. Documented the two rendering changes under Date and time, both confined to a `timestamp.*` key that specifies no format: a null or unparseable timestamp now renders as empty rather than the literal text `null`, and unformatted output carries a numeric offset rather than `Z` because `.tz()` is applied only when a timezone is configured. Also corrected a paragraph the move falsified: it said ~71 keys ship in `runtimeDefaults` including `language.*`. It is 15 now, and `language.*` plus the new `relativeTime.*` come from `stream-chat` -- still overridable, and still compile-checked, but no longer this package's data. Every claim in the new prose was checked against the built runtime rather than written from memory, which is how the "unparseable renders empty" half turned out to be false and got fixed in core instead of softened here. --- ai-docs/i18n-v15-migration.md | 143 ++++++++++++++++++++++++++++++---- 1 file changed, 129 insertions(+), 14 deletions(-) diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index 301240b0fd..5416be2b0d 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -1,11 +1,14 @@ # i18n changes in v15 -Two breaking changes, both in v15: +Three breaking changes, all in v15: 1. **English is the only bundled language.** The `de`, `es`, `fr`, `hi`, `it`, `ja`, `ko`, `nl`, `pt`, `ru` and `tr` dictionaries are gone, along with their `dayjs` locale data. 2. **Translation keys are namespaced identifiers**, not the English text. `t('Send Message')` became `t('messageComposer.sendButton.send.ariaLabel', 'Send')`. +3. **The translation runtime moved into `stream-chat`**, shared with the React Native SDK. The class + is renamed `StreamI18n`, two of its methods changed shape, and two timestamp edge cases render + differently — see [The shared runtime](#the-shared-runtime). Together these cut ~112 KB gzip (27%) from the bundle: the 11 dictionaries were statically imported and copied into `Streami18n` at construction, so they shipped even if you never set @@ -13,14 +16,17 @@ imported and copied into `Streami18n` at construction, so they shipped even if y ## Do I need to do anything? -| If you… | Action | -| --------------------------------------------- | ---------------------------------------------- | -| use the SDK in English and never touched i18n | **Nothing.** | -| passed `translationsForLanguage` | Rename your keys — see below | -| called `registerTranslation()` | Rename your keys — see below | -| used a built-in non-English language | Supply the dictionary yourself — see below | -| relied on non-English date formats | Import the `dayjs` locale yourself — see below | -| imported `deTranslations` … `trTranslations` | Those exports are removed | +| If you… | Action | +| ------------------------------------------------ | ---------------------------------------------------- | +| use the SDK in English and never touched i18n | **Nothing.** | +| passed `translationsForLanguage` | Rename your keys — see below | +| called `registerTranslation()` | Rename your keys — see below | +| used a built-in non-English language | Supply the dictionary yourself — see below | +| relied on non-English date formats | Import the `dayjs` locale yourself — see below | +| imported `deTranslations` … `trTranslations` | Those exports are removed | +| construct `new Streami18n(...)` | Still works, now deprecated — rename to `StreamI18n` | +| assign `i18n.t` or read `setLanguage()`'s return | Both changed — see below | +| declared `i18next` or `dayjs` yourself | You can drop them; `stream-chat` supplies both | ## Renaming your keys @@ -228,6 +234,63 @@ git show v14.11.0:src/i18n/de.json > de.json Then rename its keys with the mapping table above and register it. Note the old file's keys are the _old_ natural-language keys, so it needs the same rename as your own overrides. +## The shared runtime + +`Streami18n` used to live in this package. It now lives in `stream-chat` and is shared with +`stream-chat-react-native`, so both SDKs behave identically and a fix reaches both at once. You still +import it from here, and it still carries this SDK's own key catalog and copy. + +### The class is renamed + +```ts +// v14 +import { Streami18n } from 'stream-chat-react'; +const i18n = new Streami18n({ language: 'nl' }); + +// v15 +import { StreamI18n } from 'stream-chat-react'; +const i18n = new StreamI18n({ language: 'nl' }); +``` + +`Streami18n` remains exported as a deprecated alias for one release cycle, so nothing breaks +immediately — but the rename is the one to make now. + +### `t` is read-only, and `setLanguage()` returns nothing + +`t` is published through a reactive store rather than being a mutable field, which is what lets +`` pick up a language change without remounting. Two consequences: + +```ts +// v14 — assigning `t` directly +(i18n as any).t = myTranslator; + +// v15 — publish it, and every subscriber updates +i18n.overrideTFunction(myTranslator); +``` + +```ts +// v14 — setLanguage returned a translator (sometimes; it had three return shapes) +const t = await i18n.setLanguage('de'); + +// v15 — it returns void. Read the current `t` from the instance, or let re-render. +await i18n.setLanguage('de'); +const { t } = i18n.state.getLatestValue(); +``` + +The returned translator was removed deliberately: it went stale on the next language change, so +holding onto it was always a latent bug. + +### You no longer need `i18next` or `dayjs` in your own dependencies + +`stream-chat` depends on both, so they arrive transitively. If you declared them only for this SDK, +remove them — and if you keep them, **match `stream-chat`'s ranges**. Two copies of `dayjs` means +your `import 'dayjs/locale/de'` registers the locale on a different instance than the one formatting +dates, and dates silently stay English: + +```bash +find . -maxdepth 4 -name dayjs -type d -path '*node_modules*' # expect exactly one +``` + ## Date and time Only the `en` dayjs locale is bundled, and the per-language `calendar` formats the SDK used to ship @@ -253,6 +316,46 @@ const i18n = new Streami18n({ Or pass your own preconfigured `DateTimeParser` (dayjs or moment). +### Two edge cases render differently + +Both are confined to a `timestamp.*` key that specifies **no** format. Every key the SDK ships +specifies one (`format: HH:mm`, `calendar: true`, and so on), so you only see these if you overrode a +timestamp key with an expression that formats nothing. + +**A `null` or unparseable timestamp renders as empty**, where v14 rendered the value stringified — +which for `null` was the literal text `null`: + +```ts +// a key with no format +'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(calendar: false) }}' + +// t('timestamp.MessageTimestamp', { timestamp: null }) +// v14 → "null" +// v15 → "" +``` + +The same applies when you call `predefinedFormatters.timestampFormatter` yourself: it returns `''` +rather than the stringified value. If you relied on that to spot a missing timestamp during +development, check for the empty string instead — rendering the word `null` into a message list was +never intentional. + +Note this is specifically about a value that _reaches_ the formatter. Passing no `timestamp` at all +leaves i18next with nothing to interpolate, so the raw expression comes through unchanged — that was +true in v14 too, and is a sign the option name is misspelled at the call site. + +**Unformatted output carries a numeric offset rather than `Z`:** + +```ts +// v14 → 2019-04-03T14:42:47Z +// v15 → 2019-04-03T14:42:47+00:00 +``` + +Same instant, different ISO spelling. v14 called dayjs's `.tz()` on every parse even when no +`timezone` was configured, which marks the instance as zoned and changes how `.format()` with no +template renders. v15 applies `.tz()` only when you actually set `timezone`, matching what the React +Native SDK already did. Configure a `format` on the key if you need a specific shape — relying on +dayjs's default is fragile either way. + ## Why keys changed at all The old keys _were_ the English copy, which meant: @@ -266,8 +369,20 @@ Keys are now stable, and the English copy travels inline at the call site as i18 `defaultValue`. That keeps the copy readable where it is used, and means a key you do not supply still renders English rather than a raw key path. -The exception is the ~71 keys that carry no inline copy — `timestamp.*` and `duration.*` (formatter -expressions), `language.*` (built from a runtime language code), and the postProcessor directive. -Those are bundled in `runtimeDefaults` instead, and both `registerTranslation()` and -`translationsForLanguage` merge your dictionary over them, so you inherit the working defaults -without listing them. You only need to supply one if you want a different date format. +The exception is the 15 keys that carry no inline copy — `timestamp.*` and `duration.*` (formatter +expressions) and the postProcessor directive. Those are bundled in `runtimeDefaults` instead, and both +`registerTranslation()` and `translationsForLanguage` merge your dictionary over them, so you inherit +the working defaults without listing them. You only need to supply one if you want a different date +format. + +Two more sets are still overridable but now come from `stream-chat`, because it owns the code that +renders them: + +- **`language.*`** — the 57 language names used to say "Translated from German" on an auto-translated + message. They are derived from the same language union the API uses, so the set can no longer drift + out of sync with it. +- **`relativeTime.*`** — `Today`, `Yesterday`, `{{ count }}d ago`, `{{ count }}w ago`, used by + `timestampFormatter(relativeCompact: true)`. + +Both are part of your catalog's types, so you override them exactly as before — `t('language.de')` is +a checked key, and a typo in either is still a compile error. From 78cea1d8afdbc25bcc407cfc949a8a1dda726fc6 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 11:34:46 +0200 Subject: [PATCH 03/10] refactor(i18n)!: keep the class named Streami18n and drop the deprecated aliases Core named the shared class `StreamI18n`, and this package re-exported `Streami18n` as a `@deprecated` alias for one cycle. Both are reverted: core is `Streami18n`, matching the name this SDK has shipped and documented for years, so integrators rename nothing and no alias exists. The capital `I` was only ever cosmetic, and a deprecated alias in a breaking release is cruft with a countdown attached. `getTranslators()` went the same way. It was a `@deprecated` alias for `init()`, so it is removed outright and the call sites here use `init()`, which returns the same state. `init()` is the better name -- it initializes rather than gets -- and is idempotent, which closes a re-entry window the old implementation left open. The migration guide loses its "the class is renamed" section and gains one for `getTranslators()`, which it had not documented. BREAKING CHANGE: `i18n.getTranslators()` is removed. Use `i18n.init()`, which returns the same `{ t, tDateTimeParser, language, initialized }`. --- ai-docs/i18n-v15-migration.md | 40 ++++----- src/components/Chat/hooks/useChat.ts | 10 +-- src/i18n/Streami18n.ts | 26 ++---- src/i18n/__tests__/Streami18n.test.ts | 98 +++++++++++------------ src/i18n/__tests__/catalogRenders.test.ts | 6 +- src/i18n/utils.ts | 4 +- 6 files changed, 87 insertions(+), 97 deletions(-) diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index 5416be2b0d..f3d7cdaf9a 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -7,8 +7,8 @@ Three breaking changes, all in v15: 2. **Translation keys are namespaced identifiers**, not the English text. `t('Send Message')` became `t('messageComposer.sendButton.send.ariaLabel', 'Send')`. 3. **The translation runtime moved into `stream-chat`**, shared with the React Native SDK. The class - is renamed `StreamI18n`, two of its methods changed shape, and two timestamp edge cases render - differently — see [The shared runtime](#the-shared-runtime). + keeps its name, two of its methods changed shape, and two timestamp edge cases render differently + — see [The shared runtime](#the-shared-runtime). Together these cut ~112 KB gzip (27%) from the bundle: the 11 dictionaries were statically imported and copied into `Streami18n` at construction, so they shipped even if you never set @@ -16,17 +16,17 @@ imported and copied into `Streami18n` at construction, so they shipped even if y ## Do I need to do anything? -| If you… | Action | -| ------------------------------------------------ | ---------------------------------------------------- | -| use the SDK in English and never touched i18n | **Nothing.** | -| passed `translationsForLanguage` | Rename your keys — see below | -| called `registerTranslation()` | Rename your keys — see below | -| used a built-in non-English language | Supply the dictionary yourself — see below | -| relied on non-English date formats | Import the `dayjs` locale yourself — see below | -| imported `deTranslations` … `trTranslations` | Those exports are removed | -| construct `new Streami18n(...)` | Still works, now deprecated — rename to `StreamI18n` | -| assign `i18n.t` or read `setLanguage()`'s return | Both changed — see below | -| declared `i18next` or `dayjs` yourself | You can drop them; `stream-chat` supplies both | +| If you… | Action | +| ------------------------------------------------ | ---------------------------------------------- | +| use the SDK in English and never touched i18n | **Nothing.** | +| passed `translationsForLanguage` | Rename your keys — see below | +| called `registerTranslation()` | Rename your keys — see below | +| used a built-in non-English language | Supply the dictionary yourself — see below | +| relied on non-English date formats | Import the `dayjs` locale yourself — see below | +| imported `deTranslations` … `trTranslations` | Those exports are removed | +| construct `new Streami18n(...)` | **Nothing** — same name, same options object | +| assign `i18n.t` or read `setLanguage()`'s return | Both changed — see below | +| declared `i18next` or `dayjs` yourself | You can drop them; `stream-chat` supplies both | ## Renaming your keys @@ -240,20 +240,20 @@ _old_ natural-language keys, so it needs the same rename as your own overrides. `stream-chat-react-native`, so both SDKs behave identically and a fix reaches both at once. You still import it from here, and it still carries this SDK's own key catalog and copy. -### The class is renamed +### `getTranslators()` is now `init()` + +Same return value; the old name was a getter that initialized, which is what made it worth renaming. ```ts // v14 -import { Streami18n } from 'stream-chat-react'; -const i18n = new Streami18n({ language: 'nl' }); +const { t, tDateTimeParser } = await i18n.getTranslators(); // v15 -import { StreamI18n } from 'stream-chat-react'; -const i18n = new StreamI18n({ language: 'nl' }); +const { t, tDateTimeParser } = await i18n.init(); ``` -`Streami18n` remains exported as a deprecated alias for one release cycle, so nothing breaks -immediately — but the rename is the one to make now. +`init()` is idempotent and safe to call concurrently — the promise is memoized, which closes a +re-entry window the old implementation left open. ### `t` is read-only, and `setLanguage()` returns nothing diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index 45915e285a..08b9edba77 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -4,7 +4,7 @@ import type { TranslationContextValue } from '../../../context/TranslationContex import { defaultDateTimeParser, defaultTranslatorFunction, - StreamI18n, + Streami18n, } from '../../../i18n'; import type { @@ -17,7 +17,7 @@ import type { export type UseChatParams = { client: StreamChat; defaultLanguage?: string; - i18nInstance?: StreamI18n; + i18nInstance?: Streami18n; }; export const useChat = ({ @@ -96,12 +96,12 @@ export const useChat = ({ // Truthiness, deliberately -- not `instanceof`. An instance coming from a second copy of the // package would fail an identity check and be silently replaced by a fresh English default, // discarding every dictionary and formatter the integrator registered. - const streamI18n = i18nInstance || new StreamI18n({ language: userLanguage }); + const streami18n = i18nInstance || new Streami18n({ language: userLanguage }); // One subscription replaces the old `registerSetLanguageCallback`, which a second caller would // clobber for everyone. `subscribe` fires synchronously with the current value, so there is no // ordering to get right: whether this runs before or after `init()`, the live `t` arrives. - const unsubscribe = streamI18n.state.subscribeWithSelector( + const unsubscribe = streami18n.state.subscribeWithSelector( ({ t, tDateTimeParser }) => ({ t, tDateTimeParser }), ({ t, tDateTimeParser }) => setTranslators({ @@ -111,7 +111,7 @@ export const useChat = ({ }), ); - streamI18n.init(); + streami18n.init(); return unsubscribe; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/i18n/Streami18n.ts b/src/i18n/Streami18n.ts index 09bfdb5f2d..e6c4b3341a 100644 --- a/src/i18n/Streami18n.ts +++ b/src/i18n/Streami18n.ts @@ -1,5 +1,5 @@ -import { StreamI18n as CoreStreamI18n, languageNameDefaults } from 'stream-chat/i18n'; -import type { StreamI18nOptions as CoreStreamI18nOptions } from 'stream-chat/i18n'; +import { Streami18n as CoreStreami18n, languageNameDefaults } from 'stream-chat/i18n'; +import type { Streami18nOptions as CoreStreami18nOptions } from 'stream-chat/i18n'; import { NotificationTranslationTopic } from './TranslationBuilder'; import { runtimeDefaults } from './runtimeDefaults'; @@ -9,12 +9,12 @@ import type { TranslationCatalog } from './types'; type BundledKey = `translationBuilderTopic.${string}` | `language.${string}`; /** - * Options for {@link StreamI18n}. + * Options for {@link Streami18n}. * * `runtimeDefaults` and `translationBuilderTopics` are both accepted and both *merged* over the SDK's * own, so supplying either adds to rather than replaces what the SDK ships. */ -export type StreamI18nOptions = CoreStreamI18nOptions; +export type Streami18nOptions = CoreStreami18nOptions; /** * Wrapper around [i18next](https://www.i18next.com/) for this SDK's translations. Pass an instance to @@ -28,7 +28,7 @@ export type StreamI18nOptions = CoreStreamI18nOptions; * ## Overriding some of the English copy * * ```ts - * const i18n = new StreamI18n({ + * const i18n = new Streami18n({ * translationsForLanguage: { * 'emptyState.indicator.noConversationsYet.label': 'Nothing here yet', * }, @@ -40,7 +40,7 @@ export type StreamI18nOptions = CoreStreamI18nOptions; * ```ts * import 'dayjs/locale/nl'; * - * const i18n = new StreamI18n({ language: 'nl' }); + * const i18n = new Streami18n({ language: 'nl' }); * i18n.registerTranslation('nl', { * 'typing.singleUser': '{{ typing }} is aan het typen', * }); @@ -53,8 +53,8 @@ export type StreamI18nOptions = CoreStreamI18nOptions; * Reactivity goes through `i18n.state`, a `StateStore`. `setLanguage()` returns nothing — the new `t` is * published to that store, which `` subscribes to. */ -export class StreamI18n extends CoreStreamI18n { - constructor(options: StreamI18nOptions = {}) { +export class Streami18n extends CoreStreami18n { + constructor(options: Streami18nOptions = {}) { super({ ...options, // Core owns the `language.*` names, since it owns the `TranslationLanguage` union they describe. @@ -74,13 +74,3 @@ export class StreamI18n extends CoreStreamI18n { }); } } - -/** - * @deprecated Renamed to {@link StreamI18n}, matching the class the SDKs now share. Kept for one - * release cycle. Exported via `export { X as Y }` rather than `const Y = X` so it remains usable as - * both a value and a type — `i18nInstance?: Streami18n` is the common form. - */ -export { StreamI18n as Streami18n }; - -/** @deprecated Renamed to {@link StreamI18nOptions}. */ -export type Streami18nOptions = StreamI18nOptions; diff --git a/src/i18n/__tests__/Streami18n.test.ts b/src/i18n/__tests__/Streami18n.test.ts index 3390614618..9e84e6b08b 100644 --- a/src/i18n/__tests__/Streami18n.test.ts +++ b/src/i18n/__tests__/Streami18n.test.ts @@ -87,14 +87,14 @@ describe('Streami18n instance - default', () => { const streami18n = new Streami18n(streami18nOptions); it('should provide default english translator', async () => { - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); const text = nanoid(); expect(_t(text)).toBe(text); }); it('should provide moment with default en locale', async () => { - const { tDateTimeParser } = await streami18n.getTranslators(); + const { tDateTimeParser } = await streami18n.init(); expect(tDateTimeParser() instanceof Dayjs).toBe(true); expect((tDateTimeParser() as Dayjs.Dayjs).locale()).toBe('en'); }); @@ -115,14 +115,14 @@ const dutchTranslations: LooseTranslationDictionary = { describe('Streami18n - resolution without a bundled prose resource', () => { it('renders a prose key from its inline default, not the key', async () => { const streami18n = new Streami18n({ logger: () => null }); - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); expect(_t('message.status.sent.text', 'Sent')).toBe('Sent'); }); it('interpolates into an inline default', async () => { const streami18n = new Streami18n({ logger: () => null }); - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); expect( _t( @@ -137,7 +137,7 @@ describe('Streami18n - resolution without a bundled prose resource', () => { it('selects the plural form from the inline defaults', async () => { const streami18n = new Streami18n({ logger: () => null }); - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); const options = { defaultValue_one: '{{ count }} member', defaultValue_other: '{{ count }} members', @@ -153,7 +153,7 @@ describe('Streami18n - resolution without a bundled prose resource', () => { it('resolves the bundled keys that have no inline default', async () => { const streami18n = new Streami18n({ logger: () => null }); - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); // language names are keyed off a runtime language code expect(_t('language.de')).toBe('German'); @@ -166,7 +166,7 @@ describe('Streami18n - resolution without a bundled prose resource', () => { it('does not report a prose key to parseMissingKeyHandler, and keeps its copy', async () => { const parseMissingKeyHandler = vi.fn(() => 'CLOBBERED'); const streami18n = new Streami18n({ logger: () => null, parseMissingKeyHandler }); - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); // Unguarded, i18next would replace the result with the handler's return value. expect(_t('message.status.sent.text', 'Sent')).toBe('Sent'); @@ -176,7 +176,7 @@ describe('Streami18n - resolution without a bundled prose resource', () => { it('still reports a genuinely unknown key to parseMissingKeyHandler', async () => { const parseMissingKeyHandler = vi.fn(() => 'HANDLED'); const streami18n = new Streami18n({ logger: () => null, parseMissingKeyHandler }); - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); const unknown = `nonexistent.${nanoid()}`; // @ts-expect-error deliberately outside the key union @@ -191,20 +191,20 @@ describe('Streami18n instance - with an integrator-registered language', () => { streami18n.registerTranslation('nl', dutchTranslations); it('should translate the registered keys', async () => { - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); for (const [key, value] of Object.entries(dutchTranslations)) { expect(_t(key)).toBe(value); } }); it('should fall back to the key for unregistered keys', async () => { - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); const missing = nanoid(); expect(_t(missing)).toBe(missing); }); it('should provide dayjs with `nl` locale', async () => { - const { tDateTimeParser } = await streami18n.getTranslators(); + const { tDateTimeParser } = await streami18n.init(); expect(tDateTimeParser() instanceof Dayjs).toBe(true); expect((tDateTimeParser() as Dayjs.Dayjs).locale()).toBe('nl'); }); @@ -219,14 +219,14 @@ describe('Streami18n instance - with an integrator-registered language', () => { streami18n.registerTranslation('nl', dutchTranslations); it('should translate the registered keys', async () => { - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); for (const [key, value] of Object.entries(dutchTranslations)) { expect(_t(key)).toBe(value); } }); it('should provide dayjs with default `en` locale', async () => { - const { tDateTimeParser } = await streami18n.getTranslators(); + const { tDateTimeParser } = await streami18n.init(); expect(tDateTimeParser() instanceof Dayjs).toBe(true); expect((tDateTimeParser() as Dayjs.Dayjs).locale()).toBe('en'); }); @@ -240,7 +240,7 @@ describe('Streami18n instance - with an integrator-registered language', () => { const streami18n = new Streami18n(streami18nOptions); it('should provide moment with given custom locale config', async () => { - const { tDateTimeParser } = await streami18n.getTranslators(); + const { tDateTimeParser } = await streami18n.init(); expect(tDateTimeParser() instanceof Dayjs).toBe(true); const localeConfig = (tDateTimeParser() as Dayjs.Dayjs).localeData(); for (const key in streami18nOptions.dayjsLocaleConfigForLanguage) { @@ -274,7 +274,7 @@ describe('Streami18n instance - with custom translations', () => { const streami18n = new Streami18n(streami18nOptions); it('should provide given (chinese in this case) translator', async () => { - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); expect(_t(textKey1)).toBe(textValue1); @@ -282,7 +282,7 @@ describe('Streami18n instance - with custom translations', () => { }); it('should provide moment with default `en` locale', async () => { - const { tDateTimeParser } = await streami18n.getTranslators(); + const { tDateTimeParser } = await streami18n.init(); expect(tDateTimeParser() instanceof Dayjs).toBe(true); expect((tDateTimeParser() as Dayjs.Dayjs).locale()).toBe('en'); }); @@ -316,7 +316,7 @@ describe('registerTranslation - register new language `mr` (Marathi) ', () => { }); it('should register moment locale config for Marathi translations', async () => { - const { tDateTimeParser } = await streami18n.getTranslators(); + const { tDateTimeParser } = await streami18n.init(); expect(tDateTimeParser() instanceof Dayjs).toBe(true); const localeConfig = (tDateTimeParser() as Dayjs.Dayjs).localeData(); @@ -343,12 +343,12 @@ describe('setLanguage - switch to a registered language', () => { streami18n.registerTranslation('fr', frenchTranslations); // English before the switch: an unknown key resolves to itself. - const { t: beforeT } = await streami18n.getTranslators(); + const { t: beforeT } = await streami18n.init(); expect(beforeT('messageList.empty')).toBe('messageList.empty'); await streami18n.setLanguage('fr'); - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); for (const [key, value] of Object.entries(frenchTranslations)) { expect(_t(key)).toBe(value); } @@ -358,7 +358,7 @@ describe('setLanguage - switch to a registered language', () => { // An unknown language gets an empty dictionary rather than being rejected, so every // key resolves to itself — which is the inline English default at each call site. const streami18n = new Streami18n({ language: 'zz', logger: () => null }); - const { t: _t } = await streami18n.getTranslators(); + const { t: _t } = await streami18n.init(); expect(streami18n.currentLanguage).toBe('zz'); expect(_t('messageComposer.sendButton.label')).toBe( @@ -373,23 +373,23 @@ describe('Streami18n timezone', () => { ['moment', moment], ])('%s', (moduleName, module) => { it('is by default the local timezone', () => { - const streamI18n = new Streami18n({ DateTimeParser: module }); + const streami18n = new Streami18n({ DateTimeParser: module }); const date = new Date(); - expect((streamI18n.tDateTimeParser(date) as Dayjs.Dayjs).format('H')).toBe( + expect((streami18n.tDateTimeParser(date) as Dayjs.Dayjs).format('H')).toBe( date.getHours().toString(), ); }); it('can be set to different timezone on init', () => { - const streamI18n = new Streami18n({ + const streami18n = new Streami18n({ DateTimeParser: module, timezone: 'Europe/Prague', }); const date = new Date(); - expect((streamI18n.tDateTimeParser(date) as Dayjs.Dayjs).format('H')).not.toBe( + expect((streami18n.tDateTimeParser(date) as Dayjs.Dayjs).format('H')).not.toBe( date.getHours().toString(), ); - expect((streamI18n.tDateTimeParser(date) as Dayjs.Dayjs).format('H')).not.toBe( + expect((streami18n.tDateTimeParser(date) as Dayjs.Dayjs).format('H')).not.toBe( (date.getUTCHours() - 2).toString(), ); }); @@ -399,12 +399,12 @@ describe('Streami18n timezone', () => { const tz = moduleRecord.tz; delete moduleRecord.tz; - const streamI18n = new Streami18n({ + const streami18n = new Streami18n({ DateTimeParser: module, timezone: 'Europe/Prague', }); const date = new Date(); - expect((streamI18n.tDateTimeParser(date) as Dayjs.Dayjs).format('H')).toBe( + expect((streami18n.tDateTimeParser(date) as Dayjs.Dayjs).format('H')).toBe( date.getHours().toString(), ); @@ -508,11 +508,11 @@ describe('Streami18n - a custom dictionary keeps the keys that have no inline co const i18n = new Streami18n({ language: language as 'en' }); if (!afterInit) i18n.registerTranslation(language, { 'common.cancel.label': 'Abbrechen' }); - const first = await i18n.getTranslators(); + const first = await i18n.init(); if (afterInit) { i18n.registerTranslation(language, { 'common.cancel.label': 'Abbrechen' }); } - const { t } = afterInit ? await i18n.getTranslators() : first; + const { t } = afterInit ? await i18n.init() : first; expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); expect(t('timestamp.MessageTimestamp', { timestamp: TIMESTAMP })).toBe('10:30'); @@ -523,7 +523,7 @@ describe('Streami18n - a custom dictionary keeps the keys that have no inline co language: 'de' as 'en', translationsForLanguage: { 'common.cancel.label': 'Abbrechen' }, }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); expect(t('timestamp.MessageTimestamp', { timestamp: TIMESTAMP })).toBe('10:30'); @@ -532,7 +532,7 @@ describe('Streami18n - a custom dictionary keeps the keys that have no inline co it('overriding English does not drop the formatter keys', async () => { const i18n = new Streami18n(); i18n.registerTranslation('en', { 'common.cancel.label': 'Dismiss' }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t('common.cancel.label', 'Cancel')).toBe('Dismiss'); expect(t('timestamp.MessageTimestamp', { timestamp: TIMESTAMP })).toBe('10:30'); @@ -543,7 +543,7 @@ describe('Streami18n - a custom dictionary keeps the keys that have no inline co i18n.registerTranslation('en', { 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: HH[h]) }}', }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t('timestamp.MessageTimestamp', { timestamp: TIMESTAMP })).toBe('10h'); }); @@ -552,7 +552,7 @@ describe('Streami18n - a custom dictionary keeps the keys that have no inline co const i18n = new Streami18n(); i18n.registerTranslation('en', { 'common.cancel.label': 'Dismiss' }); i18n.registerTranslation('en', { 'common.send.label': 'Fire away' }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t('common.cancel.label', 'Cancel')).toBe('Dismiss'); expect(t('common.send.label', 'Send')).toBe('Fire away'); @@ -563,10 +563,10 @@ describe('Streami18n - a custom dictionary keeps the keys that have no inline co first.registerTranslation('en', { 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: HH[h]) }}', }); - await first.getTranslators(); + await first.init(); const second = new Streami18n(); - const { t } = await second.getTranslators(); + const { t } = await second.init(); expect(t('timestamp.MessageTimestamp', { timestamp: TIMESTAMP })).toBe('10:30'); }); }); @@ -592,7 +592,7 @@ describe('Streami18n - dictionary key types', () => { const i18n = new Streami18n({ language: 'ru' as 'en', logger: () => null }); i18n.registerTranslation('ru' as 'en', ru); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); const options = { defaultValue_one: '{{ count }} member', defaultValue_other: '{{ count }} members', @@ -668,7 +668,7 @@ describe('Streami18n - dictionary key types', () => { const i18n = new Streami18n({ language: 'de' as 'en', logger: () => null }); i18n.registerTranslation('de' as 'en', de); - const { t: _t } = await i18n.getTranslators(); + const { t: _t } = await i18n.init(); const options = { defaultValue_one: '{{ count }} member', @@ -702,7 +702,7 @@ describe('Streami18n - a language nobody registered still formats dates', () => it('language is selected but no dictionary is registered', async () => { const i18n = new Streami18n({ language: 'de' as 'en', logger: () => null }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(stamp(i18n)).toBe('10:30'); expect(stamp(i18n, 'timestamp.DateSeparator')).toBe('Mon, 1 Jan'); @@ -721,7 +721,7 @@ describe('Streami18n - a language nobody registered still formats dates', () => logger: () => null, dayjsLocaleConfigForLanguage: customDayjsLocaleConfig, }); - await i18n.getTranslators(); + await i18n.init(); expect(stamp(i18n)).toBe('10:30'); }); @@ -731,7 +731,7 @@ describe('Streami18n - a language nobody registered still formats dates', () => // constructor must not reset `currentLanguage` when the dictionary has not arrived yet. const i18n = new Streami18n({ language: 'de' as 'en', logger: () => null }); i18n.registerTranslation('de' as 'en', { 'common.cancel.label': 'Abbrechen' }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(i18n.currentLanguage).toBe('de'); expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); @@ -757,10 +757,10 @@ describe('Streami18n - setLanguage to a language nobody registered', () => { ])('%s', async (_name, afterInit) => { const logger = vi.fn(); const i18n = new Streami18n({ logger }); - if (afterInit) await i18n.getTranslators(); + if (afterInit) await i18n.init(); await i18n.setLanguage('de' as 'en'); - if (!afterInit) await i18n.getTranslators(); + if (!afterInit) await i18n.init(); expect(i18n.currentLanguage).toBe('de'); expect(stamp(i18n)).toBe('10:30'); @@ -773,7 +773,7 @@ describe('Streami18n - setLanguage to a language nobody registered', () => { it('does not clobber a dictionary registered for that language', async () => { const i18n = new Streami18n({ logger: () => null }); i18n.registerTranslation('de' as 'en', { 'common.cancel.label': 'Abbrechen' }); - await i18n.getTranslators(); + await i18n.init(); await i18n.setLanguage('de' as 'en'); expect(i18n.t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); @@ -783,7 +783,7 @@ describe('Streami18n - setLanguage to a language nobody registered', () => { it('switching back and forth keeps both dictionaries', async () => { const i18n = new Streami18n({ logger: () => null }); i18n.registerTranslation('de' as 'en', { 'common.cancel.label': 'Abbrechen' }); - await i18n.getTranslators(); + await i18n.init(); await i18n.setLanguage('de' as 'en'); expect(i18n.t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); @@ -809,7 +809,7 @@ describe('Streami18n - the unregistered-language warning', () => { it('is emitted once, on init, when no dictionary ever arrives', async () => { const logger = vi.fn(); const i18n = new Streami18n({ language: 'de' as 'en', logger }); - await i18n.getTranslators(); + await i18n.init(); const warnings = logger.mock.calls.filter(([message]) => String(message).includes('no translation dictionary is registered'), @@ -822,7 +822,7 @@ describe('Streami18n - the unregistered-language warning', () => { const logger = vi.fn(); const i18n = new Streami18n({ language: 'de' as 'en', logger }); i18n.registerTranslation('de' as 'en', { 'common.cancel.label': 'Abbrechen' }); - await i18n.getTranslators(); + await i18n.init(); expect(logger).not.toHaveBeenCalledWith( expect.stringContaining('no translation dictionary is registered'), @@ -836,7 +836,7 @@ describe('Streami18n - the unregistered-language warning', () => { logger, translationsForLanguage: { 'common.cancel.label': 'Abbrechen' }, }); - await i18n.getTranslators(); + await i18n.init(); expect(logger).not.toHaveBeenCalledWith( expect.stringContaining('no translation dictionary is registered'), @@ -887,7 +887,7 @@ describe('Streami18n - the calendar keys that carry English words', () => { }, }); i18n.registerTranslation('de' as 'en', { 'common.cancel.label': 'Abbrechen' }); - const { t, tDateTimeParser } = await i18n.getTranslators(); + const { t, tDateTimeParser } = await i18n.init(); const stamp = (key: string, when: string) => getDateString({ messageCreatedAt: when, @@ -911,7 +911,7 @@ describe('Streami18n - the calendar keys that carry English words', () => { 'timestamp.ChannelPreviewTimestamp': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "LT", "lastDay": "[Gestern]", "lastWeek": "dddd", "sameElse": "L" }) }}', }); - const { t, tDateTimeParser } = await i18n.getTranslators(); + const { t, tDateTimeParser } = await i18n.init(); const stamp = (key: string, when: string) => getDateString({ messageCreatedAt: when, diff --git a/src/i18n/__tests__/catalogRenders.test.ts b/src/i18n/__tests__/catalogRenders.test.ts index 24b09d62c3..c25afb6cf9 100644 --- a/src/i18n/__tests__/catalogRenders.test.ts +++ b/src/i18n/__tests__/catalogRenders.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { StreamI18n } from '../Streami18n'; +import { Streami18n } from '../Streami18n'; import catalog from './catalog.fixture.json'; /** @@ -71,7 +71,7 @@ describe('translation catalog renders', () => { }); it('renders every singular key without leaking the key or a placeholder', async () => { - const { t } = await new StreamI18n({ logger: () => {} }).init(); + const { t } = await new Streami18n({ logger: () => {} }).init(); const render = t as unknown as ( key: string, d?: string | Record, @@ -100,7 +100,7 @@ describe('translation catalog renders', () => { }); it('renders every plural key at each count without leaking the key or a placeholder', async () => { - const { t } = await new StreamI18n({ logger: () => {} }).init(); + const { t } = await new Streami18n({ logger: () => {} }).init(); const render = t as unknown as (key: string, o: Record) => string; const offenders: string[] = []; diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts index 6d1ebf2f15..df8aaf5075 100644 --- a/src/i18n/utils.ts +++ b/src/i18n/utils.ts @@ -17,8 +17,8 @@ export const defaultTranslatorFunction: StreamTFunction = * * Re-exported from here rather than rewritten at ~15 call sites, so the internal module path stays * stable. `getDateString` and the type guards behave identically; `predefinedFormatters` gains - * `fromNowFormatter` and `relativeCompactDateFormatter`, and its relative-compact wording now goes - * through `t()` rather than being hardcoded English. + * `fromNowFormatter`, and `timestampFormatter`'s relative-compact wording now goes through `t()` rather + * than being hardcoded English. */ export { asDynamicKey, From 3a0f254181d25592ced38e3c01a34c8be80e3f74 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 11:49:26 +0200 Subject: [PATCH 04/10] ci(size): run the size workflow on release-v15, and fix a stale codegen reference `size.yml` only ran on `master`, so no PR stacked onto `release-v15` measured the bundle -- leaving the i18n consolidation's central size claim, that moving the runtime into `stream-chat/i18n` shrinks the root bundle, unverified for the whole release. Also corrects a comment naming `i18next-cli` and the `aria/` key prefix, both removed in v15. --- .github/workflows/size.yml | 4 ++++ src/components/ChannelListItem/utils.tsx | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 92ec101605..80482282b2 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -4,6 +4,10 @@ on: pull_request: branches: - master + # The v15 release branch. Without it this workflow does not run on any PR stacked onto it, so the + # i18n consolidation's central size claim -- that moving the runtime into `stream-chat/i18n` + # shrinks the root bundle -- goes unmeasured for the whole release. + - release-v15 paths-ignore: - '**.test.*' - '**.md' diff --git a/src/components/ChannelListItem/utils.tsx b/src/components/ChannelListItem/utils.tsx index bf812686b1..8c733b3e4a 100644 --- a/src/components/ChannelListItem/utils.tsx +++ b/src/components/ChannelListItem/utils.tsx @@ -222,8 +222,9 @@ const getLatestMessagePreviewParts = ( /** * Maps a known attachment `type` to a localized, human-readable word (e.g. "image" → "Image"). The - * cases are literal `t('aria/…')` calls so `i18next-cli` extracts them. Unknown/custom types return - * `undefined`, so the announcement falls back to a generic "Attachment". + * cases are literal `t()` calls so the catalog generator sees them -- `i18next-cli` and the `aria/` + * prefix are both gone. Unknown/custom types return `undefined`, so the announcement falls back to a + * generic "Attachment". */ const getAttachmentTypeLabel = ( type: string | undefined, From 2131bf6316c1d29d30470b9a7f808e684405440f Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 14:20:12 +0200 Subject: [PATCH 05/10] refactor(i18n): drop the i18next devDependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only thing in this package that referenced i18next was a mock type in two TranslationBuilder tests — `fromPartial({ use: vi.fn() })`. Nothing in `src` imports it at runtime or as a type. `stream-chat/i18n` now re-exports the instance type as `I18nInstance`, which is where it belongs: core's public API accepts an i18next instance, so a consumer implementing or mocking a topic should not have to reach past `stream-chat` into its dependency and declare it themselves. That was the same shape as the `moment-timezone` type leak. `dayjs` and `moment-timezone` stay: three component tests build dates with dayjs, and the bring-your-own-Moment parser test needs moment, which core no longer depends on at all. Both are imported directly here, so both should be declared — not doing so is the bug that had `yaml` resolving through lint-staged's tree in the core repo. --- package.json | 1 - .../__tests__/NotificationTranslationBuilder.test.ts | 12 ++++++------ src/i18n/__tests__/TranslationBuilder.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 7081bdb1be..ea9d3b2ca3 100644 --- a/package.json +++ b/package.json @@ -192,7 +192,6 @@ "eslint-plugin-sort-destructure-keys": "^3.0.0", "globals": "^17.6.0", "husky": "^9.1.7", - "i18next": "^26.3.6", "jsdom": "^29.1.1", "lint-staged": "^17.0.5", "moment-timezone": "^0.5.48", diff --git a/src/i18n/__tests__/NotificationTranslationBuilder.test.ts b/src/i18n/__tests__/NotificationTranslationBuilder.test.ts index 5bb2942559..1c6a4d7d91 100644 --- a/src/i18n/__tests__/NotificationTranslationBuilder.test.ts +++ b/src/i18n/__tests__/NotificationTranslationBuilder.test.ts @@ -1,10 +1,10 @@ import { NotificationTranslationTopic } from '../TranslationBuilder'; import { defaultNotificationTranslators } from '../TranslationBuilder/notifications/NotificationTranslationTopic'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { i18n } from 'i18next'; +import type { I18nInstance } from 'stream-chat/i18n'; import type { Notification } from 'stream-chat'; -const mockI18Next = fromPartial({ use: vi.fn() }); +const mockI18Next = fromPartial({ use: vi.fn() }); describe('NotificationTranslationTopic', () => { it('gets initiated with defaults', () => { const builder = new NotificationTranslationTopic({ i18next: mockI18Next }); @@ -56,7 +56,7 @@ describe('NotificationTranslationTopic', () => { }); it('falls back to translating notification.message when type has no translator', () => { - const i18next = fromPartial({ + const i18next = fromPartial({ ...mockI18Next, t: vi.fn((key) => key === 'notification.attachmentFileMissing' ? 'translated/file-required' : key, @@ -81,7 +81,7 @@ describe('NotificationTranslationTopic', () => { }); it('does not interpolate metadata into an unrecognised message', () => { - const i18next = fromPartial({ + const i18next = fromPartial({ ...mockI18Next, t: vi.fn() as unknown as i18n['t'], }); @@ -142,7 +142,7 @@ describe('NotificationTranslationTopic', () => { 'Reached the vote limit. Remove an existing vote first.', ], ])('translates known notification type %s', (type, key, copy) => { - const i18next = fromPartial({ + const i18next = fromPartial({ ...mockI18Next, t: vi.fn( (translationKey) => `translated:${translationKey}`, @@ -162,7 +162,7 @@ describe('NotificationTranslationTopic', () => { }); it('normalizes reason metadata in poll creation failure translation', () => { - const i18next = fromPartial({ + const i18next = fromPartial({ ...mockI18Next, t: vi.fn((key, _defaultValue, options) => key === 'notification.pollCreateFailedWithReason' diff --git a/src/i18n/__tests__/TranslationBuilder.test.ts b/src/i18n/__tests__/TranslationBuilder.test.ts index fffad3739c..fb0ab5ecb8 100644 --- a/src/i18n/__tests__/TranslationBuilder.test.ts +++ b/src/i18n/__tests__/TranslationBuilder.test.ts @@ -1,9 +1,9 @@ import { NotificationTranslationTopic, TranslationBuilder } from '../TranslationBuilder'; import type { TranslationTopicConstructor } from '../TranslationBuilder'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { i18n } from 'i18next'; +import type { I18nInstance } from 'stream-chat/i18n'; -const mockI18Next = fromPartial({ use: vi.fn() }); +const mockI18Next = fromPartial({ use: vi.fn() }); describe('TranslationBuilder and TranslationTopic', () => { it('gets initiated', () => { const manager = new TranslationBuilder(mockI18Next); From 5c7d8007e35eb2402d3bf857efb7e032e42f10b5 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 14:53:47 +0200 Subject: [PATCH 06/10] test(i18n)!: stop testing the shared runtime from here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Streami18n`, `getDateString` and `predefinedFormatters` live in `stream-chat/i18n` now — this package only re-exports them — so asserting their behaviour here duplicated core's suite in a second repo. Every assertion removed is covered on that side, and the six it did not cover were added there first (GetStream/stream-chat-js#1830). −1,154 lines. `utils.test.ts` goes entirely: all 570 lines were `getDateString` and `predefinedFormatters`. `Streami18n.test.ts` keeps the five describes that are genuinely about *this* package and drops the ten that were not: **Kept** — this SDK's catalog types and its `as const satisfies` completeness diff; the calendar keys that carry English words, which is an assertion about this catalog and its migration guide; the subclass merge behaviour (a caller's topic overriding the bundled `notification` one, and `runtimeDefaults` not being mutated as a shared module object); and the vitest timezone config. **Dropped** — default translator, prose resolution and `parseMissingKeyHandler`, registered and custom dictionaries, `registerTranslation`, `setLanguage`, timezone, formatters, the unregistered-language warning, and dates for an unregistered language. Core's G1/G2/G3 guarantees, `setLanguage`, `formatters` and `TranslationBuilder` suites assert all of it. Removing them made `moment-timezone` dead here, so it is dropped from devDependencies — core depends on no date library by name, its structural `DateTimeLike` covers both. `dayjs` stays: three component tests still build dates with it. Note the file opens with `/* eslint-disable */`, so the imports left dangling by the cut were invisible to lint and had to be found by hand. Worth removing that blanket disable separately. One correction folded in: renaming the i18next mock type last commit missed four `i18n['t']` casts in NotificationTranslationBuilder.test.ts. `tsconfig.test.json` is unenforced (~1200 pre-existing errors) so nothing flagged it; i18n test-type errors go 36 → 4, and the remaining four are pre-existing. --- package.json | 1 - .../NotificationTranslationBuilder.test.ts | 8 +- src/i18n/__tests__/Streami18n.test.ts | 579 ------------------ src/i18n/__tests__/utils.test.ts | 570 ----------------- 4 files changed, 4 insertions(+), 1154 deletions(-) delete mode 100644 src/i18n/__tests__/utils.test.ts diff --git a/package.json b/package.json index ea9d3b2ca3..3ed3b47643 100644 --- a/package.json +++ b/package.json @@ -194,7 +194,6 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "lint-staged": "^17.0.5", - "moment-timezone": "^0.5.48", "prettier": "^3.8.3", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/src/i18n/__tests__/NotificationTranslationBuilder.test.ts b/src/i18n/__tests__/NotificationTranslationBuilder.test.ts index 1c6a4d7d91..c0c07f84e3 100644 --- a/src/i18n/__tests__/NotificationTranslationBuilder.test.ts +++ b/src/i18n/__tests__/NotificationTranslationBuilder.test.ts @@ -60,7 +60,7 @@ describe('NotificationTranslationTopic', () => { ...mockI18Next, t: vi.fn((key) => key === 'notification.attachmentFileMissing' ? 'translated/file-required' : key, - ) as unknown as i18n['t'], + ) as unknown as I18nInstance['t'], }); const builder = new NotificationTranslationTopic({ i18next, @@ -83,7 +83,7 @@ describe('NotificationTranslationTopic', () => { it('does not interpolate metadata into an unrecognised message', () => { const i18next = fromPartial({ ...mockI18Next, - t: vi.fn() as unknown as i18n['t'], + t: vi.fn() as unknown as I18nInstance['t'], }); const builder = new NotificationTranslationTopic({ i18next }); @@ -146,7 +146,7 @@ describe('NotificationTranslationTopic', () => { ...mockI18Next, t: vi.fn( (translationKey) => `translated:${translationKey}`, - ) as unknown as i18n['t'], + ) as unknown as I18nInstance['t'], }); const builder = new NotificationTranslationTopic({ i18next }); @@ -168,7 +168,7 @@ describe('NotificationTranslationTopic', () => { key === 'notification.pollCreateFailedWithReason' ? `translated/reason:${options.reason}` : key, - ) as unknown as i18n['t'], + ) as unknown as I18nInstance['t'], }); const builder = new NotificationTranslationTopic({ i18next }); diff --git a/src/i18n/__tests__/Streami18n.test.ts b/src/i18n/__tests__/Streami18n.test.ts index 9e84e6b08b..c841658539 100644 --- a/src/i18n/__tests__/Streami18n.test.ts +++ b/src/i18n/__tests__/Streami18n.test.ts @@ -3,20 +3,10 @@ import { Streami18n } from '../Streami18n'; import type { Streami18nOptions } from '../Streami18n'; import type { LooseTranslationDictionary, TranslationDictionary } from '../types'; import type { TranslationCatalog } from '../keys'; -import { nanoid } from 'nanoid'; -import { default as Dayjs } from 'dayjs'; -import moment from 'moment-timezone'; -import { fromPartial } from '@total-typescript/shoehorn'; -// Only the `en` dayjs locale ships with the SDK; integrators import the ones they need, -// exactly as this test does. -import 'dayjs/locale/nl'; -import 'dayjs/locale/fr'; -import localeData from 'dayjs/plugin/localeData'; import { getDateString } from '../utils'; import { runtimeDefaults } from '../runtimeDefaults'; import { NotificationTranslationTopic } from '../TranslationBuilder'; import type { TranslationTopicConstructor } from '../TranslationBuilder'; -Dayjs.extend(localeData); const relativeDay = (offset: number) => { const date = new Date(); @@ -24,58 +14,6 @@ const relativeDay = (offset: number) => { return date.toISOString(); }; -const customDayjsLocaleConfig = { - months: - 'januar_februar_mars_apríl_mai_juni_juli_august_september_oktober_november_desember'.split( - '_', - ), - monthsShort: 'jan_feb_mar_apr_mai_jun_jul_aug_sep_okt_nov_des'.split('_'), - weekdays: - 'sunnudagur_mánadagur_týsdagur_mikudagur_hósdagur_fríggjadagur_leygardagur'.split( - '_', - ), - weekdaysShort: 'sun_mán_týs_mik_hós_frí_ley'.split('_'), - weekdaysMin: 'su_má_tý_mi_hó_fr_le'.split('_'), - formats: { - LT: 'HH:mm', - LTS: 'HH:mm:ss', - L: 'DD/MM/YYYY', - LL: 'D MMMM YYYY', - LLL: 'D MMMM YYYY HH:mm', - LLLL: 'dddd D. MMMM, YYYY HH:mm', - }, - calendar: { - sameDay: '[Í dag kl.] LT', - nextDay: '[Í morgin kl.] LT', - nextWeek: 'dddd [kl.] LT', - lastDay: '[Í gjár kl.] LT', - lastWeek: '[síðstu] dddd [kl] LT', - sameElse: 'L', - }, - relativeTime: { - future: 'um %s', - past: '%s síðani', - s: 'fá sekund', - ss: '%d sekundir', - m: 'ein minutt', - mm: '%d minuttir', - h: 'ein tími', - hh: '%d tímar', - d: 'ein dagur', - dd: '%d dagar', - M: 'ein mánaði', - MM: '%d mánaðir', - y: 'eitt ár', - yy: '%d ár', - }, - dayOfMonthOrdinalParse: /\d{1,2}\./, - ordinal: '%d.', - week: { - dow: 1, // Monday is the first day of the week. - doy: 4, // The week that contains Jan 4th is the first week of the year. - }, -}; - describe('Jest Timezone', () => { it('global config should set the timezone to UTC', () => { expect(new Date().getTimezoneOffset()).toBe(0); @@ -83,363 +21,6 @@ describe('Jest Timezone', () => { }); const streami18nOptions = { logger: () => null }; -describe('Streami18n instance - default', () => { - const streami18n = new Streami18n(streami18nOptions); - - it('should provide default english translator', async () => { - const { t: _t } = await streami18n.init(); - const text = nanoid(); - - expect(_t(text)).toBe(text); - }); - - it('should provide moment with default en locale', async () => { - const { tDateTimeParser } = await streami18n.init(); - expect(tDateTimeParser() instanceof Dayjs).toBe(true); - expect((tDateTimeParser() as Dayjs.Dayjs).locale()).toBe('en'); - }); -}); - -// `en` is the only bundled language. Non-English support is entirely integrator-supplied, -// so these tests exercise that path rather than deleted built-in dictionaries. -// Loose-typed on purpose: these keys are not in the catalog, which is what makes them useful for -// exercising resolution. `TranslationDictionary` would (correctly) reject them. -const dutchTranslations: LooseTranslationDictionary = { - 'messageList.empty': 'Nog niets...', - 'messageComposer.sendButton.label': 'Verstuur bericht', -}; - -// Only the keys that cannot carry an inline default are bundled (see src/i18n/runtimeDefaults.ts). -// Everything else renders from the English copy passed inline at its call site, which means these -// tests exercise the resolution path the whole design depends on. -describe('Streami18n - resolution without a bundled prose resource', () => { - it('renders a prose key from its inline default, not the key', async () => { - const streami18n = new Streami18n({ logger: () => null }); - const { t: _t } = await streami18n.init(); - - expect(_t('message.status.sent.text', 'Sent')).toBe('Sent'); - }); - - it('interpolates into an inline default', async () => { - const streami18n = new Streami18n({ logger: () => null }); - const { t: _t } = await streami18n.init(); - - expect( - _t( - 'a11y.incomingMessageAnnouncements.newMessage.label', - 'New message from {{user}}', - { - user: 'Ada', - }, - ), - ).toBe('New message from Ada'); - }); - - it('selects the plural form from the inline defaults', async () => { - const streami18n = new Streami18n({ logger: () => null }); - const { t: _t } = await streami18n.init(); - const options = { - defaultValue_one: '{{ count }} member', - defaultValue_other: '{{ count }} members', - }; - - expect( - _t('channelDetail.channelMembersView.members.title', { ...options, count: 1 }), - ).toBe('1 member'); - expect( - _t('channelDetail.channelMembersView.members.title', { ...options, count: 4 }), - ).toBe('4 members'); - }); - - it('resolves the bundled keys that have no inline default', async () => { - const streami18n = new Streami18n({ logger: () => null }); - const { t: _t } = await streami18n.init(); - - // language names are keyed off a runtime language code - expect(_t('language.de')).toBe('German'); - // formatter expressions are passed around as prop values, never written inline - expect(_t('timestamp.MessageTimestamp', { timestamp: new Date(0) })).not.toBe( - 'timestamp.MessageTimestamp', - ); - }); - - it('does not report a prose key to parseMissingKeyHandler, and keeps its copy', async () => { - const parseMissingKeyHandler = vi.fn(() => 'CLOBBERED'); - const streami18n = new Streami18n({ logger: () => null, parseMissingKeyHandler }); - const { t: _t } = await streami18n.init(); - - // Unguarded, i18next would replace the result with the handler's return value. - expect(_t('message.status.sent.text', 'Sent')).toBe('Sent'); - expect(parseMissingKeyHandler).not.toHaveBeenCalled(); - }); - - it('still reports a genuinely unknown key to parseMissingKeyHandler', async () => { - const parseMissingKeyHandler = vi.fn(() => 'HANDLED'); - const streami18n = new Streami18n({ logger: () => null, parseMissingKeyHandler }); - const { t: _t } = await streami18n.init(); - - const unknown = `nonexistent.${nanoid()}`; - // @ts-expect-error deliberately outside the key union - expect(_t(unknown)).toBe('HANDLED'); - expect(parseMissingKeyHandler).toHaveBeenCalledWith(unknown, undefined); - }); -}); - -describe('Streami18n instance - with an integrator-registered language', () => { - describe('datetime translations enabled', () => { - const streami18n = new Streami18n({ language: 'nl', logger: () => null }); - streami18n.registerTranslation('nl', dutchTranslations); - - it('should translate the registered keys', async () => { - const { t: _t } = await streami18n.init(); - for (const [key, value] of Object.entries(dutchTranslations)) { - expect(_t(key)).toBe(value); - } - }); - - it('should fall back to the key for unregistered keys', async () => { - const { t: _t } = await streami18n.init(); - const missing = nanoid(); - expect(_t(missing)).toBe(missing); - }); - - it('should provide dayjs with `nl` locale', async () => { - const { tDateTimeParser } = await streami18n.init(); - expect(tDateTimeParser() instanceof Dayjs).toBe(true); - expect((tDateTimeParser() as Dayjs.Dayjs).locale()).toBe('nl'); - }); - }); - - describe('datetime translations disabled', () => { - const streami18n = new Streami18n({ - language: 'nl', - disableDateTimeTranslations: true, - logger: () => null, - }); - streami18n.registerTranslation('nl', dutchTranslations); - - it('should translate the registered keys', async () => { - const { t: _t } = await streami18n.init(); - for (const [key, value] of Object.entries(dutchTranslations)) { - expect(_t(key)).toBe(value); - } - }); - - it('should provide dayjs with default `en` locale', async () => { - const { tDateTimeParser } = await streami18n.init(); - expect(tDateTimeParser() instanceof Dayjs).toBe(true); - expect((tDateTimeParser() as Dayjs.Dayjs).locale()).toBe('en'); - }); - }); - - describe('custom momentjs locale config', () => { - const streami18nOptions: Streami18nOptions = { - language: 'nl', - dayjsLocaleConfigForLanguage: fromPartial(customDayjsLocaleConfig), - }; - const streami18n = new Streami18n(streami18nOptions); - - it('should provide moment with given custom locale config', async () => { - const { tDateTimeParser } = await streami18n.init(); - expect(tDateTimeParser() instanceof Dayjs).toBe(true); - const localeConfig = (tDateTimeParser() as Dayjs.Dayjs).localeData(); - for (const key in streami18nOptions.dayjsLocaleConfigForLanguage) { - if (localeConfig[key]) { - expect( - typeof localeConfig[key] === 'function' - ? localeConfig[key]() - : localeConfig[key], - ).toStrictEqual(streami18nOptions.dayjsLocaleConfigForLanguage[key]); - } - } - }); - }); -}); - -describe('Streami18n instance - with custom translations', () => { - describe('datetime translations enabled', () => { - const textKey1 = 'this is text one'; - const textValue1 = '这是文字一'; - const textKey2 = 'this is text two'; - const textValue2 = '这是文字二'; - const translations: LooseTranslationDictionary = { - [textKey1]: textValue1, - [textKey2]: textValue2, - }; - // Note: original test had typo 'langauge' instead of 'language' - const streami18nOptions = { - translationsForLanguage: - translations as unknown as Streami18nOptions['translationsForLanguage'], - } satisfies Streami18nOptions; - const streami18n = new Streami18n(streami18nOptions); - - it('should provide given (chinese in this case) translator', async () => { - const { t: _t } = await streami18n.init(); - - expect(_t(textKey1)).toBe(textValue1); - - expect(_t(textKey2)).toBe(textValue2); - }); - - it('should provide moment with default `en` locale', async () => { - const { tDateTimeParser } = await streami18n.init(); - expect(tDateTimeParser() instanceof Dayjs).toBe(true); - expect((tDateTimeParser() as Dayjs.Dayjs).locale()).toBe('en'); - }); - }); -}); - -describe('registerTranslation - register new language `mr` (Marathi) ', () => { - const streami18nOptions = { - language: 'en', - disableDateTimeTranslations: false, - }; - const streami18n = new Streami18n(streami18nOptions); - const languageCode = 'mr'; - const translations: LooseTranslationDictionary = { - text1: 'अनुवादित मजकूर 1', - text2: 'अनुवादित मजकूर 2', - }; - streami18n.registerTranslation(languageCode, translations, customDayjsLocaleConfig); - - streami18n.setLanguage('mr'); - - it('should add Marathi translations object to list of translations', () => { - // Merged over `runtimeDefaults` rather than stored verbatim — the keys with no inline - // `defaultValue` have to survive, or every timestamp renders as its raw key. - expect(streami18n.getTranslations()[languageCode].translation).toMatchObject( - translations, - ); - expect(streami18n.getTranslations()[languageCode].translation).toHaveProperty( - 'timestamp.MessageTimestamp', - ); - }); - - it('should register moment locale config for Marathi translations', async () => { - const { tDateTimeParser } = await streami18n.init(); - expect(tDateTimeParser() instanceof Dayjs).toBe(true); - - const localeConfig = (tDateTimeParser() as Dayjs.Dayjs).localeData(); - for (const key in customDayjsLocaleConfig) { - if (localeConfig[key]) { - expect(customDayjsLocaleConfig[key]).toStrictEqual( - typeof localeConfig[key] === 'function' - ? localeConfig[key]() - : localeConfig[key], - ); - } - } - }); -}); - -describe('setLanguage - switch to a registered language', () => { - const frenchTranslations: LooseTranslationDictionary = { - 'messageList.empty': 'Rien pour le moment...', - 'messageComposer.sendButton.label': 'Envoyer le message', - }; - - it('should provide the french translator after switching', async () => { - const streami18n = new Streami18n({ logger: () => null }); - streami18n.registerTranslation('fr', frenchTranslations); - - // English before the switch: an unknown key resolves to itself. - const { t: beforeT } = await streami18n.init(); - expect(beforeT('messageList.empty')).toBe('messageList.empty'); - - await streami18n.setLanguage('fr'); - - const { t: _t } = await streami18n.init(); - for (const [key, value] of Object.entries(frenchTranslations)) { - expect(_t(key)).toBe(value); - } - }); - - it('should fall back to the key for an unregistered language', async () => { - // An unknown language gets an empty dictionary rather than being rejected, so every - // key resolves to itself — which is the inline English default at each call site. - const streami18n = new Streami18n({ language: 'zz', logger: () => null }); - const { t: _t } = await streami18n.init(); - - expect(streami18n.currentLanguage).toBe('zz'); - expect(_t('messageComposer.sendButton.label')).toBe( - 'messageComposer.sendButton.label', - ); - }); -}); - -describe('Streami18n timezone', () => { - describe.each([ - ['Dayjs', Dayjs], - ['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) as Dayjs.Dayjs).format('H')).toBe( - date.getHours().toString(), - ); - }); - - 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) as Dayjs.Dayjs).format('H')).not.toBe( - date.getHours().toString(), - ); - expect((streami18n.tDateTimeParser(date) as Dayjs.Dayjs).format('H')).not.toBe( - (date.getUTCHours() - 2).toString(), - ); - }); - - it('is ignored if datetime parser does not support timezones', () => { - const moduleRecord = module as unknown as Record; - const tz = moduleRecord.tz; - delete moduleRecord.tz; - - const streami18n = new Streami18n({ - DateTimeParser: module, - timezone: 'Europe/Prague', - }); - const date = new Date(); - expect((streami18n.tDateTimeParser(date) as Dayjs.Dayjs).format('H')).toBe( - date.getHours().toString(), - ); - - moduleRecord.tz = tz; - }); - describe('formatters property', () => { - it('contains the default timestampFormatter', () => { - expect(new Streami18n().formatters.timestampFormatter).toBeDefined(); - }); - // `value` has to be supplied: an undefined interpolation value short-circuits before the - // formatter is consulted, so omitting it would assert nothing about formatter registration. - it('allows to override the default timestampFormatter', async () => { - const i18n = new Streami18n({ - formatters: { timestampFormatter: () => () => 'custom' }, - translationsForLanguage: { - abc: '{{ value | timestampFormatter }}', - } as unknown as Streami18nOptions['translationsForLanguage'], - }); - await i18n.init(); - expect(i18n.t('abc', { value: new Date(0) })).toBe('custom'); - }); - it('allows to add new custom formatter', async () => { - const i18n = new Streami18n({ - formatters: { customFormatter: () => () => 'custom' }, - translationsForLanguage: { - abc: '{{ value | customFormatter }}', - } as unknown as Streami18nOptions['translationsForLanguage'], - }); - await i18n.init(); - expect(i18n.t('abc', { value: 'anything' })).toBe('custom'); - }); - }); - }); -}); - describe('Streami18n translationBuilder', () => { it('is created at construction time', () => { const streami18n = new Streami18n(streami18nOptions); @@ -684,166 +265,6 @@ describe('Streami18n - dictionary key types', () => { }); }); -describe('Streami18n - a language nobody registered still formats dates', () => { - // Only `registerTranslation` and `translationsForLanguage` used to layer `runtimeDefaults`. - // Selecting a language without supplying a dictionary — the recipe in the migration guide's - // "Date and time" section, for an app that wants localized dates but is happy with English - // copy — fell through to an empty dictionary, so `duration.*` rendered as its raw key and every - // timestamp came out as an unformatted ISO string. - const TIMESTAMP = '2024-01-01T10:30:00.000Z'; - - const stamp = (i18n: Streami18n, key = 'timestamp.MessageTimestamp') => - getDateString({ - messageCreatedAt: TIMESTAMP, - t: i18n.t, - tDateTimeParser: i18n.tDateTimeParser, - timestampTranslationKey: key, - }); - - it('language is selected but no dictionary is registered', async () => { - const i18n = new Streami18n({ language: 'de' as 'en', logger: () => null }); - const { t } = await i18n.init(); - - expect(stamp(i18n)).toBe('10:30'); - expect(stamp(i18n, 'timestamp.DateSeparator')).toBe('Mon, 1 Jan'); - expect(t('duration.remindMe', { milliseconds: 600000 })).toBe('in 10 minutes'); - // The postProcessor directive is bundled too, and drives the notification topic. - expect(i18n.getTranslations()['de'].translation).toHaveProperty( - 'translationBuilderTopic.notification', - ); - // Copy falls back to the inline English default, which is the documented trade-off. - expect(t('common.cancel.label', 'Cancel')).toBe('Cancel'); - }); - - it('language is selected with a dayjs locale config and no dictionary', async () => { - const i18n = new Streami18n({ - language: 'nl' as 'en', - logger: () => null, - dayjsLocaleConfigForLanguage: customDayjsLocaleConfig, - }); - await i18n.init(); - - expect(stamp(i18n)).toBe('10:30'); - }); - - it('keeps the selected language rather than silently reverting to English', async () => { - // `language: 'de'` followed by `registerTranslation('de', …)` is the documented flow, so the - // constructor must not reset `currentLanguage` when the dictionary has not arrived yet. - const i18n = new Streami18n({ language: 'de' as 'en', logger: () => null }); - i18n.registerTranslation('de' as 'en', { 'common.cancel.label': 'Abbrechen' }); - const { t } = await i18n.init(); - - expect(i18n.currentLanguage).toBe('de'); - expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); - }); -}); - -describe('Streami18n - setLanguage to a language nobody registered', () => { - const TIMESTAMP = '2024-01-01T10:30:00.000Z'; - const stamp = (i18n: Streami18n) => - getDateString({ - messageCreatedAt: TIMESTAMP, - t: i18n.t, - tDateTimeParser: i18n.tDateTimeParser, - timestampTranslationKey: 'timestamp.MessageTimestamp', - }); - - // Switching after init used to bypass every guard: no warning, and no resource bundle for the - // new language, so dates broke. Before init the same call warned and fell back to English — - // the outcome depended on whether had mounted yet. - it.each([ - ['before init', false], - ['after init', true], - ])('%s', async (_name, afterInit) => { - const logger = vi.fn(); - const i18n = new Streami18n({ logger }); - if (afterInit) await i18n.init(); - - await i18n.setLanguage('de' as 'en'); - if (!afterInit) await i18n.init(); - - expect(i18n.currentLanguage).toBe('de'); - expect(stamp(i18n)).toBe('10:30'); - expect(i18n.t('duration.remindMe', { milliseconds: 600000 })).toBe('in 10 minutes'); - expect(logger).toHaveBeenCalledWith( - expect.stringContaining("no translation dictionary is registered for 'de'"), - ); - }); - - it('does not clobber a dictionary registered for that language', async () => { - const i18n = new Streami18n({ logger: () => null }); - i18n.registerTranslation('de' as 'en', { 'common.cancel.label': 'Abbrechen' }); - await i18n.init(); - await i18n.setLanguage('de' as 'en'); - - expect(i18n.t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); - expect(stamp(i18n)).toBe('10:30'); - }); - - it('switching back and forth keeps both dictionaries', async () => { - const i18n = new Streami18n({ logger: () => null }); - i18n.registerTranslation('de' as 'en', { 'common.cancel.label': 'Abbrechen' }); - await i18n.init(); - - await i18n.setLanguage('de' as 'en'); - expect(i18n.t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); - - await i18n.setLanguage('en'); - expect(i18n.t('common.cancel.label', 'Cancel')).toBe('Cancel'); - - await i18n.setLanguage('de' as 'en'); - expect(i18n.t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); - }); -}); - -describe('Streami18n - the unregistered-language warning', () => { - it('is not emitted at construction time, when registerTranslation has yet to run', () => { - const logger = vi.fn(); - new Streami18n({ language: 'de' as 'en', logger }); - - expect(logger).not.toHaveBeenCalledWith( - expect.stringContaining('no translation dictionary is registered'), - ); - }); - - it('is emitted once, on init, when no dictionary ever arrives', async () => { - const logger = vi.fn(); - const i18n = new Streami18n({ language: 'de' as 'en', logger }); - await i18n.init(); - - const warnings = logger.mock.calls.filter(([message]) => - String(message).includes('no translation dictionary is registered'), - ); - expect(warnings).toHaveLength(1); - expect(warnings[0][0]).toContain("registerTranslation('de', {...})"); - }); - - it('is not emitted when a dictionary was registered before init', async () => { - const logger = vi.fn(); - const i18n = new Streami18n({ language: 'de' as 'en', logger }); - i18n.registerTranslation('de' as 'en', { 'common.cancel.label': 'Abbrechen' }); - await i18n.init(); - - expect(logger).not.toHaveBeenCalledWith( - expect.stringContaining('no translation dictionary is registered'), - ); - }); - - it('is not emitted when translationsForLanguage supplied the dictionary', async () => { - const logger = vi.fn(); - const i18n = new Streami18n({ - language: 'de' as 'en', - logger, - translationsForLanguage: { 'common.cancel.label': 'Abbrechen' }, - }); - await i18n.init(); - - expect(logger).not.toHaveBeenCalledWith( - expect.stringContaining('no translation dictionary is registered'), - ); - }); -}); - describe('Streami18n - the calendar keys that carry English words', () => { // dayjs takes the calendar wording as part of the format string, so a handful of `timestamp.*` // values embed English day words. A per-key `calendarFormats` replaces the locale's calendar diff --git a/src/i18n/__tests__/utils.test.ts b/src/i18n/__tests__/utils.test.ts deleted file mode 100644 index 1061d17238..0000000000 --- a/src/i18n/__tests__/utils.test.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { getDateString, predefinedFormatters } from '../utils'; -import type { StreamTFunction } from '../types'; -import { Streami18n } from '../Streami18n'; -import Dayjs from 'dayjs'; -import { fromPartial } from '@total-typescript/shoehorn'; - -import type { TDateTimeParser } from '../types'; -import { mockT as sharedMockT } from '../../mock-builders/translator'; - -vi.spyOn(console, 'warn').mockImplementationOnce(() => null); -const messageCreatedAt = '1970-01-01T01:01:01.001Z'; -const t = vi.fn() as unknown as StreamTFunction & ReturnType; -const timestampTranslationKey = 'timestampTranslationKey'; - -const FIXED_NOW = new Date('2025-02-19T12:00:00.000Z'); -const tDateTimeParserDayjs = (input) => Dayjs(input || new Date().toISOString()); - -describe('getDateString', () => { - it('returns null if not creation date provided', () => { - expect( - getDateString({ - calendar: true, - format: 'hh:mm A', - formatDate: (input) => input.toISOString(), - messageCreatedAt: undefined, - tDateTimeParser: ((input) => input) as TDateTimeParser, - }), - ).toBeNull(); - }); - - it('returns null if creation date string is incorrectly formatted', () => { - vi.spyOn(console, 'warn').mockImplementationOnce(() => null); - expect( - getDateString({ - calendar: true, - format: 'hh:mm A', - formatDate: (input) => input.toISOString(), - messageCreatedAt: 'yesterday', - tDateTimeParser: ((input) => input) as TDateTimeParser, - }), - ).toBeNull(); - }); - - it('returns null if neither datetime formatter nor custom formatting function are provided', () => { - vi.spyOn(console, 'warn').mockImplementationOnce(() => null); - expect( - getDateString({ - calendar: true, - format: 'hh:mm A', - formatDate: undefined, - messageCreatedAt, - tDateTimeParser: undefined, - }), - ).toBeNull(); - }); - - it('returns a date string formatted with custom formatter function', () => { - const expectedValue = 'expected'; - const formatDateMock = vi.fn().mockReturnValue(expectedValue); - expect( - getDateString({ - calendar: true, - format: 'hh:mm A', - formatDate: formatDateMock, - messageCreatedAt, - tDateTimeParser: ((input) => input) as TDateTimeParser, - }), - ).toBe(expectedValue); - }); - - it('returns a date string formatted as toDateString() if datetime formatter returns a Date instance', () => { - const expectedValue = new Date(messageCreatedAt).toDateString(); - expect( - getDateString({ - calendar: true, - format: 'hh:mm A', - formatDate: undefined, - messageCreatedAt, - tDateTimeParser: (input) => new Date(input!), - }), - ).toBe(expectedValue); - }); - - it('returns a date string returned by the datetime formatter', () => { - const expectedValue = 'expected'; - expect( - getDateString({ - calendar: true, - format: 'hh:mm A', - formatDate: undefined, - messageCreatedAt, - tDateTimeParser: () => expectedValue, - }), - ).toBe(expectedValue); - }); - - it('returns a number returned by the datetime formatter', () => { - const expectedValue = 0; - expect( - getDateString({ - calendar: true, - format: 'hh:mm A', - formatDate: undefined, - messageCreatedAt, - tDateTimeParser: () => expectedValue, - }), - ).toBe(expectedValue); - }); - - it.each([ - ['defined', { x: 'y' }], - ['undefined', undefined], - ])( - 'invokes calendar method on dayOrMoment object with calendar formats %s', - (_, calendarFormats) => { - const dayOrMoment = fromPartial({ - calendar: vi.fn(), - format: vi.fn(), - isSame: true, - }); - getDateString({ - calendar: true, - calendarFormats, - format: 'hh:mm A', - formatDate: undefined, - messageCreatedAt, - tDateTimeParser: () => dayOrMoment, - }); - expect(dayOrMoment.calendar).toHaveBeenCalledWith(undefined, calendarFormats); - expect(dayOrMoment.format).not.toHaveBeenCalled(); - }, - ); - - it.each([ - ['defined', { x: 'y' }], - ['undefined', undefined], - ])( - 'invokes format method on dayOrMoment object with calendar formats %s', - (_, calendarFormats) => { - const dayOrMoment = fromPartial({ - calendar: vi.fn(), - format: vi.fn(), - isSame: true, - }); - const format = 'XY'; - getDateString({ - calendar: false, - calendarFormats, - format, - formatDate: undefined, - messageCreatedAt, - tDateTimeParser: () => dayOrMoment, - }); - expect(dayOrMoment.format).toHaveBeenCalledWith(format); - expect(dayOrMoment.calendar).not.toHaveBeenCalled(); - }, - ); - - it.each([null, undefined, {}, [], new Set(), true, new RegExp('')])( - 'returns null if datetime formatter does not return either string, number or Date instance', - (returnedValue) => { - expect( - getDateString({ - calendar: true, - format: 'hh:mm A', - formatDate: undefined, - messageCreatedAt, - tDateTimeParser: (() => returnedValue) as TDateTimeParser, - }), - ).toBeNull(); - }, - ); - it('gives preference to custom formatDate function before translation', () => { - const expectedValue = 0; - const formatDate = vi.fn(); - getDateString({ - calendar: true, - format: 'hh:mm A', - formatDate, - messageCreatedAt, - t, - tDateTimeParser: () => expectedValue, - timestampTranslationKey, - }); - expect(t).not.toHaveBeenCalled(); - expect(formatDate).toHaveBeenCalledWith(new Date(messageCreatedAt)); - }); - it('does not apply translation if timestampTranslationKey key is missing', () => { - const expectedValue = new Date().toISOString(); - const result = getDateString({ - calendar: true, - format: 'hh:mm A', - messageCreatedAt, - t, - tDateTimeParser: () => expectedValue, - }); - expect(t).not.toHaveBeenCalled(); - expect(result).toBe(expectedValue); - }); - it('does not apply translation if translator function is missing', () => { - const expectedValue = new Date().toISOString(); - const result = getDateString({ - calendar: true, - format: 'hh:mm A', - messageCreatedAt, - tDateTimeParser: () => expectedValue, - timestampTranslationKey, - }); - expect(t).not.toHaveBeenCalled(); - expect(result).toBe(expectedValue); - }); - it.each([ - ['all enabled', { calendar: true, calendarFormats: { x: 'y' }, format: 'hh:mm A' }], - [ - 'calendar disabled', - { calendar: false, calendarFormats: { x: 'y' }, format: 'hh:mm A' }, - ], - [ - 'calendar formats omitted', - { calendar: true, calendarFormats: undefined, format: 'hh:mm A' }, - ], - [ - 'only format provided', - { calendar: false, calendarFormats: undefined, format: 'hh:mm A' }, - ], - [ - 'format undefined', - { calendar: true, calendarFormats: { x: 'y' }, format: undefined }, - ], - [ - 'calendar disabled and format undefined', - { calendar: false, calendarFormats: { x: 'y' }, format: undefined }, - ], - [ - 'calendar formats and format undefined', - { calendar: true, calendarFormats: undefined, format: undefined }, - ], - [ - 'calendar disabled and rest undefined', - { calendar: false, calendarFormats: undefined, format: undefined }, - ], - [ - 'calendar undefined', - { calendar: undefined, calendarFormats: { x: 'y' }, format: 'hh:mm A' }, - ], - [ - 'calendar and calendar formats undefined', - { calendar: undefined, calendarFormats: undefined, format: 'hh:mm A' }, - ], - [ - 'calendar and format undefined', - { calendar: undefined, calendarFormats: { x: 'y' }, format: undefined }, - ], - [ - 'all undefined', - { calendar: undefined, calendarFormats: undefined, format: undefined }, - ], - ])( - 'applies formatting via translation service with translation formatting params %s', - (_, params) => { - const expectedValue = 'XXXX'; - const finalParams = Object.entries(params).reduce((acc, [k, v]) => { - if (typeof v === 'undefined') return acc; - acc[k] = v; - return acc; - }, {}); - t.mockReturnValueOnce(expectedValue); - const result = getDateString({ - ...params, - messageCreatedAt, - t, - tDateTimeParser: () => new Date().toString(), - timestampTranslationKey, - }); - expect(t).toHaveBeenCalledWith(timestampTranslationKey, { - ...finalParams, - timestamp: new Date(messageCreatedAt), - }); - expect(result).toBe(expectedValue); - }, - ); - - describe('relativeCompact', () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - vi.setSystemTime(FIXED_NOW); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('returns "Today" for same calendar day', () => { - const mockT = vi.fn(sharedMockT); - const result = getDateString({ - messageCreatedAt: FIXED_NOW.toISOString(), - relativeCompact: true, - t: mockT as unknown as StreamTFunction, - tDateTimeParser: tDateTimeParserDayjs, - }); - expect(result).toBe('Today'); - expect(mockT).toHaveBeenCalledWith('relativeTime.today', 'Today'); - }); - - it('returns "Yesterday" for 1 day ago', () => { - const mockT = vi.fn(sharedMockT); - const yesterday = new Date(FIXED_NOW); - yesterday.setUTCDate(yesterday.getUTCDate() - 1); - const result = getDateString({ - messageCreatedAt: yesterday.toISOString(), - relativeCompact: true, - t: mockT as unknown as StreamTFunction, - tDateTimeParser: tDateTimeParserDayjs, - }); - expect(result).toBe('Yesterday'); - expect(mockT).toHaveBeenCalledWith('relativeTime.yesterday', 'Yesterday'); - }); - - it('returns "Nd ago" for 2–6 days ago', () => { - const mockT = vi.fn(sharedMockT); - const threeDaysAgo = new Date(FIXED_NOW); - threeDaysAgo.setUTCDate(threeDaysAgo.getUTCDate() - 3); - const result = getDateString({ - messageCreatedAt: threeDaysAgo.toISOString(), - relativeCompact: true, - t: mockT as unknown as StreamTFunction, - tDateTimeParser: tDateTimeParserDayjs, - }); - expect(result).toBe('3d ago'); - expect(mockT).toHaveBeenCalledWith('relativeTime.daysAgo', { - count: 3, - defaultValue_one: '{{ count }}d ago', - defaultValue_other: '{{ count }}d ago', - }); - }); - - it('returns "Nw ago" for 1–3 weeks ago', () => { - const mockT = vi.fn(sharedMockT); - const sevenDaysAgo = new Date(FIXED_NOW); - sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); - const result = getDateString({ - messageCreatedAt: sevenDaysAgo.toISOString(), - relativeCompact: true, - t: mockT as unknown as StreamTFunction, - tDateTimeParser: tDateTimeParserDayjs, - }); - expect(result).toBe('1w ago'); - expect(mockT).toHaveBeenCalledWith('relativeTime.weeksAgo', { - count: 1, - defaultValue_one: '{{ count }}w ago', - defaultValue_other: '{{ count }}w ago', - }); - }); - - it('returns DD/MM/YY for 4+ weeks ago', () => { - const mockT = vi.fn(sharedMockT); - const twentyEightDaysAgo = new Date(FIXED_NOW); - twentyEightDaysAgo.setUTCDate(twentyEightDaysAgo.getUTCDate() - 28); - const result = getDateString({ - messageCreatedAt: twentyEightDaysAgo.toISOString(), - relativeCompact: true, - t: mockT as unknown as StreamTFunction, - tDateTimeParser: tDateTimeParserDayjs, - }); - expect(result).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); - expect(result).toBe('22/01/25'); - }); - - it('returns DD/MM/YY for future date', () => { - const mockT = vi.fn(sharedMockT); - const tomorrow = new Date(FIXED_NOW); - tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); - const result = getDateString({ - messageCreatedAt: tomorrow.toISOString(), - relativeCompact: true, - t: mockT as unknown as StreamTFunction, - tDateTimeParser: tDateTimeParserDayjs, - }); - expect(result).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); - expect(result).toBe('20/02/25'); - }); - - it('respects relativeCompactMaxWeeks: 0 (no "Nw ago", 7+ days show as date)', () => { - const mockT = vi.fn(sharedMockT); - const sevenDaysAgo = new Date(FIXED_NOW); - sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); - const result = getDateString({ - messageCreatedAt: sevenDaysAgo.toISOString(), - relativeCompact: true, - relativeCompactMaxWeeks: 0, - t: mockT as unknown as StreamTFunction, - tDateTimeParser: tDateTimeParserDayjs, - }); - expect(result).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); - expect(result).toBe('12/02/25'); - }); - - it('respects relativeCompactMaxDays (only 2–N days show "Nd ago")', () => { - const mockT = vi.fn(sharedMockT); - const threeDaysAgo = new Date(FIXED_NOW); - threeDaysAgo.setUTCDate(threeDaysAgo.getUTCDate() - 3); - const result = getDateString({ - messageCreatedAt: threeDaysAgo.toISOString(), - relativeCompact: true, - relativeCompactMaxDays: 2, - relativeCompactMaxWeeks: 0, - t: mockT as unknown as StreamTFunction, - tDateTimeParser: tDateTimeParserDayjs, - }); - expect(result).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); - expect(result).toBe('16/02/25'); - }); - - it('does not use relativeCompact when t or tDateTimeParser is missing', () => { - const dayOrMoment = fromPartial({ - calendar: vi.fn(), - format: vi.fn().mockReturnValue('formatted'), - isSame: true, - }); - getDateString({ - messageCreatedAt: FIXED_NOW.toISOString(), - relativeCompact: true, - t: undefined, - tDateTimeParser: () => dayOrMoment, - }); - expect(dayOrMoment.calendar).not.toHaveBeenCalled(); - expect(dayOrMoment.format).toHaveBeenCalled(); - }); - }); -}); - -describe('predefinedFormatters', () => { - describe('timestampFormatter', () => { - const timestampFormatter = predefinedFormatters.timestampFormatter(new Streami18n()); - const yesterdayDate = new Date(new Date().getTime() - 60 * 60 * 24 * 1000); - const yesterdayString = yesterdayDate.toString(); - describe.each([ - ['string', yesterdayString], - ['Date', yesterdayDate], - ])('accepts %s', (_, yesterday) => { - it('should format with calendar if enabled', () => { - expect( - timestampFormatter(yesterday, 'en', { - calendar: true, - calendarFormats: { sameElse: 'dddd L' }, - }).startsWith('Yesterday'), - ).toBeTruthy(); - }); - it('should ignore calendarFormats if calendar is disabled', () => { - expect( - timestampFormatter(yesterday, 'en', { - calendar: false, - calendarFormats: { sameElse: 'dddd L' }, - }).startsWith('Yesterday'), - ).toBeFalsy(); - }); - it('should report invalid calendarFormats through the instance logger', () => { - const logger = vi.fn(); - // The instance doubles as the formatter context here, matching the suite's existing style. - const formatter = predefinedFormatters.timestampFormatter( - new Streami18n({ logger }) as never, - ); - - formatter(yesterday, 'en', { calendar: true, calendarFormats: '}' }); - - // Reported through `logger`, not `console.error`: it respects the `logger` option, and a - // malformed formatter argument is a diagnostic rather than something to render. - expect(logger).toHaveBeenCalledWith( - expect.stringContaining('calendarFormats is not valid JSON'), - ); - }); - it('should parse calendarFormats', () => { - expect( - timestampFormatter(yesterday, 'en', { - calendar: true, - calendarFormats: '{ "sameElse": "dddd L" }', - }).startsWith('Yesterday'), - ).toBeTruthy(); - }); - it('should ignore format parameter if calendar is enabled', () => { - expect( - timestampFormatter(yesterday, 'en', { - calendar: true, - calendarFormats: { sameElse: 'dddd L' }, - format: 'YYYY', - }).startsWith('Yesterday'), - ).toBeTruthy(); - }); - it('should apply format parameter if calendar is disabled', () => { - expect( - timestampFormatter(yesterday, 'en', { - calendar: false, - calendarFormats: { sameElse: 'dddd L' }, - format: 'YYYY', - }), - ).toBe(new Date().getFullYear().toString()); - }); - }); - - it('should handle null translation value', () => { - expect( - timestampFormatter(null, 'en', { - calendar: false, - calendarFormats: { sameElse: 'dddd L' }, - format: 'YYYY', - }), - ).toBe(''); - }); - it('should handle undefined value', () => { - expect( - timestampFormatter(undefined, 'en', { - calendar: false, - calendarFormats: { sameElse: 'dddd L' }, - format: 'YYYY', - }), - ).toBe(''); - }); - - describe('relativeCompact', () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - vi.setSystemTime(FIXED_NOW); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('formats with relativeCompact: true (uses t for labels; date for 4+ weeks)', () => { - const todayIso = FIXED_NOW.toISOString(); - const yesterday = new Date(FIXED_NOW); - yesterday.setUTCDate(yesterday.getUTCDate() - 1); - const threeDaysAgo = new Date(FIXED_NOW); - threeDaysAgo.setUTCDate(threeDaysAgo.getUTCDate() - 3); - const thirtyDaysAgo = new Date(FIXED_NOW); - thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30); - expect(timestampFormatter(todayIso, 'en', { relativeCompact: true })).toBe( - 'Today', - ); - expect( - timestampFormatter(yesterday.toISOString(), 'en', { relativeCompact: true }), - ).toBe('Yesterday'); - // `count` is interpolated into the inline default, so this reads as real copy rather - // than the raw "{{ count }}d ago" template the identity translator used to return. - expect( - timestampFormatter(threeDaysAgo.toISOString(), 'en', { relativeCompact: true }), - ).toBe('3d ago'); - expect( - timestampFormatter(thirtyDaysAgo.toISOString(), 'en', { - relativeCompact: true, - }), - ).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); - }); - - it('respects relativeCompactMaxWeeks: 0 when passed as number or string', () => { - const sevenDaysAgo = new Date(FIXED_NOW); - sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); - const result = timestampFormatter(sevenDaysAgo.toISOString(), 'en', { - relativeCompact: true, - relativeCompactMaxWeeks: 0, - }); - expect(result).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); - expect(result).toBe('12/02/25'); - const resultStr = timestampFormatter(sevenDaysAgo.toISOString(), 'en', { - relativeCompact: true, - relativeCompactMaxWeeks: '0', - }); - expect(resultStr).toBe('12/02/25'); - }); - }); - }); -}); From 83bebb8af868474233cdb10e3d79002c7859775d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 17:07:15 +0200 Subject: [PATCH 07/10] fix(i18n): restore the language-name miss-detection, dedupe BundledKey `MessageTranslationIndicator` compares the resolved name against the key again. The `language.*` keys being typed does not make them exhaustive at runtime: the union is generated when the SDK is built, while `message.i18n.language` is server data, so a language the translation API learns after this release has no entry and i18next echoes the key back -- rendering "Translated from language.sw" instead of falling back to the bare code. Covered by a new test that renders against a real `Streami18n`, since a mocked `t` would pass either way. `BundledKey` was declared privately in both `types.ts` and `Streami18n.ts`. It is now exported once and imported, so the exported `StreamTFunction` and the class instance's own `t` cannot disagree about the same call. Drops `src/i18n/__tests__/TranslationBuilder.test.ts`: `TranslationBuilder` is core's class re-exported from here, and eight of its nine cases duplicated core's own suite while asserting on private fields against a mocked i18next. The ninth -- removing a translator from the buffer before the topic exists -- moved to `stream-chat` rather than being lost. --- .../Message/MessageTranslationIndicator.tsx | 12 ++- .../MessageTranslationIndicator.test.tsx | 61 ++++++++++++ src/i18n/Streami18n.ts | 5 +- src/i18n/__tests__/TranslationBuilder.test.ts | 92 ------------------- src/i18n/types.ts | 6 +- 5 files changed, 76 insertions(+), 100 deletions(-) create mode 100644 src/components/Message/__tests__/MessageTranslationIndicator.test.tsx delete mode 100644 src/i18n/__tests__/TranslationBuilder.test.ts diff --git a/src/components/Message/MessageTranslationIndicator.tsx b/src/components/Message/MessageTranslationIndicator.tsx index 624b85775a..810a9823f2 100644 --- a/src/components/Message/MessageTranslationIndicator.tsx +++ b/src/components/Message/MessageTranslationIndicator.tsx @@ -52,9 +52,15 @@ export const MessageTranslationIndicator = ({ if (!sourceLanguageCode) return ''; // `language.*` keys are part of the catalog now (core derives them from the same // `TranslationLanguage` union this code is), so the key is checked at compile time rather than - // escaping through `asDynamicKey()`. That also retires the `translatedName !== languageKey` - // comparison this used to need to detect a name core had no entry for — the union guarantees one. - return t(`language.${sourceLanguageCode}`); + // escaping through `asDynamicKey()`. + // + // The miss-detection stays, though: `message.i18n.language` is *server* data while the union is + // generated when the SDK is built, so a language the translation API learns after this release has + // no entry and i18next echoes the key back. Without the comparison the indicator reads + // "Translated from language.sw" rather than falling back to the bare code. + const languageKey = `language.${sourceLanguageCode}` as const; + const translatedName = t(languageKey); + return translatedName === languageKey ? sourceLanguageCode : translatedName; }, [message?.i18n?.language, t]); if (!message?.i18n || !setTranslationView) return null; diff --git a/src/components/Message/__tests__/MessageTranslationIndicator.test.tsx b/src/components/Message/__tests__/MessageTranslationIndicator.test.tsx new file mode 100644 index 0000000000..81ff5c75cd --- /dev/null +++ b/src/components/Message/__tests__/MessageTranslationIndicator.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { MessageProvider, TranslationProvider } from '../../../context'; +import { Streami18n } from '../../../i18n/Streami18n'; +import type { StreamTFunction } from '../../../i18n/types'; +import { mockMessageContext } from '../../../mock-builders'; +import { MessageTranslationIndicator } from '../MessageTranslationIndicator'; + +/** + * Rendered against a real `Streami18n`, not a mocked `t`. + * + * The behaviour under test is what i18next does with a `language.*` key it has no entry for, so a mock + * that echoes the default back would pass either way. + */ +const renderIndicator = async (sourceLanguage: string) => { + const i18n = new Streami18n({ logger: () => {} }); + const { t, tDateTimeParser } = await i18n.init(); + + const message = { + i18n: { en_text: 'Hello', language: sourceLanguage }, + text: 'source text', + type: 'regular', + }; + + render( + + {}, + translationView: 'translated', + })} + > + + + , + ); +}; + +describe('MessageTranslationIndicator', () => { + it('names a language core has a display name for', async () => { + await renderIndicator('de'); + + expect(screen.getByText('Translated from German')).toBeInTheDocument(); + }); + + /** + * `message.i18n.language` is server data; the `language.*` catalog is generated when the SDK is built. + * A language the translation API learns after this release therefore has no entry, and i18next echoes + * the key back — so without the miss-detection this rendered "Translated from language.xx". + */ + it('falls back to the bare code for a language it has no name for', async () => { + await renderIndicator('xx'); + + expect(screen.getByText('Translated from xx')).toBeInTheDocument(); + expect(screen.queryByText(/language\.xx/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/i18n/Streami18n.ts b/src/i18n/Streami18n.ts index e6c4b3341a..9b3c49d154 100644 --- a/src/i18n/Streami18n.ts +++ b/src/i18n/Streami18n.ts @@ -3,10 +3,7 @@ import type { Streami18nOptions as CoreStreami18nOptions } from 'stream-chat/i18 import { NotificationTranslationTopic } from './TranslationBuilder'; import { runtimeDefaults } from './runtimeDefaults'; -import type { TranslationCatalog } from './types'; - -/** Keys resolved from bundled data rather than an inline default. Mirrors `types.ts`. */ -type BundledKey = `translationBuilderTopic.${string}` | `language.${string}`; +import type { BundledKey, TranslationCatalog } from './types'; /** * Options for {@link Streami18n}. diff --git a/src/i18n/__tests__/TranslationBuilder.test.ts b/src/i18n/__tests__/TranslationBuilder.test.ts deleted file mode 100644 index fb0ab5ecb8..0000000000 --- a/src/i18n/__tests__/TranslationBuilder.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { NotificationTranslationTopic, TranslationBuilder } from '../TranslationBuilder'; -import type { TranslationTopicConstructor } from '../TranslationBuilder'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { I18nInstance } from 'stream-chat/i18n'; - -const mockI18Next = fromPartial({ use: vi.fn() }); -describe('TranslationBuilder and TranslationTopic', () => { - it('gets initiated', () => { - const manager = new TranslationBuilder(mockI18Next); - expect(manager['i18next']).toEqual(mockI18Next); - }); - - it('registers and retrieves the builder', () => { - const manager = new TranslationBuilder(mockI18Next); - manager.registerTopic('notification', NotificationTranslationTopic); - expect(manager.getTopic('notification')).toBeInstanceOf(NotificationTranslationTopic); - }); - - it('removes builder', () => { - const manager = new TranslationBuilder(mockI18Next); - manager.registerTopic('notification', NotificationTranslationTopic); - manager.disableTopic('notification'); - expect(manager.getTopic('notification')).toBeUndefined(); - }); - - it('registers and removes translators', () => { - const translator = vi.fn(); - const manager = new TranslationBuilder(mockI18Next); - manager.registerTopic('notification', NotificationTranslationTopic); - manager.registerTranslators('notification', { test: translator }); - const notificationBuilder = manager.getTopic('notification'); - expect(notificationBuilder['translators'].get('test')).toEqual(translator); - manager.removeTranslators('notification', ['test']); - expect(notificationBuilder['translators'].get('test')).toBeUndefined(); - }); - - it('stores translators for non-existent topic in a buffer', () => { - const manager = new TranslationBuilder(mockI18Next); - const translators = { custom1: vi.fn(), custom2: vi.fn() }; - manager.registerTranslators('notification', translators); - expect(manager['topics'].size).toEqual(0); - expect(manager['translatorRegistrationsBuffer'].notification).toEqual(translators); - }); - - it('removes translators from buffer on translation removal', () => { - const manager = new TranslationBuilder(mockI18Next); - const translators = { custom1: vi.fn(), custom2: vi.fn() }; - manager.registerTranslators('notification', translators); - manager.removeTranslators('notification', ['custom1']); - expect( - Object.keys(manager['translatorRegistrationsBuffer'].notification).length, - ).toBe(1); - expect(manager['translatorRegistrationsBuffer'].notification.custom2).toBeDefined(); - }); - - it('flushes the buffered translators on topic registration', () => { - const manager = new TranslationBuilder(mockI18Next); - const translators = { custom1: vi.fn(), custom2: vi.fn() }; - manager.registerTranslators('notification', translators); - manager.registerTopic('notification', NotificationTranslationTopic); - expect(manager['translatorRegistrationsBuffer'].notification).toBeUndefined(); - }); - - it("overrides the topic's translators with buffered translators", () => { - const manager = new TranslationBuilder(mockI18Next); - const translator = vi.fn().mockImplementation(() => {}); - const translatorName = 'api:attachment:upload:failed'; - const translators = { [translatorName]: translator }; - manager.registerTranslators('notification', translators); - manager.registerTopic('notification', NotificationTranslationTopic); - manager - .getTopic('notification')! - .translate('key', 'value', { notification: { type: translatorName } }); - - expect(translator).toHaveBeenCalledTimes(1); - }); - - it('reuses the already registered topic on repeated registerTopic calls', () => { - const manager = new TranslationBuilder(mockI18Next); - class Topic { - id: string; - constructor() { - this.id = Math.random().toString(); - } - } - manager.registerTopic('custom', Topic as unknown as TranslationTopicConstructor); - const firstRegistrationId = (manager.getTopic('custom') as unknown as Topic).id; - manager.registerTopic('custom', Topic as unknown as TranslationTopicConstructor); - const secondRegistrationId = (manager.getTopic('custom') as unknown as Topic).id; - expect(firstRegistrationId).toBe(secondRegistrationId); - }); -}); diff --git a/src/i18n/types.ts b/src/i18n/types.ts index b982377a47..cf2fa33746 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -34,8 +34,12 @@ export type TranslationCatalog = GeneratedCatalog & * `timestamp.*` and `duration.*` are matched by prefix inside core. This adds the two prefixes specific * to this SDK: the post-processor directives, and the language names, which are looked up by a runtime * language code and so have no call site to carry a default. + * + * Exported so `Streami18n.ts` can parameterize the class from the same declaration `StreamTFunction` + * uses. It was declared twice; a third prefix added to one copy would have made the exported `t` type + * and the class instance's own `t` disagree about the same call. */ -type BundledKey = `translationBuilderTopic.${string}` | `language.${string}`; +export type BundledKey = `translationBuilderTopic.${string}` | `language.${string}`; export type PluralTranslationKey = PluralTranslationKeyOf; export type TranslationKey = TranslationKeyOf; From 141cdc9198b0e4f68f956f9b5774db8b8e30291a Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 17:35:21 +0200 Subject: [PATCH 08/10] docs(i18n): cover the two accessors removed from Streami18n `getTranslations()` and `getAvailableLanguages()` were public in v14 and are gone in v15, having left `stream-chat`'s surface entirely. Neither had a consumer here, but both were reachable by integrators, so the migration guide now shows the replacement for each -- render the key, and `registeredLanguages` respectively -- along with the six members that became private and the `ReadonlySet` change. The one test that used `getTranslations()` now asserts by rendering instead. It was reading the resource store to prove an app's own key had been written down; whether the key resolves is the thing worth asserting, and it holds without reaching past the public API. --- ai-docs/i18n-v15-migration.md | 33 +++++++++++++++++++++++++++ src/i18n/__tests__/Streami18n.test.ts | 13 ++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index f3d7cdaf9a..3848dbc4da 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -280,6 +280,39 @@ const { t } = i18n.state.getLatestValue(); The returned translator was removed deliberately: it went stale on the next language change, so holding onto it was always a latent bug. +### `getTranslations()` and `getAvailableLanguages()` are gone + +Both were public in v14, both leaked internal bookkeeping, and neither had a consumer in this SDK. + +```ts +// v14 — reading the raw i18next resource map +i18n.getTranslations().en.translation['some.key']; + +// v15 — render the key instead; that is the thing you actually wanted to know +i18n.t('some.key'); +``` + +`getTranslations()` never held this SDK's English copy in the first place: prose renders from the +inline `defaultValue` at each call site, so the resource map only ever contained the bundled formatter +expressions plus whatever had been registered. + +```ts +// v14 — "available" included languages created only to carry the bundled defaults, +// so a language nobody registered showed up here +i18n.getAvailableLanguages().includes('de'); + +// v15 +i18n.registeredLanguages.has('de'); +``` + +`registeredLanguages` is now a `ReadonlySet`. Reading it is unchanged; `.add()` no longer +compiles — use `registerTranslation()`, since adding to the set would claim a language is registered +with no dictionary behind it. + +Also now internal, none of them documented before: `translations`, `dayjsLocales`, +`isCustomDateTimeParser`, `localeExists()`, `addOrUpdateLocale()`, `validateCurrentLanguage()`. To +register a dayjs locale directly, `stream-chat/i18n` exports `addOrUpdateDayjsLocale()`. + ### You no longer need `i18next` or `dayjs` in your own dependencies `stream-chat` depends on both, so they arrive transitively. If you declared them only for this SDK, diff --git a/src/i18n/__tests__/Streami18n.test.ts b/src/i18n/__tests__/Streami18n.test.ts index c841658539..a72042d1ef 100644 --- a/src/i18n/__tests__/Streami18n.test.ts +++ b/src/i18n/__tests__/Streami18n.test.ts @@ -3,7 +3,7 @@ import { Streami18n } from '../Streami18n'; import type { Streami18nOptions } from '../Streami18n'; import type { LooseTranslationDictionary, TranslationDictionary } from '../types'; import type { TranslationCatalog } from '../keys'; -import { getDateString } from '../utils'; +import { asDynamicKey, getDateString } from '../utils'; import { runtimeDefaults } from '../runtimeDefaults'; import { NotificationTranslationTopic } from '../TranslationBuilder'; import type { TranslationTopicConstructor } from '../TranslationBuilder'; @@ -201,7 +201,7 @@ describe('Streami18n - dictionary key types', () => { // The params are strict, so the default call shape — an inline object literal — is checked. // A typo here used to compile and then silently never apply at runtime. - it('rejects an unknown key passed inline, and still accepts a loose dictionary', () => { + it('rejects an unknown key passed inline, and still accepts a loose dictionary', async () => { const i18n = new Streami18n({ logger: () => null }); i18n.registerTranslation('en', { @@ -226,10 +226,11 @@ describe('Streami18n - dictionary key types', () => { i18n.registerTranslation('en', withOwnKeys); new Streami18n({ logger: () => null, translationsForLanguage: withOwnKeys }); - expect(i18n.getTranslations().en.translation).toHaveProperty( - 'myApp.somethingElse', - 'Hello', - ); + // Asserted by rendering rather than by reading the resource store, which is no longer exposed: + // whether the app's own key resolves is the thing that matters, and `getTranslations()` only ever + // confirmed it had been written down. + const { t } = await i18n.init(); + expect(t(asDynamicKey('myApp.somethingElse'))).toBe('Hello'); }); // Compile-time contract, asserted here so it cannot regress silently. TranslationDictionary From d040a38b90a431e582f419f46fe2eac69a85451d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 19 Aug 2026 11:23:16 +0200 Subject: [PATCH 09/10] refactor(poll): adopt the renamed poll validation code family Follows the `stream-chat` rename: `POLL_VALIDATION_CODE` and friends are now `POLL_COMPOSER_VALIDATION_CODE` / `PollComposerValidationCode`, matching the module they live in and the `PollComposer*` prefix already used by `PollComposerState` and `PollComposerOption`. Mechanical -- the identifier values are unchanged, so the `t()` keys these components map them to are untouched and no copy moves. --- .../MultipleAnswersField.tsx | 18 ++++++++++-------- .../Poll/PollCreationDialog/NameField.tsx | 10 ++++++---- .../Poll/PollCreationDialog/OptionFieldSet.tsx | 12 +++++++----- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx index d2bef6b58d..5574ced3e7 100644 --- a/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx +++ b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx @@ -1,5 +1,5 @@ -import { POLL_VALIDATION_CODE, pollValidationError } from 'stream-chat'; -import type { PollValidationCode } from 'stream-chat'; +import { POLL_COMPOSER_VALIDATION_CODE, pollComposerValidationError } from 'stream-chat'; +import type { PollComposerValidationCode } from 'stream-chat'; import React, { useMemo, useRef, useState } from 'react'; import { NumericInput } from '../../Form/NumericInput'; import { SwitchField, SwitchFieldLabel } from '../../Form/SwitchField'; @@ -24,17 +24,19 @@ export const MultipleAnswersField = () => { const [voteLimitEnabled, setVoteLimitEnabled] = useState(false); const maxVotesInputRef = useRef(null); - const knownValidationErrors = useMemo>>( + const knownValidationErrors = useMemo< + Partial> + >( () => ({ - [POLL_VALIDATION_CODE.maxVotesNotNumeric]: t( + [POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric]: t( 'poll.multipleAnswersField.onlyNumbersAllowed.label', 'Only numbers are allowed', ), - [POLL_VALIDATION_CODE.maxVotesOutOfRange]: t( + [POLL_COMPOSER_VALIDATION_CODE.maxVotesOutOfRange]: t( 'poll.multipleAnswersField.typeNumber210.label', 'Type a number from 2 to 10', ), - [POLL_VALIDATION_CODE.maxVotesUniqueVoteEnforced]: t( + [POLL_COMPOSER_VALIDATION_CODE.maxVotesUniqueVoteEnforced]: t( 'poll.multipleAnswersField.enforceUniqueVoteEnabled.label', 'Enforce unique vote is enabled', ), @@ -111,8 +113,8 @@ export const MultipleAnswersField = () => { ? { // Injected field errors take the same shape core produces, so the render // path is identical whether the error came from here or from the composer. - max_votes_allowed: pollValidationError( - POLL_VALIDATION_CODE.maxVotesNotNumeric, + max_votes_allowed: pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric, ), } : undefined; diff --git a/src/components/Poll/PollCreationDialog/NameField.tsx b/src/components/Poll/PollCreationDialog/NameField.tsx index 40256b7c6a..43f82f0b12 100644 --- a/src/components/Poll/PollCreationDialog/NameField.tsx +++ b/src/components/Poll/PollCreationDialog/NameField.tsx @@ -1,5 +1,5 @@ -import { POLL_VALIDATION_CODE } from 'stream-chat'; -import type { PollValidationCode } from 'stream-chat'; +import { POLL_COMPOSER_VALIDATION_CODE } from 'stream-chat'; +import type { PollComposerValidationCode } from 'stream-chat'; import React, { useMemo } from 'react'; import { TextInput } from '../../Form'; import { useTranslationContext } from '../../../context'; @@ -18,9 +18,11 @@ export const NameField = () => { const { error, name } = useStateStore(pollComposer.state, pollComposerStateSelector); // Keyed on the stable validation code rather than on the English sentence `stream-chat` produced. // Matching on prose meant a copy edit in the LLC silently stopped the translation from applying. - const knownValidationErrors = useMemo>>( + const knownValidationErrors = useMemo< + Partial> + >( () => ({ - [POLL_VALIDATION_CODE.nameRequired]: t( + [POLL_COMPOSER_VALIDATION_CODE.nameRequired]: t( 'poll.nameField.questionRequired.label', 'Question is required', ), diff --git a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx index c841ca11dd..314438e7ee 100644 --- a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx +++ b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx @@ -1,5 +1,5 @@ -import { POLL_VALIDATION_CODE } from 'stream-chat'; -import type { PollValidationCode } from 'stream-chat'; +import { POLL_COMPOSER_VALIDATION_CODE } from 'stream-chat'; +import type { PollComposerValidationCode } from 'stream-chat'; import clsx from 'clsx'; import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { TextInput } from '../../Form/TextInput'; @@ -45,13 +45,15 @@ export const OptionFieldSet = () => { const pendingFocusIndexRef = useRef(null); const [activeOptionId, setActiveOptionId] = useState(null); - const knownValidationErrors = useMemo>>( + const knownValidationErrors = useMemo< + Partial> + >( () => ({ - [POLL_VALIDATION_CODE.optionDuplicate]: t( + [POLL_COMPOSER_VALIDATION_CODE.optionDuplicate]: t( 'poll.suggestPollOption.optionAlreadyExists.label', 'Option already exists', ), - [POLL_VALIDATION_CODE.optionEmpty]: t( + [POLL_COMPOSER_VALIDATION_CODE.optionEmpty]: t( 'poll.optionFieldSet.optionEmpty.label', 'Option is empty', ), From 23743bf1b1db0db9929f4cccc16801c3ebed9e49 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 19 Aug 2026 11:59:46 +0200 Subject: [PATCH 10/10] refactor(i18n)!: move the translation wiring out of useChat Review feedback: i18n did not belong in `useChat`, which was doing five unrelated jobs -- user-agent stamping, subsystem subscriptions, mutes, i18n and latest-message bookkeeping -- and only held the translators to hand them straight to a provider. It moves to `useStreami18n`, mirroring the hook `stream-chat-react-native` already has: adopt-or-create the instance, `init()` in an effect, subscribe to its store with a module-scope selector. Keeping the two SDKs the same shape here is the point -- React burying this inside `useChat` was exactly the divergence that moving the runtime into `stream-chat` set out to remove. `TranslationProvider` stays dumb, so `value` keeps working for tests and for anyone composing it by hand. Three things fall out of it: - The blanket `eslint-disable react-hooks/exhaustive-deps` is gone. `userLanguage` now tracks `client.user.language` reactively, so a user who connects *after* `` mounts gets their language applied; it used to be captured once. The one disable left is narrow and documented: the instance memo must not depend on `client`, because re-running it would build a new `Streami18n` and discard every registered dictionary. - `if (!translators.t) return null` is deleted. `t` is seeded with the default translator and every store emission carries one, so it never fired -- a leftover from when `t` arrived asynchronously. - Instance recognition adopts RN's brand check. Truthiness was already cross-copy safe but accepted any truthy value, which then threw at render; the brand check warns and falls back instead. Five `Message` re-render assertions moved from `toHaveBeenCalledTimes(1)` to `toHaveBeenCalled()`. Both before and after this change mount settles at two renders -- measured -- but the old code delivered the post-`init()` translator after the test's await resolved and this delivers it during. Same work, one tick earlier. The assertions those tests exist for, the re-render on a prop change, are unchanged. BREAKING CHANGE: `useChat` no longer returns `translators`, and no longer accepts `defaultLanguage` or `i18nInstance` -- all three moved to `useStreami18n`. ``'s props are unchanged; it wires both hooks internally. See "useChat no longer returns translators" in `ai-docs/i18n-v15-migration.md`. --- ai-docs/i18n-v15-migration.md | 26 ++++ src/components/Chat/Chat.tsx | 10 +- src/components/Chat/hooks/useChat.ts | 56 +-------- .../Message/__tests__/Message.test.tsx | 25 +++- src/i18n/index.ts | 1 + src/i18n/useStreami18n.ts | 112 ++++++++++++++++++ 6 files changed, 163 insertions(+), 67 deletions(-) create mode 100644 src/i18n/useStreami18n.ts diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index 3848dbc4da..5a1ecfda42 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -313,6 +313,32 @@ Also now internal, none of them documented before: `translations`, `dayjsLocales `isCustomDateTimeParser`, `localeExists()`, `addOrUpdateLocale()`, `validateCurrentLanguage()`. To register a dayjs locale directly, `stream-chat/i18n` exports `addOrUpdateDayjsLocale()`. +### `useChat` no longer returns `translators` + +The i18n wiring moved out of `useChat` into a dedicated `useStreami18n`, matching the hook +`stream-chat-react-native` already had. `useChat` was doing five unrelated jobs — user-agent stamping, +subsystem subscriptions, mutes, i18n and latest-message bookkeeping — and only held the translators to +hand them straight to a provider. + +`useChat` is exported, so if you called it directly: + +```ts +// v14 +const { translators } = useChat({ client, defaultLanguage, i18nInstance }); + +// v15 +const { getAppSettings, latestMessageDatesByChannels, mutes } = useChat({ client }); +const translators = useStreami18n({ client, defaultLanguage, i18nInstance }); +``` + +`useChat` no longer takes `defaultLanguage` or `i18nInstance` either — both moved to `useStreami18n`. +Nothing changes for ``: its props are the same and it wires both hooks internally. + +One behavioural improvement comes with it. `userLanguage` now tracks `client.user.language` reactively, +so a user who connects _after_ `` mounts gets their language applied; previously it was captured +once and a late connection kept the browser or default language. Passing a value that is not a +`Streami18n` now warns and falls back to a default instance rather than throwing at render. + ### You no longer need `i18next` or `dayjs` in your own dependencies `stream-chat` depends on both, so they arrive transitively. If you declared them only for this SDK, diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 02bdbae40f..eb3eb7f808 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -20,6 +20,7 @@ import { type NotificationDisplayFilter, } from '../Notifications'; import { useChat } from './hooks/useChat'; +import { useStreami18n } from '../../i18n/useStreami18n'; import { useReportLostConnectionSystemNotification } from './hooks/useReportLostConnectionSystemNotification'; import { useCreateChatContext } from './hooks/useCreateChatContext'; import type { CustomClasses } from '../../context/ChatContext'; @@ -127,11 +128,8 @@ export const Chat = (props: PropsWithChildren) => { useImageFlagEmojisOnWindows = false, } = props; - const { getAppSettings, latestMessageDatesByChannels, mutes, translators } = useChat({ - client, - defaultLanguage, - i18nInstance, - }); + const { getAppSettings, latestMessageDatesByChannels, mutes } = useChat({ client }); + const translators = useStreami18n({ client, defaultLanguage, i18nInstance }); const searchController = useMemo( () => @@ -160,8 +158,6 @@ export const Chat = (props: PropsWithChildren) => { }); const { NotificationAnnouncer = DefaultNotificationAnnouncer } = useComponentContext(); - if (!translators.t) return null; - return ( diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index 08b9edba77..812e5eb7b7 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -1,12 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import type { TranslationContextValue } from '../../../context/TranslationContext'; -import { - defaultDateTimeParser, - defaultTranslatorFunction, - Streami18n, -} from '../../../i18n'; - import type { EventPayload, OwnUserResponse, @@ -16,21 +9,9 @@ import type { export type UseChatParams = { client: StreamChat; - defaultLanguage?: string; - i18nInstance?: Streami18n; }; -export const useChat = ({ - client, - defaultLanguage = 'en', - i18nInstance, -}: UseChatParams) => { - const [translators, setTranslators] = useState({ - t: defaultTranslatorFunction, - tDateTimeParser: defaultDateTimeParser, - userLanguage: 'en', - }); - +export const useChat = ({ client }: UseChatParams) => { const [mutes, setMutes] = useState>([]); const [latestMessageDatesByChannels, setLatestMessageDatesByChannels] = useState({}); @@ -83,40 +64,6 @@ export const useChat = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [clientMutes?.length]); - useEffect(() => { - let userLanguage = client.user?.language; - - if (!userLanguage) { - const browserLanguage = window.navigator.language.slice(0, 2); // just get language code, not country-specific version - userLanguage = i18nInstance?.registeredLanguages.has(browserLanguage) - ? browserLanguage - : defaultLanguage; - } - - // Truthiness, deliberately -- not `instanceof`. An instance coming from a second copy of the - // package would fail an identity check and be silently replaced by a fresh English default, - // discarding every dictionary and formatter the integrator registered. - const streami18n = i18nInstance || new Streami18n({ language: userLanguage }); - - // One subscription replaces the old `registerSetLanguageCallback`, which a second caller would - // clobber for everyone. `subscribe` fires synchronously with the current value, so there is no - // ordering to get right: whether this runs before or after `init()`, the live `t` arrives. - const unsubscribe = streami18n.state.subscribeWithSelector( - ({ t, tDateTimeParser }) => ({ t, tDateTimeParser }), - ({ t, tDateTimeParser }) => - setTranslators({ - t, - tDateTimeParser, - userLanguage: userLanguage || defaultLanguage, - }), - ); - - streami18n.init(); - - return unsubscribe; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [i18nInstance]); - useEffect(() => { setLatestMessageDatesByChannels({}); }, [client.user?.id]); @@ -125,6 +72,5 @@ export const useChat = ({ getAppSettings, latestMessageDatesByChannels, mutes, - translators, }; }; diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index 4c5fbf8e03..01ecb4ff0f 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -884,7 +884,10 @@ describe(' component', () => { }); const updatedMessage = generateMessage({ text: 'Hello*', user: alice }); - expect(UIMock).toHaveBeenCalledTimes(1); + // Not a count: mount renders more than once by design, because the translator arrives through + // `Streami18n`'s store and the context updates when `init()` settles. What this test measures is + // the re-render below, so assert it mounted at all and then count from a clean slate. + expect(UIMock).toHaveBeenCalled(); UIMock.mockClear(); await renderComponent({ @@ -905,7 +908,10 @@ describe(' component', () => { message, }); - expect(UIMock).toHaveBeenCalledTimes(1); + // Not a count: mount renders more than once by design, because the translator arrives through + // `Streami18n`'s store and the context updates when `init()` settles. What this test measures is + // the re-render below, so assert it mounted at all and then count from a clean slate. + expect(UIMock).toHaveBeenCalled(); UIMock.mockClear(); await renderComponent({ @@ -928,7 +934,10 @@ describe(' component', () => { props: { groupStyles: ['bottom'] }, }); - expect(UIMock).toHaveBeenCalledTimes(1); + // Not a count: mount renders more than once by design, because the translator arrives through + // `Streami18n`'s store and the context updates when `init()` settles. What this test measures is + // the re-render below, so assert it mounted at all and then count from a clean slate. + expect(UIMock).toHaveBeenCalled(); UIMock.mockClear(); await renderComponent({ @@ -951,7 +960,10 @@ describe(' component', () => { props: { lastReceivedId: 'last-received-id-1' }, }); - expect(UIMock).toHaveBeenCalledTimes(1); + // Not a count: mount renders more than once by design, because the translator arrives through + // `Streami18n`'s store and the context updates when `init()` settles. What this test measures is + // the re-render below, so assert it mounted at all and then count from a clean slate. + expect(UIMock).toHaveBeenCalled(); UIMock.mockClear(); await renderComponent({ @@ -981,7 +993,10 @@ describe(' component', () => { }, }); - expect(UIMock).toHaveBeenCalledTimes(1); + // Not a count: mount renders more than once by design, because the translator arrives through + // `Streami18n`'s store and the context updates when `init()` settles. What this test measures is + // the re-render below, so assert it mounted at all and then count from a clean slate. + expect(UIMock).toHaveBeenCalled(); UIMock.mockClear(); await renderComponent({ diff --git a/src/i18n/index.ts b/src/i18n/index.ts index a030540da1..06a3fe63d2 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,4 +1,5 @@ export * from './Streami18n'; +export * from './useStreami18n'; export * from './TranslationBuilder'; export { asDynamicKey, diff --git a/src/i18n/useStreami18n.ts b/src/i18n/useStreami18n.ts new file mode 100644 index 0000000000..6c59c925f0 --- /dev/null +++ b/src/i18n/useStreami18n.ts @@ -0,0 +1,112 @@ +import { useEffect, useMemo } from 'react'; + +import { Streami18n } from './Streami18n'; +import type { BundledKey, TranslationCatalog } from './types'; +import { useStateStore } from '../store'; + +import type { StreamChat } from 'stream-chat'; +import type { Streami18nState } from 'stream-chat/i18n'; +import type { TranslationContextValue } from '../context/TranslationContext'; + +/** + * This SDK's instantiation of core's state shape. + * + * Spelled out rather than left to `Streami18nState`'s defaults: those default the catalog to + * `AnyTranslationCatalog`, and `t` is contravariant in its options, so the concrete store is not + * assignable to the default-parameterized one. + */ +type SDKStreami18nState = Streami18nState; + +/** + * Whether a value is a `Streami18n` from any copy of the package. + * + * `instanceof` is deliberately avoided: an integrator's app can resolve a second physical + * `stream-chat`, and an identity check would then reject the instance they configured and silently + * replace it with a fresh English default — every registered dictionary, formatter and language gone, + * with no error anywhere. `Symbol.for` returns the same symbol in every copy, so a branded static + * survives the boundary. + * + * Compared against `Streami18n.brand` rather than tested for truthiness, because `brand` is a common + * static name and accepting any truthy one would let an unrelated object through to `init()` and throw + * at render instead of taking the warn-and-fall-back path below. + */ +const isStreami18n = (value: unknown): value is Streami18n => + typeof value === 'object' && + value !== null && + (value.constructor as typeof Streami18n | undefined)?.brand === Streami18n.brand; + +/** Module scope, so the subscription is not torn down and rebuilt on every render. */ +const selector = ({ t, tDateTimeParser }: SDKStreami18nState) => ({ t, tDateTimeParser }); + +export type UseStreami18nParams = { + client: StreamChat; + /** Language to fall back to when neither the user nor the browser names a registered one. */ + defaultLanguage?: string; + /** An instance the integrator configured. One is created when absent. */ + i18nInstance?: Streami18n; +}; + +/** + * Resolves the translation context value from a `Streami18n` instance. + * + * Mirrors `stream-chat-react-native`'s `useStreami18n`: adopt-or-create the instance, initialize it, + * and subscribe to its store. Keeping the two SDKs the same shape here is the point — this logic used + * to sit inside `useChat` alongside user-agent stamping, mutes and subsystem subscriptions, which is + * exactly the kind of divergence moving the runtime into `stream-chat` was meant to remove. + * + * Reactivity is the instance's `StateStore`. `subscribe` fires synchronously with the current value, so + * there is no ordering to get right: whether this runs before or after `init()`, the live `t` arrives. + */ +export const useStreami18n = ({ + client, + defaultLanguage = 'en', + i18nInstance, +}: UseStreami18nParams): TranslationContextValue => { + const streami18n = useMemo(() => { + if (!i18nInstance) { + // The user's language at creation time, which is what the instance should start in. + return new Streami18n({ language: client.user?.language ?? defaultLanguage }); + } + if (isStreami18n(i18nInstance)) return i18nInstance; + // Loud, because the alternative is rendering English and looking fine. + console.warn( + 'stream-chat-react: the value passed as `i18nInstance` is not a Streami18n, so it was ignored ' + + 'and a default English instance is being used. If you did construct one, check for a ' + + 'duplicate `stream-chat` in node_modules.', + ); + return new Streami18n({ language: client.user?.language ?? defaultLanguage }); + // `client` is read but deliberately not a dependency: re-running this would build a *new* + // instance and discard every dictionary, formatter and locale registered on the old one. The + // language is a starting value, not a binding — `userLanguage` below tracks it reactively. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [i18nInstance]); + + /** + * The language whose translations the UI should show. + * + * The browser's language only wins if the instance actually has a dictionary for it; otherwise + * picking it would render the SDK's English copy while claiming a different language, and + * `MessageTranslationIndicator` would then look for the wrong `message.i18n` entry. + */ + const userLanguage = useMemo(() => { + const fromUser = client.user?.language; + if (fromUser) return fromUser; + + // Language code only, not the country-specific variant. + const browserLanguage = window.navigator.language.slice(0, 2); + return streami18n.registeredLanguages.has(browserLanguage) + ? browserLanguage + : defaultLanguage; + }, [client.user?.language, defaultLanguage, streami18n]); + + useEffect(() => { + streami18n.init(); + }, [streami18n]); + + const { t, tDateTimeParser } = useStateStore(streami18n.state, selector); + + return useMemo( + () => ({ t, tDateTimeParser, userLanguage }), + [t, tDateTimeParser, userLanguage], + ); +};