refactor(i18n): adopt named interpolation and react-i18next's useTranslation

Merged
TA
3mo ago
+1007-1049

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:

LocaleWroteRendered
zh-HK (18 keys)%s — fullwidth percent (U+FF05)literal %s
sr (2 keys)% с — Cyrillic es, split by a spaceliteral % с
hi-IN%एस — Devanagari transliterationliteral %एस
ta-IN% கள் — Tamil transliterationliteral % கள்
be-BY% з — Cyrillic ze, split by a spaceliteral % з

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.

LocaleKeyWhy
th-THThe_setting_s_is_configured_to_s_and_you_are_accessing_from_ssentence truncated mid-clause, 1 of 3 slots
ta-INThe_setting_s_is_configured_to_s_and_you_are_accessing_from_smarkup shredded, % and s orphaned across nested <strong>
heThe_setting_s_is_configured_to_s_and_you_are_accessing_from_sRTL mangling split %s into % … S, 2 of 3 slots
ta-INPrune_Warning_after4 slots against 3; surplus rendered undefined
kuPrune_Warning_before4 slots against 3; surplus rendered undefined
ja, ka-GE, mnPrune_Warning_betweenomit 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_of uses {{total}}, not {{count}}. count is i18next's pluralization trigger; it happens to work here only because no plural-suffixed variants of the key exist. The sibling Showing_current_of_total already uses {{total}}.
  • Channel_already_exist uses {{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