From 6b092c2daa314822bd43280e1cd2662da2b7d3b5 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 28 Aug 2026 01:17:26 +0200 Subject: [PATCH] fix: wrongly migrated translations --- ai-docs/i18n-v10-migration.md | 7 +- .../AudioRecorder/AudioRecordingButton.tsx | 10 +- package/src/i18n/__tests__/callSites.test.ts | 343 ++++++++++++++++++ .../i18n/__tests__/dateNormalization.test.ts | 88 +++++ package/src/i18n/keys.ts | 2 - package/src/i18n/runtimeDefaults.ts | 2 - package/src/i18n/utils.ts | 73 +++- .../src/utils/__tests__/Streami18n.test.ts | 226 ++++++++++++ package/src/utils/i18n/Streami18n.ts | 85 ++++- 9 files changed, 821 insertions(+), 15 deletions(-) create mode 100644 package/src/i18n/__tests__/callSites.test.ts create mode 100644 package/src/i18n/__tests__/dateNormalization.test.ts diff --git a/ai-docs/i18n-v10-migration.md b/ai-docs/i18n-v10-migration.md index fd54066fcd..4a0d5ce6d8 100644 --- a/ai-docs/i18n-v10-migration.md +++ b/ai-docs/i18n-v10-migration.md @@ -192,9 +192,10 @@ relative date renders the plugin's built-in English scaffolding around a transla > Last **Mittwoch** at 5:10 PM -The SDK applies its own `calendar` block to `en` internally for exactly this reason. That the field is plugin-owned is -also why `DayjsLocaleConfig` is exported: typing the argument as a bare `Partial` makes passing a calendar -config a TS2345 "no properties in common" error. +The SDK applies its own `calendar` block to `en` internally for exactly this reason; it is exported as +`englishCalendarFormats`, so a language can start from it rather than transcribing the six slots. That the field is +plugin-owned is also why `DayjsLocaleConfig` is exported: typing the argument as a bare `Partial` makes passing +a calendar config a TS2345 "no properties in common" error. ```ts import { Streami18n, type DayjsLocaleConfig } from 'stream-chat-react-native'; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 49886d52c3..461c97b469 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -105,7 +105,10 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps if (!recording) { NativeHandlers.triggerHaptic('notificationError'); addNotification({ - message: 'messageInput.audioRecorder.holdToRecord.text', + message: t( + 'messageInput.audioRecorder.holdToRecord.text', + 'Hold to record. Release to save.', + ), options: { severity: 'info', type: 'validation:audio:recording:hold-required', @@ -128,7 +131,10 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps const permissionsGranted = await startVoiceRecording(); if (!permissionsGranted) { addNotification({ - message: 'messageInput.audioRecorder.permissionDenied.text', + message: t( + 'messageInput.audioRecorder.permissionDenied.text', + 'Please allow Audio permissions in settings.', + ), options: { actions: [ { diff --git a/package/src/i18n/__tests__/callSites.test.ts b/package/src/i18n/__tests__/callSites.test.ts new file mode 100644 index 0000000000..fccc569b35 --- /dev/null +++ b/package/src/i18n/__tests__/callSites.test.ts @@ -0,0 +1,343 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import ts from 'typescript'; + +import catalogJson from './catalog.fixture.json'; + +import { runtimeDefaults } from '../runtimeDefaults'; + +/** + * Every `t()` call site, checked against the catalog. + * + * `catalogRenders.test.ts` proves each key *can* render; it says nothing about whether the call + * sites ask for the right thing. These are the failures it cannot see, all of which ship a visibly + * wrong string rather than an error: + * + * - a dotted key handed to something that renders it verbatim, so the user reads + * `messageInput.audioRecorder.holdToRecord.text` off the screen + * - `t('key')` with neither an inline default nor a bundled value — i18next misses and returns the key + * - copy that interpolates `{{name}}` while the call site passes `{ user }`, leaving `{{name}}` on screen + * - a plural key called without `count`, so i18next cannot pick a form + * + * Parsing the source is the only way to see any of it: each one is a mismatch between two files that + * individually type-check. + */ + +const SRC = path.resolve(__dirname, '../..'); +const catalog = catalogJson as Record; +const bundledKeys = new Set(Object.keys(runtimeDefaults)); + +/** Callables that translate their first argument. None of them takes an inline default. */ +const TRANSLATING = new Set(['t', 'translate', 'useA11yLabel']); + +const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/; +const catalogKeys = new Set(Object.keys(catalog)); +const pluralBases = new Set( + Object.keys(catalog) + .filter((key) => PLURAL_SUFFIX.test(key)) + .map((key) => key.replace(PLURAL_SUFFIX, '')), +); +const resolvable = (key: string) => catalogKeys.has(key) || pluralBases.has(key); +const copyFor = (key: string) => + catalog[key] ?? catalog[`${key}_other`] ?? catalog[`${key}_one`] ?? undefined; + +/** `{{ x | fmt }}` resolves through a formatter, so its placeholder is not a caller's to supply. */ +const isFormatterExpression = (copy: string) => /\{\{[^}]*\|[^}]*\}\}/.test(copy); +const placeholders = (copy: string) => + [...copy.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)].map((m) => m[1]); + +const sourceFiles = (() => { + const found: string[] = []; + const 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) && + // Both are catalogs of keys, so every string in them would look like an untranslated use. + entry.name !== 'keys.ts' && + entry.name !== 'runtimeDefaults.ts' + ) { + found.push(full); + } + } + }; + walk(SRC); + return found; +})(); + +type Finding = { detail: string; where: string }; + +const calleeName = (node: ts.CallExpression) => { + const callee = node.expression; + if (ts.isIdentifier(callee)) return callee.text; + if (ts.isPropertyAccessExpression(callee)) return callee.name.text; + return undefined; +}; + +/** `asDynamicKey(x)` is a branding wrapper; the key is what it wraps. */ +const unwrapDynamic = (node: ts.Expression | undefined) => + node && + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'asDynamicKey' + ? node.arguments[0] + : node; + +const objectPropNames = (node: ts.Node | undefined) => + node && ts.isObjectLiteralExpression(node) + ? node.properties + .map((p) => + p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) + ? p.name.text + : undefined, + ) + .filter((n): n is string => !!n) + : []; + +const audit = () => { + const rawKeyLeaks: Finding[] = []; + const unknownKeys: Finding[] = []; + const unresolvable: Finding[] = []; + const drift: Finding[] = []; + const missingInterpolation: Finding[] = []; + const pluralWithoutCount: Finding[] = []; + + /** + * Identifiers whose value reaches a translating call somewhere in the SDK, so a key literal + * assigned to one is translated even though its own line has no `t()` on it — a lookup table + * (`SUBTITLE_KEY[type]`) or an exported constant. + */ + const translatedRoots = new Set(); + const parsed = sourceFiles.map((file) => ({ + file, + sf: ts.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ), + })); + + for (const { sf } of parsed) { + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node) && TRANSLATING.has(calleeName(node) ?? '')) { + const arg = unwrapDynamic(node.arguments[0]); + if (arg && !ts.isStringLiteral(arg)) { + let root: ts.Node = arg; + while ( + ts.isPropertyAccessExpression(root) || + ts.isElementAccessExpression(root) || + ts.isNonNullExpression(root) || + ts.isParenthesizedExpression(root) + ) { + if (ts.isPropertyAccessExpression(root)) translatedRoots.add(root.name.text); + root = root.expression; + } + if (ts.isIdentifier(root)) translatedRoots.add(root.text); + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + } + + for (const { file, sf } of parsed) { + const rel = path.relative(SRC, file); + const at = (node: ts.Node) => + `${rel}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}`; + + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node)) { + const name = calleeName(node); + + if (name === 't' || name === 'translate') { + const keyNode = unwrapDynamic(node.arguments[0]); + if (keyNode && ts.isStringLiteral(keyNode)) { + const key = keyNode.text; + const second = node.arguments[1]; + const inlineDefault = second && ts.isStringLiteral(second) ? second.text : undefined; + const options = objectPropNames( + second && ts.isObjectLiteralExpression(second) ? second : node.arguments[2], + ); + + if (!resolvable(key)) { + unknownKeys.push({ detail: `t('${key}')`, where: at(node) }); + } else { + // A plural call site carries its copy as `defaultValue_one` / `defaultValue_other` + // inside the options object rather than as the second argument. + const hasDefault = + !!inlineDefault || options.some((o) => o.startsWith('defaultValue')); + if (!hasDefault && !bundledKeys.has(key)) { + unresolvable.push({ + detail: `t('${key}') — no inline default, not in runtimeDefaults`, + where: at(node), + }); + } + if (inlineDefault && catalog[key] !== undefined && inlineDefault !== catalog[key]) { + drift.push({ + detail: `t('${key}')\n inline : ${JSON.stringify(inlineDefault)}\n catalog: ${JSON.stringify(catalog[key])}`, + where: at(node), + }); + } + const copy = copyFor(key); + if (copy && !isFormatterExpression(copy)) { + const missing = placeholders(copy).filter((v) => !options.includes(v)); + if (missing.length) { + missingInterpolation.push({ + detail: `t('${key}') needs {{${missing.join('}}, {{')}}}; options supply [${options.join(', ')}]`, + where: at(node), + }); + } + } + if (pluralBases.has(key) && !options.includes('count')) { + pluralWithoutCount.push({ detail: `t('${key}')`, where: at(node) }); + } + } + } + } + + if (name === 'useA11yLabel') { + const arg = node.arguments[0]; + if (arg && ts.isStringLiteral(arg)) { + if (!resolvable(arg.text)) + unknownKeys.push({ detail: `useA11yLabel('${arg.text}')`, where: at(node) }); + else if (!bundledKeys.has(arg.text)) { + unresolvable.push({ + detail: `useA11yLabel('${arg.text}') — the hook passes no inline default, so the key must be bundled`, + where: at(node), + }); + } else { + const needed = placeholders(copyFor(arg.text) ?? ''); + if (needed.length && !node.arguments[1]) { + missingInterpolation.push({ + detail: `useA11yLabel('${arg.text}') needs {{${needed.join('}}, {{')}}} but passes no params`, + where: at(node), + }); + } + } + } + } + } + + if (ts.isStringLiteral(node) && node.text.includes('.') && resolvable(node.text)) { + // Climb past pass-through syntax (ternaries, `??`, parens, JSX braces) to the slot that names + // this value, then decide whether that slot is translated downstream. + let parent: ts.Node = node.parent; + let child: ts.Node = node; + let translated = false; + while (parent) { + if (ts.isCallExpression(parent) && TRANSLATING.has(calleeName(parent) ?? '')) { + const first = parent.arguments[0]; + if (first === child || unwrapDynamic(first) === child) translated = true; + } + if ( + !( + ts.isConditionalExpression(parent) || + ts.isParenthesizedExpression(parent) || + ts.isBinaryExpression(parent) || + ts.isJsxExpression(parent) || + ts.isAsExpression(parent) || + ts.isCallExpression(parent) + ) + ) { + break; + } + child = parent; + parent = parent.parent; + } + + if (!translated) { + let slot: string | undefined; + if (ts.isJsxAttribute(parent)) slot = parent.name.getText(sf); + else if (ts.isPropertyAssignment(parent)) slot = parent.name.getText(sf); + else if (ts.isBindingElement(parent) && parent.name) slot = parent.name.getText(sf); + else if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) + slot = parent.name.text; + + // A `*Key` slot is translated by whoever receives it (`Button`'s `accessibilityLabelKey`, + // `getDateString`'s `timestampTranslationKey`). Those receivers pass no inline default, so + // the key has to be bundled. + if (slot && /Key$/.test(slot)) { + if (!bundledKeys.has(node.text)) { + unresolvable.push({ + detail: `${slot}='${node.text}' — translated without an inline default, so it must be bundled`, + where: at(node), + }); + } + } else { + const names: string[] = []; + let walker: ts.Node | undefined = node.parent; + while (walker) { + if (ts.isPropertyAssignment(walker) && walker.name) + names.push(walker.name.getText(sf)); + if (ts.isVariableDeclaration(walker) && ts.isIdentifier(walker.name)) { + names.push(walker.name.text); + break; + } + walker = walker.parent; + } + if (!names.some((n) => translatedRoots.has(n))) { + rawKeyLeaks.push({ + detail: `'${node.text}' in ${slot ? `slot '${slot}'` : ts.SyntaxKind[parent?.kind]} — never reaches t()`, + where: at(node), + }); + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + } + + return { + drift, + missingInterpolation, + pluralWithoutCount, + rawKeyLeaks, + unknownKeys, + unresolvable, + }; +}; + +const format = (findings: Finding[]) => + findings.map((f) => ` ${f.where}\n ${f.detail}`).join('\n'); + +describe('translation call sites', () => { + const result = audit(); + + it('scanned the source tree', () => { + // Guards against the walk silently finding nothing and every assertion below passing vacuously. + expect(sourceFiles.length).toBeGreaterThan(500); + expect(catalogKeys.size).toBeGreaterThan(300); + }); + + it('never hands a translation key to something that renders it verbatim', () => { + expect(format(result.rawKeyLeaks)).toBe(''); + }); + + it('only asks for keys the catalog has', () => { + expect(format(result.unknownKeys)).toBe(''); + }); + + it('always supplies copy, inline or bundled', () => { + expect(format(result.unresolvable)).toBe(''); + }); + + it('keeps inline copy identical to the generated catalog', () => { + expect(format(result.drift)).toBe(''); + }); + + it('supplies every value the copy interpolates', () => { + expect(format(result.missingInterpolation)).toBe(''); + }); + + it('passes count for every plural key', () => { + expect(format(result.pluralWithoutCount)).toBe(''); + }); +}); diff --git a/package/src/i18n/__tests__/dateNormalization.test.ts b/package/src/i18n/__tests__/dateNormalization.test.ts new file mode 100644 index 0000000000..f845e51ee0 --- /dev/null +++ b/package/src/i18n/__tests__/dateNormalization.test.ts @@ -0,0 +1,88 @@ +import { Streami18n } from '../../utils/i18n/Streami18n'; +import { getCalendarDateStringForA11y, getDateString } from '../utils'; + +/** + * Timestamps that reach the UI in a shape core's formatters cannot handle. + * + * The API expresses timestamps as nanoseconds since the epoch, and `stream-chat` converts them for + * the fields its response decoders name. `PollResponseData`'s decoder names `latest_answers` and + * `own_votes` but **not** `latest_votes_by_option`, so a poll vote's `created_at` arrives as a raw + * integer near 1e18 — measured on a device as `1787870023772367000`. That is past the largest value + * `new Date` accepts, so Day.js builds an invalid instance and `format()` renders the literal string + * `"Invalid Date"`, which is what the poll results screen showed next to the voter's name. + * + * Every date the SDK renders goes through these wrappers, so the guard belongs here rather than at + * any one of the ~14 call sites: the shape a timestamp arrived in is not something a call site can + * see. + */ +describe('date normalization', () => { + let t: Awaited>['t']; + let tDateTimeParser: Awaited>['tDateTimeParser']; + + beforeAll(async () => { + ({ t, tDateTimeParser } = await new Streami18n({ logger: () => {} }).init()); + }); + + const render = (messageCreatedAt: unknown, timestampTranslationKey: string) => + getDateString({ + // The declared type is `string | Date`; the whole point is that reality is wider. + messageCreatedAt: messageCreatedAt as string | Date, + t, + tDateTimeParser, + timestampTranslationKey, + }); + + it('renders a nanosecond timestamp as the instant it represents', () => { + const instant = Date.UTC(2026, 7, 20, 12, 0, 0); + const nanoseconds = instant * 1e6; + + // `timestamp.MessageTimestamp` formats as `LT`, so the assertion pins the actual instant rather + // than a relative word that depends on the clock. + expect(render(nanoseconds, 'timestamp.MessageTimestamp')).toBe( + render(new Date(instant), 'timestamp.MessageTimestamp'), + ); + expect(render(nanoseconds, 'timestamp.MessageTimestamp')).toBe('12:00 PM'); + }); + + it('is the regression case measured on device', () => { + // The exact value the poll results screen rendered as "Invalid Date". + expect(render(1787870023772367000, 'timestamp.PollVote')).not.toMatch(/Invalid Date/); + expect(render(1787870023772367000, 'timestamp.MessageTimestamp')).toBe( + render(new Date(1787870023772367000 / 1e6), 'timestamp.MessageTimestamp'), + ); + }); + + it('leaves a millisecond timestamp alone', () => { + // In range, so not rescaled — an integrator passing epoch millis through the public + // `getDateString` must keep working. + const instant = Date.UTC(2026, 7, 20, 12, 0, 0); + expect(render(instant, 'timestamp.MessageTimestamp')).toBe('12:00 PM'); + }); + + it('renders nothing rather than the words "Invalid Date"', () => { + // Out of range even after rescaling (anything past 8.64e15 nanoseconds-worth), so there is no + // instant to show. `null` is what every caller already treats as "omit the element". + expect(render(1e22, 'timestamp.MessageTimestamp')).toBeNull(); + // And the output guard catches an already-invalid Date, whatever produced it. + expect(render(new Date('nonsense'), 'timestamp.MessageTimestamp')).toBeNull(); + }); + + it('normalizes the accessibility date the same way', () => { + const instant = Date.UTC(2026, 7, 20, 12, 0, 0); + const spoken = getCalendarDateStringForA11y({ + messageCreatedAt: (instant * 1e6) as unknown as Date, + tDateTimeParser, + }); + + expect(spoken).not.toMatch(/Invalid Date/); + expect(spoken).toBe( + getCalendarDateStringForA11y({ messageCreatedAt: new Date(instant), tDateTimeParser }), + ); + }); + + it('still renders ordinary Date and ISO string inputs', () => { + const instant = new Date(Date.UTC(2026, 7, 20, 12, 0, 0)); + expect(render(instant, 'timestamp.MessageTimestamp')).toBe('12:00 PM'); + expect(render(instant.toISOString(), 'timestamp.MessageTimestamp')).toBe('12:00 PM'); + }); +}); diff --git a/package/src/i18n/keys.ts b/package/src/i18n/keys.ts index ac2dfc13a8..4ebf680c60 100644 --- a/package/src/i18n/keys.ts +++ b/package/src/i18n/keys.ts @@ -484,8 +484,6 @@ export type BundledTranslationKey = | 'message.status.sent.accessibilityLabel' | 'messageInput.addAttachment.accessibilityLabel' | 'messageInput.audioRecorder.delete.accessibilityLabel' - | 'messageInput.audioRecorder.holdToRecord.text' - | 'messageInput.audioRecorder.permissionDenied.text' | 'messageInput.audioRecorder.send.accessibilityLabel' | 'messageInput.audioRecorder.start.accessibilityLabel' | 'messageInput.audioRecorder.stop.accessibilityLabel' diff --git a/package/src/i18n/runtimeDefaults.ts b/package/src/i18n/runtimeDefaults.ts index c0b033ba9f..54c2507c45 100644 --- a/package/src/i18n/runtimeDefaults.ts +++ b/package/src/i18n/runtimeDefaults.ts @@ -111,8 +111,6 @@ export const runtimeDefaults = { 'message.status.sent.accessibilityLabel': 'Sent', 'messageInput.addAttachment.accessibilityLabel': 'Add attachment', 'messageInput.audioRecorder.delete.accessibilityLabel': 'Delete voice recording', - 'messageInput.audioRecorder.holdToRecord.text': 'Hold to record. Release to save.', - 'messageInput.audioRecorder.permissionDenied.text': 'Please allow Audio permissions in settings.', 'messageInput.audioRecorder.send.accessibilityLabel': 'Send voice recording', 'messageInput.audioRecorder.start.accessibilityLabel': 'Start voice recording', 'messageInput.audioRecorder.stop.accessibilityLabel': 'Stop voice recording', diff --git a/package/src/i18n/utils.ts b/package/src/i18n/utils.ts index a0ae3537bf..86eaf2251a 100644 --- a/package/src/i18n/utils.ts +++ b/package/src/i18n/utils.ts @@ -1,4 +1,9 @@ -import { createDefaultTranslatorFunction } from 'stream-chat/i18n'; +import { + createDefaultTranslatorFunction, + getCalendarDateStringForA11y as coreGetCalendarDateStringForA11y, + getDateString as coreGetDateString, + getDateStringForA11y as coreGetDateStringForA11y, +} from 'stream-chat/i18n'; import type { StreamTFunction } from './types'; @@ -24,11 +29,71 @@ export const defaultTranslatorFunction: StreamTFunction = export { asDynamicKey, defaultDateTimeParser, - getCalendarDateStringForA11y, - getDateString, - getDateStringForA11y, isDate, isDayOrMoment, isNumberOrString, predefinedFormatters, } from 'stream-chat/i18n'; + +/** + * The largest value `new Date(ms)` accepts before it clips to an invalid instance. + * ECMA-262 `TimeClip`, 8.64e15 ms — about ±273,790 years. + */ +const MAX_TIME_VALUE = 8.64e15; + +/** + * Rescales a timestamp that arrived in nanoseconds. + * + * The API expresses timestamps as nanoseconds since the epoch and `stream-chat` converts them on the + * way in — but only for the fields its response decoders name. `latest_votes_by_option` is not one of + * them (unlike `latest_answers` and `own_votes` beside it), so a poll vote's `created_at` reaches the + * UI as a raw integer around 1e18. That is past `MAX_TIME_VALUE`, so Day.js builds an invalid instance + * and `format()` renders the literal string `"Invalid Date"` next to the voter's name. + * + * Only out-of-range numbers are touched, so a millisecond timestamp an integrator passes through the + * public `getDateString` keeps working. The `1e6` divisor is the conversion core's own `DatetimeType` + * decoder applies, so a rescaled value lands on the same instant core would have produced. + */ +const normalizeTimestamp = (value: T): T | Date | undefined => { + if (typeof value !== 'number' || Math.abs(value) <= MAX_TIME_VALUE) return value; + + const milliseconds = Math.floor(value / 1e6); + return Math.abs(milliseconds) <= MAX_TIME_VALUE ? new Date(milliseconds) : undefined; +}; + +/** + * `null` means "nothing renderable", which every caller already handles by omitting the element. + * `"Invalid Date"` is what Day.js formats an unparseable instance into, and it reaches the screen as + * copy. Whatever shape produced it, showing nothing is the better failure. + */ +const withoutInvalidDate = (result: T) => + typeof result === 'string' && result.includes('Invalid Date') ? null : result; + +/** + * This SDK's date formatters: core's, with the timestamp normalized on the way in and an invalid + * result suppressed on the way out. Wrapped rather than fixed at the ~14 call sites, because the + * shape a timestamp arrives in is not something a call site can see. + */ +export const getDateString: typeof coreGetDateString = ({ messageCreatedAt, ...rest }) => + withoutInvalidDate( + coreGetDateString({ ...rest, messageCreatedAt: normalizeTimestamp(messageCreatedAt) }), + ); + +export const getDateStringForA11y: typeof coreGetDateStringForA11y = ({ + messageCreatedAt, + ...rest +}) => + withoutInvalidDate( + coreGetDateStringForA11y({ ...rest, messageCreatedAt: normalizeTimestamp(messageCreatedAt) }), + ); + +export const getCalendarDateStringForA11y: typeof coreGetCalendarDateStringForA11y = ({ + messageCreatedAt, + ...rest +}) => { + const result = coreGetCalendarDateStringForA11y({ + ...rest, + messageCreatedAt: normalizeTimestamp(messageCreatedAt), + }); + return withoutInvalidDate(result) ?? undefined; +}; diff --git a/package/src/utils/__tests__/Streami18n.test.ts b/package/src/utils/__tests__/Streami18n.test.ts index 9d26b50124..85af2a3a1e 100644 --- a/package/src/utils/__tests__/Streami18n.test.ts +++ b/package/src/utils/__tests__/Streami18n.test.ts @@ -1,4 +1,12 @@ +import Dayjs from 'dayjs'; +import 'dayjs/locale/de'; + +import { addOrUpdateDayjsLocale } from 'stream-chat/i18n'; +import type { CalendarFormats } from 'stream-chat/i18n'; + import { runtimeDefaults } from '../../i18n/runtimeDefaults'; +import { getDateString } from '../../i18n/utils'; +import { englishCalendarFormats, Streami18n } from '../i18n/Streami18n'; describe('Jest Timezone', () => { it('global config should set the timezone to UTC', () => { @@ -52,3 +60,221 @@ describe('runtimeDefaults', () => { ]); }); }); + +describe('English calendar wording', () => { + // Every `timestamp.*` key formatted with `calendar: true` and no `calendarFormats` of its own. The + // other two calendar keys carry their own formats and so never read the locale. + const CALENDAR_KEYS = [ + 'timestamp.ImageGalleryHeader', + 'timestamp.InlineDateSeparator', + 'timestamp.MessageSystem', + 'timestamp.StickyHeader', + ] as const; + + /** + * Noon, so the calendar bucket a date falls into does not depend on the wall clock: dayjs measures + * the difference from the start of today, and ±12h can never cross into the neighbouring bucket. + */ + const noonOffsetByDays = (days: number) => { + const date = new Date(); + date.setUTCHours(12, 0, 0, 0); + date.setUTCDate(date.getUTCDate() + days); + return date; + }; + + /** Spelled out through `Intl` rather than dayjs, so the expectation is independent of the subject. */ + const weekdayName = (date: Date, locale = 'en') => + new Intl.DateTimeFormat(locale, { timeZone: 'UTC', weekday: 'long' }).format(date); + + let t: Streami18n['t']; + let tDateTimeParser: Streami18n['tDateTimeParser']; + + const render = (key: string, date: Date) => + getDateString({ messageCreatedAt: date, t, tDateTimeParser, timestampTranslationKey: key }); + + beforeAll(async () => { + const i18n = new Streami18n({ logger: () => {} }); + ({ t, tDateTimeParser } = await i18n.init()); + }); + + it.each(CALENDAR_KEYS)('%s renders the day alone, with no time appended', (key) => { + // The regression these guard: dayjs's own calendar defaults are `[Today at] h:mm A`, + // `[Last] dddd [at] h:mm A` and so on, which is what renders when the `en` locale carries no + // calendar config. + expect(render(key, noonOffsetByDays(0))).toBe('Today'); + expect(render(key, noonOffsetByDays(-1))).toBe('Yesterday'); + expect(render(key, noonOffsetByDays(1))).toBe('Tomorrow'); + + const lastWeek = noonOffsetByDays(-3); + expect(render(key, lastWeek)).toBe(weekdayName(lastWeek)); + }); + + describe('is a default, not an override', () => { + // Each of these writes to the shared dayjs `en` locale, so it is put back afterwards. Applying all + // six slots is a full reset: `updateLocale` replaces the `calendar` key wholesale. + afterEach(() => { + addOrUpdateDayjsLocale('en', { calendar: englishCalendarFormats }); + }); + + // Read off `init()` rather than written as `Streami18nState`: the bare type defaults its catalog to + // `AnyTranslationCatalog`, and `t` is contravariant in its options, so the concrete one does not + // assign to it. + const stickyHeader = ( + { t, tDateTimeParser }: Awaited>, + date: Date, + ) => + getDateString({ + messageCreatedAt: date, + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp.StickyHeader', + }); + + it('yields to a config the app registered on dayjs itself', async () => { + // What the v10 migration guide suggests for an app bringing its own `DateTimeParser`, and what + // this SDK used to do at module scope. It runs before the constructor, so it has to survive it. + addOrUpdateDayjsLocale('en', { calendar: { sameDay: '[Right now]' } as CalendarFormats }); + + const state = await new Streami18n({ logger: () => {} }).init(); + + expect(stickyHeader(state, noonOffsetByDays(0))).toBe('Right now'); + // Slots the app left alone still get the bundled wording rather than dayjs's defaults. + expect(stickyHeader(state, noonOffsetByDays(-1))).toBe('Yesterday'); + }); + + it('yields to dayjsLocaleConfigForLanguage, per slot', async () => { + const state = await new Streami18n({ + dayjsLocaleConfigForLanguage: { calendar: { sameDay: '[Right now]' } as CalendarFormats }, + logger: () => {}, + }).init(); + + expect(stickyHeader(state, noonOffsetByDays(0))).toBe('Right now'); + expect(stickyHeader(state, noonOffsetByDays(-1))).toBe('Yesterday'); + }); + + it('does not reach a language with its own dayjs locale and calendar config', async () => { + const state = await new Streami18n({ + dayjsLocaleConfigForLanguage: { + calendar: { + lastDay: '[gestern]', + lastWeek: 'dddd', + nextDay: '[morgen]', + nextWeek: 'dddd [um] LT', + sameDay: '[heute]', + sameElse: 'L', + }, + }, + language: 'de', + logger: () => {}, + }).init(); + + expect(stickyHeader(state, noonOffsetByDays(0))).toBe('heute'); + expect(stickyHeader(state, noonOffsetByDays(-1))).toBe('gestern'); + expect(stickyHeader(state, noonOffsetByDays(-3))).toBe( + weekdayName(noonOffsetByDays(-3), 'de'), + ); + }); + + it('yields to registerTranslation, before init', async () => { + const i18n = new Streami18n({ logger: () => {} }); + i18n.registerTranslation( + 'en', + {}, + { calendar: { sameDay: '[Right now]' } as CalendarFormats }, + ); + + const state = await i18n.init(); + + expect(stickyHeader(state, noonOffsetByDays(0))).toBe('Right now'); + expect(stickyHeader(state, noonOffsetByDays(-1))).toBe('Yesterday'); + }); + + it('yields to a config applied on the parser after construction', async () => { + const state = await new Streami18n({ logger: () => {} }).init(); + addOrUpdateDayjsLocale('en', { calendar: { sameDay: '[Right now]' } as CalendarFormats }); + + expect(stickyHeader(state, noonOffsetByDays(0))).toBe('Right now'); + }); + + it('yields to a preconfigured DateTimeParser the integrator brought', async () => { + // The `DateTimeParser` route: we write to *their* module's registry, so their own `en` calendar + // has to be read off that module rather than assumed absent. + addOrUpdateDayjsLocale( + 'en', + { calendar: { sameDay: '[Right now]' } as CalendarFormats }, + Dayjs, + ); + + const state = await new Streami18n({ DateTimeParser: Dayjs, logger: () => {} }).init(); + + expect(stickyHeader(state, noonOffsetByDays(0))).toBe('Right now'); + expect(stickyHeader(state, noonOffsetByDays(-1))).toBe('Yesterday'); + }); + + it('does not touch a key that carries its own calendarFormats', async () => { + // A per-key `calendarFormats` replaces the locale's calendar wholesale, so this route bypasses + // the dayjs locale entirely — including anything we put there. + const state = await new Streami18n({ + logger: () => {}, + translationsForLanguage: { + 'timestamp.StickyHeader': + '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {"lastDay":"[Mine: lastDay]", "lastWeek":"dddd", "nextDay":"[Mine: nextDay]", "nextWeek":"dddd", "sameDay":"[Mine: sameDay]", "sameElse":"L"}) }}', + }, + }).init(); + + expect(stickyHeader(state, noonOffsetByDays(0))).toBe('Mine: sameDay'); + expect(stickyHeader(state, noonOffsetByDays(-1))).toBe('Mine: lastDay'); + }); + + it('does not touch a replaced timestampFormatter', async () => { + const state = await new Streami18n({ + formatters: { timestampFormatter: () => () => 'from my own formatter' }, + logger: () => {}, + }).init(); + + expect(stickyHeader(state, noonOffsetByDays(0))).toBe('from my own formatter'); + }); + + it('survives setLanguage in both directions', async () => { + const i18n = new Streami18n({ logger: () => {} }); + i18n.registerTranslation( + 'de', + {}, + { + calendar: { + lastDay: '[gestern]', + lastWeek: 'dddd', + nextDay: '[morgen]', + nextWeek: 'dddd [um] LT', + sameDay: '[heute]', + sameElse: 'L', + }, + }, + ); + await i18n.init(); + + expect(stickyHeader(i18n.state.getLatestValue(), noonOffsetByDays(0))).toBe('Today'); + + await i18n.setLanguage('de'); + expect(stickyHeader(i18n.state.getLatestValue(), noonOffsetByDays(0))).toBe('heute'); + + // Back to English: our block is still there, and the German one did not overwrite it. + await i18n.setLanguage('en'); + expect(stickyHeader(i18n.state.getLatestValue(), noonOffsetByDays(0))).toBe('Today'); + }); + + it('does not leak into a language whose dayjs locale carries no calendar config', async () => { + // No dayjs locale file defines `calendar`, so this app gets the plugin's own English scaffolding + // around German day names — the case core logs a warning for. The point here is only that the + // scaffolding is dayjs's and not ours: the SDK's `en` block must not stand in for a missing `de` + // one, because that would be the SDK forcing English on a translated app. + const state = await new Streami18n({ language: 'de', logger: () => {} }).init(); + + expect(stickyHeader(state, noonOffsetByDays(0))).not.toBe('Today'); + expect(stickyHeader(state, noonOffsetByDays(-1))).not.toBe('Yesterday'); + expect(stickyHeader(state, noonOffsetByDays(-3))).toContain( + weekdayName(noonOffsetByDays(-3), 'de'), + ); + }); + }); +}); diff --git a/package/src/utils/i18n/Streami18n.ts b/package/src/utils/i18n/Streami18n.ts index 6a1c24e259..dc5cde7d3e 100644 --- a/package/src/utils/i18n/Streami18n.ts +++ b/package/src/utils/i18n/Streami18n.ts @@ -1,10 +1,71 @@ -import { languageNameDefaults, Streami18n as CoreStreami18n } from 'stream-chat/i18n'; -import type { Streami18nOptions as CoreStreami18nOptions } from 'stream-chat/i18n'; +import { + addOrUpdateDayjsLocale, + DEFAULT_LANGUAGE, + isDayjsLike, + languageNameDefaults, + Streami18n as CoreStreami18n, +} from 'stream-chat/i18n'; +import type { + CalendarFormats, + Streami18nOptions as CoreStreami18nOptions, + DateTimeParserModule, +} from 'stream-chat/i18n'; import type { BundledTranslationKey } from '../../i18n/keys'; import { runtimeDefaults } from '../../i18n/runtimeDefaults'; import type { TranslationCatalog } from '../../i18n/types'; +/** + * The English calendar wording the SDK ships, for the dayjs calendar plugin. + * + * Bundled data, the same way `runtimeDefaults` is, and layered the same way: underneath anything an + * integrator supplies. `timestamp.*` keys formatted with `timestampFormatter(calendar: true)` and no + * `calendarFormats` argument of their own read this off the active dayjs locale, and nothing else + * supplies it — no locale file defines `calendar`, and dayjs already has `en` registered, so core's + * locale fallback never gets a chance to fill it in. Without it the plugin's own defaults apply and a + * date separator reads "Today at 3:04 PM" where every previous version read "Today". + * + * Exported so a new language can be built from it (`{ ...englishCalendarFormats, sameDay: '[heute]' }`) + * rather than transcribed. + */ +export const englishCalendarFormats: CalendarFormats = { + lastDay: '[Yesterday]', + lastWeek: 'dddd', + nextDay: '[Tomorrow]', + nextWeek: 'dddd [at] LT', + sameDay: '[Today]', + sameElse: 'L', +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +/** + * The calendar slots already registered on dayjs's `en` locale. + * + * Read back rather than assumed empty, because {@link englishCalendarFormats} is a default and must not + * overwrite a choice already made. Two things get there before this runs: core applies + * `dayjsLocaleConfigForLanguage` inside its own constructor, and an app is free to call + * `Dayjs.updateLocale('en', { calendar })` itself — which the v10 migration guide suggests for anyone + * bringing their own `DateTimeParser`. + * + * Filtered to the six known slots with string values: whatever is in the registry came from outside and + * is typed `unknown`, and a stray key would be spread straight into a config dayjs then formats with. + */ +const registeredEnglishCalendar = (parser: DateTimeParserModule): Partial => { + const registry = 'Ls' in parser ? parser.Ls : undefined; + if (!isRecord(registry)) return {}; + + const locale = registry[DEFAULT_LANGUAGE]; + if (!isRecord(locale) || !isRecord(locale.calendar)) return {}; + + const slots: [string, string][] = []; + for (const [slot, format] of Object.entries(locale.calendar)) { + if (slot in englishCalendarFormats && typeof format === 'string') slots.push([slot, format]); + } + return Object.fromEntries(slots); +}; + /** * Options for {@link Streami18n}. * @@ -60,5 +121,25 @@ export class Streami18n extends CoreStreami18n