refactor(i18n): adopt named interpolation and react-i18next's useTranslation
Proposed changes (including videos or screenshots)
Groundwork for the i18next upgrade, in two related parts.
1. Adopt useTranslation from react-i18next in ui-client, web-ui-registration and fuselage-ui-kit, replacing the deprecated re-export from @rocket.chat/ui-contexts.
2. Migrate 19 translation keys from sprintf %s to i18next named interpolation ({{name}}), across every locale that defines them, along with all their call sites.
The second part is a prerequisite for finishing the first. The hook exported by ui-contexts returns a translate function wrapped by addSprinfToI18n, so t('Max_length_is', 120) works today. react-i18next's t has no such wrapper — those call sites keep type-checking in some positions but stop substituting, silently rendering a literal %s. Converting the keys first makes the remaining hook migration purely mechanical.
There is a second benefit. packages/i18n's missing-placeholders check validates every locale's {{…}} placeholders against the base language, but cannot see anything about %s. Each key converted here moves from unverifiable to CI-enforced — which is how most of the bugs below were found.
Rendering bugs fixed along the way
All of these predate this PR and were invisible precisely because %s is unvalidated. 23 placeholders repaired:
| Locale | Wrote | Rendered |
|---|---|---|
zh-HK (18 keys) | %s — fullwidth percent (U+FF05) | literal %s |
sr (2 keys) | % с — Cyrillic es, split by a space | literal % с |
hi-IN | %एस — Devanagari transliteration | literal %एस |
ta-IN | % கள் — Tamil transliteration | literal % கள் |
be-BY | % з — Cyrillic ze, split by a space | literal % з |
Plus one semantic fix: es had The_user_will_be_removed_from_s as "El usuario %s se eliminará de %s", a copy-paste of the line directly above it (The_user_s_will_be_removed_from_role_s, which legitimately takes two placeholders). With only one argument supplied, Spanish users saw the room name in the wrong slot followed by a literal undefined.
Translations dropped — these need re-translation
Eight entries could not render their message regardless of placeholder syntax, so they were removed and now fall back to en. All were already broken; missing-placeholders reports them once the keys are named, and repairing them would have meant authoring grammar in languages I cannot verify.
| Locale | Key | Why |
|---|---|---|
th-TH | The_setting_s_is_configured_to_s_and_you_are_accessing_from_s | sentence truncated mid-clause, 1 of 3 slots |
ta-IN | The_setting_s_is_configured_to_s_and_you_are_accessing_from_s | markup shredded, % and s orphaned across nested <strong> |
he | The_setting_s_is_configured_to_s_and_you_are_accessing_from_s | RTL mangling split %s into % … S, 2 of 3 slots |
ta-IN | Prune_Warning_after | 4 slots against 3; surplus rendered undefined |
ku | Prune_Warning_before | 4 slots against 3; surplus rendered undefined |
ja, ka-GE, mn | Prune_Warning_between | omit the room name and reorder, so dates landed where the room name belongs |
Keys migrated
Max_length_is · Min_length_is · The_user_wont_be_able_to_type_in_s · The_user_will_be_removed_from_s · User_has_been_removed_from_s · Channel_already_exist · Custom_oauth_helper · The_setting_s_is_configured_to_s_and_you_are_accessing_from_s · Do_you_want_to_change_to_s_question · Showing_results_of · if_they_are_from · Prune_Warning_all · Prune_Warning_after · Prune_Warning_before · Prune_Warning_between · The_user_s_will_be_removed_from_role_s · Users_Table_Generic_No_users · Mail_Messages_Subject · Mail_Message_Invalid_emails
One commit per key, so any single migration can be reviewed or reverted on its own.
Issue(s)
Steps to test or reproduce
Each migrated key is behaviour-preserving in en, so the useful check is that placeholders still substitute and that the repaired locales now render a value instead of a literal %s. Switching to 中文 (香港) exercises 18 of the 23 repairs.
- Admin → Users → Edit — set a status message or bio over the limit; the validation message should read a number, not
%s - Admin → Users — filter to Pending/Deactivated with no results; the empty state names the tab
- Admin → Permissions → a role → remove a user — the modal names the user and the role
- Admin → Settings → OAuth → a custom provider — the callback-URL helper shows the URL
- Kebab → Prune Messages — set a date range, plus "Only prune content from these users"; the warning names the room, the dates and the users
- Kebab → Export Messages → Send email — the subject names the room; enter an invalid address to see the error list them
- User card → Mute / Remove from room — the confirmation names the room
- Create a channel with an existing name — the validator names the channel;
/create <existing>covers the server path - Access the workspace on a URL other than
Site_Url— the warning names the setting and both URLs - Admin → Import, and any admin table pagination — "Showing results 1 - 25 of 100"
Further comments
Placeholder naming follows what each key's neighbours already use — {{roomName}} (56 prior uses), {{username}} (41), {{url}} (5). Two were deliberate:
Showing_results_ofuses{{total}}, not{{count}}.countis i18next's pluralization trigger; it happens to work here only because no plural-suffixed variants of the key exist. The siblingShowing_current_of_totalalready uses{{total}}.Channel_already_existuses{{channelName}}rather than the more common{{roomName}}, matching the two existing#{{channelName}}templates since this one hardcodes the#.
Escaping is unchanged on the client: TranslationProvider sets interpolation.escapeValue: false, so the three keys feeding DOMPurify.sanitize receive byte-identical input. The one exception is Channel_already_exist's server call site in /create, where the server i18next instance does escape — a no-op for channel names under the default UTF8_Channel_Names_Validation regex ([0-9a-zA-Z-_.]+), and consistent with existing server-side named interpolation such as exportRoomMessagesToFile.
Positional mapping is safe. No locale anywhere uses %1$s, so sprintf already substituted strictly left to right; replacing the Nth %s with the Nth name is exactly behaviour-preserving.
replace-sprintf has a gap. yarn workspace @rocket.chat/i18n replace-sprintf <key> does this same migration interactively, but matches only ASCII %s — it would have skipped all 23 manglings above without reporting them. Worth teaching it the fullwidth and transliterated variants, and making it fail loudly on a locale it cannot convert, before the remaining keys are done.
Still on sprintf, deliberately out of scope. Three client call sites translate a key supplied at runtime rather than written literally: ActionInputBase and useUserBanners translate a key plus a positional argument array received from the server, and TranslationContextMock implements the post-processor so Storybook mirrors the real provider. type: 'action' settings return { message: TranslationKey; params?: string[] } from a Meteor method or REST endpoint, so converting those means changing that contract on both sides at once — a coordinated change rather than a call-site edit. Every client call site passing a literal key is now on named interpolation. 56 keys still contain %s in en; the remainder are server-only or unreferenced.
Summary by CodeRabbit
- Bug Fixes
- Improved dynamic value interpolation in warnings, confirmations, validation messages, pagination labels, imports, exports, and moderation dialogs.
- Ensured channel names, URLs, limits, usernames, dates, and other values display consistently in translated content.
- Improved translated labels and actions across setup, registration, account, and conferencing screens.
- Localization
- Updated supported translations to use named placeholders for more reliable formatting.
- Removed obsolete translation entries where applicable.
Task: ARCH-2329