diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 92ec10160..80482282b 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/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index 301240b0f..5a1ecfda4 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 + 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 @@ -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(...)` | **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 @@ -228,6 +234,122 @@ 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. + +### `getTranslators()` is now `init()` + +Same return value; the old name was a getter that initialized, which is what made it worth renaming. + +```ts +// v14 +const { t, tDateTimeParser } = await i18n.getTranslators(); + +// v15 +const { t, tDateTimeParser } = await i18n.init(); +``` + +`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 + +`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. + +### `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()`. + +### `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, +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 +375,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 +428,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. diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index c09064bc5..4cadb1aff 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -16,7 +16,7 @@ "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.2", + "stream-chat": "10.0.0-rc.5", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/package.json b/examples/vite/package.json index 3445639be..afd74e868 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -17,7 +17,7 @@ "modern-normalize": "^3.0.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.2", + "stream-chat": "10.0.0-rc.5", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/package.json b/package.json index 5a401a92f..04919beae 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.5" }, "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", @@ -196,13 +194,12 @@ "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", "sass": "^1.100.0", "semantic-release": "^25.0.3", - "stream-chat": "10.0.0-rc.2", + "stream-chat": "10.0.0-rc.5", "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 f25c8f72b..d3564e4ec 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 d3ab917df..000000000 --- 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/ChannelListItem/utils.tsx b/src/components/ChannelListItem/utils.tsx index bf812686b..8c733b3e4 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, diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 02bdbae40..eb3eb7f80 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/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index 459bc7ccd..9e50d11ef 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 ed9f0c908..812e5eb7b 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,31 +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; - } - - const streami18n = i18nInstance || new Streami18n({ language: userLanguage }); - - streami18n.registerSetLanguageCallback((t) => - setTranslators((prevTranslator) => ({ ...prevTranslator, t })), - ); - - streami18n.getTranslators().then((translator) => { - setTranslators({ - ...translator, - userLanguage: userLanguage || defaultLanguage, - }); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [i18nInstance]); - useEffect(() => { setLatestMessageDatesByChannels({}); }, [client.user?.id]); @@ -116,6 +72,5 @@ export const useChat = ({ getAppSettings, latestMessageDatesByChannels, mutes, - translators, }; }; diff --git a/src/components/Message/MessageTranslationIndicator.tsx b/src/components/Message/MessageTranslationIndicator.tsx index ebb62f8ae..810a9823f 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,17 @@ 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()`. + // + // 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__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index 4c5fbf8e0..01ecb4ff0 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/components/Message/__tests__/MessageTimestamp.test.tsx b/src/components/Message/__tests__/MessageTimestamp.test.tsx index 891d7949a..ecbaeb253 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/Message/__tests__/MessageTranslationIndicator.test.tsx b/src/components/Message/__tests__/MessageTranslationIndicator.test.tsx new file mode 100644 index 000000000..81ff5c75c --- /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/components/Poll/PollCreationDialog/MultipleAnswersField.tsx b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx index d24e30d1c..5574ced3e 100644 --- a/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx +++ b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx @@ -1,3 +1,5 @@ +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'; @@ -22,22 +24,28 @@ export const MultipleAnswersField = () => { const [voteLimitEnabled, setVoteLimitEnabled] = useState(false); const maxVotesInputRef = useRef(null); - const knownValidationErrors = useMemo>( + const knownValidationErrors = useMemo< + Partial> + >( () => ({ - 'Enforce unique vote is enabled': t( - 'poll.multipleAnswersField.enforceUniqueVoteEnabled.label', - 'Enforce unique vote is enabled', + [POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric]: t( + 'poll.multipleAnswersField.onlyNumbersAllowed.label', + 'Only numbers are allowed', ), - 'Type a number from 2 to 10': t( + [POLL_COMPOSER_VALIDATION_CODE.maxVotesOutOfRange]: t( 'poll.multipleAnswersField.typeNumber210.label', 'Type a number from 2 to 10', ), + [POLL_COMPOSER_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 +111,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: pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric, ), } : undefined; diff --git a/src/components/Poll/PollCreationDialog/NameField.tsx b/src/components/Poll/PollCreationDialog/NameField.tsx index 4abc88aa0..43f82f0b1 100644 --- a/src/components/Poll/PollCreationDialog/NameField.tsx +++ b/src/components/Poll/PollCreationDialog/NameField.tsx @@ -1,3 +1,5 @@ +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'; @@ -14,9 +16,13 @@ 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< + Partial> + >( () => ({ - 'Question is required': t( + [POLL_COMPOSER_VALIDATION_CODE.nameRequired]: t( 'poll.nameField.questionRequired.label', 'Question is required', ), @@ -35,7 +41,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 f31b23f44..314438e7e 100644 --- a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx +++ b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx @@ -1,3 +1,5 @@ +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'; @@ -43,13 +45,18 @@ export const OptionFieldSet = () => { const pendingFocusIndexRef = useRef(null); const [activeOptionId, setActiveOptionId] = useState(null); - const knownValidationErrors = useMemo>( + const knownValidationErrors = useMemo< + Partial> + >( () => ({ - 'Option already exists': t( + [POLL_COMPOSER_VALIDATION_CODE.optionDuplicate]: t( 'poll.suggestPollOption.optionAlreadyExists.label', 'Option already exists', ), - 'Option is empty': t('poll.optionFieldSet.optionEmpty.label', 'Option is empty'), + [POLL_COMPOSER_VALIDATION_CODE.optionEmpty]: t( + 'poll.optionFieldSet.optionEmpty.label', + 'Option is empty', + ), }), [t], ); @@ -239,8 +246,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 bd4b38be3..4e19501c6 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 218d18031..9b3c49d15 100644 --- a/src/i18n/Streami18n.ts +++ b/src/i18n/Streami18n.ts @@ -1,579 +1,73 @@ -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 'dayjs/locale/en.js'; - -const defaultNS = 'translation'; -const defaultLng = 'en'; - -type CalendarLocaleConfig = { - lastDay: string; - lastWeek: string; - nextDay: string; - nextWeek: string; - sameDay: string; - sameElse: string; -}; +import type { BundledKey, TranslationCatalog } from './types'; /** - * A dayjs locale config, as accepted by `dayjsLocaleConfigForLanguage` and by - * `registerTranslation`'s third argument. + * Options for {@link Streami18n}. * - * `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. + * `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 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, - }, -}; +export type Streami18nOptions = CoreStreami18nOptions; /** - * Wraps an integrator's `parseMissingKeyHandler` so it only sees genuinely missing translations. + * Wrapper around [i18next](https://www.i18next.com/) for this SDK's translations. Pass an instance to + * `` to control language and copy. * - * 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. - */ -const guardMissingKeyHandler = - (handler: (key: string, defaultValue?: string) => string) => - (key: string, defaultValue?: string) => { - if (typeof defaultValue === 'string') return defaultValue; - return handler(key, defaultValue); - }; - -/** - * 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. - * - * 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 * - * ``` + * ```ts * const i18n = new Streami18n({ - * translationsForLanguage: { - * 'emptyState.indicator.noConversationsYet.label': 'Nothing here yet', - * } + * 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 - * - * 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. + * compile error. A partial dictionary is safe: unsupplied keys render the English copy that ships inline + * with each component, never a raw dotted path. * - * 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 - */ +export class Streami18n extends CoreStreami18n { constructor(options: Streami18nOptions = {}) { - const finalOptions = { - ...defaultStreami18nOptions, + 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); - }; - } - - /** - * 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; - } - } - - registerSetLanguageCallback(callback: (t: StreamTFunction) => void) { - this.setLanguageCallback = callback; + // 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, + }, + }); } } diff --git a/src/i18n/TranslationBuilder/TranslationBuilder.ts b/src/i18n/TranslationBuilder/TranslationBuilder.ts deleted file mode 100644 index 6923c244b..000000000 --- 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 972919e2c..67bcd727d 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 4d1ed1849..a223905e5 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 c06a7ff77..79bca1718 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 5883d9106..c0c07f84e 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,11 +56,11 @@ 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, - ) as unknown as i18n['t'], + ) as unknown as I18nInstance['t'], }); const builder = new NotificationTranslationTopic({ i18next, @@ -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', () => { - const i18next = fromPartial({ + 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 I18nInstance['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', @@ -160,11 +142,11 @@ 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}`, - ) as unknown as i18n['t'], + ) as unknown as I18nInstance['t'], }); const builder = new NotificationTranslationTopic({ i18next }); @@ -180,13 +162,13 @@ 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' ? `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 339061461..a72042d1e 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 { asDynamicKey, 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.getTranslators(); - const text = nanoid(); - - expect(_t(text)).toBe(text); - }); - - it('should provide moment with default en locale', async () => { - const { tDateTimeParser } = await streami18n.getTranslators(); - 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.getTranslators(); - - 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(); - - 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.getTranslators(); - 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.getTranslators(); - - // 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.getTranslators(); - - // 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.getTranslators(); - - 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.getTranslators(); - 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 missing = nanoid(); - expect(_t(missing)).toBe(missing); - }); - - it('should provide dayjs with `nl` locale', async () => { - const { tDateTimeParser } = await streami18n.getTranslators(); - 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.getTranslators(); - 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(); - 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.getTranslators(); - 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.getTranslators(); - - expect(_t(textKey1)).toBe(textValue1); - - expect(_t(textKey2)).toBe(textValue2); - }); - - it('should provide moment with default `en` locale', async () => { - const { tDateTimeParser } = await streami18n.getTranslators(); - 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.getTranslators(); - 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.getTranslators(); - expect(beforeT('messageList.empty')).toBe('messageList.empty'); - - await streami18n.setLanguage('fr'); - - const { t: _t } = await streami18n.getTranslators(); - 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.getTranslators(); - - 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); @@ -508,11 +89,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 +104,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 +113,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 +124,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 +133,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 +144,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 +173,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', @@ -620,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', { @@ -645,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 @@ -668,7 +250,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', @@ -684,166 +266,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.getTranslators(); - - 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.getTranslators(); - - 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.getTranslators(); - - 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.getTranslators(); - - await i18n.setLanguage('de' as 'en'); - if (!afterInit) await i18n.getTranslators(); - - 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.getTranslators(); - 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.getTranslators(); - - 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.getTranslators(); - - 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.getTranslators(); - - 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.getTranslators(); - - 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 @@ -887,7 +309,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 +333,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__/TranslationBuilder.test.ts b/src/i18n/__tests__/TranslationBuilder.test.ts deleted file mode 100644 index fffad3739..000000000 --- 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 { i18n } from 'i18next'; - -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/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json new file mode 100644 index 000000000..be04a7336 --- /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; relativeCompactWeekRounding: ceil) }}", + "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; relativeCompactWeekRounding: ceil) }}", + "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 000000000..c25afb6cf --- /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__/relativeCompactThresholds.test.ts b/src/i18n/__tests__/relativeCompactThresholds.test.ts new file mode 100644 index 000000000..33c78e458 --- /dev/null +++ b/src/i18n/__tests__/relativeCompactThresholds.test.ts @@ -0,0 +1,44 @@ +import { Streami18n } from '../Streami18n'; + +/** + * The two bundled `relativeCompact` keys must render the labels this SDK rendered before its + * formatter moved into `stream-chat`, whose default rounding differs. + */ +describe('relativeCompact week labels', () => { + const NOW = new Date('2026-04-30T12:00:00.000Z'); + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const at = (daysAgo: number) => new Date(NOW.getTime() - daysAgo * 24 * 3600 * 1000); + + it.each([ + [7, '1w ago'], + [8, '2w ago'], + [14, '2w ago'], + [15, '3w ago'], + [21, '3w ago'], + ])('renders %i days ago as %s', async (daysAgo, expected) => { + const i18n = new Streami18n({ logger: () => null }); + const { t } = await i18n.init(); + expect(t('timestamp.PollVote', { timestamp: at(daysAgo) })).toBe(expected); + expect(t('timestamp.ChannelMembersLastActive', { timestamp: at(daysAgo) })).toBe( + expected, + ); + }); + + it('falls through to a date past 21 days, as it did before', async () => { + const i18n = new Streami18n({ logger: () => null }); + const { t } = await i18n.init(); + expect(t('timestamp.PollVote', { timestamp: at(22) })).toMatch( + /^\d{2}\/\d{2}\/\d{2}$/, + ); + expect(t('timestamp.PollVote', { timestamp: at(27) })).toMatch( + /^\d{2}\/\d{2}\/\d{2}$/, + ); + }); +}); diff --git a/src/i18n/__tests__/useStreami18n.test.tsx b/src/i18n/__tests__/useStreami18n.test.tsx new file mode 100644 index 000000000..c4cfd026e --- /dev/null +++ b/src/i18n/__tests__/useStreami18n.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import type { StreamChat } from 'stream-chat'; + +import { useStreami18n } from '../useStreami18n'; +import { Streami18n } from '../Streami18n'; +import { + TranslationProvider, + useTranslationContext, +} from '../../context/TranslationContext'; + +const client = fromPartial({ user: undefined }); + +const Readout = ({ onRender }: { onRender?: (lang: string) => void }) => { + const { userLanguage } = useTranslationContext(); + onRender?.(userLanguage); + return {userLanguage}; +}; + +const Harness = ({ + i18nInstance, + onRender, +}: { + i18nInstance?: Streami18n; + onRender?: (lang: string) => void; +}) => { + const value = useStreami18n({ client, i18nInstance }); + return ( + + + + ); +}; + +describe('useStreami18n', () => { + /** + * `window.navigator.language` must not be read during render. + * + * Two reasons: there is no `window` on the server, and a value that differs between the server + * render and the first client render is a hydration mismatch. Both are covered by the same + * observable property — the browser language may only appear *after* mount. + * + * Not asserted through `renderToString`, deliberately. `` is not server-renderable today for + * an unrelated reason: `useStateStore` calls `useSyncExternalStore` without a `getServerSnapshot`, + * so anything reading a `StateStore` throws "Missing getServerSnapshot" on the server. A real + * server-render test here would fail on that rather than on this hook. If that is ever fixed, add + * one. + */ + describe('applies the browser language after mount, not during the first render', () => { + it('starts at the default and switches once the effect has run', async () => { + vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('de-DE'); + const i18nInstance = new Streami18n({ logger: () => null }); + i18nInstance.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + const seen: string[] = []; + + render( + seen.push(lang)} />, + ); + + // First render agrees with what the server would have produced. + expect(seen[0]).toBe('en'); + await waitFor(() => expect(seen.at(-1)).toBe('de')); + }); + + it('ignores a browser language the instance has no dictionary for', async () => { + vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('sw-KE'); + const seen: string[] = []; + + render( seen.push(lang)} />); + + await waitFor(() => expect(seen.length).toBeGreaterThan(0)); + expect(new Set(seen)).toEqual(new Set(['en'])); + }); + }); +}); diff --git a/src/i18n/__tests__/utils.test.ts b/src/i18n/__tests__/utils.test.ts deleted file mode 100644 index 61c9ff663..000000000 --- a/src/i18n/__tests__/utils.test.ts +++ /dev/null @@ -1,566 +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 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 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('null'); - }); - it('should handle undefined value', () => { - expect( - timestampFormatter(undefined, 'en', { - calendar: false, - calendarFormats: { sameElse: 'dddd L' }, - format: 'YYYY', - }), - ).toBeUndefined(); - }); - - 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'); - }); - }); - }); -}); diff --git a/src/i18n/externalStrings.ts b/src/i18n/externalStrings.ts deleted file mode 100644 index 05c83cffa..000000000 --- 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/index.ts b/src/i18n/index.ts index a030540da..06a3fe63d 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/keys.ts b/src/i18n/keys.ts index 7fc68eb3a..7da3f379e 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 }}'; @@ -628,13 +566,13 @@ export type TranslationCatalog = { '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.ChannelMembersLastActive': '{{ timestamp | timestampFormatter(relativeCompact: true; relativeCompactWeekRounding: ceil) }}'; '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.PollVote': '{{ timestamp | timestampFormatter(relativeCompact: true; relativeCompactWeekRounding: ceil) }}'; '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) }}'; diff --git a/src/i18n/runtimeDefaults.ts b/src/i18n/runtimeDefaults.ts index 8b436a7e9..61aa742ac 100644 --- a/src/i18n/runtimeDefaults.ts +++ b/src/i18n/runtimeDefaults.ts @@ -21,67 +21,10 @@ 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': - '{{ timestamp | timestampFormatter(relativeCompact: true) }}', + '{{ timestamp | timestampFormatter(relativeCompact: true; relativeCompactWeekRounding: ceil) }}', 'timestamp.ChannelPreviewTimestamp': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "LT", "lastDay": "[Yesterday]", "lastWeek": "dddd", "sameElse": "L" }) }}', 'timestamp.DateSeparator': @@ -92,7 +35,8 @@ export const runtimeDefaults = { 'timestamp.LiveLocation': '{{ timestamp | timestampFormatter(calendar: true) }}', 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(calendar: false; format: HH:mm) }}', - 'timestamp.PollVote': '{{ timestamp | timestampFormatter(relativeCompact: true) }}', + 'timestamp.PollVote': + '{{ timestamp | timestampFormatter(relativeCompact: true; relativeCompactWeekRounding: ceil) }}', '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" }) }}', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index d0ef60d83..dc50c0e19 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -1,311 +1,89 @@ -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 ->; +import type { + Streami18nState as CoreStreami18nState, + LanguageNameCatalog, + LooseTranslationDictionaryOf, + PluralTranslationKeyOf, + RelativeTimeCatalog, + StreamTFunctionFor, + TDateTimeParser, + TimestampFormatterOptions, + TranslationDictionaryOf, + TranslationKeyOf, +} from 'stream-chat/i18n'; + +import type { TranslationCatalog as GeneratedCatalog } from './keys'; /** - * The interpolation variables a copy string requires. + * The SDK's i18n types, instantiated from the generic helpers in `stream-chat/i18n`. * - * 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. + * 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. * - * 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. + * `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 TranslationKey = - | Exclude - | PluralTranslationKey; +export type TranslationCatalog = GeneratedCatalog & + LanguageNameCatalog & + RelativeTimeCatalog; /** - * 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. + * Keys resolved from bundled data rather than an inline `defaultValue`. * - * Widen to {@link LooseTranslationDictionary} only when you need keys the SDK does not define. + * `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. * - * @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 }} участников', - * }; - */ -export type TranslationDictionary = Partial> & - Partial>; - -/** - * 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. - * - * `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. + * 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. */ -export type LooseTranslationDictionary = Partial> & - Record; - -/** 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 BundledKey = `translationBuilderTopic.${string}` | `language.${string}`; -/** - * 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`. - * - * 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. - */ -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; -}; +export type PluralTranslationKey = PluralTranslationKeyOf; +export type TranslationKey = TranslationKeyOf; +export type TranslationDictionary = TranslationDictionaryOf; +export type LooseTranslationDictionary = LooseTranslationDictionaryOf; +export type StreamTFunction = StreamTFunctionFor; /** - * 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. + * The value held by `Streami18n.state`, parameterized for this SDK's catalog. * - * @example t(asDynamicKey(command.description)) + * Exported because `state` is public: a consumer subscribing to it needs to be able to name the + * type for a module-scope selector. The default-parameterized `Streami18nState` from core is not a + * substitute -- `t` is contravariant in its options, so that one is not assignable to this. */ -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; -}; +export type Streami18nState = CoreStreami18nState; /** - * 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" + * Options for `getDateString`. * - * // 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 + * 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 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, + DayjsLocaleConfig, + DurationFormatterOptions, + DynamicTranslationKey, + FormatterFactory, + LanguageNameCatalog, + PredefinedFormatters, + RelativeTimeCatalog, + TDateTimeParser, + TDateTimeParserInput, + TDateTimeParserOutput, + TimestampFormatterOptions, +} from 'stream-chat/i18n'; diff --git a/src/i18n/useStreami18n.ts b/src/i18n/useStreami18n.ts new file mode 100644 index 000000000..101a269fe --- /dev/null +++ b/src/i18n/useStreami18n.ts @@ -0,0 +1,124 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { Streami18n } from './Streami18n'; +import type { Streami18nState } from './types'; +import { useStateStore } from '../store'; + +import type { StreamChat } from 'stream-chat'; +import type { TranslationContextValue } from '../context/TranslationContext'; + +/** + * 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 }: Streami18nState) => ({ 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 browser's language, read **after mount** rather than during render. + * + * Two reasons, and both bite silently. There is no `window` on the server, so reading it in render + * throws during SSR — which is why `useChat` read it inside an effect, and why this keeps that + * timing. And a value that differs between the server render and the first client render is a + * hydration mismatch, so the first client render has to agree with the server's: the default. + * + * Lazily seeding this from `window` would save one render on the client, but it is the hydration + * mismatch in a different shape: the server would produce the default and the first client render + * something else. The render it saves does not propagate anyway — `userLanguage` is unchanged + * whenever the browser language is the default or is not registered, so the context value is + * memoized to the same object and no consumer re-renders. + * + * None of this makes `` server-renderable today: `useStateStore` calls + * `useSyncExternalStore` with no `getServerSnapshot`, so any component reading a `StateStore` + * throws on the server. This hook is simply not the thing standing in the way. + */ + const [browserLanguage, setBrowserLanguage] = useState(); + useEffect(() => { + // Language code only, not the country-specific variant. + setBrowserLanguage(window.navigator.language.slice(0, 2)); + }, []); + + /** + * 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; + + return browserLanguage && streami18n.registeredLanguages.has(browserLanguage) + ? browserLanguage + : defaultLanguage; + }, [browserLanguage, client.user?.language, defaultLanguage, streami18n]); + + useEffect(() => { + streami18n.init(); + }, [streami18n]); + + const { t, tDateTimeParser } = useStateStore(streami18n.state, selector); + + return useMemo( + () => ({ t, tDateTimeParser, userLanguage }), + [t, tDateTimeParser, userLanguage], + ); +}; diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts index 85c298276..df8aaf507 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 `timestampFormatter`'s 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'; diff --git a/yarn.lock b/yarn.lock index b7a93a2f3..f10c2ac90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1829,7 +1829,7 @@ __metadata: emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" - stream-chat: "npm:10.0.0-rc.2" + stream-chat: "npm:10.0.0-rc.5" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -1856,7 +1856,7 @@ __metadata: react: "npm:^19.2.6" react-dom: "npm:^19.2.6" sass: "npm:^1.100.0" - stream-chat: "npm:10.0.0-rc.2" + stream-chat: "npm:10.0.0-rc.5" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -2202,16 +2202,6 @@ __metadata: languageName: node linkType: hard -"@types/jsonwebtoken@npm:^9.0.8": - version: 9.0.10 - resolution: "@types/jsonwebtoken@npm:9.0.10" - dependencies: - "@types/ms": "npm:*" - "@types/node": "npm:*" - checksum: 10c0/0688ac8fb75f809201cb7e18a12b9d80ce539cb9dd27e1b01e11807cb1a337059e899b8ee3abc3f2c9417f02e363a3069d9eab9ef9724b1da1f0e10713514f94 - languageName: node - linkType: hard - "@types/linkifyjs@npm:^2.1.7": version: 2.1.7 resolution: "@types/linkifyjs@npm:2.1.7" @@ -2344,15 +2334,6 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:^8.18.1": - version: 8.18.1 - resolution: "@types/ws@npm:8.18.1" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/61aff1129143fcc4312f083bc9e9e168aa3026b7dd6e70796276dcfb2c8211c4292603f9c4864fae702f2ed86e4abd4d38aa421831c2fd7f856c931a481afbab - languageName: node - linkType: hard - "@typescript-eslint/eslint-plugin@npm:8.59.4": version: 8.59.4 resolution: "@typescript-eslint/eslint-plugin@npm:8.59.4" @@ -3054,15 +3035,15 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.16.1": - version: 1.17.0 - resolution: "axios@npm:1.17.0" +"axios@npm:^1.19.0": + version: 1.19.0 + resolution: "axios@npm:1.19.0" dependencies: follow-redirects: "npm:^1.16.0" - form-data: "npm:^4.0.5" + form-data: "npm:^4.0.6" https-proxy-agent: "npm:^5.0.1" proxy-from-env: "npm:^2.1.0" - checksum: 10c0/c4fa19ff3a3a63bde48beec03ad816b133b9a6385cccffffe172577ab18c6a70e299280d57f12c80c867fe25df41f92cb91d3a8258708a6d2be3e9e085f92650 + checksum: 10c0/559fe7d51291787def61566a3db78b87510c8faf9c8a8c006d9d8b933808628ff0d8eca7756b40ae25e07da96d946c68c1ffba3da9075ed0b5d3661801d76869 languageName: node linkType: hard @@ -3096,13 +3077,6 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.5.1": - version: 1.5.1 - resolution: "base64-js@npm:1.5.1" - checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf - languageName: node - linkType: hard - "baseline-browser-mapping@npm:^2.10.12": version: 2.10.29 resolution: "baseline-browser-mapping@npm:2.10.29" @@ -3198,13 +3172,6 @@ __metadata: languageName: node linkType: hard -"buffer-equal-constant-time@npm:^1.0.1": - version: 1.0.1 - resolution: "buffer-equal-constant-time@npm:1.0.1" - checksum: 10c0/fb2294e64d23c573d0dd1f1e7a466c3e978fe94a4e0f8183937912ca374619773bef8e2aceb854129d2efecbbc515bbd0cc78d2734a3e3031edb0888531bbc8e - languageName: node - linkType: hard - "cacache@npm:^20.0.0, cacache@npm:^20.0.1, cacache@npm:^20.0.4": version: 20.0.4 resolution: "cacache@npm:20.0.4" @@ -3785,10 +3752,10 @@ __metadata: languageName: node linkType: hard -"dayjs@npm:^1.11.20": - version: 1.11.20 - resolution: "dayjs@npm:1.11.20" - checksum: 10c0/8af525e2aa100c8db9923d706c42b2b2d30579faf89456619413a5c10916efc92c2b166e193c27c02eb3174b30aa440ee1e7b72b0a2876b3da651d204db848a0 +"dayjs@npm:^1.11.13, dayjs@npm:^1.11.20": + version: 1.11.23 + resolution: "dayjs@npm:1.11.23" + checksum: 10c0/69ab04bf19c676e44ab50cc2fca223d265f3dafd107151ef3b0f3ad74d360c37caa602abe62f87cb25d90bd9f6bee003698af47df5b0dbf10a998b7b5f3bb3f1 languageName: node linkType: hard @@ -3970,15 +3937,6 @@ __metadata: languageName: node linkType: hard -"ecdsa-sig-formatter@npm:1.0.11": - version: 1.0.11 - resolution: "ecdsa-sig-formatter@npm:1.0.11" - dependencies: - safe-buffer: "npm:^5.0.1" - checksum: 10c0/ebfbf19d4b8be938f4dd4a83b8788385da353d63307ede301a9252f9f7f88672e76f2191618fd8edfc2f24679236064176fab0b78131b161ee73daa37125408c - languageName: node - linkType: hard - "electron-to-chromium@npm:^1.5.328": version: 1.5.353 resolution: "electron-to-chromium@npm:1.5.353" @@ -4754,16 +4712,16 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.5": - version: 4.0.5 - resolution: "form-data@npm:4.0.5" +"form-data@npm:^4.0.6": + version: 4.0.6 + resolution: "form-data@npm:4.0.6" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.12" - checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b + hasown: "npm:^2.0.4" + mime-types: "npm:^2.1.35" + checksum: 10c0/43947a77bf0ff45c6ceed789778982d47a3f3e720a74b71721174ebf3310a5f1a8be1d6b38a3ee3688e8a18a2c4273073ec0844cd37efda3eaf46d41c9c318ff languageName: node linkType: hard @@ -5136,6 +5094,15 @@ __metadata: languageName: node linkType: hard +"hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/2d8de939e270b70618f8cebb69746620db10617dbb495bc66ddad326955ea24d3ca4af133aff3eb7c1853e0218f867bc2b050ec26fe02e3aea58f880ffc5e506 + languageName: node + linkType: hard + "hast-util-find-and-replace@npm:^5.0.1": version: 5.0.1 resolution: "hast-util-find-and-replace@npm:5.0.1" @@ -5895,15 +5862,6 @@ __metadata: languageName: node linkType: hard -"isomorphic-ws@npm:^5.0.0": - version: 5.0.0 - resolution: "isomorphic-ws@npm:5.0.0" - peerDependencies: - ws: "*" - checksum: 10c0/a058ac8b5e6efe9e46252cb0bc67fd325005d7216451d1a51238bc62d7da8486f828ef017df54ddf742e0fffcbe4b1bcc2a66cc115b027ed0180334cd18df252 - languageName: node - linkType: hard - "issue-parser@npm:^7.0.0": version: 7.0.2 resolution: "issue-parser@npm:7.0.2" @@ -6146,24 +6104,6 @@ __metadata: languageName: node linkType: hard -"jsonwebtoken@npm:^9.0.3": - version: 9.0.3 - resolution: "jsonwebtoken@npm:9.0.3" - dependencies: - jws: "npm:^4.0.1" - lodash.includes: "npm:^4.3.0" - lodash.isboolean: "npm:^3.0.3" - lodash.isinteger: "npm:^4.0.4" - lodash.isnumber: "npm:^3.0.3" - lodash.isplainobject: "npm:^4.0.6" - lodash.isstring: "npm:^4.0.1" - lodash.once: "npm:^4.0.0" - ms: "npm:^2.1.1" - semver: "npm:^7.5.4" - checksum: 10c0/6ca7f1e54886ea3bde7146a5a22b53847c46e25453c7f7307a69818b9a6ad48c390b2e59d5690fcfd03c529b01960060cc4bb0c686991d6edae2285dfd30f4ba - languageName: node - linkType: hard - "jsx-ast-utils@npm:^2.4.1 || ^3.0.0": version: 3.3.5 resolution: "jsx-ast-utils@npm:3.3.5" @@ -6190,27 +6130,6 @@ __metadata: languageName: node linkType: hard -"jwa@npm:^2.0.1": - version: 2.0.1 - resolution: "jwa@npm:2.0.1" - dependencies: - buffer-equal-constant-time: "npm:^1.0.1" - ecdsa-sig-formatter: "npm:1.0.11" - safe-buffer: "npm:^5.0.1" - checksum: 10c0/ab3ebc6598e10dc11419d4ed675c9ca714a387481466b10e8a6f3f65d8d9c9237e2826f2505280a739cf4cbcf511cb288eeec22b5c9c63286fc5a2e4f97e78cf - languageName: node - linkType: hard - -"jws@npm:^4.0.1": - version: 4.0.1 - resolution: "jws@npm:4.0.1" - dependencies: - jwa: "npm:^2.0.1" - safe-buffer: "npm:^5.0.1" - checksum: 10c0/6be1ed93023aef570ccc5ea8d162b065840f3ef12f0d1bb3114cade844de7a357d5dc558201d9a65101e70885a6fa56b17462f520e6b0d426195510618a154d0 - languageName: node - linkType: hard - "keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -6593,34 +6512,6 @@ __metadata: languageName: node linkType: hard -"lodash.includes@npm:^4.3.0": - version: 4.3.0 - resolution: "lodash.includes@npm:4.3.0" - checksum: 10c0/7ca498b9b75bf602d04e48c0adb842dfc7d90f77bcb2a91a2b2be34a723ad24bc1c8b3683ec6b2552a90f216c723cdea530ddb11a3320e08fa38265703978f4b - languageName: node - linkType: hard - -"lodash.isboolean@npm:^3.0.3": - version: 3.0.3 - resolution: "lodash.isboolean@npm:3.0.3" - checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7 - languageName: node - linkType: hard - -"lodash.isinteger@npm:^4.0.4": - version: 4.0.4 - resolution: "lodash.isinteger@npm:4.0.4" - checksum: 10c0/4c3e023a2373bf65bf366d3b8605b97ec830bca702a926939bcaa53f8e02789b6a176e7f166b082f9365bfec4121bfeb52e86e9040cb8d450e64c858583f61b7 - languageName: node - linkType: hard - -"lodash.isnumber@npm:^3.0.3": - version: 3.0.3 - resolution: "lodash.isnumber@npm:3.0.3" - checksum: 10c0/2d01530513a1ee4f72dd79528444db4e6360588adcb0e2ff663db2b3f642d4bb3d687051ae1115751ca9082db4fdef675160071226ca6bbf5f0c123dbf0aa12d - languageName: node - linkType: hard - "lodash.isplainobject@npm:^4.0.6": version: 4.0.6 resolution: "lodash.isplainobject@npm:4.0.6" @@ -6649,13 +6540,6 @@ __metadata: languageName: node linkType: hard -"lodash.once@npm:^4.0.0": - version: 4.1.1 - resolution: "lodash.once@npm:4.1.1" - checksum: 10c0/46a9a0a66c45dd812fcc016e46605d85ad599fe87d71a02f6736220554b52ffbe82e79a483ad40f52a8a95755b0d1077fba259da8bfb6694a7abbf4a48f1fc04 - languageName: node - linkType: hard - "lodash.throttle@npm:^4.1.1": version: 4.1.1 resolution: "lodash.throttle@npm:4.1.1" @@ -7424,7 +7308,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12": +"mime-types@npm:^2.1.35": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -7578,22 +7462,6 @@ __metadata: languageName: node linkType: hard -"moment-timezone@npm:^0.5.48": - version: 0.5.48 - resolution: "moment-timezone@npm:0.5.48" - dependencies: - moment: "npm:^2.29.4" - checksum: 10c0/ab14ec9d94bc33f29ac18e5417b7f8aca0b17130b952c5cc9697b8fea839e5ece9313af5fd3c9703a05db472b1560ddbfc7ad2aa24aac9afd047d6da6c3c6033 - languageName: node - linkType: hard - -"moment@npm:^2.29.4": - version: 2.30.1 - resolution: "moment@npm:2.30.1" - checksum: 10c0/865e4279418c6de666fca7786607705fd0189d8a7b7624e2e56be99290ac846f90878a6f602e34b4e0455c549b85385b1baf9966845962b313699e7cb847543a - languageName: node - linkType: hard - "ms@npm:^2.1.1, ms@npm:^2.1.2, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -9121,13 +8989,6 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:^5.0.1": - version: 5.2.1 - resolution: "safe-buffer@npm:5.2.1" - checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 - languageName: node - linkType: hard - "safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" @@ -9250,7 +9111,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.7.4": +"semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.6.0, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.7.4": version: 7.8.0 resolution: "semver@npm:7.8.0" bin: @@ -9621,7 +9482,7 @@ __metadata: clsx: "npm:^2.1.1" concurrently: "npm:^9.2.1" conventional-changelog-conventionalcommits: "npm:^9.3.1" - dayjs: "npm:^1.11.20" + dayjs: "npm:^1.11.13" emoji-mart: "npm:^5.6.0" emoji-regex: "npm:^9.2.2" eslint: "npm:^9.39.4" @@ -9633,7 +9494,6 @@ __metadata: globals: "npm:^17.6.0" hast-util-find-and-replace: "npm:^5.0.1" husky: "npm:^9.1.7" - i18next: "npm:^26.3.6" jsdom: "npm:^29.1.1" linkifyjs: "npm:^4.3.3" lint-staged: "npm:^17.0.5" @@ -9642,7 +9502,6 @@ __metadata: lodash.throttle: "npm:^4.1.1" lodash.uniqby: "npm:^4.7.0" mdast-util-to-string: "npm:^4.0.0" - moment-timezone: "npm:^0.5.48" nanoid: "npm:^3.3.12" prettier: "npm:^3.8.3" react: "npm:^19.2.6" @@ -9657,7 +9516,7 @@ __metadata: remark-parse: "npm:^11.0.0" sass: "npm:^1.100.0" semantic-release: "npm:^25.0.3" - stream-chat: "npm:10.0.0-rc.2" + stream-chat: "npm:10.0.0-rc.5" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" unified: "npm:^11.0.5" @@ -9675,7 +9534,7 @@ __metadata: 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.5 dependenciesMeta: "@parcel/watcher": built: true @@ -9701,26 +9560,21 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:10.0.0-rc.2": - version: 10.0.0-rc.2 - resolution: "stream-chat@npm:10.0.0-rc.2" +"stream-chat@npm:10.0.0-rc.5": + version: 10.0.0-rc.5 + resolution: "stream-chat@npm:10.0.0-rc.5" dependencies: "@stream-io/logger": "npm:^2.0.0" - "@types/jsonwebtoken": "npm:^9.0.8" - "@types/ws": "npm:^8.18.1" - axios: "npm:^1.16.1" - base64-js: "npm:^1.5.1" - form-data: "npm:^4.0.5" - isomorphic-ws: "npm:^5.0.0" - jsonwebtoken: "npm:^9.0.3" + axios: "npm:^1.19.0" + dayjs: "npm:^1.11.13" + i18next: "npm:^26.3.6" linkifyjs: "npm:^4.3.3" - ws: "npm:^8.20.1" dependenciesMeta: esbuild: built: true husky: built: true - checksum: 10c0/a8ad3570d6820edfec476330674fdb2c96dda28324b7ea6f198e9320a5045eea754afd85f88a8a6702f09cd06dbfc88df9f85262ba0bc588c683fc45083c0d49 + checksum: 10c0/e04a95a1ede0a8eaca72cf31a3b0c2bfd22f78812c4fb7dff225ad44f99502dd7b00edbe2bc2cf712dd53e4997ca796212d7adb0be709e249e0fcfd917d75c01 languageName: node linkType: hard @@ -11050,21 +10904,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.20.1": - version: 8.21.0 - resolution: "ws@npm:8.21.0" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10c0/ef4a243476283fc49bc7550966c4af4aa0eef56273837211e700de3b664e08604a760cdddcb5ba43c049140e74ccfec5b0ee0bb439e08c2adf9138902fdde5f9 - languageName: node - linkType: hard - "xml-name-validator@npm:^5.0.0": version: 5.0.0 resolution: "xml-name-validator@npm:5.0.0"