feat: support i18n (#345)

* add i18next

* language selector

* add chinese translation to general settings

* fix: type error

Signed-off-by: Innei <i@innei.in>

* fix: selection style

Signed-off-by: Innei <i@innei.in>

* refactor: init i18n and language map

Signed-off-by: Innei <i@innei.in>

* feat: intelligence

Signed-off-by: Innei <i@innei.in>

* refactor: i18n dx hmr

Signed-off-by: Innei <i@innei.in>

* chore: lockfile

Signed-off-by: Innei <i@innei.in>

* chore: code style

Signed-off-by: Innei <i@innei.in>

* cleanup

Signed-off-by: Innei <i@innei.in>

---------

Signed-off-by: Innei <i@innei.in>
Co-authored-by: Innei <i@innei.in>
This commit is contained in:
Randy 2024-09-11 18:01:53 +08:00 committed by GitHub
parent 9125a49fcc
commit 53c1c6639f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 585 additions and 182 deletions

44
.vscode/settings.json vendored
View File

@ -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"
}

97
locales/en.json Normal file
View File

@ -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."
}
}
}

View File

@ -0,0 +1,3 @@
{
"name": "English"
}

View File

@ -0,0 +1,3 @@
{
"name": "简体中文(部分完成)"
}

64
locales/zh_CN.json Normal file
View File

@ -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": "声音"
}
}
}

View File

@ -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",

View File

@ -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

11
src/renderer/src/@types/i18next.d.ts vendored Normal file
View File

@ -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;
}
}

View File

@ -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

View File

@ -6,6 +6,7 @@ import { createSettingAtom } from "./helper"
const createDefaultSettings = (): GeneralSettings => ({
// App
appLaunchOnStartup: false,
language: "en",
// Data control
dataPersist: true,
sendAnonymousData: true,

View File

@ -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

View File

@ -0,0 +1,8 @@
export enum ErrorComponentType {
Modal = "Modal",
Page = "Page",
// Feed
FeedFoundCanBeFollow = "FeedFoundCanBeFollow",
FeedNotFound = "FeedNotFound",
}

View File

@ -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,

View File

@ -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"

View File

@ -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)

61
src/renderer/src/i18n.ts Normal file
View File

@ -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/<module-name>/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
}
}

View File

@ -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

View File

@ -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 (
<div className="center flex h-full" onClick={canClose ? modal.dismiss : undefined}>
<m.div
@ -24,7 +27,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
{...modalMontionConfig}
>
<div className="mb-8 mt-4 text-center align-middle font-sans text-2xl font-bold leading-relaxed">
<span className="text-xl">Sign in to </span>
<span className="text-xl">{t("signin.sign_in_to")}</span>
<span className="center flex translate-y-px gap-2 font-theme text-accent">
<FollowIcon className="size-4" />
{APP_NAME}
@ -37,7 +40,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
loginHandler("github", runtime)
}}
>
<i className="i-mgc-github-cute-fi mr-2 text-xl" /> Continue with GitHub
<i className="i-mgc-github-cute-fi mr-2 text-xl" /> {t("signin.continue_with_github")}
</Button>
<Button
className="h-[48px] w-[320px] rounded-[8px] bg-blue-500 font-sans text-base text-white hover:bg-blue-500/90 focus:!border-blue-500/80 focus:!ring-blue-500/80"
@ -45,7 +48,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
loginHandler("google", runtime)
}}
>
<i className="i-mgc-google-cute-fi mr-2 text-xl" /> Continue with Google
<i className="i-mgc-google-cute-fi mr-2 text-xl" /> {t("signin.continue_with_google")}
</Button>
</div>
</m.div>

View File

@ -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());

View File

