diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000000..48643b9ffa --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 766d64dce6..e2e10bece5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 diff --git a/.gitignore b/.gitignore index f7171c62d0..b83ec27e19 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 34404e7c03..64ba1aae64 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -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" } diff --git a/CLAUDE.md b/CLAUDE.md index b53764d91f..ab9eac41ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 # 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 @@ -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 @@ -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` @@ -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) @@ -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` @@ -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 `` +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 ``. ## Key Patterns for Development @@ -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/ diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 6e1bc98781..6edf3736b3 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -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, `{ "": { "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.) diff --git a/ai-docs/i18n-v15-key-map.json b/ai-docs/i18n-v15-key-map.json new file mode 100644 index 0000000000..2b384cbaf2 --- /dev/null +++ b/ai-docs/i18n-v15-key-map.json @@ -0,0 +1,2502 @@ +{ + "$comment": "Migration table: natural-language translation key (v14) -> namespaced key (v15). Hand-reviewed; nothing regenerates it. Integrators who passed translationsForLanguage/registerTranslation dictionaries keyed on the old strings should use this to rename their keys. See ai-docs/i18n-v15-migration.md.", + "count": 603, + "keys": { + "aria/Active": { + "key": "a11y.accessibleLabel.active.ariaLabel", + "prose": true + }, + "aria/{{ count }} unread message": { + "key": "a11y.accessibleLabel.unreadMessage.ariaLabel", + "prose": true, + "plural": true + }, + "New message from {{user}}": { + "key": "a11y.incomingMessageAnnouncements.newMessage.label", + "prose": true + }, + "aria/Command activated: {{ command }}": { + "key": "a11y.interactionAnnouncements.commandActivated.ariaLabel", + "prose": true + }, + "aria/Dropped \"{{ option }}\" at position {{ position }}.": { + "key": "a11y.interactionAnnouncements.droppedPosition.ariaLabel", + "prose": true + }, + "aria/Giphy canceled": { + "key": "a11y.interactionAnnouncements.giphyCanceled.ariaLabel", + "prose": true + }, + "aria/Giphy image changed": { + "key": "a11y.interactionAnnouncements.giphyImageChanged.ariaLabel", + "prose": true + }, + "aria/Giphy image changed: {{ title }}": { + "key": "a11y.interactionAnnouncements.giphyImageChanged.withTitle.ariaLabel", + "prose": true + }, + "aria/Giphy sent": { + "key": "a11y.interactionAnnouncements.giphySent.ariaLabel", + "prose": true + }, + "aria/No search results found": { + "key": "a11y.interactionAnnouncements.noSearchResultsFound.ariaLabel", + "prose": true + }, + "aria/Opened channel: {{ name }}": { + "key": "a11y.interactionAnnouncements.openedChannel.ariaLabel", + "prose": true + }, + "aria/Opened thread in {{ name }}": { + "key": "a11y.interactionAnnouncements.openedThread.ariaLabel", + "prose": true + }, + "aria/Picked up \"{{ option }}\". Use arrow keys to reorder. Press Space or Tab to drop.": { + "key": "a11y.interactionAnnouncements.pickedUpUseArrow.ariaLabel", + "prose": true + }, + "aria/Poll dialog opened": { + "key": "a11y.interactionAnnouncements.pollDialogOpened.ariaLabel", + "prose": true + }, + "aria/Poll sent": { + "key": "a11y.interactionAnnouncements.pollSent.ariaLabel", + "prose": true + }, + "aria/Press Enter to start typing": { + "key": "a11y.interactionAnnouncements.pressEnterStartTyping.ariaLabel", + "prose": true + }, + "aria/Recording paused": { + "key": "a11y.interactionAnnouncements.recordingPaused.ariaLabel", + "prose": true + }, + "aria/Recording resumed": { + "key": "a11y.interactionAnnouncements.recordingResumed.ariaLabel", + "prose": true + }, + "aria/Recording started": { + "key": "a11y.interactionAnnouncements.recordingStarted.ariaLabel", + "prose": true + }, + "aria/Removed option {{ option }}": { + "key": "a11y.interactionAnnouncements.removedOption.ariaLabel", + "prose": true + }, + "aria/Search cleared": { + "key": "a11y.interactionAnnouncements.searchCleared.ariaLabel", + "prose": true + }, + "aria/{{ count }} search results": { + "key": "a11y.interactionAnnouncements.searchResults.ariaLabel", + "prose": true, + "plural": true + }, + "aria/{{ count }} suggestions": { + "key": "a11y.interactionAnnouncements.suggestions.ariaLabel", + "prose": true, + "plural": true + }, + "aria/{{ count }} {{ suggestionsLabel }}": { + "key": "a11y.interactionAnnouncements.suggestionsWithLabel.ariaLabel", + "prose": true, + "plural": true + }, + "aria/User selected: {{ user }}": { + "key": "a11y.interactionAnnouncements.userSelected.ariaLabel", + "prose": true + }, + "aria/Voice message sent": { + "key": "a11y.interactionAnnouncements.voiceMessageSent.ariaLabel", + "prose": true + }, + "aria/Voice recording attached": { + "key": "a11y.interactionAnnouncements.voiceRecordingAttached.ariaLabel", + "prose": true + }, + "Generating...": { + "key": "aiState.indicator.generating.label", + "prose": true + }, + "Thinking...": { + "key": "aiState.indicator.thinking.label", + "prose": true + }, + "aria/Giphy actions": { + "key": "attachment.actions.giphyActions.ariaLabel", + "prose": true + }, + "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": { + "key": "attachment.actions.giphyPreviewOnlyVisible.ariaLabel", + "prose": true + }, + "Shuffle": { + "key": "attachment.actions.shuffle.label", + "prose": true + }, + "Live until {{ timestamp }}": { + "key": "attachment.geolocation.liveUntil.text", + "prose": true + }, + "Location sharing ended": { + "key": "attachment.geolocation.locationSharingEnded.text", + "prose": true + }, + "Open location in a map": { + "key": "attachment.geolocation.openLocationMap.ariaLabel", + "prose": true + }, + "Stop sharing": { + "key": "attachment.geolocation.stopSharing.text", + "prose": true + }, + "aria/Animated GIF": { + "key": "attachment.giphy.animatedGif.ariaLabel", + "prose": true + }, + "aria/Animated GIF: {{ title }}": { + "key": "attachment.giphy.animatedGif.withTitle.ariaLabel", + "prose": true + }, + "Open gallery at image {{ index }}": { + "key": "attachment.modalGallery.openGalleryImage.label", + "prose": true + }, + "Open image in gallery": { + "key": "attachment.modalGallery.openImageGallery.label", + "prose": true + }, + "this content could not be displayed": { + "key": "attachment.unableRenderCard.text", + "prose": true + }, + "Only visible to you": { + "key": "attachment.visibilityDisclaimer.onlyVisible.text", + "prose": true + }, + "Cannot seek in the recording": { + "key": "audioPlayback.audioPlayerNotifications.cannotSeekRecording.label", + "prose": true + }, + "Failed to play the recording": { + "key": "audioPlayback.audioPlayerNotifications.failedPlayRecording.label", + "prose": true + }, + "Recording format is not supported and cannot be reproduced": { + "key": "audioPlayback.audioPlayerNotifications.recordingFormatNotSupported.label", + "prose": true + }, + "aria/Seek audio position": { + "key": "audioPlayback.progressBar.seekAudioPosition.ariaLabel", + "prose": true + }, + "aria/Audio position {{ elapsed }} of {{ duration }}": { + "key": "audioPlayback.progressBarA11y.audioPosition.ariaLabel", + "prose": true + }, + "aria/Audio position {{ progress }} percent": { + "key": "audioPlayback.progressBarA11y.audioPositionPercent.ariaLabel", + "prose": true + }, + "aria/Image failed to load": { + "key": "baseImage.imagePlaceholder.imageFailedLoad.ariaLabel", + "prose": true + }, + "Channel Missing": { + "key": "channel.channelMissing.text", + "prose": true + }, + "aria/Channel details": { + "key": "channelDetail.avatarChannelDetail.channelDetails.ariaLabel", + "prose": true + }, + "aria/Open channel details": { + "key": "channelDetail.avatarChannelDetail.openChannelDetails.ariaLabel", + "prose": true + }, + "No files": { + "key": "channelDetail.channelFilesEmpty.noFiles.text", + "prose": true + }, + "Share a file to see it here": { + "key": "channelDetail.channelFilesEmpty.shareFileSee.text", + "prose": true + }, + "Files": { + "key": "channelDetail.channelFilesView.files.title", + "prose": true + }, + "Block user": { + "key": "channelDetail.channelManagementActions.blockUser.title", + "prose": true + }, + "Chat deleted": { + "key": "channelDetail.channelManagementActions.chatDeleted.text", + "prose": true + }, + "Delete chat": { + "key": "channelDetail.channelManagementActions.deleteChat.title", + "prose": true + }, + "Error blocking user": { + "key": "channelDetail.channelManagementActions.errorBlockingUser.text", + "prose": true + }, + "Error deleting chat": { + "key": "channelDetail.channelManagementActions.errorDeletingChat.text", + "prose": true + }, + "Error muting channel": { + "key": "channelDetail.channelManagementActions.errorMutingChannel.text", + "prose": true + }, + "Error muting user": { + "key": "channelDetail.channelManagementActions.errorMutingUser.text", + "prose": true + }, + "Error unblocking user": { + "key": "channelDetail.channelManagementActions.errorUnblockingUser.text", + "prose": true + }, + "Error unmuting channel": { + "key": "channelDetail.channelManagementActions.errorUnmutingChannel.text", + "prose": true + }, + "Error unmuting user": { + "key": "channelDetail.channelManagementActions.errorUnmutingUser.text", + "prose": true + }, + "Leave chat": { + "key": "channelDetail.channelManagementActions.leaveChat.title", + "prose": true + }, + "Mute chat": { + "key": "channelDetail.channelManagementActions.muteChat.title", + "prose": true + }, + "Mute user": { + "key": "channelDetail.channelManagementActions.muteUser.title", + "prose": true + }, + "This permanently deletes your message history with {{ user }}. This can't be undone.": { + "key": "channelDetail.channelManagementActions.permanentlyDeletesMessageHistory.description", + "prose": true + }, + "Are you sure you want to leave this channel?": { + "key": "channelDetail.channelManagementActions.sureWantLeaveChannel.description", + "prose": true + }, + "Unmute chat": { + "key": "channelDetail.channelManagementActions.unmuteChat.title", + "prose": true + }, + "Unmute user": { + "key": "channelDetail.channelManagementActions.unmuteUser.title", + "prose": true + }, + "This user will be able to message you again.": { + "key": "channelDetail.channelManagementActions.userAbleMessageAgain.description", + "prose": true + }, + "User muted": { + "key": "channelDetail.channelManagementActions.userMuted.text", + "prose": true + }, + "User unmuted": { + "key": "channelDetail.channelManagementActions.userUnmuted.text", + "prose": true + }, + "This user won't be able to message you anymore. You can unblock them anytime.": { + "key": "channelDetail.channelManagementActions.userWonTAble.description", + "prose": true + }, + "Changes saved": { + "key": "channelDetail.channelManagementView.changesSaved.text", + "prose": true + }, + "Contact info": { + "key": "channelDetail.channelManagementView.contactInfo.label", + "prose": true + }, + "Contact name": { + "key": "channelDetail.channelManagementView.contactName.label", + "prose": true + }, + "Edit": { + "key": "channelDetail.channelManagementView.edit.text", + "prose": true + }, + "Edit chat data": { + "key": "channelDetail.channelManagementView.editChatData.ariaLabel", + "prose": true + }, + "Edit contact": { + "key": "channelDetail.channelManagementView.editContact.label", + "prose": true + }, + "Edit group": { + "key": "channelDetail.channelManagementView.editGroup.label", + "prose": true + }, + "Failed to save changes": { + "key": "channelDetail.channelManagementView.failedSaveChanges.text", + "prose": true + }, + "Group info": { + "key": "channelDetail.channelManagementView.groupInfo.label", + "prose": true + }, + "Group name": { + "key": "channelDetail.channelManagementView.groupName.label", + "prose": true + }, + "Manage channel": { + "key": "channelDetail.channelManagementView.manageChannel.description", + "prose": true + }, + "Save": { + "key": "channelDetail.channelManagementView.save.text", + "prose": true + }, + "Upload Picture": { + "key": "channelDetail.channelManagementView.uploadPicture.text", + "prose": true + }, + "No photos or videos": { + "key": "channelDetail.channelMediaEmpty.noPhotosVideos.text", + "prose": true + }, + "Share a photo or video to see it here": { + "key": "channelDetail.channelMediaEmpty.sharePhotoVideoSee.text", + "prose": true + }, + "Next": { + "key": "channelDetail.channelMediaView.next.text", + "prose": true + }, + "aria/Next page": { + "key": "channelDetail.channelMediaView.nextPage.ariaLabel", + "prose": true + }, + "aria/Open image shared by {{ name }}": { + "key": "channelDetail.channelMediaView.openImageShared.ariaLabel", + "prose": true + }, + "aria/Open video shared by {{ name }}": { + "key": "channelDetail.channelMediaView.openVideoShared.ariaLabel", + "prose": true + }, + "Photos & videos": { + "key": "channelDetail.channelMediaView.photosVideos.title", + "prose": true + }, + "Previous": { + "key": "channelDetail.channelMediaView.previous.text", + "prose": true + }, + "aria/Previous page": { + "key": "channelDetail.channelMediaView.previousPage.ariaLabel", + "prose": true + }, + "{{ member }} will be able to message you again.": { + "key": "channelDetail.channelMemberActions.ableMessageAgain.description", + "prose": true + }, + "Error opening direct message": { + "key": "channelDetail.channelMemberActions.errorOpeningDirectMessage.text", + "prose": true + }, + "Error removing user": { + "key": "channelDetail.channelMemberActions.errorRemovingUser.text", + "prose": true + }, + "Remove {{ member }} from this channel?": { + "key": "channelDetail.channelMemberActions.removeChannel.description", + "prose": true + }, + "Remove user": { + "key": "channelDetail.channelMemberActions.removeUser.title", + "prose": true + }, + "Send direct message": { + "key": "channelDetail.channelMemberActions.sendDirectMessage.title", + "prose": true + }, + "Unblock user": { + "key": "channelDetail.channelMemberActions.unblockUser.title", + "prose": true + }, + "User removed": { + "key": "channelDetail.channelMemberActions.userRemoved.text", + "prose": true + }, + "{{ member }} won't be able to message you anymore.": { + "key": "channelDetail.channelMemberActions.wonTAbleMessage.description", + "prose": true + }, + "Last seen {{ timestamp }}": { + "key": "channelDetail.channelMemberDetail.lastSeen.label", + "prose": true + }, + "Member detail": { + "key": "channelDetail.channelMemberDetail.memberDetail.title", + "prose": true + }, + "Add {{ count }} members": { + "key": "channelDetail.channelMembersAdd.addMembers.text", + "prose": true, + "plural": true + }, + "Already a member": { + "key": "channelDetail.channelMembersAdd.alreadyMember.label", + "prose": true + }, + "Error adding members": { + "key": "channelDetail.channelMembersAdd.errorAddingMembers.text", + "prose": true + }, + "{{ count }} members added": { + "key": "channelDetail.channelMembersAdd.membersAdded.text", + "prose": true, + "plural": true + }, + "No user found": { + "key": "channelDetail.channelMembersAdd.noUserFound.text", + "prose": true + }, + "Admin": { + "key": "channelDetail.channelMembersBrowse.admin.label", + "prose": true + }, + "Moderator": { + "key": "channelDetail.channelMembersBrowse.moderator.label", + "prose": true + }, + "No member found": { + "key": "channelDetail.channelMembersBrowse.noMemberFound.text", + "prose": true + }, + "Owner": { + "key": "channelDetail.channelMembersBrowse.owner.label", + "prose": true + }, + "View member details for {{ member }}": { + "key": "channelDetail.channelMembersBrowse.viewMemberDetails.ariaLabel", + "prose": true + }, + "Actions": { + "key": "channelDetail.channelMembersHeader.actions.text", + "prose": true + }, + "Add": { + "key": "channelDetail.channelMembersHeader.add.text", + "prose": true + }, + "Add channel members": { + "key": "channelDetail.channelMembersHeader.addChannelMembers.ariaLabel", + "prose": true + }, + "Open members actions": { + "key": "channelDetail.channelMembersHeader.openMembersActions.ariaLabel", + "prose": true + }, + "Add members": { + "key": "channelDetail.channelMembersView.addMembers.label", + "prose": true + }, + "Browse channel members": { + "key": "channelDetail.channelMembersView.browseChannelMembers.description", + "prose": true + }, + "{{ count }} members": { + "key": "channelDetail.channelMembersView.members.title", + "prose": true, + "plural": true + }, + "No pinned messages": { + "key": "channelDetail.pinnedMessagesEmpty.noPinnedMessages.text", + "prose": true + }, + "Pin a message to see it here": { + "key": "channelDetail.pinnedMessagesEmpty.pinMessageSee.text", + "prose": true + }, + "Browse pinned messages": { + "key": "channelDetail.pinnedMessagesView.browsePinnedMessages.description", + "prose": true + }, + "No messages found": { + "key": "channelDetail.pinnedMessagesView.noMessagesFound.text", + "prose": true + }, + "Pinned message": { + "key": "channelDetail.pinnedMessagesView.pinnedMessage.label", + "prose": true + }, + "Pinned messages": { + "key": "channelDetail.pinnedMessagesView.pinnedMessages.title", + "prose": true + }, + "Open menu": { + "key": "channelDetail.sectionNavigatorHeader.openMenu.ariaLabel", + "prose": true + }, + "{{ memberCount }} members": { + "key": "channelHeader.online.members.label", + "prose": true + }, + "{{ watcherCount }} online": { + "key": "channelHeader.online.online.label", + "prose": true + }, + "aria/Channel list": { + "key": "channelList.channelList.ariaLabel", + "prose": true + }, + "Chats": { + "key": "channelList.header.chats.text", + "prose": true + }, + "Archive": { + "key": "channelListItem.archive.title", + "prose": true + }, + "aria/Attachment": { + "key": "channelListItem.attachment.ariaLabel", + "prose": true + }, + "🏙 Attachment...": { + "key": "channelListItem.attachment.text", + "prose": true + }, + "aria/Attachment {{ attachmentType }}": { + "key": "channelListItem.attachment.withAttachmentType.ariaLabel", + "prose": true + }, + "aria/{{ count }} attachment": { + "key": "channelListItem.attachmentCount.ariaLabel", + "prose": true, + "plural": true + }, + "aria/audio": { + "key": "channelListItem.audio.ariaLabel", + "prose": true + }, + "aria/Channel Actions": { + "key": "channelListItem.channelActions.ariaLabel", + "prose": true + }, + "Channel archived": { + "key": "channelListItem.channelArchived.text", + "prose": true + }, + "Direct message": { + "key": "channelListItem.channelDisplayName.directMessage.label", + "prose": true + }, + "Channel pinned": { + "key": "channelListItem.channelPinned.text", + "prose": true + }, + "Channel unarchived": { + "key": "channelListItem.channelUnarchived.text", + "prose": true + }, + "Channel unpinned": { + "key": "channelListItem.channelUnpinned.text", + "prose": true + }, + "📊 {{createdBy}} created: {{ pollName}}": { + "key": "channelListItem.created.text", + "prose": true + }, + "aria/Delivered": { + "key": "channelListItem.delivered.ariaLabel", + "prose": true + }, + "aria/Delivery status: {{ deliveryStatus }}": { + "key": "channelListItem.deliveryStatus.ariaLabel", + "prose": true + }, + "Failed to block user": { + "key": "channelListItem.failedBlockUser.text", + "prose": true + }, + "Failed to update channel archive status": { + "key": "channelListItem.failedUpdateChannelArchive.text", + "prose": true + }, + "Failed to update channel mute status": { + "key": "channelListItem.failedUpdateChannelMute.text", + "prose": true + }, + "Failed to update channel pinned status": { + "key": "channelListItem.failedUpdateChannelPinned.text", + "prose": true + }, + "aria/file": { + "key": "channelListItem.file.ariaLabel", + "prose": true + }, + "aria/GIF": { + "key": "channelListItem.gif.ariaLabel", + "prose": true + }, + "aria/image": { + "key": "channelListItem.image.ariaLabel", + "prose": true + }, + "aria/Last message: {{ messagePreview }}": { + "key": "channelListItem.lastMessage.withMessagePreview.ariaLabel", + "prose": true + }, + "aria/Last message from {{ sender }}: {{ messagePreview }}": { + "key": "channelListItem.lastMessage.withSenderAndMessagePreview.ariaLabel", + "prose": true + }, + "Leave Channel": { + "key": "channelListItem.leaveChannel.title", + "prose": true + }, + "aria/Message with attachments": { + "key": "channelListItem.messageAttachments.ariaLabel", + "prose": true + }, + "aria/There are no messages in this chat.": { + "key": "channelListItem.noMessagesChat.ariaLabel", + "prose": true + }, + "aria/Open Channel Actions Menu": { + "key": "channelListItem.openChannelActionsMenu.ariaLabel", + "prose": true + }, + "aria/Poll: {{ pollName }}": { + "key": "channelListItem.poll.ariaLabel", + "prose": true + }, + "aria/Read": { + "key": "channelListItem.read.ariaLabel", + "prose": true + }, + "aria/Sent": { + "key": "channelListItem.sent.ariaLabel", + "prose": true + }, + "aria/Shared a link": { + "key": "channelListItem.sharedLink.ariaLabel", + "prose": true + }, + "aria/Shared a link with title: {{ linkTitle }}": { + "key": "channelListItem.sharedLinkTitle.ariaLabel", + "prose": true + }, + "aria/Shared location": { + "key": "channelListItem.sharedLocation.ariaLabel", + "prose": true + }, + "📍Shared location": { + "key": "channelListItem.sharedLocation.text", + "prose": true + }, + "Unarchive": { + "key": "channelListItem.unarchive.title", + "prose": true + }, + "Unblock User": { + "key": "channelListItem.unblockUser.title", + "prose": true + }, + "aria/video": { + "key": "channelListItem.video.ariaLabel", + "prose": true + }, + "aria/voice message": { + "key": "channelListItem.voiceMessage.ariaLabel", + "prose": true + }, + "📊 {{votedBy}} voted: {{pollOptionText}}": { + "key": "channelListItem.voted.text", + "prose": true + }, + "Waiting for network…": { + "key": "chat.reportLostConnection.waitingNetwork.text", + "prose": true + }, + "ban-command-args": { + "key": "command.ban.args", + "prose": true + }, + "ban-command-description": { + "key": "command.ban.description", + "prose": true + }, + "giphy-command-args": { + "key": "command.giphy.args", + "prose": true + }, + "giphy-command-description": { + "key": "command.giphy.description", + "prose": true + }, + "mute-command-args": { + "key": "command.mute.args", + "prose": true + }, + "mute-command-description": { + "key": "command.mute.description", + "prose": true + }, + "unban-command-args": { + "key": "command.unban.args", + "prose": true + }, + "unban-command-description": { + "key": "command.unban.description", + "prose": true + }, + "unmute-command-args": { + "key": "command.unmute.args", + "prose": true + }, + "unmute-command-description": { + "key": "command.unmute.description", + "prose": true + }, + "Add reaction": { + "key": "common.addReaction.text", + "prose": true, + "shared": true + }, + "Anonymous": { + "key": "common.anonymous.label", + "prose": true, + "shared": true + }, + "Back": { + "key": "common.back.label", + "prose": true, + "shared": true + }, + "Block User": { + "key": "common.blockUser.title", + "prose": true, + "shared": true + }, + "Cancel": { + "key": "common.cancel.label", + "prose": true, + "shared": true + }, + "Channel muted": { + "key": "common.channelMuted.text", + "prose": true, + "shared": true + }, + "Channel unmuted": { + "key": "common.channelUnmuted.text", + "prose": true, + "shared": true + }, + "Close": { + "key": "common.close.ariaLabel", + "prose": true, + "shared": true + }, + "Create a question, add options, and configure poll settings": { + "key": "common.createQuestionAddOptions.label", + "prose": true, + "shared": true + }, + "Current location": { + "key": "common.currentLocation.text", + "prose": true, + "shared": true + }, + "Delete": { + "key": "common.delete.text", + "prose": true, + "shared": true + }, + "aria/Download attachment": { + "key": "common.downloadAttachment.ariaLabel", + "prose": true, + "shared": true + }, + "Download Attachment": { + "key": "common.downloadAttachment.title", + "prose": true, + "shared": true + }, + "Edit Message": { + "key": "common.editMessage.text", + "prose": true, + "shared": true + }, + "Empty message...": { + "key": "common.emptyMessage.text", + "prose": true, + "shared": true + }, + "Error deleting message": { + "key": "common.errorDeletingMessage.label", + "prose": true, + "shared": true + }, + "Error muting a user ...": { + "key": "common.errorMutingUser.label", + "prose": true, + "shared": true + }, + "Error pinning message": { + "key": "common.errorPinningMessage.label", + "prose": true, + "shared": true + }, + "Error removing message pin": { + "key": "common.errorRemovingMessagePin.label", + "prose": true, + "shared": true + }, + "Error unmuting a user ...": { + "key": "common.errorUnmutingUser.label", + "prose": true, + "shared": true + }, + "Failed to leave channel": { + "key": "common.failedLeaveChannel.text", + "prose": true, + "shared": true + }, + "aria/Last activity: {{ time }}": { + "key": "common.lastActivity.ariaLabel", + "prose": true, + "shared": true + }, + "Left channel": { + "key": "common.leftChannel.text", + "prose": true, + "shared": true + }, + "Live location": { + "key": "common.liveLocation.text", + "prose": true, + "shared": true + }, + "Location": { + "key": "common.location.text", + "prose": true, + "shared": true + }, + "Message deleted": { + "key": "common.messageDeleted.text", + "prose": true, + "shared": true + }, + "Message pinned": { + "key": "common.messagePinned.label", + "prose": true, + "shared": true + }, + "Mute": { + "key": "common.mute.title", + "prose": true, + "shared": true + }, + "{{ user }} has been muted": { + "key": "common.muted.label", + "prose": true, + "shared": true + }, + "{{count}} new messages": { + "key": "common.newMessages.label", + "prose": true, + "plural": true, + "shared": true + }, + "Nothing yet...": { + "key": "common.nothingYet.text", + "prose": true, + "shared": true + }, + "Offline": { + "key": "common.offline.label", + "prose": true, + "shared": true + }, + "Online": { + "key": "common.online.label", + "prose": true, + "shared": true + }, + "aria/Open Reaction Selector": { + "key": "common.openReactionSelector.ariaLabel", + "prose": true, + "shared": true + }, + "aria/Pause": { + "key": "common.pause.ariaLabel", + "prose": true, + "shared": true + }, + "Pin": { + "key": "common.pin.title", + "prose": true, + "shared": true + }, + "aria/Play": { + "key": "common.play.ariaLabel", + "prose": true, + "shared": true + }, + "Playback speed {{ rate }}x": { + "key": "common.playbackSpeedX.label", + "prose": true, + "shared": true + }, + "Poll": { + "key": "common.poll.label", + "prose": true, + "shared": true + }, + "Reminder set": { + "key": "common.reminderSet.text", + "prose": true, + "shared": true + }, + "replyCount": { + "key": "common.replyCount.label", + "prose": true, + "plural": true, + "shared": true + }, + "All results loaded": { + "key": "common.resultsLoaded.label", + "prose": true, + "shared": true + }, + "aria/Retry upload": { + "key": "common.retryUpload.ariaLabel", + "prose": true, + "shared": true + }, + "Saved for later": { + "key": "common.savedLater.text", + "prose": true, + "shared": true + }, + "Search": { + "key": "common.search.ariaLabel", + "prose": true, + "shared": true + }, + "Send": { + "key": "common.send.label", + "prose": true, + "shared": true + }, + "Threads": { + "key": "common.threads.text", + "prose": true, + "shared": true + }, + "Unblock": { + "key": "common.unblock.ariaLabel", + "prose": true, + "shared": true + }, + "Unmute": { + "key": "common.unmute.title", + "prose": true, + "shared": true + }, + "{{ user }} has been unmuted": { + "key": "common.unmuted.label", + "prose": true, + "shared": true + }, + "Unpin": { + "key": "common.unpin.title", + "prose": true, + "shared": true + }, + "Unsupported attachment": { + "key": "common.unsupportedAttachment.text", + "prose": true, + "shared": true + }, + "User blocked": { + "key": "common.userBlocked.text", + "prose": true, + "shared": true + }, + "User unblocked": { + "key": "common.userUnblocked.text", + "prose": true, + "shared": true + }, + "User uploaded content": { + "key": "common.userUploadedContent.label", + "prose": true, + "shared": true + }, + "Voice message": { + "key": "common.voiceMessage.label", + "prose": true, + "shared": true + }, + "You": { + "key": "common.you.label", + "prose": true, + "shared": true + }, + "aria/Close callout dialog": { + "key": "dialog.callout.closeCalloutDialog.ariaLabel", + "prose": true + }, + "aria/Back to parent menu button": { + "key": "dialog.contextMenu.backParentMenuButton.ariaLabel", + "prose": true + }, + "aria/Submenu": { + "key": "dialog.contextMenu.submenu.ariaLabel", + "prose": true + }, + "Go back": { + "key": "dialog.prompt.goBack.ariaLabel", + "prose": true + }, + "Close dialog": { + "key": "dialog.viewer.closeDialog.ariaLabel", + "prose": true + }, + "duration/Message reminder": { + "key": "duration.messageReminder", + "prose": false + }, + "duration/Remind Me": { + "key": "duration.remindMe", + "prose": false + }, + "duration/Share Location": { + "key": "duration.shareLocation", + "prose": false + }, + "aria/Emoji picker": { + "key": "emojiPicker.emojiPicker.ariaLabel", + "prose": true + }, + "No conversations yet": { + "key": "emptyState.indicator.noConversationsYet.label", + "prose": true + }, + "No items exist": { + "key": "emptyState.indicator.noItemsExist.text", + "prose": true + }, + "Send a message to start the conversation": { + "key": "emptyState.indicator.sendMessageStartConversation.label", + "prose": true + }, + "aria/File upload": { + "key": "fileUpload.uploadButton.fileUpload.ariaLabel", + "prose": true + }, + "aria/Decrease value": { + "key": "form.numericInput.decreaseValue.ariaLabel", + "prose": true + }, + "aria/Increase value": { + "key": "form.numericInput.increaseValue.ariaLabel", + "prose": true + }, + "aria/{{ setting }} disabled": { + "key": "form.switchField.disabled.ariaLabel", + "prose": true + }, + "aria/{{ setting }} enabled": { + "key": "form.switchField.enabled.ariaLabel", + "prose": true + }, + "Next image": { + "key": "gallery.ui.nextImage.ariaLabel", + "prose": true + }, + "Previous image": { + "key": "gallery.ui.previousImage.ariaLabel", + "prose": true + }, + "language/af": { + "key": "language.af", + "prose": true + }, + "language/am": { + "key": "language.am", + "prose": true + }, + "language/ar": { + "key": "language.ar", + "prose": true + }, + "language/az": { + "key": "language.az", + "prose": true + }, + "language/bg": { + "key": "language.bg", + "prose": true + }, + "language/bn": { + "key": "language.bn", + "prose": true + }, + "language/bs": { + "key": "language.bs", + "prose": true + }, + "language/cs": { + "key": "language.cs", + "prose": true + }, + "language/da": { + "key": "language.da", + "prose": true + }, + "language/de": { + "key": "language.de", + "prose": true + }, + "language/el": { + "key": "language.el", + "prose": true + }, + "language/en": { + "key": "language.en", + "prose": true + }, + "language/es": { + "key": "language.es", + "prose": true + }, + "language/es-MX": { + "key": "language.es-MX", + "prose": true + }, + "language/et": { + "key": "language.et", + "prose": true + }, + "language/fa": { + "key": "language.fa", + "prose": true + }, + "language/fa-AF": { + "key": "language.fa-AF", + "prose": true + }, + "language/fi": { + "key": "language.fi", + "prose": true + }, + "language/fr": { + "key": "language.fr", + "prose": true + }, + "language/fr-CA": { + "key": "language.fr-CA", + "prose": true + }, + "language/ha": { + "key": "language.ha", + "prose": true + }, + "language/he": { + "key": "language.he", + "prose": true + }, + "language/hi": { + "key": "language.hi", + "prose": true + }, + "language/hr": { + "key": "language.hr", + "prose": true + }, + "language/ht": { + "key": "language.ht", + "prose": true + }, + "language/hu": { + "key": "language.hu", + "prose": true + }, + "language/id": { + "key": "language.id", + "prose": true + }, + "language/it": { + "key": "language.it", + "prose": true + }, + "language/ja": { + "key": "language.ja", + "prose": true + }, + "language/ka": { + "key": "language.ka", + "prose": true + }, + "language/ko": { + "key": "language.ko", + "prose": true + }, + "language/lt": { + "key": "language.lt", + "prose": true + }, + "language/lv": { + "key": "language.lv", + "prose": true + }, + "language/ms": { + "key": "language.ms", + "prose": true + }, + "language/nl": { + "key": "language.nl", + "prose": true + }, + "language/no": { + "key": "language.no", + "prose": true + }, + "language/pl": { + "key": "language.pl", + "prose": true + }, + "language/ps": { + "key": "language.ps", + "prose": true + }, + "language/pt": { + "key": "language.pt", + "prose": true + }, + "language/ro": { + "key": "language.ro", + "prose": true + }, + "language/ru": { + "key": "language.ru", + "prose": true + }, + "language/sk": { + "key": "language.sk", + "prose": true + }, + "language/sl": { + "key": "language.sl", + "prose": true + }, + "language/so": { + "key": "language.so", + "prose": true + }, + "language/sq": { + "key": "language.sq", + "prose": true + }, + "language/sr": { + "key": "language.sr", + "prose": true + }, + "language/sv": { + "key": "language.sv", + "prose": true + }, + "language/sw": { + "key": "language.sw", + "prose": true + }, + "language/ta": { + "key": "language.ta", + "prose": true + }, + "language/th": { + "key": "language.th", + "prose": true + }, + "language/tl": { + "key": "language.tl", + "prose": true + }, + "language/tr": { + "key": "language.tr", + "prose": true + }, + "language/uk": { + "key": "language.uk", + "prose": true + }, + "language/ur": { + "key": "language.ur", + "prose": true + }, + "language/vi": { + "key": "language.vi", + "prose": true + }, + "language/zh": { + "key": "language.zh", + "prose": true + }, + "language/zh-TW": { + "key": "language.zh-TW", + "prose": true + }, + "Error: {{ errorMessage }}": { + "key": "loading.errorIndicator.error.text", + "prose": true + }, + "aria/Percent complete": { + "key": "loading.progressIndicators.percentComplete.ariaLabel", + "prose": true + }, + "Load more": { + "key": "loadMore.button.loadMore.label", + "prose": true + }, + "Attach": { + "key": "location.shareLocationDialog.attach.text", + "prose": true + }, + "Select your current location and optionally enable live location sharing": { + "key": "location.shareLocationDialog.description", + "prose": true + }, + "Share": { + "key": "location.shareLocationDialog.share.text", + "prose": true + }, + "Share live location for": { + "key": "location.shareLocationDialog.shareLiveLocation.title", + "prose": true + }, + "Share Location": { + "key": "location.shareLocationDialog.shareLocation.title", + "prose": true + }, + "aria/Cancel recording": { + "key": "mediaRecorder.audioRecorderRecording.cancelRecording.ariaLabel", + "prose": true + }, + "aria/Complete recording": { + "key": "mediaRecorder.audioRecorderRecording.completeRecording.ariaLabel", + "prose": true + }, + "aria/Pause recording": { + "key": "mediaRecorder.audioRecorderRecording.pauseRecording.ariaLabel", + "prose": true + }, + "aria/Resume recording": { + "key": "mediaRecorder.audioRecorderRecording.resumeRecording.ariaLabel", + "prose": true + }, + "Voice message deleted": { + "key": "mediaRecorder.audioRecorderRecording.voiceMessageDeleted.text", + "prose": true + }, + "aria/Start recording audio": { + "key": "mediaRecorder.audioRecordingButton.startRecordingAudio.ariaLabel", + "prose": true + }, + "An error has occurred during the recording processing": { + "key": "mediaRecorder.error.processing", + "prose": true + }, + "An error has occurred during recording": { + "key": "mediaRecorder.error.recording", + "prose": true + }, + "Error starting recording": { + "key": "mediaRecorder.error.start", + "prose": true + }, + "To start recording, allow the camera access in your browser": { + "key": "mediaRecorder.permissionDenied.camera.body", + "prose": true + }, + "Allow access to camera": { + "key": "mediaRecorder.permissionDenied.camera.heading", + "prose": true + }, + "To start recording, allow the microphone access in your browser": { + "key": "mediaRecorder.permissionDenied.microphone.body", + "prose": true + }, + "Allow access to microphone": { + "key": "mediaRecorder.permissionDenied.microphone.heading", + "prose": true + }, + "mention/Channel Description": { + "key": "mention.channel.description", + "prose": true + }, + "mention/Here Description": { + "key": "mention.here.description", + "prose": true + }, + "Also sent in channel": { + "key": "message.alsoSent.alsoSentChannel.text", + "prose": true + }, + "Replied to a thread": { + "key": "message.alsoSent.repliedThread.text", + "prose": true + }, + "View": { + "key": "message.alsoSent.view.text", + "prose": true + }, + "{{ commaSeparatedUsers }}, and {{ lastUser }}": { + "key": "message.and.withCommaSeparatedUsersAndLastUser.label", + "prose": true + }, + "{{ firstUser }} and {{ secondUser }}": { + "key": "message.and.withFirstUserAndSecondUser.label", + "prose": true + }, + "Message was blocked by moderation policies": { + "key": "message.blocked.text", + "prose": true + }, + "Edited": { + "key": "message.editedIndicator.edited.text", + "prose": true + }, + "{{ commaSeparatedUsers }} and {{ moreCount }} more": { + "key": "message.more.label", + "prose": true + }, + "Pinned by You": { + "key": "message.pinIndicator.pinned.label", + "prose": true + }, + "Pinned by {{ name }}": { + "key": "message.pinIndicator.pinned.withName.label", + "prose": true + }, + "Due {{ timeLeft }}": { + "key": "message.reminderNotification.due.label", + "prose": true + }, + "Due since {{ dueSince }}": { + "key": "message.reminderNotification.dueSince.label", + "prose": true + }, + "Delivered": { + "key": "message.status.delivered.text", + "prose": true + }, + "Sending...": { + "key": "message.status.sending.text", + "prose": true + }, + "Sent": { + "key": "message.status.sent.text", + "prose": true + }, + "aria/Message,": { + "key": "message.text.message.ariaLabel", + "prose": true + }, + "aria/Message from {{ user }},": { + "key": "message.text.message.withUser.ariaLabel", + "prose": true + }, + "Original": { + "key": "message.translationIndicator.original.text", + "prose": true + }, + "Translated": { + "key": "message.translationIndicator.translated.text", + "prose": true + }, + "Translated from {{ language }}": { + "key": "message.translationIndicator.translated.withLanguage.text", + "prose": true + }, + "View original": { + "key": "message.translationIndicator.viewOriginal.text", + "prose": true + }, + "View translation": { + "key": "message.translationIndicator.viewTranslation.text", + "prose": true + }, + "aria/Review bounced message": { + "key": "message.ui.reviewBouncedMessage.ariaLabel", + "prose": true + }, + "aria/Block User": { + "key": "messageActions.blockUser.ariaLabel", + "prose": true + }, + "aria/Bookmark Message": { + "key": "messageActions.bookmarkMessage.ariaLabel", + "prose": true + }, + "Copy Message": { + "key": "messageActions.copyMessage.text", + "prose": true + }, + "aria/Copy Message Text": { + "key": "messageActions.copyMessageText.ariaLabel", + "prose": true + }, + "aria/Delete Message": { + "key": "messageActions.deleteMessage.ariaLabel", + "prose": true + }, + "Delete message": { + "key": "messageActions.deleteMessageAlert.deleteMessage.title", + "prose": true + }, + "Are you sure you want to delete this message?": { + "key": "messageActions.deleteMessageAlert.description", + "prose": true + }, + "Download {{ fileName }}": { + "key": "messageActions.downloadSubmenu.download.label", + "prose": true + }, + "Download All": { + "key": "messageActions.downloadSubmenu.download.text", + "prose": true + }, + "Download attachment {{ number }}": { + "key": "messageActions.downloadSubmenu.downloadAttachment.label", + "prose": true + }, + "aria/Edit Message": { + "key": "messageActions.editMessage.ariaLabel", + "prose": true + }, + "Error adding flag": { + "key": "messageActions.errorAddingFlag.text", + "prose": true + }, + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": { + "key": "messageActions.errorMarkingMessageUnread.text", + "prose": true + }, + "Flag": { + "key": "messageActions.flag.text", + "prose": true + }, + "aria/Flag Message": { + "key": "messageActions.flagMessage.ariaLabel", + "prose": true + }, + "aria/Mark Message Unread": { + "key": "messageActions.markMessageUnread.ariaLabel", + "prose": true + }, + "Mark as unread": { + "key": "messageActions.markUnread.text", + "prose": true + }, + "aria/Message Actions": { + "key": "messageActions.messageActions.ariaLabel", + "prose": true + }, + "Message marked as unread": { + "key": "messageActions.messageMarkedUnread.text", + "prose": true + }, + "Message has been successfully flagged": { + "key": "messageActions.messageSuccessfullyFlagged.text", + "prose": true + }, + "Message unpinned": { + "key": "messageActions.messageUnpinned.text", + "prose": true + }, + "aria/Mute User": { + "key": "messageActions.muteUser.ariaLabel", + "prose": true + }, + "aria/Open Message Actions Menu": { + "key": "messageActions.openMessageActionsMenu.ariaLabel", + "prose": true + }, + "aria/Open Thread": { + "key": "messageActions.openThread.ariaLabel", + "prose": true + }, + "aria/Pin Message": { + "key": "messageActions.pinMessage.ariaLabel", + "prose": true + }, + "aria/Quote Message": { + "key": "messageActions.quoteMessage.ariaLabel", + "prose": true + }, + "Quote Reply": { + "key": "messageActions.quoteReply.text", + "prose": true + }, + "Remind me": { + "key": "messageActions.remindMe.text", + "prose": true + }, + "aria/Remind Me Message": { + "key": "messageActions.remindMeMessage.ariaLabel", + "prose": true + }, + "Remind Me": { + "key": "messageActions.remindMeSubmenu.remindMe.text", + "prose": true + }, + "aria/Remove Reminder": { + "key": "messageActions.removeReminder.ariaLabel", + "prose": true + }, + "Remove reminder": { + "key": "messageActions.removeReminder.text", + "prose": true + }, + "aria/Remove Save For Later": { + "key": "messageActions.removeSaveLater.ariaLabel", + "prose": true + }, + "Remove save for later": { + "key": "messageActions.removeSaveLater.text", + "prose": true + }, + "Resend": { + "key": "messageActions.resend.text", + "prose": true + }, + "aria/Resend Message": { + "key": "messageActions.resendMessage.ariaLabel", + "prose": true + }, + "Save for later": { + "key": "messageActions.saveLater.text", + "prose": true + }, + "Thread Reply": { + "key": "messageActions.threadReply.text", + "prose": true + }, + "aria/Unmute User": { + "key": "messageActions.unmuteUser.ariaLabel", + "prose": true + }, + "aria/Unpin Message": { + "key": "messageActions.unpinMessage.ariaLabel", + "prose": true + }, + "Review this message and choose whether to delete it, edit it, or send it anyway": { + "key": "messageBounce.prompt.description", + "prose": true + }, + "Send Anyway": { + "key": "messageBounce.prompt.sendAnyway.text", + "prose": true + }, + "This message did not meet our content guidelines": { + "key": "messageBounce.prompt.title", + "prose": true + }, + "aria/Show preview": { + "key": "messageComposer.attachmentPreviewRoot.showPreview.ariaLabel", + "prose": true + }, + "aria/Attachment Actions": { + "key": "messageComposer.attachmentSelector.attachmentActions.ariaLabel", + "prose": true + }, + "Commands": { + "key": "messageComposer.attachmentSelector.commands.text", + "prose": true + }, + "File": { + "key": "messageComposer.attachmentSelector.file.text", + "prose": true + }, + "aria/Open Attachment Selector": { + "key": "messageComposer.attachmentSelector.openAttachmentSelector.ariaLabel", + "prose": true + }, + "File too large": { + "key": "messageComposer.audioAttachmentPreview.fileTooLarge.text", + "prose": true + }, + "Retry upload": { + "key": "messageComposer.audioAttachmentPreview.retryUpload.text", + "prose": true + }, + "Upload blocked": { + "key": "messageComposer.audioAttachmentPreview.uploadBlocked.text", + "prose": true + }, + "Upload error": { + "key": "messageComposer.audioAttachmentPreview.uploadError.text", + "prose": true + }, + "Upload failed": { + "key": "messageComposer.audioAttachmentPreview.uploadFailed.text", + "prose": true + }, + "Exit command {{ command }}": { + "key": "messageComposer.commandChip.exitCommand.ariaLabel", + "prose": true + }, + "aria/Back to attachments": { + "key": "messageComposer.commandsMenu.backAttachments.ariaLabel", + "prose": true + }, + "Instant commands": { + "key": "messageComposer.commandsMenu.instantCommands.text", + "prose": true + }, + "Drag your files here": { + "key": "messageComposer.dragDropUpload.dragFiles.text", + "prose": true + }, + "Some of the files will not be accepted": { + "key": "messageComposer.dragDropUpload.someFilesNotAccepted.text", + "prose": true + }, + "Live for {{duration}}": { + "key": "messageComposer.geolocationPreview.live.text", + "prose": true + }, + "Location: {{ coordinates }}": { + "key": "messageComposer.geolocationPreview.location.text", + "prose": true + }, + "aria/Remove location attachment": { + "key": "messageComposer.geolocationPreview.removeLocationAttachment.ariaLabel", + "prose": true + }, + "Shared location": { + "key": "messageComposer.geolocationPreview.sharedLocation.title", + "prose": true + }, + "Attach files": { + "key": "messageComposer.icons.attachFiles.text", + "prose": true + }, + "aria/Cancel Reply": { + "key": "messageComposer.quotedMessagePreview.cancelReply.ariaLabel", + "prose": true + }, + "{{ count }} files": { + "key": "messageComposer.quotedMessagePreview.files.label", + "prose": true, + "plural": true + }, + "aria/Jump to quoted message": { + "key": "messageComposer.quotedMessagePreview.jumpQuotedMessage.ariaLabel", + "prose": true + }, + "Photo": { + "key": "messageComposer.quotedMessagePreview.photo.label", + "prose": true + }, + "{{ count }} photos": { + "key": "messageComposer.quotedMessagePreview.photos.label", + "prose": true, + "plural": true + }, + "Reply": { + "key": "messageComposer.quotedMessagePreview.reply.text", + "prose": true + }, + "Reply to {{ authorName }}": { + "key": "messageComposer.quotedMessagePreview.reply.withAuthorName.text", + "prose": true + }, + "Video": { + "key": "messageComposer.quotedMessagePreview.video.label", + "prose": true + }, + "{{ count }} videos": { + "key": "messageComposer.quotedMessagePreview.videos.label", + "prose": true, + "plural": true + }, + "Voice message {{ duration }}": { + "key": "messageComposer.quotedMessagePreview.voiceMessage.label", + "prose": true + }, + "aria/Remove attachment": { + "key": "messageComposer.removeAttachmentPreview.removeAttachment.ariaLabel", + "prose": true + }, + "aria/Send": { + "key": "messageComposer.sendButton.send.ariaLabel", + "prose": true + }, + "Also send in channel": { + "key": "messageComposer.sendChannelCheckbox.alsoSendChannel.label", + "prose": true + }, + "Also send as a direct message": { + "key": "messageComposer.sendChannelCheckbox.alsoSendDirectMessage.label", + "prose": true + }, + "Send message request failed": { + "key": "messageComposer.sendMessageFn.sendMessageRequestFailed.text", + "prose": true + }, + "aria/Stop AI Generation": { + "key": "messageComposer.stopAiGeneration.stopAiGeneration.ariaLabel", + "prose": true + }, + "Edit message request failed": { + "key": "messageComposer.updateMessageFn.editMessageRequestFailed.text", + "prose": true + }, + "New Messages!": { + "key": "messageList.newMessageNotification.newMessages.label", + "prose": true + }, + "aria/Jump to latest message": { + "key": "messageList.scrollLatestMessage.jumpLatestMessage.ariaLabel", + "prose": true + }, + "aria/Mark messages as read": { + "key": "messageList.unreadMessagesNotification.markMessagesRead.ariaLabel", + "prose": true + }, + "{{count}} unread": { + "key": "messageList.unreadMessagesNotification.unread.text", + "prose": true, + "plural": true + }, + "Unread messages": { + "key": "messageList.unreadMessagesNotification.unreadMessages.text", + "prose": true + }, + "fileCount": { + "key": "messagePreview.latestMessagePreview.fileCount.label", + "prose": true, + "plural": true + }, + "imageCount": { + "key": "messagePreview.latestMessagePreview.imageCount.label", + "prose": true, + "plural": true + }, + "linkCount": { + "key": "messagePreview.latestMessagePreview.linkCount.label", + "prose": true, + "plural": true + }, + "Message failed to send": { + "key": "messagePreview.latestMessagePreview.messageFailedSend.text", + "prose": true + }, + "videoCount": { + "key": "messagePreview.latestMessagePreview.videoCount.label", + "prose": true, + "plural": true + }, + "voiceMessageCount": { + "key": "messagePreview.latestMessagePreview.voiceMessageCount.label", + "prose": true, + "plural": true + }, + "File is required for upload attachment": { + "key": "notification.attachmentFileMissing", + "prose": true + }, + "Local upload attachment missing local id": { + "key": "notification.attachmentIdMissing", + "prose": true + }, + "Attachment upload blocked due to {{reason}}": { + "key": "notification.attachmentUploadBlockedWithReason", + "prose": true + }, + "Error uploading attachment": { + "key": "notification.attachmentUploadFailed", + "prose": true + }, + "Attachment upload failed due to {{reason}}": { + "key": "notification.attachmentUploadFailedWithReason", + "prose": true + }, + "Wait until all attachments have uploaded": { + "key": "notification.attachmentUploadInProgress", + "prose": true + }, + "Error reproducing the recording": { + "key": "notification.audioPlaybackError", + "prose": true + }, + "Command not available": { + "key": "notification.commandDisabled", + "prose": true + }, + "Command not available while editing": { + "key": "notification.commandDisabledWhileEditing", + "prose": true + }, + "Command not available while replying": { + "key": "notification.commandDisabledWhileReplying", + "prose": true + }, + "aria/Dismiss notification": { + "key": "notification.dismissNotification.ariaLabel", + "prose": true + }, + "Failed to jump to the first unread message": { + "key": "notification.jumpToFirstUnreadFailed", + "prose": true + }, + "aria/Notifications": { + "key": "notification.list.notifications.ariaLabel", + "prose": true + }, + "Failed to retrieve location": { + "key": "notification.locationGetFailed", + "prose": true + }, + "Failed to share location": { + "key": "notification.locationShareFailed", + "prose": true + }, + "Failed to create the poll": { + "key": "notification.pollCreateFailed", + "prose": true + }, + "Failed to create the poll due to {{reason}}": { + "key": "notification.pollCreateFailedWithReason", + "prose": true + }, + "Failed to end the poll": { + "key": "notification.pollEndFailed", + "prose": true + }, + "Failed to end the poll due to {{reason}}": { + "key": "notification.pollEndFailedWithReason", + "prose": true + }, + "Poll ended": { + "key": "notification.pollEndSuccess", + "prose": true + }, + "Reached the vote limit. Remove an existing vote first.": { + "key": "notification.pollVoteLimit", + "prose": true + }, + "size limit": { + "key": "notification.reason.sizeLimit", + "prose": true + }, + "unknown error": { + "key": "notification.reason.unknownError", + "prose": true + }, + "unsupported file type": { + "key": "notification.reason.unsupportedFileType", + "prose": true + }, + "Thread has not been found": { + "key": "notification.replySearchFailed", + "prose": true + }, + "Suggest an option": { + "key": "poll.actions.suggestOption.label", + "prose": true + }, + "View {{count}} comments": { + "key": "poll.actions.viewComments.label", + "prose": true, + "plural": true + }, + "View results": { + "key": "poll.actions.viewResults.label", + "prose": true + }, + "Add a comment": { + "key": "poll.addCommentPrompt.addComment.label", + "prose": true + }, + "Add a comment to your poll answer": { + "key": "poll.addCommentPrompt.addCommentPollAnswer.label", + "prose": true + }, + "This field cannot be empty or contain only spaces": { + "key": "poll.addCommentPrompt.fieldCannotEmptyContain.label", + "prose": true + }, + "Update": { + "key": "poll.addCommentPrompt.update.text", + "prose": true + }, + "Update your comment": { + "key": "poll.addCommentPrompt.updateComment.label", + "prose": true + }, + "Update the comment attached to your poll answer": { + "key": "poll.addCommentPrompt.updateCommentAttachedPoll.label", + "prose": true + }, + "Review comments submitted with poll answers": { + "key": "poll.answerList.description", + "prose": true + }, + "Poll comments": { + "key": "poll.answerList.pollComments.title", + "prose": true + }, + "Allow others to add comments": { + "key": "poll.creationDialog.allowOthersAddComments.description", + "prose": true + }, + "Anonymous poll": { + "key": "poll.creationDialog.anonymousPoll.title", + "prose": true + }, + "Create poll": { + "key": "poll.creationDialog.createPoll.title", + "prose": true + }, + "Hide who voted": { + "key": "poll.creationDialog.hideWhoVoted.description", + "prose": true + }, + "Let others add options": { + "key": "poll.creationDialog.letOthersAddOptions.description", + "prose": true + }, + "Poll sent": { + "key": "poll.creationDialog.pollSent.text", + "prose": true + }, + "Send poll": { + "key": "poll.creationDialog.sendPoll.text", + "prose": true + }, + "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": { + "key": "poll.endPollAlert.description", + "prose": true + }, + "End poll": { + "key": "poll.endPollAlert.endPoll.text", + "prose": true + }, + "End this poll?": { + "key": "poll.endPollAlert.endPoll.title", + "prose": true + }, + "Select one": { + "key": "poll.header.selectOne.label", + "prose": true + }, + "Select one or more": { + "key": "poll.header.selectOneMore.label", + "prose": true + }, + "Select up to {{count}}": { + "key": "poll.header.selectUp.label", + "prose": true, + "plural": true + }, + "Vote ended": { + "key": "poll.header.voteEnded.label", + "prose": true + }, + "Choose between 2 to 10 options": { + "key": "poll.multipleAnswersField.chooseBetween210.description", + "prose": true + }, + "Enforce unique vote is enabled": { + "key": "poll.multipleAnswersField.enforceUniqueVoteEnabled.label", + "prose": true + }, + "Limit votes per person": { + "key": "poll.multipleAnswersField.limitVotesPerPerson.title", + "prose": true + }, + "Maximum votes per person": { + "key": "poll.multipleAnswersField.maximumVotesPerPerson.ariaLabel", + "prose": true + }, + "Multiple votes": { + "key": "poll.multipleAnswersField.multipleVotes.title", + "prose": true + }, + "Only numbers are allowed": { + "key": "poll.multipleAnswersField.onlyNumbersAllowed.label", + "prose": true + }, + "Select more than one option": { + "key": "poll.multipleAnswersField.selectMoreThanOne.description", + "prose": true + }, + "Type a number from 2 to 10": { + "key": "poll.multipleAnswersField.typeNumber210.label", + "prose": true + }, + "Ask a question": { + "key": "poll.nameField.askQuestion.placeholder", + "prose": true + }, + "Error": { + "key": "poll.nameField.error.text", + "prose": true + }, + "Question is required": { + "key": "poll.nameField.questionRequired.label", + "prose": true + }, + "Add an option": { + "key": "poll.optionFieldSet.addOption.placeholder", + "prose": true + }, + "aria/Option {{ position }}": { + "key": "poll.optionFieldSet.option.ariaLabel", + "prose": true + }, + "aria/This option can be reordered and removed.": { + "key": "poll.optionFieldSet.optionCanReorderedRemoved.ariaLabel", + "prose": true + }, + "Option is empty": { + "key": "poll.optionFieldSet.optionEmpty.label", + "prose": true + }, + "Options": { + "key": "poll.optionFieldSet.options.label", + "prose": true + }, + "aria/Options can now be reordered and removed.": { + "key": "poll.optionFieldSet.optionsCanNowReordered.ariaLabel", + "prose": true + }, + "aria/Remove option: {{ option }}": { + "key": "poll.optionFieldSet.removeOption.ariaLabel", + "prose": true + }, + "+{{count}} more options": { + "key": "poll.optionList.moreOptions.label", + "prose": true, + "plural": true + }, + "aria/Press Space to select this option, use the Up and Down arrow keys to move it, then press Space again to deselect it.": { + "key": "poll.optionReorder.pressSpaceSelectOption.ariaLabel", + "prose": true + }, + "aria/Reorder option {{ position }}": { + "key": "poll.optionReorder.reorderOption.ariaLabel", + "prose": true + }, + "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": { + "key": "poll.optionReorder.reorderPosition.ariaLabel", + "prose": true + }, + "Review all options available in this poll": { + "key": "poll.optionsFull.description", + "prose": true + }, + "Poll options": { + "key": "poll.optionsFull.pollOptions.title", + "prose": true + }, + "Question {{ optionOrderNumber}}": { + "key": "poll.optionVotes.question.text", + "prose": true + }, + "View all": { + "key": "poll.optionVotes.view.text", + "prose": true + }, + "{{count}} votes": { + "key": "poll.optionVotes.votes.text", + "prose": true, + "plural": true + }, + "placeholder/PollComment": { + "key": "poll.pollComment.placeholder", + "prose": true + }, + "placeholder/PollOptionSuggestion": { + "key": "poll.pollOptionSuggestion.placeholder", + "prose": true + }, + "Question": { + "key": "poll.question.question.text", + "prose": true + }, + "Poll results": { + "key": "poll.results.pollResults.title", + "prose": true + }, + "Review poll results and open an option to see detailed votes": { + "key": "poll.results.reviewPollResultsOpen.description", + "prose": true + }, + "Review who voted for this option": { + "key": "poll.results.reviewWhoVotedOption.description", + "prose": true + }, + "totalVoteCount": { + "key": "poll.results.totalVoteCount.text", + "prose": true, + "plural": true + }, + "Votes": { + "key": "poll.results.votes.title", + "prose": true + }, + "Suggest a new option to add to this poll": { + "key": "poll.suggestPollOption.description", + "prose": true + }, + "Option already exists": { + "key": "poll.suggestPollOption.optionAlreadyExists.label", + "prose": true + }, + "Error fetching reactions": { + "key": "reactions.fetchReactions.errorFetchingReactions.text", + "prose": true + }, + "aria/Reaction list": { + "key": "reactions.messageReactions.reactionList.ariaLabel", + "prose": true + }, + "aria/Select Reaction: {{ reactionName }}": { + "key": "reactions.messageReactions.selectReaction.ariaLabel", + "prose": true + }, + "{{ count }} reactions": { + "key": "reactions.messageReactionsDetail.reactions.text", + "prose": true, + "plural": true + }, + "Tap to remove: {{ reactionName }}": { + "key": "reactions.messageReactionsDetail.tapRemove.ariaLabel", + "prose": true + }, + "Tap to remove": { + "key": "reactions.messageReactionsDetail.tapRemove.text", + "prose": true + }, + "aria/Clear search": { + "key": "search.bar.clearSearch.ariaLabel", + "prose": true + }, + "aria/Exit search": { + "key": "search.bar.exitSearch.ariaLabel", + "prose": true + }, + "aria/Select User Channel: {{ name }}": { + "key": "search.resultItem.selectUserChannel.ariaLabel", + "prose": true + }, + "aria/Search results": { + "key": "search.results.searchResults.ariaLabel", + "prose": true + }, + "aria/Search results header filter button for: {{ source }}": { + "key": "search.resultsHeader.ariaLabel", + "prose": true + }, + "search-results-header-filter-source-button-label--channels": { + "key": "search.resultsHeader.filterSource.channels", + "prose": true + }, + "search-results-header-filter-source-button-label--messages": { + "key": "search.resultsHeader.filterSource.messages", + "prose": true + }, + "search-results-header-filter-source-button-label--users": { + "key": "search.resultsHeader.filterSource.users", + "prose": true + }, + "Start typing to search": { + "key": "search.resultsPresearch.startTypingSearch.text", + "prose": true + }, + "No results found": { + "key": "search.sourceResults.noResultsFound.text", + "prose": true + }, + "Searching for {{ searchSourceType }}...": { + "key": "search.sourceResults.searching.text", + "prose": true + }, + "Channels": { + "key": "slotLayout.chatView.channels.text", + "prose": true + }, + "aria/Chat view controls": { + "key": "slotLayout.chatView.chatViewControls.ariaLabel", + "prose": true + }, + "aria/Open channels view": { + "key": "slotLayout.chatView.openChannelsView.ariaLabel", + "prose": true + }, + "aria/Open threads view": { + "key": "slotLayout.chatView.openThreadsView.ariaLabel", + "prose": true + }, + "aria/Open threads view with unread threads": { + "key": "slotLayout.chatView.openThreadsViewUnread.ariaLabel", + "prose": true, + "plural": true + }, + "aria/Message input": { + "key": "textareaComposer.messageInput.ariaLabel", + "prose": true + }, + "Notify all {{ role }} members": { + "key": "textareaComposer.roleItem.notifyMembers.label", + "prose": true + }, + "aria/Command Suggestions": { + "key": "textareaComposer.suggestionList.commandSuggestions.ariaLabel", + "prose": true + }, + "aria/Emoji Suggestions": { + "key": "textareaComposer.suggestionList.emojiSuggestions.ariaLabel", + "prose": true + }, + "aria/Mention Suggestions": { + "key": "textareaComposer.suggestionList.mentionSuggestions.ariaLabel", + "prose": true + }, + "aria/Suggestions": { + "key": "textareaComposer.suggestionList.suggestions.ariaLabel", + "prose": true + }, + "Search GIFs": { + "key": "textareaComposer.textareaPlaceholder.searchGiFs.label", + "prose": true + }, + "Send a message": { + "key": "textareaComposer.textareaPlaceholder.sendMessage.label", + "prose": true + }, + "Slow mode, wait {{ seconds }}s...": { + "key": "textareaComposer.textareaPlaceholder.slowModeWaitS.label", + "prose": true + }, + "aria/Close thread": { + "key": "thread.header.closeThread.ariaLabel", + "prose": true + }, + "Thread": { + "key": "thread.header.thread.text", + "prose": true + }, + "aria/Chat: {{ channelName }}": { + "key": "threadList.chat.ariaLabel", + "prose": true + }, + "Reply to a message to start a thread": { + "key": "threadList.empty.text", + "prose": true + }, + "aria/Thread: {{ messagePreview }}": { + "key": "threadList.thread.ariaLabel", + "prose": true + }, + "aria/Thread list": { + "key": "threadList.threadList.ariaLabel", + "prose": true + }, + "ThreadListUnseenThreadsBanner/loading": { + "key": "threadList.unseenBanner.loading", + "prose": true + }, + "ThreadListUnseenThreadsBanner/unreadThreads": { + "key": "threadList.unseenBanner.unreadThreads", + "prose": true + }, + "timestamp/ChannelDetailPinnedMessageTimestamp": { + "key": "timestamp.ChannelDetailPinnedMessageTimestamp", + "prose": false + }, + "timestamp/ChannelMembersLastActive": { + "key": "timestamp.ChannelMembersLastActive", + "prose": false + }, + "timestamp/ChannelPreviewTimestamp": { + "key": "timestamp.ChannelPreviewTimestamp", + "prose": false + }, + "timestamp/DateSeparator": { + "key": "timestamp.DateSeparator", + "prose": false + }, + "timestamp/LiveLocation": { + "key": "timestamp.LiveLocation", + "prose": false + }, + "timestamp/MessageTimestamp": { + "key": "timestamp.MessageTimestamp", + "prose": false + }, + "timestamp/PollVote": { + "key": "timestamp.PollVote", + "prose": false + }, + "timestamp/PollVoteTooltip": { + "key": "timestamp.PollVoteTooltip", + "prose": false + }, + "timestamp/relativeDaysAgo": { + "key": "timestamp.relativeDaysAgo", + "prose": true + }, + "timestamp/relativeToday": { + "key": "timestamp.relativeToday", + "prose": true + }, + "timestamp/relativeWeeksAgo": { + "key": "timestamp.relativeWeeksAgo", + "prose": true + }, + "timestamp/relativeYesterday": { + "key": "timestamp.relativeYesterday", + "prose": true + }, + "timestamp/ReminderNotification": { + "key": "timestamp.ReminderNotification", + "prose": false + }, + "timestamp/SystemMessage": { + "key": "timestamp.SystemMessage", + "prose": false + }, + "translationBuilderTopic/notification": { + "key": "translationBuilderTopic.notification", + "prose": false + }, + "{{ count }} people are typing": { + "key": "typing.manyUsers", + "prose": true, + "plural": true + }, + "{{ typing }} is typing": { + "key": "typing.singleUser", + "prose": true + }, + "{{ typing }} are typing": { + "key": "typing.twoUsers", + "prose": true + }, + "Play video": { + "key": "videoPlayer.videoThumbnail.playVideo.ariaLabel", + "prose": true + } + } +} diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md new file mode 100644 index 0000000000..301240b0fd --- /dev/null +++ b/ai-docs/i18n-v15-migration.md @@ -0,0 +1,273 @@ +# i18n changes in v15 + +Two breaking changes, both in v15: + +1. **English is the only bundled language.** The `de`, `es`, `fr`, `hi`, `it`, `ja`, `ko`, `nl`, + `pt`, `ru` and `tr` dictionaries are gone, along with their `dayjs` locale data. +2. **Translation keys are namespaced identifiers**, not the English text. `t('Send Message')` + became `t('messageComposer.sendButton.send.ariaLabel', 'Send')`. + +Together these cut ~112 KB gzip (27%) from the bundle: the 11 dictionaries were statically +imported and copied into `Streami18n` at construction, so they shipped even if you never set +`language`. + +## Do I need to do anything? + +| If you… | Action | +| --------------------------------------------- | ---------------------------------------------- | +| use the SDK in English and never touched i18n | **Nothing.** | +| passed `translationsForLanguage` | Rename your keys — see below | +| called `registerTranslation()` | Rename your keys — see below | +| used a built-in non-English language | Supply the dictionary yourself — see below | +| relied on non-English date formats | Import the `dayjs` locale yourself — see below | +| imported `deTranslations` … `trTranslations` | Those exports are removed | + +## Renaming your keys + +Every old key maps to exactly one new key. The full table (603 rows) is +[`i18n-v15-key-map.json`](./i18n-v15-key-map.json): + +```json +{ + "keys": { + "Cancel": { "key": "common.cancel.label", "prose": true }, + "aria/Send": { "key": "messageComposer.sendButton.send.ariaLabel", "prose": true }, + "{{ count }} members": { + "key": "channelDetail.channelMembersView.members.title", + "prose": true, + "plural": true + }, + "giphy-command-args": { "key": "command.giphy.args", "prose": true }, + "language/de": { "key": "language.de", "prose": true }, + "timestamp/MessageTimestamp": { "key": "timestamp.MessageTimestamp", "prose": false } + } +} +``` + +Before: + +```ts +i18n.registerTranslation('de', { + Cancel: 'Abbrechen', + 'aria/Send': 'Senden', + '{{ count }} members_one': '{{ count }} Mitglied', + '{{ count }} members_other': '{{ count }} Mitglieder', +}); +``` + +After: + +```ts +import type { TranslationDictionary } from 'stream-chat-react'; + +const de: TranslationDictionary = { + 'common.cancel.label': 'Abbrechen', + 'messageComposer.sendButton.send.ariaLabel': 'Senden', + 'channelDetail.channelMembersView.members.title_one': '{{ count }} Mitglied', + 'channelDetail.channelMembersView.members.title_other': '{{ count }} Mitglieder', +}; + +i18n.registerTranslation('de', de); +``` + +**Renaming is not optional and it fails quietly.** An old key simply never matches, so your +override stops applying and the English copy renders instead — no error. + +Typing the dictionary as `TranslationDictionary` turns that silent failure into a compile error: + +```ts +const de: TranslationDictionary = { + 'common.cancel.label': 'Abbrechen', + Cancel: 'Abbrechen', // ← v14 key: compile error, exactly what you want here +}; +``` + +Widen to `LooseTranslationDictionary` only where you need keys of your own — it admits any key, so +nothing catches a stale one there. The extra plural categories some languages use (`_few`, `_many`, +`_zero`) do **not** need it; `TranslationDictionary` accepts those already. + +> Do **not** key a dictionary on `Partial>`. `TranslationKey` is the +> set `t()` accepts, where a plural is the bare ``; a dictionary needs the `_one` / `_other` +> entries, which that type rejects. `TranslationDictionary` already handles this. + +## Discovering the keys + +- **`TranslationDictionary`** — the one to reach for: every SDK key, including the plural forms, and + nothing else. A typo or a stale v14 key is a compile error. A plural key takes any category + `Intl.PluralRules` can select, so `_few` / `_many` / `_zero` are checked too; a plural suffix on a + key that is not plural is rejected. +- **`LooseTranslationDictionary`** — the same, plus any key you like, so one instance can also carry + your app's own copy and extra plural categories. Nothing catches a stale key here. Opt in by + annotating the variable you pass; `registerTranslation()` and `translationsForLanguage` take + `TranslationDictionary`, so a key typed inline is checked: + + ```ts + i18n.registerTranslation('de', { 'common.cancel.lable': 'Abbrechen' }); // ← compile error + + const withOwnKeys: LooseTranslationDictionary = { 'myApp.somethingElse': 'Hallo' }; + i18n.registerTranslation('de', withOwnKeys); // ← fine + ``` + +- **`TranslationKey`** — the union `t()` accepts (a plural appears as the bare key). Use it to type + a `t` parameter; it is not the right key type for a dictionary. +- **`TranslationCatalog`** — every key mapped to its English copy, exported from + `stream-chat-react`. Type-only, so it adds nothing to your bundle; hover a key to see what it + renders, or index it (`TranslationCatalog['common.cancel.label']` is `'Cancel'`). +- **A JSON catalog** — the file to hand to translators. The SDK does not check one in (the copy + lives inline at each `t()` call site, so a committed catalog would be a duplicate that can go + stale). Generate it from a clone with: + + ```bash + yarn i18n:export + ``` + + That writes `en.json` in the repo root with the 619 translatable keys. The 14 `timestamp.*`, + `duration.*` and `translationBuilderTopic.*` entries are left out on purpose — they are dayjs and + i18next expressions, and a TMS that "translates" `{{value, notification}}` breaks notifications + outright. Four of them do carry English day words; those are handled by overriding the key, see + [Date and time](#date-and-time). Pass `--all` for the complete 633-key catalog. + + `ai-docs/i18n-v15-key-map.json` also lists every key, alongside the v14 string it replaced. + +Keys are namespaced after the source tree, so they are predictable from the component: +`message.*`, `messageComposer.*`, `poll.*`, `channelList.*`, with genuinely shared copy under +`common.*`. The last segment is the modality: `.label`, `.ariaLabel`, `.placeholder`, `.title`, +`.description`, `.text`. + +### Plurals + +The SDK's own copy only needs `_one` / `_other`. Supply whichever categories your language +needs — i18next selects between them with `Intl.PluralRules`, so Russian or Arabic can add `_few`, +`_many` and `_zero` and still have every key checked: + +```ts +i18n.registerTranslation('ru', { + 'channelDetail.channelMembersView.members.title_one': '{{ count }} участник', + 'channelDetail.channelMembersView.members.title_few': '{{ count }} участника', + 'channelDetail.channelMembersView.members.title_many': '{{ count }} участников', +}); +``` + +### Keys that are not copy + +Entries with `"prose": false` — `timestamp.*`, `duration.*`, `translationBuilderTopic.*` — hold +formatter expressions, not text: + +```json +"timestamp.MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: false; format: HH:mm) }}" +``` + +Most of them only need overriding to change _how_ a date is formatted. **Four of them also carry +English words**, because dayjs takes the calendar wording as part of the format string: + +| Key | English baked into `calendarFormats` | +| ----------------------------------------------- | ---------------------------------------------- | +| `timestamp.DateSeparator` | `Today`, `Tomorrow`, `Yesterday`, `Last` | +| `timestamp.ReminderNotification` | `Today`, `Tomorrow`, `Yesterday`, `Last`, `at` | +| `timestamp.ChannelPreviewTimestamp` | `Yesterday` | +| `timestamp.ChannelDetailPinnedMessageTimestamp` | `Yesterday` | + +Translating those four means overriding the key itself — `dayjsLocaleConfigForLanguage` does not +reach them. See [Date and time](#date-and-time) for the how and why. + +`relativeTime.*` ("Today", "{{ count }}d ago") is ordinary copy and translates normally, as does +everything else. + +## Keeping a language up to date across upgrades + +When a later SDK release adds a key, **your build stays green.** `TranslationDictionary` is +`Partial`, so nothing is required; the new string renders its inline English until you translate it. +That is deliberate — a partial dictionary is always safe, and no key ever renders as a raw dotted +path — but it does mean new copy arrives untranslated without telling you. + +To be told, diff your dictionary against the catalog at the type level. Declare it `as const` so +TypeScript keeps the literal keys, then `Exclude` them from the catalog: + +```ts +import type { TranslationCatalog, TranslationDictionary } from 'stream-chat-react'; + +export const de = { + 'common.cancel.label': 'Abbrechen', + 'common.back.label': 'Zurück', +} as const satisfies TranslationDictionary; + +/** Every key still needing German. Hover it to read the list. */ +type Untranslated = Exclude; +``` + +Hovering `Untranslated` in your editor lists the missing keys, and it shrinks as you add them. To +turn "am I complete?" into a build failure — useful in CI after a dependency bump — assert the diff +is empty: + +```ts +type AssertEmpty = T; +type TranslationsComplete = AssertEmpty; +// ^ compile error naming a missing key until `de` covers the whole catalog +``` + +`satisfies` is doing real work here: it still type-checks every key against the catalog (so a typo +is an error) while `as const` preserves the literal keys that `keyof typeof de` needs. Using a plain +`: TranslationDictionary` annotation would widen `keyof typeof de` to the whole catalog and the diff +would always be empty. + +One caveat: there is no _runtime_ list of keys to diff against — `TranslationCatalog` is a type, +which is what keeps the typed surface free at runtime. So this check is compile-time only; a script +cannot ask the installed package "which keys exist?". + +Extra plural categories are safe here: they are accepted by `TranslationDictionary` but are not +catalog keys, so they neither break the `satisfies` check nor shrink the diff. + +## Supplying a language the SDK used to ship + +The last published dictionaries are in git history. To recover one: + +```bash +git show v14.11.0:src/i18n/de.json > de.json +``` + +Then rename its keys with the mapping table above and register it. Note the old file's keys are the +_old_ natural-language keys, so it needs the same rename as your own overrides. + +## Date and time + +Only the `en` dayjs locale is bundled, and the per-language `calendar` formats the SDK used to ship +are gone. For any other language, import the locale and supply the calendar config: + +```ts +import 'dayjs/locale/de.js'; + +const i18n = new Streami18n({ + language: 'de', + dayjsLocaleConfigForLanguage: { + calendar: { + sameDay: '[heute um] LT', + lastDay: '[gestern um] LT', + lastWeek: '[letzten] dddd [um] LT', + nextDay: '[morgen um] LT', + nextWeek: 'dddd [um] LT', + sameElse: 'L', + }, + }, +}); +``` + +Or pass your own preconfigured `DateTimeParser` (dayjs or moment). + +## Why keys changed at all + +The old keys _were_ the English copy, which meant: + +- 375 of 706 entries were `"X": "X"` duplication. +- Any copy edit silently orphaned every translation, because the key changed with the text. +- The same word in different contexts could not be disambiguated. The codebase had already grown an + ad-hoc `aria/` prefix to work around exactly this. + +Keys are now stable, and the English copy travels inline at the call site as i18next's +`defaultValue`. That keeps the copy readable where it is used, and means a key you do not supply +still renders English rather than a raw key path. + +The exception is the ~71 keys that carry no inline copy — `timestamp.*` and `duration.*` (formatter +expressions), `language.*` (built from a runtime language code), and the postProcessor directive. +Those are bundled in `runtimeDefaults` instead, and both `registerTranslation()` and +`translationsForLanguage` merge your dictionary over them, so you inherit the working defaults +without listing them. You only need to supply one if you want a different date format. diff --git a/examples/vite/package.json b/examples/vite/package.json index 675a347f78..bb52b27bef 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -11,6 +11,7 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "clsx": "^2.1.1", + "dayjs": "^1.11.20", "emoji-mart": "^5.6.0", "human-id": "^4.1.3", "modern-normalize": "^3.0.1", diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 24794d22af..1f132974d8 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -35,7 +35,6 @@ import { type NotificationListProps, type ReactionOptions, Search, - Streami18n, useCreateChatClient, WithComponents, } from 'stream-chat-react'; @@ -92,6 +91,7 @@ import { import { ConfigurableMessageActions } from './CustomMessageActions'; import { SidebarToggle } from './Sidebar/SidebarToggle.tsx'; import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx'; +import { streamI18n } from './i18n'; const PUBLIC_VITE_EXAMPLE_API_KEY = 'xzwhhgtazy6h'; @@ -230,15 +230,6 @@ const ConfigurableNotificationList = (props: NotificationListProps) => { return ; }; -const language = new URLSearchParams(window.location.search).get('language'); -const i18nInstance = language - ? new Streami18n({ - language: language as NonNullable< - ConstructorParameters[0] - >['language'], - }) - : undefined; - const messageUiVariant = getMessageUiVariant(); const MessageUiOverride = messageUiVariant ? getMessageUiComponent(messageUiVariant) @@ -569,7 +560,7 @@ const App = () => { { const minClampedValue = Math.max(min, value); @@ -124,6 +132,9 @@ const defaultAppSettingsState: AppSettingsState = { chatView: { iconOnly: true, }, + language: { + code: DEFAULT_LANGUAGE, + }, layout: {}, messageActions: { customMessageActions: { @@ -254,6 +265,37 @@ const getThemeModeFromUrl = (): ThemeSettingsState['mode'] | undefined => { } }; +const getLanguageFromUrl = (): string | undefined => { + if (typeof window === 'undefined') return; + + return new URLSearchParams(window.location.search).get(languageUrlParam) ?? undefined; +}; + +/** The store is the source of truth; this pushes the choice into the SDK. */ +const applyLanguage = (code: string) => { + void streamI18n.setLanguage(code); +}; + +const persistLanguageInUrl = (code: string) => { + if (typeof window === 'undefined') return; + + const url = new URL(window.location.href); + + if (url.searchParams.get(languageUrlParam) === code) return; + + if (code === DEFAULT_LANGUAGE) { + url.searchParams.delete(languageUrlParam); + } else { + url.searchParams.set(languageUrlParam, code); + } + + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}`, + ); +}; + const persistDirection = (direction: ThemeSettingsState['direction']) => { if (typeof window === 'undefined') return; @@ -307,6 +349,9 @@ const persistThemeModeInUrl = (themeMode: ThemeSettingsState['mode']) => { const initialAppSettingsState: AppSettingsState = { ...defaultAppSettingsState, + language: { + code: getLanguageFromUrl() ?? defaultAppSettingsState.language.code, + }, panelLayout: getStoredPanelLayoutSettings() ?? defaultAppSettingsState.panelLayout, theme: { ...defaultAppSettingsState.theme, @@ -334,6 +379,16 @@ appSettingsStore.subscribeWithSelector( }, ); +// The switcher writes to the store; this is what makes the UI change language. `streamI18n` was +// constructed with the initial code already, so the immediate invocation is a no-op. +appSettingsStore.subscribeWithSelector( + ({ language }) => ({ code: language.code }), + ({ code }) => { + applyLanguage(code); + persistLanguageInUrl(code); + }, +); + // Apply initial direction on load applyDirection(initialAppSettingsState.theme.direction); diff --git a/examples/vite/src/AppSettings/tabs/ChannelDetail/removeMembersHeaderActions.tsx b/examples/vite/src/AppSettings/tabs/ChannelDetail/removeMembersHeaderActions.tsx index 05d82881db..d146273d38 100644 --- a/examples/vite/src/AppSettings/tabs/ChannelDetail/removeMembersHeaderActions.tsx +++ b/examples/vite/src/AppSettings/tabs/ChannelDetail/removeMembersHeaderActions.tsx @@ -1,4 +1,5 @@ import { + asDynamicKey, Button, ContextMenuButton, IconUserRemove, @@ -20,13 +21,16 @@ export const RemoveMembersHeaderAction = ({ return ( ); }; @@ -41,14 +45,17 @@ export const RemoveMembersMenuAction = ({ return ( { modeController.setMode('remove'); closeMenu?.(); }} > - {t('Remove')} + {t(asDynamicKey('viteExample.removeMembers.trigger.label'), 'Remove')} ); }; diff --git a/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx b/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx index 1cff346a8b..95d5c969db 100644 --- a/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx +++ b/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import type { ChannelPaginatorsOrchestratorState } from 'stream-chat'; import { Button, useChatContext, useStateStore } from 'stream-chat-react'; import { appSettingsStore, useAppSettingsState } from '../../state'; +import { availableLanguages } from '../../../i18n'; import { SearchableSelect, type SearchableSelectOption } from '../../SearchableSelect'; import { SettingsTabBody, @@ -18,6 +19,7 @@ const paginatorsSelector = (state: ChannelPaginatorsOrchestratorState) => ({ export const GeneralTab = ({ close }: GeneralTabProps) => { const { + language, messageList, theme, theme: { direction }, @@ -63,6 +65,27 @@ export const GeneralTab = ({ close }: GeneralTabProps) => { /> +
+
Language
+
+ Switches the SDK's UI language live — every language is registered on one{' '} + Streami18n instance and this calls setLanguage(). + English is built in; German and Italian are supplied by this app in{' '} + src/i18n/. +
+
+ {availableLanguages.map(({ code, label }) => ( + + ))} +
+
Text direction
diff --git a/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx b/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx index 7ff64c16ef..d19d785930 100644 --- a/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx +++ b/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx @@ -1,6 +1,7 @@ import type { ChannelMemberResponse, UserResponse } from 'stream-chat'; import { useMemo, useState } from 'react'; import { + asDynamicKey, Avatar, Checkbox, InfiniteScrollPaginator, @@ -38,17 +39,21 @@ const getPresenceStatusText = ( user: ChannelMemberResponse['user'], t: ReturnType['t'], ) => { - if (user?.online) return t('Online'); + if (user?.online) return t('common.online.label', 'Online'); if (user?.last_active) { - return t('Last seen {{ timestamp }}', { - timestamp: t('timestamp/ChannelMembersLastActive', { - timestamp: user.last_active, - }), - }); + return t( + 'channelDetail.channelMemberDetail.lastSeen.label', + 'Last seen {{ timestamp }}', + { + timestamp: t('timestamp.ChannelMembersLastActive', { + timestamp: user.last_active, + }), + }, + ); } - return t('Offline'); + return t('common.offline.label', 'Offline'); }; export const ChannelMembersRemoveView = ({ @@ -89,7 +94,11 @@ export const ChannelMembersRemoveView = ({ addNotification({ context: { channel }, emitter: 'ChannelMembersRemoveView', - message: t('Removed {{ count }} members', { count: memberCount }), + message: t(asDynamicKey('viteExample.removeMembers.removed.text'), { + count: memberCount, + defaultValue_one: 'Removed {{ count }} member', + defaultValue_other: 'Removed {{ count }} members', + }), severity: 'success', type: 'api:channel:remove-members:success', }); @@ -101,7 +110,10 @@ export const ChannelMembersRemoveView = ({ context: { channel }, emitter: 'ChannelMembersRemoveView', error: toError(error), - message: t('Error removing members'), + message: t( + asDynamicKey('viteExample.removeMembers.error.text'), + 'Error removing members', + ), severity: 'error', type: 'api:channel:remove-members:failed', }); @@ -155,7 +167,12 @@ export const ChannelMembersRemoveView = ({ ); }) ) : ( - {t('No member found')} + + {t( + 'channelDetail.channelMembersBrowse.noMemberFound.text', + 'No member found', + )} + )} @@ -167,7 +184,11 @@ export const ChannelMembersRemoveView = ({ disabled={isRemoving} onClick={handleRemove} > - {t('Remove {{ count }} members', { count: selectedMemberUserIds.length })} + {t(asDynamicKey('viteExample.removeMembers.submit.label'), { + count: selectedMemberUserIds.length, + defaultValue_one: 'Remove {{ count }} member', + defaultValue_other: 'Remove {{ count }} members', + })} diff --git a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx index 90d37afb15..6f3fc6d9d9 100644 --- a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx +++ b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useTranslationContext } from 'stream-chat-react'; +import { asDynamicKey, useTranslationContext } from 'stream-chat-react'; import { AvatarWithChannelDetail, type AvatarWithChannelDetailProps, @@ -18,7 +18,11 @@ import { ChannelMembersRemoveView } from './ChannelMembersRemoveView'; const ChannelMembersRemoveTitle = () => { const { t } = useTranslationContext(); - return <>{t('Manage members')}; + return ( + <> + {t(asDynamicKey('viteExample.channelDetail.manageMembers.title'), 'Manage members')} + + ); }; // Register the app-provided bulk-remove mode. The SDK ships no bulk removal; the diff --git a/examples/vite/src/ChatLayout/Panels.tsx b/examples/vite/src/ChatLayout/Panels.tsx index aaa5721cae..5d11f1da51 100644 --- a/examples/vite/src/ChatLayout/Panels.tsx +++ b/examples/vite/src/ChatLayout/Panels.tsx @@ -338,7 +338,7 @@ const LayerThreadHeader = ({ thread }: ThreadHeaderProps) => {
-
{t('Thread')}
+
+ {t('thread.header.thread.text', 'Thread')} +
- {replyCount === 1 - ? t('1 reply') - : t('{{ count }} replies', { count: replyCount })} + {/* One plural key rather than a hand-rolled count branch: i18next picks the form via + Intl.PluralRules, so a language with more categories works without touching this. */} + {t('common.replyCount.label', { + count: replyCount, + defaultValue_one: '1 reply', + defaultValue_other: '{{ count }} replies', + })}
diff --git a/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx index ee3a11ee5e..4093b07e1a 100644 --- a/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx +++ b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx @@ -9,6 +9,7 @@ import { import type { DeleteMessageOptions, LocalMessage } from 'stream-chat'; import { Alert, + asDynamicKey, Button, ContextMenuButton, defaultMessageActionSet, @@ -71,8 +72,14 @@ const CustomDeleteMessageAlert = ({ data-testid='message-delete-alert' > {enableOptionConfiguration && (
@@ -81,7 +88,10 @@ const CustomDeleteMessageAlert = ({ id='delete-message-alert-delete-only-for-me-switch' onChange={(event) => setDeleteForMe(event.target.checked)} > - {t('Delete for me only')} + {t( + asDynamicKey('viteExample.deleteAlert.deleteForMeOnly.label'), + 'Delete for me only', + )} - {t('Hard delete')} + {t(asDynamicKey('viteExample.deleteAlert.hardDelete.label'), 'Hard delete')} - {t('Soft delete')} + {t(asDynamicKey('viteExample.deleteAlert.softDelete.label'), 'Soft delete')}
)} @@ -120,7 +130,7 @@ const CustomDeleteMessageAlert = ({ size='md' variant='danger' > - {t('Delete message')} + {t('messageActions.deleteMessageAlert.deleteMessage.title', 'Delete message')} @@ -174,7 +184,7 @@ const CustomDeleteMessageAction = () => { return ( { @@ -183,7 +193,7 @@ const CustomDeleteMessageAction = () => { }} variant='destructive' > - {t('Delete message')} + {t('messageActions.deleteMessageAlert.deleteMessage.title', 'Delete message')} ); }; @@ -198,7 +208,7 @@ const CustomMarkOwnUnreadMessageAction = () => { return ( { @@ -209,7 +219,10 @@ const CustomMarkOwnUnreadMessageAction = () => { message, }, emitter: 'MessageActions', - message: t('Message marked as unread'), + message: t( + 'messageActions.messageMarkedUnread.text', + 'Message marked as unread', + ), severity: 'success', type: 'api:message:markUnread:success', }); @@ -223,6 +236,7 @@ const CustomMarkOwnUnreadMessageAction = () => { message: getErrorMessage( error, t( + 'messageActions.errorMarkingMessageUnread.text', 'Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.', ), ), @@ -234,7 +248,7 @@ const CustomMarkOwnUnreadMessageAction = () => { } }} > - {t('Mark as unread')} + {t('messageActions.markUnread.text', 'Mark as unread')} ); }; @@ -465,7 +479,7 @@ export const ConfigurableMessageActions = ( message: deleteDialogTarget.message, }, emitter: 'MessageActions', - message: t('Message deleted'), + message: t('common.messageDeleted.text', 'Message deleted'), severity: 'success', type: 'api:message:delete:success', }); @@ -476,7 +490,10 @@ export const ConfigurableMessageActions = ( }, emitter: 'MessageActions', error: getNotificationError(error), - message: getErrorMessage(error, t('Error deleting message')), + message: getErrorMessage( + error, + t('common.errorDeletingMessage.label', 'Error deleting message'), + ), severity: 'error', type: 'api:message:delete:failed', }); diff --git a/examples/vite/src/Sidebar/SidebarToggle.tsx b/examples/vite/src/Sidebar/SidebarToggle.tsx index 1b0e7ed507..95371d04cd 100644 --- a/examples/vite/src/Sidebar/SidebarToggle.tsx +++ b/examples/vite/src/Sidebar/SidebarToggle.tsx @@ -1,6 +1,11 @@ import { useState } from 'react'; import { useSidebar } from '../ChatLayout/SidebarContext.tsx'; -import { Button, PopperTooltip, useTranslationContext } from 'stream-chat-react'; +import { + asDynamicKey, + Button, + PopperTooltip, + useTranslationContext, +} from 'stream-chat-react'; import { IconSidebar } from '../icons.tsx'; export const SidebarToggle = () => { @@ -10,11 +15,15 @@ export const SidebarToggle = () => { const [tooltipVisible, setTooltipVisible] = useState(false); const tooltipText = sidebarOpen ? 'Close sidebar' : 'Open sidebar'; + const toggleLabel = sidebarOpen + ? t(asDynamicKey('viteExample.sidebar.collapse.ariaLabel'), 'Collapse sidebar') + : t(asDynamicKey('viteExample.sidebar.expand.ariaLabel'), 'Expand sidebar'); + return ( <>
- {t('Live until {{ timestamp }}', { - timestamp: t('timestamp/LiveLocation', { timestamp: location.end_at }), - })} + {t( + 'attachment.geolocation.liveUntil.text', + 'Live until {{ timestamp }}', + { + timestamp: t('timestamp.LiveLocation', { + timestamp: location.end_at, + }), + }, + )}
) : (
- {t('Live location')} + {t('common.liveLocation.text', 'Live location')}
- {t('Live until {{ timestamp }}', { - timestamp: t('timestamp/LiveLocation', { timestamp: location.end_at }), - })} + {t( + 'attachment.geolocation.liveUntil.text', + 'Live until {{ timestamp }}', + { + timestamp: t('timestamp.LiveLocation', { + timestamp: location.end_at, + }), + }, + )}
) ) : ( - t('Current location') + t('common.currentLocation.text', 'Current location') )} @@ -111,7 +126,10 @@ const DefaultGeolocationAttachmentMapPlaceholder = ({ > { // localized generic label instead of exposing the URL as the accessible name. const descriptiveTitle = getGiphyDescriptiveTitle(title); const accessibleName = descriptiveTitle - ? t('aria/Animated GIF: {{ title }}', { title: descriptiveTitle }) - : t('aria/Animated GIF'); + ? t('attachment.giphy.animatedGif.withTitle.ariaLabel', 'Animated GIF: {{ title }}', { + title: descriptiveTitle, + }) + : t('attachment.giphy.animatedGif.ariaLabel', 'Animated GIF'); const imageStyleVariables = useMemo(() => { const originalHeight = Number(dimensions?.height); const originalWidth = Number(dimensions?.width); diff --git a/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx b/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx index 2d17e3ce1e..7c559c8388 100644 --- a/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx +++ b/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx @@ -14,7 +14,7 @@ export const UnableToRenderCard = ({ type }: { type?: Attachment['type'] }) => { >
- {t('this content could not be displayed')} + {t('attachment.unableRenderCard.text', 'this content could not be displayed')}
diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx index 3557c68be7..907d39fc50 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -172,7 +172,7 @@ const ThumbnailButton = ({ }; const buttonLabel = showRetryIndicator - ? t('aria/Retry upload') + ? t('common.retryUpload.ariaLabel', 'Retry upload') : itemCountAwareLabel({ imageIndex: index + 1, itemCount, t }); return ( @@ -186,11 +186,14 @@ const ThumbnailButton = ({ type='button' > {item.videoThumbnailUrl ? ( - + ) : ( { setIsImageLoading(false); setIsLoadFailed(true); @@ -242,10 +245,14 @@ const itemCountAwareLabel = ({ t: ReturnType['t']; }) => itemCount === 1 - ? t('Open image in gallery') - : t('Open gallery at image {{ index }}', { - index: imageIndex, - }); + ? t('attachment.modalGallery.openImageGallery.label', 'Open image in gallery') + : t( + 'attachment.modalGallery.openGalleryImage.label', + 'Open gallery at image {{ index }}', + { + index: imageIndex, + }, + ); const getBaseImageProps = (item: GalleryItem): BaseImagePropsWithoutSrc => { const baseImageProps: PartialBaseImagePropMap = {}; diff --git a/src/components/Attachment/UnsupportedAttachment.tsx b/src/components/Attachment/UnsupportedAttachment.tsx index b91fcb6d17..fd2c4dd712 100644 --- a/src/components/Attachment/UnsupportedAttachment.tsx +++ b/src/components/Attachment/UnsupportedAttachment.tsx @@ -20,7 +20,7 @@ export const UnsupportedAttachment = () => { className='str-chat__message-attachment-unsupported__title' data-testid='unsupported-attachment-title' > - {t('Unsupported attachment')} + {t('common.unsupportedAttachment.text', 'Unsupported attachment')} diff --git a/src/components/Attachment/VisibilityDisclaimer.tsx b/src/components/Attachment/VisibilityDisclaimer.tsx index 0f58095588..a3b5aabeb3 100644 --- a/src/components/Attachment/VisibilityDisclaimer.tsx +++ b/src/components/Attachment/VisibilityDisclaimer.tsx @@ -7,7 +7,7 @@ export const VisibilityDisclaimer = () => { return (
- {t('Only visible to you')} + {t('attachment.visibilityDisclaimer.onlyVisible.text', 'Only visible to you')}
); }; diff --git a/src/components/Attachment/VoiceRecording.tsx b/src/components/Attachment/VoiceRecording.tsx index 13f667cb16..622ec242cd 100644 --- a/src/components/Attachment/VoiceRecording.tsx +++ b/src/components/Attachment/VoiceRecording.tsx @@ -81,7 +81,7 @@ const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) =>
{ const { t } = useTranslationContext(); - const { asset_url, title = t('Voice message') } = attachment; + const { asset_url, title = t('common.voiceMessage.label', 'Voice message') } = + attachment; const { duration = 0, file_size, diff --git a/src/components/Attachment/__tests__/AttachmentActions.test.tsx b/src/components/Attachment/__tests__/AttachmentActions.test.tsx index e633bb5878..1ebe53e292 100644 --- a/src/components/Attachment/__tests__/AttachmentActions.test.tsx +++ b/src/components/Attachment/__tests__/AttachmentActions.test.tsx @@ -19,7 +19,27 @@ vi.mock('../../Accessibility', () => ({ })); vi.mock('../../../context', () => ({ - useTranslationContext: () => ({ t: (key) => key.replace(/^aria\//, '') }), + useTranslationContext: () => ({ + t: (key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }, + }), })); const getComponent = (props) => ; diff --git a/src/components/Attachment/__tests__/Audio.test.tsx b/src/components/Attachment/__tests__/Audio.test.tsx index d8643599a2..d56a4bf61f 100644 --- a/src/components/Attachment/__tests__/Audio.test.tsx +++ b/src/components/Attachment/__tests__/Audio.test.tsx @@ -22,7 +22,7 @@ vi.mock('../../../context/ChatContext', () => ({ useChatContext: () => ({ client: mockClient }), })); vi.mock('../../../context/TranslationContext', () => ({ - useTranslationContext: () => ({ t: (s) => tSpy(s) }), + useTranslationContext: () => ({ t: (...args) => tSpy(...args) }), })); vi.mock('../../Notifications', () => ({ useNotificationApi: () => ({ @@ -35,7 +35,9 @@ vi.mock('../../Notifications', () => ({ const mockClient = { notifications: { add: addNotificationSpy }, }; -const tSpy = (s) => s; +// Mirrors i18next: render the inline English defaultValue when one is given. +const tSpy = (key, defaultValue) => + typeof defaultValue === 'string' ? defaultValue : key; // capture created Audio() elements so we can assert src & dispatch events const createdAudios = []; //HTMLAudioElement[] diff --git a/src/components/Attachment/__tests__/Giphy.test.tsx b/src/components/Attachment/__tests__/Giphy.test.tsx index d6dee86402..27eeb75da5 100644 --- a/src/components/Attachment/__tests__/Giphy.test.tsx +++ b/src/components/Attachment/__tests__/Giphy.test.tsx @@ -15,15 +15,25 @@ vi.mock('../../../context', () => ({ useChannelStateContext: () => channelStateMock, useComponentContext: () => ({}), useTranslationContext: () => ({ - t: (key, params) => - Object.keys(params ?? {}).reduce( - (acc, paramKey) => - acc.replace( - new RegExp(`\\{\\{\\s${paramKey}\\s\\}\\}`, 'g'), - String(params?.[paramKey]), - ), - key.replace(/^aria\//, ''), - ), + t: (key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }, }), })); diff --git a/src/components/Attachment/__tests__/WaveProgressBar.test.tsx b/src/components/Attachment/__tests__/WaveProgressBar.test.tsx index 9d3a15f2e1..d76d1a5e2a 100644 --- a/src/components/Attachment/__tests__/WaveProgressBar.test.tsx +++ b/src/components/Attachment/__tests__/WaveProgressBar.test.tsx @@ -133,15 +133,12 @@ describe('WaveProgressBar', () => { const root = screen.getByTestId(BAR_ROOT_TEST_ID); expect(root).toHaveAttribute('role', 'slider'); - expect(root).toHaveAttribute('aria-label', 'aria/Seek audio position'); + expect(root).toHaveAttribute('aria-label', 'Seek audio position'); expect(root).toHaveAttribute('tabindex', '0'); expect(root).toHaveAttribute('aria-valuemin', '0'); expect(root).toHaveAttribute('aria-valuemax', '100'); expect(root).toHaveAttribute('aria-valuenow', '20'); - expect(root).toHaveAttribute( - 'aria-valuetext', - 'aria/Audio position {{ progress }} percent', - ); + expect(root).toHaveAttribute('aria-valuetext', 'Audio position 20 percent'); fireEvent.keyDown(root, { key: 'End' }); diff --git a/src/components/Attachment/components/DownloadButton.tsx b/src/components/Attachment/components/DownloadButton.tsx index 73c150dff0..a58b199a3b 100644 --- a/src/components/Attachment/components/DownloadButton.tsx +++ b/src/components/Attachment/components/DownloadButton.tsx @@ -32,7 +32,7 @@ export const DownloadButton = ({ return (
diff --git a/src/components/AudioPlayback/__tests__/ProgressBar.test.tsx b/src/components/AudioPlayback/__tests__/ProgressBar.test.tsx index bf2462569b..345c875bda 100644 --- a/src/components/AudioPlayback/__tests__/ProgressBar.test.tsx +++ b/src/components/AudioPlayback/__tests__/ProgressBar.test.tsx @@ -9,15 +9,12 @@ describe('ProgressBar', () => { const root = screen.getByTestId('audio-progress'); expect(root).toHaveAttribute('role', 'slider'); - expect(root).toHaveAttribute('aria-label', 'aria/Seek audio position'); + expect(root).toHaveAttribute('aria-label', 'Seek audio position'); expect(root).toHaveAttribute('tabindex', '0'); expect(root).toHaveAttribute('aria-valuemin', '0'); expect(root).toHaveAttribute('aria-valuemax', '100'); expect(root).toHaveAttribute('aria-valuenow', '40'); - expect(root).toHaveAttribute( - 'aria-valuetext', - 'aria/Audio position {{ progress }} percent', - ); + expect(root).toHaveAttribute('aria-valuetext', 'Audio position 40 percent'); }); it('seeks forward with ArrowRight key', () => { @@ -71,9 +68,6 @@ describe('ProgressBar', () => { ); const root = screen.getByTestId('audio-progress'); - expect(root).toHaveAttribute( - 'aria-valuetext', - 'aria/Audio position {{ elapsed }} of {{ duration }}', - ); + expect(root).toHaveAttribute('aria-valuetext', 'Audio position 01:20 of 03:20'); }); }); diff --git a/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx b/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx index af153896b6..0701092f59 100644 --- a/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx +++ b/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx @@ -11,7 +11,25 @@ const { mockAddNotification } = vi.hoisted(() => ({ // mock context used by WithAudioPlayback vi.mock('../../../context', () => { - const t = (s: string) => s; + const t = (key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }; return { __esModule: true, useTranslationContext: () => ({ t }), diff --git a/src/components/AudioPlayback/components/ProgressBar.tsx b/src/components/AudioPlayback/components/ProgressBar.tsx index 9aa9568ff4..bae689479c 100644 --- a/src/components/AudioPlayback/components/ProgressBar.tsx +++ b/src/components/AudioPlayback/components/ProgressBar.tsx @@ -44,7 +44,10 @@ export const ProgressBar = ({ return (
{ const errors: Record = { - 'failed-to-start': new Error(t('Failed to play the recording')), + 'failed-to-start': new Error( + t( + 'audioPlayback.audioPlayerNotifications.failedPlayRecording.label', + 'Failed to play the recording', + ), + ), 'not-playable': new Error( - t('Recording format is not supported and cannot be reproduced'), + t( + 'audioPlayback.audioPlayerNotifications.recordingFormatNotSupported.label', + 'Recording format is not supported and cannot be reproduced', + ), + ), + 'seek-not-supported': new Error( + t( + 'audioPlayback.audioPlayerNotifications.cannotSeekRecording.label', + 'Cannot seek in the recording', + ), ), - 'seek-not-supported': new Error(t('Cannot seek in the recording')), }; let lastSeekNotSupportedNotificationAt: number | undefined; @@ -44,7 +57,9 @@ export const audioPlayerNotificationsPluginFactory = ({ const error = (errCode && errors[errCode]) ?? e ?? - new Error(t('Error reproducing the recording')); + new Error( + t('notification.audioPlaybackError', 'Error reproducing the recording'), + ); addNotification({ emitter: 'AudioPlayer', diff --git a/src/components/AudioPlayback/plugins/__tests__/AudioPlayerNotificationsPlugin.test.ts b/src/components/AudioPlayback/plugins/__tests__/AudioPlayerNotificationsPlugin.test.ts index bd887e18cc..6fcb55dad5 100644 --- a/src/components/AudioPlayback/plugins/__tests__/AudioPlayerNotificationsPlugin.test.ts +++ b/src/components/AudioPlayback/plugins/__tests__/AudioPlayerNotificationsPlugin.test.ts @@ -1,9 +1,11 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { TFunction } from 'i18next'; +import type { StreamTFunction } from '../../../../i18n/types'; + import { audioPlayerNotificationsPluginFactory } from '../AudioPlayerNotificationsPlugin'; +import { mockT } from '../../../../mock-builders/translator'; describe('audioPlayerNotificationsPluginFactory', () => { - const t: TFunction = ((s: string) => s) as TFunction; + const t: StreamTFunction = mockT as StreamTFunction; const makeNotifier = () => { const addNotification = vi.fn(); diff --git a/src/components/BaseImage/ImagePlaceholder.tsx b/src/components/BaseImage/ImagePlaceholder.tsx index af2b99c4d9..c1a5f6c6f6 100644 --- a/src/components/BaseImage/ImagePlaceholder.tsx +++ b/src/components/BaseImage/ImagePlaceholder.tsx @@ -11,7 +11,10 @@ export const ImagePlaceholder = ({ className }: ImagePlaceholderProps) => { const { t } = useTranslationContext(); return (
-
{t('Channel Missing')}
+
{t('channel.channelMissing.text', 'Channel Missing')}
); } diff --git a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts index 99b5873dd5..e670b8c438 100644 --- a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts +++ b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts @@ -37,8 +37,10 @@ export function useChannelHeaderOnlineStatus(): string | null { if (!memberCount) return null; if (isDirectMessagingChannel) { - return hasMembersOnline ? t('Online') : t('Offline'); + return hasMembersOnline + ? t('common.online.label', 'Online') + : t('common.offline.label', 'Offline'); } - return `${t('{{ memberCount }} members', { memberCount })} · ${t('{{ watcherCount }} online', { watcherCount })}`; + return `${t('channelHeader.online.members.label', '{{ memberCount }} members', { memberCount })} · ${t('channelHeader.online.online.label', '{{ watcherCount }} online', { watcherCount })}`; } diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index fb3ad6c153..83d4573b1b 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -80,7 +80,7 @@ export const ChannelList = ({ return ( - aria-label={t('aria/Channel list')} + aria-label={t('channelList.channelList.ariaLabel', 'Channel list')} contentProps={{ role: 'presentation' }} EmptyListIndicator={EmptyListIndicator} EndReachedIndicator={EndReachedIndicator} diff --git a/src/components/ChannelList/ChannelListHeader.tsx b/src/components/ChannelList/ChannelListHeader.tsx index 6d65281e44..eac51f0012 100644 --- a/src/components/ChannelList/ChannelListHeader.tsx +++ b/src/components/ChannelList/ChannelListHeader.tsx @@ -12,7 +12,9 @@ export const ChannelListHeader = () => { const hasActiveChannel = useWorkspaceNavigation().openChannels.length > 0; return (
-
{t('Chats')}
+
+ {t('channelList.header.chats.text', 'Chats')} +
{hasActiveChannel && HeaderEndContent && }
); diff --git a/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx b/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx index 8d01a67d4d..7062002be6 100644 --- a/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx @@ -5,8 +5,9 @@ import { WithComponents, WorkspaceNavigationProvider } from '../../../context'; import { TranslationProvider } from '../../../context/TranslationContext'; import { mockTranslationContextValue } from '../../../mock-builders'; import { ChannelListHeader } from '../ChannelListHeader'; +import { mockT } from '../../../mock-builders/translator'; -const t = vi.fn((key: string) => key); +const t = vi.fn(mockT); const HeaderEndContent = () =>
; afterEach(cleanup); diff --git a/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx b/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx index d73b16e8f6..e40cca3f51 100644 --- a/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx +++ b/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx @@ -51,7 +51,7 @@ const useMuteAction = (): ChannelActionBehavior => { addNotification({ context: { channel }, emitter: ChannelListItemActionButtons.name, - message: t('Channel unmuted'), + message: t('common.channelUnmuted.text', 'Channel unmuted'), severity: 'success', type: 'api:channel:unmute:success', }); @@ -60,7 +60,7 @@ const useMuteAction = (): ChannelActionBehavior => { addNotification({ context: { channel }, emitter: ChannelListItemActionButtons.name, - message: t('Channel muted'), + message: t('common.channelMuted.text', 'Channel muted'), severity: 'success', type: 'api:channel:mute:success', }); @@ -70,14 +70,21 @@ const useMuteAction = (): ChannelActionBehavior => { context: { channel }, emitter: ChannelListItemActionButtons.name, error: error instanceof Error ? error : new Error('An unknown error occurred'), - message: t('Failed to update channel mute status'), + message: t( + 'channelListItem.failedUpdateChannelMute.text', + 'Failed to update channel mute status', + ), severity: 'error', type: 'api:channel:mute:failed', }); } }; - return { 'aria-pressed': isMuted, title: isMuted ? t('Unmute') : t('Mute'), toggle }; + return { + 'aria-pressed': isMuted, + title: isMuted ? t('common.unmute.title', 'Unmute') : t('common.mute.title', 'Mute'), + toggle, + }; }; // Core archive/unarchive action — performs the API call and reports the result. @@ -94,7 +101,7 @@ const useArchiveAction = (): ChannelActionBehavior => { addNotification({ context: { channel }, emitter: ChannelListItemActionButtons.name, - message: t('Channel unarchived'), + message: t('channelListItem.channelUnarchived.text', 'Channel unarchived'), severity: 'success', type: 'api:channel:unarchive:success', }); @@ -103,7 +110,7 @@ const useArchiveAction = (): ChannelActionBehavior => { addNotification({ context: { channel }, emitter: ChannelListItemActionButtons.name, - message: t('Channel archived'), + message: t('channelListItem.channelArchived.text', 'Channel archived'), severity: 'success', type: 'api:channel:archive:success', }); @@ -113,7 +120,10 @@ const useArchiveAction = (): ChannelActionBehavior => { context: { channel }, emitter: ChannelListItemActionButtons.name, error: error instanceof Error ? error : new Error('An unknown error occurred'), - message: t('Failed to update channel archive status'), + message: t( + 'channelListItem.failedUpdateChannelArchive.text', + 'Failed to update channel archive status', + ), severity: 'error', type: 'api:channel:archive:failed', }); @@ -122,7 +132,9 @@ const useArchiveAction = (): ChannelActionBehavior => { return { 'aria-pressed': typeof membership.archived_at === 'string', - title: membership.archived_at ? t('Unarchive') : t('Archive'), + title: membership.archived_at + ? t('channelListItem.unarchive.title', 'Unarchive') + : t('channelListItem.archive.title', 'Archive'), toggle, }; }; @@ -213,7 +225,7 @@ const useBanAction = (): ChannelActionBehavior => { addNotification({ context: { channel }, emitter: ChannelListItemActionButtons.name, - message: t('User unblocked'), + message: t('common.userUnblocked.text', 'User unblocked'), severity: 'success', type: 'api:user:unban:success', }); @@ -222,7 +234,7 @@ const useBanAction = (): ChannelActionBehavior => { addNotification({ context: { channel }, emitter: ChannelListItemActionButtons.name, - message: t('User blocked'), + message: t('common.userBlocked.text', 'User blocked'), severity: 'success', type: 'api:user:ban:success', }); @@ -232,7 +244,7 @@ const useBanAction = (): ChannelActionBehavior => { context: { channel }, emitter: ChannelListItemActionButtons.name, error: error instanceof Error ? error : new Error('An unknown error occurred'), - message: t('Failed to block user'), + message: t('channelListItem.failedBlockUser.text', 'Failed to block user'), severity: 'error', type: 'api:user:ban:failed', }); @@ -241,7 +253,9 @@ const useBanAction = (): ChannelActionBehavior => { return { 'aria-pressed': isUserBanned, - title: isUserBanned ? t('Unblock User') : t('Block User'), + title: isUserBanned + ? t('channelListItem.unblockUser.title', 'Unblock User') + : t('common.blockUser.title', 'Block User'), toggle, }; }; @@ -260,7 +274,7 @@ const useLeaveAction = (): ChannelActionBehavior => { addNotification({ context: { channel }, emitter: ChannelListItemActionButtons.name, - message: t('Left channel'), + message: t('common.leftChannel.text', 'Left channel'), severity: 'success', type: 'api:channel:leave:success', }); @@ -269,14 +283,14 @@ const useLeaveAction = (): ChannelActionBehavior => { context: { channel }, emitter: ChannelListItemActionButtons.name, error: error instanceof Error ? error : new Error('An unknown error occurred'), - message: t('Failed to leave channel'), + message: t('common.failedLeaveChannel.text', 'Failed to leave channel'), severity: 'error', type: 'api:channel:leave:failed', }); } }; - return { title: t('Leave Channel'), toggle }; + return { title: t('channelListItem.leaveChannel.title', 'Leave Channel'), toggle }; }; // Core pin/unpin action. @@ -293,7 +307,7 @@ const usePinAction = (): ChannelActionBehavior => { addNotification({ context: { channel }, emitter: ChannelListItemActionButtons.name, - message: t('Channel unpinned'), + message: t('channelListItem.channelUnpinned.text', 'Channel unpinned'), severity: 'success', type: 'api:channel:unpin:success', }); @@ -302,7 +316,7 @@ const usePinAction = (): ChannelActionBehavior => { addNotification({ context: { channel }, emitter: ChannelListItemActionButtons.name, - message: t('Channel pinned'), + message: t('channelListItem.channelPinned.text', 'Channel pinned'), severity: 'success', type: 'api:channel:pin:success', }); @@ -312,7 +326,10 @@ const usePinAction = (): ChannelActionBehavior => { context: { channel }, emitter: ChannelListItemActionButtons.name, error: error instanceof Error ? error : new Error('An unknown error occurred'), - message: t('Failed to update channel pinned status'), + message: t( + 'channelListItem.failedUpdateChannelPinned.text', + 'Failed to update channel pinned status', + ), severity: 'error', type: 'api:channel:pin:failed', }); @@ -321,7 +338,9 @@ const usePinAction = (): ChannelActionBehavior => { return { 'aria-pressed': !!membership.pinned_at, - title: membership.pinned_at ? t('Unpin') : t('Pin'), + title: membership.pinned_at + ? t('common.unpin.title', 'Unpin') + : t('common.pin.title', 'Pin'), toggle, }; }; @@ -461,7 +480,10 @@ const defaultComponents = { diff --git a/src/components/Gallery/GalleryUI.tsx b/src/components/Gallery/GalleryUI.tsx index 4112fd4152..e0a1688b95 100644 --- a/src/components/Gallery/GalleryUI.tsx +++ b/src/components/Gallery/GalleryUI.tsx @@ -200,7 +200,7 @@ export const GalleryUI = () => {
{
) => { const { t } = useTranslationContext('UnMemoizedLoadMoreButton'); - const childrenOrDefaultString = children ?? t('Load more'); + const childrenOrDefaultString = + children ?? t('loadMore.button.loadMore.label', 'Load more'); return (
; + return ( +
+ {t('loading.errorIndicator.error.text', 'Error: {{ errorMessage }}', { + errorMessage: error.message, + })} +
+ ); }; export const LoadingErrorIndicator = React.memo( diff --git a/src/components/Loading/__tests__/LoadingErrorIndicator.test.tsx b/src/components/Loading/__tests__/LoadingErrorIndicator.test.tsx index c7a36dfa3d..dfbdac8e39 100644 --- a/src/components/Loading/__tests__/LoadingErrorIndicator.test.tsx +++ b/src/components/Loading/__tests__/LoadingErrorIndicator.test.tsx @@ -26,7 +26,7 @@ describe('LoadingErrorIndicator', () => { expect(container).toMatchInlineSnapshot(`
- Error: {{ errorMessage }} + Error: this is an error
`); diff --git a/src/components/Loading/progress-indicators.tsx b/src/components/Loading/progress-indicators.tsx index ccc53db25b..21b694f574 100644 --- a/src/components/Loading/progress-indicators.tsx +++ b/src/components/Loading/progress-indicators.tsx @@ -18,7 +18,11 @@ export const CircularProgressIndicator = ({ percent }: ProgressIndicatorProps) = return (
durations.length > 0 - ? t('duration/Share Location', { + ? t('duration.shareLocation', { milliseconds: selectedDuration ?? durations[0], }) : undefined, @@ -153,9 +153,10 @@ export const ShareLocationDialog = ({ {liveLocationSwitchEnabled && selectedDurationLabel && (
@@ -214,7 +218,7 @@ export const ShareLocationDialog = ({ close(); }} > - {t('Cancel')} + {t('common.cancel.label', 'Cancel')} - {t('Attach')} + {t('location.shareLocationDialog.attach.text', 'Attach')} - {t('Share')} + {t('location.shareLocationDialog.share.text', 'Share')} @@ -314,7 +324,7 @@ const DurationDropdownItems = ({ }} role='menuitemradio' > - {t('duration/Share Location', { milliseconds: duration })} + {t('duration.shareLocation', { milliseconds: duration })} ))} diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx index a3b96cb522..a0c758d08f 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx @@ -18,7 +18,17 @@ const ToggleRecordingButton = () => { return (
); diff --git a/src/components/Message/MessageBlocked.tsx b/src/components/Message/MessageBlocked.tsx index 3a71387083..d24112e1fd 100644 --- a/src/components/Message/MessageBlocked.tsx +++ b/src/components/Message/MessageBlocked.tsx @@ -27,7 +27,7 @@ export const MessageBlocked = () => { key={message.id} >
- {t('Message was blocked by moderation policies')} + {t('message.blocked.text', 'Message was blocked by moderation policies')}
); diff --git a/src/components/Message/MessageDeletedBubble.tsx b/src/components/Message/MessageDeletedBubble.tsx index ab2e86e4bf..8d3103feb1 100644 --- a/src/components/Message/MessageDeletedBubble.tsx +++ b/src/components/Message/MessageDeletedBubble.tsx @@ -17,7 +17,7 @@ export const MessageDeletedBubble = () => {
- {t('Message deleted')} + {t('common.messageDeleted.text', 'Message deleted')}
); diff --git a/src/components/Message/MessageEditedIndicator.tsx b/src/components/Message/MessageEditedIndicator.tsx index d8d37d2027..5daf71025a 100644 --- a/src/components/Message/MessageEditedIndicator.tsx +++ b/src/components/Message/MessageEditedIndicator.tsx @@ -40,7 +40,7 @@ const UnMemoizedMessageEditedIndicator = (props: MessageEditedIndicatorProps) => onMouseLeave={handleLeave} ref={setReferenceElement} > - {t('Edited')} + {t('message.editedIndicator.edited.text', 'Edited')} 1) { replyCountText = `${replyCount} ${labelPlural}`; diff --git a/src/components/Message/MessageStatus.tsx b/src/components/Message/MessageStatus.tsx index c49006bfd5..e49ce8b7d2 100644 --- a/src/components/Message/MessageStatus.tsx +++ b/src/components/Message/MessageStatus.tsx @@ -101,7 +101,7 @@ const UnMemoizedMessageStatus = (props: MessageStatusProps) => { referenceElement={referenceElement} visible={tooltipVisible} > - {t('Sending...')} + {t('message.status.sending.text', 'Sending...')} @@ -117,7 +117,7 @@ const UnMemoizedMessageStatus = (props: MessageStatusProps) => { referenceElement={referenceElement} visible={tooltipVisible} > - {t('Sent')} + {t('message.status.sent.text', 'Sent')} @@ -133,7 +133,7 @@ const UnMemoizedMessageStatus = (props: MessageStatusProps) => { referenceElement={referenceElement} visible={tooltipVisible} > - {t('Delivered')} + {t('message.status.delivered.text', 'Delivered')} diff --git a/src/components/Message/MessageText.tsx b/src/components/Message/MessageText.tsx index 94b440699a..507ba6b01b 100644 --- a/src/components/Message/MessageText.tsx +++ b/src/components/Message/MessageText.tsx @@ -83,8 +83,10 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => { hasMentions && typeof onMentionsClickMessage === 'function'; const senderName = message.user?.name; const messageContext = senderName - ? t('aria/Message from {{ user }},', { user: senderName }) - : t('aria/Message,'); + ? t('message.text.message.withUser.ariaLabel', 'Message from {{ user }},', { + user: senderName, + }) + : t('message.text.message.ariaLabel', 'Message,'); // `aria-labelledby` accepts a space-separated list of element ids. We point to the // hidden message context and the rendered message text so screen readers announce both. const messageLabelledBy = `${messageContextId} ${messageTextId}`; diff --git a/src/components/Message/MessageTranslationIndicator.tsx b/src/components/Message/MessageTranslationIndicator.tsx index 1a32cf8a2e..6b523c1a20 100644 --- a/src/components/Message/MessageTranslationIndicator.tsx +++ b/src/components/Message/MessageTranslationIndicator.tsx @@ -7,6 +7,7 @@ import { useTranslationContext, } from '../../context'; import { Button } from '../Button'; +import { asDynamicKey } from '../../i18n/utils'; export type TranslationIndicatorProps = { message?: LocalMessage; @@ -50,8 +51,8 @@ export const MessageTranslationIndicator = ({ const sourceLanguageName = useMemo(() => { const sourceLanguageCode = message?.i18n?.language; if (!sourceLanguageCode) return ''; - const languageKey = 'language/' + sourceLanguageCode; - const translatedName = t(languageKey); + const languageKey = 'language.' + sourceLanguageCode; + const translatedName = t(asDynamicKey(languageKey)); return translatedName && translatedName !== languageKey ? translatedName : sourceLanguageCode; @@ -65,10 +66,14 @@ export const MessageTranslationIndicator = ({ {viewingOriginal - ? t('Original') + ? t('message.translationIndicator.original.text', 'Original') : sourceLanguageName - ? t('Translated from {{ language }}', { language: sourceLanguageName }) - : t('Translated')} + ? t( + 'message.translationIndicator.translated.withLanguage.text', + 'Translated from {{ language }}', + { language: sourceLanguageName }, + ) + : t('message.translationIndicator.translated.text', 'Translated')} · ); diff --git a/src/components/Message/MessageUI.tsx b/src/components/Message/MessageUI.tsx index bad8d48878..96631f0f0e 100644 --- a/src/components/Message/MessageUI.tsx +++ b/src/components/Message/MessageUI.tsx @@ -182,7 +182,7 @@ const MessageUIWithContext = ({ const isMessageInnerInteractive = !!handleClick; const messageInnerAriaLabel = isMessageInnerInteractive - ? t('aria/Review bounced message') + ? t('message.ui.reviewBouncedMessage.ariaLabel', 'Review bounced message') : undefined; const handleMessageInnerKeyDown = (event: React.KeyboardEvent) => { diff --git a/src/components/Message/PinIndicator.tsx b/src/components/Message/PinIndicator.tsx index a8e1b18581..770c9d0664 100644 --- a/src/components/Message/PinIndicator.tsx +++ b/src/components/Message/PinIndicator.tsx @@ -22,10 +22,10 @@ export const PinIndicator = ({ message }: PinIndicatorProps) => { const name = message.pinned_by?.name ?? message.pinned_by?.id ?? ''; const label = isOwnPin - ? t('Pinned by You') + ? t('message.pinIndicator.pinned.label', 'Pinned by You') : name - ? t('Pinned by {{ name }}', { name }) - : t('Message pinned'); + ? t('message.pinIndicator.pinned.withName.label', 'Pinned by {{ name }}', { name }) + : t('common.messagePinned.label', 'Message pinned'); return (
diff --git a/src/components/Message/ReminderNotification.tsx b/src/components/Message/ReminderNotification.tsx index 0b2736ed52..ee1ff72163 100644 --- a/src/components/Message/ReminderNotification.tsx +++ b/src/components/Message/ReminderNotification.tsx @@ -17,7 +17,7 @@ function SavedForLaterContent() { return (
- {t('Saved for later')} + {t('common.savedLater.text', 'Saved for later')}
); } @@ -51,32 +51,40 @@ function RemindMeContent({ reminder }: { reminder: Reminder }) { if (useAbsoluteFormat) { // > 59 min ago: calendar + time (same as DateSeparator + HH:mm) // e.g. "Due since Today at 15:00", "Due since Yesterday at 09:30" - return t('Due since {{ dueSince }}', { - dueSince: t('timestamp/ReminderNotification', { - timestamp: reminder.remindAt, - }), - }); + return t( + 'message.reminderNotification.dueSince.label', + 'Due since {{ dueSince }}', + { + dueSince: t('timestamp.ReminderNotification', { + timestamp: reminder.remindAt, + }), + }, + ); } // Within 59 min ago: relative // e.g. "Due since 5 minutes ago", "Due since a minute ago" - return t('Due since {{ dueSince }}', { - dueSince: t('duration/Message reminder', { - milliseconds: diffMs, - }), - }); + return t( + 'message.reminderNotification.dueSince.label', + 'Due since {{ dueSince }}', + { + dueSince: t('duration.messageReminder', { + milliseconds: diffMs, + }), + }, + ); } // Future: reminder not yet due if (useAbsoluteFormat) { // > 59 min from now: calendar + time (no "Due" prefix) // e.g. "Today at 15:00", "Tomorrow at 09:30" - return t('timestamp/ReminderNotification', { + return t('timestamp.ReminderNotification', { timestamp: reminder.remindAt, }); } // Within 59 min from now: relative // e.g. "Due in 30 minutes", "Due in a minute" - return t('Due {{ timeLeft }}', { - timeLeft: t('duration/Message reminder', { + return t('message.reminderNotification.due.label', 'Due {{ timeLeft }}', { + timeLeft: t('duration.messageReminder', { milliseconds: timeLeftMs, }), }); @@ -85,7 +93,7 @@ function RemindMeContent({ reminder }: { reminder: Reminder }) { return (

- {t('Reminder set')} + {t('common.reminderSet.text', 'Reminder set')} · {renderTime()}

diff --git a/src/components/Message/Timestamp.tsx b/src/components/Message/Timestamp.tsx index 976476eeca..58f4f3fda1 100644 --- a/src/components/Message/Timestamp.tsx +++ b/src/components/Message/Timestamp.tsx @@ -31,7 +31,7 @@ export function Timestamp(props: TimestampProps) { messageCreatedAt: normalizedTimestamp, t, tDateTimeParser, - timestampTranslationKey: 'timestamp/MessageTimestamp', + timestampTranslationKey: 'timestamp.MessageTimestamp', }), [ calendar, diff --git a/src/components/Message/__tests__/MessageDeleted.test.tsx b/src/components/Message/__tests__/MessageDeleted.test.tsx index 1368d15fd1..6adcadda82 100644 --- a/src/components/Message/__tests__/MessageDeleted.test.tsx +++ b/src/components/Message/__tests__/MessageDeleted.test.tsx @@ -5,11 +5,12 @@ import { MessageDeletedBubble } from '../MessageDeletedBubble'; import { TranslationProvider } from '../../../context/TranslationContext'; import { mockTranslationContextValue } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; const messageDeletedTestId = 'message-deleted-bubble'; function renderComponent() { - const t = vi.fn((key) => key); + const t = vi.fn(mockT); return render( diff --git a/src/components/Message/__tests__/MessageStatus.test.tsx b/src/components/Message/__tests__/MessageStatus.test.tsx index 1ef6803c45..920b30909f 100644 --- a/src/components/Message/__tests__/MessageStatus.test.tsx +++ b/src/components/Message/__tests__/MessageStatus.test.tsx @@ -17,6 +17,7 @@ import { mockMessageContext, mockTranslationContextValue, } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; const MESSAGE_STATUS_SENDING_TEST_ID = 'message-status-sending'; const MESSAGE_STATUS_DELIVERED_TEST_ID = 'message-status-delivered'; @@ -46,7 +47,7 @@ const sendingMsg = { ...foreignMsg, status: 'sending', user }; const sentMsg = { ...foreignMsg, user }; const deliveredTo = [otherUser, user]; const readByOthers = [otherUser, user]; -const t = vi.fn((s) => s); +const t = vi.fn(mockT); const defaultMsgCtx = { isMyMessage: vi.fn().mockReturnValue(true), diff --git a/src/components/Message/__tests__/MessageText.test.tsx b/src/components/Message/__tests__/MessageText.test.tsx index ee114b3bb0..f41651be69 100644 --- a/src/components/Message/__tests__/MessageText.test.tsx +++ b/src/components/Message/__tests__/MessageText.test.tsx @@ -31,6 +31,7 @@ import { MessageText } from '../MessageText'; import type { MessageProps } from '../types'; import type { MessageTextProps } from '../MessageText'; import type { TranslationContextValue } from '../../../context'; +import { mockT } from '../../../mock-builders/translator'; vi.mock('../../ChatView', async (importOriginal) => { const actual = await importOriginal(); @@ -60,8 +61,6 @@ const defaultProps = { message: generateMessage(), threadList: false, }; -const translate = (key: string, options?: Record) => - key.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, token: string) => options?.[token] ?? ''); function generateAliceMessage(messageOptions) { return generateMessage({ @@ -87,8 +86,7 @@ async function renderMessageText({ customProps = {} } = {}) { ) => - translate(key, options)) as TranslationContextValue['t'], + t: mockT as TranslationContextValue['t'], tDateTimeParser: customDateTimeParser as TranslationContextValue['tDateTimeParser'], userLanguage: 'en', @@ -301,7 +299,7 @@ describe('', () => { const focusableWrapper = getByTestId(messageTextTestId).parentElement; - expect(focusableWrapper).toHaveAccessibleName(`aria/Message from alice, ${text}`); + expect(focusableWrapper).toHaveAccessibleName(`Message from alice, ${text}`); }); it('should expose sender context on the mention-interactive text wrapper', async () => { @@ -312,7 +310,7 @@ describe('', () => { }); expect(getByTestId(messageTextTestId)).toHaveAccessibleName( - `aria/Message from alice, ${text}`, + `Message from alice, ${text}`, ); }); @@ -328,7 +326,7 @@ describe('', () => { const focusableWrapper = getByTestId(messageTextTestId).parentElement; - expect(focusableWrapper).toHaveAccessibleName(`aria/Message, ${text}`); + expect(focusableWrapper).toHaveAccessibleName(`Message, ${text}`); }); it('should inform that message was not sent when message is has type "error"', async () => { diff --git a/src/components/Message/__tests__/MessageTimestamp.test.tsx b/src/components/Message/__tests__/MessageTimestamp.test.tsx index 95ce34e20a..891d7949af 100644 --- a/src/components/Message/__tests__/MessageTimestamp.test.tsx +++ b/src/components/Message/__tests__/MessageTimestamp.test.tsx @@ -132,7 +132,7 @@ describe('', () => { chatProps: { i18nInstance: new Streami18n({ translationsForLanguage: { - 'timestamp/MessageTimestamp': + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(calendar: false) }}', }, }), @@ -146,7 +146,7 @@ describe('', () => { chatProps: { i18nInstance: new Streami18n({ translationsForLanguage: { - 'timestamp/MessageTimestamp': + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: h:mmA) }}', }, }), @@ -169,7 +169,7 @@ describe('', () => { chatProps: { i18nInstance: new Streami18n({ translationsForLanguage: { - 'timestamp/MessageTimestamp': + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: h:mmA) }}', }, }), @@ -186,7 +186,7 @@ describe('', () => { chatProps: { i18nInstance: new Streami18n({ translationsForLanguage: { - 'timestamp/MessageTimestamp': + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(calendar: false) }}', }, }), @@ -208,7 +208,7 @@ describe('', () => { chatProps: { i18nInstance: new Streami18n({ translationsForLanguage: { - 'timestamp/MessageTimestamp': + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: h:mmA) }}', }, }), diff --git a/src/components/Message/__tests__/QuotedMessage.test.tsx b/src/components/Message/__tests__/QuotedMessage.test.tsx index 7bcbd4fa70..df2254a1fa 100644 --- a/src/components/Message/__tests__/QuotedMessage.test.tsx +++ b/src/components/Message/__tests__/QuotedMessage.test.tsx @@ -23,6 +23,7 @@ import { Message } from '../Message'; import { MessageUI } from '../MessageUI'; import { QuotedMessage } from '../QuotedMessage'; import { renderText } from '../renderText'; +import { mockT } from '../../../mock-builders/translator'; vi.mock('../../ChatView', async (importOriginal) => { const actual = await importOriginal(); @@ -68,7 +69,7 @@ async function renderQuotedMessage({ key, + t: mockT, tDateTimeParser: customDateTimeParser, userLanguage: 'en', })} @@ -248,10 +249,7 @@ describe('QuotedMessage', () => { }); const quotedMessagePreview = getByTestId(quotedMessagePreviewTestId); - expect(quotedMessagePreview).toHaveAttribute( - 'aria-label', - 'aria/Jump to quoted message', - ); + expect(quotedMessagePreview).toHaveAttribute('aria-label', 'Jump to quoted message'); expect(quotedMessagePreview).toHaveAttribute('role', 'button'); expect(quotedMessagePreview).toHaveAttribute('tabindex', '0'); }); diff --git a/src/components/Message/__tests__/utils.test.ts b/src/components/Message/__tests__/utils.test.ts index 0fbde731b4..3e57f8f996 100644 --- a/src/components/Message/__tests__/utils.test.ts +++ b/src/components/Message/__tests__/utils.test.ts @@ -1,6 +1,7 @@ import { generateMessage, generateReaction, generateUser } from 'mock-builders'; +import type { StreamTFunction } from '../../../i18n/types'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { TFunction } from 'i18next'; + import type { ChannelConfigWithInfo, LocalMessage, @@ -12,7 +13,7 @@ import { countReactions, getTestClientWithUser, groupReactions, - mockTranslatorFunction, + mockT, } from '../../../mock-builders'; import { areMessagePropsEqual, @@ -463,7 +464,7 @@ describe('Message utils', () => { it('ignores the client user', () => { const result = getReadByTooltipText( [client.user], - mockTranslatorFunction as unknown as TFunction, + mockT as unknown as StreamTFunction, client, tooltipUserNameMapper, ); @@ -472,7 +473,7 @@ describe('Message utils', () => { it('returns a single user if only one user in array', () => { const result = getReadByTooltipText( [bob], - mockTranslatorFunction as unknown as TFunction, + mockT as unknown as StreamTFunction, client, tooltipUserNameMapper, ); @@ -482,7 +483,7 @@ describe('Message utils', () => { const users = [generateUser({ name: '1' }), generateUser({ name: '2' })]; const result = getReadByTooltipText( users, - mockTranslatorFunction as unknown as TFunction, + mockT as unknown as StreamTFunction, client, tooltipUserNameMapper, ); @@ -496,7 +497,7 @@ describe('Message utils', () => { ]; const result = getReadByTooltipText( users, - mockTranslatorFunction as unknown as TFunction, + mockT as unknown as StreamTFunction, client, tooltipUserNameMapper, ); @@ -508,7 +509,7 @@ describe('Message utils', () => { ); const result = getReadByTooltipText( users, - mockTranslatorFunction as unknown as TFunction, + mockT as unknown as StreamTFunction, client, tooltipUserNameMapper, ); @@ -518,7 +519,7 @@ describe('Message utils', () => { const users = [generateUser({ name: '1' }), generateUser({ name: '2' })]; const result = getReadByTooltipText( users, - mockTranslatorFunction as unknown as TFunction, + mockT as unknown as StreamTFunction, client, (user) => `Dr. ${user.name}`, ); @@ -528,7 +529,7 @@ describe('Message utils', () => { expect(() => getReadByTooltipText( [], - null as unknown as TFunction, + null as unknown as StreamTFunction, client, tooltipUserNameMapper, ), @@ -540,7 +541,7 @@ describe('Message utils', () => { expect(() => getReadByTooltipText( [], - mockTranslatorFunction as unknown as TFunction, + mockT as unknown as StreamTFunction, client, undefined as unknown as typeof tooltipUserNameMapper, ), diff --git a/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx b/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx index 191fd82bb1..37460bd62f 100644 --- a/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx +++ b/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx @@ -35,7 +35,27 @@ vi.mock('../../../../context', () => ({ }, }), useMessageContext: () => ({ message: mocks.state.message }), - useTranslationContext: () => ({ t: (key: string) => key }), + useTranslationContext: () => ({ + t: (key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }, + }), // `isChannelActive` mirrors the old `useSlotForKey` presence check: truthy when the channel // is already shown in the workspace. useWorkspaceNavigation: () => ({ diff --git a/src/components/Message/hooks/useDeleteHandler.ts b/src/components/Message/hooks/useDeleteHandler.ts index f7ac3fb960..047fa64fb0 100644 --- a/src/components/Message/hooks/useDeleteHandler.ts +++ b/src/components/Message/hooks/useDeleteHandler.ts @@ -47,7 +47,12 @@ export const useDeleteHandler = ( const errorMessage = getErrorNotification && validateAndGetMessage(getErrorNotification, [message]); - if (notify) notify(errorMessage || t('Error deleting message'), 'error'); + if (notify) + notify( + errorMessage || + t('common.errorDeletingMessage.label', 'Error deleting message'), + 'error', + ); } }; }; diff --git a/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts b/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts index 8281d7bb49..587f5dbb80 100644 --- a/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts +++ b/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts @@ -39,7 +39,7 @@ export const useMessageAlsoSentInChannelNavigation = const addThreadNotFoundNotification = (error: Error) => { client.notifications.addError({ - message: t('Thread has not been found'), + message: t('notification.replySearchFailed', 'Thread has not been found'), options: { originalError: error, type: 'api:message:search:not-found', diff --git a/src/components/Message/hooks/useMuteHandler.ts b/src/components/Message/hooks/useMuteHandler.ts index a710a9cafc..e7fd9e4701 100644 --- a/src/components/Message/hooks/useMuteHandler.ts +++ b/src/components/Message/hooks/useMuteHandler.ts @@ -46,7 +46,7 @@ export const useMuteHandler = ( const successMessage = (getSuccessNotification && validateAndGetMessage(getSuccessNotification, [message.user])) || - t('{{ user }} has been muted', { + t('common.muted.label', '{{ user }} has been muted', { user: message.user.name || message.user.id, }); @@ -56,7 +56,7 @@ export const useMuteHandler = ( const errorMessage = (getErrorNotification && validateAndGetMessage(getErrorNotification, [message.user])) || - t('Error muting a user ...'); + t('common.errorMutingUser.label', 'Error muting a user ...'); if (typeof errorMessage === 'string') notify(errorMessage, 'error'); } @@ -68,7 +68,7 @@ export const useMuteHandler = ( const successMessage = (getSuccessNotification && validateAndGetMessage(getSuccessNotification, [message.user])) || - t('{{ user }} has been unmuted', { + t('common.unmuted.label', '{{ user }} has been unmuted', { user: message.user.name || message.user.id, }); @@ -78,7 +78,7 @@ export const useMuteHandler = ( const errorMessage = (getErrorNotification && validateAndGetMessage(getErrorNotification, [message.user])) || - t('Error unmuting a user ...'); + t('common.errorUnmutingUser.label', 'Error unmuting a user ...'); if (typeof errorMessage === 'string') notify(errorMessage, 'error'); } diff --git a/src/components/Message/hooks/usePinHandler.ts b/src/components/Message/hooks/usePinHandler.ts index 8caa1c659c..c5858e7833 100644 --- a/src/components/Message/hooks/usePinHandler.ts +++ b/src/components/Message/hooks/usePinHandler.ts @@ -70,7 +70,12 @@ export const usePinHandler = ( const errorMessage = getErrorNotification && validateAndGetMessage(getErrorNotification, [message]); - if (notify) notify(errorMessage || t('Error pinning message'), 'error'); + if (notify) + notify( + errorMessage || + t('common.errorPinningMessage.label', 'Error pinning message'), + 'error', + ); messagePaginator.ingestItem(message); } } else { @@ -90,7 +95,12 @@ export const usePinHandler = ( const errorMessage = getErrorNotification && validateAndGetMessage(getErrorNotification, [message]); - if (notify) notify(errorMessage || t('Error removing message pin'), 'error'); + if (notify) + notify( + errorMessage || + t('common.errorRemovingMessagePin.label', 'Error removing message pin'), + 'error', + ); messagePaginator.ingestItem(message); } } diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 930439f4cb..b9b665f166 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -2,7 +2,6 @@ import deepequal from 'react-fast-compare'; import { EMOJI_REGEX } from './emojiRegex'; -import type { TFunction } from 'i18next'; import type { ChannelConfigWithInfo, LocalMessage, @@ -13,6 +12,7 @@ import type { } from 'stream-chat'; import type { MessageProps } from './types'; import type { MessageContextValue } from '../../context'; +import type { StreamTFunction } from '../../i18n/types'; /** * Following function validates a function which returns notification message. @@ -312,7 +312,7 @@ export const mapToUserNameOrId: TooltipUsernameMapper = (user) => user.name || u export const getReadByTooltipText = ( users: UserResponse[], - t: TFunction, + t: StreamTFunction, client: StreamChat, tooltipUserNameMapper: TooltipUsernameMapper, ) => { @@ -342,25 +342,37 @@ export const getReadByTooltipText = ( } else if (slicedArr.length === 2) { // joins all with "and" but =no commas // example: "bob and sam" - outStr = t('{{ firstUser }} and {{ secondUser }}', { - firstUser: slicedArr[0], - secondUser: slicedArr[1], - }); + outStr = t( + 'message.and.withFirstUserAndSecondUser.label', + '{{ firstUser }} and {{ secondUser }}', + { + firstUser: slicedArr[0], + secondUser: slicedArr[1], + }, + ); } else if (slicedArr.length > 2) { // joins all with commas, but last one gets ", and" (oxford comma!) // example: "bob, joe, sam and 4 more" if (restLength === 0) { // mutate slicedArr to remove last user to display it separately - const lastUser = slicedArr.splice(slicedArr.length - 1, 1); - outStr = t('{{ commaSeparatedUsers }}, and {{ lastUser }}', { - commaSeparatedUsers: slicedArr.join(', '), - lastUser, - }); + const [lastUser] = slicedArr.splice(slicedArr.length - 1, 1); + outStr = t( + 'message.and.withCommaSeparatedUsersAndLastUser.label', + '{{ commaSeparatedUsers }}, and {{ lastUser }}', + { + commaSeparatedUsers: slicedArr.join(', '), + lastUser, + }, + ); } else { - outStr = t('{{ commaSeparatedUsers }} and {{ moreCount }} more', { - commaSeparatedUsers: slicedArr.join(', '), - moreCount: restLength, - }); + outStr = t( + 'message.more.label', + '{{ commaSeparatedUsers }} and {{ moreCount }} more', + { + commaSeparatedUsers: slicedArr.join(', '), + moreCount: restLength, + }, + ); } } diff --git a/src/components/MessageActions/DeleteMessageAlert.tsx b/src/components/MessageActions/DeleteMessageAlert.tsx index 64786be772..342c5d6883 100644 --- a/src/components/MessageActions/DeleteMessageAlert.tsx +++ b/src/components/MessageActions/DeleteMessageAlert.tsx @@ -18,8 +18,14 @@ export const DeleteMessageAlert = ({ onCancel, onDelete }: DeleteMessageAlertPro data-testid='message-delete-alert' > diff --git a/src/components/MessageActions/DownloadSubmenu.tsx b/src/components/MessageActions/DownloadSubmenu.tsx index dfe3c4b430..aa69552e6f 100644 --- a/src/components/MessageActions/DownloadSubmenu.tsx +++ b/src/components/MessageActions/DownloadSubmenu.tsx @@ -24,7 +24,7 @@ export const DownloadSubmenuHeader = () => { - {t('Download Attachment')} + {t('common.downloadAttachment.title', 'Download Attachment')} ); @@ -44,8 +44,16 @@ export const DownloadSubmenu = () => { {downloadableAttachments.map((attachment, index) => { const fileName = attachment.localMetadata?.file?.name ?? attachment.title; const label = fileName - ? t('Download {{ fileName }}', { fileName }) - : t('Download attachment {{ number }}', { number: index + 1 }); + ? t( + 'messageActions.downloadSubmenu.download.label', + 'Download {{ fileName }}', + { fileName }, + ) + : t( + 'messageActions.downloadSubmenu.downloadAttachment.label', + 'Download attachment {{ number }}', + { number: index + 1 }, + ); return ( { closeMenu(); }} > - {t('Download All')} + {t('messageActions.downloadSubmenu.download.text', 'Download All')}
); diff --git a/src/components/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx index 1062ec759f..ec1bec935b 100644 --- a/src/components/MessageActions/MessageActions.defaults.tsx +++ b/src/components/MessageActions/MessageActions.defaults.tsx @@ -106,7 +106,10 @@ const DefaultMessageActionComponents = { - {t('Add reaction')} + {t('common.addReaction.text', 'Add reaction')} ); @@ -138,7 +141,7 @@ const DefaultMessageActionComponents = { return ( - {t('Thread Reply')} + {t('messageActions.threadReply.text', 'Thread Reply')} ); }, @@ -172,7 +175,7 @@ const DefaultMessageActionComponents = { return ( { @@ -180,7 +183,7 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {t('Quote Reply')} + {t('messageActions.quoteReply.text', 'Quote Reply')} ); }, @@ -197,7 +200,7 @@ const DefaultMessageActionComponents = { return ( 1} Icon={IconDownload} @@ -215,7 +218,7 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {t('Download Attachment')} + {t('common.downloadAttachment.title', 'Download Attachment')} ); }, @@ -227,7 +230,11 @@ const DefaultMessageActionComponents = { const isPinned = !!message.pinned; return ( { @@ -238,7 +245,9 @@ const DefaultMessageActionComponents = { message, }, emitter: 'MessageActions', - message: isPinned ? t('Message unpinned') : t('Message pinned'), + message: isPinned + ? t('messageActions.messageUnpinned.text', 'Message unpinned') + : t('common.messagePinned.label', 'Message pinned'), severity: 'success', type: isPinned ? 'api:message:unpin:success' : 'api:message:pin:success', }); @@ -251,7 +260,12 @@ const DefaultMessageActionComponents = { error: getNotificationError(error), message: getErrorMessage( error, - isPinned ? t('Error removing message pin') : t('Error pinning message'), + isPinned + ? t( + 'common.errorRemovingMessagePin.label', + 'Error removing message pin', + ) + : t('common.errorPinningMessage.label', 'Error pinning message'), ), severity: 'error', type: isPinned ? 'api:message:unpin:failed' : 'api:message:pin:failed', @@ -260,7 +274,7 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {isPinned ? t('Unpin') : t('Pin')} + {isPinned ? t('common.unpin.title', 'Unpin') : t('common.pin.title', 'Pin')} ); }, @@ -271,7 +285,7 @@ const DefaultMessageActionComponents = { return ( { @@ -279,7 +293,7 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {t('Copy Message')} + {t('messageActions.copyMessage.text', 'Copy Message')} ); }, @@ -290,7 +304,7 @@ const DefaultMessageActionComponents = { return ( { @@ -298,7 +312,7 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {t('Resend')} + {t('messageActions.resend.text', 'Resend')} ); }, @@ -310,7 +324,7 @@ const DefaultMessageActionComponents = { return ( { @@ -319,7 +333,7 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {t('Edit Message')} + {t('common.editMessage.text', 'Edit Message')} ); }, @@ -331,7 +345,10 @@ const DefaultMessageActionComponents = { return ( { @@ -342,7 +359,10 @@ const DefaultMessageActionComponents = { message, }, emitter: 'MessageActions', - message: t('Message marked as unread'), + message: t( + 'messageActions.messageMarkedUnread.text', + 'Message marked as unread', + ), severity: 'success', type: 'api:message:markUnread:success', }); @@ -356,6 +376,7 @@ const DefaultMessageActionComponents = { message: getErrorMessage( error, t( + 'messageActions.errorMarkingMessageUnread.text', 'Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.', ), ), @@ -366,7 +387,7 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {t('Mark as unread')} + {t('messageActions.markUnread.text', 'Mark as unread')} ); }, @@ -383,7 +404,11 @@ const DefaultMessageActionComponents = { return ( - {reminder ? t('Remove reminder') : t('Remind me')} + {reminder + ? t('messageActions.removeReminder.text', 'Remove reminder') + : t('messageActions.remindMe.text', 'Remind me')} ); }, @@ -441,7 +468,9 @@ const DefaultMessageActionComponents = { return ( - {reminder ? t('Remove save for later') : t('Save for later')} + {reminder + ? t('messageActions.removeSaveLater.text', 'Remove save for later') + : t('messageActions.saveLater.text', 'Save for later')} ); }, @@ -505,7 +539,7 @@ const DefaultMessageActionComponents = { return ( { @@ -516,7 +550,10 @@ const DefaultMessageActionComponents = { message, }, emitter: 'MessageActions', - message: t('Message has been successfully flagged'), + message: t( + 'messageActions.messageSuccessfullyFlagged.text', + 'Message has been successfully flagged', + ), severity: 'success', type: 'api:message:flag:success', }); @@ -527,7 +564,10 @@ const DefaultMessageActionComponents = { }, emitter: 'MessageActions', error: getNotificationError(error), - message: getErrorMessage(error, t('Error adding flag')), + message: getErrorMessage( + error, + t('messageActions.errorAddingFlag.text', 'Error adding flag'), + ), severity: 'error', type: 'api:message:flag:failed', }); @@ -535,7 +575,7 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {t('Flag')} + {t('messageActions.flag.text', 'Flag')} ); }, @@ -549,7 +589,11 @@ const DefaultMessageActionComponents = { const isMuted = isUserMuted(message, mutes); return ( { @@ -561,10 +605,10 @@ const DefaultMessageActionComponents = { }, emitter: 'MessageActions', message: isMuted - ? t('{{ user }} has been unmuted', { + ? t('common.unmuted.label', '{{ user }} has been unmuted', { user: message.user?.name || message.user?.id, }) - : t('{{ user }} has been muted', { + : t('common.muted.label', '{{ user }} has been muted', { user: message.user?.name || message.user?.id, }), severity: 'success', @@ -579,7 +623,9 @@ const DefaultMessageActionComponents = { error: getNotificationError(error), message: getErrorMessage( error, - isMuted ? t('Error unmuting a user ...') : t('Error muting a user ...'), + isMuted + ? t('common.errorUnmutingUser.label', 'Error unmuting a user ...') + : t('common.errorMutingUser.label', 'Error muting a user ...'), ), severity: 'error', type: isMuted ? 'api:user:unmute:failed' : 'api:user:mute:failed', @@ -588,7 +634,7 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {isMuted ? t('Unmute') : t('Mute')} + {isMuted ? t('common.unmute.title', 'Unmute') : t('common.mute.title', 'Mute')} ); }, @@ -605,7 +651,7 @@ const DefaultMessageActionComponents = { return ( <> { @@ -613,7 +659,7 @@ const DefaultMessageActionComponents = { }} variant='destructive' > - {t('Delete message')} + {t('messageActions.deleteMessageAlert.deleteMessage.title', 'Delete message')} { @@ -677,7 +730,9 @@ const DefaultMessageActionComponents = { closeMenu(); }} > - {isBlocked ? t('Unblock') : t('Block User')} + {isBlocked + ? t('common.unblock.ariaLabel', 'Unblock') + : t('common.blockUser.title', 'Block User')} ); }, @@ -705,7 +760,10 @@ const DefaultMessageActionComponents = { { @@ -729,7 +787,7 @@ const DefaultMessageActionComponents = { return ( { - {t('Remind Me')} + {t('messageActions.remindMeSubmenu.remindMe.text', 'Remind Me')} ); @@ -58,7 +58,7 @@ export const RemindMeSubmenu = () => { message, }, emitter: 'MessageActions', - message: t('Reminder set'), + message: t('common.reminderSet.text', 'Reminder set'), severity: 'success', type: 'api:message:reminder:set:success', }); @@ -78,7 +78,7 @@ export const RemindMeSubmenu = () => { } }} > - {t('duration/Remind Me', { milliseconds: offsetMs })} + {t('duration.remindMe', { milliseconds: offsetMs })} ))} {/* todo: potential improvement to add a custom option that would trigger rendering modal with custom date picker - we need date picker */} diff --git a/src/components/MessageBounce/MessageBouncePrompt.tsx b/src/components/MessageBounce/MessageBouncePrompt.tsx index d24bd5642a..528776cacd 100644 --- a/src/components/MessageBounce/MessageBouncePrompt.tsx +++ b/src/components/MessageBounce/MessageBouncePrompt.tsx @@ -37,13 +37,19 @@ export function MessageBouncePrompt({ children }: MessageBouncePromptProps) { description={ !children ? t( + 'messageBounce.prompt.description', 'Review this message and choose whether to delete it, edit it, or send it anyway', ) : undefined } Icon={IconExclamationMark} title={ - !children ? t('This message did not meet our content guidelines') : undefined + !children + ? t( + 'messageBounce.prompt.title', + 'This message did not meet our content guidelines', + ) + : undefined } > {children} @@ -57,7 +63,7 @@ export function MessageBouncePrompt({ children }: MessageBouncePromptProps) { size='md' variant='danger' > - {t('Delete')} + {t('common.delete.text', 'Delete')} diff --git a/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx index 4f878f9733..87b766672a 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx @@ -92,7 +92,9 @@ export const AudioAttachmentPreview = ({
- {isVoiceRecordingAttachment(attachment) ? t('Voice message') : attachment.title} + {isVoiceRecordingAttachment(attachment) + ? t('common.voiceMessage.label', 'Voice message') + : attachment.title}
{uploadState === 'uploading' && ( @@ -134,18 +136,32 @@ export const AudioAttachmentPreview = ({ {hasSizeLimitError - ? t('File too large') + ? t( + 'messageComposer.audioAttachmentPreview.fileTooLarge.text', + 'File too large', + ) : uploadState === 'blocked' - ? t('Upload blocked') - : t('Upload failed')} + ? t( + 'messageComposer.audioAttachmentPreview.uploadBlocked.text', + 'Upload blocked', + ) + : t( + 'messageComposer.audioAttachmentPreview.uploadFailed.text', + 'Upload failed', + )}
) : (
- {t('Upload error')} + + {t( + 'messageComposer.audioAttachmentPreview.uploadError.text', + 'Upload error', + )} +
)} @@ -161,7 +180,7 @@ export const AudioAttachmentPreview = ({
{audioPlayer && canPlayRecord && ( {hasSizeLimitError - ? t('File too large') + ? t( + 'messageComposer.audioAttachmentPreview.fileTooLarge.text', + 'File too large', + ) : uploadState === 'blocked' - ? t('Upload blocked') - : t('Upload failed')} + ? t( + 'messageComposer.audioAttachmentPreview.uploadBlocked.text', + 'Upload blocked', + ) + : t( + 'messageComposer.audioAttachmentPreview.uploadFailed.text', + 'Upload failed', + )} )} {hasRetriableError && (
- {t('Upload error')} + + {t( + 'messageComposer.audioAttachmentPreview.uploadError.text', + 'Upload error', + )} +
)} diff --git a/src/components/MessageComposer/AttachmentPreviewList/GeolocationPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/GeolocationPreview.tsx index b6a3417789..1c9d996a06 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/GeolocationPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/GeolocationPreview.tsx @@ -28,7 +28,9 @@ export const GeolocationPreview = ({ }: GeolocationPreviewProps) => { const { t } = useTranslationContext(); const shareDuration = (location as LiveLocationPreview).durationMs; - const title = shareDuration ? t('Live location') : t('Current location'); + const title = shareDuration + ? t('common.liveLocation.text', 'Live location') + : t('common.currentLocation.text', 'Current location'); return (
@@ -36,19 +38,26 @@ export const GeolocationPreview = ({
{title}
- {t('Location: {{ coordinates }}', { - coordinates: `${location.latitude}, ${location.longitude}`, - })} + {t( + 'messageComposer.geolocationPreview.location.text', + 'Location: {{ coordinates }}', + { + coordinates: `${location.latitude}, ${location.longitude}`, + }, + )}
{shareDuration && (
- {t('Live for {{duration}}', { - duration: t('duration/Share Location', { + {t('messageComposer.geolocationPreview.live.text', 'Live for {{duration}}', { + duration: t('duration.shareLocation', { milliseconds: shareDuration, }), })} @@ -57,7 +66,10 @@ export const GeolocationPreview = ({
{remove && ( - {t('Unsupported attachment')} + {t('common.unsupportedAttachment.text', 'Unsupported attachment')}
inputRef.current?.click()} ref={setButtonElement} @@ -189,7 +192,7 @@ export const DefaultAttachmentSelectorComponents = { }); }} > - {t('Commands')} + {t('messageComposer.attachmentSelector.commands.text', 'Commands')} ); }, @@ -207,7 +210,7 @@ export const DefaultAttachmentSelectorComponents = { closeMenu(); }} > - {t('File')} + {t('messageComposer.attachmentSelector.file.text', 'File')} ); }, @@ -223,7 +226,7 @@ export const DefaultAttachmentSelectorComponents = { closeMenu(); }} > - {t('Location')} + {t('common.location.text', 'Location')} ); }, @@ -239,7 +242,7 @@ export const DefaultAttachmentSelectorComponents = { closeMenu(); }} > - {t('Poll')} + {t('common.poll.label', 'Poll')} ); }, @@ -439,7 +442,10 @@ export const AttachmentSelector = ({ {...buttonProps} aria-expanded={menuDialogIsOpen} aria-haspopup='true' - aria-label={t('aria/Open Attachment Selector')} + aria-label={t( + 'messageComposer.attachmentSelector.openAttachmentSelector.ariaLabel', + 'Open Attachment Selector', + )} disabled={isCooldownActive} iconClassName={clsx('str-chat__prepare-rotate45', { 'str-chat__rotate45': menuDialogIsOpen, @@ -449,8 +455,11 @@ export const AttachmentSelector = ({ /> = { ban: IconUserRemove, @@ -37,11 +38,16 @@ export const CommandsSubmenuHeader = () => { return ( - {t('Instant commands')} + + {t('messageComposer.commandsMenu.instantCommands.text', 'Instant commands')} + ); @@ -51,7 +57,9 @@ export const CommandsMenuHeader = () => { const { t } = useTranslationContext(); return ( - {t('Instant commands')} + + {t('messageComposer.commandsMenu.instantCommands.text', 'Instant commands')} + ); }; @@ -91,30 +99,32 @@ export const useCommandTranslation = (command: Command) => { const knownArgsTranslations = useMemo>( () => ({ - ban: t('ban-command-args'), - giphy: t('giphy-command-args'), - mute: t('mute-command-args'), - unban: t('unban-command-args'), - unmute: t('unmute-command-args'), + ban: t('command.ban.args', '[@username] [text]'), + giphy: t('command.giphy.args', '[text]'), + mute: t('command.mute.args', '[@username]'), + unban: t('command.unban.args', '[@username]'), + unmute: t('command.unmute.args', '[@username]'), }), [t], ); const knownDescriptionTranslations = useMemo>( () => ({ - ban: t('ban-command-description'), - giphy: t('giphy-command-description'), - mute: t('mute-command-description'), - unban: t('unban-command-description'), - unmute: t('unmute-command-description'), + ban: t('command.ban.description', 'Ban a user'), + giphy: t('command.giphy.description', 'Post a random gif to the channel'), + mute: t('command.mute.description', 'Mute a user'), + unban: t('command.unban.description', 'Unban a user'), + unmute: t('command.unmute.description', 'Unmute a user'), }), [t], ); const args = - command.args && (knownArgsTranslations[command.name ?? ''] ?? t(command.args)); + command.args && + (knownArgsTranslations[command.name ?? ''] ?? t(asDynamicKey(command.args))); const description = command.description && - (knownDescriptionTranslations[command.name ?? ''] ?? t(command.description)); + (knownDescriptionTranslations[command.name ?? ''] ?? + t(asDynamicKey(command.description))); return { args, description }; }; diff --git a/src/components/MessageComposer/AttachmentSelector/__tests__/CommandsMenu.test.tsx b/src/components/MessageComposer/AttachmentSelector/__tests__/CommandsMenu.test.tsx index 43ecb142cd..bb76d5db37 100644 --- a/src/components/MessageComposer/AttachmentSelector/__tests__/CommandsMenu.test.tsx +++ b/src/components/MessageComposer/AttachmentSelector/__tests__/CommandsMenu.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; import { CommandsMenu, CommandsSubmenuHeader } from '../CommandsMenu'; +import { mockT } from '../../../../mock-builders/translator'; const { announceInteraction, closeMenu, returnToParentMenu, setCommand } = vi.hoisted( () => ({ @@ -14,8 +15,7 @@ const { announceInteraction, closeMenu, returnToParentMenu, setCommand } = vi.ho const { commandsMock } = vi.hoisted(() => ({ commandsMock: { value: [] as unknown[] } })); -// Strip the `aria/` prefix so assertions read the natural-language value. -const t = (key: string) => (key.startsWith('aria/') ? key.replace('aria/', '') : key); +const t = mockT; // Keep the real Dialog primitives (ContextMenuBackButton/Button/Header) — only stub the context hook. vi.mock('../../../Dialog', async (importOriginal) => ({ diff --git a/src/components/MessageComposer/CommandChip.tsx b/src/components/MessageComposer/CommandChip.tsx index 436a0b4eb4..33d4f6d17d 100644 --- a/src/components/MessageComposer/CommandChip.tsx +++ b/src/components/MessageComposer/CommandChip.tsx @@ -20,7 +20,11 @@ export const CommandChip = ({ command }: CommandChipProps) => { {command.name} diff --git a/src/components/Poll/PollActions/PollActions.tsx b/src/components/Poll/PollActions/PollActions.tsx index 59524354b3..53085942b3 100644 --- a/src/components/Poll/PollActions/PollActions.tsx +++ b/src/components/Poll/PollActions/PollActions.tsx @@ -92,7 +92,7 @@ export const PollActions = ({
{!is_closed && created_by_id === client.user?.id && ( 0 && channelCapabilities.has('query-poll-votes') && (
@@ -64,7 +67,10 @@ export const PollAnswerList = ({ onUpdateOwnAnswerClick }: PollAnswerListProps) size='md' variant='secondary' > - {t('Update your comment')} + {t( + 'poll.addCommentPrompt.updateComment.label', + 'Update Your Comment', + )}
)} @@ -87,7 +93,9 @@ export const PollAnswerList = ({ onUpdateOwnAnswerClick }: PollAnswerListProps) {/* className='str-chat__poll-action'*/} {/* onClick={onUpdateOwnAnswerClick}*/} {/* >*/} - {/* {ownAnswer ? t('Update your comment') : t('Add a comment')}*/} + {/* {ownAnswer*/} + {/* ? t('poll.addCommentPrompt.updateComment.label', 'Update Your Comment')*/} + {/* : t('poll.addCommentPrompt.addComment.label', 'Add a Comment')}*/} {/* */} {/* */} {/* )}*/} diff --git a/src/components/Poll/PollActions/PollOptionsFullList.tsx b/src/components/Poll/PollActions/PollOptionsFullList.tsx index aa6d58e80e..3c73f24366 100644 --- a/src/components/Poll/PollActions/PollOptionsFullList.tsx +++ b/src/components/Poll/PollActions/PollOptionsFullList.tsx @@ -22,8 +22,11 @@ export const PollOptionsFullList = () => { diff --git a/src/components/Poll/PollActions/PollQuestion.tsx b/src/components/Poll/PollActions/PollQuestion.tsx index 7f08ffd480..2d59692626 100644 --- a/src/components/Poll/PollActions/PollQuestion.tsx +++ b/src/components/Poll/PollActions/PollQuestion.tsx @@ -8,7 +8,9 @@ export const PollQuestion = ({ question }: PollQuestionProps) => { const { t } = useTranslationContext(); return (
-
{t('Question')}
+
+ {t('poll.question.question.text', 'Question')} +
{question}
); diff --git a/src/components/Poll/PollActions/PollResults/PollOptionWithVotes.tsx b/src/components/Poll/PollActions/PollResults/PollOptionWithVotes.tsx index 5a89706e2c..d9719b7150 100644 --- a/src/components/Poll/PollActions/PollResults/PollOptionWithVotes.tsx +++ b/src/components/Poll/PollActions/PollResults/PollOptionWithVotes.tsx @@ -65,7 +65,7 @@ export const PollOptionWithVotes = ({ size='md' variant='secondary' > - {t('View all')} + {t('poll.optionVotes.view.text', 'View all')}
)} diff --git a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx index 3aaf128d91..af4d42b26d 100644 --- a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx +++ b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx @@ -33,7 +33,11 @@ export const PollResultOptionVoteCounter = ({ )} - {t('{{count}} votes', { count: vote_counts_by_option[optionId] ?? 0 })} + {t('poll.optionVotes.votes.text', { + count: vote_counts_by_option[optionId] ?? 0, + defaultValue_one: '{{count}} vote', + defaultValue_other: '{{count}} votes', + })} ); @@ -53,7 +57,9 @@ export const PollOptionWithVotesHeader = ({ return (
- {t('Question {{ optionOrderNumber}}', { optionOrderNumber })} + {t('poll.optionVotes.question.text', 'Question {{ optionOrderNumber}}', { + optionOrderNumber, + })}
{option.text}
diff --git a/src/components/Poll/PollActions/PollResults/PollResults.tsx b/src/components/Poll/PollActions/PollResults/PollResults.tsx index dbf45440a3..eb1b0392be 100644 --- a/src/components/Poll/PollActions/PollResults/PollResults.tsx +++ b/src/components/Poll/PollActions/PollResults/PollResults.tsx @@ -50,9 +50,12 @@ export const PollResults = () => { <> { @@ -98,7 +102,11 @@ export const PollResults = () => {
- {t('totalVoteCount', { count: vote_count })} + {t('poll.results.totalVoteCount.text', { + count: vote_count, + defaultValue_one: '1 vote total', + defaultValue_other: '{{ count }} votes total', + })}
diff --git a/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx b/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx index 4dbb26fae7..3a6cc1453c 100644 --- a/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx +++ b/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx @@ -32,11 +32,21 @@ export const SuggestPollOptionPrompt = () => { optionText: (v: string) => { const trimmed = typeof v === 'string' ? v.trim() : ''; if (!trimmed) { - return new Error(t('This field cannot be empty or contain only spaces')); + return new Error( + t( + 'poll.addCommentPrompt.fieldCannotEmptyContain.label', + 'This field cannot be empty or contain only spaces', + ), + ); } const existingOption = options.find((option) => option.text === trimmed); if (existingOption) { - return new Error(t('Option already exists')); + return new Error( + t( + 'poll.suggestPollOption.optionAlreadyExists.label', + 'Option already exists', + ), + ); } return undefined; }, @@ -73,19 +83,22 @@ export const SuggestPollOptionPrompt = () => {
setFieldValue('optionText', e.target.value)} - placeholder={t('placeholder/PollOptionSuggestion')} + placeholder={t('poll.pollOptionSuggestion.placeholder', 'Enter a new option')} ref={setInput} required type='text' @@ -98,14 +111,14 @@ export const SuggestPollOptionPrompt = () => { className='str-chat__prompt__footer__controls-button--cancel' onClick={close} > - {t('Cancel')} + {t('common.cancel.label', 'Cancel')} 0 || submitDisabled} type='submit' > - {t('Send')} + {t('common.send.label', 'Send')} diff --git a/src/components/Poll/PollActions/__tests__/EndPollAlert.test.tsx b/src/components/Poll/PollActions/__tests__/EndPollAlert.test.tsx index d6aa9366fb..ffcd244bb7 100644 --- a/src/components/Poll/PollActions/__tests__/EndPollAlert.test.tsx +++ b/src/components/Poll/PollActions/__tests__/EndPollAlert.test.tsx @@ -16,6 +16,7 @@ import { mockTranslationContextValue, } from '../../../../mock-builders'; import { Poll } from 'stream-chat'; +import { mockT } from '../../../../mock-builders/translator'; describe('EndPollAlert', () => { it('closes modal and notifies on successful poll end', async () => { @@ -29,7 +30,7 @@ describe('EndPollAlert', () => { render( - k })}> + @@ -48,7 +49,7 @@ describe('EndPollAlert', () => { expect(close).toHaveBeenCalledTimes(1); expect(addSpy).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Poll ended', + message: 'Poll Ended', options: expect.objectContaining({ severity: 'success' }), }), ); @@ -66,7 +67,7 @@ describe('EndPollAlert', () => { render( - k })}> + diff --git a/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx index 53be789d2a..d24e30d1c3 100644 --- a/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx +++ b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx @@ -24,8 +24,14 @@ export const MultipleAnswersField = () => { const knownValidationErrors = useMemo>( () => ({ - 'Enforce unique vote is enabled': t('Enforce unique vote is enabled'), - 'Type a number from 2 to 10': t('Type a number from 2 to 10'), + 'Enforce unique vote is enabled': t( + 'poll.multipleAnswersField.enforceUniqueVoteEnabled.label', + 'Enforce unique vote is enabled', + ), + 'Type a number from 2 to 10': t( + 'poll.multipleAnswersField.typeNumber210.label', + 'Type a number from 2 to 10', + ), }), [t], ); @@ -39,13 +45,16 @@ export const MultipleAnswersField = () => {
{ setVoteLimitEnabled(false); pollComposer.updateFields({ enforce_unique_vote: !e.target.checked }); }} - title={t('Multiple votes')} + title={t('poll.multipleAnswersField.multipleVotes.title', 'Multiple Votes')} /> {multipleVotesEnabled && ( {
{voteLimitEnabled && ( { const raw = e.target.value; const nativeFieldValidation = raw !== '' && !/^\d+$/.test(raw) - ? { max_votes_allowed: t('Only numbers are allowed') } + ? { + max_votes_allowed: t( + 'poll.multipleAnswersField.onlyNumbersAllowed.label', + 'Only numbers are allowed', + ), + } : undefined; pollComposer.updateFields( { diff --git a/src/components/Poll/PollCreationDialog/NameField.tsx b/src/components/Poll/PollCreationDialog/NameField.tsx index e100fdad82..4abc88aa08 100644 --- a/src/components/Poll/PollCreationDialog/NameField.tsx +++ b/src/components/Poll/PollCreationDialog/NameField.tsx @@ -16,7 +16,10 @@ export const NameField = () => { const { error, name } = useStateStore(pollComposer.state, pollComposerStateSelector); const knownValidationErrors = useMemo>( () => ({ - 'Question is required': t('Question is required'), + 'Question is required': t( + 'poll.nameField.questionRequired.label', + 'Question is required', + ), }), [t], ); @@ -32,19 +35,19 @@ export const NameField = () => { errorMessage={ error ? ( - {knownValidationErrors[error] ?? t('Error')} + {knownValidationErrors[error] ?? t('poll.nameField.error.text', 'Error')} ) : undefined } id='name' - label={t('Question')} + label={t('poll.question.question.text', 'Question')} onBlur={() => { pollComposer.handleFieldBlur('name'); }} onChange={(e) => { pollComposer.updateFields({ name: e.target.value }); }} - placeholder={t('Ask a question')} + placeholder={t('poll.nameField.askQuestion.placeholder', 'Ask a Question')} type='text' value={name} /> diff --git a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx index e597ce70a1..7efee453d5 100644 --- a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx +++ b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx @@ -45,15 +45,19 @@ export const OptionFieldSet = () => { const knownValidationErrors = useMemo>( () => ({ - 'Option already exists': t('Option already exists'), - 'Option is empty': t('Option is empty'), + 'Option already exists': t( + 'poll.suggestPollOption.optionAlreadyExists.label', + 'Option already exists', + ), + 'Option is empty': t('poll.optionFieldSet.optionEmpty.label', 'Option is empty'), }), [t], ); const labelForOption = useCallback( (option: PollComposerOption, position: number) => - option.text.trim() || t('aria/Option {{ position }}', { position }), + option.text.trim() || + t('poll.optionFieldSet.option.ariaLabel', 'Option {{ position }}', { position }), [t], ); @@ -188,7 +192,10 @@ export const OptionFieldSet = () => { useSettledAnnouncement(announce, { active: draggable, - message: t('aria/Options can now be reordered and removed.'), + message: t( + 'poll.optionFieldSet.optionsCanNowReordered.ariaLabel', + 'Options can now be reordered and removed.', + ), settleKey: options, }); @@ -196,7 +203,7 @@ export const OptionFieldSet = () => { <> {options.map((option, i) => { @@ -232,7 +239,8 @@ export const OptionFieldSet = () => { message={ error ? ( - {knownValidationErrors[error] ?? t('Error')} + {knownValidationErrors[error] ?? + t('poll.nameField.error.text', 'Error')} ) : undefined } @@ -250,7 +258,10 @@ export const OptionFieldSet = () => { optionInputRefs.current[i + 1]?.focus(); } }} - placeholder={t('Add an option')} + placeholder={t( + 'poll.optionFieldSet.addOption.placeholder', + 'Add an Option', + )} ref={(element) => { optionInputRefs.current[i] = element; }} @@ -258,9 +269,13 @@ export const OptionFieldSet = () => { draggable ? ( clearOption(option.id)} /> ) : undefined @@ -274,7 +289,10 @@ export const OptionFieldSet = () => { {draggable && ( - {t('aria/This option can be reordered and removed.')} + {t( + 'poll.optionFieldSet.optionCanReorderedRemoved.ariaLabel', + 'This option can be reordered and removed.', + )} )} diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx index 57a890f289..65dd51adcb 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx @@ -40,8 +40,11 @@ export const PollCreationDialog = ({ close }: PollCreationDialogProps) => { > @@ -51,7 +54,10 @@ export const PollCreationDialog = ({ close }: PollCreationDialogProps) => { pollComposer.updateFields({ @@ -60,27 +66,33 @@ export const PollCreationDialog = ({ close }: PollCreationDialogProps) => { : VotingVisibility.public, }) } - title={t('Anonymous poll')} + title={t('poll.creationDialog.anonymousPoll.title', 'Anonymous Poll')} /> pollComposer.updateFields({ allow_user_suggested_options: e.target.checked, }) } - title={t('Suggest an option')} + title={t('poll.actions.suggestOption.label', 'Suggest an Option')} /> pollComposer.updateFields({ allow_answers: e.target.checked }) } - title={t('Add a comment')} + title={t('poll.addCommentPrompt.addComment.label', 'Add a Comment')} />
diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx index 8f2f15531d..da41970901 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx @@ -31,7 +31,7 @@ export const PollCreationDialogControls = ({ onClick={close} type='button' > - {t('Cancel')} + {t('common.cancel.label', 'Cancel')} - {t('Send poll')} + {t('poll.creationDialog.sendPoll.text', 'Send Poll')} diff --git a/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx b/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx index edcc0a14d7..f2fdecaaaa 100644 --- a/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx +++ b/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx @@ -36,7 +36,9 @@ export const PollOptionReorderHandle = ({ const focusAnnouncementFrameRef = useRef(null); const position = index + 1; - const optionLabel = option.text.trim() || t('aria/Option {{ position }}', { position }); + const optionLabel = + option.text.trim() || + t('poll.optionFieldSet.option.ariaLabel', 'Option {{ position }}', { position }); // While picked up, fold the option text + new position into the aria-label // so VoiceOver speaks "Reorder 'option B' at position 1 of 3" on the focus // event triggered by ArrowUp/ArrowDown. That replaces the otherwise @@ -44,12 +46,18 @@ export const PollOptionReorderHandle = ({ // and removes the need for a duplicate live-region "moved to position" // message. const ariaLabel = isActive - ? t('aria/Reorder "{{ option }}" at position {{ position }} of {{ total }}', { - option: optionLabel, + ? t( + 'poll.optionReorder.reorderPosition.ariaLabel', + 'Reorder "{{ option }}" at position {{ position }} of {{ total }}', + { + option: optionLabel, + position, + total: totalOptionCount, + }, + ) + : t('poll.optionReorder.reorderOption.ariaLabel', 'Reorder option {{ position }}', { position, - total: totalOptionCount, - }) - : t('aria/Reorder option {{ position }}', { position }); + }); useEffect( () => () => { @@ -108,7 +116,8 @@ export const PollOptionReorderHandle = ({ focusAnnouncementFrameRef.current = requestAnimationFrame(() => { announce( t( - 'aria/Press Space to select this option, use the Up and Down arrow keys to move it, then press Space again to deselect it.', + 'poll.optionReorder.pressSpaceSelectOption.ariaLabel', + 'Press Space to select this option, use the Up and Down arrow keys to move it, then press Space again to deselect it.', ), { priority: 'assertive' }, ); diff --git a/src/components/Poll/PollHeader.tsx b/src/components/Poll/PollHeader.tsx index d692dd680a..53b7553af5 100644 --- a/src/components/Poll/PollHeader.tsx +++ b/src/components/Poll/PollHeader.tsx @@ -26,13 +26,17 @@ export const PollHeader = () => { useStateStore(poll.state, pollStateSelector); const selectionInstructions = useMemo(() => { - if (is_closed) return t('Vote ended'); - if (enforce_unique_vote || options.length === 1) return t('Select one'); + if (is_closed) return t('poll.header.voteEnded.label', 'Vote ended'); + if (enforce_unique_vote || options.length === 1) + return t('poll.header.selectOne.label', 'Select one'); if (max_votes_allowed) - return t('Select up to {{count}}', { + return t('poll.header.selectUp.label', { count: max_votes_allowed > options.length ? options.length : max_votes_allowed, + defaultValue_one: 'Select up to {{count}}', + defaultValue_other: 'Select up to {{count}}', }); - if (options.length > 1) return t('Select one or more'); + if (options.length > 1) + return t('poll.header.selectOneMore.label', 'Select one or more'); return ''; }, [is_closed, enforce_unique_vote, max_votes_allowed, options.length, t]); diff --git a/src/components/Poll/PollOptionList.tsx b/src/components/Poll/PollOptionList.tsx index 502750146d..34bd312e94 100644 --- a/src/components/Poll/PollOptionList.tsx +++ b/src/components/Poll/PollOptionList.tsx @@ -54,8 +54,10 @@ export const PollOptionList = ({
{showMoreOptionsButton && ( {voteCountVerbose - ? t('{{count}} votes', { + ? t('poll.optionVotes.votes.text', { count: vote_counts_by_option[option.id] ?? 0, + defaultValue_one: '{{count}} vote', + defaultValue_other: '{{count}} votes', }) : (vote_counts_by_option[option.id] ?? 0)} diff --git a/src/components/Poll/PollVote.tsx b/src/components/Poll/PollVote.tsx index 3c9d00d4a9..0a0afea686 100644 --- a/src/components/Poll/PollVote.tsx +++ b/src/components/Poll/PollVote.tsx @@ -24,14 +24,14 @@ const PollVoteTimestamp = ({ timestamp }: { timestamp: string | Date }) => { onMouseLeave={handleLeave} ref={setReferenceElement} > - {t('timestamp/PollVote', { timestamp: timestampDate })} + {t('timestamp.PollVote', { timestamp: timestampDate })} - {t('timestamp/PollVoteTooltip', { timestamp: timestampDate })} + {t('timestamp.PollVoteTooltip', { timestamp: timestampDate })} ); @@ -50,8 +50,8 @@ const PollVoteAuthor = ({ vote }: PollVoteAuthor) => { useComponentContext(); const displayName = client.user?.id && client.user.id === vote.user?.id - ? t('You') - : vote.user?.name || vote.user?.id || t('Anonymous'); + ? t('common.you.label', 'You') + : vote.user?.name || vote.user?.id || t('common.anonymous.label', 'Anonymous'); return (
diff --git a/src/components/Poll/__tests__/AddCommentForm.test.tsx b/src/components/Poll/__tests__/AddCommentForm.test.tsx index 2d429493a6..10e89744ca 100644 --- a/src/components/Poll/__tests__/AddCommentForm.test.tsx +++ b/src/components/Poll/__tests__/AddCommentForm.test.tsx @@ -7,12 +7,13 @@ import { AddCommentPrompt } from '../PollActions'; import { PollProvider, TranslationProvider } from '../../../context'; import type { TranslationContextValue } from '../../../context'; import { generatePoll, mockTranslationContextValue } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; const close = vi.fn(); const messageId = 'messageId'; const newlyTypedValue = 'XX'; -const t = ((v: string) => v) as TranslationContextValue['t']; +const t = mockT as TranslationContextValue['t']; const renderComponent = ({ poll, props }: any = {}) => render( diff --git a/src/components/Poll/__tests__/Poll.test.tsx b/src/components/Poll/__tests__/Poll.test.tsx index 4bc1eb5bec..3fa9a6d020 100644 --- a/src/components/Poll/__tests__/Poll.test.tsx +++ b/src/components/Poll/__tests__/Poll.test.tsx @@ -19,12 +19,13 @@ import { mockChatContext, mockTranslationContextValue, } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; const POLL_ACTIONS__CLASS = '.str-chat__poll-actions'; const POLL_OPTION_LIST__CLASS = '.str-chat__poll-option-list'; const POLL_HEADER__CLASS = '.str-chat__poll-header'; -const t = (v) => v; +const t = mockT; // MERGE-RECONCILE (test migration): the deleted ChannelStateContext no longer provides // `channelCapabilities`. Poll components now read capabilities via useChannelCapabilities({ cid }), diff --git a/src/components/Poll/__tests__/PollActions.test.tsx b/src/components/Poll/__tests__/PollActions.test.tsx index 7a31d97952..f584b08cc6 100644 --- a/src/components/Poll/__tests__/PollActions.test.tsx +++ b/src/components/Poll/__tests__/PollActions.test.tsx @@ -21,6 +21,7 @@ import { mockMessageContext, mockTranslationContextValue, } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; // MERGE-RECONCILE (test migration): the deleted ChannelStateContext no longer provides // `channelCapabilities`. Poll components now read capabilities via useChannelCapabilities({ cid }), @@ -41,13 +42,13 @@ const makeChannel = (capabilities: Record = {}) => }, }); -const SUGGEST_OPTION_ACTION_TEXT = 'Suggest an option'; -const UPDATE_COMMENT_ACTION_TEXT = 'Update your comment'; -const VIEW_COMMENTS_ACTION_TEXT = 'View {{count}} comments'; -const VIEW_RESULTS_ACTION_TEXT = 'View results'; -const END_VOTE_ACTION_TEXT = 'End poll'; +const SUGGEST_OPTION_ACTION_TEXT = 'Suggest an Option'; +const UPDATE_COMMENT_ACTION_TEXT = 'Update Your Comment'; +const VIEW_COMMENTS_ACTION_TEXT = 'View 1 Comment'; +const VIEW_RESULTS_ACTION_TEXT = 'View Results'; +const END_VOTE_ACTION_TEXT = 'End Poll'; -const t = (v: any) => v; +const t = mockT; const defaultChannelStateContext = { channelCapabilities: { 'cast-poll-vote': true, 'query-poll-votes': true }, diff --git a/src/components/Poll/__tests__/PollHeader.test.tsx b/src/components/Poll/__tests__/PollHeader.test.tsx index 0f67aeba40..5b4189ab85 100644 --- a/src/components/Poll/__tests__/PollHeader.test.tsx +++ b/src/components/Poll/__tests__/PollHeader.test.tsx @@ -7,11 +7,12 @@ import { Poll } from 'stream-chat'; import type { StreamChat } from 'stream-chat'; import { fromPartial } from '@total-typescript/shoehorn'; import { generatePoll, mockTranslationContextValue } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; const TITLE_SELECTOR = '.str-chat__poll-title'; const SUBTITLE_SELECTOR = '.str-chat__poll-subtitle'; -const t = ((v: string) => v) as TranslationContextValue['t']; +const t = mockT as TranslationContextValue['t']; const renderComponent = ({ poll }) => render( @@ -61,7 +62,7 @@ describe('PollHeader', () => { const nameDiv = container.querySelector(TITLE_SELECTOR); const subtitleDiv = container.querySelector(SUBTITLE_SELECTOR); expect(nameDiv).toHaveTextContent(pollData.name); - expect(subtitleDiv).toHaveTextContent('Select up to {{count}}'); + expect(subtitleDiv).toHaveTextContent('Select up to 2'); }); it('should render Select one or more header', () => { diff --git a/src/components/Poll/__tests__/PollOptionList.test.tsx b/src/components/Poll/__tests__/PollOptionList.test.tsx index 88bb3e6936..300ad4739a 100644 --- a/src/components/Poll/__tests__/PollOptionList.test.tsx +++ b/src/components/Poll/__tests__/PollOptionList.test.tsx @@ -24,6 +24,7 @@ import { mockMessageContext, mockTranslationContextValue, } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; // MERGE-RECONCILE (test migration): the deleted ChannelStateContext no longer provides // `channelCapabilities`. Poll components now read capabilities via useChannelCapabilities({ cid }), @@ -50,7 +51,10 @@ const CHECKMARK_SELECTOR = '.str-chat__checkmark'; const CHECKMARK_CHECKED_SELECTOR = '.str-chat__checkmark--checked'; const VOTE_COUNT_SELECTOR = '.str-chat__poll-option-vote-count'; -const MORE_OPTIONS_ACTION_TEXT = '+{{count}} more options'; +// NOTE: the component interpolates `options.length` (6 here), not the number of *hidden* +// options (3). That reads oddly but is pre-existing behaviour — the previous assertion +// matched the uninterpolated template, so it never surfaced. +const MORE_OPTIONS_ACTION_TEXT = '+6 more options'; const pollWithNoVotes = generatePoll({ answers_count: 1, @@ -61,7 +65,7 @@ const pollWithNoVotes = generatePoll({ vote_counts_by_option: {}, }); -const t = (v: any) => v; +const t = mockT; const defaultChannelStateContext = { channelCapabilities: { 'cast-poll-vote': true }, diff --git a/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx b/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx index bafc7e1d20..40fbd6b335 100644 --- a/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx +++ b/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx @@ -11,12 +11,13 @@ import { mockChatContext, mockTranslationContextValue, } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; const SUBMIT_BUTTON_TEXT = 'Send'; const newlyTypedValue = 'XX'; -const t = ((v: string) => v) as TranslationContextValue['t']; +const t = mockT as TranslationContextValue['t']; const renderComponent = ({ client, poll, props }: any) => render( diff --git a/src/components/ReactFileUtilities/UploadButton.tsx b/src/components/ReactFileUtilities/UploadButton.tsx index 863613f75d..5e0cef11fa 100644 --- a/src/components/ReactFileUtilities/UploadButton.tsx +++ b/src/components/ReactFileUtilities/UploadButton.tsx @@ -59,7 +59,7 @@ export const UploadFileInput = forwardRef(function UploadFileInput( return ( 1} diff --git a/src/components/Reactions/MessageReactions.tsx b/src/components/Reactions/MessageReactions.tsx index c9078ae8bb..d572f569a6 100644 --- a/src/components/Reactions/MessageReactions.tsx +++ b/src/components/Reactions/MessageReactions.tsx @@ -148,7 +148,10 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => { return ( <>
{ > { key={reactionType} > handleReactionButtonClick(reactionType)} @@ -208,7 +218,10 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => { visualStyle === 'segmented' && (
  • )}
  • diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index 2cda7962f7..f03f7b44a1 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -90,7 +90,7 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => { return (
    { ({ Component, name: reactionName, type: reactionType }) => (
  • )}
  • diff --git a/src/components/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx index 8ff807c719..e7a832d7b0 100644 --- a/src/components/Search/SearchResults/SearchResultItem.tsx +++ b/src/components/Search/SearchResults/SearchResultItem.tsx @@ -172,9 +172,13 @@ export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemPro return (
    diff --git a/src/components/Search/SearchResults/SearchSourceResultsEmpty.tsx b/src/components/Search/SearchResults/SearchSourceResultsEmpty.tsx index abaae805bb..7fb0f41646 100644 --- a/src/components/Search/SearchResults/SearchSourceResultsEmpty.tsx +++ b/src/components/Search/SearchResults/SearchSourceResultsEmpty.tsx @@ -4,6 +4,8 @@ import { useTranslationContext } from '../../../context'; export const SearchSourceResultsEmpty = () => { const { t } = useTranslationContext(); return ( -
    {t('No results found')}
    +
    + {t('search.sourceResults.noResultsFound.text', 'No results found')} +
    ); }; diff --git a/src/components/Search/SearchResults/SearchSourceResultsLoadingIndicator.tsx b/src/components/Search/SearchResults/SearchSourceResultsLoadingIndicator.tsx index 6ddef10189..cbdff954b5 100644 --- a/src/components/Search/SearchResults/SearchSourceResultsLoadingIndicator.tsx +++ b/src/components/Search/SearchResults/SearchSourceResultsLoadingIndicator.tsx @@ -10,9 +10,13 @@ export const SearchSourceResultsLoadingIndicator = () => { className='str-chat__search-source-results__loading-indicator' data-testid='search-loading-indicator' > - {t('Searching for {{ searchSourceType }}...', { - searchSourceType: searchSource.type, - })} + {t( + 'search.sourceResults.searching.text', + 'Searching for {{ searchSourceType }}...', + { + searchSourceType: searchSource.type, + }, + )}
    ); }; diff --git a/src/components/Search/__tests__/Search.test.tsx b/src/components/Search/__tests__/Search.test.tsx index 2dc3da57a4..b2d6e5db3b 100644 --- a/src/components/Search/__tests__/Search.test.tsx +++ b/src/components/Search/__tests__/Search.test.tsx @@ -16,6 +16,7 @@ import type { } from '../../../context'; import { useStateStore } from '../../../store'; import type { SearchContextValue } from '../SearchContext'; +import { mockT } from '../../../mock-builders/translator'; // vi.mock('../SearchContext'); vi.mock('../../../context'); @@ -23,7 +24,7 @@ vi.mock('../../../store'); const SEARCH_TEST_ID = 'search'; const SEARCH_BAR_TEST_ID = 'search-bar'; -const SEARCH_RESULTS_ARIA_LABEL = 'aria/Search results'; +const SEARCH_RESULTS_ARIA_LABEL = 'Search results'; const CustomSearchBar = () => (
    Custom Search Bar
    @@ -55,7 +56,7 @@ describe('Search', () => { vi.mocked(useTranslationContext).mockReturnValue( fromPartial({ - t: (key) => key, + t: mockT, }), ); diff --git a/src/components/Search/__tests__/SearchBar.test.tsx b/src/components/Search/__tests__/SearchBar.test.tsx index 3f99534300..4699bbb40f 100644 --- a/src/components/Search/__tests__/SearchBar.test.tsx +++ b/src/components/Search/__tests__/SearchBar.test.tsx @@ -10,6 +10,7 @@ import type { TranslationContextValue } from '../../../context'; import { useTranslationContext } from '../../../context'; import { useStateStore } from '../../../store'; import { axe } from '../../../../axe-helper'; +import { mockT } from '../../../mock-builders/translator'; const { announceInteraction } = vi.hoisted(() => ({ announceInteraction: vi.fn() })); @@ -25,7 +26,7 @@ vi.mock('../../Accessibility', () => ({ })); const INPUT_TEST_ID = 'search-input'; -const CLEAR_SEARCH_BUTTON_ARIA_LABEL = 'aria/Clear search'; +const CLEAR_SEARCH_BUTTON_ARIA_LABEL = 'Clear search'; const SEARCH_INPUT_ACCESSIBLE_NAME = 'Search'; describe('SearchBar', () => { @@ -52,7 +53,7 @@ describe('SearchBar', () => { fromPartial(defaultProps), ); vi.mocked(useTranslationContext).mockReturnValue( - fromPartial({ t: (key: any) => key }), + fromPartial({ t: mockT }), ); vi.mocked(useStateStore).mockReturnValue({ isActive: false, diff --git a/src/components/Search/__tests__/SearchResultItem.test.tsx b/src/components/Search/__tests__/SearchResultItem.test.tsx index 965d1590a6..e3217dedbb 100644 --- a/src/components/Search/__tests__/SearchResultItem.test.tsx +++ b/src/components/Search/__tests__/SearchResultItem.test.tsx @@ -23,6 +23,7 @@ import { initClientWithChannels, mockTranslationContextValue, } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; const CHANNEL_PREVIEW_BUTTON_TEST_ID = 'channel-list-item-button'; @@ -41,16 +42,7 @@ vi.mock('../../../context', async (importOriginal) => ({ }), })); -const mockTranslation = (key: string, options?: Record) => { - const interpolated = Object.entries(options || {}).reduce( - (value, [name, arg]) => value.replace(`{{ ${name} }}`, String(arg)), - key, - ); - - return interpolated.startsWith('aria/') - ? interpolated.replace('aria/', '') - : interpolated; -}; +const mockTranslation = mockT; const renderComponent = async ({ activeChannel, diff --git a/src/components/Search/__tests__/SearchResults.test.tsx b/src/components/Search/__tests__/SearchResults.test.tsx index 44082707e0..3c590c491e 100644 --- a/src/components/Search/__tests__/SearchResults.test.tsx +++ b/src/components/Search/__tests__/SearchResults.test.tsx @@ -8,6 +8,7 @@ import type { SearchContextValue } from '../SearchContext'; import { useComponentContext, useTranslationContext } from '../../../context'; import type { TranslationContextValue } from '../../../context'; import { useStateStore } from '../../../store'; +import { mockT } from '../../../mock-builders/translator'; vi.mock('../SearchContext'); vi.mock('../../../context'); @@ -27,7 +28,7 @@ const mockedUseStateStore = vi.mocked(useStateStore); const SOURCE_RESULTS_TEST_ID = 'default-source-results'; const SEARCH_RESULTS_HEADER_TEST_ID = 'default-header'; const PRESEARCH_TEST_ID = 'default-presearch'; -const SEARCH_RESULTS_ARIA_LABEL = 'aria/Search results'; +const SEARCH_RESULTS_ARIA_LABEL = 'Search results'; describe('SearchResults', () => { const mockSearchSource = { isActive: true, @@ -81,7 +82,7 @@ describe('SearchResults', () => { mockedUseTranslationContext.mockReturnValue( fromPartial({ - t: (key) => key, + t: mockT, }), ); diff --git a/src/components/Search/__tests__/SearchResultsHeader.test.tsx b/src/components/Search/__tests__/SearchResultsHeader.test.tsx index bee3576910..1a112b54f7 100644 --- a/src/components/Search/__tests__/SearchResultsHeader.test.tsx +++ b/src/components/Search/__tests__/SearchResultsHeader.test.tsx @@ -5,6 +5,7 @@ import { SearchResultsHeader } from '../SearchResults'; import { useSearchContext } from '../SearchContext'; import { useTranslationContext } from '../../../context'; import { useStateStore } from '../../../store'; +import { mockT } from '../../../mock-builders/translator'; vi.mock('../SearchContext'); vi.mock('../../../context'); @@ -43,7 +44,7 @@ describe('SearchResultsHeader', () => { }); useTranslationContext['mockReturnValue']({ - t: (key) => key, + t: mockT, }); useStateStore['mockReturnValue']({ isActive: false }); @@ -65,24 +66,19 @@ describe('SearchResultsHeader', () => { const buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(3); - expect( - screen.getByText('search-results-header-filter-source-button-label--channels'), - ).toBeInTheDocument(); - expect( - screen.getByText('search-results-header-filter-source-button-label--messages'), - ).toBeInTheDocument(); - expect( - screen.getByText('search-results-header-filter-source-button-label--users'), - ).toBeInTheDocument(); + expect(screen.getByText('channels')).toBeInTheDocument(); + expect(screen.getByText('messages')).toBeInTheDocument(); + expect(screen.getByText('users')).toBeInTheDocument(); }); it('applies correct aria-labels to all buttons', () => { render(); const buttons = screen.getAllByRole('button'); - buttons.forEach((button) => { - expect(button).toHaveAttribute( + // Each button names its own source, interpolated into the shared label. + ['channels', 'messages', 'users'].forEach((source, index) => { + expect(buttons[index]).toHaveAttribute( 'aria-label', - 'aria/Search results header filter button for: {{ source }}', + `Search results header filter button for: ${source}`, ); }); }); @@ -93,9 +89,7 @@ describe('SearchResultsHeader', () => { useStateStore['mockReturnValue']({ isActive: true }); render(); - const label = screen.getByText( - 'search-results-header-filter-source-button-label--messages', - ); + const label = screen.getByText('messages'); const button = label.closest('button'); expect(button).toHaveClass( 'str-chat__search-results-header__filter-source-button--active', @@ -106,9 +100,7 @@ describe('SearchResultsHeader', () => { useStateStore['mockReturnValue']({ isActive: false }); render(); - const label = screen.getByText( - 'search-results-header-filter-source-button-label--messages', - ); + const label = screen.getByText('messages'); const button = label.closest('button'); expect(button).not.toHaveClass( 'str-chat__search-results-header__filter-source-button--active', @@ -124,9 +116,7 @@ describe('SearchResultsHeader', () => { }); render(); - fireEvent.click( - screen.getByText('search-results-header-filter-source-button-label--messages'), - ); + fireEvent.click(screen.getByText('messages')); expect(mockSearchController.deactivateSource).toHaveBeenCalledWith('messages'); expect(mockSearchController.activateSource).not.toHaveBeenCalled(); @@ -138,9 +128,7 @@ describe('SearchResultsHeader', () => { it('activates and searches source with no items', () => { render(); - fireEvent.click( - screen.getByText('search-results-header-filter-source-button-label--channels'), - ); + fireEvent.click(screen.getByText('channels')); expect(mockSearchController.activateSource).toHaveBeenCalledWith('channels'); expect(mockSources.channels.search).toHaveBeenCalledWith('test query'); @@ -148,9 +136,7 @@ describe('SearchResultsHeader', () => { it('only performs search upon activation if it does not have items loaded', () => { render(); - fireEvent.click( - screen.getByText('search-results-header-filter-source-button-label--messages'), - ); + fireEvent.click(screen.getByText('messages')); expect(mockSearchController.activateSource).toHaveBeenCalledWith('messages'); expect(mockSources.messages.search).not.toHaveBeenCalled(); @@ -160,9 +146,7 @@ describe('SearchResultsHeader', () => { mockSearchController.searchQuery = ''; render(); - fireEvent.click( - screen.getByText('search-results-header-filter-source-button-label--channels'), - ); + fireEvent.click(screen.getByText('channels')); expect(mockSearchController.activateSource).toHaveBeenCalledWith('channels'); expect(mockSources.channels.search).not.toHaveBeenCalled(); }); diff --git a/src/components/Search/__tests__/SearchSourceResultListFooter.test.tsx b/src/components/Search/__tests__/SearchSourceResultListFooter.test.tsx index 37cbc503b6..1c1a99e58f 100644 --- a/src/components/Search/__tests__/SearchSourceResultListFooter.test.tsx +++ b/src/components/Search/__tests__/SearchSourceResultListFooter.test.tsx @@ -7,6 +7,7 @@ import { useSearchSourceResultsContext } from '../SearchSourceResultsContext'; import type { ComponentContextValue, TranslationContextValue } from '../../../context'; import { useComponentContext, useTranslationContext } from '../../../context'; import { useStateStore } from '../../../store'; +import { mockT } from '../../../mock-builders/translator'; vi.mock('../SearchSourceResultsContext'); vi.mock('../../../context'); @@ -40,7 +41,7 @@ describe('SearchSourceResultListFooter', () => { vi.mocked(useTranslationContext).mockReturnValue( fromPartial({ - t: (key: any) => key, + t: mockT, }), ); @@ -110,7 +111,9 @@ describe('SearchSourceResultListFooter', () => { }); it('translates "All results loaded" message', () => { - const mockTranslate = vi.fn((key: any) => `Translated ${key}`); + const mockTranslate = vi.fn( + (key: any, defaultValue?: any) => `Translated ${defaultValue ?? key}`, + ); vi.mocked(useTranslationContext).mockReturnValue( fromPartial({ t: mockTranslate }), ); @@ -122,7 +125,10 @@ describe('SearchSourceResultListFooter', () => { render(); - expect(mockTranslate).toHaveBeenCalledWith('All results loaded'); + expect(mockTranslate).toHaveBeenCalledWith( + 'common.resultsLoaded.label', + 'All results loaded', + ); expect(screen.getByText('Translated All results loaded')).toBeInTheDocument(); }); diff --git a/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx b/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx index 228b278975..3b1b47d500 100644 --- a/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx +++ b/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx @@ -323,7 +323,7 @@ describe('useLatestMessagePreview', () => { }); const { result } = renderPreviewHook({ latestMessage: message }); expect(result.current.type).toBe('image'); - expect(result.current.text).toBe('imageCount'); + expect(result.current.text).toBe('2 images'); }); it('uses file type for mixed attachment types', () => { @@ -334,7 +334,7 @@ describe('useLatestMessagePreview', () => { }); const { result } = renderPreviewHook({ latestMessage: message }); expect(result.current.type).toBe('file'); - expect(result.current.text).toBe('fileCount'); + expect(result.current.text).toBe('2 files'); }); // v10: attachment-specific fields such as `duration` live under `attachment.custom`. @@ -366,7 +366,7 @@ describe('useLatestMessagePreview', () => { const { result } = renderPreviewHook({ latestMessage: message }); expect(result.current.type).toBe('voice'); // voice recordings use generic fallback (voiceMessageCount) since fallback text is not useful - expect(result.current.text).toBe('voiceMessageCount (1:04)'); + expect(result.current.text).toBe('Voice message (1:04)'); }); it('formats zero-second duration correctly', () => { diff --git a/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts b/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts index 98ea0f4be4..336d34bc11 100644 --- a/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts +++ b/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts @@ -102,19 +102,43 @@ function getAttachmentFallbackText( ): string { switch (type) { case 'image': - return t('imageCount', { count }); + return t('messagePreview.latestMessagePreview.imageCount.label', { + count, + defaultValue_one: 'Image', + defaultValue_other: '{{ count }} images', + }); case 'video': - return t('videoCount', { count }); + return t('messagePreview.latestMessagePreview.videoCount.label', { + count, + defaultValue_one: 'Video', + defaultValue_other: '{{ count }} videos', + }); case 'voice': - return t('voiceMessageCount', { count }); + return t('messagePreview.latestMessagePreview.voiceMessageCount.label', { + count, + defaultValue_one: 'Voice message', + defaultValue_other: '{{ count }} voice messages', + }); case 'link': - return t('linkCount', { count }); + return t('messagePreview.latestMessagePreview.linkCount.label', { + count, + defaultValue_one: 'Link', + defaultValue_other: '{{ count }} links', + }); case 'file': - return t('fileCount', { count }); + return t('messagePreview.latestMessagePreview.fileCount.label', { + count, + defaultValue_one: 'File', + defaultValue_other: '{{ count }} files', + }); case 'unsupported': - return t('Unsupported attachment'); + return t('common.unsupportedAttachment.text', 'Unsupported attachment'); default: - return t('fileCount', { count }); + return t('messagePreview.latestMessagePreview.fileCount.label', { + count, + defaultValue_one: 'File', + defaultValue_other: '{{ count }} files', + }); } } @@ -141,11 +165,20 @@ export const useLatestMessagePreview = ({ return useMemo(() => { if (!latestMessage) { - return { text: t('Nothing yet...'), type: 'empty' as const }; + return { + text: t('common.nothingYet.text', 'Nothing yet...'), + type: 'empty' as const, + }; } if (latestMessage.status === 'failed' || latestMessage.type === 'error') { - return { text: t('Message failed to send'), type: 'error' as const }; + return { + text: t( + 'messagePreview.latestMessagePreview.messageFailedSend.text', + 'Message failed to send', + ), + type: 'error' as const, + }; } const isOwnMessage = latestMessage.user?.id === client.user?.id; @@ -157,7 +190,7 @@ export const useLatestMessagePreview = ({ let senderName: string | undefined; if (isOwnMessage) { - senderName = t('You'); + senderName = t('common.you.label', 'You'); } else if (!isOwnMessage && participantCount !== undefined && participantCount > 2) { senderName = latestMessage.user?.name || latestMessage.user?.id; } @@ -166,7 +199,7 @@ export const useLatestMessagePreview = ({ return { deliveryStatus, senderName, - text: t('Message deleted'), + text: t('common.messageDeleted.text', 'Message deleted'), type: 'deleted' as const, }; } @@ -175,7 +208,7 @@ export const useLatestMessagePreview = ({ return { deliveryStatus, senderName, - text: t('Poll'), + text: t('common.poll.label', 'Poll'), type: 'poll' as const, }; } @@ -188,7 +221,7 @@ export const useLatestMessagePreview = ({ return { deliveryStatus, senderName, - text: textContent || t('Location'), + text: textContent || t('common.location.text', 'Location'), type: 'location' as const, }; } @@ -255,7 +288,10 @@ export const useLatestMessagePreview = ({ }; } - return { text: t('Empty message...'), type: 'empty' as const }; + return { + text: t('common.emptyMessage.text', 'Empty message...'), + type: 'empty' as const, + }; }, [ client.user?.id, latestMessage, diff --git a/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx b/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx index d6f1e7105f..f9e0aedcaa 100644 --- a/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx @@ -19,8 +19,8 @@ export const BroadcastMentionItem = ({ const { t } = useTranslationContext(); const description = entity.mentionType === 'channel' - ? t('mention/Channel Description') - : t('mention/Here Description'); + ? t('mention.channel.description', 'Notify everyone in this channel') + : t('mention.here.description', 'Notify every online member in this channel'); return ( title: `@${role}`, }} selected={focused} - subtitle={t('Notify all {{ role }} members', { role })} + subtitle={t( + 'textareaComposer.roleItem.notifyMembers.label', + 'Notify all {{ role }} members', + { role }, + )} subtitleClassName='str-chat__suggestion-list__item-details' title={ diff --git a/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx b/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx index 1eefd06918..14ef7086e9 100644 --- a/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx +++ b/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx @@ -260,13 +260,22 @@ export const SuggestionList = ({ const suggestionMenuLabel = useMemo(() => { switch (suggestions?.searchSource.type) { case 'commands': - return t('aria/Command Suggestions'); + return t( + 'textareaComposer.suggestionList.commandSuggestions.ariaLabel', + 'Command Suggestions', + ); case 'emoji': - return t('aria/Emoji Suggestions'); + return t( + 'textareaComposer.suggestionList.emojiSuggestions.ariaLabel', + 'Emoji Suggestions', + ); case 'mentions': - return t('aria/Mention Suggestions'); + return t( + 'textareaComposer.suggestionList.mentionSuggestions.ariaLabel', + 'Mention Suggestions', + ); default: - return t('aria/Suggestions'); + return t('textareaComposer.suggestionList.suggestions.ariaLabel', 'Suggestions'); } }, [suggestions?.searchSource.type, t]); diff --git a/src/components/TextareaComposer/TextareaComposer.tsx b/src/components/TextareaComposer/TextareaComposer.tsx index 23ee470ffa..33b913bd3a 100644 --- a/src/components/TextareaComposer/TextareaComposer.tsx +++ b/src/components/TextareaComposer/TextareaComposer.tsx @@ -146,7 +146,9 @@ const TextareaComposerWithLiveAnnouncements = ({ // to a stable label instead of the placeholder, which may be a command-specific // template (e.g. mention/command args) that would otherwise be re-announced as a // stale name even though the field already holds real content. - const ariaLabel = text ? t('aria/Message input') : placeholder; + const ariaLabel = text + ? t('textareaComposer.messageInput.ariaLabel', 'Message input') + : placeholder; // react-textarea-autosize can measure placeholder content as multi-line in narrow layouts, // producing an inflated initial height (e.g. 2 rows) before the user types. diff --git a/src/components/TextareaComposer/__tests__/CommandItem.test.tsx b/src/components/TextareaComposer/__tests__/CommandItem.test.tsx index 5dffad064e..a0c34c6263 100644 --- a/src/components/TextareaComposer/__tests__/CommandItem.test.tsx +++ b/src/components/TextareaComposer/__tests__/CommandItem.test.tsx @@ -23,7 +23,25 @@ vi.mock('../../MessageComposer/hooks', () => ({ vi.mock('../../../context', () => ({ useTranslationContext: () => ({ - t: (key: string) => key, + t: (key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }, }), })); diff --git a/src/components/TextareaComposer/__tests__/MentionItem.test.tsx b/src/components/TextareaComposer/__tests__/MentionItem.test.tsx index 1d6ee31a85..94f2b73927 100644 --- a/src/components/TextareaComposer/__tests__/MentionItem.test.tsx +++ b/src/components/TextareaComposer/__tests__/MentionItem.test.tsx @@ -13,6 +13,7 @@ import type { } from '../SuggestionList'; import { MentionItem } from '../SuggestionList'; import { mockTranslationContextValue } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; afterEach(cleanup); @@ -21,10 +22,7 @@ describe('MentionItem', () => { const { container, getByRole, getByText, queryByTestId } = render( - key === 'mention/Channel Description' - ? 'Notify everyone in this channel' - : key, + t: mockT, })} >
    @@ -54,10 +52,7 @@ describe('MentionItem', () => { const { getByText } = render( - key === 'mention/Here Description' - ? 'Notify every online member in this channel' - : key, + t: mockT, })} >
    @@ -81,10 +76,7 @@ describe('MentionItem', () => { const { container, getByRole, getByText, queryByTestId } = render( ) => - key === 'Notify all {{ role }} members' && options?.role - ? `Notify all ${options.role} members` - : key, + t: mockT, })} >
    diff --git a/src/components/TextareaComposer/__tests__/SuggestionList.test.tsx b/src/components/TextareaComposer/__tests__/SuggestionList.test.tsx index 0955911d0b..ae8768243a 100644 --- a/src/components/TextareaComposer/__tests__/SuggestionList.test.tsx +++ b/src/components/TextareaComposer/__tests__/SuggestionList.test.tsx @@ -32,7 +32,25 @@ vi.mock('../../../context', async (importOriginal) => ({ textareaRef: { current: null }, }), useTranslationContext: () => ({ - t: (key: string) => key, + t: (key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }, }), })); @@ -152,7 +170,7 @@ describe('SuggestionList', () => { expect(announceInteractionMock).toHaveBeenCalledWith('suggestions.count', { count: 3, - suggestionsLabel: 'aria/Mention Suggestions', // mentions → localized type label (t mock returns the key) + suggestionsLabel: 'Mention Suggestions', // mentions → localized type label (t mock returns the key) }); }); @@ -161,7 +179,7 @@ describe('SuggestionList', () => { expect(announceInteractionMock).toHaveBeenLastCalledWith('suggestions.count', { count: 3, - suggestionsLabel: 'aria/Mention Suggestions', + suggestionsLabel: 'Mention Suggestions', }); fakeComposerState = buildState(1); @@ -173,7 +191,7 @@ describe('SuggestionList', () => { expect(announceInteractionMock).toHaveBeenLastCalledWith('suggestions.count', { count: 1, - suggestionsLabel: 'aria/Mention Suggestions', + suggestionsLabel: 'Mention Suggestions', }); }); @@ -195,7 +213,7 @@ describe('SuggestionList', () => { expect(announceInteractionMock).toHaveBeenCalledTimes(2); expect(announceInteractionMock).toHaveBeenLastCalledWith('suggestions.count', { count: 3, - suggestionsLabel: 'aria/Mention Suggestions', + suggestionsLabel: 'Mention Suggestions', }); }); @@ -204,7 +222,7 @@ describe('SuggestionList', () => { renderSuggestionList(); expect(announceInteractionMock).toHaveBeenLastCalledWith('suggestions.count', { count: 2, - suggestionsLabel: 'aria/Emoji Suggestions', + suggestionsLabel: 'Emoji Suggestions', }); }); @@ -213,7 +231,7 @@ describe('SuggestionList', () => { renderSuggestionList(); expect(announceInteractionMock).toHaveBeenLastCalledWith('suggestions.count', { count: 2, - suggestionsLabel: 'aria/Suggestions', + suggestionsLabel: 'Suggestions', }); }); @@ -231,7 +249,7 @@ describe('SuggestionList', () => { const listbox = container.querySelector('[role="listbox"]'); expect(listbox).toBeInTheDocument(); // The listbox carries the localized type label as its accessible name. - expect(listbox).toHaveAttribute('aria-label', 'aria/Mention Suggestions'); + expect(listbox).toHaveAttribute('aria-label', 'Mention Suggestions'); const options = container.querySelectorAll('[role="option"]'); expect(options.length).toBe(3); diff --git a/src/components/TextareaComposer/hooks/useTextareaPlaceholder.ts b/src/components/TextareaComposer/hooks/useTextareaPlaceholder.ts index d2f472c476..248ff3958d 100644 --- a/src/components/TextareaComposer/hooks/useTextareaPlaceholder.ts +++ b/src/components/TextareaComposer/hooks/useTextareaPlaceholder.ts @@ -4,6 +4,7 @@ import { useMessageComposerContext, useTranslationContext } from '../../../conte import { useStateStore } from '../../../store'; import { useCooldownRemaining } from '../../MessageComposer/hooks/useCooldownRemaining'; import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; +import { asDynamicKey } from '../../../i18n/utils'; type UseTextareaPlaceholderProps = { placeholder?: string; @@ -25,25 +26,34 @@ export const useTextareaPlaceholder = ({ const knownArgsTranslations = useMemo>( () => ({ - ban: t('ban-command-args'), - giphy: t('giphy-command-args'), - mute: t('mute-command-args'), - unban: t('unban-command-args'), - unmute: t('unmute-command-args'), + ban: t('command.ban.args', '[@username] [text]'), + giphy: t('command.giphy.args', '[text]'), + mute: t('command.mute.args', '[@username]'), + unban: t('command.unban.args', '[@username]'), + unmute: t('command.unmute.args', '[@username]'), }), [t], ); const commandArgs = - command?.args && (knownArgsTranslations[command.name ?? ''] ?? t(command.args)); + command?.args && + (knownArgsTranslations[command.name ?? ''] ?? t(asDynamicKey(command.args))); const commandPlaceholder = - command?.name === 'giphy' ? t('Search GIFs') : (commandArgs ?? undefined); + command?.name === 'giphy' + ? t('textareaComposer.textareaPlaceholder.searchGiFs.label', 'Search GIFs') + : (commandArgs ?? undefined); const defaultPlaceholder = - placeholder ?? additionalTextareaProps?.placeholder ?? t('Send a message'); + placeholder ?? + additionalTextareaProps?.placeholder ?? + t('textareaComposer.textareaPlaceholder.sendMessage.label', 'Send a message'); if (cooldownRemaining) { - return t('Slow mode, wait {{ seconds }}s...', { seconds: cooldownRemaining }); + return t( + 'textareaComposer.textareaPlaceholder.slowModeWaitS.label', + 'Slow mode, wait {{ seconds }}s...', + { seconds: cooldownRemaining }, + ); } return commandPlaceholder ?? defaultPlaceholder; diff --git a/src/components/Thread/ThreadHeader.tsx b/src/components/Thread/ThreadHeader.tsx index 664155baf9..85329f7305 100644 --- a/src/components/Thread/ThreadHeader.tsx +++ b/src/components/Thread/ThreadHeader.tsx @@ -47,7 +47,11 @@ const ThreadHeaderSubtitle = ({ ({ parent_id, user }) => user?.id !== client.user?.id && parent_id === parentId, ); const hasTyping = channelConfig?.typing_events !== false && typingInThread.length > 0; - const replyCountText = t('replyCount', { count: replyCount ?? 0 }); + const replyCountText = t('common.replyCount.label', { + count: replyCount ?? 0, + defaultValue_one: '1 reply', + defaultValue_other: '{{ count }} replies', + }); const defaultSubtitle = threadDisplayName ? `${threadDisplayName} · ${replyCountText}` : replyCountText; @@ -112,7 +116,9 @@ export const ThreadHeader = (props: ThreadHeaderProps) => { {isThreadsView && HeaderStartContent && }
    -
    {t('Thread')}
    +
    + {t('thread.header.thread.text', 'Thread')} +
    {
    diff --git a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx index da3dbfeac1..3c5ebe442f 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx @@ -5,6 +5,7 @@ import type { StreamChat } from 'stream-chat'; import { ThreadList } from '../ThreadList'; import { initClientWithChannels } from '../../../../mock-builders'; +import { mockT } from '../../../../mock-builders/translator'; const mockUseChatContext = vi.fn(); const mockUseComponentContext = vi.fn(); @@ -95,7 +96,7 @@ describe('ThreadList', () => { vi.spyOn(client.threads, 'reload').mockResolvedValue(undefined); mockUseChatContext.mockReturnValue({ client }); mockUseComponentContext.mockReturnValue({}); - mockUseTranslationContext.mockReturnValue({ t: (value: string) => value }); + mockUseTranslationContext.mockReturnValue({ t: mockT }); mockUseStateStore.mockReturnValue({ isLoading: false, threads: [] }); }); @@ -129,7 +130,7 @@ describe('ThreadList', () => { expect(screen.queryByTestId('loading-channels')).not.toBeInTheDocument(); expect(mockVirtuoso).toHaveBeenCalledTimes(1); expect(mockVirtuoso.mock.calls[0][0]).toMatchObject({ - 'aria-label': 'aria/Thread list', + 'aria-label': 'Thread list', role: 'listbox', }); }); diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx index a7f2307693..1fd3b9ca83 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx @@ -5,8 +5,9 @@ import { WithComponents, WorkspaceNavigationProvider } from '../../../../context import { TranslationProvider } from '../../../../context/TranslationContext'; import { mockTranslationContextValue } from '../../../../mock-builders'; import { ThreadListHeader } from '../ThreadListHeader'; +import { mockT } from '../../../../mock-builders/translator'; -const t = vi.fn((key: string) => key); +const t = vi.fn(mockT); const HeaderEndContent = () =>
    ; afterEach(cleanup); diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx index e2604f2ac3..7d22f0d6da 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { axe } from '../../../../../axe-helper'; import { ThreadListItemUI } from '../ThreadListItemUI'; +import { mockT } from '../../../../mock-builders/translator'; const { announceInteraction } = vi.hoisted(() => ({ announceInteraction: vi.fn() })); @@ -77,16 +78,7 @@ describe('ThreadListItemUI', () => { beforeEach(() => { mockUseChatContext.mockReturnValue({ client: { userID: 'martin' } }); mockUseTranslationContext.mockReturnValue({ - t: (key: string, values?: Record) => { - if (key === 'replyCount') return `${values?.count ?? 0} replies`; - const interpolated = Object.entries(values ?? {}).reduce( - (value, [name, arg]) => value.replace(`{{ ${name} }}`, String(arg)), - key, - ); - return interpolated.startsWith('aria/') - ? interpolated.replace('aria/', '') - : interpolated; - }, + t: mockT, tDateTimeParser: () => 'recently', userLanguage: 'en', }); diff --git a/src/components/Threads/ThreadList/__tests__/utils.a11y.test.ts b/src/components/Threads/ThreadList/__tests__/utils.a11y.test.ts index ce321cdbd3..a319664afa 100644 --- a/src/components/Threads/ThreadList/__tests__/utils.a11y.test.ts +++ b/src/components/Threads/ThreadList/__tests__/utils.a11y.test.ts @@ -3,23 +3,13 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { LocalMessage, StreamChat } from 'stream-chat'; import type { TranslationContextValue } from '../../../../context/TranslationContext'; +import { mockT } from '../../../../mock-builders/translator'; import { composeThreadListItemAccessibleLabel, DEFAULT_THREAD_LIST_ITEM_LABEL_ORDER, } from '../utils.a11y'; -// Mirrors the natural-language fallback: interpolate {{ name }} and drop the `aria/` prefix. The -// `replyCount` plural key carries no placeholder in the key itself, so special-case it. -const t = ((key: string, opts?: Record) => { - if (key === 'replyCount') return `${opts?.count} replies`; - const interpolated = Object.entries(opts ?? {}).reduce( - (value, [name, arg]) => value.replace(`{{ ${name} }}`, String(arg)), - key, - ); - return interpolated.startsWith('aria/') - ? interpolated.replace('aria/', '') - : interpolated; -}) as TranslationContextValue['t']; +const t = mockT as TranslationContextValue['t']; const tDateTimeParser = (() => 'recently') as unknown as TranslationContextValue['tDateTimeParser']; @@ -40,7 +30,7 @@ const baseData = { describe('composeThreadListItemAccessibleLabel', () => { it('composes name, unread, parent message, reply count, and time in reading order', () => { expect(composeThreadListItemAccessibleLabel(baseData)).toBe( - 'Chat: General. 2 unread message. Thread: hello world. 3 replies. Last activity: recently', + 'Chat: General. 2 unread messages. Thread: hello world. 3 replies. Last activity: recently', ); }); diff --git a/src/components/Threads/ThreadList/utils.a11y.ts b/src/components/Threads/ThreadList/utils.a11y.ts index 8a24340f49..2dc40eb3f7 100644 --- a/src/components/Threads/ThreadList/utils.a11y.ts +++ b/src/components/Threads/ThreadList/utils.a11y.ts @@ -61,7 +61,9 @@ export const defaultThreadListItemLabelParts = { active: activeLabelPart, name: ({ displayTitle, t }) => displayTitle - ? t('aria/Chat: {{ channelName }}', { channelName: displayTitle }) + ? t('threadList.chat.ariaLabel', 'Chat: {{ channelName }}', { + channelName: displayTitle, + }) : undefined, // The message the thread is about (shown as the row's subtitle); same preview the subtitle shows. parentMessage: ({ parentMessagePreview, parentMessageSender, t }) => { @@ -69,11 +71,17 @@ export const defaultThreadListItemLabelParts = { const preview = parentMessageSender ? `${parentMessageSender}: ${parentMessagePreview}` : parentMessagePreview; - return t('aria/Thread: {{ messagePreview }}', { messagePreview: preview }); + return t('threadList.thread.ariaLabel', 'Thread: {{ messagePreview }}', { + messagePreview: preview, + }); }, replyCount: ({ replyCount, t }) => typeof replyCount === 'number' && replyCount > 0 - ? t('replyCount', { count: replyCount }) + ? t('common.replyCount.label', { + count: replyCount, + defaultValue_one: '1 reply', + defaultValue_other: '{{ count }} replies', + }) : undefined, time: ({ latestReply, t, tDateTimeParser }) => { const createdAt = latestReply?.created_at; @@ -82,9 +90,13 @@ export const defaultThreadListItemLabelParts = { messageCreatedAt: createdAt.toISOString(), t, tDateTimeParser, - timestampTranslationKey: 'timestamp/ChannelPreviewTimestamp', + timestampTranslationKey: 'timestamp.ChannelPreviewTimestamp', }); - return when ? t('aria/Last activity: {{ time }}', { time: String(when) }) : undefined; + return when + ? t('common.lastActivity.ariaLabel', 'Last activity: {{ time }}', { + time: String(when), + }) + : undefined; }, unreadCount: unreadCountLabelPart, } satisfies Record; diff --git a/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx b/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx index 2f0d7495e7..e229578dab 100644 --- a/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx +++ b/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx @@ -25,7 +25,7 @@ import { // config from `client.configsStore`. The former TypingContext/ChannelStateContext providers and // scrollToBottom/threadList props are gone. The visible text is now a visually-hidden // `typing-indicator-status` live region whose content comes from `getTypingStatusMessage` -// ('{{ typing }} is typing' / '{{ typing }} are typing' / '{{ count }} people are typing'), so +// ('jessica is typing' / 'jessica and joris are typing' / '3 people are typing'), so // assertions match those keys plus the AvatarStack (capped at 3, with a "+N" overflow badge). // (This replaces the stale TypingIndicator.test.js, whose JSX-in-.js content could not be parsed.) @@ -137,7 +137,7 @@ describe('TypingIndicator', () => { expect(container.firstChild).toHaveClass('str-chat__typing-indicator--typing'); expect(screen.getByTestId('typing-indicator-status')).toHaveTextContent( - '{{ typing }} is typing', + 'jessica is typing', ); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -153,7 +153,7 @@ describe('TypingIndicator', () => { expect(container.firstChild).toHaveClass('str-chat__typing-indicator--typing'); // Own typing entry is filtered out, so a single (foreign) typer remains. expect(screen.getByTestId('typing-indicator-status')).toHaveTextContent( - '{{ typing }} is typing', + 'jessica is typing', ); expect(screen.getAllByTestId('avatar')).toHaveLength(1); const results = await axe(container); @@ -167,7 +167,7 @@ describe('TypingIndicator', () => { joris: { user: { id: 'joris', image: 'joris.jpg' } }, }); expect(screen.getByTestId('typing-indicator-status')).toHaveTextContent( - '{{ typing }} are typing', + 'jessica and joris are typing', ); expect(screen.getAllByTestId('avatar')).toHaveLength(2); const results = await axe(container); @@ -182,7 +182,7 @@ describe('TypingIndicator', () => { margriet: { user: { id: 'margriet', image: 'margriet.jpg' } }, }); expect(screen.getByTestId('typing-indicator-status')).toHaveTextContent( - '{{ count }} people are typing', + '3 people are typing', ); // 3 foreign typers == AvatarStack cap, so all avatars render without an overflow badge. expect(screen.getAllByTestId('avatar')).toHaveLength(3); @@ -200,7 +200,7 @@ describe('TypingIndicator', () => { margriet: { user: { id: 'margriet', image: 'margriet.jpg' } }, }); expect(screen.getByTestId('typing-indicator-status')).toHaveTextContent( - '{{ count }} people are typing', + '4 people are typing', ); // 4 foreign typers exceed the AvatarStack cap of 3 -> overflow badge for the remainder. expect(screen.getAllByTestId('avatar')).toHaveLength(3); diff --git a/src/components/TypingIndicator/utils/__tests__/getTypingStatusMessage.test.ts b/src/components/TypingIndicator/utils/__tests__/getTypingStatusMessage.test.ts index 18038801a1..d8944bf3f7 100644 --- a/src/components/TypingIndicator/utils/__tests__/getTypingStatusMessage.test.ts +++ b/src/components/TypingIndicator/utils/__tests__/getTypingStatusMessage.test.ts @@ -1,12 +1,9 @@ import { getTypingStatusMessage } from '../getTypingStatusMessage'; import type { TypingEntry } from '../../hooks/useDebouncedTypingActive'; +import { mockT } from '../../../../mock-builders/translator'; -const translate = (value: string, options?: Record) => - Object.entries(options || {}).reduce( - (result, [key, optionValue]) => result.replace(`{{ ${key} }}`, String(optionValue)), - value, - ); +const translate = mockT as unknown as Parameters[1]; describe('getTypingStatusMessage', () => { it('formats a single typing user', () => { diff --git a/src/components/TypingIndicator/utils/getTypingStatusMessage.ts b/src/components/TypingIndicator/utils/getTypingStatusMessage.ts index 3c82382d53..977fad817d 100644 --- a/src/components/TypingIndicator/utils/getTypingStatusMessage.ts +++ b/src/components/TypingIndicator/utils/getTypingStatusMessage.ts @@ -1,16 +1,12 @@ +import type { TranslationContextValue } from '../../../context/TranslationContext'; import type { TypingEntry } from '../hooks/useDebouncedTypingActive'; -type TranslationFunction = ( - key: string, - options?: Record, -) => string; - /** * Build a localized typing-status message for screen-reader and inline indicator text. */ export const getTypingStatusMessage = ( displayUsers: readonly TypingEntry[], - t: TranslationFunction, + t: TranslationContextValue['t'], ) => { const namedUsers = displayUsers .map(({ user }) => user?.name?.trim() || user?.id || '') @@ -18,14 +14,18 @@ export const getTypingStatusMessage = ( const count = displayUsers.length; if (count === 1 && namedUsers.length === 1) { - return t('{{ typing }} is typing', { typing: namedUsers[0] }); + return t('typing.singleUser', '{{ typing }} is typing', { typing: namedUsers[0] }); } if (count === 2 && namedUsers.length === 2) { - return t('{{ typing }} are typing', { + return t('typing.twoUsers', '{{ typing }} are typing', { typing: `${namedUsers[0]} and ${namedUsers[1]}`, }); } - return t('{{ count }} people are typing', { count }); + return t('typing.manyUsers', { + count, + defaultValue_one: '{{ count }} person is typing', + defaultValue_other: '{{ count }} people are typing', + }); }; diff --git a/src/components/VideoPlayer/VideoThumbnail.tsx b/src/components/VideoPlayer/VideoThumbnail.tsx index 724fb2a3ac..8b0471071b 100644 --- a/src/components/VideoPlayer/VideoThumbnail.tsx +++ b/src/components/VideoPlayer/VideoThumbnail.tsx @@ -25,7 +25,7 @@ export const VideoThumbnail = ({ {onPlay ? ( } onChange={handleSearchChange} - placeholder={t('Search')} + placeholder={t('common.search.ariaLabel', 'Search')} type='search' value={searchInput} /> diff --git a/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx b/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx index ef4fc40cb4..dffa6af84b 100644 --- a/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx +++ b/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx @@ -27,7 +27,10 @@ export const SectionNavigatorHeader = (props: SectionNavigatorHeaderProps) => { return (
    diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx index 3c9c8273a3..7847cffdcf 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.tsx @@ -184,7 +184,10 @@ export const ChannelFilesView: React.ComponentType = () = return (
    - + ({ searchSourceActivate: vi.fn(), @@ -192,7 +193,7 @@ describe('ChannelFilesView', () => { mocks.searchSourceOptions.length = 0; vi.mocked(useTranslationContext).mockReturnValue({ - t: (key: string) => key, + t: mockT, tDateTimeParser: (input?: string | number | Date) => Dayjs(input), } as unknown as ReturnType); diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx index 90ef9fb37a..3cc2024d1a 100644 --- a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx @@ -212,7 +212,7 @@ const ChannelMuteAction = () => { addNotification({ context: { channel: targetChannel }, emitter: 'ChannelManagementView', - message: t('Channel unmuted'), + message: t('common.channelUnmuted.text', 'Channel unmuted'), severity: 'success', type: 'api:channel:unmute:success', }), @@ -227,7 +227,10 @@ const ChannelMuteAction = () => { context: { channel: targetChannel }, emitter: 'ChannelManagementView', error: toError(error), - message: t('Error unmuting channel'), + message: t( + 'channelDetail.channelManagementActions.errorUnmutingChannel.text', + 'Error unmuting channel', + ), severity: 'error', type: 'api:channel:unmute:failed', }); @@ -240,7 +243,7 @@ const ChannelMuteAction = () => { addNotification({ context: { channel: targetChannel }, emitter: 'ChannelManagementView', - message: t('Channel muted'), + message: t('common.channelMuted.text', 'Channel muted'), severity: 'success', type: 'api:channel:mute:success', }), @@ -252,7 +255,10 @@ const ChannelMuteAction = () => { context: { channel: targetChannel }, emitter: 'ChannelManagementView', error: toError(error), - message: t('Error muting channel'), + message: t( + 'channelDetail.channelManagementActions.errorMutingChannel.text', + 'Error muting channel', + ), severity: 'error', type: 'api:channel:mute:failed', }); @@ -304,7 +310,11 @@ const ChannelMuteAction = () => { LeadingIcon={optimisticChannelMuted ? MutedActionIcon : MuteActionIcon} RootElement='button' rootProps={rootProps} - title={optimisticChannelMuted ? t('Unmute chat') : t('Mute chat')} + title={ + optimisticChannelMuted + ? t('channelDetail.channelManagementActions.unmuteChat.title', 'Unmute chat') + : t('channelDetail.channelManagementActions.muteChat.title', 'Mute chat') + } TrailingSlot={TrailingSlot} /> ); @@ -335,7 +345,10 @@ const UserMuteAction = () => { addNotification({ context: { channel }, emitter: 'ChannelManagementView', - message: t('User unmuted'), + message: t( + 'channelDetail.channelManagementActions.userUnmuted.text', + 'User unmuted', + ), severity: 'success', type: 'api:user:unmute:success', }), @@ -350,7 +363,10 @@ const UserMuteAction = () => { context: { channel }, emitter: 'ChannelManagementView', error: toError(error), - message: t('Error unmuting user'), + message: t( + 'channelDetail.channelManagementActions.errorUnmutingUser.text', + 'Error unmuting user', + ), severity: 'error', type: 'api:user:unmute:failed', }); @@ -363,7 +379,10 @@ const UserMuteAction = () => { addNotification({ context: { channel }, emitter: 'ChannelManagementView', - message: t('User muted'), + message: t( + 'channelDetail.channelManagementActions.userMuted.text', + 'User muted', + ), severity: 'success', type: 'api:user:mute:success', }), @@ -375,7 +394,10 @@ const UserMuteAction = () => { context: { channel }, emitter: 'ChannelManagementView', error: toError(error), - message: t('Error muting user'), + message: t( + 'channelDetail.channelManagementActions.errorMutingUser.text', + 'Error muting user', + ), severity: 'error', type: 'api:user:mute:failed', }); @@ -426,7 +448,11 @@ const UserMuteAction = () => { LeadingIcon={optimisticUserMuted ? MutedActionIcon : MuteActionIcon} RootElement='button' rootProps={rootProps} - title={optimisticUserMuted ? t('Unmute user') : t('Mute user')} + title={ + optimisticUserMuted + ? t('channelDetail.channelManagementActions.unmuteUser.title', 'Unmute user') + : t('channelDetail.channelManagementActions.muteUser.title', 'Mute user') + } TrailingSlot={TrailingSlot} /> ); @@ -468,7 +494,7 @@ const BlockUserAction = () => { addNotification({ context: { channel }, emitter: 'ChannelManagementView', - message: t('User unblocked'), + message: t('common.userUnblocked.text', 'User unblocked'), severity: 'success', type: 'api:user:unblock:success', }); @@ -477,7 +503,10 @@ const BlockUserAction = () => { context: { channel }, emitter: 'ChannelManagementView', error: toError(error), - message: t('Error unblocking user'), + message: t( + 'channelDetail.channelManagementActions.errorUnblockingUser.text', + 'Error unblocking user', + ), severity: 'error', type: 'api:user:unblock:failed', }); @@ -496,7 +525,7 @@ const BlockUserAction = () => { addNotification({ context: { channel }, emitter: 'ChannelManagementView', - message: t('User blocked'), + message: t('common.userBlocked.text', 'User blocked'), severity: 'success', type: 'api:user:block:success', }); @@ -505,7 +534,10 @@ const BlockUserAction = () => { context: { channel }, emitter: 'ChannelManagementView', error: toError(error), - message: t('Error blocking user'), + message: t( + 'channelDetail.channelManagementActions.errorBlockingUser.text', + 'Error blocking user', + ), severity: 'error', type: 'api:user:block:failed', }); @@ -531,17 +563,29 @@ const BlockUserAction = () => { LeadingIcon={BlockUserActionIcon} RootElement='button' rootProps={rootProps} - title={isBlocked ? t('Unblock') : t('Block user')} + title={ + isBlocked + ? t('common.unblock.ariaLabel', 'Unblock') + : t('channelDetail.channelManagementActions.blockUser.title', 'Block user') + } /> { onCancel={closeBlockUserAlert} onConfirm={isBlocked ? unblockUser : blockUser} testId='channel-detail-block-user-alert' - title={isBlocked ? t('Unblock') : t('Block User')} + title={ + isBlocked + ? t('common.unblock.ariaLabel', 'Unblock') + : t('common.blockUser.title', 'Block User') + } /> @@ -583,7 +631,7 @@ const LeaveChannelAction = () => { addNotification({ context: { channel }, emitter: 'ChannelManagementView', - message: t('Left channel'), + message: t('common.leftChannel.text', 'Left channel'), severity: 'success', type: 'api:channel:leave:success', }); @@ -594,7 +642,7 @@ const LeaveChannelAction = () => { context: { channel }, emitter: 'ChannelManagementView', error: toError(error), - message: t('Failed to leave channel'), + message: t('common.failedLeaveChannel.text', 'Failed to leave channel'), severity: 'error', type: 'api:channel:leave:failed', }); @@ -619,19 +667,28 @@ const LeaveChannelAction = () => { LeadingIcon={LeaveChannelActionIcon} RootElement='button' rootProps={rootProps} - title={t('Leave chat')} + title={t('channelDetail.channelManagementActions.leaveChat.title', 'Leave chat')} /> @@ -664,7 +721,10 @@ const DeleteChatAction = () => { addNotification({ context: { channel }, emitter: 'ChannelManagementView', - message: t('Chat deleted'), + message: t( + 'channelDetail.channelManagementActions.chatDeleted.text', + 'Chat deleted', + ), severity: 'success', type: 'api:channel:delete:success', }); @@ -675,7 +735,10 @@ const DeleteChatAction = () => { context: { channel }, emitter: 'ChannelManagementView', error: toError(error), - message: t('Error deleting chat'), + message: t( + 'channelDetail.channelManagementActions.errorDeletingChat.text', + 'Error deleting chat', + ), severity: 'error', type: 'api:channel:delete:failed', }); @@ -700,14 +763,21 @@ const DeleteChatAction = () => { LeadingIcon={DeleteChatActionIcon} RootElement='button' rootProps={rootProps} - title={t('Delete chat')} + title={t( + 'channelDetail.channelManagementActions.deleteChat.title', + 'Delete chat', + )} /> { onCancel={closeDeleteChatAlert} onConfirm={deleteChat} testId='channel-detail-delete-chat-alert' - title={t('Delete chat')} + title={t( + 'channelDetail.channelManagementActions.deleteChat.title', + 'Delete chat', + )} /> diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx index 429ebf83ab..7e593b70e6 100644 --- a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx @@ -174,7 +174,9 @@ const useChannelManagementEditForm = ({ const resolvedIsDmChannel = isDmChannel({ channel, ownUserId: client.user?.id }); const hasMembersOnline = useChannelHasMembersOnline({ channel }); const isOnline = resolvedIsDmChannel ? hasMembersOnline : undefined; - const nameLabel = resolvedIsDmChannel ? t('Contact name') : t('Group name'); + const nameLabel = resolvedIsDmChannel + ? t('channelDetail.channelManagementView.contactName.label', 'Contact name') + : t('channelDetail.channelManagementView.groupName.label', 'Group name'); // Dirty-tracking baseline; advanced to the saved value on success so the form // is no longer considered dirty (and the Save button hides) after a write. @@ -264,7 +266,10 @@ const useChannelManagementEditForm = ({ operation: 'update', status: 'success', }, - message: t('Changes saved'), + message: t( + 'channelDetail.channelManagementView.changesSaved.text', + 'Changes saved', + ), severity: 'success', }); } catch (error) { @@ -277,7 +282,10 @@ const useChannelManagementEditForm = ({ operation: 'update', status: 'failed', }, - message: t('Failed to save changes'), + message: t( + 'channelDetail.channelManagementView.failedSaveChanges.text', + 'Failed to save changes', + ), severity: 'error', }); } finally { @@ -360,7 +368,10 @@ export const ChannelManagementEditBody = (props: ChannelManagementEditBodyProps) type='button' variant='secondary' > - {t('Upload Picture')} + {t( + 'channelDetail.channelManagementView.uploadPicture.text', + 'Upload Picture', + )} {hasAvatarImage && ( )} - {t('Save')} + {t('channelDetail.channelManagementView.save.text', 'Save')} )} @@ -444,7 +455,10 @@ export const ChannelManagementView = ({ return ( ); }, @@ -461,17 +475,24 @@ export const ChannelManagementView = ({ const headerTitle = isEditMode ? resolvedIsDmChannel - ? t('Edit contact') - : t('Edit group') + ? t('channelDetail.channelManagementView.editContact.label', 'Edit contact') + : t('channelDetail.channelManagementView.editGroup.label', 'Edit group') : resolvedIsDmChannel - ? t('Contact info') - : t('Group info'); + ? t('channelDetail.channelManagementView.contactInfo.label', 'Contact info') + : t('channelDetail.channelManagementView.groupInfo.label', 'Group info'); return (
    setIsEditing(false) : undefined} title={headerTitle} TrailingContent={!isEditMode && canEditChannel ? EditChannelButton : undefined} diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx index a03128e786..eb662725eb 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx @@ -9,10 +9,16 @@ export const ChannelMediaEmptyList = () => {

    - {t('No photos or videos')} + {t( + 'channelDetail.channelMediaEmpty.noPhotosVideos.text', + 'No photos or videos', + )}

    - {t('Share a photo or video to see it here')} + {t( + 'channelDetail.channelMediaEmpty.sharePhotoVideoSee.text', + 'Share a photo or video to see it here', + )}

    diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx index 5e1257e958..e6d32b41c2 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx @@ -62,8 +62,16 @@ const ChannelMediaGridItem = ({ const durationLabel = formatTime(item.durationSeconds, 'floor'); const label = item.type === 'video' - ? t('aria/Open video shared by {{ name }}', { name: displayName }) - : t('aria/Open image shared by {{ name }}', { name: displayName }); + ? t( + 'channelDetail.channelMediaView.openVideoShared.ariaLabel', + 'Open video shared by {{ name }}', + { name: displayName }, + ) + : t( + 'channelDetail.channelMediaView.openImageShared.ariaLabel', + 'Open image shared by {{ name }}', + { name: displayName }, + ); return (
    @@ -273,7 +284,10 @@ export const ChannelMediaView: React.ComponentType = ({ return (
    - +
    diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx index 2afdc62256..15cf994e97 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx @@ -12,6 +12,7 @@ import { import { useStateStore } from '../../../../../store'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { ChannelMediaView } from '../ChannelMediaView'; +import { mockT } from '../../../../../mock-builders/translator'; const mocks = vi.hoisted(() => ({ searchSourceActivate: vi.fn(), @@ -126,7 +127,7 @@ describe('ChannelMediaView', () => { mocks.searchSourceOptions.length = 0; vi.mocked(useTranslationContext).mockReturnValue({ - t: (key: string) => key, + t: mockT, } as ReturnType); vi.mocked(useChatContext).mockReturnValue({ @@ -243,8 +244,8 @@ describe('ChannelMediaView', () => { renderView(); expect(getMediaItems()).toHaveLength(30); - expect(screen.queryByRole('button', { name: 'aria/Previous page' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'aria/Next page' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Previous page' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Next page' })).toBeNull(); }); it('paginates 30 items per page through the previous/next buttons', () => { @@ -258,8 +259,8 @@ describe('ChannelMediaView', () => { // First page: 30 items, previous disabled, next enabled. expect(getMediaItems()).toHaveLength(30); - const previous = screen.getByRole('button', { name: 'aria/Previous page' }); - const next = screen.getByRole('button', { name: 'aria/Next page' }); + const previous = screen.getByRole('button', { name: 'Previous page' }); + const next = screen.getByRole('button', { name: 'Next page' }); expect(previous).toBeDisabled(); expect(next).toBeEnabled(); @@ -286,7 +287,7 @@ describe('ChannelMediaView', () => { renderView(); // First page is full and the source has more, so next stays enabled. - const next = screen.getByRole('button', { name: 'aria/Next page' }); + const next = screen.getByRole('button', { name: 'Next page' }); expect(next).toBeEnabled(); mocks.searchSourceSearch.mockClear(); @@ -343,12 +344,12 @@ describe('ChannelMediaView', () => { // First page shows 10 (not the default 30); 35 items spread across 4 pages. expect(getMediaItems()).toHaveLength(10); - expect(screen.getByRole('button', { name: 'aria/Previous page' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'aria/Next page' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled(); - fireEvent.click(screen.getByRole('button', { name: 'aria/Next page' })); + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); expect(getMediaItems()).toHaveLength(10); - expect(screen.getByRole('button', { name: 'aria/Previous page' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Previous page' })).toBeEnabled(); }); }); diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index b959f6f380..3ba8b14b20 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -238,7 +238,10 @@ const SendDirectMessageAction = () => { context: { channel }, emitter: 'ChannelMemberDetail', error: toError(error), - message: t('Error opening direct message'), + message: t( + 'channelDetail.channelMemberActions.errorOpeningDirectMessage.text', + 'Error opening direct message', + ), severity: 'error', type: 'api:channel:watch:failed', }); @@ -271,7 +274,10 @@ const SendDirectMessageAction = () => { LeadingIcon={SendDirectMessageActionIcon} RootElement='button' rootProps={rootProps} - title={t('Send direct message')} + title={t( + 'channelDetail.channelMemberActions.sendDirectMessage.title', + 'Send direct message', + )} /> ); }; @@ -301,7 +307,10 @@ const UserMuteAction = () => { addNotification({ context: { channel }, emitter: 'ChannelMemberDetail', - message: t('User unmuted'), + message: t( + 'channelDetail.channelManagementActions.userUnmuted.text', + 'User unmuted', + ), severity: 'success', type: 'api:user:unmute:success', }), @@ -315,7 +324,10 @@ const UserMuteAction = () => { context: { channel }, emitter: 'ChannelMemberDetail', error: toError(error), - message: t('Error unmuting user'), + message: t( + 'channelDetail.channelManagementActions.errorUnmutingUser.text', + 'Error unmuting user', + ), severity: 'error', type: 'api:user:unmute:failed', }); @@ -328,7 +340,10 @@ const UserMuteAction = () => { addNotification({ context: { channel }, emitter: 'ChannelMemberDetail', - message: t('User muted'), + message: t( + 'channelDetail.channelManagementActions.userMuted.text', + 'User muted', + ), severity: 'success', type: 'api:user:mute:success', }), @@ -339,7 +354,10 @@ const UserMuteAction = () => { context: { channel }, emitter: 'ChannelMemberDetail', error: toError(error), - message: t('Error muting user'), + message: t( + 'channelDetail.channelManagementActions.errorMutingUser.text', + 'Error muting user', + ), severity: 'error', type: 'api:user:mute:failed', }); @@ -386,7 +404,11 @@ const UserMuteAction = () => { LeadingIcon={optimisticUserMuted ? MemberUnmuteActionIcon : MemberMuteActionIcon} RootElement='button' rootProps={rootProps} - title={optimisticUserMuted ? t('Unmute user') : t('Mute user')} + title={ + optimisticUserMuted + ? t('channelDetail.channelManagementActions.unmuteUser.title', 'Unmute user') + : t('channelDetail.channelManagementActions.muteUser.title', 'Mute user') + } TrailingSlot={TrailingSlot} /> ); @@ -424,7 +446,7 @@ const BlockUserAction = () => { addNotification({ context: { channel }, emitter: 'ChannelMemberDetail', - message: t('User unblocked'), + message: t('common.userUnblocked.text', 'User unblocked'), severity: 'success', type: 'api:user:unblock:success', }); @@ -433,7 +455,10 @@ const BlockUserAction = () => { context: { channel }, emitter: 'ChannelMemberDetail', error: toError(error), - message: t('Error unblocking user'), + message: t( + 'channelDetail.channelManagementActions.errorUnblockingUser.text', + 'Error unblocking user', + ), severity: 'error', type: 'api:user:unblock:failed', }); @@ -452,7 +477,7 @@ const BlockUserAction = () => { addNotification({ context: { channel }, emitter: 'ChannelMemberDetail', - message: t('User blocked'), + message: t('common.userBlocked.text', 'User blocked'), severity: 'success', type: 'api:user:block:success', }); @@ -461,7 +486,10 @@ const BlockUserAction = () => { context: { channel }, emitter: 'ChannelMemberDetail', error: toError(error), - message: t('Error blocking user'), + message: t( + 'channelDetail.channelManagementActions.errorBlockingUser.text', + 'Error blocking user', + ), severity: 'error', type: 'api:user:block:failed', }); @@ -487,27 +515,47 @@ const BlockUserAction = () => { LeadingIcon={BlockUserActionIcon} RootElement='button' rootProps={rootProps} - title={isBlocked ? t('Unblock user') : t('Block user')} + title={ + isBlocked + ? t('channelDetail.channelMemberActions.unblockUser.title', 'Unblock user') + : t('channelDetail.channelManagementActions.blockUser.title', 'Block user') + } /> @@ -540,7 +588,7 @@ const RemoveUserAction = () => { addNotification({ context: { channel }, emitter: 'ChannelMemberDetail', - message: t('User removed'), + message: t('channelDetail.channelMemberActions.userRemoved.text', 'User removed'), severity: 'success', type: 'api:channel:remove-members:success', }); @@ -550,7 +598,10 @@ const RemoveUserAction = () => { context: { channel }, emitter: 'ChannelMemberDetail', error: toError(error), - message: t('Error removing user'), + message: t( + 'channelDetail.channelMemberActions.errorRemovingUser.text', + 'Error removing user', + ), severity: 'error', type: 'api:channel:remove-members:failed', }); @@ -575,21 +626,28 @@ const RemoveUserAction = () => { LeadingIcon={RemoveUserActionIcon} RootElement='button' rootProps={rootProps} - title={t('Remove user')} + title={t('channelDetail.channelMemberActions.removeUser.title', 'Remove user')} /> diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx index 73ae17297d..a0a98562c3 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx @@ -33,17 +33,21 @@ const getPresenceStatusText = ( user: ChannelMemberResponse['user'], t: ReturnType['t'], ) => { - if (user?.online) return t('Online'); + if (user?.online) return t('common.online.label', 'Online'); if (user?.last_active) { - return t('Last seen {{ timestamp }}', { - timestamp: t('timestamp/ChannelMembersLastActive', { - timestamp: user.last_active, - }), - }); + return t( + 'channelDetail.channelMemberDetail.lastSeen.label', + 'Last seen {{ timestamp }}', + { + timestamp: t('timestamp.ChannelMembersLastActive', { + timestamp: user.last_active, + }), + }, + ); } - return t('Offline'); + return t('common.offline.label', 'Offline'); }; export const ChannelMemberDetail = ({ @@ -99,7 +103,11 @@ const ChannelMemberDetailContent = ({ return (
    - +
    { vi.clearAllMocks(); vi.mocked(useTranslationContext).mockReturnValue({ - t: (key: string, options?: { timestamp?: string }) => - options?.timestamp ? `${key}:${options.timestamp}` : key, + t: mockT, } as ReturnType); vi.mocked(useChatContext).mockReturnValue({ diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx index 88e4f26c89..5454ee4d0a 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx @@ -112,7 +112,11 @@ const ChannelMembersAddViewItem = ({ @@ -207,7 +211,11 @@ export const ChannelMembersAddView = ({ () => function ChannelMembersAddEmptyPlaceholder() { if (isLoading || !users) return null; - return {t('No user found')}; + return ( + + {t('channelDetail.channelMembersAdd.noUserFound.text', 'No user found')} + + ); }, [isLoading, t, users], ); @@ -229,7 +237,11 @@ export const ChannelMembersAddView = ({ addNotification({ context: { channel }, emitter: 'ChannelMembersView', - message: t('{{ count }} members added', { count: selectedUserIds.length }), + message: t('channelDetail.channelMembersAdd.membersAdded.text', { + count: selectedUserIds.length, + defaultValue_one: '{{ count }} member added', + defaultValue_other: '{{ count }} members added', + }), severity: 'success', type: 'api:channel:addMembers:success', }); @@ -242,7 +254,10 @@ export const ChannelMembersAddView = ({ context: { channel }, emitter: 'ChannelMembersView', error: error as Error, - message: t('Error adding members'), + message: t( + 'channelDetail.channelMembersAdd.errorAddingMembers.text', + 'Error adding members', + ), severity: 'error', type: 'api:channel:addMembers:failed', }); @@ -267,7 +282,11 @@ export const ChannelMembersAddView = ({ - {t('Add {{ count }} members', { count: selectedUserIds.length })} + {t('channelDetail.channelMembersAdd.addMembers.text', { + count: selectedUserIds.length, + defaultValue_one: 'Add {{ count }} member', + defaultValue_other: 'Add {{ count }} members', + })} diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx index 477bffc0a4..429da89c06 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx @@ -22,10 +22,12 @@ const getMemberRoleTranslation = ( member: ChannelMemberResponse, t: ReturnType['t'], ) => { - if ([member.user?.role, member.channel_role].includes('admin')) return t('Admin'); + if ([member.user?.role, member.channel_role].includes('admin')) + return t('channelDetail.channelMembersBrowse.admin.label', 'Admin'); if (member.channel_role === 'channel_moderator' || member.channel_role === 'moderator') - return t('Moderator'); - if (member.role === 'owner') return t('Owner'); + return t('channelDetail.channelMembersBrowse.moderator.label', 'Moderator'); + if (member.role === 'owner') + return t('channelDetail.channelMembersBrowse.owner.label', 'Owner'); return undefined; }; @@ -34,17 +36,21 @@ const getPresenceStatusText = ( user: ChannelMemberResponse['user'], t: ReturnType['t'], ) => { - if (user?.online) return t('Online'); + if (user?.online) return t('common.online.label', 'Online'); if (user?.last_active) { - return t('Last seen {{ timestamp }}', { - timestamp: t('timestamp/ChannelMembersLastActive', { - timestamp: user.last_active, - }), - }); + return t( + 'channelDetail.channelMemberDetail.lastSeen.label', + 'Last seen {{ timestamp }}', + { + timestamp: t('timestamp.ChannelMembersLastActive', { + timestamp: user.last_active, + }), + }, + ); } - return t('Offline'); + return t('common.offline.label', 'Offline'); }; const ChannelMembersBrowseViewItem = ({ @@ -94,9 +100,13 @@ const ChannelMembersBrowseViewItem = ({ const rootProps = useMemo( () => ({ - 'aria-label': t('View member details for {{ member }}', { - member: displayName, - }), + 'aria-label': t( + 'channelDetail.channelMembersBrowse.viewMemberDetails.ariaLabel', + 'View member details for {{ member }}', + { + member: displayName, + }, + ), className: 'str-chat__channel-detail__channel-members-view__list-item', onClick: () => onMemberSelect?.(member), }), @@ -160,7 +170,14 @@ export const ChannelMembersBrowseView = ({ const EmptyPlaceholder = useMemo( () => function ChannelMembersEmptyPlaceholder() { - return {t('No member found')}; + return ( + + {t( + 'channelDetail.channelMembersBrowse.noMemberFound.text', + 'No member found', + )} + + ); }, [t], ); diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx index e125f3c5d0..dec4cdd36a 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx @@ -75,13 +75,16 @@ const AddMembersHeaderAction = ({ return ( ); }; @@ -96,14 +99,17 @@ const AddMembersMenuAction = ({ return ( { modeController.setMode('add'); closeMenu?.(); }} > - {t('Add')} + {t('channelDetail.channelMembersHeader.add.text', 'Add')} ); }; @@ -136,14 +142,17 @@ export const DefaultHeaderActionsMenuTrigger = ({ return ( ); }; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx index 0c80c9f694..5f4bb6e162 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersView.tsx @@ -65,7 +65,7 @@ const isReservedMode = (mode: ChannelMembersViewMode) => RESERVED_MODES.includes const AddMembersModeTitle = () => { const { t } = useTranslationContext(); - return <>{t('Add members')}; + return <>{t('channelDetail.channelMembersView.addMembers.label', 'Add members')}; }; /** Built-in mode descriptors. Merged with (and overridable by) the `modeViews` prop. */ @@ -159,13 +159,24 @@ export const ChannelMembersView = ({
    ) : ( - t('{{ count }} members', { count: memberCount }) + t('channelDetail.channelMembersView.members.title', { + count: memberCount, + defaultValue_one: '{{ count }} member', + defaultValue_other: '{{ count }} members', + }) ) } TrailingContent={HeaderTrailingActions} diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx index 630656a724..3fb6a7e34c 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx @@ -17,6 +17,7 @@ import { querySelectableMemberButton, renderWithChannel, } from './testUtils'; +import { mockT } from '../../../../../mock-builders/translator'; const mocks = vi.hoisted(() => ({ virtuosoRenderCount: 0, @@ -88,8 +89,7 @@ describe('ChannelMembersAddView', () => { mocks.virtuosoRenderCount = 0; vi.mocked(useTranslationContext).mockReturnValue({ - t: (key: string, options?: { count?: number }) => - options?.count ? `${key}:${options.count}` : key, + t: mockT, } as ReturnType); vi.mocked(useChatContext).mockReturnValue({ @@ -148,11 +148,9 @@ describe('ChannelMembersAddView', () => { fireEvent.click(getSelectableMemberButton('Bob')); fireEvent.click(getSelectableMemberButton('Carol')); - expect( - screen.getByRole('button', { name: 'Add {{ count }} members:2' }), - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add 2 members' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Add {{ count }} members:2' })); + fireEvent.click(screen.getByRole('button', { name: 'Add 2 members' })); await waitFor(() => { expect(channel.addMembers).toHaveBeenCalledWith(['user-2', 'user-3']); @@ -204,16 +202,14 @@ describe('ChannelMembersAddView', () => { ); fireEvent.click(getSelectableMemberButton('Bob')); - expect( - screen.getByRole('button', { name: /Add {{ count }} members/ }), - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Add \d+ members?/ })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /Add {{ count }} members/ })); + fireEvent.click(screen.getByRole('button', { name: /Add \d+ members?/ })); await waitFor(() => expect(channel.addMembers).toHaveBeenCalledWith(['user-2'])); expect( - screen.queryByRole('button', { name: /Add {{ count }} members/ }), + screen.queryByRole('button', { name: /Add \d+ members?/ }), ).not.toBeInTheDocument(); }); @@ -235,7 +231,7 @@ describe('ChannelMembersAddView', () => { document.querySelector('.str-chat__channel-detail__channel-members-view__checkbox'), ).not.toBeInTheDocument(); expect( - screen.queryByRole('button', { name: /Add {{ count }} members/ }), + screen.queryByRole('button', { name: /Add \d+ members?/ }), ).not.toBeInTheDocument(); }); diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx index 2c6cf0afde..b20b98d36a 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx @@ -10,6 +10,7 @@ import { import { useStateStore } from '../../../../../store'; import { ChannelMembersBrowseView } from '../ChannelMembersBrowseView'; import { createChannel, emitChannelEvent, renderWithChannel } from './testUtils'; +import { mockT } from '../../../../../mock-builders/translator'; const mocks = vi.hoisted(() => ({ searchSourceActivate: vi.fn(), @@ -115,11 +116,7 @@ describe('ChannelMembersBrowseView', () => { mocks.searchSourceOptions.length = 0; vi.mocked(useTranslationContext).mockReturnValue({ - t: (key: string, options?: { count?: number; timestamp?: string }) => { - if (options?.count) return `${key}:${options.count}`; - if (options?.timestamp) return `${key}:${options.timestamp}`; - return key; - }, + t: mockT, } as ReturnType); vi.mocked(useChatContext).mockReturnValue({ mutes: [], diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx index e9fb24cbd2..7819ed114c 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersHeaderActions.defaults.test.tsx @@ -19,6 +19,7 @@ import type { ChannelMembersModeController, ChannelMembersViewMode, } from '../ChannelMembersView'; +import { mockT } from '../../../../../mock-builders/translator'; vi.mock('../../../../../context'); @@ -75,7 +76,7 @@ describe('ChannelMembersHeaderActions.defaults', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(useTranslationContext).mockReturnValue({ - t: (key: string) => key, + t: mockT, } as ReturnType); vi.mocked(useModalContext).mockReturnValue({} as ReturnType); vi.mocked(useComponentContext).mockReturnValue( diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx index 29d170ef5e..f9fdbcfa89 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx @@ -14,6 +14,7 @@ import { import type { ChannelMembersHeaderActionItem } from '../ChannelMembersHeaderActions.defaults'; import { useChannelMemberCount } from '../useChannelMemberCount'; import { createChannel, renderWithChannel } from './testUtils'; +import { mockT } from '../../../../../mock-builders/translator'; vi.mock('../useChannelMemberCount'); @@ -209,8 +210,7 @@ describe('ChannelMembersView', () => { vi.clearAllMocks(); vi.mocked(useTranslationContext).mockReturnValue({ - t: (key: string, options?: { count?: number }) => - options?.count ? `${key}:${options.count}` : key, + t: mockT, } as ReturnType); vi.mocked(useModalContext).mockReturnValue({ @@ -347,9 +347,7 @@ describe('ChannelMembersView', () => { fireEvent.click(screen.getByRole('button', { name: 'Go back' })); expect(screen.getByTestId('channel-members-browse-view')).toBeInTheDocument(); - expect( - screen.getByRole('heading', { name: '{{ count }} members:2' }), - ).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '2 members' })).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Remove channel members' }), ).toBeInTheDocument(); diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx index 210c9610a2..54ca916f12 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx @@ -9,10 +9,16 @@ export const PinnedMessagesEmptyList = () => {

    - {t('No pinned messages')} + {t( + 'channelDetail.pinnedMessagesEmpty.noPinnedMessages.text', + 'No pinned messages', + )}

    - {t('Pin a message to see it here')} + {t( + 'channelDetail.pinnedMessagesEmpty.pinMessageSee.text', + 'Pin a message to see it here', + )}

    diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx index bdf9ef302a..a1babb1cf7 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx @@ -45,7 +45,10 @@ const getPinnedMessagePreview = ( const attachmentPreview = attachment?.title || attachment?.text || attachment?.fallback || attachment?.type; - return attachmentPreview || t('Pinned message'); + return ( + attachmentPreview || + t('channelDetail.pinnedMessagesView.pinnedMessage.label', 'Pinned message') + ); }; const PinnedMessageDate = ({ message }: { message: PinnedMessage }) => { @@ -58,7 +61,7 @@ const PinnedMessageDate = ({ message }: { message: PinnedMessage }) => { messageCreatedAt: normalizedTimestamp, t, tDateTimeParser, - timestampTranslationKey: 'timestamp/ChannelDetailPinnedMessageTimestamp', + timestampTranslationKey: 'timestamp.ChannelDetailPinnedMessageTimestamp', }), [normalizedTimestamp, t, tDateTimeParser], ); @@ -171,7 +174,12 @@ export const PinnedMessagesView: React.ComponentType = if (!hasPinnedMessages) return ; if (hasSearchResultsLoaded) return ( - {t('No messages found')} + + {t( + 'channelDetail.pinnedMessagesView.noMessagesFound.text', + 'No messages found', + )} + ); return null; }, @@ -192,8 +200,14 @@ export const PinnedMessagesView: React.ComponentType =
    {hasPinnedMessages && ( diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx index 20a437fc35..4b4e8ef579 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx @@ -18,6 +18,7 @@ import { useModalContext, useTranslationContext, } from '../../../../../context'; +import { mockT } from '../../../../../mock-builders/translator'; import { useStateStore } from '../../../../../store'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { PinnedMessagesView } from '../PinnedMessagesView'; @@ -222,11 +223,14 @@ describe('PinnedMessagesView', () => { mocks.searchSourceOptions.length = 0; vi.mocked(useTranslationContext).mockReturnValue({ - t: (key: string, options?: { timestamp?: Date }) => { - if (key === 'timestamp/ChannelDetailPinnedMessageTimestamp') { + t: (key: string, second?: unknown, third?: unknown) => { + const options = (typeof second === 'object' ? second : third) as + | { timestamp?: Date } + | undefined; + if (key === 'timestamp.ChannelDetailPinnedMessageTimestamp') { return options?.timestamp?.toISOString() ?? key; } - return key; + return mockT(key, second as never, third as never); }, tDateTimeParser: (input?: string | Date) => new Date(input ?? Date.now()), } as ReturnType); diff --git a/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx index 17e75dc177..1563a99ce9 100644 --- a/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx @@ -10,7 +10,6 @@ import { defaultChannelManagementActionSet, useBaseChannelManagementActionSetFilter, } from '../Views/ChannelManagementView/ChannelManagementActions.defaults'; - const mocks = vi.hoisted(() => { const addNotification = vi.fn(); const blockUser = vi.fn(); @@ -19,7 +18,26 @@ const mocks = vi.hoisted(() => { const mute = vi.fn(); const muteUser = vi.fn(); const removeMembers = vi.fn(); - const t = vi.fn((key: string) => key); + // Inlined rather than imported: `vi.hoisted` runs before module imports are initialized. + const t = vi.fn((key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }); const unblockUser = vi.fn(); const unmute = vi.fn(); const unmuteUser = vi.fn(); @@ -112,7 +130,11 @@ vi.mock('../../../context', () => ({ }), useModalContext: () => ({ close: mocks.close }), useTranslationContext: () => ({ - t: mocks.useStableTranslationFunction ? mocks.t : (key: string) => mocks.t(key), + // The unstable variant must still forward the inline defaultValue, or every call would + // resolve to the raw key. + t: mocks.useStableTranslationFunction + ? mocks.t + : (...args: unknown[]) => mocks.t(...args), }), })); diff --git a/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx index e877d8df1a..b34305c390 100644 --- a/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx @@ -40,7 +40,27 @@ vi.mock('../../../context', () => ({ Avatar: () =>
    , }), useModalContext: () => ({ close: mocks.close }), - useTranslationContext: () => ({ t: (key: string) => key }), + useTranslationContext: () => ({ + t: (key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }, + }), })); vi.mock('../../../context/ChatContext', () => ({ diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index 4972da647d..758e00a36f 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -124,7 +124,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => {