diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ac6ccaee2..be1c660b9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,6 +27,12 @@ jobs: - name: Run lint and format checks run: vp run check + - name: Run i18n key sync check + run: pnpm i18n:check + + - name: Run i18n lint (hardcoded strings, split sentences) + run: pnpm i18n:lint + 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 e358cd476..f0f35b386 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,6 +91,38 @@ 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. Write the English text as the second argument of `t()`, and as `defaults` on ``: + + ```tsx + t('common.save', 'Save'); + t('login.device.opened', 'Opened {{hostname}}', { hostname }); + ]} + defaults="Requires <0>Detailed Notifications." + />; + ``` + +- After changing a string, 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, every `t()`/`` has an English default, and `en.json` has no empty or key-path values (also runs in CI) + - `pnpm i18n:lint` — flag hardcoded or untranslated UI strings + - `pnpm i18n:status` — locale health summary +- Don't run `i18next-cli extract --trust-derived`: it replaces real English text with key names. +- 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. +- When a sentence contains markup (bold, links, tooltips), keep it as one `` string instead of splitting it into several `t()` fragments, so translators can reorder the words. A component that requires a single child (e.g. Primer's `Tooltip`) needs a small wrapper that accepts `children`. + ### 🎨 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. @@ -209,3 +241,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..806e0ddf9 --- /dev/null +++ b/i18next.config.ts @@ -0,0 +1,23 @@ +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}'], + checkConcatenation: 'error', + }, +}); diff --git a/package.json b/package.json index cbc3370a8..784bc90e6 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,13 @@ "package:win": "electron-builder --win", "check": "vp check", "check:fix": "vp check --fix", + "i18n:extract": "i18next-cli extract --sync-all", + "i18n:check": "concurrently --kill-others-on-fail --names \"sync,values,defaults\" --prefix-colors \"yellow,green,cyan\" \"pnpm i18n:check:sync\" \"pnpm i18n:check:values\" \"pnpm i18n:check:defaults\"", + "i18n:check:sync": "i18next-cli extract --ci --quiet", + "i18n:check:values": "node ./scripts/check-en-locales.mjs", + "i18n:check:defaults": "node ./scripts/check-t-defaults.mjs", + "i18n:lint": "i18next-cli lint", + "i18n:status": "i18next-cli status", "lint": "vp lint --fix", "lint:check": "vp lint", "format": "vp fmt", @@ -79,8 +86,11 @@ "electron-log": "5.4.4", "electron-menubar": "11.1.2", "electron-updater": "6.8.11", + "i18next": "26.4.2", + "i18next-browser-languagedetector": "8.2.1", "react": "19.3.1", "react-dom": "19.3.1", + "react-i18next": "17.0.15", "react-router-dom": "7.18.4" }, "devDependencies": { @@ -129,6 +139,7 @@ "graphql-config": "6.0.0", "happy-dom": "21.0.4", "husky": "9.1.7", + "i18next-cli": "1.74.1", "playwright": "1.64.1", "react-devtools": "8.0.0", "rimraf": "6.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9223feb38..cf6a3b1e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,12 +204,21 @@ importers: electron-updater: specifier: 6.8.11 version: 6.8.11(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.1 version: 19.3.1 react-dom: specifier: 19.3.1 version: 19.3.1(react@19.3.1) + react-i18next: + specifier: 17.0.15 + version: 17.0.15(i18next@26.4.2(typescript@7.0.2))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@7.0.2) react-router-dom: specifier: 7.18.4 version: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -349,6 +358,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.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@7.0.2) playwright: specifier: 1.64.1 version: 1.64.1 @@ -481,6 +493,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==} @@ -1529,10 +1547,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.20': + resolution: {integrity: sha512-i7NFSDnuE6tdBvBgmoVKKmburgcwZt/FZK2IusJaVGrjFrMUC1hrgE3RI+Kt9RhYM7vHvQj0W9j53zSxKEMbWQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.16.20': + resolution: {integrity: sha512-ho/LkT1Q3beSBeZGNZnm1mVbh5bKZHC0UGHtK3MswqOylbHtZoMXJBL40N8JpDj5SBxwECmdNEyXnU9HJ4F6RA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.16.20': + resolution: {integrity: sha512-xm3/s3zp9WyNgB1Sl78tpH76MHgobI3TSenTY15bkSHyrdQa9W2MUSESHRPd7CHGFLfpWl/0jwwNp5+hwG0e9A==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.16.20': + resolution: {integrity: sha512-e6Um0U+VcTQyurOXhbratRQlrPITSRwugG00fhICbi2MGTi1vRiE4TMqGCjWpR1zPmZKXDGMw+yNFvh7AAgT2Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.16.20': + resolution: {integrity: sha512-ekCO2obfK9NFwXGHfAQjjuU5mLDDC7qMaUJH/V/4iyzD0BqNhilr6McZrXLytOQ/xKtTNxLQ71wYrD3M2LW8Kg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.16.20': + resolution: {integrity: sha512-6/x2K3l4GNUWyaSmkywnth6ipZISzhVzOLsI66bj12/hSQYaKAyg9MKTwnuFf2B/iYA6GrjDghk36RYghRameQ==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.16.20': + resolution: {integrity: sha512-1GTd8sqS/NpcF+Y3YDSE0bT31V61+hD7ZZWEsVyiyxk/HQt2zpk4zlJJHOASBLc+/CyUuBZ949n6QMszbIXkmQ==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.16.20': + resolution: {integrity: sha512-XtMeYAgQa5UZhAX84TXn0sXPmhf1xHwcv0cl5hHiBXxTVbplY7WiTwIQU9uOtVm3VUqL/quyCm78/umUNZ7FmA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.16.20': + resolution: {integrity: sha512-qeF3UILa+MA4zJ29CdQ8KE7YbGFPMQBxWo3Lh0Ed0ETFEJ8pCKISWo7wQG4/SmfM9HX4KT1Odoj6ixyBn6Ji2Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.16.20': + resolution: {integrity: sha512-ciMtHXAmE+jnrWWO14rErr6tfK34QZm8jZRSdRLPodqJlzrYDw7TqN60HC8Yim5Ua9a1r/poyfkpNft6GRXvxA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.16.20': + resolution: {integrity: sha512-MTbkgGooWebmjmATx1+QRECVZ+q4ICKNXCutwOrFVD5b5hdxla/yV4e6cRFrfNMowejaNpmMQg31ENWt0Yfzpg==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.16.20': + resolution: {integrity: sha512-Lf5oMaSdJBImtGhDecasxcIa7Z5LeJ4aGmBOY7TScroO+A/MxxB9PslIpgV0rtG/FcxtipN7hveBg9ZTeboaaw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.16.20': + resolution: {integrity: sha512-zcdfS7k28OJIMuLhqRRyGja9PxrC2GXhFXkbQ1X15y6uZEMsTrqyej55V1DiJtlXaDOmwSLn5222YPT9ZoFxPA==} + 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'} @@ -2412,6 +2530,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'} @@ -2454,6 +2576,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'} @@ -2770,6 +2896,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + execa@10.2.1: + resolution: {integrity: sha512-/Bofl3ZBh2Q4/PG0W63qFzP5vDAypi8Zc90UWTBzaDNIYiX7apPOd0N6L8hG8CojUdq4V51adu8BObP+iVsSTw==} + engines: {node: '>=22'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -2822,6 +2952,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==} @@ -2910,6 +3044,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'} @@ -3032,6 +3170,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.3.0: resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} @@ -3051,11 +3192,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.2: + resolution: {integrity: sha512-1SwWSlbgpMx/AyWYJvPrg2ns1CzXsaM0olmqfDSm2fIzwbChdkxeaiAXw0xhdNPuo/v8nuwxVTLfpf8NxrdlNw==} + 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'} @@ -3101,6 +3266,15 @@ packages: resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} engines: {node: '>=10'} + inquirer@14.2.5: + resolution: {integrity: sha512-s5r4MqAyAXGau1AYf9weaQbj6wbkY/wuuqROGRwyEiR3KvE4QSJ8y5OYfOfWLYyRVhoK6zU2hNmf9vbt/k+Wag==} + 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'} @@ -3147,6 +3321,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'} @@ -3167,6 +3345,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'} @@ -3175,6 +3357,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==} @@ -3273,6 +3459,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==} @@ -3696,6 +3885,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.74.0: resolution: {integrity: sha512-G4OP7FgwGYZR5ttRpCOOfJdyRsSyvtn34vSXnjDq6Oqed5Dufg5mB44mank+51nAdHOKuTJ0lj0mksY7L+N61A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3768,6 +3961,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'} @@ -3855,6 +4052,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} @@ -3913,6 +4114,22 @@ packages: peerDependencies: react: ^19.3.1 + 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: @@ -4033,6 +4250,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==} @@ -4150,6 +4371,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==} @@ -4180,6 +4405,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'} @@ -4486,6 +4715,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'} @@ -4754,6 +4988,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 @@ -5883,8 +6123,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.20': + optional: true + + '@swc/core-darwin-x64@1.16.20': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.16.20': + optional: true + + '@swc/core-linux-arm64-gnu@1.16.20': + optional: true + + '@swc/core-linux-arm64-musl@1.16.20': + optional: true + + '@swc/core-linux-ppc64-gnu@1.16.20': + optional: true + + '@swc/core-linux-s390x-gnu@1.16.20': + optional: true + + '@swc/core-linux-x64-gnu@1.16.20': + optional: true + + '@swc/core-linux-x64-musl@1.16.20': + optional: true + + '@swc/core-win32-arm64-msvc@1.16.20': + optional: true + + '@swc/core-win32-ia32-msvc@1.16.20': + optional: true + + '@swc/core-win32-x64-msvc@1.16.20': + optional: true + + '@swc/core@1.16.20': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.16.20 + '@swc/core-darwin-x64': 1.16.20 + '@swc/core-linux-arm-gnueabihf': 1.16.20 + '@swc/core-linux-arm64-gnu': 1.16.20 + '@swc/core-linux-arm64-musl': 1.16.20 + '@swc/core-linux-ppc64-gnu': 1.16.20 + '@swc/core-linux-s390x-gnu': 1.16.20 + '@swc/core-linux-x64-gnu': 1.16.20 + '@swc/core-linux-x64-musl': 1.16.20 + '@swc/core-win32-arm64-msvc': 1.16.20 + '@swc/core-win32-ia32-msvc': 1.16.20 + '@swc/core-win32-x64-msvc': 1.16.20 + + '@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 @@ -6643,6 +6947,8 @@ snapshots: dependencies: restore-cursor: 5.1.0 + cli-spinners@3.4.0: {} + cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 @@ -6682,6 +6988,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@15.0.0: {} + commander@5.1.0: {} commander@9.5.0: @@ -7004,6 +7312,21 @@ snapshots: eventemitter3@5.0.4: {} + execa@10.2.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 @@ -7059,6 +7382,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 @@ -7161,6 +7488,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 @@ -7326,6 +7658,8 @@ snapshots: hsluv@1.0.1: {} + html-parse-stringify@4.0.1: {} + http-cache-semantics@4.3.0: {} http-proxy-agent@7.0.2(supports-color@10.2.2): @@ -7349,8 +7683,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.10 + + i18next-cli@1.74.1(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@7.0.2): + dependencies: + '@croct/json5-parser': 0.2.2 + '@swc/core': 1.16.20 + chokidar: 5.0.0 + commander: 15.0.0 + execa: 10.2.1 + glob: 13.0.6 + i18next: 26.4.2(typescript@7.0.2) + i18next-resources-for-ts: 2.1.2 + inquirer: 14.2.5(@types/node@24.19.2) + jiti: 2.7.0 + jsonc-parser: 3.3.1 + magic-string: 1.4.5 + 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.1(react@19.3.1))(react@19.3.1)(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.2: + dependencies: + '@babel/runtime': 7.29.10 + '@swc/core': 1.16.20 + 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 @@ -7383,6 +7762,17 @@ snapshots: ini@2.0.0: {} + inquirer@14.2.5(@types/node@24.19.2): + dependencies: + '@inquirer/ansi': 2.0.10 + '@inquirer/core': 12.0.6(@types/node@24.19.2) + '@inquirer/prompts': 8.7.5(@types/node@24.19.2) + '@inquirer/type': 4.2.1(@types/node@24.19.2) + mute-stream: 3.0.0 + run-async: 4.0.6 + optionalDependencies: + '@types/node': 24.19.2 + internal-ip@6.2.0: dependencies: default-gateway: 6.0.3 @@ -7426,6 +7816,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 @@ -7438,12 +7830,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: @@ -7512,6 +7908,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -7865,6 +8263,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.74.0(vite-plus@1.4.0(@types/node@24.19.2)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(happy-dom@21.0.4)(jiti@2.7.0)(playwright@1.64.1)(typescript@7.0.2)(yaml@2.9.1)): dependencies: tinypool: 2.2.0 @@ -7965,6 +8374,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: {} @@ -8046,6 +8457,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 19.3.1 + pretty-ms@9.3.1: + dependencies: + parse-ms: 4.0.0 + proc-log@6.1.0: {} process-nextick-args@2.0.1: {} @@ -8117,6 +8532,17 @@ snapshots: react: 19.3.1 scheduler: 0.28.0 + react-i18next@17.0.15(i18next@26.4.2(typescript@7.0.2))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@7.0.2): + dependencies: + '@babel/runtime': 7.29.10 + html-parse-stringify: 4.0.1 + i18next: 26.4.2(typescript@7.0.2) + react: 19.3.1 + use-sync-external-store: 1.7.0(react@19.3.1) + optionalDependencies: + react-dom: 19.3.1(react@19.3.1) + typescript: 7.0.2 + react-intersection-observer@10.1.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1): dependencies: react: 19.3.1 @@ -8226,6 +8652,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 @@ -8320,6 +8748,8 @@ snapshots: std-env@4.3.0: {} + stdin-discarder@0.3.2: {} + string-env-interpolation@1.0.1: {} string-width@4.2.3: @@ -8353,6 +8783,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 @@ -8672,6 +9104,8 @@ snapshots: whatwg-mimetype@5.0.0: {} + which-command@0.1.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/scripts/check-en-locales.mjs b/scripts/check-en-locales.mjs new file mode 100644 index 000000000..c72332c81 --- /dev/null +++ b/scripts/check-en-locales.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Guard for the primary locale (en.json). + * + * Fails when any leaf value is empty or equals its own dotted key path. Both + * mean the English text is missing from code: an empty value shows the raw + * dotted key to English users, and a key-path value means `--trust-derived` + * (or a missing default) baked the key in as the translation. + * + * Runs as part of `pnpm i18n:check`. Optionally accepts a locale path + * (defaults to src/renderer/i18n/locales/en.json). + */ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const defaultPath = fileURLToPath(new URL('../src/renderer/i18n/locales/en.json', import.meta.url)); +const localePath = process.argv[2] ?? defaultPath; + +const en = JSON.parse(await readFile(localePath, 'utf8')); + +const issues = []; + +function walk(obj, prefix) { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'string') { + if (v === '') { + issues.push(`${key}: empty value`); + } else if (v === key) { + issues.push(`${key}: value equals key path`); + } + } else { + walk(v, key); + } + } +} + +walk(en, ''); + +if (issues.length > 0) { + console.error( + 'en.json contains values that cannot seed the primary locale. Add an English default to the t() call so extraction writes the English text.', + ); + for (const issue of issues) { + console.error(` - ${issue}`); + } + process.exit(1); +} + +console.log('✅ en.json: no empty or key-path values'); diff --git a/scripts/check-t-defaults.mjs b/scripts/check-t-defaults.mjs new file mode 100644 index 000000000..6c96c9af5 --- /dev/null +++ b/scripts/check-t-defaults.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * Source-level guard: every t() / i18n.t() call must pass its English text as + * the second argument, e.g. `t('common.save', 'Save')`, and every must + * set `defaults`. The extractor generates en.json from these defaults, so a + * missing one would ship an empty string or a raw key to English users. + * + * Runs as part of `pnpm i18n:check`. Scans src/renderer, skipping tests, the + * i18n bootstrap files and the locales directory. + */ +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const SCAN_DIR = join(ROOT, 'src', 'renderer'); +const SKIP_DIRS = new Set(['node_modules', 'locales', '__mocks__', '__helpers__']); +const I18N_DIR = join(SCAN_DIR, 'i18n'); + +async function collectFiles(dir, out = []) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) { + await collectFiles(p, out); + } + } else if ( + /\.(ts|tsx)$/.test(entry.name) && + !/\.test\./.test(entry.name) && + !p.startsWith(I18N_DIR) + ) { + out.push(p); + } + } + return out; +} + +function lineOf(code, index) { + return code.slice(0, index).split('\n').length; +} + +function skipSpace(code, start) { + let i = start; + while (i < code.length && /\s/.test(code[i])) { + i++; + } + return i; +} + +/** Return the index just past a string literal starting at `start`, or -1. */ +function endOfString(code, start) { + const q = code[start]; + for (let i = start + 1; i < code.length; i++) { + if (code[i] === '\\') { + i++; + } else if (code[i] === q) { + return i + 1; + } + } + return -1; +} + +const T_CALL = /(?`. */ +function openingTagProps(code, start) { + let depth = 0; + for (let i = start; i < code.length; i++) { + const c = code[i]; + if (c === "'" || c === '"' || c === '`') { + const end = endOfString(code, i); + if (end === -1) { + return null; + } + i = end - 1; + } else if (c === '{') { + depth++; + } else if (c === '}') { + depth--; + } else if (c === '>' && depth === 0) { + return code.slice(start, i); + } + } + return null; +} + +function findTransIssues(code) { + const issues = []; + for (const match of code.matchAll(/'; + issues.push(`${key} (line ${lineOf(code, match.index)}): without defaults`); + } + } + return issues; +} + +const files = await collectFiles(SCAN_DIR); +const problemsByFile = new Map(); + +for (const file of files) { + const code = await readFile(file, 'utf8'); + const issues = [...findTCallIssues(code), ...findTransIssues(code)]; + if (issues.length > 0) { + problemsByFile.set(relative(ROOT, file), issues); + } +} + +if (problemsByFile.size > 0) { + console.error( + "Missing English defaults. Write t('key', 'English text') and give every a `defaults` prop so extraction can generate en.json.", + ); + for (const [file, issues] of problemsByFile) { + for (const issue of issues) { + console.error(` - ${file}: ${issue}`); + } + } + process.exit(1); +} + +console.log('✅ src/renderer: every t() call and carries an English default'); diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 28c9f6ec7..7448f0f13 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -1,6 +1,10 @@ import '@testing-library/jest-dom/vitest'; +// Initialize the real i18n instance so tests render the English defaults. +import '../i18n'; import { createGitifyBridgeApi } from '../../__helpers__/gitify-bridge'; +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 @@ -98,10 +102,22 @@ function getRequestTarget(input: RequestInfo | URL): string { 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 21c77e69d..79edcc9d9 100644 --- a/src/renderer/components/AllRead.tsx +++ b/src/renderer/components/AllRead.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { Constants } from '../constants'; @@ -13,11 +14,14 @@ interface AllReadProps { } export function AllRead({ fullHeight = true }: Readonly) { + 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 5d147e86f..e302676ca 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 function Oops({ error, fullHeight = true }: Readonly) { 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 ee784fb77..22be2463d 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { BellIcon, CrosshairsIcon, @@ -20,6 +22,7 @@ import { useAccountsStore, useFiltersStore, useSettingsStore } from '../stores'; import { LogoIcon } from '../icons/LogoIcon'; export function Sidebar() { + const { t } = useTranslation(); const { status, notificationCount, hasUnreadNotifications, isFetching } = useNotifications(); const { shortcuts } = useShortcutActions(); @@ -42,7 +45,7 @@ export function Sidebar() { } keybindingHint={shortcuts.home.key} onClick={() => shortcuts.home.action()} @@ -54,8 +57,16 @@ export function Sidebar() { {isLoggedIn && ( <> shortcuts.myNotifications.action()} @@ -65,9 +76,11 @@ export function Sidebar() { /> shortcuts.filters.action()} @@ -89,7 +102,7 @@ export function Sidebar() { /> shortcuts.myIssues.action()} @@ -99,7 +112,7 @@ export function Sidebar() { /> shortcuts.myPullRequests.action()} @@ -115,8 +128,8 @@ export function Sidebar() { {isLoggedIn && ( <> shortcuts.settings.action()} @@ -142,7 +155,9 @@ export function Sidebar() { {!isLoggedIn && ( shortcuts.quit.action()} diff --git a/src/renderer/components/__snapshots__/AllRead.test.tsx.snap b/src/renderer/components/__snapshots__/AllRead.test.tsx.snap index fa01653ca..f16c62136 100644 --- a/src/renderer/components/__snapshots__/AllRead.test.tsx.snap +++ b/src/renderer/components/__snapshots__/AllRead.test.tsx.snap @@ -44,7 +44,7 @@ exports[`renderer/components/AllRead.tsx > renders itself & its children - no fi
- No new notifications + No new notifications
@@ -96,7 +96,7 @@ exports[`renderer/components/AllRead.tsx > renders itself & its children - with
- 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 141fe8b0f..6f797cc65 100644 --- a/src/renderer/components/filters/AccountFilter.tsx +++ b/src/renderer/components/filters/AccountFilter.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { PersonIcon } from '@primer/octicons-react'; import { Stack, Text } from '@primer/react'; @@ -10,6 +12,7 @@ import { getAccountUUID } from '../../utils/auth/utils'; import { FilterCheckbox } from './FilterCheckbox'; export function AccountFilter() { + const { t } = useTranslation(); const { notifications } = useNotifications(); const accounts = useAccountsStore((s) => s.accounts); const filteredAccounts = useFiltersStore((s) => s.accounts); @@ -20,9 +23,9 @@ export function AccountFilter() { Filter notifications by account.} + tooltip={{t('filters.account.help', 'Filter notifications by account.')}} > - Account + {t('filters.account.title', 'Account')} diff --git a/src/renderer/components/filters/ReasonFilter.tsx b/src/renderer/components/filters/ReasonFilter.tsx index 6b20b2c43..88bfe648f 100644 --- a/src/renderer/components/filters/ReasonFilter.tsx +++ b/src/renderer/components/filters/ReasonFilter.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { NoteIcon } from '@primer/octicons-react'; import { Text } from '@primer/react'; @@ -5,14 +7,16 @@ import { reasonFilter } from '../../utils/notifications/filters'; import { FilterSection } from './FilterSection'; export function ReasonFilter() { + 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 8b24d7987..2cc366811 100644 --- a/src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx +++ b/src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx @@ -1,10 +1,15 @@ +import { Trans } from 'react-i18next'; + import { Text } from '@primer/react'; export function RequiresDetailedNotificationWarning() { return ( - ⚠️ 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 dcbce054c..dfbad4bd3 100644 --- a/src/renderer/components/filters/ReviewRequestTypeFilter.tsx +++ b/src/renderer/components/filters/ReviewRequestTypeFilter.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { GitPullRequestIcon } from '@primer/octicons-react'; import { Stack, Text } from '@primer/react'; @@ -5,22 +7,28 @@ import { reviewRequestTypeFilter } from '../../utils/notifications/filters'; import { FilterSection } from './FilterSection'; export function ReviewRequestTypeFilter() { + 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 d013e6b64..782877147 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { CheckCircleFillIcon, CommentIcon, @@ -26,6 +28,7 @@ import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificat import { TokenSearchInput } from './TokenSearchInput'; export function SearchFilter() { + const { t } = useTranslation(); const updateFilter = useFiltersStore((s) => s.updateFilter); const includeSearchTokens = useFiltersStore((s) => s.includeSearchTokens); const excludeSearchTokens = useFiltersStore((s) => s.excludeSearchTokens); @@ -71,7 +74,7 @@ export function SearchFilter() { icon={SearchIcon} tooltip={ - Filter notifications by: + {t('filters.search.help', 'Filter notifications by:')}
@@ -80,7 +83,7 @@ export function SearchFilter() { aria-disabled={!detailedNotifications} className={cn('text-gitify-caution', !detailedNotifications && 'line-through')} > - Author (author:handle) + {t('filters.search.author', 'Author (author:handle)')} @@ -89,36 +92,38 @@ export function SearchFilter() { aria-disabled={!detailedNotifications} className={cn('text-gitify-caution', !detailedNotifications && 'line-through')} > - 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')} 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 5f9161abb..66fc662ab 100644 --- a/src/renderer/components/filters/SubjectTypeFilter.tsx +++ b/src/renderer/components/filters/SubjectTypeFilter.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { BellIcon } from '@primer/octicons-react'; import { Text } from '@primer/react'; @@ -5,14 +7,16 @@ import { subjectTypeFilter } from '../../utils/notifications/filters'; import { FilterSection } from './FilterSection'; export function SubjectTypeFilter() { + 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 3bbdd1442..8d82dc4cb 100644 --- a/src/renderer/components/filters/UserTypeFilter.tsx +++ b/src/renderer/components/filters/UserTypeFilter.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { DependabotIcon, FeedPersonIcon, @@ -12,16 +14,18 @@ import { userTypeFilter } from '../../utils/notifications/filters'; import { FilterSection } from './FilterSection'; export function UserTypeFilter() { + const { t } = useTranslation(); + return ( - Filter notifications by user type: + {t('filters.user_type.help', 'Filter notifications by user type:')}
@@ -43,7 +47,10 @@ export function UserTypeFilter() {
- 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/layout/AppLayout.tsx b/src/renderer/components/layout/AppLayout.tsx index bd9059a92..1faeab219 100644 --- a/src/renderer/components/layout/AppLayout.tsx +++ b/src/renderer/components/layout/AppLayout.tsx @@ -1,4 +1,5 @@ import type { 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 function AppLayout({ children }: Readonly) { + const { t } = useTranslation(); const setFocusRef = (el: HTMLButtonElement | null) => { if (el) { el.focus(); @@ -20,7 +22,12 @@ export function AppLayout({ children }: Readonly) { return (
{/* Hidden focus sentinel; grabs initial focus via callback ref */} - @@ -268,7 +281,7 @@ export function LoginWithPersonalAccessTokenForm({ onClick={handleSubmit} variant="primary" > - Login + {t('common.login', 'Login')} diff --git a/src/renderer/components/metrics/CommentsPill.tsx b/src/renderer/components/metrics/CommentsPill.tsx index ffd606c65..8cc77d467 100644 --- a/src/renderer/components/metrics/CommentsPill.tsx +++ b/src/renderer/components/metrics/CommentsPill.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { CommentIcon } from '@primer/octicons-react'; import { IconColor } from '../../types'; @@ -10,11 +12,12 @@ export interface CommentsPillProps { } export function CommentsPill({ commentCount }: Readonly) { + const { t } = useTranslation(); if (!commentCount) { return null; } - const description = formatMetricDescription(commentCount, 'comment'); + const description = formatMetricDescription(commentCount, t('metrics.comment', 'comment')); return ( ) { + const { t } = useTranslation(); if (!linkedIssues?.length) { return null; } const description = formatMetricDescription( linkedIssues.length, - 'issue', + t('metrics.issue', 'issue'), (_, noun) => - `Linked ${noun}: ${linkedIssues.map((issue) => formatGitHubNumber(issue.number)).join(', ')}`, + t('metrics.linked_to', 'Linked {{noun}}: {{issues}}', { + noun, + issues: linkedIssues.map((issue) => formatGitHubNumber(issue.number)).join(', '), + }), ); const handleClick = diff --git a/src/renderer/components/metrics/ParentPill.tsx b/src/renderer/components/metrics/ParentPill.tsx index 923d79641..b782d98a9 100644 --- a/src/renderer/components/metrics/ParentPill.tsx +++ b/src/renderer/components/metrics/ParentPill.tsx @@ -1,4 +1,5 @@ import type { MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { IssueTrackedByIcon } from '@primer/octicons-react'; @@ -12,6 +13,7 @@ export interface ParentPillProps { } export function ParentPill({ parent }: Readonly) { + const { t } = useTranslation(); if (!parent) { return null; } @@ -21,11 +23,16 @@ export function ParentPill({ parent }: Readonly) { openExternalLink(parent.url); }; + const label = t('metrics.parent_issue', 'Parent issue: #{{number}} {{title}}', { + number: parent.number, + title: parent.title, + }); + return ( ) { + const { t } = useTranslation(); if (!isStacked) { return null; } @@ -23,8 +26,11 @@ export function StackedPrsPill({ stackPosition != null && stackDepth != null ? `${stackPosition}/${stackDepth}` : undefined; const description = metric - ? `Stacked pull request: ${stackPosition} of ${stackDepth}` - : 'Stacked pull request'; + ? t('metrics.stacked_pr_position', 'Stacked pull request: {{position}} of {{depth}}', { + position: stackPosition, + depth: stackDepth, + }) + : t('metrics.stacked_pr', 'Stacked pull request'); return ( ) { + const { t } = useTranslation(); const chevron = getChevronDetails(notificationCount > 0, !isCollapsed, 'account'); return ( @@ -51,7 +53,7 @@ export function AccountHeader({ 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 49b4cbfab..1a8d750bd 100644 --- a/src/renderer/components/notifications/NotificationHeader.tsx +++ b/src/renderer/components/notifications/NotificationHeader.tsx @@ -1,4 +1,5 @@ import type { MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { Stack } from '@primer/react'; @@ -17,6 +18,7 @@ export interface NotificationHeaderProps { } export function NotificationHeader({ notification }: NotificationHeaderProps) { + const { t } = useTranslation(); const showNumber = useSettingsStore((s) => s.showNumber); return ( @@ -31,7 +33,7 @@ export function NotificationHeader({ notification }: NotificationHeaderProps) { event.stopPropagation(); openRepository(notification.repository); }} - title="Open repository ↗" + title={t('repository.open', 'Open repository ↗')} type="button" > { act(() => { useNotificationActionFailuresStore.getState().setFailure(failureKey, { action, - error: Errors.ACTION_FORBIDDEN, + error: getErrorDetails('ACTION_FORBIDDEN'), }); }); }; @@ -403,7 +403,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', @@ -439,7 +439,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'); }); diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx index f587df561..f81a4bd43 100644 --- a/src/renderer/components/notifications/NotificationRow.tsx +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { BellSlashIcon, CheckIcon, ReadIcon } from '@primer/octicons-react'; import { Stack, Text, Tooltip } from '@primer/react'; @@ -37,6 +39,7 @@ export function NotificationRow({ isAnimatingExit, onAnimateExit, }: Readonly) { + const { t } = useTranslation(); const { markNotificationsAsRead, markNotificationsAsDone, unsubscribeNotification } = useNotificationActions(); @@ -57,7 +60,14 @@ export function NotificationRow({ // "...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 your browser.` + ? t( + 'notifications.failure_tooltip', + '{{title}}: {{description}} You can also try opening this notification in your browser.', + { + title: failure.error.title, + description: failure.error.descriptions.join(' '), + }, + ) : undefined; const runAction = async (action: () => Promise) => { @@ -156,7 +166,9 @@ export function NotificationRow({ 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'} @@ -167,7 +179,9 @@ export function NotificationRow({ 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'} @@ -179,8 +193,8 @@ export function NotificationRow({ 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/NotificationRow.visual.test.tsx b/src/renderer/components/notifications/NotificationRow.visual.test.tsx index bb4953a14..2b2c3359e 100644 --- a/src/renderer/components/notifications/NotificationRow.visual.test.tsx +++ b/src/renderer/components/notifications/NotificationRow.visual.test.tsx @@ -11,7 +11,7 @@ import { getNotificationFailureKey, useNotificationActionFailuresStore } from '. import { type AccountNotifications, DesignLanguage, Theme } from '../../types'; -import { Errors } from '../../utils/core/errors'; +import { getErrorDetails } from '../../utils/core/errors'; const notifications: AccountNotifications[] = [ { @@ -128,7 +128,7 @@ describe('renderer/components/notifications/NotificationRow visual regression', mockGitHubCloudAccount, mockGitHubCloudGitifyNotifications[0].id, ), - { action: 'markAsRead', error: Errors.ACTION_FORBIDDEN }, + { action: 'markAsRead', error: getErrorDetails('ACTION_FORBIDDEN') }, ); await renderNotifications(theme, designLanguage); diff --git a/src/renderer/components/notifications/RepositoryHeader.tsx b/src/renderer/components/notifications/RepositoryHeader.tsx index 960b0f606..0945dbee7 100644 --- a/src/renderer/components/notifications/RepositoryHeader.tsx +++ b/src/renderer/components/notifications/RepositoryHeader.tsx @@ -1,4 +1,5 @@ import { type MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { CheckIcon, ReadIcon } from '@primer/octicons-react'; import { Button, Stack } from '@primer/react'; @@ -36,6 +37,7 @@ export function RepositoryHeader({ onToggle, onAnimateExit, }: Readonly) { + const { t } = useTranslation(); const { markNotificationsAsRead, markNotificationsAsDone } = useNotificationActions(); const shouldAnimateExit = shouldRemoveNotificationsFromState(); @@ -76,7 +78,7 @@ export function RepositoryHeader({ event.stopPropagation(); openRepository(repoNotifications[0].repository); }} - title="Open repository ↗" + title={t('repository.open', 'Open repository ↗')} variant="invisible" > 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" /> @@ -105,7 +107,7 @@ export function RepositoryHeader({ !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/primitives/Header.tsx b/src/renderer/components/primitives/Header.tsx index 14ecb5f47..421fcb6e6 100644 --- a/src/renderer/components/primitives/Header.tsx +++ b/src/renderer/components/primitives/Header.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { ArrowLeftIcon, type Icon } from '@primer/octicons-react'; @@ -14,6 +15,7 @@ interface HeaderProps { } export function Header(props: Readonly) { + const { t } = useTranslation(); const navigate = useNavigate(); const { refetchNotifications: fetchNotifications } = useNotificationActions(); @@ -22,7 +24,7 @@ export function Header(props: Readonly) {
{ void navigate(-1); diff --git a/src/renderer/components/primitives/HelpPopover.tsx b/src/renderer/components/primitives/HelpPopover.tsx index 7e736d1a5..fc92dd40d 100644 --- a/src/renderer/components/primitives/HelpPopover.tsx +++ b/src/renderer/components/primitives/HelpPopover.tsx @@ -5,6 +5,8 @@ import { AnchoredOverlay, IconButton } from '@primer/react'; import { cn } from 'cn'; +import i18n from '../../i18n'; + export interface HelpPopoverProps { name: string; /** Accessible name for the help button, e.g. the label of the control it explains. */ @@ -76,7 +78,7 @@ export function HelpPopover(props: Readonly) { renderAnchor={(anchorProps) => ( s.toggleSetting); const updateSetting = useSettingsStore((s) => s.updateSetting); @@ -34,35 +37,44 @@ export function AlertSettings() { return (
- Alerts + {t('settings.alerts.title', 'Alerts')} toggleSetting('showNotifications')} - tooltip="Display native operating system notifications for new unread notifications." + tooltip={t( + 'settings.alerts.show_desktop_help', + 'Display native operating system notifications for new unread notifications.', + )} /> toggleSetting('playSound')} - tooltip="Play a sound when new unread notifications arrive after filtering. Use the notification volume controls to adjust the sound level." + tooltip={t( + 'settings.alerts.play_sound_help', + 'Play a sound when new unread notifications arrive after filtering. Use the notification volume controls to adjust the sound level.', + )} /> { @@ -73,7 +85,7 @@ export function AlertSettings() { />
diff --git a/src/renderer/components/settings/AppearanceSettings.test.tsx b/src/renderer/components/settings/AppearanceSettings.test.tsx index 66a07bebc..d8fdc4b8d 100644 --- a/src/renderer/components/settings/AppearanceSettings.test.tsx +++ b/src/renderer/components/settings/AppearanceSettings.test.tsx @@ -41,6 +41,14 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { expect(updateSettingSpy).toHaveBeenCalledWith('designLanguage', DesignLanguage.GLASS); }); + it('hides the language selector while English is the only language', async () => { + await act(async () => { + renderWithProviders(); + }); + + expect(screen.queryByRole('combobox', { name: 'Language' })).not.toBeInTheDocument(); + }); + 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 4995a215a..6a00084df 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { PaintbrushIcon, SyncIcon, ZoomInIcon, ZoomOutIcon } from '@primer/octicons-react'; import { Button, ButtonGroup, IconButton, Select } from '@primer/react'; @@ -9,6 +11,7 @@ import { SectionHeading } from '../primitives/SectionHeading'; import { DesignLanguage, Theme } from '../../types'; +import { LANGUAGES, type Language, loadLanguageLocale, setLanguage } from '../../i18n'; import { resolveColorMode, supportedColorModes } from '../../utils/ui/theme'; import { canDecreaseZoom, @@ -23,6 +26,8 @@ import { SettingsRow } from './controls/SettingsRow'; import { SettingsToggle } from './controls/SettingsToggle'; export function AppearanceSettings() { + const { t } = useTranslation(); + // Setting store actions const toggleSetting = useSettingsStore((s) => s.toggleSetting); const updateSetting = useSettingsStore((s) => s.updateSetting); @@ -36,88 +41,159 @@ export function AppearanceSettings() { const colorModeSupported = (mode: Theme) => supportedColorModes(designLanguage).includes(mode); const zoomPercentage = useSettingsStore((s) => s.zoomPercentage); + // Language is stored independently of the settings store (see i18n/loader). + const language = loadLanguageLocale(); + // A selector with a single option is noise; show it once a second locale ships. + const showLanguageSelector = LANGUAGES.length > 1; + return (
- Appearance + {t('settings.appearance.title', 'Appearance')} + {showLanguageSelector && ( + + + + )} + updateSetting('designLanguage', value as DesignLanguage)} options={[ - { label: 'Classic', value: DesignLanguage.CLASSIC }, - { label: 'Glass', value: DesignLanguage.GLASS }, + { + label: t('settings.appearance.design_language_classic', 'Classic'), + value: DesignLanguage.CLASSIC, + }, + { + label: t('settings.appearance.design_language_glass', 'Glass'), + value: DesignLanguage.GLASS, + }, ]} - tooltip={`Choose Classic for the original ${APPLICATION.NAME} layout, or Glass for the translucent, layered design language.`} + tooltip={t( + 'settings.appearance.design_help', + 'Choose Classic for the original {{appName}} layout, or Glass for the translucent, layered design language.', + { appName: APPLICATION.NAME }, + )} value={designLanguage} /> updateSetting('theme', value as Theme)} presentation="select" selectChildren={ <> - - System + + + {t('settings.appearance.theme_system', 'System')} + - - Light default + + + {t('settings.appearance.theme_light_default', 'Light default')} + {colorModeSupported(Theme.LIGHT_COLORBLIND) && ( - Light colorblind + + {t('settings.appearance.theme_light_colorblind', 'Light colorblind')} + )} {colorModeSupported(Theme.LIGHT_TRITANOPIA) && ( - Light Tritanopia + + {t('settings.appearance.theme_light_tritanopia', 'Light Tritanopia')} + )} - - Dark default + + + {t('settings.appearance.theme_dark_default', 'Dark default')} + {colorModeSupported(Theme.DARK_COLORBLIND) && ( - Dark colorblind + + {t('settings.appearance.theme_dark_colorblind', 'Dark colorblind')} + )} {colorModeSupported(Theme.DARK_TRITANOPIA) && ( - Dark Tritanopia + + {t('settings.appearance.theme_dark_tritanopia', 'Dark Tritanopia')} + )} {colorModeSupported(Theme.DARK_DIMMED) && ( - Soft dark + + {t('settings.appearance.theme_dark_dimmed', 'Soft dark')} + )} } - tooltip="Choose a light, dark, or system color mode. Available options depend on the selected design language." + tooltip={t( + 'settings.appearance.theme_help', + 'Choose a light, dark, or system color mode. Available options depend on the selected design language.', + )} value={resolveColorMode(designLanguage, theme)} /> 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." + tooltip={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} /> toggleSetting('showStatusIconColors')} - tooltip="Color the notification type icons by their state (open, closed, done, attention). Glass renders them monochrome by default." + tooltip={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} /> decreaseZoom(zoomPercentage)} @@ -126,7 +202,7 @@ export function AppearanceSettings() { /> } @@ -301,18 +383,22 @@ export function InboxSettings() { toggleSetting('detailedNotifications')} tooltip={ - Enrich notifications with detailed user and state information. You may also - choose to display notification metrics. + {t( + 'settings.inbox.fetch_detailed_help', + 'Enrich notifications with detailed user and state information. You may also choose to display notification metrics.', + )} - Users with a large number of unread notifications may experience rate - limiting. + {t( + 'settings.inbox.fetch_detailed_caution', + 'Users with a large number of unread notifications may experience rate limiting.', + )} } @@ -330,15 +416,17 @@ export function InboxSettings() { description={ - One or more accounts are missing scopes required for notification - enrichment. Notification details may be incomplete. + {t( + 'settings.inbox.scope_warning', + 'One or more accounts are missing scopes required for notification enrichment. Notification details may be incomplete.', + )} } - title="Missing notification scopes" + title={t('settings.inbox.scope_warning_title', 'Missing notification scopes')} variant="warning" /> )} @@ -347,37 +435,39 @@ export function InboxSettings() {
toggleSetting('showPills')} tooltip={ - Show notification metrics for: + + {t('settings.inbox.show_metrics_help', 'Show notification metrics for:')} +
- linked issues + {t('settings.notifications.pill_linked_issues', 'linked issues')} - reactions + {t('settings.notifications.pill_reactions', 'reactions')} - pull request reviews + {t('settings.notifications.pill_pr_reviews', 'pull request reviews')} - comments + {t('settings.notifications.pill_comments', 'comments')} - labels + {t('settings.notifications.pill_labels', 'labels')} - milestones + {t('settings.notifications.pill_milestones', 'milestones')}
@@ -390,35 +480,50 @@ export function InboxSettings() { toggleSetting('markAsDoneOnOpen')} - tooltip="Mark as done is supported by GitHub Cloud and GitHub Enterprise Server 3.13 or later." + tooltip={t( + 'settings.inbox.mark_done_help', + 'Mark as done is supported by GitHub Cloud and GitHub Enterprise Server 3.13 or later.', + )} /> toggleSetting('markAsDoneOnUnsubscribe')} - tooltip="Mark as done is supported by GitHub Cloud and GitHub Enterprise Server 3.13 or later." + tooltip={t( + 'settings.inbox.mark_done_help', + 'Mark as done is supported by GitHub Cloud and GitHub Enterprise Server 3.13 or later.', + )} /> ) : ( - GitHub-specific options require a GitHub account. + {t( + 'settings.inbox.github_requires_account', + 'GitHub-specific options require a GitHub account.', + )} )}
diff --git a/src/renderer/components/settings/SettingsFooter.tsx b/src/renderer/components/settings/SettingsFooter.tsx index 86b7861cf..47a17dc85 100644 --- a/src/renderer/components/settings/SettingsFooter.tsx +++ b/src/renderer/components/settings/SettingsFooter.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { PersonIcon, XCircleIcon } from '@primer/octicons-react'; import { Button, IconButton, Stack, Tooltip } from '@primer/react'; @@ -13,6 +14,7 @@ import { getAppVersion } from '../../utils/system/comms'; import { openGitifyReleaseNotes } from '../../utils/system/links'; export function SettingsFooter() { + const { t } = useTranslation(); const [appVersion, setAppVersion] = useState(null); const { shortcuts } = useShortcutActions(); @@ -26,7 +28,7 @@ export function SettingsFooter() { return (
- + ); diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 3f1189f3a..2f9a4c7d0 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -1,8 +1,11 @@ import { 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'; +import { APPLICATION } from '../../../shared/constants'; + import { DEFAULT_SETTINGS_STATE, useSettingsStore, @@ -28,6 +31,7 @@ import { SettingsToggle } from './controls/SettingsToggle'; const defaultSettings = DEFAULT_SETTINGS_STATE; export function SystemSettings() { + const { t } = useTranslation(); const shortcutRegistrationError = useShortcutRegistrationStore( (s) => s.shortcutRegistrationError, ); @@ -116,40 +120,60 @@ export function SystemSettings() { 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', 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 whether opening an external link brings the destination app to the front. + {t( + 'settings.system.open_links_help', + 'Controls whether opening an external link brings the destination app to the front.', + )} - Foreground brings the opened window or browser to the - front. + ]} + defaults="<0>Foreground brings the opened window or browser to the front." + /> - Background opens the link without stealing focus from the - current window. + ]} + defaults="<0>Background opens the link without stealing focus from the current window." + /> - This applies to every link Gitify opens, including notification links opened in the - browser. + {t( + 'settings.system.open_links_help_scope', + 'This applies to every link {{appName}} opens, including notification links opened in the browser.', + { appName: APPLICATION.NAME }, + )} } @@ -162,19 +186,19 @@ export function SystemSettings() { {shortcutRegistrationError} } hideTitle - title="Shortcut error" + title={t('settings.system.shortcut_error_title', 'Shortcut error')} variant="critical" /> )} { if (keyboardShortcut) { @@ -183,44 +207,64 @@ export function SystemSettings() { } toggleSetting('keyboardShortcut'); }} - tooltip="Show or hide Gitify from anywhere with a keyboard shortcut." + tooltip={t( + 'settings.system.enable_shortcut_help', + 'Show or hide {{appName}} from anywhere with a keyboard shortcut.', + { appName: APPLICATION.NAME }, + )} />
- Shortcuts must include: + + {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.)', + )} +
@@ -229,7 +273,7 @@ export function SystemSettings() { > { @@ -241,7 +285,7 @@ export function SystemSettings() { unsafeDisableTooltip={true} /> toggleSetting('keepWindowOnBlur')} - tooltip="Keep the Gitify window open when you click outside it." + tooltip={t( + 'settings.system.keep_window_on_blur_help', + 'Keep the {{appName}} window open when you click outside it.', + { appName: APPLICATION.NAME }, + )} /> toggleSetting('openAtStartup')} - tooltip="Launch Gitify automatically at startup." + tooltip={t( + 'settings.system.open_at_startup_help', + 'Launch {{appName}} automatically at startup.', + { appName: APPLICATION.NAME }, + )} visible={!isLinux} /> toggleSetting('useX11Backend')} - tooltip="Run Gitify under X11/XWayland so the window can open next to the tray icon. Takes effect after restarting Gitify." + tooltip={t( + 'settings.system.use_x11_backend_help', + 'Run {{appName}} under X11/XWayland so the window can open next to the tray icon. Takes effect after restarting {{appName}}.', + { appName: APPLICATION.NAME }, + )} visible={isLinux} /> @@ -298,14 +357,16 @@ export function SystemSettings() { {isLinux && ( - On GNOME Wayland, install the Gitify extension to open the window next to its tray icon. - Install and manage it through GNOME's extension tools. + {t( + 'settings.system.gnome_extension_help', + "On GNOME Wayland, install the Gitify extension to open the window next to its tray icon. Install and manage it through GNOME's extension tools.", + )} )} diff --git a/src/renderer/components/settings/TraySettings.tsx b/src/renderer/components/settings/TraySettings.tsx index 11f4ea425..54d3f97c9 100644 --- a/src/renderer/components/settings/TraySettings.tsx +++ b/src/renderer/components/settings/TraySettings.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { DevicesIcon } from '@primer/octicons-react'; import { Stack, Text } from '@primer/react'; @@ -13,6 +15,7 @@ import { SettingsList } from './controls/SettingsList'; import { SettingsToggle } from './controls/SettingsToggle'; export function TraySettings() { + const { t } = useTranslation(); const updateSetting = useSettingsStore((s) => s.updateSetting); // Setting store actions @@ -26,29 +29,36 @@ export function TraySettings() { return (
- Tray + {t('settings.tray.title', 'Tray')} toggleSetting('showNotificationsCountInTray')} - tooltip="Show the unread notification count next to the tray icon. Useful for a quick glance at unread activity." + tooltip={t( + 'settings.tray.show_notification_count_help', + 'Show the unread notification count next to the tray icon. Useful for a quick glance at unread activity.', + )} visible={window.gitify.platform.isMacOS()} /> toggleSetting('useUnreadActiveIcon')} - tooltip={`Turn the ${APPLICATION.NAME} logo green while there are unread notifications.`} + tooltip={t( + 'settings.tray.highlight_unread_help', + 'Turn the {{appName}} logo green while there are unread notifications.', + { appName: APPLICATION.NAME }, + )} /> { if (isTrayIconAppearance(value)) { @@ -56,18 +66,31 @@ export function TraySettings() { } }} options={[ - { label: 'Automatic', value: 'auto' }, - { label: 'Light', value: 'light' }, - { label: 'Dark', value: 'dark' }, + { label: t('settings.tray.idle_icon_automatic', 'Automatic'), value: 'auto' }, + { label: t('settings.tray.idle_icon_light', 'Light'), value: 'light' }, + { label: t('settings.tray.idle_icon_dark', 'Dark'), value: 'dark' }, ]} tooltip={ - Pick a light icon for dark panels or a dark icon for light panels. - Automatic follows the tray appearance on macOS and Windows; Linux uses light because - panel detection is unreliable. + {t( + 'settings.tray.idle_icon_help', + 'Pick a light icon for dark panels or a dark icon for light panels.', + )} + + + {t( + 'settings.tray.idle_icon_help_automatic', + 'Automatic follows the tray appearance on macOS and Windows; Linux uses light because panel detection is unreliable.', + )} + + + {t( + 'settings.tray.idle_icon_help_theme', + 'This setting is independent of the {{appName}} app theme.', + { appName: APPLICATION.NAME }, + )} - This setting is independent of the {APPLICATION.NAME} app theme. } value={trayIconAppearance} diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index ccd9d504a..352a324b5 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -37,6 +37,7 @@ export const Constants = { ACCOUNTS: 'gitify-accounts', FILTERS: 'gitify-filters', SETTINGS: 'gitify-settings', + LANGUAGE: 'gitify-language', }, // GitHub OAuth Scopes — each tier is an ordered array of OAUTH_SCOPE objects. diff --git a/src/renderer/hooks/useNotifications.test.tsx b/src/renderer/hooks/useNotifications.test.tsx index e050fad2b..40105b920 100644 --- a/src/renderer/hooks/useNotifications.test.tsx +++ b/src/renderer/hooks/useNotifications.test.tsx @@ -24,7 +24,7 @@ import { import type { AccountNotifications, Percentage } from '../types'; -import { Errors } from '../utils/core/errors'; +import { getErrorDetails } from '../utils/core/errors'; import * as logger from '../utils/core/logger'; import { getAdapter } from '../utils/forges/registry'; import * as notificationsUtils from '../utils/notifications/notifications'; @@ -146,12 +146,12 @@ describe('renderer/hooks/useNotifications.ts', () => { { account: mockGitHubCloudAccount, notifications: [], - error: Errors.BAD_CREDENTIALS, + error: getErrorDetails('BAD_CREDENTIALS'), }, { account: mockGitHubEnterpriseServerAccount, notifications: [], - error: Errors.BAD_CREDENTIALS, + error: getErrorDetails('BAD_CREDENTIALS'), }, ]; useAccountsStore.setState({ @@ -163,7 +163,7 @@ describe('renderer/hooks/useNotifications.ts', () => { await waitFor(() => expect(result.current.status).toBe('error')); - expect(result.current.globalError).toBe(Errors.BAD_CREDENTIALS); + expect(result.current.globalError?.type).toBe('BAD_CREDENTIALS'); }); it('sets error status without global error when account errors differ', async () => { @@ -171,12 +171,12 @@ describe('renderer/hooks/useNotifications.ts', () => { { account: mockGitHubCloudAccount, notifications: [], - error: Errors.BAD_CREDENTIALS, + error: getErrorDetails('BAD_CREDENTIALS'), }, { account: mockGitHubEnterpriseServerAccount, notifications: [], - error: Errors.RATE_LIMITED, + error: getErrorDetails('RATE_LIMITED'), }, ]; useAccountsStore.setState({ diff --git a/src/renderer/i18n/index.ts b/src/renderer/i18n/index.ts new file mode 100644 index 000000000..fe7305104 --- /dev/null +++ b/src/renderer/i18n/index.ts @@ -0,0 +1,4 @@ +export * from './types'; + +export * from './loader'; +export { default } from './loader'; diff --git a/src/renderer/i18n/loader.test.ts b/src/renderer/i18n/loader.test.ts new file mode 100644 index 000000000..28d745c1d --- /dev/null +++ b/src/renderer/i18n/loader.test.ts @@ -0,0 +1,63 @@ +import { Constants } from '../constants'; + +import { DEFAULT_LANGUAGE, LANGUAGES, SUPPORTED_LANGUAGES } from './types'; + +import i18n, { loadLanguageLocale, setLanguage } from './loader'; + +describe('renderer/i18n', () => { + describe('types', () => { + it('should support English as the only language', () => { + expect(SUPPORTED_LANGUAGES).toEqual(['en']); + expect(DEFAULT_LANGUAGE).toBe('en'); + }); + + it('should expose language options for the settings selector', () => { + expect(LANGUAGES).toEqual([{ label: 'English', value: 'en' }]); + }); + }); + + describe('loadLanguageLocale', () => { + it('should return the default language when no preference is stored', () => { + localStorage.removeItem(Constants.STORAGE.LANGUAGE); + + expect(loadLanguageLocale()).toBe(DEFAULT_LANGUAGE); + }); + + it('should return the stored language preference', () => { + localStorage.setItem(Constants.STORAGE.LANGUAGE, 'en'); + + expect(loadLanguageLocale()).toBe('en'); + }); + it('should ignore a stored language that is not supported', () => { + localStorage.setItem(Constants.STORAGE.LANGUAGE, 'xx'); + + expect(loadLanguageLocale()).toBe(DEFAULT_LANGUAGE); + }); + }); + + describe('setLanguage', () => { + it('persists an explicit choice and switches the active language', async () => { + localStorage.removeItem(Constants.STORAGE.LANGUAGE); + + await setLanguage('en'); + + expect(localStorage.getItem(Constants.STORAGE.LANGUAGE)).toBe('en'); + expect(i18n.language).toBe('en'); + }); + }); + + describe('detection', () => { + it('does not persist the detected language', () => { + localStorage.removeItem(Constants.STORAGE.LANGUAGE); + + i18n.services.languageDetector.cacheUserLanguage?.('en'); + + expect(localStorage.getItem(Constants.STORAGE.LANGUAGE)).toBeNull(); + }); + + it('resolves regional locales to their base language', () => { + expect(i18n.services.languageUtils.getLanguagePartFromCode('en-GB')).toBe('en'); + expect(i18n.options.load).toBe('languageOnly'); + }); + }); +}); diff --git a/src/renderer/i18n/loader.ts b/src/renderer/i18n/loader.ts new file mode 100644 index 000000000..0c0e75612 --- /dev/null +++ b/src/renderer/i18n/loader.ts @@ -0,0 +1,70 @@ +import { initReactI18next } from 'react-i18next'; + +import i18n from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import { Constants } from '../constants'; + +import { DEFAULT_LANGUAGE, type Language, SUPPORTED_LANGUAGES } from './types'; + +// Locales +import enTranslation from './locales/en.json'; + +const locales: Record = { + en: enTranslation, +}; + +const resources = Object.fromEntries( + Object.entries(locales).map(([lng, data]) => [lng, { translation: data }]), +) as Record; + +void i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: DEFAULT_LANGUAGE, + supportedLngs: [...SUPPORTED_LANGUAGES], + // Resolve regional OS locales (e.g. `fr-FR`) to their base language. + load: 'languageOnly', + debug: process.env.NODE_ENV === 'development', + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + detection: { + // order and from where user language should be detected + order: ['localStorage', 'navigator'], + // keys or params to lookup language from + lookupLocalStorage: Constants.STORAGE.LANGUAGE, + // Only an explicit choice is persisted (see `setLanguage`), so the app + // keeps following the OS language until the user picks one. + caches: [], + }, + }); + +function isSupportedLanguage(value: string | null | undefined): value is Language { + return SUPPORTED_LANGUAGES.includes(value as Language); +} + +/** + * The language currently in effect: the user's saved choice when supported, + * otherwise the language i18next resolved from the OS. + */ +export function loadLanguageLocale(): Language { + const stored = localStorage.getItem(Constants.STORAGE.LANGUAGE); + if (isSupportedLanguage(stored)) { + return stored; + } + + return isSupportedLanguage(i18n.resolvedLanguage) ? i18n.resolvedLanguage : DEFAULT_LANGUAGE; +} + +/** + * Switch the interface language and persist it as the user's explicit choice. + */ +export async function setLanguage(language: Language): Promise { + localStorage.setItem(Constants.STORAGE.LANGUAGE, language); + await i18n.changeLanguage(language); +} + +export default i18n; diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json new file mode 100644 index 000000000..29806baab --- /dev/null +++ b/src/renderer/i18n/locales/en.json @@ -0,0 +1,549 @@ +{ + "account_scopes": { + "additional_scopes": "Additional scopes", + "detailed_notifications": "Detailed Notifications", + "detailed_notifications_scopes": "Detailed notifications scopes", + "developer_settings": "Developer settings", + "not_loaded": "Scopes not loaded — try refreshing the account.", + "required": "Required", + "required_scopes": "Required scopes", + "title": "Scopes" + }, + "accounts": { + "add_new": "Add new account", + "back": "Back", + "bad_credentials": "Invalid or expired credentials", + "click_to_set": "click to set", + "current_primary": "current primary", + "logout": "Logout {{login}}", + "my_issues": "My issues ↗", + "my_pull_requests": "My pull requests ↗", + "open_developer_settings": "Open developer settings ↗", + "open_host": "Open host ↗", + "open_profile": "Open account profile ↗", + "reauthenticate": "Re-authenticate {{login}}", + "refresh": "Refresh {{login}}", + "set_primary": "Set as primary account ({{status}})", + "title": "Accounts", + "view_scopes": "View scopes for {{login}}" + }, + "allRead": { + "heading": "No new notifications", + "heading_filtered": "No new filtered notifications" + }, + "auth": { + "method": { + "custom_oauth_app": "Custom OAuth App", + "github_cli": "GitHub CLI", + "gitify_oauth_app": "Gitify OAuth App", + "personal_access_token": "Personal Access Token" + } + }, + "chevron": { + "account": "account", + "hide": "Hide {{type}} notifications", + "none": "No notifications for {{type}}", + "repository": "repository", + "show": "Show {{type}} notifications" + }, + "common": { + "cancel": "Cancel", + "continue": "Continue", + "docs": "Docs", + "login": "Login" + }, + "errors": { + "action_forbidden": { + "description": "GitHub rejected this request for this account via Gitify.", + "title": "Action Forbidden" + }, + "bad_credentials": { + "description": "Your credentials are either invalid or expired.", + "title": "Bad Credentials" + }, + "manage_accounts": "Manage Accounts", + "missing_scopes": { + "description": "Your credentials are missing a required API scope.", + "title": "Missing Scopes" + }, + "network": { + "description1": "Unable to connect to one or more of your GitHub environments.", + "description2": "Please check your network connection, including whether you require a VPN, and try again.", + "title": "Network Error" + }, + "offline": { + "description1": "Your device is offline.", + "description2": "Please check your network connection.", + "title": "Network Offline" + }, + "rate_limited": { + "description": "Please wait a while before trying again.", + "title": "Rate Limited" + }, + "unknown": { + "description": "Please try again later.", + "title": "Oops! Something went wrong" + } + }, + "filters": { + "account": { + "help": "Filter notifications by account.", + "title": "Account" + }, + "clear_all_tooltip": "Clear all filters", + "clear_filters": "Clear filters", + "reason": { + "help": "Filter notifications by reason.", + "other": "Other", + "other_description": "Notifications with a reason that is not listed above.", + "title": "Reason" + }, + "requires_detailed_notifications": "⚠️ This filter requires the <0>Detailed Notifications setting to be enabled.", + "review_request_type": { + "direct": "Direct", + "direct_description": "You were asked to review directly.", + "help": "Filter review requests by whether you were directly requested or requested via a team. Selecting an option shows only matching review requests.", + "other": "Other", + "other_description": "Not a review request, such as a mention, comment, or assigned issue.", + "other_help": "Other shows all notifications that are not a review request, such as mentions, comments, and assigned issues.", + "team": "Team", + "team_description": "A team you belong to was asked to review.", + "title": "Review Request Type" + }, + "search": { + "author": "Author (author:handle)", + "case_insensitive": "All qualifiers match case-insensitively.", + "commenter": "Commenter (commenter:handle)", + "exclude": "Exclude", + "help": "Filter notifications by:", + "include": "Include", + "organization": "Organization (org:name)", + "repository": "Repository (repo:fullname)", + "title": "Title (title:text)", + "title_short": "Search" + }, + "state": { + "closed": "Closed", + "closed_description": "Closed, completed, duplicate, resolved, or not planned.", + "draft": "Draft", + "help": "Filter notifications by state.", + "merged": "Merged", + "open": "Open", + "open_description": "Open or reopened.", + "other": "Other", + "other_description": "Any other notification state.", + "title": "State" + }, + "subject_type": { + "advisory": "Advisory", + "agent_session_thread": "Agent Session Thread", + "bitbucket": "Bitbucket", + "check_suite": "Check Suite", + "commit": "Commit", + "dependabot_alert": "Dependabot Alert", + "discussion": "Discussion", + "gitlab_todo": "GitLab To-Do", + "help": "Filter notifications by type.", + "invitation": "Invitation", + "issue": "Issue", + "other": "Other", + "other_description": "A type not listed above.", + "pull_request": "Pull Request", + "release": "Release", + "title": "Type", + "vulnerability_alert": "Vulnerability Alert", + "workflow_run": "Workflow Run" + }, + "title": "Filters", + "user_type": { + "bot": "Bot", + "bot_description": "Bot accounts such as @copilot, @dependabot, @renovate, @netlify, etc.", + "help": "Filter notifications by user type:", + "organization": "Organization", + "other": "Other", + "other_description": "Notifications with no author, or an author that is not a User, Bot, or Organization.", + "other_help": "Other covers notifications with no author or a non-human, non-organization author.", + "title": "User Type", + "user": "User" + } + }, + "forge": { + "method": { + "atlassian_api_token": "Atlassian API Token", + "custom_oauth_app": "Custom OAuth App", + "github_cli": "GitHub CLI", + "managed_by_cli": "Managed by the GitHub CLI", + "personal_access_token": "Personal Access Token" + }, + "tagline": { + "bitbucket": "Bitbucket Cloud", + "gitea": "Gitea, Forgejo & Codeberg", + "github": "GitHub Cloud & GitHub Enterprise Server", + "gitlab": "GitLab.com & Self-Managed" + } + }, + "header": { + "go_back": "Go back" + }, + "help": { + "button_label": "Help: {{label}}" + }, + "layout": { + "initial_focus": "initial focus" + }, + "login": { + "choose_forge": "Choose a forge", + "cli": { + "form_errors": "Form errors", + "hostname": "Hostname", + "hostname_caption": "Change only if you are using GitHub Enterprise Server", + "hostname_invalid": "Hostname format is invalid", + "intro_scopes": "The scopes come from the GitHub CLI and cannot be changed from Gitify. Its <0>repo scope covers notification access; run <1>gh auth refresh -s notifications to grant that scope explicitly.", + "intro_token": "Uses the token your local <0>gh install already holds, so no token has to be created or pasted. Gitify reads it from the CLI on every refresh, which means <1>gh auth login and <2>gh auth refresh keep working without re-authenticating here.", + "login_failed": "Failed to login with GitHub CLI against {{hostname}}", + "title": "Login with GitHub CLI" + }, + "continue_with": "Continue with {{name}}", + "custom_oauth": { + "authorize_title": "Authorize with your OAuth app", + "client_id_placeholder": "Your generated client ID (20 characters)", + "create_app": "Create new OAuth app", + "enable_device_flow": "and enable its <0>device flow, then use its client ID below.", + "hostname_caption": "Change only if you are using GitHub Enterprise Server", + "intro": "Authorize Gitify with your own GitHub OAuth app. Create an app, enable its device flow, then paste its client ID below.", + "title": "Login with Custom OAuth App" + }, + "device": { + "auth_failed": "Authentication failed. Please try again.", + "authorize": "Authorize the app <0>using this code.", + "code_copied": "Code copied to clipboard", + "code_expired": "Device code expired. Please start again.", + "copy_code": "Copy device code", + "copy_link": "Copy link", + "developer_settings_link": "{{forge}} → Developer Settings", + "error_disabled": "Device flow is not enabled for this OAuth app. Enable it in the app's settings on {{hostname}}, then try again.", + "error_disabled_custom": "Device flow is not enabled for this OAuth app. Enable it in that app's settings under {{url}}, then try again.", + "error_invalid_client_id": "The client ID is not valid for {{hostname}}. Check it and try again.", + "initializing": "Initializing", + "initializing_authentication": "Initializing authentication...", + "login_errors": "Login errors", + "open": "Open {{hostname}}", + "opened": "Opened {{hostname}}", + "receive_notifications_for": "Receive notifications for:", + "revoke_note": "Note: to change previously granted permissions, revoke Gitify's access at <0>{{label}}, then re-authorize above.", + "revoke_note_custom": "Note: to change previously granted permissions, revoke your app's access at <0>{{label}}, then re-authorize above.", + "scope_full_description": "Best experience, but requires broader permissions.", + "scope_full_title": "Public and Private", + "scope_public_description": "Limited experience with least privilege permissions.", + "scope_public_title": "Public", + "scopes": "Scopes: {{scopes}}", + "start_failed": "Failed to start authentication. Please try again.", + "title": "Authorize Gitify's OAuth App", + "waiting": "Waiting for authorization..." + }, + "hide_token": "Hide token", + "oauth": { + "client_id": "Client ID", + "client_id_invalid": "Client ID format is invalid", + "client_id_required": "Client ID is required", + "docs_tooltip": "GitHub documentation", + "hostname": "Hostname" + }, + "pat": { + "atlassian_email": "Atlassian Email", + "atlassian_email_required": "Atlassian email is required", + "docs_tooltip_bitbucket": "Atlassian API token documentation", + "docs_tooltip_gitea": "Gitea API documentation", + "docs_tooltip_github": "GitHub documentation", + "docs_tooltip_gitlab": "GitLab To-Do API documentation", + "form_errors": "Form errors", + "hostname": "Hostname", + "hostname_caption_enterprise": "Change only if you are using GitHub Enterprise Server", + "hostname_caption_gitea": "Your instance hostname or origin, e.g. gitea.example.com or http://git.internal:3000. Defaults to HTTPS.", + "hostname_caption_gitlab": "Your GitLab instance hostname (for example gitlab.com)", + "hostname_invalid": "Hostname format is invalid", + "hostname_required": "Hostname is required", + "invalid_token": "Failed to validate provided token against {{hostname}}", + "scopes_note": "Use a classic personal access token; fine-grained tokens are not supported. The <0>recommended scopes will be automatically selected for you.", + "scopes_note_gitlab": "Requires the <0>api scope, or a fine-grained token with <1>Todo (read, update) and <2>User (read) permissions. GitLab shows your To-Do items, so you will see fewer notifications than on GitHub.", + "title_bitbucket": "Login to Bitbucket Cloud with API Token", + "title_gitea": "Login to Gitea with Personal Access Token", + "title_github": "Login with Personal Access Token", + "title_gitlab": "Login to GitLab with Personal Access Token", + "token": "Token", + "token_invalid": "Token format is invalid", + "token_placeholder_bitbucket": "Your Atlassian API token", + "token_placeholder_gitea": "Your Gitea personal access token", + "token_placeholder_github": "Your classic token (40 characters)", + "token_placeholder_gitlab": "Your GitLab personal access token", + "token_required": "Token is required", + "token_settings_caption_bitbucket": "on your Atlassian account to create an API token, then paste it below.", + "token_settings_caption_gitea": "on your Gitea instance to create a token, then paste it below.", + "token_settings_caption_github": "on GitHub to paste the token below.", + "token_settings_label": "Open token settings", + "token_settings_label_github": "Generate a PAT", + "username_caption_bitbucket": "Your Atlassian account email address" + }, + "show_token": "Show token", + "subtitle": "on your menu bar", + "title": "Git Notifications" + }, + "metrics": { + "comment": "comment", + "issue": "issue", + "linked_to": "Linked {{noun}}: {{issues}}", + "parent_issue": "Parent issue: #{{number}} {{title}}", + "stacked_pr": "Stacked pull request", + "stacked_pr_position": "Stacked pull request: {{position}} of {{depth}}" + }, + "notifications": { + "failure_tooltip": "{{title}}: {{description}} You can also try opening this notification in your browser.", + "mark_as_done": "Mark as done", + "mark_as_read": "Mark as read", + "unsubscribe": "Unsubscribe from thread" + }, + "reasons": { + "agent_session_finished": "Agent Session Finished", + "agent_session_finished_description": "A GitHub Copilot cloud agent session finished running.", + "approval_requested": "Approval Requested", + "approval_requested_description": "You were requested to review and approve a deployment.", + "assign": "Assigned", + "assign_description": "You were assigned to the issue.", + "author": "Authored", + "author_description": "You created the thread.", + "ci_activity": "Workflow Run Completed", + "ci_activity_description": "A GitHub Actions workflow run was triggered for your repository.", + "comment": "Commented", + "comment_description": "You commented on the thread.", + "invitation": "Invitation Received", + "invitation_description": "You accepted an invitation to contribute to the repository.", + "manual": "Updated", + "manual_description": "You subscribed to the thread (via an issue or pull request).", + "member_feature_requested": "Member Feature Requested", + "member_feature_requested_description": "Organization members have requested to enable a feature such as Draft Pull Requests or Copilot.", + "mention": "Mentioned", + "mention_description": "You were specifically @mentioned in the content.", + "review_requested": "Review Requested", + "review_requested_description": "You, or a team you're a member of, were requested to review a pull request.", + "security_advisory_credit": "Security Advisory Credit Received", + "security_advisory_credit_description": "You were credited for contributing to a security advisory.", + "security_alert": "Security Alert Received", + "security_alert_description": "GitHub discovered a security vulnerability in your repository.", + "state_change": "State Changed", + "state_change_description": "You changed the thread state (for example, closing an issue or merging a pull request).", + "subscribed": "Updated", + "subscribed_description": "You're watching the repository.", + "team_mention": "Team Mentioned", + "team_mention_description": "You were on a team that was mentioned.", + "unknown": "Unknown", + "unknown_description": "The reason for this notification is not supported by the app." + }, + "repository": { + "mark_as_done": "Mark repository as done", + "mark_as_read": "Mark repository as read", + "open": "Open repository ↗" + }, + "review": { + "approved": "{{users}} approved these changes", + "changes_requested": "{{users}} requested changes", + "commented": "{{users}} left review comments", + "dismissed_plural": "{{users}} reviews have been dismissed", + "dismissed_singular": "{{user}} review has been dismissed" + }, + "scopes": { + "status": { + "granted": "granted", + "granted_subject": "{{subject}} granted", + "missing": "missing", + "missing_subject": "{{subject}} missing", + "not_applicable": "not applicable", + "not_applicable_subject": "{{subject}} not applicable" + } + }, + "settings": { + "alerts": { + "play_sound": "Play notification sound", + "play_sound_help": "Play a sound when new unread notifications arrive after filtering. Use the notification volume controls to adjust the sound level.", + "show_desktop": "Show desktop notifications", + "show_desktop_help": "Display native operating system notifications for new unread notifications.", + "show_updates": "Show app update notifications", + "show_updates_help": "Show a notification when a {{appName}} update is ready. Updates are still checked and shown in the menu bar.", + "title": "Alerts", + "volume": "Notification volume", + "volume_down": "Volume down", + "volume_help": "Adjust the volume of the notification sound.", + "volume_percentage": "Volume percentage", + "volume_reset": "Reset volume", + "volume_up": "Volume up" + }, + "appearance": { + "design": "Design", + "design_help": "Choose Classic for the original {{appName}} layout, or Glass for the translucent, layered design language.", + "design_language_classic": "Classic", + "design_language_glass": "Glass", + "increase_contrast": "Increase contrast", + "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.", + "language": "Language", + "language_help": "Choose the language for the {{appName}} interface.", + "show_status_icon_colors": "Show status icon colors", + "show_status_icon_colors_help": "Color the notification type icons by their state (open, closed, done, attention). Glass renders them monochrome by default.", + "theme": "Theme", + "theme_dark_colorblind": "Dark colorblind", + "theme_dark_default": "Dark default", + "theme_dark_dimmed": "Soft dark", + "theme_dark_tritanopia": "Dark Tritanopia", + "theme_group_dark": "Dark", + "theme_group_light": "Light", + "theme_group_system": "System", + "theme_help": "Choose a light, dark, or system color mode. Available options depend on the selected design language.", + "theme_light_colorblind": "Light colorblind", + "theme_light_default": "Light default", + "theme_light_tritanopia": "Light Tritanopia", + "theme_system": "System", + "title": "Appearance", + "zoom": "Zoom", + "zoom_help": "Change the size of the {{appName}} interface. The setting applies immediately and is remembered for the next launch.", + "zoom_in": "Zoom in", + "zoom_out": "Zoom out", + "zoom_percentage": "Zoom percentage", + "zoom_reset": "Reset zoom" + }, + "footer": { + "accounts": "Accounts", + "quit": "Quit {{appName}}", + "release_notes": "View release notes" + }, + "inbox": { + "delay_state": "Delay notification removal", + "delay_state_help": "Keep the notification in {{appName}} after interacting with it until the next refresh window.", + "fetch_all": "Fetch all notifications", + "fetch_all_help": "When checked, fetch all notifications from your inbox.", + "fetch_all_help_unchecked": "When unchecked, fetch only the first page of notifications.", + "fetch_detailed": "Fetch detailed notifications", + "fetch_detailed_caution": "Users with a large number of unread notifications may experience rate limiting.", + "fetch_detailed_help": "Enrich notifications with detailed user and state information. You may also choose to display notification metrics.", + "fetch_interval": "Fetch interval", + "fetch_interval_help": "Check for new notifications at this interval. Shorter intervals keep the list fresher but spend more API requests.", + "fetch_read": "Fetch read and done notifications", + "fetch_read_done_caution": "GitHub's API does not distinguish between read and done states, so Mark as done actions are unavailable while this is enabled.", + "fetch_read_help": "Fetch all notifications including read and done.", + "fetch_read_rate_caution": "This increases API usage and may cause rate limiting for users with many notifications.", + "github_requires_account": "GitHub-specific options require a GitHub account.", + "group_by": "Group by", + "group_by_date": "Date", + "group_by_date_help": "<0>Date shows notifications in chronological order.", + "group_by_help": "Choose how notifications are displayed in the list.", + "group_by_repository": "Repository", + "group_by_repository_help": "<0>Repository groups notifications by repository name.", + "group_fetch": "Fetch", + "group_github": "GitHub", + "group_list": "List", + "learn_more": "Learn more", + "mark_done_caption": "Requires GitHub Cloud or GitHub Enterprise Server 3.13 or later.", + "mark_done_help": "Mark as done is supported by GitHub Cloud and GitHub Enterprise Server 3.13 or later.", + "mark_done_open": "Mark as done on open", + "mark_done_unsubscribe": "Mark as done on unsubscribe", + "participating": "Fetch only participating", + "participating_help": "When checked, fetch only participating notifications.", + "participating_help_unchecked": "When unchecked, fetch participating and watching notifications.", + "reference_commit": "Commit", + "reference_discussion": "Discussion", + "reference_issue": "Issue", + "reference_pull_request": "Pull request", + "reference_release": "Release", + "review_account_scopes": "Review account scopes", + "scope_warning": "One or more accounts are missing scopes required for notification enrichment. Notification details may be incomplete.", + "scope_warning_title": "Missing notification scopes", + "show_account_header": "Show account header", + "show_account_header_help": "Show your avatar, username, and quick links above the notifications list.", + "show_metrics": "Show notification metrics", + "show_metrics_help": "Show notification metrics for:", + "show_reference": "Show reference", + "show_reference_help": "Show the reference where available:", + "title": "Inbox", + "wrap_notification_title": "Show full notification title", + "wrap_notification_title_help": "Wrap long notification titles onto multiple lines instead of truncating them." + }, + "notifications": { + "fetch_interval_decrease": "Decrease fetch interval", + "fetch_interval_display": "Fetch interval", + "fetch_interval_increase": "Increase fetch interval", + "fetch_interval_reset": "Reset fetch interval", + "participating_docs_title": "Open GitHub documentation for participating and watching notifications", + "pill_comments": "comments", + "pill_labels": "labels", + "pill_linked_issues": "linked issues", + "pill_milestones": "milestones", + "pill_pr_reviews": "pull request reviews", + "pill_reactions": "reactions" + }, + "reset": { + "confirm": "Reset", + "confirm_content": "Please confirm that you want to reset all settings to the {{appName}} defaults", + "confirm_title": "Reset settings?", + "title": "Reset Settings" + }, + "system": { + "current_shortcut": "Current shortcut", + "edit_shortcut": "Edit global shortcut", + "enable_shortcut": "Enable global shortcut", + "enable_shortcut_help": "Show or hide {{appName}} from anywhere with a keyboard shortcut.", + "gnome_extension_button": "GNOME extension installation", + "gnome_extension_help": "On GNOME Wayland, install the Gitify extension to open the window next to its tray icon. Install and manage it through GNOME's extension tools.", + "keep_window_on_blur": "Keep window open when unfocused", + "keep_window_on_blur_help": "Keep the {{appName}} window open when you click outside it.", + "keyboard_shortcut_help_modifiers": "Shortcuts must include:", + "keyboard_shortcut_non_modifier": "A non-modifier key (letter, number, etc.)", + "keyboard_shortcut_optional_modifiers": "Optional modifiers: <0>{{modifier1}}, <1>{{modifier2}}", + "keyboard_shortcut_primary_modifier": "Primary modifier: <0>{{accelerator}}", + "link_focus": "Link focus", + "open_at_startup": "Launch at startup", + "open_at_startup_help": "Launch {{appName}} automatically at startup.", + "open_links_background": "Background", + "open_links_background_help": "<0>Background opens the link without stealing focus from the current window.", + "open_links_foreground": "Foreground", + "open_links_foreground_help": "<0>Foreground brings the opened window or browser to the front.", + "open_links_help": "Controls whether opening an external link brings the destination app to the front.", + "open_links_help_scope": "This applies to every link {{appName}} opens, including notification links opened in the browser.", + "press_keys": "Press keys…", + "reset_shortcut": "Reset global shortcut to default", + "shortcut_error_dismiss": "Dismiss", + "shortcut_error_title": "Shortcut error", + "shortcut_label": "Global shortcut", + "title": "System", + "use_x11_backend": "Use X11 backend (restart required)", + "use_x11_backend_help": "Run {{appName}} under X11/XWayland so the window can open next to the tray icon. Takes effect after restarting {{appName}}." + }, + "title": "Settings", + "tray": { + "highlight_unread": "Highlight unread notifications", + "highlight_unread_help": "Turn the {{appName}} logo green while there are unread notifications.", + "idle_icon": "Idle icon", + "idle_icon_automatic": "Automatic", + "idle_icon_dark": "Dark", + "idle_icon_help": "Pick a light icon for dark panels or a dark icon for light panels.", + "idle_icon_help_automatic": "Automatic follows the tray appearance on macOS and Windows; Linux uses light because panel detection is unreliable.", + "idle_icon_help_theme": "This setting is independent of the {{appName}} app theme.", + "idle_icon_light": "Light", + "show_notification_count": "Show notification count", + "show_notification_count_help": "Show the unread notification count next to the tray icon. Useful for a quick glance at unread activity.", + "title": "Tray" + } + }, + "sidebar": { + "filter_notifications": "Filter notifications", + "filters": "Filters", + "focused": "Focused (participating only)", + "focused_mode": "Toggle focused mode", + "home": "Home", + "my_issues": "My issues ↗", + "my_pull_requests": "My pull requests ↗", + "notifications": "Notifications", + "notifications_count": "{{count}} notifications ↗", + "participating_watching": "Participating and watching", + "refresh": "Refresh", + "refresh_notifications": "Refresh notifications", + "settings": "Settings", + "unread_notifications_count": "{{count}} unread notifications ↗" + } +} diff --git a/src/renderer/i18n/types.ts b/src/renderer/i18n/types.ts new file mode 100644 index 000000000..9f7354156 --- /dev/null +++ b/src/renderer/i18n/types.ts @@ -0,0 +1,21 @@ +export const DEFAULT_LANGUAGE: Language = 'en'; + +export const SUPPORTED_LANGUAGES = ['en'] as const; + +/** + * The different types of allowed languages. + */ +export type Language = (typeof SUPPORTED_LANGUAGES)[number]; + +const LANGUAGE_LABELS: Record = { + en: 'English', +}; + +interface LanguageOption { + label: string; + value: Language; +} + +export const LANGUAGES: LanguageOption[] = Object.entries(LANGUAGE_LABELS).map( + ([value, label]) => ({ label, value: value as Language }), +); diff --git a/src/renderer/icons/ScopeStatusIcon.tsx b/src/renderer/icons/ScopeStatusIcon.tsx index f345f938e..86387dea3 100644 --- a/src/renderer/icons/ScopeStatusIcon.tsx +++ b/src/renderer/icons/ScopeStatusIcon.tsx @@ -1,5 +1,7 @@ import { CheckCircleFillIcon, DashIcon, XCircleFillIcon } from '@primer/octicons-react'; +import i18n from '../i18n'; + export interface ScopeStatusIconProps { granted: boolean; notApplicable?: boolean; @@ -14,11 +16,7 @@ export function ScopeStatusIcon({ size = 14, subject, }: Readonly) { - let status = granted ? 'granted' : 'missing'; - if (notApplicable) { - status = 'not applicable'; - } - const ariaLabel = subject ? `${subject} ${status}` : status; + const ariaLabel = getScopeStatusLabel(granted, notApplicable, subject); if (notApplicable) { return ; @@ -30,3 +28,19 @@ export function ScopeStatusIcon({ ); } + +function getScopeStatusLabel(granted: boolean, notApplicable: boolean, subject?: string): string { + if (notApplicable) { + return subject + ? i18n.t('scopes.status.not_applicable_subject', '{{subject}} not applicable', { subject }) + : i18n.t('scopes.status.not_applicable', 'not applicable'); + } + if (granted) { + return subject + ? i18n.t('scopes.status.granted_subject', '{{subject}} granted', { subject }) + : i18n.t('scopes.status.granted', 'granted'); + } + return subject + ? i18n.t('scopes.status.missing_subject', '{{subject}} missing', { subject }) + : i18n.t('scopes.status.missing', 'missing'); +} diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 9b6cb2142..cc8a47cfd 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -1,5 +1,6 @@ import { createRoot } from 'react-dom/client'; +import './i18n'; import { App } from './App'; const container = document.getElementById('root'); diff --git a/src/renderer/routes/AccountScopes.tsx b/src/renderer/routes/AccountScopes.tsx index 84b7638f5..e6bca76be 100644 --- a/src/renderer/routes/AccountScopes.tsx +++ b/src/renderer/routes/AccountScopes.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import { AlertFillIcon, LinkExternalIcon, ShieldCheckIcon } from '@primer/octicons-react'; @@ -27,6 +28,7 @@ interface LocationState { } export function AccountScopesRoute() { + const { t } = useTranslation(); const location = useLocation(); const { account } = location.state as LocationState; @@ -50,7 +52,7 @@ export function AccountScopesRoute() { return ( -
Scopes
+
{t('account_scopes.title', 'Scopes')}
{!scopesLoaded && ( @@ -62,7 +64,9 @@ export function AccountScopesRoute() { gap="condensed" > - Scopes not loaded — try refreshing the account. + + {t('account_scopes.not_loaded', 'Scopes not loaded — try refreshing the account.')} + )} @@ -75,8 +79,12 @@ export function AccountScopesRoute() { direction="horizontal" gap="condensed" > - - Required + + {t('account_scopes.required', 'Required')} {managedElsewhere ? ( @@ -127,9 +135,14 @@ export function AccountScopesRoute() { - Detailed Notifications + + {t('account_scopes.detailed_notifications', 'Detailed Notifications')} + {/* repo row */} @@ -181,7 +194,7 @@ export function AccountScopesRoute() { gap="condensed" > - Additional scopes + {t('account_scopes.additional_scopes', 'Additional scopes')}
diff --git a/src/renderer/routes/Accounts.test.tsx b/src/renderer/routes/Accounts.test.tsx index 2c760d723..b456dc688 100644 --- a/src/renderer/routes/Accounts.test.tsx +++ b/src/renderer/routes/Accounts.test.tsx @@ -17,7 +17,7 @@ import { getRequiredScopeNames, } from '../utils/auth/scopes'; import { getAccountUUID } from '../utils/auth/utils'; -import { Errors } from '../utils/core/errors'; +import { getErrorDetails } from '../utils/core/errors'; import * as logger from '../utils/core/logger'; import * as comms from '../utils/system/comms'; import * as links from '../utils/system/links'; @@ -461,7 +461,7 @@ describe('renderer/routes/Accounts.tsx', () => { { account: mockPersonalAccessTokenAccount, notifications: [], - error: Errors.BAD_CREDENTIALS, + error: getErrorDetails('BAD_CREDENTIALS'), }, ], }); @@ -486,7 +486,7 @@ describe('renderer/routes/Accounts.tsx', () => { { account: mockGitifyOAuthAppAccount, notifications: [], - error: Errors.BAD_CREDENTIALS, + error: getErrorDetails('BAD_CREDENTIALS'), }, ], }); @@ -509,7 +509,7 @@ describe('renderer/routes/Accounts.tsx', () => { { account: mockCustomOAuthAppAccount, notifications: [], - error: Errors.BAD_CREDENTIALS, + error: getErrorDetails('BAD_CREDENTIALS'), }, ], }); @@ -532,7 +532,7 @@ describe('renderer/routes/Accounts.tsx', () => { { account: mockGiteaAccount, notifications: [], - error: Errors.BAD_CREDENTIALS, + error: getErrorDetails('BAD_CREDENTIALS'), }, ], }); @@ -562,7 +562,7 @@ describe('renderer/routes/Accounts.tsx', () => { { account: unsupportedAccount, notifications: [], - error: Errors.BAD_CREDENTIALS, + error: getErrorDetails('BAD_CREDENTIALS'), }, ], }); @@ -590,7 +590,7 @@ describe('renderer/routes/Accounts.tsx', () => { { account: mockPersonalAccessTokenAccount, notifications: [], - error: Errors.BAD_CREDENTIALS, + error: getErrorDetails('BAD_CREDENTIALS'), }, ], }); diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx index 868e688ca..a64e9fb75 100644 --- a/src/renderer/routes/Accounts.tsx +++ b/src/renderer/routes/Accounts.tsx @@ -1,4 +1,5 @@ import { type FC, useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { @@ -35,8 +36,13 @@ import type { ForgeAdapter } from '../utils/forges/types'; import { determineFailureType } from '../utils/api/errors'; import { hasAlternateScopes, hasRecommendedScopes } from '../utils/auth/scopes'; import { getAccountUUID } from '../utils/auth/utils'; -import { Errors } from '../utils/core/errors'; +import { isErrorType } from '../utils/core/errors'; import { rendererLogError, toError } from '../utils/core/logger'; +import { + translateAuthMethod, + translateForgeTagline, + translateLoginMethodLabel, +} from '../utils/forges/labels'; import { getAdapter, listAdapters } from '../utils/forges/registry'; import { openAccountProfile, openAccountSettings, openHost } from '../utils/system/links'; import { getPlatformIcon } from '../utils/ui/icons'; @@ -60,6 +66,7 @@ function getScopeIcon(account: Account): FC { } export function AccountsRoute() { + const { t } = useTranslation(); const navigate = useNavigate(); const { logoutFromAccount } = useLogins(); @@ -154,7 +161,7 @@ export function AccountsRoute() { return ( -
Accounts
+
{t('accounts.title', 'Accounts')}
{accounts.map((account, i) => { @@ -163,7 +170,7 @@ export function AccountsRoute() { const PlatformIcon = getPlatformIcon(account.platform); const accountUUID = getAccountUUID(account); const accountError = getAccountError(account); - const hasBadCredentials = accountError === Errors.BAD_CREDENTIALS; + const hasBadCredentials = isErrorType(accountError, 'BAD_CREDENTIALS'); return (
@@ -172,7 +179,7 @@ export function AccountsRoute() { @@ -331,7 +353,7 @@ export function AccountsRoute() { - Back + {t('accounts.back', 'Back')} @@ -355,7 +377,7 @@ export function AccountsRoute() { - {method.label} + {translateLoginMethodLabel(method.label)} ); })} @@ -385,7 +407,7 @@ export function AccountsRoute() { {adapter.displayName} - {adapter.tagline} + {translateForgeTagline(adapter.tagline)} {hasMultipleMethods && ( diff --git a/src/renderer/routes/Filters.tsx b/src/renderer/routes/Filters.tsx index 0ec0cfc41..bb5f59e1d 100644 --- a/src/renderer/routes/Filters.tsx +++ b/src/renderer/routes/Filters.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { FilterIcon, FilterRemoveIcon } from '@primer/octicons-react'; import { Button, Stack, Tooltip } from '@primer/react'; @@ -16,6 +18,7 @@ import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; export function FiltersRoute() { + const { t } = useTranslation(); const clearFilters = useFiltersStore((s) => s.reset); const hasMultipleAccounts = useAccountsStore((s) => s.hasMultipleAccounts()); @@ -23,7 +26,7 @@ export function FiltersRoute() { return (
- Filters + {t('filters.title', 'Filters')}
@@ -39,9 +42,9 @@ export function FiltersRoute() {
- +
diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx index 6a9316f44..47970e1ae 100644 --- a/src/renderer/routes/Login.tsx +++ b/src/renderer/routes/Login.tsx @@ -1,4 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { Button, Heading, Stack, Text } from '@primer/react'; @@ -13,6 +14,7 @@ import { type Forge, Size } from '../types'; import type { ForgeAdapter, LoginMethodDescriptor } from '../utils/forges/types'; import { LogoIcon } from '../icons/LogoIcon'; +import { translateForgeTagline, translateLoginMethodLabel } from '../utils/forges/labels'; import { listAdapters } from '../utils/forges/registry'; import { showWindow } from '../utils/system/comms'; @@ -25,6 +27,7 @@ function pickPrimaryMethod(adapter: ForgeAdapter): LoginMethodDescriptor | undef } export function LoginRoute() { + const { t } = useTranslation(); const navigate = useNavigate(); const adapters = listAdapters(); @@ -94,13 +97,13 @@ export function LoginRoute() { gap="none" style={{ animationDelay: '60ms' }} > - Git Notifications - on your menu bar + {t('login.title', 'Git Notifications')} + {t('login.subtitle', 'on your menu bar')} {adapters.length > 1 && (
navigate(primary.route)} variant="primary" > - Continue with {activeAdapter.displayName} + {t('login.continue_with', 'Continue with {{name}}', { + name: activeAdapter.displayName, + })} )} @@ -188,7 +193,7 @@ export function LoginRoute() { onClick={() => navigate(method.route)} variant={method.variant ?? 'invisible'} > - {method.label} + {translateLoginMethodLabel(method.label)} ))} @@ -198,7 +203,7 @@ export function LoginRoute() { className="pt-1 text-center text-[var(--fgColor-muted)] text-xxs" size="small" > - {activeAdapter.tagline} + {translateForgeTagline(activeAdapter.tagline)} )} diff --git a/src/renderer/routes/Notifications.test.tsx b/src/renderer/routes/Notifications.test.tsx index f2f72cf5c..d006dda72 100644 --- a/src/renderer/routes/Notifications.test.tsx +++ b/src/renderer/routes/Notifications.test.tsx @@ -4,7 +4,7 @@ import { renderWithProviders } from '../__helpers__/test-utils'; import { mockMultipleAccountNotifications } from '../__mocks__/notifications-mocks'; import { mockSettings } from '../__mocks__/state-mocks'; -import { Errors } from '../utils/core/errors'; +import { getErrorDetails } from '../utils/core/errors'; import { NotificationsRoute } from './Notifications'; vi.mock('../components/notifications/NotificationList', () => ({ @@ -82,7 +82,7 @@ describe('renderer/routes/Notifications.tsx', () => { it('prefers the error state over loading', () => { renderWithProviders(, { - globalError: Errors.UNKNOWN, + globalError: getErrorDetails('UNKNOWN'), status: 'error', }); @@ -108,37 +108,15 @@ describe('renderer/routes/Notifications.tsx', () => { }); describe('error conditions - oops', () => { - it('bad credentials', () => { + it.each([ + ['bad credentials', 'BAD_CREDENTIALS'], + ['missing scopes', 'MISSING_SCOPES'], + ['rate limited', 'RATE_LIMITED'], + ['unknown error', 'UNKNOWN'], + ] as const)('%s', (_name, errorType) => { const tree = renderWithProviders(, { status: 'error', - globalError: Errors.BAD_CREDENTIALS, - }); - - expect(tree.container).toMatchSnapshot(); - }); - - it('missing scopes', () => { - const tree = renderWithProviders(, { - status: 'error', - globalError: Errors.MISSING_SCOPES, - }); - - expect(tree.container).toMatchSnapshot(); - }); - - it('rate limited', () => { - const tree = renderWithProviders(, { - status: 'error', - globalError: Errors.RATE_LIMITED, - }); - - expect(tree.container).toMatchSnapshot(); - }); - - it('unknown error', () => { - const tree = renderWithProviders(, { - status: 'error', - globalError: Errors.UNKNOWN, + globalError: getErrorDetails(errorType), }); expect(tree.container).toMatchSnapshot(); diff --git a/src/renderer/routes/Notifications.tsx b/src/renderer/routes/Notifications.tsx index ba8f2e203..93441ddc5 100644 --- a/src/renderer/routes/Notifications.tsx +++ b/src/renderer/routes/Notifications.tsx @@ -11,7 +11,7 @@ import { NotificationList } from '../components/notifications/NotificationList'; import { Oops } from '../components/Oops'; import { getAccountUUID } from '../utils/auth/utils'; -import { Errors } from '../utils/core/errors'; +import { getErrorDetails } from '../utils/core/errors'; import { hasNoAccountErrors } from '../utils/notifications/status'; export function NotificationsRoute() { @@ -36,11 +36,11 @@ export function NotificationsRoute() { ); if (!isOnline) { - return ; + return ; } if (status === 'error') { - return ; + return ; } if (status === 'loading') { diff --git a/src/renderer/routes/NotificationsStates.visual.test.tsx b/src/renderer/routes/NotificationsStates.visual.test.tsx index cd5765689..e63d605da 100644 --- a/src/renderer/routes/NotificationsStates.visual.test.tsx +++ b/src/renderer/routes/NotificationsStates.visual.test.tsx @@ -8,7 +8,7 @@ import { mockGitHubCloudAccount } from '../__mocks__/account-mocks'; import { DesignLanguage, Theme } from '../types'; -import { Errors } from '../utils/core/errors'; +import { getErrorDetails } from '../utils/core/errors'; import { NotificationsRoute } from './Notifications'; const THEME_VARIANTS = [ @@ -39,7 +39,8 @@ const STATES = [ { name: 'error', heading: 'Network Error', - prepare: () => setNotificationsOverrides({ globalError: Errors.NETWORK, status: 'error' }), + prepare: () => + setNotificationsOverrides({ globalError: getErrorDetails('NETWORK'), status: 'error' }), }, ] as const; diff --git a/src/renderer/routes/Settings.tsx b/src/renderer/routes/Settings.tsx index c54610888..0bd4e5124 100644 --- a/src/renderer/routes/Settings.tsx +++ b/src/renderer/routes/Settings.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { GearIcon } from '@primer/octicons-react'; import { Stack } from '@primer/react'; @@ -13,10 +15,12 @@ import { SystemSettings } from '../components/settings/SystemSettings'; import { TraySettings } from '../components/settings/TraySettings'; export function SettingsRoute() { + const { t } = useTranslation(); + return (
- Settings + {t('settings.title', 'Settings')}
diff --git a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-dark-classic-chromium-linux.png index a40eaedf5..e168a84dd 100644 Binary files a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-dark-glass-chromium-linux.png index 04832f1c9..cc2dfd147 100644 Binary files a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-dark-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-light-classic-chromium-linux.png index fe4fd5b72..cafc708bc 100644 Binary files a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-light-glass-chromium-linux.png index 5e324c9f7..e6b40371a 100644 Binary files a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-light-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-cli-error-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-dark-classic-chromium-linux.png index 80892786f..b909a22cc 100644 Binary files a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-dark-glass-chromium-linux.png index cc4061161..7163719cf 100644 Binary files a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-dark-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-light-classic-chromium-linux.png index f466cbb65..119c739b3 100644 Binary files a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-light-glass-chromium-linux.png index 2533de46b..ddea721a9 100644 Binary files a/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-light-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/LoginStates.visual.test.tsx/login-pat-error-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-classic-chromium-linux.png index cd61114b5..a1802a9cb 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-glass-chromium-linux.png index 4e485b486..07eebc3ef 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-classic-chromium-linux.png index b1f51d313..16f0c2a82 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-glass-chromium-linux.png index 9ede084c4..242269fec 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-classic-chromium-linux.png index 401a8400e..6ea6c266c 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-glass-chromium-linux.png index 74ff301b5..be07ad16d 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-classic-chromium-linux.png index 5258a6168..76fb9a10b 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-glass-chromium-linux.png index 6bdd08a2f..bc5ec44d3 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-device-flow-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-classic-chromium-linux.png index 79fc66f47..94486aba0 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-glass-chromium-linux.png index a1d8ec991..7d829c7b2 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-classic-chromium-linux.png index b8ab9fc44..8202ed662 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-glass-chromium-linux.png index 98249ed40..4ff4f597f 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-personal-access-token-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-dark-classic-chromium-linux.png index 31d516f54..3f9efe9b3 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-dark-glass-chromium-linux.png index fedfc5c0a..08eac45bb 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-dark-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-light-classic-chromium-linux.png index c71496e0d..2f5d441ec 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-light-glass-chromium-linux.png index 13e1a40d8..aaeb090e9 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-light-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-gitlab-personal-access-token-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.tsx index fa1e2a660..2b91d509b 100644 --- a/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.tsx +++ b/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.tsx @@ -1,17 +1,27 @@ +import { useTranslation } from 'react-i18next'; + import { LoginWithPersonalAccessTokenForm } from '../../components/login/LoginWithPersonalAccessTokenForm'; export function BitbucketLoginWithPersonalAccessTokenRoute() { + const { t } = useTranslation(); + return ( ); diff --git a/src/renderer/routes/gitea/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/gitea/LoginWithPersonalAccessToken.tsx index d806605b4..176c71391 100644 --- a/src/renderer/routes/gitea/LoginWithPersonalAccessToken.tsx +++ b/src/renderer/routes/gitea/LoginWithPersonalAccessToken.tsx @@ -1,16 +1,26 @@ +import { useTranslation } from 'react-i18next'; + import { LoginWithPersonalAccessTokenForm } from '../../components/login/LoginWithPersonalAccessTokenForm'; export function GiteaLoginWithPersonalAccessTokenRoute() { + const { t } = useTranslation(); + return ( ); } diff --git a/src/renderer/routes/github/DeviceFlowAuthorize.tsx b/src/renderer/routes/github/DeviceFlowAuthorize.tsx index c547d696b..b2ffd706c 100644 --- a/src/renderer/routes/github/DeviceFlowAuthorize.tsx +++ b/src/renderer/routes/github/DeviceFlowAuthorize.tsx @@ -1,4 +1,5 @@ import { type ReactNode, useCallback, useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { @@ -23,6 +24,7 @@ import { Header } from '../../components/primitives/Header'; import type { Account, ClientID, Forge, Hostname, Link } from '../../types'; import type { DeviceFlowSession } from '../../utils/auth/types'; +import i18n from '../../i18n'; import { getAlternateScopeNames, getRecommendedScopeNames } from '../../utils/auth/scopeNames'; import { rendererLogError, toError } from '../../utils/core/logger'; import { getAdapter } from '../../utils/forges/registry'; @@ -48,6 +50,7 @@ export function GitHubDeviceFlowAuthorize({ hostname, title, }: Readonly) { + const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const { account: reAuthAccount } = (location.state ?? {}) as LocationState; @@ -90,7 +93,10 @@ export function GitHubDeviceFlowAuthorize({ rendererLogError('LoginWithDeviceFlow', 'Failed to start device flow', toError(err)); setError( getAdapter(forge).deviceFlow?.describeStartError?.(err, resolvedHostname, clientId) ?? - 'Failed to start authentication. Please try again.', + i18n.t( + 'login.device.start_failed', + 'Failed to start authentication. Please try again.', + ), ); } }; @@ -115,7 +121,9 @@ export function GitHubDeviceFlowAuthorize({ const poll = async (): Promise => { if (!isActive || Date.now() >= session.expiresAt) { if (isActive) { - setError('Device code expired. Please start again.'); + setError( + i18n.t('login.device.code_expired', 'Device code expired. Please start again.'), + ); } return; } @@ -140,7 +148,7 @@ export function GitHubDeviceFlowAuthorize({ } catch (err) { if (isActive) { rendererLogError('LoginWithDeviceFlow', 'Failed to poll device flow', toError(err)); - setError('Authentication failed. Please try again.'); + setError(i18n.t('login.device.auth_failed', 'Authentication failed. Please try again.')); } } finally { if (isActive) { @@ -187,7 +195,11 @@ export function GitHubDeviceFlowAuthorize({ return ( - Authorize the app using this code. + ]} + defaults="Authorize the app <0>using this code." + /> @@ -204,7 +216,7 @@ export function GitHubDeviceFlowAuthorize({ {session.userCode} - Code copied to clipboard + {t('login.device.code_copied', 'Code copied to clipboard')} - Opened{' '} - {session.hostname === Constants.GITHUB_HOSTNAME ? 'GitHub.com' : session.hostname} + {t('login.device.opened', 'Opened {{hostname}}', { + hostname: + session.hostname === Constants.GITHUB_HOSTNAME ? 'GitHub.com' : session.hostname, + })} - Waiting for authorization... + {t('login.device.waiting', 'Waiting for authorization...')} @@ -237,7 +251,9 @@ export function GitHubDeviceFlowAuthorize({ const renderScopeChoiceUI = () => ( - Receive notifications for: + + {t('login.device.receive_notifications_for', 'Receive notifications for:')} + @@ -272,22 +302,40 @@ export function GitHubDeviceFlowAuthorize({ if (!revokeUrl) { return null; } - const label = `${adapter.displayName} → Developer Settings`; + const label = t( + 'login.device.developer_settings_link', + '{{forge}} → Developer Settings', + { + forge: adapter.displayName, + }, + ); + const revokeLink = ( + - {', then re-authorize above.'} + {clientId ? ( + + ) : ( + + )} ); @@ -299,13 +347,13 @@ export function GitHubDeviceFlowAuthorize({ const renderInitializingUI = () => ( - Initializing authentication... + {t('login.device.initializing_authentication', 'Initializing authentication...')} ); @@ -320,7 +368,9 @@ export function GitHubDeviceFlowAuthorize({ return ( -
{title ?? "Authorize Gitify's OAuth App"}
+
+ {title ?? t('login.device.title', "Authorize Gitify's OAuth App")} +
{error && ( @@ -334,7 +384,7 @@ export function GitHubDeviceFlowAuthorize({ } hideTitle - title="Login errors" + title={t('login.device.login_errors', 'Login errors')} variant="critical" /> )} @@ -344,21 +394,24 @@ export function GitHubDeviceFlowAuthorize({ {session ? (
) : (
)} diff --git a/src/renderer/routes/github/LoginWithCustomOAuthApp.tsx b/src/renderer/routes/github/LoginWithCustomOAuthApp.tsx index 8d610dbdc..4ccc645cd 100644 --- a/src/renderer/routes/github/LoginWithCustomOAuthApp.tsx +++ b/src/renderer/routes/github/LoginWithCustomOAuthApp.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import { BookIcon, PersonIcon } from '@primer/octicons-react'; @@ -33,6 +34,7 @@ interface LocationState { * secret), then hands off to the shared device-flow authorization UI. */ export function GitHubLoginWithCustomOAuthAppRoute() { + const { t } = useTranslation(); const location = useLocation(); const { account: reAuthAccount } = (location.state ?? {}) as LocationState; @@ -65,26 +67,35 @@ export function GitHubLoginWithCustomOAuthAppRoute() { ); } return ( -
Login with Custom OAuth App
+
+ {t('login.custom_oauth.title', 'Login with Custom OAuth App')} +
- Authorize Gitify with your own GitHub OAuth app. Create an app, enable its device flow, - then paste its client ID below. + {t( + 'login.custom_oauth.intro', + 'Authorize Gitify with your own GitHub OAuth app. Create an app, enable its device flow, then paste its client ID below.', + )} - Hostname + {t('login.oauth.hostname', 'Hostname')} - Change only if you are using GitHub Enterprise Server + + {t( + 'login.custom_oauth.hostname_caption', + 'Change only if you are using GitHub Enterprise Server', + )} + - Create new OAuth app + {t('login.custom_oauth.create_app', 'Create new OAuth app')} - and enable its device flow, then use its client ID below. + ]} + defaults="and enable its <0>device flow, then use its client ID below." + i18nKey="login.custom_oauth.enable_device_flow" + /> - Client ID + {t('login.oauth.client_id', 'Client ID')} {errors.clientId && ( @@ -144,18 +162,18 @@ export function GitHubLoginWithCustomOAuthAppRoute() {
- +
diff --git a/src/renderer/routes/github/LoginWithGitHubCLI.tsx b/src/renderer/routes/github/LoginWithGitHubCLI.tsx index 34f7205e7..3979a6d2a 100644 --- a/src/renderer/routes/github/LoginWithGitHubCLI.tsx +++ b/src/renderer/routes/github/LoginWithGitHubCLI.tsx @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { SignInIcon, TerminalIcon } from '@primer/octicons-react'; @@ -15,6 +16,7 @@ import { Header } from '../../components/primitives/Header'; import type { Account, Hostname } from '../../types'; +import i18n from '../../i18n'; import { summarizeLoginError } from '../../utils/auth/loginValidation'; import { isValidHostname } from '../../utils/auth/utils'; import { rendererLogError, toError } from '../../utils/core/logger'; @@ -24,6 +26,7 @@ interface LocationState { } export function GitHubLoginWithCLIRoute() { + const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const { account: reAuthAccount } = (location.state ?? {}) as LocationState; @@ -38,7 +41,7 @@ export function GitHubLoginWithCLIRoute() { const handleSubmit = useCallback(async () => { if (!hostname || !isValidHostname(hostname)) { - setError('Hostname format is invalid'); + setError(i18n.t('login.cli.hostname_invalid', 'Hostname format is invalid')); return; } @@ -50,7 +53,12 @@ export function GitHubLoginWithCLIRoute() { void navigate('/'); } catch (err) { rendererLogError('loginWithCli', 'Failed to login with GitHub CLI', toError(err)); - setError(summarizeLoginError(err) ?? `Failed to login with GitHub CLI against ${hostname}`); + setError( + summarizeLoginError(err) ?? + i18n.t('login.cli.login_failed', 'Failed to login with GitHub CLI against {{hostname}}', { + hostname, + }), + ); } finally { setIsLoggingIn(false); } @@ -58,7 +66,7 @@ export function GitHubLoginWithCLIRoute() { return ( -
Login with GitHub CLI
+
{t('login.cli.title', 'Login with GitHub CLI')}
{error && ( @@ -66,29 +74,45 @@ export function GitHubLoginWithCLIRoute() { data-testid="login-errors" description={{error}} hideTitle - title="Form errors" + title={t('login.cli.form_errors', 'Form errors')} variant="critical" /> )} - Uses the token your local gh install already holds, so no token has - to be created or pasted. Gitify reads it from the CLI on every refresh, which means{' '} - gh auth login and gh auth refresh keep working - without re-authenticating here. + , + , + , + ]} + defaults="Uses the token your local <0>gh install already holds, so no token has to be created or pasted. Gitify reads it from the CLI on every refresh, which means <1>gh auth login and <2>gh auth refresh keep working without re-authenticating here." + /> - The scopes come from the GitHub CLI and cannot be changed from Gitify. Its{' '} - repo scope covers notification access; run{' '} - gh auth refresh -s notifications to grant that scope explicitly. + , + , + , + ]} + defaults="The scopes come from the GitHub CLI and cannot be changed from Gitify. Its <0>repo scope covers notification access; run <1>gh auth refresh -s notifications to grant that scope explicitly." + /> - Hostname + {t('login.cli.hostname', 'Hostname')} - Change only if you are using GitHub Enterprise Server + + {t( + 'login.cli.hostname_caption', + 'Change only if you are using GitHub Enterprise Server', + )} + - Login + {t('common.login', 'Login')}
diff --git a/src/renderer/routes/github/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/github/LoginWithPersonalAccessToken.tsx index 5d5d8c895..61b7c89a2 100644 --- a/src/renderer/routes/github/LoginWithPersonalAccessToken.tsx +++ b/src/renderer/routes/github/LoginWithPersonalAccessToken.tsx @@ -1,29 +1,51 @@ +import type { ReactNode } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + import { Text, Tooltip } from '@primer/react'; import { LoginWithPersonalAccessTokenForm } from '../../components/login/LoginWithPersonalAccessTokenForm'; import { formatRecommendedOAuthScopes } from '../../utils/auth/scopes'; +function RecommendedScopesLink({ children }: Readonly<{ children?: ReactNode }>) { + return ( + + + + ); +} + export function GitHubLoginWithPersonalAccessTokenRoute() { + const { t } = useTranslation(); + return ( - Use a classic personal access token; fine-grained tokens are not supported. The{' '} - - - {' '} - will be automatically selected for you. + ]} + defaults="Use a classic personal access token; fine-grained tokens are not supported. The <0>recommended scopes will be automatically selected for you." + i18nKey="login.pat.scopes_note" + /> ); diff --git a/src/renderer/routes/github/__snapshots__/LoginWithGitHubCLI.test.tsx.snap b/src/renderer/routes/github/__snapshots__/LoginWithGitHubCLI.test.tsx.snap index 9714204b0..3255bfe09 100644 --- a/src/renderer/routes/github/__snapshots__/LoginWithGitHubCLI.test.tsx.snap +++ b/src/renderer/routes/github/__snapshots__/LoginWithGitHubCLI.test.tsx.snap @@ -121,8 +121,7 @@ exports[`renderer/routes/github/LoginWithGitHubCLI.tsx > renders correctly 1`] = > gh - install already holds, so no token has to be created or pasted. Gitify reads it from the CLI on every refresh, which means - + install already holds, so no token has to be created or pasted. Gitify reads it from the CLI on every refresh, which means renders correctly 1`] = class="text-xs prc-Text-Text-9mHv3" data-component="Text" > - The scopes come from the GitHub CLI and cannot be changed from Gitify. Its - + The scopes come from the GitHub CLI and cannot be changed from Gitify. Its repo - scope covers notification access; run - + scope covers notification access; run - Requires the api scope, or a fine-grained token with{' '} - Todo (read, update) and{' '} - User (read) permissions. GitLab shows your To-Do - items, so you will see fewer notifications than on GitHub. + , + , + , + ]} + defaults="Requires the <0>api scope, or a fine-grained token with <1>Todo (read, update) and <2>User (read) permissions. GitLab shows your To-Do items, so you will see fewer notifications than on GitHub." + />
); diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 4ca08b876..afb6656c8 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -193,6 +193,11 @@ export interface GitifyUser { * Gitify error details. */ export interface GitifyError { + /** + * The type of error, used for comparison (see `isErrorType`). + */ + type?: ErrorType; + /** * The title of the error. */ diff --git a/src/renderer/utils/api/errors.test.ts b/src/renderer/utils/api/errors.test.ts index 99d944a2d..a249aff2d 100644 --- a/src/renderer/utils/api/errors.test.ts +++ b/src/renderer/utils/api/errors.test.ts @@ -3,7 +3,7 @@ import { RequestError } from '@octokit/request-error'; import { EVENTS } from '../../../shared/events'; -import { Errors } from '../core/errors'; +import { getErrorDetails } from '../core/errors'; import * as rendererLogger from '../core/logger'; import { determineFailureType, handleGraphQLResponseError } from './errors'; @@ -15,129 +15,67 @@ describe('renderer/utils/api/errors.ts', () => { `Error invoking remote method '${EVENTS.SAFE_STORAGE_DECRYPT}': Error: Error while decrypting the ciphertext provided to safeStorage.decryptString. Ciphertext does not appear to be encrypted.`, ); const result = determineFailureType(mockError); - expect(result).toBe(Errors.BAD_CREDENTIALS); + expect(result).toEqual(getErrorDetails('BAD_CREDENTIALS')); }); it('unknown error - generic error', () => { const mockError = new Error('Something went wrong'); const result = determineFailureType(mockError); - expect(result).toBe(Errors.UNKNOWN); + expect(result).toEqual(getErrorDetails('UNKNOWN')); }); }); describe('REST API errors (RequestError)', () => { - it('bad credentials - 401 status', () => { - const mockError = new RequestError('Bad credentials', 401, { - request: { - method: 'GET', - url: 'https://api.github.com', - headers: {}, - }, - }); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.BAD_CREDENTIALS); - }); - - it('missing scopes - 403 with scope message', () => { - const mockError = new RequestError("Missing the 'notifications' scope", 403, { - request: { - method: 'GET', - url: 'https://api.github.com', - headers: {}, - }, - }); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.MISSING_SCOPES); - }); - - it('rate limited - primary rate limit', () => { - const mockError = new RequestError('API rate limit exceeded', 403, { - request: { - method: 'GET', - url: 'https://api.github.com', - headers: {}, - }, - }); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.RATE_LIMITED); - }); - - it('rate limited - secondary rate limit', () => { - const mockError = new RequestError('You have exceeded a secondary rate limit', 403, { - request: { - method: 'GET', - url: 'https://api.github.com', - headers: {}, - }, - }); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.RATE_LIMITED); - }); - - it('action forbidden - unmatched 403', () => { - const mockError = new RequestError( + it.each([ + ['bad credentials - 401 status', 'Bad credentials', 401, 'BAD_CREDENTIALS'], + [ + 'missing scopes - 403 with scope message', + "Missing the 'notifications' scope", + 403, + 'MISSING_SCOPES', + ], + ['rate limited - primary rate limit', 'API rate limit exceeded', 403, 'RATE_LIMITED'], + [ + 'rate limited - secondary rate limit', + 'You have exceeded a secondary rate limit', + 403, + 'RATE_LIMITED', + ], + [ + 'action forbidden - unmatched 403', 'As an Enterprise Managed User, you cannot access this content', 403, - { - request: { - method: 'GET', - url: 'https://api.github.com', - headers: {}, - }, - }, - ); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.ACTION_FORBIDDEN); - }); - - it('network error - no status', () => { - const mockError = new RequestError('Network error', 500, { + 'ACTION_FORBIDDEN', + ], + ['network error - no status', 'Network error', 500, 'NETWORK'], + ['unknown error - unhandled 418', 'Forbidden', 418, 'UNKNOWN'], + ] as const)('%s', (_name, message, status, errorType) => { + const mockError = new RequestError(message, status, { request: { method: 'GET', url: 'https://api.github.com', headers: {}, }, }); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.NETWORK); - }); - it('unknown error - unhandled 418', () => { - const mockError = new RequestError('Forbidden', 418, { - request: { - method: 'GET', - url: 'https://api.github.com', - headers: {}, - }, - }); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.UNKNOWN); + expect(determineFailureType(mockError)).toEqual(getErrorDetails(errorType)); }); }); describe('GraphQL API errors (GraphqlResponseError)', () => { - it('bad credentials', () => { - const mockError = createGraphQLResponseError('Bad credentials'); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.BAD_CREDENTIALS); - }); - - it('rate limited - primary rate limit', () => { - const mockError = createGraphQLResponseError('API rate limit exceeded'); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.RATE_LIMITED); - }); - - it('rate limited - secondary rate limit', () => { - const mockError = createGraphQLResponseError('You have exceeded a secondary rate limit'); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.RATE_LIMITED); - }); - - it('unknown error', () => { - const mockError = createGraphQLResponseError('Something went wrong'); - const result = determineFailureType(mockError); - expect(result).toBe(Errors.UNKNOWN); + it.each([ + ['bad credentials', 'Bad credentials', 'BAD_CREDENTIALS'], + ['rate limited - primary rate limit', 'API rate limit exceeded', 'RATE_LIMITED'], + [ + 'rate limited - secondary rate limit', + 'You have exceeded a secondary rate limit', + 'RATE_LIMITED', + ], + ['unknown error', 'Something went wrong', 'UNKNOWN'], + ] as const)('%s', (_name, message, errorType) => { + expect(determineFailureType(createGraphQLResponseError(message))).toEqual( + getErrorDetails(errorType), + ); }); }); }); diff --git a/src/renderer/utils/api/errors.ts b/src/renderer/utils/api/errors.ts index ecf939b99..1e3eacb44 100644 --- a/src/renderer/utils/api/errors.ts +++ b/src/renderer/utils/api/errors.ts @@ -5,7 +5,7 @@ import { EVENTS } from '../../../shared/events'; import type { GitifyError } from '../../types'; -import { Errors } from '../core/errors'; +import { getErrorDetails } from '../core/errors'; import { rendererLogError } from '../core/logger'; /** @@ -22,7 +22,7 @@ export function determineFailureType( // Check for safe storage decryption failures first (happens before API call) if (message.includes(EVENTS.SAFE_STORAGE_DECRYPT)) { - return Errors.BAD_CREDENTIALS; + return getErrorDetails('BAD_CREDENTIALS'); } // Handle Octokit REST RequestError @@ -31,22 +31,22 @@ export function determineFailureType( switch (status) { case 401: - return Errors.BAD_CREDENTIALS; + return getErrorDetails('BAD_CREDENTIALS'); case 403: if (message.includes("Missing the 'notifications' scope")) { - return Errors.MISSING_SCOPES; + return getErrorDetails('MISSING_SCOPES'); } if ( message.includes('API rate limit exceeded') || message.includes('You have exceeded a secondary rate limit') ) { - return Errors.RATE_LIMITED; + return getErrorDetails('RATE_LIMITED'); } - return Errors.ACTION_FORBIDDEN; + return getErrorDetails('ACTION_FORBIDDEN'); case 500: - return Errors.NETWORK; + return getErrorDetails('NETWORK'); default: break; } @@ -57,18 +57,18 @@ export function determineFailureType( const errorMessages = err.errors?.map((e) => e.message).join('; ') || message; if (errorMessages.includes('Bad credentials')) { - return Errors.BAD_CREDENTIALS; + return getErrorDetails('BAD_CREDENTIALS'); } if ( errorMessages.includes('API rate limit exceeded') || errorMessages.includes('You have exceeded a secondary rate limit') ) { - return Errors.RATE_LIMITED; + return getErrorDetails('RATE_LIMITED'); } } - return Errors.UNKNOWN; + return getErrorDetails('UNKNOWN'); } /** diff --git a/src/renderer/utils/auth/loginValidation.ts b/src/renderer/utils/auth/loginValidation.ts index 44289c800..220e2719e 100644 --- a/src/renderer/utils/auth/loginValidation.ts +++ b/src/renderer/utils/auth/loginValidation.ts @@ -1,5 +1,6 @@ import type { ClientID, Forge, Hostname, Token } from '../../types'; +import i18n from '../../i18n'; import { getAdapter } from '../forges/registry'; import { isValidHostname } from './utils'; @@ -57,19 +58,19 @@ export const validatePersonalAccessTokenForm = ( const adapter = getAdapter(forge); if (!values.hostname) { - errors.hostname = 'Hostname is required'; + errors.hostname = i18n.t('login.pat.hostname_required', 'Hostname is required'); } else if (!adapter.validateHostname(values.hostname)) { - errors.hostname = 'Hostname format is invalid'; + errors.hostname = i18n.t('login.pat.hostname_invalid', 'Hostname format is invalid'); } if (forge === 'bitbucket' && !values.username) { - errors.username = 'Atlassian email is required'; + errors.username = i18n.t('login.pat.atlassian_email_required', 'Atlassian email is required'); } if (!values.token) { - errors.token = 'Token is required'; + errors.token = i18n.t('login.pat.token_required', 'Token is required'); } else if (!adapter.validateToken(values.token)) { - errors.token = 'Token format is invalid'; + errors.token = i18n.t('login.pat.token_invalid', 'Token format is invalid'); } return errors; @@ -83,15 +84,15 @@ export const validateCustomOAuthAppForm = ( const adapter = getAdapter(forge); if (!values.hostname) { - errors.hostname = 'Hostname is required'; + errors.hostname = i18n.t('login.pat.hostname_required', 'Hostname is required'); } else if (!isValidHostname(values.hostname)) { - errors.hostname = 'Hostname format is invalid'; + errors.hostname = i18n.t('login.pat.hostname_invalid', 'Hostname format is invalid'); } if (!values.clientId) { - errors.clientId = 'Client ID is required'; + errors.clientId = i18n.t('login.oauth.client_id_required', 'Client ID is required'); } else if (!adapter.customOAuthApp?.validateClientId(values.clientId)) { - errors.clientId = 'Client ID format is invalid'; + errors.clientId = i18n.t('login.oauth.client_id_invalid', 'Client ID format is invalid'); } return errors; diff --git a/src/renderer/utils/core/errors.test.ts b/src/renderer/utils/core/errors.test.ts index 8431ad5b1..d9281c104 100644 --- a/src/renderer/utils/core/errors.test.ts +++ b/src/renderer/utils/core/errors.test.ts @@ -2,7 +2,7 @@ import { mockAccountWithError } from '../../__mocks__/account-mocks'; import type { AccountNotifications, GitifyError } from '../../types'; -import { areAllAccountErrorsSame, doesAllAccountsHaveErrors, Errors } from './errors'; +import { areAllAccountErrorsSame, doesAllAccountsHaveErrors, getErrorDetails } from './errors'; describe('renderer/utils/errors.ts', () => { describe('doesAllAccountsHaveErrors', () => { @@ -12,7 +12,7 @@ describe('renderer/utils/errors.ts', () => { it('returns false when some accounts have no error', () => { const items: AccountNotifications[] = [ - mockAccountWithError(Errors.NETWORK), + mockAccountWithError(getErrorDetails('NETWORK')), mockAccountWithError(null as unknown as GitifyError), ]; @@ -21,8 +21,8 @@ describe('renderer/utils/errors.ts', () => { it('returns true when every account has an error', () => { const items: AccountNotifications[] = [ - mockAccountWithError(Errors.NETWORK), - mockAccountWithError(Errors.RATE_LIMITED), + mockAccountWithError(getErrorDetails('NETWORK')), + mockAccountWithError(getErrorDetails('RATE_LIMITED')), ]; expect(doesAllAccountsHaveErrors(items)).toBe(true); @@ -35,7 +35,7 @@ describe('renderer/utils/errors.ts', () => { }); it('returns true when all errors are identical object reference', () => { - const err = Errors.NETWORK; + const err = getErrorDetails('NETWORK'); const items: AccountNotifications[] = [mockAccountWithError(err), mockAccountWithError(err)]; expect(areAllAccountErrorsSame(items)).toBe(true); @@ -43,8 +43,8 @@ describe('renderer/utils/errors.ts', () => { it('returns false when errors differ', () => { const items: AccountNotifications[] = [ - mockAccountWithError(Errors.NETWORK), - mockAccountWithError(Errors.RATE_LIMITED), + mockAccountWithError(getErrorDetails('NETWORK')), + mockAccountWithError(getErrorDetails('RATE_LIMITED')), ]; expect(areAllAccountErrorsSame(items)).toBe(false); @@ -52,7 +52,7 @@ describe('renderer/utils/errors.ts', () => { it('returns false when one account has null error', () => { const items: AccountNotifications[] = [ - mockAccountWithError(Errors.NETWORK), + mockAccountWithError(getErrorDetails('NETWORK')), mockAccountWithError(null as unknown as GitifyError), ]; diff --git a/src/renderer/utils/core/errors.ts b/src/renderer/utils/core/errors.ts index fcc278b0e..9cf41fdeb 100644 --- a/src/renderer/utils/core/errors.ts +++ b/src/renderer/utils/core/errors.ts @@ -4,62 +4,121 @@ import { Constants } from '../../constants'; import type { AccountNotifications, ErrorType, GitifyError } from '../../types'; -export const Errors: Record = { - ACTION_FORBIDDEN: { - title: 'Action Forbidden', - descriptions: ['GitHub rejected this request for this account via Gitify.'], - emojis: Constants.EMOJIS.ERRORS.ACTION_FORBIDDEN, - }, - BAD_CREDENTIALS: { - title: 'Bad Credentials', - descriptions: ['Your credentials are either invalid or expired.'], - emojis: Constants.EMOJIS.ERRORS.BAD_CREDENTIALS, - actions: [ - { - label: 'Manage Accounts', - route: '/accounts', - variant: 'danger', - icon: PersonIcon, - }, - ], - }, - MISSING_SCOPES: { - title: 'Missing Scopes', - descriptions: ['Your credentials are missing a required API scope.'], - emojis: Constants.EMOJIS.ERRORS.MISSING_SCOPES, - actions: [ - { - label: 'Manage Accounts', - route: '/accounts', - variant: 'danger', - icon: PersonIcon, - }, - ], - }, - NETWORK: { - title: 'Network Error', - descriptions: [ - 'Unable to connect to one or more of your GitHub environments.', - 'Please check your network connection, including whether you require a VPN, and try again.', - ], - emojis: Constants.EMOJIS.ERRORS.NETWORK, - }, - OFFLINE: { - title: 'Network Offline', - descriptions: ['Your device is offline.', 'Please check your network connection.'], - emojis: Constants.EMOJIS.ERRORS.OFFLINE, - }, - RATE_LIMITED: { - title: 'Rate Limited', - descriptions: ['Please wait a while before trying again.'], - emojis: Constants.EMOJIS.ERRORS.RATE_LIMITED, - }, - UNKNOWN: { - title: 'Oops! Something went wrong', - descriptions: ['Please try again later.'], - emojis: Constants.EMOJIS.ERRORS.UNKNOWN, - }, -}; +import i18n from '../../i18n'; + +/** + * Builds the `GitifyError` for an `ErrorType` at call time so the copy is + * always translated in the active language. Never store the result in a + * module-scope object — that would snapshot the language at import time. + */ +export function getErrorDetails(errorType: ErrorType): GitifyError { + switch (errorType) { + case 'ACTION_FORBIDDEN': + return { + type: errorType, + title: i18n.t('errors.action_forbidden.title', 'Action Forbidden'), + descriptions: [ + i18n.t( + 'errors.action_forbidden.description', + 'GitHub rejected this request for this account via Gitify.', + ), + ], + emojis: Constants.EMOJIS.ERRORS.ACTION_FORBIDDEN, + }; + case 'BAD_CREDENTIALS': + return { + type: errorType, + title: i18n.t('errors.bad_credentials.title', 'Bad Credentials'), + descriptions: [ + i18n.t( + 'errors.bad_credentials.description', + 'Your credentials are either invalid or expired.', + ), + ], + emojis: Constants.EMOJIS.ERRORS.BAD_CREDENTIALS, + actions: [ + { + label: i18n.t('errors.manage_accounts', 'Manage Accounts'), + route: '/accounts', + variant: 'danger', + icon: PersonIcon, + }, + ], + }; + case 'MISSING_SCOPES': + return { + type: errorType, + title: i18n.t('errors.missing_scopes.title', 'Missing Scopes'), + descriptions: [ + i18n.t( + 'errors.missing_scopes.description', + 'Your credentials are missing a required API scope.', + ), + ], + emojis: Constants.EMOJIS.ERRORS.MISSING_SCOPES, + actions: [ + { + label: i18n.t('errors.manage_accounts', 'Manage Accounts'), + route: '/accounts', + variant: 'danger', + icon: PersonIcon, + }, + ], + }; + case 'NETWORK': + return { + type: errorType, + title: i18n.t('errors.network.title', 'Network Error'), + descriptions: [ + i18n.t( + 'errors.network.description1', + 'Unable to connect to one or more of your GitHub environments.', + ), + i18n.t( + 'errors.network.description2', + 'Please check your network connection, including whether you require a VPN, and try again.', + ), + ], + emojis: Constants.EMOJIS.ERRORS.NETWORK, + }; + case 'OFFLINE': + return { + type: errorType, + title: i18n.t('errors.offline.title', 'Network Offline'), + descriptions: [ + i18n.t('errors.offline.description1', 'Your device is offline.'), + i18n.t('errors.offline.description2', 'Please check your network connection.'), + ], + emojis: Constants.EMOJIS.ERRORS.OFFLINE, + }; + case 'RATE_LIMITED': + return { + type: errorType, + title: i18n.t('errors.rate_limited.title', 'Rate Limited'), + descriptions: [ + i18n.t('errors.rate_limited.description', 'Please wait a while before trying again.'), + ], + emojis: Constants.EMOJIS.ERRORS.RATE_LIMITED, + }; + case 'UNKNOWN': + default: + return { + type: 'UNKNOWN' as ErrorType, + title: i18n.t('errors.unknown.title', 'Oops! Something went wrong'), + descriptions: [i18n.t('errors.unknown.description', 'Please try again later.')], + emojis: Constants.EMOJIS.ERRORS.UNKNOWN, + }; + } +} + +/** + * Compares an error against an `ErrorType` without relying on object identity. + * Errors produced by `getErrorDetails` carry their `type`; hand-built errors + * (e.g. in tests) without one never match. + */ +export function isErrorType(error: GitifyError | null | undefined, errorType: ErrorType): boolean { + return error?.type === errorType; +} /** * Check if all accounts have errors @@ -80,5 +139,5 @@ export function areAllAccountErrorsSame(accountNotifications: AccountNotificatio } const firstError = accountNotifications[0].error; - return accountNotifications.every((account) => account.error === firstError); + return accountNotifications.every((account) => account.error?.type === firstError?.type); } diff --git a/src/renderer/utils/forges/github/flows.ts b/src/renderer/utils/forges/github/flows.ts index 6cb767114..9bda99ba4 100644 --- a/src/renderer/utils/forges/github/flows.ts +++ b/src/renderer/utils/forges/github/flows.ts @@ -7,6 +7,7 @@ import { Constants } from '../../../constants'; import type { ClientID, Hostname, Token } from '../../../types'; import type { DeviceFlowSession } from '../../auth/types'; +import i18n from '../../../i18n'; import { getRecommendedScopeNames } from '../../auth/scopeNames'; import { rendererLogError, toError } from '../../core/logger'; import { getGitHubAuthBaseUrl } from './auth'; @@ -81,10 +82,22 @@ export function describeDeviceFlowStartError( switch (errorCode) { case 'device_flow_disabled': return clientId - ? `Device flow is not enabled for this OAuth app. Enable it in that app's settings under https://${hostname}/settings/developers, then try again.` - : `Device flow is not enabled for this OAuth app. Enable it in the app's settings on ${hostname}, then try again.`; + ? i18n.t( + 'login.device.error_disabled_custom', + "Device flow is not enabled for this OAuth app. Enable it in that app's settings under {{url}}, then try again.", + { url: `https://${hostname}/settings/developers` }, + ) + : i18n.t( + 'login.device.error_disabled', + "Device flow is not enabled for this OAuth app. Enable it in the app's settings on {{hostname}}, then try again.", + { hostname }, + ); case 'incorrect_client_credentials': - return `The client ID is not valid for ${hostname}. Check it and try again.`; + return i18n.t( + 'login.device.error_invalid_client_id', + 'The client ID is not valid for {{hostname}}. Check it and try again.', + { hostname }, + ); default: return undefined; } diff --git a/src/renderer/utils/forges/labels.ts b/src/renderer/utils/forges/labels.ts new file mode 100644 index 000000000..06cce1c8a --- /dev/null +++ b/src/renderer/utils/forges/labels.ts @@ -0,0 +1,64 @@ +import type { AuthMethod } from '../auth/types'; + +import i18n from '../../i18n'; + +/** + * Translates a forge login method label for display. Brand/product-named + * labels (e.g. `'GitHub'`) are returned unchanged — they act as identifiers + * and read the same in any language. + */ +export function translateLoginMethodLabel(label: string): string { + switch (label) { + case 'Atlassian API Token': + return i18n.t('forge.method.atlassian_api_token', 'Atlassian API Token'); + case 'GitHub CLI': + return i18n.t('forge.method.github_cli', 'GitHub CLI'); + case 'Managed by the GitHub CLI': + return i18n.t('forge.method.managed_by_cli', 'Managed by the GitHub CLI'); + case 'Custom OAuth App': + return i18n.t('forge.method.custom_oauth_app', 'Custom OAuth App'); + case 'Personal Access Token': + return i18n.t('forge.method.personal_access_token', 'Personal Access Token'); + default: + return label; + } +} + +/** + * Translates a forge tagline for display. Unknown/undefined taglines pass + * through unchanged. + */ +export function translateForgeTagline(tagline: string | undefined): string | undefined { + switch (tagline) { + case 'GitHub Cloud & GitHub Enterprise Server': + return i18n.t('forge.tagline.github', 'GitHub Cloud & GitHub Enterprise Server'); + case 'GitLab.com & Self-Managed': + return i18n.t('forge.tagline.gitlab', 'GitLab.com & Self-Managed'); + case 'Gitea, Forgejo & Codeberg': + return i18n.t('forge.tagline.gitea', 'Gitea, Forgejo & Codeberg'); + case 'Bitbucket Cloud': + return i18n.t('forge.tagline.bitbucket', 'Bitbucket Cloud'); + default: + return tagline; + } +} + +/** + * Translates an auth method value for display in the accounts list. The raw + * value remains the storage/identifier form (`AuthMethod`); only the rendered + * copy is localized. + */ +export function translateAuthMethod(method: AuthMethod): string { + switch (method) { + case 'Gitify OAuth App': + return i18n.t('auth.method.gitify_oauth_app', 'Gitify OAuth App'); + case 'Custom OAuth App': + return i18n.t('auth.method.custom_oauth_app', 'Custom OAuth App'); + case 'GitHub CLI': + return i18n.t('auth.method.github_cli', 'GitHub CLI'); + case 'Personal Access Token': + return i18n.t('auth.method.personal_access_token', 'Personal Access Token'); + default: + return method; + } +} diff --git a/src/renderer/utils/notifications/filters/reason.ts b/src/renderer/utils/notifications/filters/reason.ts index 2c4ea969a..77692f991 100644 --- a/src/renderer/utils/notifications/filters/reason.ts +++ b/src/renderer/utils/notifications/filters/reason.ts @@ -8,18 +8,22 @@ import type { } from '../../../types'; import type { Filter } from './types'; -import { REASON_TYPE_DETAILS } from '../reason'; - -const REASON_FILTER_TYPE_DETAILS: Record = { - ...REASON_TYPE_DETAILS, - other: { - title: 'Other', - description: 'Notifications with a reason that is not listed above.', - }, -}; +import i18n from '../../../i18n'; +import { getReasonTypeDetails } from '../reason'; export const reasonFilter: Filter = { - FILTER_TYPES: REASON_FILTER_TYPE_DETAILS, + get FILTER_TYPES(): Record { + return { + ...getReasonTypeDetails(), + other: { + title: i18n.t('filters.reason.other', 'Other'), + description: i18n.t( + 'filters.reason.other_description', + 'Notifications with a reason that is not listed above.', + ), + }, + }; + }, requiresDetailsNotifications: false, @@ -47,13 +51,13 @@ export const reasonFilter: Filter = { filterNotification(notification: RawGitifyNotification, reason: ReasonFilterValue): boolean { if (reason === 'other') { - return !(notification.reason.code in REASON_TYPE_DETAILS); + return !(notification.reason.code in getReasonTypeDetails()); } return notification.reason.code === reason; }, classify(notification: RawGitifyNotification): ReasonFilterValue { const code = notification.reason.code; - return code in REASON_TYPE_DETAILS ? code : 'other'; + return code in getReasonTypeDetails() ? code : 'other'; }, }; diff --git a/src/renderer/utils/notifications/filters/reviewRequestType.ts b/src/renderer/utils/notifications/filters/reviewRequestType.ts index dd3b5a9e6..209058825 100644 --- a/src/renderer/utils/notifications/filters/reviewRequestType.ts +++ b/src/renderer/utils/notifications/filters/reviewRequestType.ts @@ -8,23 +8,34 @@ import type { } from '../../../types'; import type { Filter } from './types'; -const REVIEW_REQUEST_TYPE_DETAILS: Record = { - direct: { - title: 'Direct', - description: 'You were asked to review directly.', - }, - team: { - title: 'Team', - description: 'A team you belong to was asked to review.', - }, - other: { - title: 'Other', - description: 'Not a review request, such as a mention, comment, or assigned issue.', - }, -}; +import i18n from '../../../i18n'; export const reviewRequestTypeFilter: Filter = { - FILTER_TYPES: REVIEW_REQUEST_TYPE_DETAILS, + get FILTER_TYPES(): Record { + return { + direct: { + title: i18n.t('filters.review_request_type.direct', 'Direct'), + description: i18n.t( + 'filters.review_request_type.direct_description', + 'You were asked to review directly.', + ), + }, + team: { + title: i18n.t('filters.review_request_type.team', 'Team'), + description: i18n.t( + 'filters.review_request_type.team_description', + 'A team you belong to was asked to review.', + ), + }, + other: { + title: i18n.t('filters.review_request_type.other', 'Other'), + description: i18n.t( + 'filters.review_request_type.other_description', + 'Not a review request, such as a mention, comment, or assigned issue.', + ), + }, + }; + }, requiresDetailsNotifications: true, diff --git a/src/renderer/utils/notifications/filters/state.ts b/src/renderer/utils/notifications/filters/state.ts index f9245724e..d04ee5d27 100644 --- a/src/renderer/utils/notifications/filters/state.ts +++ b/src/renderer/utils/notifications/filters/state.ts @@ -9,29 +9,34 @@ import type { } from '../../../types'; import type { Filter } from './types'; -const STATE_TYPE_DETAILS: Record = { - draft: { - title: 'Draft', - }, - open: { - title: 'Open', - description: 'Open or reopened.', - }, - merged: { - title: 'Merged', - }, - closed: { - title: 'Closed', - description: 'Closed, completed, duplicate, resolved, or not planned.', - }, - other: { - title: 'Other', - description: 'Any other notification state.', - }, -}; +import i18n from '../../../i18n'; export const stateFilter: Filter = { - FILTER_TYPES: STATE_TYPE_DETAILS, + get FILTER_TYPES(): Record { + return { + draft: { + title: i18n.t('filters.state.draft', 'Draft'), + }, + open: { + title: i18n.t('filters.state.open', 'Open'), + description: i18n.t('filters.state.open_description', 'Open or reopened.'), + }, + merged: { + title: i18n.t('filters.state.merged', 'Merged'), + }, + closed: { + title: i18n.t('filters.state.closed', 'Closed'), + description: i18n.t( + 'filters.state.closed_description', + 'Closed, completed, duplicate, resolved, or not planned.', + ), + }, + other: { + title: i18n.t('filters.state.other', 'Other'), + description: i18n.t('filters.state.other_description', 'Any other notification state.'), + }, + }; + }, requiresDetailsNotifications: true, diff --git a/src/renderer/utils/notifications/filters/subjectType.ts b/src/renderer/utils/notifications/filters/subjectType.ts index d383f24d0..7075b9111 100644 --- a/src/renderer/utils/notifications/filters/subjectType.ts +++ b/src/renderer/utils/notifications/filters/subjectType.ts @@ -9,57 +9,59 @@ import type { } from '../../../types'; import type { Filter } from './types'; -const SUBJECT_TYPE_DETAILS: Record = { - AgentSessionThread: { - title: 'Agent Session Thread', - }, - BitbucketNotification: { - title: 'Bitbucket', - }, - CheckSuite: { - title: 'Check Suite', - }, - Commit: { - title: 'Commit', - }, - Discussion: { - title: 'Discussion', - }, - GitLabTodo: { - title: 'GitLab To-Do', - }, - Issue: { - title: 'Issue', - }, - PullRequest: { - title: 'Pull Request', - }, - Release: { - title: 'Release', - }, - RepositoryAdvisory: { - title: 'Advisory', - }, - RepositoryDependabotAlertsThread: { - title: 'Dependabot Alert', - }, - RepositoryInvitation: { - title: 'Invitation', - }, - RepositoryVulnerabilityAlert: { - title: 'Vulnerability Alert', - }, - WorkflowRun: { - title: 'Workflow Run', - }, - other: { - title: 'Other', - description: 'A type not listed above.', - }, -}; +import i18n from '../../../i18n'; export const subjectTypeFilter: Filter = { - FILTER_TYPES: SUBJECT_TYPE_DETAILS, + get FILTER_TYPES(): Record { + return { + AgentSessionThread: { + title: i18n.t('filters.subject_type.agent_session_thread', 'Agent Session Thread'), + }, + BitbucketNotification: { + title: i18n.t('filters.subject_type.bitbucket', 'Bitbucket'), + }, + CheckSuite: { + title: i18n.t('filters.subject_type.check_suite', 'Check Suite'), + }, + Commit: { + title: i18n.t('filters.subject_type.commit', 'Commit'), + }, + Discussion: { + title: i18n.t('filters.subject_type.discussion', 'Discussion'), + }, + GitLabTodo: { + title: i18n.t('filters.subject_type.gitlab_todo', 'GitLab To-Do'), + }, + Issue: { + title: i18n.t('filters.subject_type.issue', 'Issue'), + }, + PullRequest: { + title: i18n.t('filters.subject_type.pull_request', 'Pull Request'), + }, + Release: { + title: i18n.t('filters.subject_type.release', 'Release'), + }, + RepositoryAdvisory: { + title: i18n.t('filters.subject_type.advisory', 'Advisory'), + }, + RepositoryDependabotAlertsThread: { + title: i18n.t('filters.subject_type.dependabot_alert', 'Dependabot Alert'), + }, + RepositoryInvitation: { + title: i18n.t('filters.subject_type.invitation', 'Invitation'), + }, + RepositoryVulnerabilityAlert: { + title: i18n.t('filters.subject_type.vulnerability_alert', 'Vulnerability Alert'), + }, + WorkflowRun: { + title: i18n.t('filters.subject_type.workflow_run', 'Workflow Run'), + }, + other: { + title: i18n.t('filters.subject_type.other', 'Other'), + description: i18n.t('filters.subject_type.other_description', 'A type not listed above.'), + }, + }; + }, requiresDetailsNotifications: false, @@ -93,13 +95,13 @@ export const subjectTypeFilter: Filter = { subjectType: SubjectTypeFilterValue, ): boolean { if (subjectType === 'other') { - return !(notification.subject.type in SUBJECT_TYPE_DETAILS); + return !(notification.subject.type in this.FILTER_TYPES); } return notification.subject.type === subjectType; }, classify(notification: RawGitifyNotification): SubjectTypeFilterValue { - return notification.subject.type in SUBJECT_TYPE_DETAILS + return notification.subject.type in this.FILTER_TYPES ? (notification.subject.type as SubjectType) : 'other'; }, diff --git a/src/renderer/utils/notifications/filters/userType.ts b/src/renderer/utils/notifications/filters/userType.ts index 00da8bc51..0ee67b373 100644 --- a/src/renderer/utils/notifications/filters/userType.ts +++ b/src/renderer/utils/notifications/filters/userType.ts @@ -9,23 +9,7 @@ import type { } from '../../../types'; import type { Filter } from './types'; -const USER_TYPE_DETAILS: Record = { - User: { - title: 'User', - }, - Bot: { - title: 'Bot', - description: 'Bot accounts such as @copilot, @dependabot, @renovate, @netlify, etc.', - }, - Organization: { - title: 'Organization', - }, - other: { - title: 'Other', - description: - 'Notifications with no author, or an author that is not a User, Bot, or Organization.', - }, -}; +import i18n from '../../../i18n'; // Author types that map to the "User" filter option. const USER_MATCHING_TYPES: readonly string[] = ['User', 'EnterpriseUserAccount'] as const; @@ -38,7 +22,30 @@ const ENUMERATED_USER_TYPES: readonly string[] = [ ] as const; export const userTypeFilter: Filter = { - FILTER_TYPES: USER_TYPE_DETAILS, + get FILTER_TYPES(): Record { + return { + User: { + title: i18n.t('filters.user_type.user', 'User'), + }, + Bot: { + title: i18n.t('filters.user_type.bot', 'Bot'), + description: i18n.t( + 'filters.user_type.bot_description', + 'Bot accounts such as @copilot, @dependabot, @renovate, @netlify, etc.', + ), + }, + Organization: { + title: i18n.t('filters.user_type.organization', 'Organization'), + }, + other: { + title: i18n.t('filters.user_type.other', 'Other'), + description: i18n.t( + 'filters.user_type.other_description', + 'Notifications with no author, or an author that is not a User, Bot, or Organization.', + ), + }, + }; + }, requiresDetailsNotifications: true, diff --git a/src/renderer/utils/notifications/mutations.test.ts b/src/renderer/utils/notifications/mutations.test.ts index 99567c9f9..0906aed79 100644 --- a/src/renderer/utils/notifications/mutations.test.ts +++ b/src/renderer/utils/notifications/mutations.test.ts @@ -7,7 +7,7 @@ import { import type { AccountNotifications } from '../../types'; -import { Errors } from '../core/errors'; +import { getErrorDetails } from '../core/errors'; import { restoreFailedNotifications, settleNotificationActions } from './mutations'; describe('renderer/utils/notifications/mutations.ts', () => { @@ -36,7 +36,7 @@ describe('renderer/utils/notifications/mutations.ts', () => { expect(result.succeeded).toEqual([first]); expect(result.failed).toHaveLength(1); expect(result.failed[0].notification).toEqual(second); - expect(result.failed[0].error).toBe(Errors.ACTION_FORBIDDEN); + expect(result.failed[0].error).toEqual(getErrorDetails('ACTION_FORBIDDEN')); expect(result.failed[0].rawError).toBe(forbiddenError); }); @@ -56,7 +56,7 @@ describe('renderer/utils/notifications/mutations.ts', () => { expect(result.succeeded).toEqual([second]); expect(result.failed[0].notification).toEqual(first); - expect(result.failed[0].error).toBe(Errors.ACTION_FORBIDDEN); + expect(result.failed[0].error).toEqual(getErrorDetails('ACTION_FORBIDDEN')); expect(action).toHaveBeenCalledTimes(2); }); @@ -79,8 +79,8 @@ describe('renderer/utils/notifications/mutations.ts', () => { const result = await settleNotificationActions([first, second], action); expect(result.succeeded).toEqual([]); - expect(result.failed[0].error).toBe(Errors.MISSING_SCOPES); - expect(result.failed[1].error).toBe(Errors.ACTION_FORBIDDEN); + expect(result.failed[0].error).toEqual(getErrorDetails('MISSING_SCOPES')); + expect(result.failed[1].error).toEqual(getErrorDetails('ACTION_FORBIDDEN')); }); }); diff --git a/src/renderer/utils/notifications/reason.ts b/src/renderer/utils/notifications/reason.ts index 4c7420f9b..cca0f6f88 100644 --- a/src/renderer/utils/notifications/reason.ts +++ b/src/renderer/utils/notifications/reason.ts @@ -1,79 +1,122 @@ import type { Reason, TypeDetails } from '../../types'; -export const REASON_TYPE_DETAILS: Record = { - agent_session_finished: { - title: 'Agent Session Finished', - description: 'A GitHub Copilot cloud agent session finished running.', - }, - approval_requested: { - title: 'Approval Requested', - description: 'You were requested to review and approve a deployment.', - }, - assign: { - title: 'Assigned', - description: 'You were assigned to the issue.', - }, - author: { - title: 'Authored', - description: 'You created the thread.', - }, - ci_activity: { - title: 'Workflow Run Completed', - description: 'A GitHub Actions workflow run was triggered for your repository.', - }, - comment: { - title: 'Commented', - description: 'You commented on the thread.', - }, - invitation: { - title: 'Invitation Received', - description: 'You accepted an invitation to contribute to the repository.', - }, - manual: { - title: 'Updated', - description: 'You subscribed to the thread (via an issue or pull request).', - }, - member_feature_requested: { - title: 'Member Feature Requested', - description: - 'Organization members have requested to enable a feature such as Draft Pull Requests or Copilot.', - }, - mention: { - title: 'Mentioned', - description: 'You were specifically @mentioned in the content.', - }, - review_requested: { - title: 'Review Requested', - description: "You, or a team you're a member of, were requested to review a pull request.", - }, - security_advisory_credit: { - title: 'Security Advisory Credit Received', - description: 'You were credited for contributing to a security advisory.', - }, - security_alert: { - title: 'Security Alert Received', - description: 'GitHub discovered a security vulnerability in your repository.', - }, - state_change: { - title: 'State Changed', - description: - 'You changed the thread state (for example, closing an issue or merging a pull request).', - }, - subscribed: { - title: 'Updated', - description: "You're watching the repository.", - }, - team_mention: { - title: 'Team Mentioned', - description: 'You were on a team that was mentioned.', - }, -}; +import i18n from '../../i18n'; -const UNKNOWN_REASON: TypeDetails = { - title: 'Unknown', - description: 'The reason for this notification is not supported by the app.', -}; +export function getReasonTypeDetails(): Record { + return { + agent_session_finished: { + title: i18n.t('reasons.agent_session_finished', 'Agent Session Finished'), + description: i18n.t( + 'reasons.agent_session_finished_description', + 'A GitHub Copilot cloud agent session finished running.', + ), + }, + approval_requested: { + title: i18n.t('reasons.approval_requested', 'Approval Requested'), + description: i18n.t( + 'reasons.approval_requested_description', + 'You were requested to review and approve a deployment.', + ), + }, + assign: { + title: i18n.t('reasons.assign', 'Assigned'), + description: i18n.t('reasons.assign_description', 'You were assigned to the issue.'), + }, + author: { + title: i18n.t('reasons.author', 'Authored'), + description: i18n.t('reasons.author_description', 'You created the thread.'), + }, + ci_activity: { + title: i18n.t('reasons.ci_activity', 'Workflow Run Completed'), + description: i18n.t( + 'reasons.ci_activity_description', + 'A GitHub Actions workflow run was triggered for your repository.', + ), + }, + comment: { + title: i18n.t('reasons.comment', 'Commented'), + description: i18n.t('reasons.comment_description', 'You commented on the thread.'), + }, + invitation: { + title: i18n.t('reasons.invitation', 'Invitation Received'), + description: i18n.t( + 'reasons.invitation_description', + 'You accepted an invitation to contribute to the repository.', + ), + }, + manual: { + title: i18n.t('reasons.manual', 'Updated'), + description: i18n.t( + 'reasons.manual_description', + 'You subscribed to the thread (via an issue or pull request).', + ), + }, + member_feature_requested: { + title: i18n.t('reasons.member_feature_requested', 'Member Feature Requested'), + description: i18n.t( + 'reasons.member_feature_requested_description', + 'Organization members have requested to enable a feature such as Draft Pull Requests or Copilot.', + ), + }, + mention: { + title: i18n.t('reasons.mention', 'Mentioned'), + description: i18n.t( + 'reasons.mention_description', + 'You were specifically @mentioned in the content.', + ), + }, + review_requested: { + title: i18n.t('reasons.review_requested', 'Review Requested'), + description: i18n.t( + 'reasons.review_requested_description', + "You, or a team you're a member of, were requested to review a pull request.", + ), + }, + security_advisory_credit: { + title: i18n.t('reasons.security_advisory_credit', 'Security Advisory Credit Received'), + description: i18n.t( + 'reasons.security_advisory_credit_description', + 'You were credited for contributing to a security advisory.', + ), + }, + security_alert: { + title: i18n.t('reasons.security_alert', 'Security Alert Received'), + description: i18n.t( + 'reasons.security_alert_description', + 'GitHub discovered a security vulnerability in your repository.', + ), + }, + state_change: { + title: i18n.t('reasons.state_change', 'State Changed'), + description: i18n.t( + 'reasons.state_change_description', + 'You changed the thread state (for example, closing an issue or merging a pull request).', + ), + }, + subscribed: { + title: i18n.t('reasons.subscribed', 'Updated'), + description: i18n.t('reasons.subscribed_description', "You're watching the repository."), + }, + team_mention: { + title: i18n.t('reasons.team_mention', 'Team Mentioned'), + description: i18n.t( + 'reasons.team_mention_description', + 'You were on a team that was mentioned.', + ), + }, + }; +} + +export function getUnknownReasonDetails(): TypeDetails { + return { + title: i18n.t('reasons.unknown', 'Unknown'), + description: i18n.t( + 'reasons.unknown_description', + 'The reason for this notification is not supported by the app.', + ), + }; +} export function getReasonDetails(reason: Reason): TypeDetails { - return REASON_TYPE_DETAILS[reason] || UNKNOWN_REASON; + return getReasonTypeDetails()[reason] || getUnknownReasonDetails(); } diff --git a/src/renderer/utils/notifications/status.test.ts b/src/renderer/utils/notifications/status.test.ts index d591cf92c..c5abc40b6 100644 --- a/src/renderer/utils/notifications/status.test.ts +++ b/src/renderer/utils/notifications/status.test.ts @@ -5,7 +5,7 @@ import { import type { AccountNotifications, GitifyError } from '../../types'; -import { Errors } from '../core/errors'; +import { getErrorDetails } from '../core/errors'; import { deriveGlobalError, deriveStatus, hasNoAccountErrors } from './status'; function group(account = mockGitHubCloudAccount, error: GitifyError | null = null) { @@ -14,9 +14,9 @@ function group(account = mockGitHubCloudAccount, error: GitifyError | null = nul const clean = group(); const cleanSecond = group(mockGitHubEnterpriseServerAccount); -const networkError = group(mockGitHubCloudAccount, Errors.NETWORK); -const networkErrorSecond = group(mockGitHubEnterpriseServerAccount, Errors.NETWORK); -const rateLimitedError = group(mockGitHubEnterpriseServerAccount, Errors.RATE_LIMITED); +const networkError = group(mockGitHubCloudAccount, getErrorDetails('NETWORK')); +const networkErrorSecond = group(mockGitHubEnterpriseServerAccount, getErrorDetails('NETWORK')); +const rateLimitedError = group(mockGitHubEnterpriseServerAccount, getErrorDetails('RATE_LIMITED')); const baseInput = { isLoading: false, isPaused: false, isError: false }; @@ -47,7 +47,9 @@ describe('renderer/utils/notifications/status.ts', () => { describe('deriveGlobalError', () => { it('returns the offline error when paused', () => { - expect(deriveGlobalError({ isPaused: true, notifications: [clean] })).toBe(Errors.OFFLINE); + expect(deriveGlobalError({ isPaused: true, notifications: [clean] })).toEqual( + getErrorDetails('OFFLINE'), + ); }); it('returns undefined when there are no accounts', () => { @@ -57,7 +59,7 @@ describe('renderer/utils/notifications/status.ts', () => { it('returns the shared error when all accounts have the same error', () => { expect( deriveGlobalError({ isPaused: false, notifications: [networkError, networkErrorSecond] }), - ).toBe(Errors.NETWORK); + ).toEqual(getErrorDetails('NETWORK')); }); it('returns undefined when accounts have differing errors', () => { diff --git a/src/renderer/utils/notifications/status.ts b/src/renderer/utils/notifications/status.ts index 26b3e24b7..07fad08ea 100644 --- a/src/renderer/utils/notifications/status.ts +++ b/src/renderer/utils/notifications/status.ts @@ -1,6 +1,10 @@ import type { AccountNotifications, GitifyError, Status } from '../../types'; -import { areAllAccountErrorsSame, doesAllAccountsHaveErrors, Errors } from '../core/errors'; +import { + areAllAccountErrorsSame, + doesAllAccountsHaveErrors, + getErrorDetails, +} from '../core/errors'; /** * Inputs that determine the notification status and global error. @@ -61,7 +65,7 @@ export function deriveGlobalError({ notifications, }: Pick): GitifyError | undefined { if (isPaused) { - return Errors.OFFLINE; + return getErrorDetails('OFFLINE'); } if (notifications.length === 0) { diff --git a/src/renderer/utils/ui/display.ts b/src/renderer/utils/ui/display.ts index af843c4be..7287a8fe6 100644 --- a/src/renderer/utils/ui/display.ts +++ b/src/renderer/utils/ui/display.ts @@ -1,5 +1,7 @@ import type { Chevron } from '../../types'; +import i18n from '../../i18n'; + export interface ParsedCodePart { id: string; type: 'text' | 'code'; @@ -19,23 +21,28 @@ export function getChevronDetails( isVisible: boolean, type: 'account' | 'repository', ): Chevron { + const typeLabel = + type === 'account' + ? i18n.t('chevron.account', 'account') + : i18n.t('chevron.repository', 'repository'); + if (!hasNotifications) { return { direction: 'left', - label: `No notifications for ${type}`, + label: i18n.t('chevron.none', 'No notifications for {{type}}', { type: typeLabel }), }; } if (isVisible) { return { direction: 'down', - label: `Hide ${type} notifications`, + label: i18n.t('chevron.hide', 'Hide {{type}} notifications', { type: typeLabel }), }; } return { direction: 'right', - label: `Show ${type} notifications`, + label: i18n.t('chevron.show', 'Show {{type}} notifications', { type: typeLabel }), }; } diff --git a/src/renderer/utils/ui/icons.ts b/src/renderer/utils/ui/icons.ts index 4d14ca29e..63a486ada 100644 --- a/src/renderer/utils/ui/icons.ts +++ b/src/renderer/utils/ui/icons.ts @@ -24,6 +24,7 @@ import { } from '../../types'; import type { PlatformType } from '../auth/types'; +import i18n from '../../i18n'; import { BitbucketIcon } from '../../icons/BitbucketIcon'; import { GitLabIcon } from '../../icons/GitLabIcon'; import type { PullRequestReviewState } from '../forges/github/graphql/generated/graphql'; @@ -43,27 +44,38 @@ export function getPullRequestReviewIcon( return { type: CheckIcon, color: IconColor.GREEN, - description: `${descriptionPrefix} approved these changes`, + description: i18n.t('review.approved', '{{users}} approved these changes', { + users: descriptionPrefix, + }), }; case 'CHANGES_REQUESTED': return { type: FileDiffIcon, color: IconColor.RED, - description: `${descriptionPrefix} requested changes`, + description: i18n.t('review.changes_requested', '{{users}} requested changes', { + users: descriptionPrefix, + }), }; case 'COMMENTED': return { type: CommentIcon, color: IconColor.YELLOW, - description: `${descriptionPrefix} left review comments`, + description: i18n.t('review.commented', '{{users}} left review comments', { + users: descriptionPrefix, + }), }; case 'DISMISSED': return { type: CommentIcon, color: IconColor.GRAY, - description: `${descriptionPrefix} ${ - review.users.length > 1 ? 'reviews have' : 'review has' - } been dismissed`, + description: + review.users.length > 1 + ? i18n.t('review.dismissed_plural', '{{users}} reviews have been dismissed', { + users: descriptionPrefix, + }) + : i18n.t('review.dismissed_singular', '{{user}} review has been dismissed', { + user: descriptionPrefix, + }), }; default: return null;