Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
207b4e9
feat(i18n): drop the 11 non-English locales, prune dead keys
oliverlaz Aug 7, 2026
82ae749
feat(i18n): replace natural-language keys with namespaced, stable keys
oliverlaz Aug 10, 2026
60114a5
feat(i18n): add a typed key surface and expose it to integrators
oliverlaz Aug 10, 2026
0926ac6
docs(i18n): add the v15 migration guide, fix the drift gate
oliverlaz Aug 10, 2026
89fadfa
chore(i18n): move the migration docs to ai-docs, add @types/node, fix…
oliverlaz Aug 10, 2026
9fa156c
chore: fix mangled .gitignore entry, untrack tsconfig.test.tsbuildinfo
oliverlaz Aug 10, 2026
492c8dd
perf(i18n): stop bundling the prose translations, keep only what cann…
oliverlaz Aug 10, 2026
8b15f9a
refactor(i18n): derive keys.ts from call sites, drop en.json and i18n…
oliverlaz Aug 10, 2026
dd0eab2
chore(i18n): delete the applied v14->v15 migration codemods
oliverlaz Aug 10, 2026
89d4036
fix(i18n): merge custom dictionaries over runtimeDefaults
oliverlaz Aug 10, 2026
b305f3a
feat(i18n): upgrade i18next to v26
oliverlaz Aug 10, 2026
4b6fc9f
fix(examples): migrate the vite example to namespaced i18n keys
oliverlaz Aug 10, 2026
8dd9b3a
feat(i18n): add TranslationDictionary for compile-checked dictionaries
oliverlaz Aug 10, 2026
ccb368e
docs(i18n): document how to find keys a language has not translated yet
oliverlaz Aug 10, 2026
1b004c8
fix(i18n): keep runtimeDefaults for a language with no dictionary
oliverlaz Aug 10, 2026
92df294
docs(i18n): flag the four timestamp keys that carry English day words
oliverlaz Aug 10, 2026
1667b08
feat(i18n): check the keys of a dictionary passed to registerTranslation
oliverlaz Aug 11, 2026
522ce3a
refactor(i18n): accept any language code, not just stream-chat's union
oliverlaz Aug 11, 2026
4cf2e6c
feat(examples): add German and Italian to the vite example
oliverlaz Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
18 changes: 18 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "vite-example",
"runtimeExecutable": "yarn",
"runtimeArgs": [
"workspace",
"@stream-io/stream-chat-react-vite",
"dev",
"--port",
"5399",
"--strictPort"
],
"port": 5399
}
]
}
13 changes: 11 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ jobs:

- run: yarn lint

# scripts/ is outside the src tsconfigs and is not linted; Node 24 strips the types in
# the .mts build scripts without checking them, so check them here.
- name: Typecheck build scripts
run: yarn types:scripts

build:
runs-on: ubuntu-latest
permissions:
Expand All @@ -47,8 +52,12 @@ jobs:
- name: Validate ESM bundle with Node ${{ env.NODE_VERSION }}
run: yarn validate-esm

- name: Validate translations
run: yarn validate-translations
- name: Validate translations are in sync with source
# `yarn build` above already ran build-translations, which regenerates keys.ts from the
# t() call sites and runtimeDefaults.ts. A diff here means the author changed a call site
# without regenerating. Scoped to the one generated file so unrelated edits under
# src/i18n (tests, Streami18n, runtimeDefaults) do not trip it.
run: git diff --exit-code -- src/i18n/keys.ts

- name: Cache Build Output
uses: actions/cache@v5
Expand Down
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,11 @@ coverage.out
# stream-chat-css/docusaurus files
docusaurus/docs/React/theming
docusaurus/docs/React/assets/stream-chat-css*
sharedtsconfig.test.tsbuildinfo
shared

# TypeScript incremental build metadata
*.tsbuildinfo

# On-demand translator/TMS catalog export (`yarn i18n:export`). Generated from the t() call
# sites and src/i18n/runtimeDefaults.ts, so it is never checked in.
/en.json
3 changes: 1 addition & 2 deletions .lintstagedrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"src/**/*.{js,jsx,ts,tsx,md}": "eslint --max-warnings 0 --no-warn-ignored",
"**/*.{js,mjs,ts,mts,jsx,tsx,md,json,yml}": "prettier --list-different",
"src/i18n/*.json": "yarn run validate-translations"
"**/*.{js,mjs,ts,mts,jsx,tsx,md,json,yml}": "prettier --list-different"
}
113 changes: 89 additions & 24 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,34 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
# (yarnPath). Any globally installed `yarn` shim launches it; no Corepack.
yarn install # Setup (installs root + examples/* workspaces)
yarn build # Full build (translations, Vite, types, SCSS)
yarn test # Run Jest tests
yarn test # Run Vitest
yarn test <pattern> # Run specific test (e.g., yarn test Channel)
yarn lint-fix # Fix all lint/format issues (prettier + eslint)
yarn types # TypeScript type checking (noEmit mode)

