From 371e65d838eff1a7c917b8b9862e321def8beeb8 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 4 Oct 2026 17:02:47 -0400 Subject: [PATCH 1/8] feat: i18n support Signed-off-by: Adam Setch --- .github/workflows/lint.yml | 3 + .vscode/extensions.json | 1 + CONTRIBUTING.md | 20 + i18next.config.ts | 22 + package.json | 8 + pnpm-lock.yaml | 434 +++++++++++++++ src/renderer/__helpers__/vitest.setup.ts | 18 + src/renderer/components/AllRead.tsx | 6 +- src/renderer/components/Oops.tsx | 4 +- src/renderer/components/Sidebar.tsx | 42 +- .../__snapshots__/AllRead.test.tsx.snap | 4 +- .../components/filters/AccountFilter.tsx | 9 +- .../components/filters/ReasonFilter.tsx | 7 +- .../RequiresDetailedNotificationsWarning.tsx | 8 +- .../filters/ReviewRequestTypeFilter.tsx | 17 +- .../components/filters/SearchFilter.tsx | 24 +- .../components/filters/StateFilter.tsx | 7 +- .../components/filters/SubjectTypeFilter.tsx | 7 +- .../components/filters/UserTypeFilter.tsx | 12 +- .../components/icons/VolumeDownIcon.tsx | 39 +- .../components/icons/VolumeUpIcon.tsx | 35 +- src/renderer/components/layout/AppLayout.tsx | 4 +- .../LoginWithPersonalAccessTokenForm.tsx | 39 +- .../components/metrics/CommentsPill.tsx | 5 +- .../components/metrics/LinkedIssuesPill.tsx | 11 +- .../components/metrics/ParentPill.tsx | 12 +- .../components/metrics/StackedPrsPill.tsx | 5 +- .../notifications/AccountHeader.tsx | 8 +- .../notifications/NotificationHeader.tsx | 4 +- .../notifications/NotificationRow.test.tsx | 18 +- .../notifications/NotificationRow.tsx | 23 +- .../notifications/RepositoryHeader.tsx | 8 +- .../__snapshots__/AccountHeader.test.tsx.snap | 2 +- src/renderer/components/primitives/Header.tsx | 4 +- .../settings/AppearanceSettings.test.tsx | 35 +- .../settings/AppearanceSettings.tsx | 128 +++-- .../settings/NotificationSettings.tsx | 254 +++++---- .../components/settings/SettingsFooter.tsx | 12 +- .../components/settings/SettingsReset.tsx | 17 +- .../components/settings/SystemSettings.tsx | 178 +++--- .../components/settings/TraySettings.tsx | 50 +- src/renderer/constants.ts | 1 + src/renderer/hooks/useNotifications.test.tsx | 12 +- src/renderer/hooks/useNotifications.ts | 8 +- src/renderer/i18n/index.ts | 4 + src/renderer/i18n/loader.test.ts | 32 ++ src/renderer/i18n/loader.ts | 48 ++ src/renderer/i18n/locales/en.json | 505 ++++++++++++++++++ src/renderer/i18n/types.ts | 21 + src/renderer/index.tsx | 1 + src/renderer/routes/AccountScopes.tsx | 18 +- src/renderer/routes/Accounts.test.tsx | 14 +- src/renderer/routes/Accounts.tsx | 58 +- src/renderer/routes/Filters.tsx | 8 +- src/renderer/routes/Login.tsx | 17 +- src/renderer/routes/Notifications.test.tsx | 10 +- src/renderer/routes/Notifications.tsx | 6 +- src/renderer/routes/Settings.tsx | 5 +- .../routes/__snapshots__/Login.test.tsx.snap | 3 +- .../__snapshots__/Settings.test.tsx.snap | 51 ++ .../LoginWithPersonalAccessToken.tsx | 39 +- .../gitea/LoginWithPersonalAccessToken.tsx | 35 +- .../routes/github/LoginWithDeviceFlow.tsx | 104 ++-- .../routes/github/LoginWithGitHubCLI.tsx | 52 +- .../routes/github/LoginWithOAuthApp.tsx | 72 ++- .../github/LoginWithPersonalAccessToken.tsx | 58 +- .../LoginWithGitHubCLI.test.tsx.snap | 9 +- .../gitlab/LoginWithPersonalAccessToken.tsx | 52 +- ...LoginWithPersonalAccessToken.test.tsx.snap | 6 +- src/renderer/types.ts | 5 + src/renderer/utils/api/errors.test.ts | 28 +- src/renderer/utils/api/errors.ts | 20 +- src/renderer/utils/core/errors.test.ts | 16 +- src/renderer/utils/core/errors.ts | 173 ++++-- src/renderer/utils/forges/labels.ts | 64 +++ .../utils/notifications/filters/reason.ts | 28 +- .../filters/reviewRequestType.ts | 42 +- .../utils/notifications/filters/state.ts | 50 +- .../notifications/filters/subjectType.ts | 101 ++-- .../utils/notifications/filters/userType.ts | 43 +- .../utils/notifications/mutations.test.ts | 10 +- src/renderer/utils/notifications/reason.ts | 178 +++--- src/renderer/utils/ui/display.ts | 13 +- src/renderer/utils/ui/icons.ts | 24 +- 84 files changed, 2742 insertions(+), 846 deletions(-) create mode 100644 i18next.config.ts create mode 100644 src/renderer/i18n/index.ts create mode 100644 src/renderer/i18n/loader.test.ts create mode 100644 src/renderer/i18n/loader.ts create mode 100644 src/renderer/i18n/locales/en.json create mode 100644 src/renderer/i18n/types.ts create mode 100644 src/renderer/utils/forges/labels.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 219f70ed4..a1f01a606 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,6 +27,9 @@ jobs: - name: Run lint and format checks run: vp run check + - name: Run i18n key sync check + run: pnpm i18n:check + lint-actions: name: Lint GitHub Actions [actionlint] runs-on: ubuntu-latest diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 5eef1796a..1dbadf436 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,7 @@ "bradlc.vscode-tailwindcss", "GraphQL.vscode-graphql", "GraphQL.vscode-graphql-syntax", + "lokalise.i18n-ally", "oxc.oxc-vscode", "SonarSource.sonarlint-vscode", "vitest.explorer", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d04bac4bb..2ce9ac874 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,24 @@ guard fails the run outside Linux rather than letting that happen. Note that these baselines capture the `backdrop-filter` fallback for Glass, not the macOS native vibrancy material, which Chromium cannot render. +### Translations + +Gitify's interface is translated through [i18next][i18next-website]. The app ships English as the only locale today, but the infrastructure supports additional languages — adding one is a contributor-friendly change. + +- Translation files live in `src/renderer/i18n/locales/` (e.g. `en.json`). +- `en.json` is generated from the code, never hand-edited. Change a string where it appears in `src/renderer/`, then sync the locale files: + - `pnpm i18n:extract` — regenerate/update locale files from the `t()` calls in the source + - `pnpm i18n:check` — verify the keys are in sync with the code (also runs in CI) + - `pnpm i18n:lint` — flag hardcoded or untranslated UI strings + - `pnpm i18n:status` — locale health summary +- To add a language: + 1. Create `src/renderer/i18n/locales/.json`, copying `en.json` as a starting point. + 2. Register it in `SUPPORTED_LANGUAGES` in `src/renderer/i18n/types.ts` (this also surfaces it in the Appearance → Language selector). + 3. Import it in the `locales` map in `src/renderer/i18n/loader.ts`. + 4. Translate the keys. The [i18n Ally][i18n-ally-website] VSCode extension is handy for working through JSON keys. + 5. Run `pnpm i18n:check` and `pnpm test`. +- Translation keys use `snake_case`, grouped by feature (e.g. `settings.appearance.language`). Prefer whole-sentence keys with `{{placeholder}}` interpolation over string concatenation, and leave brand names (GitHub, GitLab, …) untranslated. + ### Code Style & Conventions - Linting and formatting are configured in `vite.config.mts` (the `lint` and `fmt` blocks). Please run `pnpm check` before submitting a PR. @@ -164,3 +182,5 @@ Currently supported forges: **GitHub** (Cloud, Enterprise Server, Enterprise Clo [github-octicons]: https://primer.style/foundations/icons [homebrew-cask-autobump-workflow]: https://github.com/Homebrew/homebrew-cask/actions/workflows/autobump.yml [vitest-website]: https://vitest.dev/ +[i18next-website]: https://www.i18next.com/ +[i18n-ally-website]: https://i18n-docs.intlify.org/ diff --git a/i18next.config.ts b/i18next.config.ts new file mode 100644 index 000000000..ce8f11deb --- /dev/null +++ b/i18next.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'i18next-cli'; + +import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES } from './src/renderer/i18n'; + +export default defineConfig({ + locales: [...SUPPORTED_LANGUAGES], + extract: { + input: ['src/renderer/**/*.{ts,tsx}'], + output: 'src/renderer/i18n/locales/{{language}}.json', + + defaultNS: false, + primaryLanguage: DEFAULT_LANGUAGE, + + sort: true, + removeUnusedKeys: true, + indentation: 2, + disablePlurals: true, + }, + lint: { + ignore: ['**/*.test.{ts,tsx}'], + }, +}); diff --git a/package.json b/package.json index a91ae6151..fa989b3ec 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,10 @@ "package:win": "electron-builder --win", "check": "vp check", "check:fix": "vp check --fix", + "i18n:extract": "i18next-cli extract --sync-all", + "i18n:check": "i18next-cli extract --ci --quiet", + "i18n:lint": "i18next-cli lint", + "i18n:status": "i18next-cli status", "lint": "vp lint --fix", "lint:check": "vp lint", "format": "vp fmt", @@ -79,8 +83,11 @@ "electron-log": "5.4.4", "electron-menubar": "11.1.0", "electron-updater": "6.8.9", + "i18next": "26.4.2", + "i18next-browser-languagedetector": "8.2.1", "react": "19.3.0", "react-dom": "19.3.0", + "react-i18next": "17.0.15", "react-router-dom": "7.18.4" }, "devDependencies": { @@ -127,6 +134,7 @@ "graphql": "17.0.2", "happy-dom": "20.14.5", "husky": "9.1.7", + "i18next-cli": "1.74.1", "playwright": "1.63.0", "react-devtools": "8.0.0", "rimraf": "6.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c728cf38..0c4cf4779 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,12 +204,21 @@ importers: electron-updater: specifier: 6.8.9 version: 6.8.9(supports-color@10.2.2) + i18next: + specifier: 26.4.2 + version: 26.4.2(typescript@7.0.2) + i18next-browser-languagedetector: + specifier: 8.2.1 + version: 8.2.1 react: specifier: 19.3.0 version: 19.3.0 react-dom: specifier: 19.3.0 version: 19.3.0(react@19.3.0) + react-i18next: + specifier: 17.0.15 + version: 17.0.15(i18next@26.4.2(typescript@7.0.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) react-router-dom: specifier: 7.18.4 version: 7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -343,6 +352,9 @@ importers: husky: specifier: 9.1.7 version: 9.1.7 + i18next-cli: + specifier: 1.74.1 + version: 1.74.1(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) playwright: specifier: 1.63.0 version: 1.63.0 @@ -475,6 +487,12 @@ packages: '@blazediff/core@1.10.0': resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@croct/json5-parser@0.2.2': + resolution: {integrity: sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw==} + + '@croct/json@2.1.0': + resolution: {integrity: sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==} + '@discordapp/twemoji@16.0.1': resolution: {integrity: sha512-figLiBWzjS5cyrAjLaGjM8AAaowO3qvK8rg5bA2dElB4qsaPMvBVlFDMO2d3x+nC1igt7kgWH4dvNmvvUHUF8w==} @@ -1469,10 +1487,110 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@swc/core-darwin-arm64@1.16.13': + resolution: {integrity: sha512-JrMH2h7ohd4mOIneNRx/diqTmCDSdWfp9uOdQ8VJ2V2CA7jOJ1jWb+qp8RoZLqgiVmwDDC7eySKB1kbvZ+jVXQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.16.13': + resolution: {integrity: sha512-HauZMRxpOImRxgY6GXjw5gbD8ts2NcPZuLMM8htY8Mic7j1Izt/lxkXrKYUwqyU56YLwQZ4dHRT+6jFHkztcag==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.16.13': + resolution: {integrity: sha512-tUSojGIythnHJKrElDM9OvuhBnK+FF39eM9iIBgj9kdfbAm6PhAM7dKbaSDcnX2gOLG2slkasjD0Z8m/YUlzOA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.16.13': + resolution: {integrity: sha512-qS3bk/ezRrLXGn1b764S/EG3/oKj6uRhnTNRAALpnuu8d9I0YwSiUkQghnX7lOH9X/yRr+qrFI3R0wAruO47Tg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.16.13': + resolution: {integrity: sha512-aeoYoG6fQsOS3mFOuTKvnJo1aaaxYNI0aRRlhdHaGP4q1YQx4XOc5Hkd35IFduWCBG3eZl3fPTjRuYvxpl7eTA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.16.13': + resolution: {integrity: sha512-eEFoueZXWZ2S1lsqL8Ve2C8RjhzH9+Ktj8U4IGTUQL6CNG04+VhQiYQWJOf5xfVBU6I+PbH80yvsYegqZOOSZQ==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.16.13': + resolution: {integrity: sha512-K0q1ReVXL3d5bzBgZxS+yJq5q2xRjg4k2XXGj9yiCEpE5ZqJGQ4Gtk1sRrk8W2lo3KY0DCJD3Myt6x2SMvGArQ==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.16.13': + resolution: {integrity: sha512-Qvpihw30irhIjy2f91BR1pamcJBCgf8hmr9XWenztAX1YLkneRkkTB/PFGWQubhaBqQhY2uZKViMj/xV61uMBQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.16.13': + resolution: {integrity: sha512-iCGxHbh3Cq5vqRvCPlw3XkkDz4RlkPSyQwfFKbOKTMj5tiEuojY/rLmf4BMfI9Z8yPJTf9VB07t3NbKInoC97g==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.16.13': + resolution: {integrity: sha512-iOabn6cd+hg3cnoKlAC8j3Yufv8nQHBVU5OIDX8dCPA95PjAZ0B0TlDn9sWivk8ezo/cLJux7p9n0B9vRej+1Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.16.13': + resolution: {integrity: sha512-+1b/3xBNuVbpnJIZUCAaSdscOPYRWq85eohKx44y/3qbPp56UMiCJWBiWtyr7K5F1nf1JENXm2IBSEJaLgWq6Q==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.16.13': + resolution: {integrity: sha512-6kEJhYshk7OVoVIWryiHqjCNuwZeOlPTS6et+bKV/WhtHs8fLA01wyTLrQGi+4ultgRe/o0M4b25K/KKdvl0dQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.16.13': + resolution: {integrity: sha512-k4uUsIMS0IwOKVsrQs48IZK75O4roV3FnuJEdL7ES+DFESftdgvz73QBqN3VUAbUmUr78We4tzG0kgYj36M22Q==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -2426,6 +2544,10 @@ packages: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-truncate@5.2.0: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} @@ -2468,6 +2590,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@5.1.0: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} @@ -2784,6 +2910,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + execa@10.0.1: + resolution: {integrity: sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==} + engines: {node: '>=22'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -2836,6 +2966,10 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -2924,6 +3058,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3036,6 +3174,9 @@ packages: hsluv@1.0.1: resolution: {integrity: sha512-zCaFTiDqBLQjCCFBu0qg7z9ASYPd+Bxx2GDCVZJsnehjK80S+jByqhuFz0pCd2Aw3FSKr18AWbRlwnKR0YdizQ==} + html-parse-stringify@4.0.1: + resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -3055,11 +3196,35 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next-cli@1.74.1: + resolution: {integrity: sha512-8OEbo4jfn4r4/QwieyAvoLWV/rJW2OR6SG09I12jA6kffyhUNsyrH6OaA4bumLIWkdBaGySbiFm0wj1k19N/jA==} + engines: {node: '>=22'} + hasBin: true + + i18next-resources-for-ts@2.1.1: + resolution: {integrity: sha512-Xhz8S8dtNIPawzeWdmssnsII1JYsbvPWOLmstJakNcgKSFLCneA+xN7qajj/zlk5nf9sx1KGDTx8RNZ73h68tw==} + hasBin: true + + i18next@26.4.2: + resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -3105,6 +3270,15 @@ packages: resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} engines: {node: '>=10'} + inquirer@14.2.2: + resolution: {integrity: sha512-WGerZIIb7mTtaFFskkD/P0europ8xBWf1OXACMiugzKPSmVzoqhcPSuSUzbzMch/t3rf1ncBT3v/0WU73cLqJQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + internal-ip@6.2.0: resolution: {integrity: sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg==} engines: {node: '>=10'} @@ -3151,6 +3325,10 @@ packages: resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} engines: {node: '>=10'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-ip@3.1.0: resolution: {integrity: sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==} engines: {node: '>=8'} @@ -3171,6 +3349,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-relative@1.0.0: resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==} engines: {node: '>=0.10.0'} @@ -3179,6 +3361,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-typedarray@1.0.0: resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} @@ -3277,6 +3463,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -3700,6 +3889,10 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + ora@9.4.1: + resolution: {integrity: sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==} + engines: {node: '>=20'} + oxfmt@0.70.0: resolution: {integrity: sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3769,6 +3962,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -3856,6 +4053,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} + engines: {node: '>=18'} + proc-log@6.1.0: resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3914,6 +4115,22 @@ packages: peerDependencies: react: ^19.3.0 + react-i18next@17.0.15: + resolution: {integrity: sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-intersection-observer@10.1.0: resolution: {integrity: sha512-V8HDu3+Llg6OEhOxx8LnUSS0t4VS+1Xk9ZatkI8Jct/H0CwKnqFTCu8NT3q7ghJTghTdIrEMPSWr2dkKPG+gdQ==} peerDependencies: @@ -4034,6 +4251,10 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} + engines: {node: '>=0.12.0'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -4161,6 +4382,10 @@ packages: std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + string-env-interpolation@1.0.1: resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==} @@ -4191,6 +4416,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -4494,6 +4723,11 @@ packages: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} + which-command@0.1.0: + resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} + engines: {node: '>=22'} + hasBin: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4763,6 +4997,12 @@ snapshots: '@blazediff/core@1.10.0': {} + '@croct/json5-parser@0.2.2': + dependencies: + '@croct/json': 2.1.0 + + '@croct/json@2.1.0': {} + '@discordapp/twemoji@16.0.1': dependencies: '@twemoji/parser': 16.0.0 @@ -5807,8 +6047,72 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sec-ant/readable-stream@0.4.1': {} + '@sindresorhus/is@4.6.0': {} + '@sindresorhus/merge-streams@4.0.0': {} + + '@swc/core-darwin-arm64@1.16.13': + optional: true + + '@swc/core-darwin-x64@1.16.13': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.16.13': + optional: true + + '@swc/core-linux-arm64-gnu@1.16.13': + optional: true + + '@swc/core-linux-arm64-musl@1.16.13': + optional: true + + '@swc/core-linux-ppc64-gnu@1.16.13': + optional: true + + '@swc/core-linux-s390x-gnu@1.16.13': + optional: true + + '@swc/core-linux-x64-gnu@1.16.13': + optional: true + + '@swc/core-linux-x64-musl@1.16.13': + optional: true + + '@swc/core-win32-arm64-msvc@1.16.13': + optional: true + + '@swc/core-win32-ia32-msvc@1.16.13': + optional: true + + '@swc/core-win32-x64-msvc@1.16.13': + optional: true + + '@swc/core@1.16.13': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.16.13 + '@swc/core-darwin-x64': 1.16.13 + '@swc/core-linux-arm-gnueabihf': 1.16.13 + '@swc/core-linux-arm64-gnu': 1.16.13 + '@swc/core-linux-arm64-musl': 1.16.13 + '@swc/core-linux-ppc64-gnu': 1.16.13 + '@swc/core-linux-s390x-gnu': 1.16.13 + '@swc/core-linux-x64-gnu': 1.16.13 + '@swc/core-linux-x64-musl': 1.16.13 + '@swc/core-win32-arm64-msvc': 1.16.13 + '@swc/core-win32-ia32-msvc': 1.16.13 + '@swc/core-win32-x64-msvc': 1.16.13 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -6602,6 +6906,8 @@ snapshots: dependencies: restore-cursor: 5.1.0 + cli-spinners@3.4.0: {} + cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 @@ -6641,6 +6947,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@15.0.0: {} + commander@5.1.0: {} commander@9.5.0: @@ -6963,6 +7271,21 @@ snapshots: eventemitter3@5.0.4: {} + execa@10.0.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.1 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + which-command: 0.1.0 + yoctocolors: 2.2.0 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -7018,6 +7341,10 @@ snapshots: fflate@0.8.3: {} + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -7120,6 +7447,11 @@ snapshots: get-stream@6.0.1: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -7263,6 +7595,8 @@ snapshots: hsluv@1.0.1: {} + html-parse-stringify@4.0.1: {} + http-cache-semantics@4.2.0: {} http-proxy-agent@7.0.2(supports-color@10.2.2): @@ -7286,8 +7620,53 @@ snapshots: human-signals@2.1.0: {} + human-signals@8.0.1: {} + husky@9.1.7: {} + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.29.7 + + i18next-cli@1.74.1(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2): + dependencies: + '@croct/json5-parser': 0.2.2 + '@swc/core': 1.16.13 + chokidar: 5.0.0 + commander: 15.0.0 + execa: 10.0.1 + glob: 13.0.6 + i18next: 26.4.2(typescript@7.0.2) + i18next-resources-for-ts: 2.1.1 + inquirer: 14.2.2(@types/node@24.19.0) + jiti: 2.7.0 + jsonc-parser: 3.3.1 + magic-string: 1.4.2 + minimatch: 10.2.6 + ora: 9.4.1 + react-i18next: 17.0.15(i18next@26.4.2(typescript@7.0.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + yaml: 2.9.1 + transitivePeerDependencies: + - '@swc/helpers' + - '@types/node' + - react + - react-dom + - react-native + - typescript + + i18next-resources-for-ts@2.1.1: + dependencies: + '@babel/runtime': 7.29.7 + '@swc/core': 1.16.13 + chokidar: 5.0.0 + yaml: 2.9.1 + transitivePeerDependencies: + - '@swc/helpers' + + i18next@26.4.2(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -7320,6 +7699,17 @@ snapshots: ini@2.0.0: {} + inquirer@14.2.2(@types/node@24.19.0): + dependencies: + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@24.19.0) + '@inquirer/prompts': 8.7.2(@types/node@24.19.0) + '@inquirer/type': 4.1.1(@types/node@24.19.0) + mute-stream: 3.0.0 + run-async: 4.0.6 + optionalDependencies: + '@types/node': 24.19.0 + internal-ip@6.2.0: dependencies: default-gateway: 6.0.3 @@ -7363,6 +7753,8 @@ snapshots: global-dirs: 3.0.1 is-path-inside: 3.0.3 + is-interactive@2.0.0: {} + is-ip@3.1.0: dependencies: ip-regex: 4.3.0 @@ -7375,12 +7767,16 @@ snapshots: is-path-inside@3.0.3: {} + is-plain-obj@4.1.0: {} + is-relative@1.0.0: dependencies: is-unc-path: 1.0.0 is-stream@2.0.1: {} + is-stream@4.0.1: {} + is-typedarray@1.0.0: {} is-unc-path@1.0.0: @@ -7449,6 +7845,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -7802,6 +8200,17 @@ snapshots: dependencies: mimic-function: 5.0.1 + ora@9.4.1: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.3.0 + oxfmt@0.70.0(vite-plus@1.0.0(@types/node@24.19.0)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-v8@5.0.1)(@vitest/ui@5.0.1)(happy-dom@20.14.5)(jiti@2.7.0)(typescript@7.0.2)(yaml@2.9.1)): dependencies: tinypool: 2.1.2 @@ -7902,6 +8311,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-ms@4.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -7983,6 +8394,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 19.3.0 + pretty-ms@9.3.1: + dependencies: + parse-ms: 4.0.0 + proc-log@6.1.0: {} process-nextick-args@2.0.1: {} @@ -8054,6 +8469,17 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 + react-i18next@17.0.15(i18next@26.4.2(typescript@7.0.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 4.0.1 + i18next: 26.4.2(typescript@7.0.2) + react: 19.3.0 + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + react-dom: 19.3.0(react@19.3.0) + typescript: 7.0.2 + react-intersection-observer@10.1.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: react: 19.3.0 @@ -8163,6 +8589,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + run-async@4.0.6: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -8263,6 +8691,8 @@ snapshots: std-env@4.3.0: {} + stdin-discarder@0.3.2: {} + string-env-interpolation@1.0.1: {} string-width@4.2.3: @@ -8296,6 +8726,8 @@ snapshots: strip-final-newline@2.0.0: {} + strip-final-newline@4.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -8608,6 +9040,8 @@ snapshots: whatwg-mimetype@5.0.0: {} + which-command@0.1.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 88ed7354e..1e8a1dbc0 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -1,9 +1,15 @@ import '@testing-library/jest-dom/vitest'; +import { Constants } from '../constants'; + // Import store modules directly (not the stores barrel) to keep this setup // file's import graph light. The accounts store pulls in the forge adapter // graph, so it is loaded lazily in beforeEach (after each test file's // vi.mock registrations) to avoid pre-caching unmocked forge modules. import useFiltersStore from '../stores/useFiltersStore'; + +// Initialize the real i18n instance so every test renders resolved English +// text (see design.md - Test strategy). +import '../i18n'; import useSettingsStore from '../stores/useSettingsStore'; /** @@ -117,10 +123,22 @@ function createGitifyBridgeApi(): Window['gitify'] { window.gitify = createGitifyBridgeApi(); +/** + * Pin the browser language so i18n detection is deterministic in tests + * regardless of the machine/CI locale. + */ +Object.defineProperty(navigator, 'language', { + value: 'en', + configurable: true, +}); + /** * Reset stores */ beforeEach(() => { + // Clear any language preference so each test detects/serves English + localStorage.removeItem(Constants.STORAGE.LANGUAGE); + vi.stubGlobal( 'fetch', vi.fn(async (input: RequestInfo | URL) => { diff --git a/src/renderer/components/AllRead.tsx b/src/renderer/components/AllRead.tsx index 608655e8c..a7e664bcd 100644 --- a/src/renderer/components/AllRead.tsx +++ b/src/renderer/components/AllRead.tsx @@ -1,4 +1,5 @@ import { type FC, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { Constants } from '../constants'; @@ -13,11 +14,14 @@ interface AllReadProps { } export const AllRead: FC = ({ fullHeight = true }: AllReadProps) => { + const { t } = useTranslation(); const hasFilters = useFiltersStore((s) => s.hasActiveFilters()); const emoji = useMemo(() => randomElement(Constants.EMOJIS.ALL_READ), []); - const heading = `No new ${hasFilters ? 'filtered ' : ''} notifications`; + const heading = hasFilters + ? t('allRead.heading_filtered', 'No new filtered notifications') + : t('allRead.heading', 'No new notifications'); return ; }; diff --git a/src/renderer/components/Oops.tsx b/src/renderer/components/Oops.tsx index 89a379ef2..900205b23 100644 --- a/src/renderer/components/Oops.tsx +++ b/src/renderer/components/Oops.tsx @@ -7,7 +7,7 @@ import { EmojiSplash } from './layout/EmojiSplash'; import type { GitifyError } from '../types'; -import { Errors } from '../utils/core/errors'; +import { getErrorDetails } from '../utils/core/errors'; import { randomElement } from '../utils/core/random'; interface OopsProps { @@ -18,7 +18,7 @@ interface OopsProps { export const Oops: FC = ({ error, fullHeight = true }: OopsProps) => { const navigate = useNavigate(); - const err = error ?? Errors.UNKNOWN; + const err = error ?? getErrorDetails('UNKNOWN'); const emoji = useMemo(() => randomElement(err.emojis), [err]); diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 6bafb3bba..3e64ba5ad 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { BellIcon, @@ -22,6 +23,7 @@ import { useAccountsStore, useFiltersStore, useSettingsStore } from '../stores'; import { LogoIcon } from './icons/LogoIcon'; export const Sidebar: FC = () => { + const { t } = useTranslation(); const { status, notificationCount, hasUnreadNotifications, isFetching } = useNotifications(); const { shortcuts } = useShortcutActions(); @@ -45,7 +47,7 @@ export const Sidebar: FC = () => { shortcuts.home.action()} @@ -57,9 +59,17 @@ export const Sidebar: FC = () => { {isLoggedIn && ( <> shortcuts.myNotifications.action()} @@ -69,10 +79,12 @@ export const Sidebar: FC = () => { /> { /> shortcuts.filters.action()} @@ -95,7 +107,7 @@ export const Sidebar: FC = () => { /> { /> { {isLoggedIn && ( <> { /> shortcuts.settings.action()} @@ -152,7 +164,9 @@ export const Sidebar: FC = () => { {!isLoggedIn && ( should render itself & its children -
- No new notifications + No new notifications
@@ -96,7 +96,7 @@ exports[`renderer/components/AllRead.tsx > should render itself & its children -
- No new filtered notifications + No new filtered notifications
diff --git a/src/renderer/components/filters/AccountFilter.tsx b/src/renderer/components/filters/AccountFilter.tsx index ff452e7d7..a61f571ca 100644 --- a/src/renderer/components/filters/AccountFilter.tsx +++ b/src/renderer/components/filters/AccountFilter.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { PersonIcon } from '@primer/octicons-react'; import { Stack, Text } from '@primer/react'; @@ -12,6 +13,7 @@ import { Title } from '../primitives/Title'; import { getAccountUUID } from '../../utils/auth/utils'; export const AccountFilter: FC = () => { + const { t } = useTranslation(); const { notifications } = useNotifications(); const accounts = useAccountsStore((s) => s.accounts); const filteredAccounts = useFiltersStore((s) => s.accounts); @@ -19,8 +21,11 @@ export const AccountFilter: FC = () => { return (
- Filter notifications by account.</Text>}> - Account + <Title + icon={PersonIcon} + tooltip={<Text>{t('filters.account.help', 'Filter notifications by account.')}</Text>} + > + {t('filters.account.title', 'Account')} diff --git a/src/renderer/components/filters/ReasonFilter.tsx b/src/renderer/components/filters/ReasonFilter.tsx index d3e3b5a30..f35dd8ac8 100644 --- a/src/renderer/components/filters/ReasonFilter.tsx +++ b/src/renderer/components/filters/ReasonFilter.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { NoteIcon } from '@primer/octicons-react'; import { Text } from '@primer/react'; @@ -7,14 +8,16 @@ import { reasonFilter } from '../../utils/notifications/filters'; import { FilterSection } from './FilterSection'; export const ReasonFilter: FC = () => { + const { t } = useTranslation(); + return ( Filter notifications by reason.} + title={t('filters.reason.title', 'Reason')} + tooltip={{t('filters.reason.help', 'Filter notifications by reason.')}} /> ); }; diff --git a/src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx b/src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx index df4c8efd3..6e9a0c078 100644 --- a/src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx +++ b/src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx @@ -1,10 +1,14 @@ import type { FC } from 'react'; +import { Trans } from 'react-i18next'; import { Text } from '@primer/react'; export const RequiresDetailedNotificationWarning: FC = () => ( - ⚠️ This filter requires the Detailed Notifications setting to be - enabled. + ]} + defaults="⚠️ This filter requires the <0>Detailed Notifications setting to be enabled." + /> ); diff --git a/src/renderer/components/filters/ReviewRequestTypeFilter.tsx b/src/renderer/components/filters/ReviewRequestTypeFilter.tsx index cd30768fe..469406233 100644 --- a/src/renderer/components/filters/ReviewRequestTypeFilter.tsx +++ b/src/renderer/components/filters/ReviewRequestTypeFilter.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { GitPullRequestIcon } from '@primer/octicons-react'; import { Stack, Text } from '@primer/react'; @@ -7,22 +8,28 @@ import { reviewRequestTypeFilter } from '../../utils/notifications/filters'; import { FilterSection } from './FilterSection'; export const ReviewRequestTypeFilter: FC = () => { + const { t } = useTranslation(); + return ( - Filter review requests by whether you were directly requested or requested via a team. - Selecting an option shows only matching review requests. + {t( + 'filters.review_request_type.help', + 'Filter review requests by whether you were directly requested or requested via a team. Selecting an option shows only matching review requests.', + )} - Other shows all notifications that are not a review request, such as mentions, comments, - and assigned issues. + {t( + 'filters.review_request_type.other_help', + 'Other shows all notifications that are not a review request, such as mentions, comments, and assigned issues.', + )} } diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index 3fa33a92e..4850a57d8 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { CheckCircleFillIcon, @@ -28,6 +29,7 @@ import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificat import { TokenSearchInput } from './TokenSearchInput'; export const SearchFilter: FC = () => { + const { t } = useTranslation(); const updateFilter = useFiltersStore((s) => s.updateFilter); const includeSearchTokens = useFiltersStore((s) => s.includeSearchTokens); const excludeSearchTokens = useFiltersStore((s) => s.excludeSearchTokens); @@ -72,7 +74,7 @@ export const SearchFilter: FC = () => { icon={SearchIcon} tooltip={ - Filter notifications by: + {t('filters.search.help', 'Filter notifications by:')}
@@ -80,7 +82,7 @@ export const SearchFilter: FC = () => { - Author (author:handle) + {t('filters.search.author', 'Author (author:handle)')} @@ -88,36 +90,38 @@ export const SearchFilter: FC = () => { - Commenter (commenter:handle) + {t('filters.search.commenter', 'Commenter (commenter:handle)')} - Organization (org:name) + {t('filters.search.organization', 'Organization (org:name)')} - Repository (repo:fullname) + {t('filters.search.repository', 'Repository (repo:fullname)')} - Title (title:text) + {t('filters.search.title', 'Title (title:text)')}
- All qualifiers match case-insensitively. + + {t('filters.search.case_insensitive', 'All qualifiers match case-insensitively.')} +
} > - Search + {t('filters.search.title_short', 'Search')} { { + const { t } = useTranslation(); + return ( Filter notifications by state.} + title={t('filters.state.title', 'State')} + tooltip={{t('filters.state.help', 'Filter notifications by state.')}} /> ); }; diff --git a/src/renderer/components/filters/SubjectTypeFilter.tsx b/src/renderer/components/filters/SubjectTypeFilter.tsx index 4d57fba97..a4140c219 100644 --- a/src/renderer/components/filters/SubjectTypeFilter.tsx +++ b/src/renderer/components/filters/SubjectTypeFilter.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { BellIcon } from '@primer/octicons-react'; import { Text } from '@primer/react'; @@ -7,14 +8,16 @@ import { subjectTypeFilter } from '../../utils/notifications/filters'; import { FilterSection } from './FilterSection'; export const SubjectTypeFilter: FC = () => { + const { t } = useTranslation(); + return ( Filter notifications by type.} + title={t('filters.subject_type.title', 'Type')} + tooltip={{t('filters.subject_type.help', 'Filter notifications by type.')}} /> ); }; diff --git a/src/renderer/components/filters/UserTypeFilter.tsx b/src/renderer/components/filters/UserTypeFilter.tsx index d6c09fcf2..60b7c4307 100644 --- a/src/renderer/components/filters/UserTypeFilter.tsx +++ b/src/renderer/components/filters/UserTypeFilter.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { DependabotIcon, @@ -14,16 +15,18 @@ import { userTypeFilter } from '../../utils/notifications/filters'; import { FilterSection } from './FilterSection'; export const UserTypeFilter: FC = () => { + const { t } = useTranslation(); + return ( - Filter notifications by user type: + {t('filters.user_type.help', 'Filter notifications by user type:')}
@@ -45,7 +48,10 @@ export const UserTypeFilter: FC = () => {
- Other covers notifications with no author or a non-human, non-organization author. + {t( + 'filters.user_type.other_help', + 'Other covers notifications with no author or a non-human, non-organization author.', + )}
} diff --git a/src/renderer/components/icons/VolumeDownIcon.tsx b/src/renderer/components/icons/VolumeDownIcon.tsx index b29824be1..7dd7f3774 100644 --- a/src/renderer/components/icons/VolumeDownIcon.tsx +++ b/src/renderer/components/icons/VolumeDownIcon.tsx @@ -1,19 +1,24 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; -export const VolumeDownIcon: FC = () => ( - -); +export const VolumeDownIcon: FC = () => { + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/src/renderer/components/icons/VolumeUpIcon.tsx b/src/renderer/components/icons/VolumeUpIcon.tsx index cfcffbffd..28b32d905 100644 --- a/src/renderer/components/icons/VolumeUpIcon.tsx +++ b/src/renderer/components/icons/VolumeUpIcon.tsx @@ -1,17 +1,22 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; -export const VolumeUpIcon: FC = () => ( - -); +export const VolumeUpIcon: FC = () => { + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/src/renderer/components/layout/AppLayout.tsx b/src/renderer/components/layout/AppLayout.tsx index 3abf7d653..6f33b92f1 100644 --- a/src/renderer/components/layout/AppLayout.tsx +++ b/src/renderer/components/layout/AppLayout.tsx @@ -1,4 +1,5 @@ import type { FC, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { Sidebar } from '../Sidebar'; @@ -11,6 +12,7 @@ interface AppLayoutProps { * It handles the basic layout with sidebar and content area. */ export const AppLayout: FC = ({ children }) => { + const { t } = useTranslation(); const setFocusRef = (el: HTMLButtonElement | null) => { if (el) { el.focus(); @@ -21,7 +23,7 @@ export const AppLayout: FC = ({ children }) => {
{/* Hidden focus sentinel; grabs initial focus via callback ref */} @@ -318,7 +331,7 @@ export const LoginWithPersonalAccessTokenForm: FC - Login + {t('common.login', 'Login')} diff --git a/src/renderer/components/metrics/CommentsPill.tsx b/src/renderer/components/metrics/CommentsPill.tsx index 9352cfb7e..091e45cca 100644 --- a/src/renderer/components/metrics/CommentsPill.tsx +++ b/src/renderer/components/metrics/CommentsPill.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { CommentIcon } from '@primer/octicons-react'; @@ -12,11 +13,13 @@ export interface CommentsPillProps { } export const CommentsPill: FC = ({ commentCount }) => { + const { t } = useTranslation(); + if (!commentCount) { return null; } - const description = formatMetricDescription(commentCount, 'comment'); + const description = formatMetricDescription(commentCount, t('metrics.comment', 'comment')); return ( = ({ linkedIssues }) => { + const { t } = useTranslation(); + if (!linkedIssues?.length) { return null; } const description = formatMetricDescription( linkedIssues.length, - 'issue', - (_, noun) => `Linked to ${noun}: ${linkedIssues.join(', ')}`, + t('metrics.issue', 'issue'), + (_, noun) => + t('metrics.linked_to', 'Linked to {{noun}}: {{issues}}', { + noun, + issues: linkedIssues.join(', '), + }), ); return ( diff --git a/src/renderer/components/metrics/ParentPill.tsx b/src/renderer/components/metrics/ParentPill.tsx index 6e695dc34..b7f3f9c1d 100644 --- a/src/renderer/components/metrics/ParentPill.tsx +++ b/src/renderer/components/metrics/ParentPill.tsx @@ -1,4 +1,5 @@ import type { FC, MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { IssueTrackedByIcon } from '@primer/octicons-react'; @@ -12,6 +13,8 @@ export interface ParentPillProps { } export const ParentPill: FC = ({ parent }) => { + const { t } = useTranslation(); + if (!parent) { return null; } @@ -21,11 +24,16 @@ export const ParentPill: FC = ({ parent }) => { openExternalLink(parent.url); }; + const label = t('metrics.parent_issue', 'Parent issue: #{{number}} {{title}}', { + number: parent.number, + title: parent.title, + }); + return ( = ({ stackPosition, stackDepth, }) => { + const { t } = useTranslation(); + if (!isStacked) { return null; } @@ -27,7 +30,7 @@ export const StackedPrsPill: FC = ({ return ( diff --git a/src/renderer/components/notifications/AccountHeader.tsx b/src/renderer/components/notifications/AccountHeader.tsx index ff5ef936d..3a2726733 100644 --- a/src/renderer/components/notifications/AccountHeader.tsx +++ b/src/renderer/components/notifications/AccountHeader.tsx @@ -1,4 +1,5 @@ import { type FC, type MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { GitPullRequestIcon, IssueOpenedIcon } from '@primer/octicons-react'; import { Button, Stack } from '@primer/react'; @@ -30,6 +31,7 @@ export const AccountHeader: FC = ({ isCollapsed, onToggle, }) => { + const { t } = useTranslation(); const Chevron = getChevronDetails(notificationCount > 0, !isCollapsed, 'account'); return ( @@ -50,7 +52,7 @@ export const AccountHeader: FC = ({ event.stopPropagation(); openAccountProfile(account); }} - title="Open account profile" + title={t('accounts.open_profile', 'Open account profile')} variant="invisible" > = ({ openHostIssues(account)} icon={IssueOpenedIcon} - label="My issues ↗" + label={t('accounts.my_issues', 'My issues ↗')} testid="account-issues" /> openHostPulls(account)} icon={GitPullRequestIcon} - label="My pull requests ↗" + label={t('accounts.my_pull_requests', 'My pull requests ↗')} testid="account-pull-requests" /> diff --git a/src/renderer/components/notifications/NotificationHeader.tsx b/src/renderer/components/notifications/NotificationHeader.tsx index 94ee086ba..9fd77c8eb 100644 --- a/src/renderer/components/notifications/NotificationHeader.tsx +++ b/src/renderer/components/notifications/NotificationHeader.tsx @@ -1,4 +1,5 @@ import type { FC, MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { Stack } from '@primer/react'; @@ -19,6 +20,7 @@ export interface NotificationHeaderProps { export const NotificationHeader: FC = ({ notification, }: NotificationHeaderProps) => { + const { t } = useTranslation(); const showNumber = useSettingsStore((s) => s.showNumber); return ( @@ -33,7 +35,7 @@ export const NotificationHeader: FC = ({ event.stopPropagation(); openRepository(notification.repository); }} - title="Open repository ↗" + title={t('repository.open', 'Open repository ↗')} type="button" > { useNotificationActionFailuresStore.getState().setFailure(failureKey, { action: 'markAsRead', - error: Errors.ACTION_FORBIDDEN, + error: getErrorDetails('ACTION_FORBIDDEN'), }); renderWithProviders(); @@ -345,7 +345,7 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { expect(markAsReadButton).toBeInTheDocument(); expect(markAsReadButton).toHaveAttribute( 'title', - expect.stringContaining(Errors.ACTION_FORBIDDEN.title), + expect.stringContaining(getErrorDetails('ACTION_FORBIDDEN').title), ); expect(markAsReadButton).toHaveAttribute( 'title', @@ -368,7 +368,7 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { }; useNotificationActionFailuresStore.getState().setFailure(failureKey, { action: 'markAsRead', - error: Errors.ACTION_FORBIDDEN, + error: getErrorDetails('ACTION_FORBIDDEN'), }); renderWithProviders( @@ -384,7 +384,7 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { const [failedButton, unaffectedButton] = screen.getAllByTestId('notification-mark-as-read'); expect(failedButton).toHaveAttribute( 'title', - expect.stringContaining(Errors.ACTION_FORBIDDEN.title), + expect.stringContaining(getErrorDetails('ACTION_FORBIDDEN').title), ); expect(unaffectedButton).toHaveAttribute('title', 'Mark as read'); }); @@ -399,7 +399,7 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { useNotificationActionFailuresStore.getState().setFailure(failureKey, { action: 'markAsDone', - error: Errors.ACTION_FORBIDDEN, + error: getErrorDetails('ACTION_FORBIDDEN'), }); renderWithProviders(, { markNotificationsAsDone: markNotificationsAsDoneMock, @@ -421,7 +421,7 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { useNotificationActionFailuresStore.getState().setFailure(failureKey, { action: 'markAsRead', - error: Errors.ACTION_FORBIDDEN, + error: getErrorDetails('ACTION_FORBIDDEN'), }); renderWithProviders(, { markNotificationsAsRead: markNotificationsAsReadMock, @@ -443,7 +443,7 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { // animate back in, instead of being short-circuited immediately. useNotificationActionFailuresStore.getState().setFailure(failureKey, { action: 'markAsRead', - error: Errors.ACTION_FORBIDDEN, + error: getErrorDetails('ACTION_FORBIDDEN'), }); let resolveRetry: () => void = () => {}; @@ -474,7 +474,7 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { // this notification once the mutation resolves. useNotificationActionFailuresStore.getState().setFailure(failureKey, { action: 'markAsRead', - error: Errors.ACTION_FORBIDDEN, + error: getErrorDetails('ACTION_FORBIDDEN'), }); resolveRetry(); diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx index c5623529b..e1436272a 100644 --- a/src/renderer/components/notifications/NotificationRow.tsx +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -1,4 +1,5 @@ import { type CSSProperties, type FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { BellSlashIcon, CheckIcon, ReadIcon } from '@primer/octicons-react'; import { Stack, Text, Tooltip } from '@primer/react'; @@ -39,6 +40,7 @@ export const NotificationRow: FC = ({ isAnimatingExit, onAnimateExit, }: NotificationRowProps) => { + const { t } = useTranslation(); const { markNotificationsAsRead, markNotificationsAsDone, unsubscribeNotification } = useNotifications(); @@ -59,7 +61,14 @@ export const NotificationRow: FC = ({ // "...instead", since some descriptions already suggest waiting/retrying // (e.g. `RATE_LIMITED`), which "instead" would read as contradicting. const failureTooltip = failure - ? `${failure.error.title}: ${failure.error.descriptions.join(' ')} You can also try opening this notification in the browser.` + ? t( + 'notifications.failure_tooltip', + '{{title}}: {{description}} You can also try opening this notification in the browser.', + { + title: failure.error.title, + description: failure.error.descriptions.join(' '), + }, + ) : undefined; const runAction = async (action: () => Promise) => { @@ -161,7 +170,9 @@ export const NotificationRow: FC = ({ enabled={!isNotificationRead} icon={ReadIcon} label={ - failure?.action === 'markAsRead' ? (failureTooltip ?? 'Mark as read') : 'Mark as read' + failure?.action === 'markAsRead' + ? (failureTooltip ?? t('notifications.mark_as_read', 'Mark as read')) + : t('notifications.mark_as_read', 'Mark as read') } testid="notification-mark-as-read" variant={failure?.action === 'markAsRead' ? 'danger' : 'invisible'} @@ -172,7 +183,9 @@ export const NotificationRow: FC = ({ enabled={isMarkAsDoneFeatureSupported(notification.account) && notification.unread} icon={CheckIcon} label={ - failure?.action === 'markAsDone' ? (failureTooltip ?? 'Mark as done') : 'Mark as done' + failure?.action === 'markAsDone' + ? (failureTooltip ?? t('notifications.mark_as_done', 'Mark as done')) + : t('notifications.mark_as_done', 'Mark as done') } testid="notification-mark-as-done" variant={failure?.action === 'markAsDone' ? 'danger' : 'invisible'} @@ -184,8 +197,8 @@ export const NotificationRow: FC = ({ icon={BellSlashIcon} label={ failure?.action === 'unsubscribe' - ? (failureTooltip ?? 'Unsubscribe from thread') - : 'Unsubscribe from thread' + ? (failureTooltip ?? t('notifications.unsubscribe', 'Unsubscribe from thread')) + : t('notifications.unsubscribe', 'Unsubscribe from thread') } testid="notification-unsubscribe-from-thread" variant={failure?.action === 'unsubscribe' ? 'danger' : 'invisible'} diff --git a/src/renderer/components/notifications/RepositoryHeader.tsx b/src/renderer/components/notifications/RepositoryHeader.tsx index 7327dab74..e11dca75d 100644 --- a/src/renderer/components/notifications/RepositoryHeader.tsx +++ b/src/renderer/components/notifications/RepositoryHeader.tsx @@ -1,4 +1,5 @@ import { type FC, type MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { CheckIcon, ReadIcon } from '@primer/octicons-react'; import { Button, Stack } from '@primer/react'; @@ -35,6 +36,7 @@ export const RepositoryHeader: FC = ({ onToggle, onAnimateExit, }) => { + const { t } = useTranslation(); const { markNotificationsAsRead, markNotificationsAsDone } = useNotifications(); const shouldAnimateExit = shouldRemoveNotificationsFromState(); @@ -74,7 +76,7 @@ export const RepositoryHeader: FC = ({ event.stopPropagation(); openRepository(repoNotifications[0].repository); }} - title="Open repository ↗" + title={t('repository.open', 'Open repository ↗')} variant="invisible" > = ({ action={() => runGroupAction(() => markNotificationsAsRead(repoNotifications))} enabled={!areAllRepoNotificationsRead} icon={ReadIcon} - label="Mark repository as read" + label={t('repository.mark_as_read', 'Mark repository as read')} testid="repository-mark-as-read" /> @@ -103,7 +105,7 @@ export const RepositoryHeader: FC = ({ !areAllRepoNotificationsRead } icon={CheckIcon} - label="Mark repository as done" + label={t('repository.mark_as_done', 'Mark repository as done')} testid="repository-mark-as-done" /> diff --git a/src/renderer/components/notifications/__snapshots__/AccountHeader.test.tsx.snap b/src/renderer/components/notifications/__snapshots__/AccountHeader.test.tsx.snap index e8421f3ce..d284f15a6 100644 --- a/src/renderer/components/notifications/__snapshots__/AccountHeader.test.tsx.snap +++ b/src/renderer/components/notifications/__snapshots__/AccountHeader.test.tsx.snap @@ -20,7 +20,7 @@ exports[`renderer/components/notifications/AccountHeader.tsx > should render an data-size="medium" data-testid="account-profile" data-variant="invisible" - title="Open account profile" + title="Open account profile ↗" type="button" > = (props: HeaderProps) => { + const { t } = useTranslation(); const navigate = useNavigate(); const { refetchNotifications: fetchNotifications } = useNotifications(); @@ -23,7 +25,7 @@ export const Header: FC = (props: HeaderProps) => {
{ diff --git a/src/renderer/components/settings/AppearanceSettings.test.tsx b/src/renderer/components/settings/AppearanceSettings.test.tsx index 29935a725..d0c72f8d4 100644 --- a/src/renderer/components/settings/AppearanceSettings.test.tsx +++ b/src/renderer/components/settings/AppearanceSettings.test.tsx @@ -1,13 +1,16 @@ -import { act, screen } from '@testing-library/react'; +import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../__helpers__/test-utils'; import { mockGitHubAppAccount } from '../../__mocks__/account-mocks'; +import { Constants } from '../../constants'; + import { useSettingsStore } from '../../stores'; import { DesignLanguage } from '../../types'; +import i18n from '../../i18n'; import * as zoom from '../../utils/ui/zoom'; import { AppearanceSettings } from './AppearanceSettings'; @@ -42,6 +45,36 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { expect(updateSettingSpy).toHaveBeenCalledWith('designLanguage', DesignLanguage.GLASS); }); + it('should render English as the only available language', async () => { + await act(async () => { + renderWithProviders(); + }); + + expect(screen.getByTestId('settings-language')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'English' })).toBeInTheDocument(); + + const languageSelect = screen.getByTestId('settings-language'); + const options = within(languageSelect).getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveValue('en'); + }); + + it('should change the interface language and persist it to localStorage', async () => { + const changeLanguageSpy = vi.spyOn(i18n, 'changeLanguage'); + + await act(async () => { + renderWithProviders(); + }); + + await userEvent.selectOptions(screen.getByTestId('settings-language'), 'en'); + + expect(changeLanguageSpy).toHaveBeenCalledWith('en'); + + await waitFor(() => { + expect(localStorage.getItem(Constants.STORAGE.LANGUAGE)).toBe('en'); + }); + }); + it('hides accessibility color modes when Glass is the active design language', async () => { await act(async () => { renderWithProviders(, { diff --git a/src/renderer/components/settings/AppearanceSettings.tsx b/src/renderer/components/settings/AppearanceSettings.tsx index fcd76447f..602d5f997 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { PaintbrushIcon, SyncIcon, ZoomInIcon, ZoomOutIcon } from '@primer/octicons-react'; import { Button, ButtonGroup, IconButton, Select, Stack, Text } from '@primer/react'; @@ -11,6 +12,13 @@ import { Title } from '../primitives/Title'; import { DesignLanguage, Theme } from '../../types'; +import i18n, { + DEFAULT_LANGUAGE, + LANGUAGES, + SUPPORTED_LANGUAGES, + loadLanguageLocale, + type Language, +} from '../../i18n'; import { resolveColorMode, supportedColorModes } from '../../utils/ui/theme'; import { canDecreaseZoom, @@ -21,6 +29,8 @@ import { } from '../../utils/ui/zoom'; export const AppearanceSettings: FC = () => { + const { t } = useTranslation(); + // Account store values const hasMultipleAccounts = useAccountsStore((s) => s.hasMultipleAccounts); @@ -39,52 +49,95 @@ export const AppearanceSettings: FC = () => { const wrapNotificationTitle = useSettingsStore((s) => s.wrapNotificationTitle); const zoomPercentage = useSettingsStore((s) => s.zoomPercentage); + // Language (stored independently of the settings store, see i18n/loader) + const locale = loadLanguageLocale(); + const language = SUPPORTED_LANGUAGES.includes(locale as Language) + ? (locale as Language) + : DEFAULT_LANGUAGE; + return (
- Appearance + {t('settings.appearance.title', 'Appearance')} - + + + + + + - + @@ -92,13 +145,15 @@ export const AppearanceSettings: FC = () => { toggleSetting('increaseContrast')} tooltip={ - Use GitHub's high-contrast color schemes for improved legibility. Also applied - automatically when your system's increase-contrast setting is enabled. + {t( + 'settings.appearance.increase_contrast_help', + "Use GitHub's high-contrast color schemes for improved legibility. Also applied automatically when your system's increase-contrast setting is enabled.", + )} } visible={designLanguage === DesignLanguage.CLASSIC} @@ -106,24 +161,26 @@ export const AppearanceSettings: FC = () => { toggleSetting('showStatusIconColors')} tooltip={ - Color the notification type icons by their state (open, closed, done, attention). - Glass renders them monochrome by default. + {t( + 'settings.appearance.show_status_icon_colors_help', + 'Color the notification type icons by their state (open, closed, done, attention). Glass renders them monochrome by default.', + )} } visible={designLanguage === DesignLanguage.GLASS} /> - + { /> { /> resetZoomLevel()} @@ -165,13 +222,15 @@ export const AppearanceSettings: FC = () => { toggleSetting('showAccountHeader')} tooltip={ - When enabled, displays an account header (avatar, username and quick links) above the - notifications list. + {t( + 'settings.appearance.show_account_header_help', + 'When enabled, displays an account header (avatar, username and quick links) above the notifications list.', + )} } visible={!hasMultipleAccounts()} @@ -179,14 +238,15 @@ export const AppearanceSettings: FC = () => { toggleSetting('wrapNotificationTitle')} tooltip={ - Wrap long notification titles onto multiple lines instead of truncating with an - ellipsis. This shows the full title but may increase the height of the notification - list. + {t( + 'settings.appearance.wrap_notification_title_help', + 'Wrap long notification titles onto multiple lines instead of truncating with an ellipsis. This shows the full title but may increase the height of the notification list.', + )} } /> diff --git a/src/renderer/components/settings/NotificationSettings.tsx b/src/renderer/components/settings/NotificationSettings.tsx index 56c3380e4..a4ee420e8 100644 --- a/src/renderer/components/settings/NotificationSettings.tsx +++ b/src/renderer/components/settings/NotificationSettings.tsx @@ -1,4 +1,5 @@ import type { FC, MouseEvent } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { @@ -37,6 +38,7 @@ import { hasAlternateScopes, hasRecommendedScopes } from '../../utils/auth/scope import { openGitHubParticipatingDocs } from '../../utils/system/links'; export const NotificationSettings: FC = () => { + const { t } = useTranslation(); const navigate = useNavigate(); // Account store values @@ -61,40 +63,46 @@ export const NotificationSettings: FC = () => { return (
- Notifications + {t('settings.notifications.title', 'Notifications')} { updateSetting('groupBy', evt.target.value as GroupBy); }} options={[ - { label: 'Repository', value: GroupBy.REPOSITORY }, - { label: 'Date', value: GroupBy.DATE }, + { + label: t('settings.notifications.group_by_repository', 'Repository'), + value: GroupBy.REPOSITORY, + }, + { label: t('settings.notifications.group_by_date', 'Date'), value: GroupBy.DATE }, ]} tooltip={ - Choose how notifications are displayed in the list. - - Repository groups notifications by their repository full - name. - - - Date shows notifications in chronological order. - + , ]} + defaults="Choose how notifications are displayed in the list. <0>Repository groups notifications by their repository full name. <1>Date shows notifications in chronological order." + /> } value={groupBy} /> - + { @@ -112,7 +120,7 @@ export const NotificationSettings: FC = () => { /> { @@ -141,7 +152,7 @@ export const NotificationSettings: FC = () => { /> { @@ -156,45 +167,48 @@ export const NotificationSettings: FC = () => { toggleSetting('fetchAllNotifications')} tooltip={ - - When checked, {APPLICATION.NAME} will fetch{' '} - all notifications from your inbox. - - - When unchecked, {APPLICATION.NAME} will fetch the first page of - notifications (max 50 records per GitHub account) - + , + , + , + ]} + defaults="When <0>checked, {{appName}} will fetch <1>all notifications from your inbox. When <2>unchecked, {{appName}} will fetch the first page of notifications (max 50 records per GitHub account)" + values={{ appName: APPLICATION.NAME }} + /> } /> toggleSetting('detailedNotifications')} tooltip={ - - When checked, {APPLICATION.NAME} will enrich notifications with - detailed user and state information. You may also choose to display{' '} - notification metric pills or{' '} - notification reference numbers. - - - When unchecked, {APPLICATION.NAME} will only fetch basic - notification details. - - - ⚠️ Users with a large number of unread notifications may experience rate - limiting under certain circumstances. Please disable this setting if you experience - this. - + , + , + , + , + ]} + defaults="When <0>checked, {{appName}} will enrich notifications with detailed user and state information. You may also choose to display <1>notification metric pills or <2>notification reference numbers. When <3>unchecked, {{appName}} will only fetch basic notification details." + values={{ appName: APPLICATION.NAME }} + /> + ]} + defaults="⚠️ Users with a large number of unread notifications may experience rate limiting under certain circumstances. Please disable this setting if you experience this." + /> } /> @@ -211,23 +225,18 @@ export const NotificationSettings: FC = () => { gap="condensed" > - - - One or more accounts are missing scopes required for notification enrichment. - Notification details may be incomplete. - - - Visit{' '} + navigate('/accounts')} type="button" - > - Accounts → Scopes - {' '} - to review. - - + />, + ]} + defaults="One or more accounts are missing scopes required for notification enrichment. Notification details may be incomplete. Visit <0>Accounts → Scopes to review." + /> )} @@ -235,37 +244,42 @@ export const NotificationSettings: FC = () => { toggleSetting('showPills')} tooltip={ - Show notification metric pills for: + + {t( + 'settings.notifications.show_pills_help', + 'Show notification metric pills for:', + )} +
- linked issues + {t('settings.notifications.pill_linked_issues', 'linked issues')} - reactions + {t('settings.notifications.pill_reactions', 'reactions')} - pr reviews + {t('settings.notifications.pill_pr_reviews', 'pr reviews')} - comments + {t('settings.notifications.pill_comments', 'comments')} - labels + {t('settings.notifications.pill_labels', 'labels')} - milestones + {t('settings.notifications.pill_milestones', 'milestones')}
@@ -275,27 +289,29 @@ export const NotificationSettings: FC = () => { toggleSetting('showNumber')} tooltip={ - Show GitHub number for: + + {t('settings.notifications.show_number_help', 'Show GitHub number for:')} +
- Discussion + {t('settings.notifications.number_discussion', 'Discussion')} - Issue + {t('settings.notifications.number_issue', 'Issue')} - Pull Request + {t('settings.notifications.number_pull_request', 'Pull Request')}
@@ -307,54 +323,62 @@ export const NotificationSettings: FC = () => { toggleSetting('participating')} tooltip={ - - When checked, {APPLICATION.NAME} will fetch only participating - notifications. - - - When unchecked, {APPLICATION.NAME} will fetch participating and - watching notifications. - - - See{' '} - {' '} - for more details. - + , + , + ); diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index af8aaf4ad..3581d6a31 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -1,4 +1,5 @@ import { type FC, useEffect, useRef, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { DeviceDesktopIcon, PencilIcon, SyncIcon } from '@primer/octicons-react'; import { Banner, Button, ButtonGroup, IconButton, Stack, Text } from '@primer/react'; @@ -32,6 +33,7 @@ import { VolumeUpIcon } from '../icons/VolumeUpIcon'; const defaultSettings = DEFAULT_SETTINGS_STATE; export const SystemSettings: FC = () => { + const { t } = useTranslation(); const shortcutRegistrationError = useShortcutRegistrationStore( (s) => s.shortcutRegistrationError, ); @@ -123,35 +125,50 @@ export const SystemSettings: FC = () => { let shortcutButtonLabel = shortcutDisplay; if (recordingShortcut) { - shortcutButtonLabel = hasLiveModifiers ? `${liveModifierDisplay}…` : 'Press keys…'; + shortcutButtonLabel = hasLiveModifiers + ? `${liveModifierDisplay}…` + : t('settings.system.press_keys', 'Press keys…'); } return (
- System + {t('settings.system.title', 'System')} { updateSetting('openLinks', evt.target.value as OpenPreference); }} options={[ - { label: 'Foreground', value: OpenPreference.FOREGROUND }, - { label: 'Background', value: OpenPreference.BACKGROUND }, + { + label: t('settings.system.open_links_foreground', 'Foreground'), + value: OpenPreference.FOREGROUND, + }, + { + label: t('settings.system.open_links_background', 'Background'), + value: OpenPreference.BACKGROUND, + }, ]} tooltip={ - Controls the behavior of how external links should opened. - Foreground will open the link and bring the opened window - or browser to the front. - - - Background opens the link without stealing focus from the - current window. + {t( + 'settings.system.open_links_help', + 'Controls the behavior of how external links should opened.', + )} + ]} + defaults="<0>Foreground will open the link and bring the opened window or browser to the front." + /> + ]} + defaults="<0>Background opens the link without stealing focus from the current window." + /> } value={openLinks} @@ -164,12 +181,12 @@ export const SystemSettings: FC = () => { {shortcutRegistrationError} } hideTitle - title="Shortcut error" + title={t('settings.system.shortcut_error_title', 'Shortcut error')} variant="critical" /> )} @@ -177,40 +194,57 @@ export const SystemSettings: FC = () => { toggleSetting('keyboardShortcut')} tooltip={ - Global keyboard shortcut to show or hide {APPLICATION.NAME}. - Shortcuts must include: + + {t( + 'settings.system.keyboard_shortcut_help', + 'Global keyboard shortcut to show or hide {{appName}}.', + { appName: APPLICATION.NAME }, + )} + + + {t('settings.system.keyboard_shortcut_help_modifiers', 'Shortcuts must include:')} +
• - - Primary modifier:{' '} - - {formatAcceleratorForDisplay('CommandOrControl', isMac)} - - + ]} + defaults="Primary modifier: <0>{{accelerator}}" + values={{ + accelerator: formatAcceleratorForDisplay('CommandOrControl', isMac), + }} + /> • - - Optional modifiers:{' '} - - {formatAcceleratorForDisplay('Shift', isMac)} - - ,{' '} - - {formatAcceleratorForDisplay('Alt', isMac)} - - + , + , + ]} + defaults="Optional modifiers: <0>{{modifier1}}, <1>{{modifier2}}" + values={{ + modifier1: formatAcceleratorForDisplay('Shift', isMac), + modifier2: formatAcceleratorForDisplay('Alt', isMac), + }} + /> • - A non-modifier key (letter, number, etc.) + + {t( + 'settings.system.keyboard_shortcut_non_modifier', + 'A non-modifier key (letter, number, etc.)', + )} +
@@ -224,7 +258,7 @@ export const SystemSettings: FC = () => { hidden={!keyboardShortcut} > { @@ -236,7 +270,7 @@ export const SystemSettings: FC = () => { unsafeDisableTooltip={true} /> { toggleSetting('showNotifications')} tooltip={ - Display native operating system notifications for new unread notifications. + + {t( + 'settings.system.show_notifications_help', + 'Display native operating system notifications for new unread notifications.', + )} + } /> toggleSetting('playSound')} />