Skip to content

Commit 8ec4862

Browse files
authored
fix: wrongly migrated translations (#3792)
## 🎯 Goal This PR fixes some badly migrated translations in the RN SDK that came with the `i18n` migration to the LLC. ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 8c6ca1a commit 8ec4862

9 files changed

Lines changed: 821 additions & 15 deletions

File tree

ai-docs/i18n-v10-migration.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,10 @@ relative date renders the plugin's built-in English scaffolding around a transla
192192

193193
> Last **Mittwoch** at 5:10 PM
194194
195-
The SDK applies its own `calendar` block to `en` internally for exactly this reason. That the field is plugin-owned is
196-
also why `DayjsLocaleConfig` is exported: typing the argument as a bare `Partial<ILocale>` makes passing a calendar
197-
config a TS2345 "no properties in common" error.
195+
The SDK applies its own `calendar` block to `en` internally for exactly this reason; it is exported as
196+
`englishCalendarFormats`, so a language can start from it rather than transcribing the six slots. That the field is
197+
plugin-owned is also why `DayjsLocaleConfig` is exported: typing the argument as a bare `Partial<ILocale>` makes passing
198+
a calendar config a TS2345 "no properties in common" error.
198199

199200
```ts
200201
import { Streami18n, type DayjsLocaleConfig } from 'stream-chat-react-native';

package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps
105105
if (!recording) {
106106
NativeHandlers.triggerHaptic('notificationError');
107107
addNotification({
108-
message: 'messageInput.audioRecorder.holdToRecord.text',
108+
message: t(
109+
'messageInput.audioRecorder.holdToRecord.text',
110+
'Hold to record. Release to save.',
111+
),
109112
options: {
110113
severity: 'info',
111114
type: 'validation:audio:recording:hold-required',
@@ -128,7 +131,10 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps
128131
const permissionsGranted = await startVoiceRecording();
129132
if (!permissionsGranted) {
130133
addNotification({
131-
message: 'messageInput.audioRecorder.permissionDenied.text',
134+
message: t(
135+
'messageInput.audioRecorder.permissionDenied.text',
136+
'Please allow Audio permissions in settings.',
137+
),
132138
options: {
133139
actions: [
134140
{
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
import ts from 'typescript';
5+
6+
import catalogJson from './catalog.fixture.json';
7+
8+
import { runtimeDefaults } from '../runtimeDefaults';
9+
10+
/**
11+
* Every `t()` call site, checked against the catalog.
12+
*
13+
* `catalogRenders.test.ts` proves each key *can* render; it says nothing about whether the call
14+
* sites ask for the right thing. These are the failures it cannot see, all of which ship a visibly
15+
* wrong string rather than an error:
16+
*
17+
* - a dotted key handed to something that renders it verbatim, so the user reads
18+
* `messageInput.audioRecorder.holdToRecord.text` off the screen
19+
* - `t('key')` with neither an inline default nor a bundled value — i18next misses and returns the key
20+
* - copy that interpolates `{{name}}` while the call site passes `{ user }`, leaving `{{name}}` on screen
21+
* - a plural key called without `count`, so i18next cannot pick a form
22+
*
23+
* Parsing the source is the only way to see any of it: each one is a mismatch between two files that
24+
* individually type-check.
25+
*/
26+
27+
const SRC = path.resolve(__dirname, '../..');
28+
const catalog = catalogJson as Record<string, string>;
29+
const bundledKeys = new Set(Object.keys(runtimeDefaults));
30+
31+
/** Callables that translate their first argument. None of them takes an inline default. */
32+
const TRANSLATING = new Set(['t', 'translate', 'useA11yLabel']);
33+
34+
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
35+
const catalogKeys = new Set(Object.keys(catalog));
36+
const pluralBases = new Set(
37+
Object.keys(catalog)
38+
.filter((key) => PLURAL_SUFFIX.test(key))
39+
.map((key) => key.replace(PLURAL_SUFFIX, '')),
40+
);
41+
const resolvable = (key: string) => catalogKeys.has(key) || pluralBases.has(key);
42+
const copyFor = (key: string) =>
43+
catalog[key] ?? catalog[`${key}_other`] ?? catalog[`${key}_one`] ?? undefined;
44+
45+
/** `{{ x | fmt }}` resolves through a formatter, so its placeholder is not a caller's to supply. */
46+
const isFormatterExpression = (copy: string) => /\{\{[^}]*\|[^}]*\}\}/.test(copy);
47+
const placeholders = (copy: string) =>
48+
[...copy.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)].map((m) => m[1]);
49+
50+
const sourceFiles = (() => {
51+
const found: string[] = [];
52+
const walk = (dir: string) => {
53+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
54+
const full = path.join(dir, entry.name);
55+
if (entry.isDirectory()) {
56+
if (entry.name === '__tests__' || entry.name === 'mock-builders') continue;
57+
walk(full);
58+
} else if (
59+
/\.tsx?$/.test(entry.name) &&
60+
// Both are catalogs of keys, so every string in them would look like an untranslated use.
61+
entry.name !== 'keys.ts' &&
62+
entry.name !== 'runtimeDefaults.ts'
63+
) {
64+
found.push(full);
65+
}
66+
}
67+
};
68+
walk(SRC);
69+
return found;
70+
})();
71+
72+
type Finding = { detail: string; where: string };
73+
74+
const calleeName = (node: ts.CallExpression) => {
75+
const callee = node.expression;
76+
if (ts.isIdentifier(callee)) return callee.text;
77+
if (ts.isPropertyAccessExpression(callee)) return callee.name.text;
78+
return undefined;
79+
};
80+
81+
/** `asDynamicKey(x)` is a branding wrapper; the key is what it wraps. */
82+
const unwrapDynamic = (node: ts.Expression | undefined) =>
83+
node &&
84+
ts.isCallExpression(node) &&
85+
ts.isIdentifier(node.expression) &&
86+
node.expression.text === 'asDynamicKey'
87+
? node.arguments[0]
88+
: node;
89+
90+
const objectPropNames = (node: ts.Node | undefined) =>
91+
node && ts.isObjectLiteralExpression(node)
92+
? node.properties
93+
.map((p) =>
94+
p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))
95+
? p.name.text
96+
: undefined,
97+
)
98+
.filter((n): n is string => !!n)
99+
: [];
100+
101+
const audit = () => {
102+
const rawKeyLeaks: Finding[] = [];
103+
const unknownKeys: Finding[] = [];
104+
const unresolvable: Finding[] = [];
105+
const drift: Finding[] = [];
106+
const missingInterpolation: Finding[] = [];
107+
const pluralWithoutCount: Finding[] = [];
108+
109+
/**
110+
* Identifiers whose value reaches a translating call somewhere in the SDK, so a key literal
111+
* assigned to one is translated even though its own line has no `t()` on it — a lookup table
112+
* (`SUBTITLE_KEY[type]`) or an exported constant.
113+
*/
114+
const translatedRoots = new Set<string>();
115+
const parsed = sourceFiles.map((file) => ({
116+
file,
117+
sf: ts.createSourceFile(
118+
file,
119+
fs.readFileSync(file, 'utf8'),
120+
ts.ScriptTarget.Latest,
121+
true,
122+
ts.ScriptKind.TSX,
123+
),
124+
}));
125+
126+
for (const { sf } of parsed) {
127+
const visit = (node: ts.Node) => {
128+
if (ts.isCallExpression(node) && TRANSLATING.has(calleeName(node) ?? '')) {
129+
const arg = unwrapDynamic(node.arguments[0]);
130+
if (arg && !ts.isStringLiteral(arg)) {
131+
let root: ts.Node = arg;
132+
while (
133+
ts.isPropertyAccessExpression(root) ||
134+
ts.isElementAccessExpression(root) ||
135+
ts.isNonNullExpression(root) ||
136+
ts.isParenthesizedExpression(root)
137+
) {
138+
if (ts.isPropertyAccessExpression(root)) translatedRoots.add(root.name.text);
139+
root = root.expression;
140+
}
141+
if (ts.isIdentifier(root)) translatedRoots.add(root.text);
142+
}
143+
}
144+
ts.forEachChild(node, visit);
145+
};
146+
visit(sf);
147+
}
148+
149+
for (const { file, sf } of parsed) {
150+
const rel = path.relative(SRC, file);
151+
const at = (node: ts.Node) =>
152+
`${rel}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}`;
153+
154+
const visit = (node: ts.Node) => {
155+
if (ts.isCallExpression(node)) {
156+
const name = calleeName(node);
157+
158+
if (name === 't' || name === 'translate') {
159+
const keyNode = unwrapDynamic(node.arguments[0]);
160+
if (keyNode && ts.isStringLiteral(keyNode)) {
161+
const key = keyNode.text;
162+
const second = node.arguments[1];
163+
const inlineDefault = second && ts.isStringLiteral(second) ? second.text : undefined;
164+
const options = objectPropNames(
165+
second && ts.isObjectLiteralExpression(second) ? second : node.arguments[2],
166+
);
167+
168+
if (!resolvable(key)) {
169+
unknownKeys.push({ detail: `t('${key}')`, where: at(node) });
170+
} else {
171+
// A plural call site carries its copy as `defaultValue_one` / `defaultValue_other`
172+
// inside the options object rather than as the second argument.
173+
const hasDefault =
174+
!!inlineDefault || options.some((o) => o.startsWith('defaultValue'));
175+
if (!hasDefault && !bundledKeys.has(key)) {
176+
unresolvable.push({
177+
detail: `t('${key}') — no inline default, not in runtimeDefaults`,
178+
where: at(node),
179+
});
180+
}
181+
if (inlineDefault && catalog[key] !== undefined && inlineDefault !== catalog[key]) {
182+
drift.push({
183+
detail: `t('${key}')\n inline : ${JSON.stringify(inlineDefault)}\n catalog: ${JSON.stringify(catalog[key])}`,
184+
where: at(node),
185+
});
186+
}
187+
const copy = copyFor(key);
188+
if (copy && !isFormatterExpression(copy)) {
189+
const missing = placeholders(copy).filter((v) => !options.includes(v));
190+
if (missing.length) {
191+
missingInterpolation.push({
192+
detail: `t('${key}') needs {{${missing.join('}}, {{')}}}; options supply [${options.join(', ')}]`,
193+
where: at(node),
194+
});
195+
}
196+
}
197+
if (pluralBases.has(key) && !options.includes('count')) {
198+
pluralWithoutCount.push({ detail: `t('${key}')`, where: at(node) });
199+
}
200+
}
201+
}
202+
}
203+
204+
if (name === 'useA11yLabel') {
205+
const arg = node.arguments[0];
206+
if (arg && ts.isStringLiteral(arg)) {
207+
if (!resolvable(arg.text))
208+
unknownKeys.push({ detail: `useA11yLabel('${arg.text}')`, where: at(node) });
209+
else if (!bundledKeys.has(arg.text)) {
210+
unresolvable.push({
211+
detail: `useA11yLabel('${arg.text}') — the hook passes no inline default, so the key must be bundled`,
212+
where: at(node),
213+
});
214+
} else {
215+
const needed = placeholders(copyFor(arg.text) ?? '');
216+
if (needed.length && !node.arguments[1]) {
217+
missingInterpolation.push({
218+
detail: `useA11yLabel('${arg.text}') needs {{${needed.join('}}, {{')}}} but passes no params`,
219+
where: at(node),
220+
});
221+
}
222+
}
223+
}
224+
}
225+
}
226+
227+
if (ts.isStringLiteral(node) && node.text.includes('.') && resolvable(node.text)) {
228+
// Climb past pass-through syntax (ternaries, `??`, parens, JSX braces) to the slot that names
229+
// this value, then decide whether that slot is translated downstream.
230+
let parent: ts.Node = node.parent;
231+
let child: ts.Node = node;
232+
let translated = false;
233+
while (parent) {
234+
if (ts.isCallExpression(parent) && TRANSLATING.has(calleeName(parent) ?? '')) {
235+
const first = parent.arguments[0];
236+
if (first === child || unwrapDynamic(first) === child) translated = true;
237+
}
238+
if (
239+
!(
240+
ts.isConditionalExpression(parent) ||
241+
ts.isParenthesizedExpression(parent) ||
242+
ts.isBinaryExpression(parent) ||
243+
ts.isJsxExpression(parent) ||
244+
ts.isAsExpression(parent) ||
245+
ts.isCallExpression(parent)
246+
)
247+
) {
248+
break;
249+
}
250+
child = parent;
251+
parent = parent.parent;
252+
}
253+
254+
if (!translated) {
255+
let slot: string | undefined;
256+
if (ts.isJsxAttribute(parent)) slot = parent.name.getText(sf);
257+
else if (ts.isPropertyAssignment(parent)) slot = parent.name.getText(sf);
258+
else if (ts.isBindingElement(parent) && parent.name) slot = parent.name.getText(sf);
259+
else if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name))
260+
slot = parent.name.text;
261+
262+
// A `*Key` slot is translated by whoever receives it (`Button`'s `accessibilityLabelKey`,
263+
// `getDateString`'s `timestampTranslationKey`). Those receivers pass no inline default, so
264+
// the key has to be bundled.
265+
if (slot && /Key$/.test(slot)) {
266+
if (!bundledKeys.has(node.text)) {
267+
unresolvable.push({
268+
detail: `${slot}='${node.text}' — translated without an inline default, so it must be bundled`,
269+
where: at(node),
270+
});
271+
}
272+
} else {
273+
const names: string[] = [];
274+
let walker: ts.Node | undefined = node.parent;
275+
while (walker) {
276+
if (ts.isPropertyAssignment(walker) && walker.name)
277+
names.push(walker.name.getText(sf));
278+
if (ts.isVariableDeclaration(walker) && ts.isIdentifier(walker.name)) {
279+
names.push(walker.name.text);
280+
break;
281+
}
282+
walker = walker.parent;
283+
}
284+
if (!names.some((n) => translatedRoots.has(n))) {
285+
rawKeyLeaks.push({
286+
detail: `'${node.text}' in ${slot ? `slot '${slot}'` : ts.SyntaxKind[parent?.kind]} — never reaches t()`,
287+
where: at(node),
288+
});
289+
}
290+
}
291+
}
292+
}
293+
ts.forEachChild(node, visit);
294+
};
295+
visit(sf);
296+
}
297+
298+
return {
299+
drift,
300+
missingInterpolation,
301+
pluralWithoutCount,
302+
rawKeyLeaks,
303+
unknownKeys,
304+
unresolvable,
305+
};
306+
};
307+
308+
const format = (findings: Finding[]) =>
309+
findings.map((f) => ` ${f.where}\n ${f.detail}`).join('\n');
310+
311+
describe('translation call sites', () => {
312+
const result = audit();
313+
314+
it('scanned the source tree', () => {
315+
// Guards against the walk silently finding nothing and every assertion below passing vacuously.
316+
expect(sourceFiles.length).toBeGreaterThan(500);
317+
expect(catalogKeys.size).toBeGreaterThan(300);
318+
});
319+
320+
it('never hands a translation key to something that renders it verbatim', () => {
321+
expect(format(result.rawKeyLeaks)).toBe('');
322+
});
323+
324+
it('only asks for keys the catalog has', () => {
325+
expect(format(result.unknownKeys)).toBe('');
326+
});
327+
328+
it('always supplies copy, inline or bundled', () => {
329+
expect(format(result.unresolvable)).toBe('');
330+
});
331+
332+
it('keeps inline copy identical to the generated catalog', () => {
333+
expect(format(result.drift)).toBe('');
334+
});
335+
336+
it('supplies every value the copy interpolates', () => {
337+
expect(format(result.missingInterpolation)).toBe('');
338+
});
339+
340+
it('passes count for every plural key', () => {
341+
expect(format(result.pluralWithoutCount)).toBe('');
342+
});
343+
});

0 commit comments

Comments
 (0)