diff --git a/.vscode/settings.json b/.vscode/settings.json index cef16e05e5..d889a99a0e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,15 +6,12 @@ }, "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "tailwindCSS.experimental.classRegex": [ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], // ["tw\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] - ["tw`([^`]*)`", "([^`]*)"] ], - // If you do not want to autofix some rules on save // You can put this in your user settings or workspace settings "eslint.codeActionsOnSave.rules": [ @@ -25,20 +22,43 @@ "!arrow-body-style", "*" ], - // If you want to silent stylistic rules // You can put this in your user settings or workspace settings "eslint.rules.customizations": [ - { "rule": "@stylistic/*", "severity": "off", "fixable": true }, - { "rule": "tailwindcss/classnames-order", "severity": "off" }, - { "rule": "antfu/consistent-list-newline", "severity": "off" }, - { "rule": "hyoban/jsx-attribute-spacing", "severity": "off" }, - { "rule": "simple-import-sort/*", "severity": "off" }, - { "rule": "prefer-const", "severity": "off" }, - { "rule": "unused-imports/no-unused-imports", "severity": "off" } + { + "rule": "@stylistic/*", + "severity": "off", + "fixable": true + }, + { + "rule": "tailwindcss/classnames-order", + "severity": "off" + }, + { + "rule": "antfu/consistent-list-newline", + "severity": "off" + }, + { + "rule": "hyoban/jsx-attribute-spacing", + "severity": "off" + }, + { + "rule": "simple-import-sort/*", + "severity": "off" + }, + { + "rule": "prefer-const", + "severity": "off" + }, + { + "rule": "unused-imports/no-unused-imports", + "severity": "off" + } ], "cSpell.words": ["rsshub"], "editor.foldingImportsByDefault": true, "commentTranslate.hover.enabled": false, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "i18n-ally.localesPaths": ["locales"], + "i18n-ally.keystyle": "nested" } diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000000..12fcf49716 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,97 @@ +{ + "words": { + "search": "Search", + "import": "Import", + "discover": "Discover", + "language": "Language" + }, + "signin": { + "continue_with_github": "Continue with GitHub", + "continue_with_google": "Continue with Google", + "sign_in_to": "Sign in to" + }, + "settings": { + "general": { + "app": "App", + "launch_at_login": "Launch at login", + "timeline": "Timeline", + "show_unread_on_launch": { + "description": "Show unread content on launch", + "label": "Show unread content on launch" + }, + "rebuild_database": { + "warning": { + "line1": "Rebuilding the database will clear all your local data.", + "line2": "Are you sure you want to continue?" + }, + "label": "Rebuild Database", + "description": "If you are experiencing rendering issues, rebuilding the database may solve them.", + "button": "Rebuild", + "title": "Rebuild Database" + }, + "group_by_date": { + "label": "Group by date", + "description": "Group entries by date." + }, + "mark_as_read": { + "scroll": { + "label": "Mark as read when scrolling", + "description": "Automatically mark entries as read when scrolled out of the view." + }, + "hover": { + "label": "Mark as read when hovering", + "description": "Automatically mark entries as read when hovered." + }, + "render": { + "label": "Mark as read when in the view", + "description": "Automatically mark single-level entries (e.g. social media posts, pictures, video views) as read when they enter the view." + } + }, + "privacy_data": "Privacy & Data", + "data_persist": { + "label": "Persist data for offline usage", + "description": "Persist data locally to enable offline access and local search." + }, + "send_anonymous_data": { + "label": "Send anonymous data", + "description": "By opting to send anonymized telemetry data, you contribute to improving the overall user experience of Follow." + }, + "voices": "Voices" + }, + "integration": { + "title": "Integration", + "eagle": { + "title": "Eagle", + "enable": { + "label": "Enable", + "description": "Display 'Save media to Eagle' button when available." + } + }, + "readwise": { + "title": "Readwise", + "enable": { + "label": "Enable", + "description": "Display 'Save to Readwise' button when available." + }, + "token": { + "label": "Readwise Access Token", + "description": "You can get it here: readwise.io/access_token." + } + }, + "instapaper": { + "title": "Instapaper", + "enable": { + "label": "Enable", + "description": "Display 'Save to Instapaper' button when available." + }, + "username": { + "label": "Instapaper Username" + }, + "password": { + "label": "Instapaper Password" + } + }, + "tip": "Tip: Your sensitive data is stored locally and is not uploaded to the server." + } + } +} diff --git a/locales/modules/lang/en.json b/locales/modules/lang/en.json new file mode 100644 index 0000000000..6c2d1e6a6c --- /dev/null +++ b/locales/modules/lang/en.json @@ -0,0 +1,3 @@ +{ + "name": "English" +} diff --git a/locales/modules/lang/zh_CN.json b/locales/modules/lang/zh_CN.json new file mode 100644 index 0000000000..b49ed8961a --- /dev/null +++ b/locales/modules/lang/zh_CN.json @@ -0,0 +1,3 @@ +{ + "name": "简体中文(部分完成)" +} diff --git a/locales/zh_CN.json b/locales/zh_CN.json new file mode 100644 index 0000000000..7c1a652717 --- /dev/null +++ b/locales/zh_CN.json @@ -0,0 +1,64 @@ +{ + "words": { + "search": "搜索", + "import": "导入", + "discover": "发现", + "language": "语言" + }, + "signin": { + "continue_with_github": "使用 GitHub 登录", + "continue_with_google": "使用 Google 登录", + "sign_in_to": "登录到" + }, + "settings": { + "general": { + "app": "应用程序", + "data_persist": { + "description": "在本地保留数据以启用离线访问和本地搜索。", + "label": "保留数据以供离线使用" + }, + "group_by_date": { + "description": "按日期对条目进行分组。", + "label": "按日期分组" + }, + "launch_at_login": "登录时启动", + "mark_as_read": { + "hover": { + "description": "悬停时自动将条目标记为已读。", + "label": "悬停时标记为已读" + }, + "render": { + "description": "当单级条目(例如社交媒体帖子、图片、视频视图)进入视图时自动将其标记为已读。", + "label": "在视图中标记为已读" + }, + "scroll": { + "description": "当滚动出视图时自动将条目标记为已读。", + "label": "滚动时标记为已读" + } + }, + "privacy_data": "隐私", + "rebuild_database": { + "button": "重建", + "description": "如果您遇到渲染问题,重建数据库可能会解决这些问题。", + "label": "重建数据库", + "title": "重建数据库", + "warning[0]": "重建数据库将清除所有本地数据。", + "warning[1]": "您确定要继续吗?", + "warning": { + "line1": "重建数据库将清除所有本地数据。", + "line2": "您确定要继续吗?" + } + }, + "send_anonymous_data": { + "description": "通过选择发送匿名遥测数据,您可以为改善“关注”的整体用户体验做出贡献。", + "label": "发送匿名数据" + }, + "show_unread_on_launch": { + "description": "启动时显示未读内容", + "label": "启动时显示未读内容" + }, + "timeline": "时间轴", + "voices": "声音" + } + } +} diff --git a/package.json b/package.json index 2199ca9d14..c9a441e40b 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "fuse.js": "7.0.0", "hast-util-to-jsx-runtime": "2.3.0", "hast-util-to-text": "4.0.2", + "i18next": "^23.15.1", "idb-keyval": "6.2.1", "immer": "10.1.1", "jotai": "2.9.3", @@ -112,6 +113,7 @@ "react-fast-marquee": "1.6.5", "react-hook-form": "7.53.0", "react-hotkeys-hook": "4.5.1", + "react-i18next": "^15.0.1", "react-intersection-observer": "9.13.1", "react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1", "react-router-dom": "6.26.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c46e89bd4f..259b62229d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,9 @@ importers: hast-util-to-text: specifier: 4.0.2 version: 4.0.2 + i18next: + specifier: ^23.15.1 + version: 23.15.1 idb-keyval: specifier: 6.2.1 version: 6.2.1 @@ -260,6 +263,9 @@ importers: react-hotkeys-hook: specifier: 4.5.1 version: 4.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-i18next: + specifier: ^15.0.1 + version: 15.0.1(i18next@23.15.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-intersection-observer: specifier: 9.13.1 version: 9.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5309,6 +5315,9 @@ packages: html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -5340,6 +5349,9 @@ packages: humps@2.0.1: resolution: {integrity: sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==} + i18next@23.15.1: + resolution: {integrity: sha512-wB4abZ3uK7EWodYisHl/asf8UYEhrI/vj/8aoSsrj/ZDxj4/UXPOa1KvFt1Fq5hkUHquNqwFlDprmjZ8iySgYA==} + iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -6953,6 +6965,19 @@ packages: react: '>=16.8.1' react-dom: '>=16.8.1' + react-i18next@15.0.1: + resolution: {integrity: sha512-NwxLqNM6CLbeGA9xPsjits0EnXdKgCRSS6cgkgOdNcPXqL+1fYNl8fBg1wmnnHvFy812Bt4IWTPE9zjoPmFj3w==} + peerDependencies: + i18next: '>= 23.2.3' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + react-intersection-observer@9.13.1: resolution: {integrity: sha512-tSzDaTy0qwNPLJHg8XZhlyHTgGW6drFKTtvjdL+p6um12rcnp8Z5XstE+QNBJ7c64n5o0Lj4ilUleA41bmDoMw==} peerDependencies: @@ -8052,6 +8077,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -14331,6 +14360,10 @@ snapshots: html-escaper@3.0.3: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-void-elements@3.0.0: {} htmlparser2@9.1.0: @@ -14370,6 +14403,10 @@ snapshots: humps@2.0.1: {} + i18next@23.15.1: + dependencies: + '@babel/runtime': 7.25.4 + iconv-corefoundation@1.1.7: dependencies: cli-truncate: 2.1.0 @@ -16175,6 +16212,15 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-i18next@15.0.1(i18next@23.15.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.25.4 + html-parse-stringify: 3.0.1 + i18next: 23.15.1 + react: 18.3.1 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + react-intersection-observer@9.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -17375,6 +17421,8 @@ snapshots: - supports-color - terser + void-elements@3.1.0: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4 diff --git a/src/renderer/src/@types/i18next.d.ts b/src/renderer/src/@types/i18next.d.ts new file mode 100644 index 0000000000..880beacada --- /dev/null +++ b/src/renderer/src/@types/i18next.d.ts @@ -0,0 +1,11 @@ +import type resources from "./resources" + +declare module "i18next" { + interface CustomTypeOptions { + resources: (typeof resources)["en"] + defaultNS: "translation" + // if you see an error like: "Argument of type 'DefaultTFuncReturn' is not assignable to parameter of type xyz" + // set returnNull to false (and also in the i18next init options) + // returnNull: false; + } +} diff --git a/src/renderer/src/@types/resources.ts b/src/renderer/src/@types/resources.ts new file mode 100644 index 0000000000..9d7e855594 --- /dev/null +++ b/src/renderer/src/@types/resources.ts @@ -0,0 +1,16 @@ +import en from "../../../../locales/en.json" +import lang_en from "../../../../locales/modules/lang/en.json" +import lang_zhCN from "../../../../locales/modules/lang/zh_CN.json" +import zhCN from "../../../../locales/zh_CN.json" + +const resources = { + en: { + translation: en, + lang: lang_en, + }, + zh_CN: { + translation: zhCN, + lang: lang_zhCN, + }, +} +export default resources diff --git a/src/renderer/src/atoms/settings/general.ts b/src/renderer/src/atoms/settings/general.ts index b6aba0b5a1..649ee99b29 100644 --- a/src/renderer/src/atoms/settings/general.ts +++ b/src/renderer/src/atoms/settings/general.ts @@ -6,6 +6,7 @@ import { createSettingAtom } from "./helper" const createDefaultSettings = (): GeneralSettings => ({ // App appLaunchOnStartup: false, + language: "en", // Data control dataPersist: true, sendAnonymousData: true, diff --git a/src/renderer/src/components/common/AppErrorBoundary.tsx b/src/renderer/src/components/common/AppErrorBoundary.tsx index 2d2a186318..31a58dd90e 100644 --- a/src/renderer/src/components/common/AppErrorBoundary.tsx +++ b/src/renderer/src/components/common/AppErrorBoundary.tsx @@ -3,8 +3,8 @@ import { ErrorBoundary } from "@sentry/react" import type { FC, PropsWithChildren } from "react" import { createElement, useCallback } from "react" -import type { ErrorComponentType } from "../errors" import { getErrorFallback } from "../errors" +import type { ErrorComponentType } from "../errors/enum" export interface AppErrorBoundaryProps extends PropsWithChildren { height?: number | string diff --git a/src/renderer/src/components/errors/enum.ts b/src/renderer/src/components/errors/enum.ts new file mode 100644 index 0000000000..f51325ee5d --- /dev/null +++ b/src/renderer/src/components/errors/enum.ts @@ -0,0 +1,8 @@ +export enum ErrorComponentType { + Modal = "Modal", + Page = "Page", + + // Feed + FeedFoundCanBeFollow = "FeedFoundCanBeFollow", + FeedNotFound = "FeedNotFound", +} diff --git a/src/renderer/src/components/errors/index.ts b/src/renderer/src/components/errors/index.ts index 65f8698d0c..0e07d847a0 100644 --- a/src/renderer/src/components/errors/index.ts +++ b/src/renderer/src/components/errors/index.ts @@ -1,17 +1,9 @@ +import { ErrorComponentType } from "./enum" import { FeedFoundCanBeFollowErrorFallback } from "./FeedFoundCanBeFollowErrorFallback" import { FeedNotFoundErrorFallback } from "./FeedNotFound" import { ModalErrorFallback } from "./ModalError" import { PageErrorFallback } from "./PageError" -export enum ErrorComponentType { - Modal = "Modal", - Page = "Page", - - // Feed - FeedFoundCanBeFollow = "FeedFoundCanBeFollow", - FeedNotFound = "FeedNotFound", -} - export const ErrorFallbackMap = { [ErrorComponentType.Modal]: ModalErrorFallback, [ErrorComponentType.Page]: PageErrorFallback, diff --git a/src/renderer/src/components/ui/modal/stacked/modal.tsx b/src/renderer/src/components/ui/modal/stacked/modal.tsx index 34b6f4462b..3df5f4e849 100644 --- a/src/renderer/src/components/ui/modal/stacked/modal.tsx +++ b/src/renderer/src/components/ui/modal/stacked/modal.tsx @@ -3,7 +3,7 @@ import { useUISettingKey } from "@renderer/atoms/settings/ui" import { AppErrorBoundary } from "@renderer/components/common/AppErrorBoundary" import { SafeFragment } from "@renderer/components/common/Fragment" import { m } from "@renderer/components/common/Motion" -import { ErrorComponentType } from "@renderer/components/errors" +import { ErrorComponentType } from "@renderer/components/errors/enum" import { isElectronBuild } from "@renderer/constants" import { useSwitchHotKeyScope } from "@renderer/hooks/common/useSwitchHotkeyScope" import { nextFrame, stopPropagation } from "@renderer/lib/dom" diff --git a/src/renderer/src/hooks/biz/useSubscriptionActions.tsx b/src/renderer/src/hooks/biz/useSubscriptionActions.tsx index 26ec0343f3..249a2d24a3 100644 --- a/src/renderer/src/hooks/biz/useSubscriptionActions.tsx +++ b/src/renderer/src/hooks/biz/useSubscriptionActions.tsx @@ -1,7 +1,7 @@ import { Kbd } from "@renderer/components/ui/kbd/Kbd" import { HotKeyScopeMap } from "@renderer/constants" import { apiClient } from "@renderer/lib/api-fetch" -import { Queries } from "@renderer/queries" +import { subscription as subscriptionQuery } from "@renderer/queries/subscriptions" import type { SubscriptionFlatModel } from "@renderer/store/subscription" import { subscriptionActions } from "@renderer/store/subscription" import { feedUnreadActions } from "@renderer/store/unread" @@ -16,7 +16,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void }) useMutation({ mutationFn: async (subscription: SubscriptionFlatModel) => subscriptionActions.unfollow(subscription.feedId).then((feed) => { - Queries.subscription.byView(subscription.view).invalidate() + subscriptionQuery.byView(subscription.view).invalidate() feedUnreadActions.updateByFeedId(subscription.feedId, 0) if (!subscription) return @@ -32,7 +32,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void }) }, }) - Queries.subscription.byView(subscription.view).invalidate() + subscriptionQuery.byView(subscription.view).invalidate() feedUnreadActions.fetchUnreadByView(subscription.view) toast.dismiss(toastId) diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts new file mode 100644 index 0000000000..625eff6330 --- /dev/null +++ b/src/renderer/src/i18n.ts @@ -0,0 +1,61 @@ +import { getGeneralSettings } from "@renderer/atoms/settings/general" +import { EventBus } from "@renderer/lib/event-bus" +import i18next from "i18next" +import { initReactI18next } from "react-i18next" + +import resources from "./@types/resources" + +export const defaultNS = "translation" + +export const fallbackLanguage = "en" +export const initI18n = async () => { + const { language } = getGeneralSettings() + + await i18next.use(initReactI18next).init({ + lng: language, + fallbackLng: fallbackLanguage, + defaultNS, + ns: [defaultNS], + debug: true, + resources, + + backend: [], + }) +} + +export const currentSupportedLanguages = Object.keys(resources) +export const languageCodeToName = Object.fromEntries( + currentSupportedLanguages.map((lang) => [lang, resources[lang].lang["name"]]), +) +if (import.meta.hot) { + import.meta.hot.on("i18n-update", ({ file, content }: { file: string; content: string }) => { + const resources = JSON.parse(content) + + // `file` is absolute path e.g. /Users/innei/git/follow/locales/en.json + // Absolute path e.g. /Users/innei/git/follow/locales/modules//en.json + + // 1. parse root language + if (!file.includes("locales/modules")) { + const lang = file.split("/").pop()?.replace(".json", "") + if (!lang) return + i18next.addResourceBundle(lang, defaultNS, resources, true, true) + i18next.reloadResources(lang, defaultNS) + } else { + const nsName = file.match(/locales\/modules\/(.+?)\//)?.[1] + + if (!nsName) return + const lang = file.split("/").pop()?.replace(".json", "") + if (!lang) return + i18next.addResourceBundle(lang, nsName, resources, true, true) + i18next.reloadResources(lang, nsName) + } + + import.meta.env.DEV && EventBus.dispatch("I18N_UPDATE", "") + }) +} + +declare module "@renderer/lib/event-bus" { + interface CustomEvent { + I18N_UPDATE: string + } +} diff --git a/src/renderer/src/initialize/index.ts b/src/renderer/src/initialize/index.ts index 76dc5fb298..e4a5299396 100644 --- a/src/renderer/src/initialize/index.ts +++ b/src/renderer/src/initialize/index.ts @@ -4,6 +4,7 @@ import { repository } from "@pkg" import { getUISettings } from "@renderer/atoms/settings/ui" import { isElectronBuild } from "@renderer/constants" import { browserDB } from "@renderer/database" +import { initI18n } from "@renderer/i18n" import { settingSyncQueue } from "@renderer/modules/settings/helper/sync-queue" import { ElectronCloseEvent, @@ -91,6 +92,8 @@ export const initializeApp = async () => { const { dataPersist: enabledDataPersist, sendAnonymousData } = getGeneralSettings() initSentry() + await initI18n() + if (sendAnonymousData) initPostHog() let dataHydratedTime: undefined | number diff --git a/src/renderer/src/modules/auth/LoginModalContent.tsx b/src/renderer/src/modules/auth/LoginModalContent.tsx index ea2c519585..b2a5c0612b 100644 --- a/src/renderer/src/modules/auth/LoginModalContent.tsx +++ b/src/renderer/src/modules/auth/LoginModalContent.tsx @@ -6,6 +6,7 @@ import type { LoginRuntime } from "@renderer/lib/auth" import { loginHandler } from "@renderer/lib/auth" import { stopPropagation } from "@renderer/lib/dom" import { m } from "framer-motion" +import { useTranslation } from "react-i18next" interface LoginModalContentProps { runtime?: LoginRuntime @@ -16,6 +17,8 @@ export const LoginModalContent = (props: LoginModalContentProps) => { const { canClose = true, runtime } = props + const { t } = useTranslation() + return (
{ {...modalMontionConfig} >
- Sign in to + {t("signin.sign_in_to")} {APP_NAME} @@ -37,7 +40,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { loginHandler("github", runtime) }} > - Continue with GitHub + {t("signin.continue_with_github")}
diff --git a/src/renderer/src/modules/discover/feed-form.tsx b/src/renderer/src/modules/discover/feed-form.tsx index 7c26e23f81..f736efaf4e 100644 --- a/src/renderer/src/modules/discover/feed-form.tsx +++ b/src/renderer/src/modules/discover/feed-form.tsx @@ -26,8 +26,8 @@ import { FeedViewType } from "@renderer/lib/enum" import { getFetchErrorMessage, toastFetchError } from "@renderer/lib/error-parser" import { getNewIssueUrl } from "@renderer/lib/issues" import { cn } from "@renderer/lib/utils" -import { Queries } from "@renderer/queries" -import { useFeed } from "@renderer/queries/feed" +import { feed as feedQuery, useFeed } from "@renderer/queries/feed" +import { subscription as subscriptionQuery } from "@renderer/queries/subscriptions" import { useFeedByIdOrUrl } from "@renderer/store/feed" import { useSubscriptionByFeedId } from "@renderer/store/subscription" import { feedUnreadActions } from "@renderer/store/unread" @@ -199,18 +199,18 @@ const FeedInnerForm = ({ }, onSuccess: (_, variables) => { if (isSubscribed && variables.view !== `${subscription?.view}`) { - Queries.subscription.byView(subscription?.view).invalidate() - tipcClient?.invalidateQuery(Queries.subscription.byView(subscription?.view).key) + subscriptionQuery.byView(subscription?.view).invalidate() + tipcClient?.invalidateQuery(subscriptionQuery.byView(subscription?.view).key) feedUnreadActions.fetchUnreadByView(subscription?.view) } - Queries.subscription.byView(Number.parseInt(variables.view)).invalidate() - tipcClient?.invalidateQuery(Queries.subscription.byView(Number.parseInt(variables.view)).key) + subscriptionQuery.byView(Number.parseInt(variables.view)).invalidate() + tipcClient?.invalidateQuery(subscriptionQuery.byView(Number.parseInt(variables.view)).key) feedUnreadActions.fetchUnreadByView(Number.parseInt(variables.view)) const feedId = feed.id if (feedId) { - Queries.feed.byId({ id: feedId }).invalidate() - tipcClient?.invalidateQuery(Queries.feed.byId({ id: feedId }).key) + feedQuery.byId({ id: feedId }).invalidate() + tipcClient?.invalidateQuery(feedQuery.byId({ id: feedId }).key) } toast(isSubscribed ? "🎉 Updated." : "🎉 Followed.", { duration: 1000, @@ -241,9 +241,7 @@ const FeedInnerForm = ({ followMutation.mutate(values) } - const categories = useAuthQuery( - Queries.subscription.categories(Number.parseInt(form.watch("view"))), - ) + const categories = useAuthQuery(subscriptionQuery.categories(Number.parseInt(form.watch("view")))) // useEffect(() => { // if (feed.isSuccess) nextFrame(() => buttonRef.current?.focus()); diff --git a/src/renderer/src/modules/settings/control.tsx b/src/renderer/src/modules/settings/control.tsx index f39e362349..0b51b81afc 100644 --- a/src/renderer/src/modules/settings/control.tsx +++ b/src/renderer/src/modules/settings/control.tsx @@ -107,7 +107,7 @@ export const SettingTabbedSegment: Component<{ export const SettingDescription: Component = ({ children, className }) => ( diff --git a/src/renderer/src/modules/settings/tabs/general.tsx b/src/renderer/src/modules/settings/tabs/general.tsx index add1ea2f69..7fcc827250 100644 --- a/src/renderer/src/modules/settings/tabs/general.tsx +++ b/src/renderer/src/modules/settings/tabs/general.tsx @@ -1,4 +1,8 @@ -import { setGeneralSetting, useGeneralSettingValue } from "@renderer/atoms/settings/general" +import { + setGeneralSetting, + useGeneralSettingSelector, + useGeneralSettingValue, +} from "@renderer/atoms/settings/general" import { createSetting } from "@renderer/atoms/settings/helper" import { createDefaultSettings, @@ -14,11 +18,13 @@ import { SelectTrigger, SelectValue, } from "@renderer/components/ui/select" +import { currentSupportedLanguages, fallbackLanguage, languageCodeToName } from "@renderer/i18n" import { initPostHog } from "@renderer/initialize/posthog" import { tipcClient } from "@renderer/lib/client" import { clearLocalPersistStoreData } from "@renderer/store/utils/clear" import { useQuery } from "@tanstack/react-query" import { useCallback, useEffect } from "react" +import { useTranslation } from "react-i18next" import { SettingsTitle } from "../title" @@ -27,6 +33,7 @@ const { defineSettingItem, SettingBuilder } = createSetting( setGeneralSetting, ) export const SettingGeneral = () => { + const { t } = useTranslation() useEffect(() => { tipcClient?.getLoginItemSettings().then((settings) => { setGeneralSetting("appLaunchOnStartup", settings.openAtLogin) @@ -40,6 +47,7 @@ export const SettingGeneral = () => { }, []) const { present } = useModalStack() + return ( <> @@ -48,44 +56,43 @@ export const SettingGeneral = () => { settings={[ { type: "title", - value: "App", - disabled: !window.electron, + value: t("settings.general.app"), }, - { - disabled: !window.electron, - label: "Launch Follow at Login", - key: "appLaunchOnStartup", + + defineSettingItem("appLaunchOnStartup", { + label: t("settings.general.launch_at_login"), + disabled: !tipcClient, onChange(value) { saveLoginSetting(value) }, - }, + }), + LanguageSelector, { type: "title", - value: "timeline", + value: t("settings.general.timeline"), }, defineSettingItem("unreadOnly", { - label: "Show unread content on launch", - description: "Display only unread content when the app is launched.", + label: t("settings.general.show_unread_on_launch.label"), + description: t("settings.general.show_unread_on_launch.description"), }), defineSettingItem("groupByDate", { - label: "Group by date", - description: "Group entries by date.", + label: t("settings.general.group_by_date.label"), + description: t("settings.general.group_by_date.description"), }), { type: "title", value: "unread" }, defineSettingItem("scrollMarkUnread", { - label: "Mark as read when scrolling", - description: "Automatically mark entries as read when scrolled out of the view.", + label: t("settings.general.mark_as_read.scroll.label"), + description: t("settings.general.mark_as_read.scroll.description"), }), defineSettingItem("hoverMarkUnread", { - label: "Mark as read when hovering", - description: "Automatically mark entries as read when hovered.", + label: t("settings.general.mark_as_read.hover.label"), + description: t("settings.general.mark_as_read.hover.description"), }), defineSettingItem("renderMarkUnread", { - label: "Mark as read when in the view", - description: - "Automatically mark single-level entries (e.g., social media posts, pictures, video views) as read when they enter the view.", + label: t("settings.general.mark_as_read.render.label"), + description: t("settings.general.mark_as_read.render.description"), }), { type: "title", value: "TTS", disabled: !window.electron }, @@ -99,18 +106,17 @@ export const SettingGeneral = () => { // }), { type: "title", - value: "Privacy & Data", + value: t("settings.general.privacy_data"), }, defineSettingItem("dataPersist", { - label: "Persist data for offline usage", - description: "Persist data locally to enable offline access and local search.", + label: t("settings.general.data_persist.label"), + description: t("settings.general.data_persist.description"), }), defineSettingItem("sendAnonymousData", { - label: "Send anonymous data", - description: - "By opting to send anonymized telemetry data, you contribute to improving the overall user experience of Follow.", + label: t("settings.general.send_anonymous_data.label"), + description: t("settings.general.send_anonymous_data.description"), onChange(value) { setGeneralSetting("sendAnonymousData", value) if (value) { @@ -121,18 +127,16 @@ export const SettingGeneral = () => { } }, }), - { - label: "Rebuild Database", + label: t("settings.general.rebuild_database.label"), action: async () => { present({ - title: "Rebuild Database", + title: t("settings.general.rebuild_database.title"), clickOutsideToDismiss: true, content: () => (
-

Rebuilding the database will clear all your local data.

-

Are you sure you want to continue?

- +

{t("settings.general.rebuild_database.warning.line1")}

+

{t("settings.general.rebuild_database.warning.line2")}