# Type checking
tsc -p tsconfig.lib.json --noEmit # The library. THIS is the real check.
yarn types:scripts # scripts/*.mts (Node strips types, it does not check them)

# i18n (see the i18n System section)
yarn build-translations # Regenerate src/i18n/keys.ts from the t() call sites
yarn validate-translations # Drift gate: regenerate and fail on any diff

# Examples (workspaces under examples/*)
yarn start:tutorial # Start the tutorial example dev server
yarn start:vite # Start the vite example dev server
yarn examples:build # Build all examples

# E2E
yarn e2e-fixtures # Generate e2e test fixtures
yarn e2e # Run Playwright tests

# Before committing
yarn lint-fix # ALWAYS run this first
```

> **`yarn types` checks nothing β€” do not rely on it.** It runs `tsc` with no `--project`, so it
> picks up the root `tsconfig.json`, which is a solution file with `"files": []`. It exits 0 even
> with a deliberate type error in `src/`. Use `tsc -p tsconfig.lib.json --noEmit`.
>
> `yarn types:tests` (`tsconfig.test.json`) reports ~1200 pre-existing errors and is not wired into
> CI. Treat it as unenforced.

## Architecture: Core Concepts

### Component Hierarchy
Expand Down Expand Up @@ -273,7 +283,8 @@ Closes #123

- [ ] `yarn lint-fix` passed
- [ ] `yarn test` passed
- [ ] `yarn types` passed
- [ ] `tsc -p tsconfig.lib.json --noEmit` passed (NOT `yarn types` β€” see Essential Commands)
- [ ] `yarn validate-translations` passed, if any `t()` call changed
- [ ] Tests added for changes
- [ ] No new warnings (zero tolerance)
- [ ] Screenshots for UI changes
Expand All @@ -288,7 +299,7 @@ When deprecating, use `@deprecated` JSDoc tag with reason and docs link. Commit

The build runs 4 steps in parallel via `concurrently`:

1. **`build-translations`** β€” Extracts `t()` calls from source via `i18next-cli`
1. **`build-translations`** β€” Regenerates `src/i18n/keys.ts` from the `t()` call sites
2. **`vite build`** β€” Bundles 3 entry points (index, emojis, mp3-encoder) as CJS + ESM, no minification
3. **`tsc`** β€” Generates `.d.ts` type declarations only (`tsconfig.lib.json`) to `dist/types/`
4. **`build-styling`** β€” Compiles `src/styling/index.scss` β†’ `dist/css/index.css`
Expand All @@ -315,12 +326,66 @@ See `examples/vite/src/index.scss` for reference implementation. Layers eliminat

## i18n System

- **12 languages**: de, en, es, fr, hi, it, ja, ko, nl, pt, ru, tr (JSON files in `src/i18n/`)
- **Keys are English text**: `t('Mute')`, `t('{{ user }} is typing...')`
- **Extraction**: `i18next-cli extract` scans `t()` calls in source β†’ updates JSON files
- **Validation**: `yarn lint` runs `scripts/validate-translations.js` β€” fails on any empty translation string (zero tolerance)
- **Date/time**: `Streami18n` class wraps i18next + Dayjs with per-locale calendar formats
- **When adding translatable strings**: Use `t()` from `useTranslationContext()`, then run `yarn build-translations` to update JSON files. All 12 language files must have non-empty values.
**English only.** Every other language is supplied by the integrator via
`Streami18n.registerTranslation()`.

**There is no checked-in `en.json`.** The catalog has exactly two sources, and both are where the
copy is actually used: the inline `defaultValue` at each `t()` call site (562 keys), and
`src/i18n/runtimeDefaults.ts` (71 keys β€” hand-maintained, and the only translation data that
ships). A committed JSON locale was a third copy of the same strings that needed an extract pass
and a sync pass to stay honest. `yarn i18n:export` writes one on demand for a translator or TMS.

**Keys are stable dotted identifiers, with the English copy inline as i18next's `defaultValue`:**

```ts
const { t } = useTranslationContext();
t('message.status.sent.text', 'Sent'); // singular
t('channel.memberCount.title', {
// plural: `count` is required
count,
defaultValue_one: '{{ count }} member',
defaultValue_other: '{{ count }} members',
});
t('timestamp.MessageTimestamp', { timestamp }); // formatter key: no default
```

The inline default is what makes a partial custom dictionary safe β€” an unsupplied key still
renders English β€” and it keeps the copy visible at the call site.

- **Namespaces follow the source tree** (`message.*`, `messageComposer.*`, `poll.*`), so keys are
predictable from the component. Genuinely shared copy lives in `common.*`. Modality is the leaf:
`.label`, `.ariaLabel`, `.placeholder`, `.title`, `.description`, `.text`.
- **`keySeparator: false` must stay.** Keys are flat strings that happen to contain dots; several
contain `...` in their copy, which `keySeparator: '.'` would mis-resolve.
- **Typed keys:** `src/i18n/keys.ts` (generated, type-only) declares `TranslationCatalog`.
`src/i18n/types.ts` derives `TranslationKey`, `TranslationDictionary` (strict),
`LooseTranslationDictionary` and `StreamTFunction`,
which is what `useTranslationContext().t` is typed as β€” a typo is a compile error. Interpolation
variables are typed for plural keys only (see the note in `types.ts` for why).
- **Runtime keys:** the ~10 keys resolved from a runtime value (a `stream-chat`
`notification.message`, slash-command metadata, a language code, an integrator prop) go through
`asDynamicKey()`. That brand is required, so every escape is deliberate and greppable.
`src/i18n/externalStrings.ts` maps the `stream-chat` messages we recognise onto stable keys.
- **`yarn build-translations`** parses the `t()` call sites (`scripts/i18n-call-sites.mts`), joins
them with `runtimeDefaults.ts`, and regenerates `keys.ts`. It hard-fails on three things:
a key used with two different inline copies; a key called with no inline default and no
`runtimeDefaults` entry (it would render as the raw dotted key); and a key present in _both_
(the bundled value wins, so editing the call site would silently change nothing β€” this is the
bug class that used to hide behind the old en.json).
- **`yarn validate-translations`** regenerates and fails on any diff to `keys.ts` β€” the drift gate.
- **There is no `i18next-cli`.** Its extract/`removeUnusedKeys` pass existed only to maintain
en.json. Dead prose keys are now structurally impossible (a key exists because a call site
declares it), which also retires the `preservePatterns` footgun that once nearly deleted the 57
`language.*` keys.
- **The v14 -> v15 key mapping** lives in `ai-docs/i18n-v15-key-map.json` (603 rows) and is read by
the integrator-facing guide. It is a hand-reviewed artifact β€” nothing regenerates it. The
one-shot codemods that produced it and rewrote the call sites were deleted once applied; recover
them from git history if a v14 -> v15 question ever needs re-deriving.
- **Date/time:** `Streami18n` wraps i18next + Dayjs. Only the `en` dayjs locale is bundled;
integrators import their own and pass `dayjsLocaleConfigForLanguage`.

**Adding a translatable string:** call `t('namespace.component.thing.label', 'English copy')`, then
run `yarn build-translations`.

## Styling Architecture (Theming & Build Details)

Expand All @@ -346,7 +411,7 @@ See `examples/vite/src/index.scss` for the reference layer setup.

`yarn build` runs 4 tasks in parallel via `concurrently`:

1. `yarn build-translations` β€” Extracts `t()` calls via `i18next-cli`
1. `yarn build-translations` β€” Regenerates `src/i18n/keys.ts` from the `t()` call sites
2. `vite build` β€” Bundles 3 entry points (index, emojis, mp3-encoder) as ESM + CJS
3. `tsc --project tsconfig.lib.json` β€” Generates `.d.ts` type declarations to `dist/types/`
4. `yarn build-styling` β€” Compiles SCSS to `dist/css/index.css`
Expand All @@ -361,12 +426,8 @@ Vite config: no minification, sourcemaps enabled, all deps externalized. Target:

### i18n System

- 12 languages in `src/i18n/*.json` β€” **Natural language keys** (English text = key)
- `yarn build-translations` extracts `t()` calls from source via `i18next-cli extract`
- `yarn validate-translations` (runs during `yarn lint`) β€” **zero-tolerance: any empty string value fails the build**
- `Streami18n` class (`src/i18n/Streami18n.ts`) wraps i18next, integrates Dayjs for date/time formatting
- Interpolation: `t('Failed to update {{ field }}', { field })`, Plurals: `_one`/`_other` suffixes
- Access via `useTranslationContext()` hook β€” only works inside `<Chat>`
See the **i18n System** section above β€” English-only, dotted keys with the copy inline as
i18next's `defaultValue`. Access via `useTranslationContext()`, which only works inside `<Chat>`.

## Key Patterns for Development

Expand All @@ -386,14 +447,18 @@ const channels = useStateStore(chatClient.state.channelsArray);

### Adding Translations

1. Add strings to `src/i18n/`
2. Run `yarn build-translations`
3. Use: `const { t } = useTranslationContext();`
1. Call `t('namespace.component.thing.label', 'English copy')` β€” the key is namespaced by the
source tree, the copy goes inline (see **i18n System**)
2. Run `yarn build-translations` to regenerate `src/i18n/keys.ts`
3. Never hand-edit `keys.ts` β€” it is generated, and CI fails on any drift. A key with no inline
copy (a formatter expression, or one built from a runtime value) goes in
`src/i18n/runtimeDefaults.ts` instead, which _is_ hand-maintained

## References

- **Integration patterns:** See `AI.md`
- **Repo structure:** See `AGENTS.md`
- **Development guides:** See `developers/`
- **i18n v15 migration (integrator-facing):** See `ai-docs/i18n-v15-migration.md`
- **Component docs:** https://getstream.io/chat/docs/sdk/react/
- **Stream Chat API:** https://getstream.io/chat/docs/javascript/
41 changes: 41 additions & 0 deletions ai-docs/ai-migration-v14-v15.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,47 @@ To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the

`Channel` no longer reflects the channel-list query state. Its loading / error / empty rendering is driven by the channel's own `watch()` bootstrap (`LoadingIndicator` while watching, `LoadingErrorIndicator` on watch failure, `EmptyPlaceholder` when no channel is provided). The channel-list query state is the `ChannelList`'s concern, not `Channel`'s.

## i18n: English-only bundle, namespaced translation keys

Two breaking changes, both of which fail **silently** β€” no error, no compile break unless the app
is typed against the new surface. Check for them explicitly.

1. **The 11 non-English dictionaries are removed** (`de`, `es`, `fr`, `hi`, `it`, `ja`, `ko`, `nl`,
`pt`, `ru`, `tr`), along with their `dayjs` locale data. The `deTranslations` …
`trTranslations` exports are gone.
2. **Keys are namespaced identifiers, not the English text.**
`t('Send Message')` β†’ `t('messageComposer.sendButton.send.ariaLabel', 'Send')`.

**What to look for in the app:**

| Symptom | Cause | Fix |
| ---------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------- |
| `registerTranslation(...)` / `translationsForLanguage` present | keys are the old English strings | rename every key |
| `language: 'de'` (or any non-`en`) with no dictionary registered | the built-in one is gone | register a dictionary |
| non-English dates render in English | the dayjs locale is no longer bundled | `import 'dayjs/locale/de.js'` + `dayjsLocaleConfigForLanguage` |
| imports of `deTranslations` etc. | exports removed | recover from a v14 tag, then rename |

An unrenamed key does **not** throw β€” it simply never matches, and the English copy renders
instead. Do not assume the absence of an error means the app is migrated.

**Renaming:** every old key maps to exactly one new key. The complete table is
[`i18n-v15-key-map.json`](./i18n-v15-key-map.json) (603 rows, `{ "<old key>": { "key": "<new key>",
"prose": bool, "plural"?: bool } }`). Entries with `"prose": false` hold formatter expressions
rather than copy. Four of them nonetheless carry English words inside their `calendarFormats`
argument β€” `timestamp.DateSeparator`, `timestamp.ReminderNotification`,
`timestamp.ChannelPreviewTimestamp`, `timestamp.ChannelDetailPinnedMessageTimestamp` β€” and must be
overridden to translate Today/Tomorrow/Yesterday/Last. `dayjsLocaleConfigForLanguage` does not
reach them, because a per-key `calendarFormats` replaces the locale's calendar.

`registerTranslation()` and `translationsForLanguage` take `TranslationDictionary` (exported from
`stream-chat-react`), so TypeScript flags every stale key in a dictionary written inline. Plural keys
accept any `Intl.PluralRules` category, so a language needing `_few` / `_many` / `_zero` stays
checked. Only if the app needs keys the SDK does not define, annotate the variable it passes as
`LooseTranslationDictionary` β€” that admits any key and will **not** flag a stale one.

Full detail, including plurals for languages needing `_few` / `_many` and how to recover a deleted
dictionary: [`i18n-v15-migration.md`](./i18n-v15-migration.md).

### `ChannelProps.EmptyPlaceholder` accepts `null`

`Channel`'s `EmptyPlaceholder` prop is now typed `React.ReactElement | null` (the default is `null`) β€” pass `null` to render an empty container when no channel is set. (Non-breaking widening; noted for completeness.)
Expand Down
Loading