@ -107,7 +107,7 @@ export const SettingTabbedSegment: Component<{
export const SettingDescription: Component = ({ children, className }) => (
<small
className={cn(
"block w-4/5 text-balance text-[13px] leading-tight text-gray-400 dark:text-neutral-500",
"block w-4/5 text-[13px] leading-tight text-gray-400 dark:text-neutral-500",
className,
)}
>

View File

@ -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 (
<>
<SettingsTitle />
@ -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: () => (
<div className="text-sm">
<p>Rebuilding the database will clear all your local data.</p>
<p>Are you sure you want to continue?</p>
<p>{t("settings.general.rebuild_database.warning.line1")}</p>
<p>{t("settings.general.rebuild_database.warning.line2")}</p>
<div className="mt-4 flex justify-end">
<Button
className="px-3 text-red-500"
@ -149,9 +153,8 @@ export const SettingGeneral = () => {
),
})
},
description:
"If you are experiencing rendering issues, rebuilding the database may solve them.",
buttonText: "Rebuild",
description: t("settings.general.rebuild_database.description"),
buttonText: t("settings.general.rebuild_database.button"),
},
]}
/>
@ -161,6 +164,7 @@ export const SettingGeneral = () => {
}
export const VoiceSelector = () => {
const { t } = useTranslation()
const { data } = useQuery({
queryFn: () => tipcClient?.getVoices(),
queryKey: ["voices"],
@ -169,7 +173,7 @@ export const VoiceSelector = () => {
return (
<div className="-mt-1 mb-3 flex items-center justify-between">
<span className="shrink-0 text-sm font-medium">Voices</span>
<span className="shrink-0 text-sm font-medium">{t("settings.general.voices")}</span>
<Select
defaultValue={createDefaultSettings().voice}
value={voice}
@ -191,3 +195,36 @@ export const VoiceSelector = () => {
</div>
)
}
export const LanguageSelector = () => {
const { t, i18n } = useTranslation()
const language = useGeneralSettingSelector((state) => state.language)
const finalRenderLanguage = currentSupportedLanguages.includes(language)
? language
: fallbackLanguage
return (
<div className="mb-3 mt-4 flex items-center justify-between">
<span className="shrink-0 text-sm font-medium">{t("words.language")}</span>
<Select
defaultValue={finalRenderLanguage}
value={finalRenderLanguage}
onValueChange={(value) => {
setGeneralSetting("language", value as string)
i18n.changeLanguage(value as string)
}}
>
<SelectTrigger size="sm" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent position="item-aligned">
{currentSupportedLanguages.map((lang) => (
<SelectItem key={lang} value={lang}>
{languageCodeToName[lang]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -9,6 +9,7 @@ import {
SimpleIconsInstapaper,
SimpleIconsReadwise,
} from "@renderer/components/ui/platform-icon/icons"
import { useTranslation } from "react-i18next"
import { SettingsTitle } from "../title"
@ -16,109 +17,101 @@ const { defineSettingItem, SettingBuilder } = createSetting(
useIntegrationSettingValue,
setIntegrationSetting,
)
export const SettingIntegration = () => (
<>
<SettingsTitle />
<div className="mt-4">
<SettingBuilder
settings={[
{
type: "title",
value: (
<span className="flex items-center gap-2 font-bold">
<SimpleIconsEagle />
Eagle
</span>
),
},
defineSettingItem("enableEagle", {
label: "Enable",
description: (
<>
Display <i>Save media to Eagle</i> button when available.
</>
),
}),
{
type: "title",
value: (
<span className="flex items-center gap-2 font-bold">
<SimpleIconsReadwise />
Readwise
</span>
),
},
defineSettingItem("enableReadwise", {
label: "Enable",
description: (
<>
Display <i>Save to Readwise</i> button when available.
</>
),
}),
defineSettingItem("readwiseToken", {
label: "Readwise Access Token",
vertical: true,
type: "password",
description: (
<>
You can get it here:{" "}
<a
target="_blank"
className="underline"
rel="noreferrer noopener"
href="https://readwise.io/access_token"
>
readwise.io/access_token
</a>
.
</>
),
}),
{
type: "title",
value: (
<span className="flex items-center gap-2 font-bold">
<SimpleIconsInstapaper />
Instapaper
</span>
),
},
defineSettingItem("enableInstapaper", {
label: "Enable",
description: (
<>
Display <i>Save to Instapaper</i> button when available.
</>
),
}),
defineSettingItem("instapaperUsername", {
label: "Instapaper Username",
componentProps: {
labelClassName: "w-[150px]",
export const SettingIntegration = () => {
const { t } = useTranslation()
return (
<>
<SettingsTitle />
<div className="mt-4">
<SettingBuilder
settings={[
{
type: "title",
value: (
<span className="flex items-center gap-2 font-bold">
<SimpleIconsEagle />
{t("settings.integration.eagle.title")}
</span>
),
},
}),
defineSettingItem("instapaperPassword", {
label: "Instapaper Password",
type: "password",
componentProps: {
labelClassName: "w-[150px]",
defineSettingItem("enableEagle", {
label: t("settings.integration.eagle.enable.label"),
description: t("settings.integration.eagle.enable.description"),
}),
{
type: "title",
value: (
<span className="flex items-center gap-2 font-bold">
<SimpleIconsReadwise />
{t("settings.integration.readwise.title")}
</span>
),
},
}),
defineSettingItem("enableReadwise", {
label: t("settings.integration.readwise.enable.label"),
description: t("settings.integration.readwise.enable.description"),
}),
defineSettingItem("readwiseToken", {
label: t("settings.integration.readwise.token.label"),
vertical: true,
type: "password",
description: (
<>
{t("settings.integration.readwise.token.description")}{" "}
<a
target="_blank"
className="underline"
rel="noreferrer noopener"
href="https://readwise.io/access_token"
>
readwise.io/access_token
</a>
.
</>
),
}),
{
type: "title",
value: (
<span className="flex items-center gap-2 font-bold">
<SimpleIconsInstapaper />
{t("settings.integration.instapaper.title")}
</span>
),
},
defineSettingItem("enableInstapaper", {
label: t("settings.integration.instapaper.enable.label"),
description: t("settings.integration.instapaper.enable.description"),
}),
defineSettingItem("instapaperUsername", {
label: t("settings.integration.instapaper.username.label"),
componentProps: {
labelClassName: "w-[150px]",
},
}),
defineSettingItem("instapaperPassword", {
label: t("settings.integration.instapaper.password.label"),
type: "password",
componentProps: {
labelClassName: "w-[150px]",
},
}),
BottomTip,
]}
/>
</div>
</>
)
BottomTip,
]}
/>
</div>
</>
)
}
const BottomTip = () => {
const { t } = useTranslation()
return (
<div className="mt-6">
<Divider />
<p className="opacity-60">
<small>Tip: Your sensitive data is stored locally and is not uploaded to the server.</small>
<small>{t("settings.integration.tip")}</small>
</p>
</div>
)

View File

@ -4,6 +4,7 @@ import { DiscoverImport } from "@renderer/modules/discover/import"
import { Recommendations } from "@renderer/modules/discover/recommendations"
import { DiscoverRSS3 } from "@renderer/modules/discover/rss3-form"
import { createElement } from "react"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
import { useSubViewTitle } from "../hooks"
@ -38,10 +39,11 @@ const tabs = [
export function Component() {
const [search, setSearch] = useSearchParams()
const { t } = useTranslation()
useSubViewTitle("Discover")
return (
<>
<div className="text-2xl font-bold">Discover</div>
<div className="text-2xl font-bold">{t("words.discover")}</div>
<Tabs
value={search.get("type") || "search"}
onValueChange={(val) => {

View File

@ -5,7 +5,7 @@ import { getUISettings, setUISetting } from "@renderer/atoms/settings/ui"
import { setFeedColumnShow, useFeedColumnShow } from "@renderer/atoms/sidebar"
import { useLoginModalShow, useWhoami } from "@renderer/atoms/user"
import { AppErrorBoundary } from "@renderer/components/common/AppErrorBoundary"
import { ErrorComponentType } from "@renderer/components/errors"
import { ErrorComponentType } from "@renderer/components/errors/enum"
import { PanelSplitter } from "@renderer/components/ui/divider"
import { DeclarativeModal } from "@renderer/components/ui/modal/stacked/declarative-modal"
import { NoopChildren } from "@renderer/components/ui/modal/stacked/utils"
@ -174,7 +174,6 @@ const FeedResponsiveResizerContainer = ({
useEffect(() => {
let timer: any
if (feedColumnShow) {
timer = setTimeout(() => {
setDelayShowSplitter(true)
}, 200)

View File

@ -0,0 +1,19 @@
import { EventBus } from "@renderer/lib/event-bus"
import i18next from "i18next"
import type { FC, PropsWithChildren } from "react"
import { useEffect, useState } from "react"
import { I18nextProvider } from "react-i18next"
export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
const [currentI18NInstance, update] = useState(i18next)
if (import.meta.env.DEV)
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/rules-of-hooks
useEffect(
() =>
EventBus.subscribe("I18N_UPDATE", () => {
update(i18next.cloneInstance())
}),
[],
)
return <I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>
}

View File

@ -12,6 +12,7 @@ import { HotkeysProvider } from "react-hotkeys-hook"
import { ContextMenuProvider } from "./context-menu-provider"
import { EventProvider } from "./event-provider"
import { I18nProvider } from "./i18n-provider"
import { InvalidateQueryProvider } from "./invalidate-query-provider"
import { StableRouterProvider } from "./stable-router-provider"
import { SettingSync } from "./ui-setting-sync"
@ -30,14 +31,16 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<PersistQueryClientProvider persistOptions={persistConfig} client={queryClient}>
<HotkeysProvider initiallyActiveScopes={HotKeyScopeMap.Home}>
<Provider store={jotaiStore}>
<EventProvider />
<UserProvider />
<SettingSync />
<ModalStackProvider />
<ContextMenuProvider />
<StableRouterProvider />
{import.meta.env.DEV && <Devtools />}
{children}
<I18nProvider>
<EventProvider />
<UserProvider />
<SettingSync />
<ModalStackProvider />
<ContextMenuProvider />
<StableRouterProvider />
{import.meta.env.DEV && <Devtools />}
{children}
</I18nProvider>
</Provider>
</HotkeysProvider>

View File

@ -1,5 +1,6 @@
export interface GeneralSettings {
appLaunchOnStartup: boolean
language: string
dataPersist: boolean
sendAnonymousData: boolean
unreadOnly: boolean

View File

@ -1,3 +1,4 @@
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { fileURLToPath } from "node:url"
@ -97,6 +98,24 @@ export default ({ mode }) => {
devPrint(),
{
name: "custom-i18n-hmr",
handleHotUpdate({ file, server }) {
if (file.endsWith(".json") && file.includes("locales")) {
server.ws.send({
type: "custom",
event: "i18n-update",
data: {
file,
content: readFileSync(file, "utf-8"),
},
})
// return empty array to prevent the default HMR
return []
}
},
},
process.env.ANALYZER && analyzer(),
],
define: {