From 01da257c9012b7deb8bd88444f9aa9fe8a8af0b3 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 13 Feb 2026 17:25:08 +0800 Subject: [PATCH] feat(i18n): merge #4597/#4774 and complete localization audit (#4839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Update Japanese translations for new features and errors Added and updated multiple Japanese locale strings across AI, app, errors, external, settings, and shortcuts modules. Changes include new labels, descriptions, error messages, and UI text for features such as scheduling, token usage, inbox management, toolbar customization, and quick search. * Add Japanese README documentation Introduces README-ja.md with comprehensive information about Folo, including features, installation instructions, community links, contribution guidelines, code signing policies, and license details for Japanese-speaking users. * Update formatting in Japanese README Corrected the admonition marker from '[!重要]' to '[!IMPORTANT]' for consistency and made minor formatting adjustments in the README-ja.md file. * Update community section in Japanese README Revised wording and formatting in the community participation section of README-ja.md for improved clarity and consistency. * Remove Japanese README file Deleted README-ja.md to reduce duplication or outdated documentation. All relevant information should now be referenced from the main README or other documentation files. * feat: add French (fr-FR) localization * fix: sort imports in default-resource.ts to resolve lint error * feat: add missing French translations for mobile/default, native, and external locales * feat: add French language filter to discovery/recommended feeds * docs: update changelog for French localization * feat(i18n): merge PR #4597/#4774 and finish localization audit --------- Co-authored-by: dai Co-authored-by: Anthony Mahé Co-authored-by: Anthony Mahé <104373430+AnthonyMahe@users.noreply.github.com> --- apps/desktop/changelog/next.md | 4 +- .../layer/renderer/src/@types/constants.ts | 3 +- .../src/@types/default-resource.electron.ts | 16 + .../renderer/src/@types/default-resource.ts | 3 + .../src/modules/discover/DiscoveryContent.tsx | 6 +- .../renderer/src/modules/trending/index.tsx | 9 +- .../discover/category/[category].tsx | 9 +- apps/mobile/changelog/next.md | 4 +- .../components/errors/GlobalErrorScreen.tsx | 8 +- .../src/components/errors/ListErrorView.tsx | 10 +- .../components/errors/ScreenErrorScreen.tsx | 10 +- apps/mobile/src/modules/ai/summary.tsx | 21 +- apps/mobile/src/modules/discover/Category.tsx | 5 +- .../src/modules/discover/Recommendations.tsx | 13 +- .../src/modules/discover/SearchTabBar.tsx | 17 +- apps/mobile/src/modules/discover/Trending.tsx | 9 +- apps/mobile/src/modules/discover/constants.ts | 21 +- .../discover/search-tabs/SearchFeed.tsx | 9 +- .../discover/search-tabs/SearchFeedCard.tsx | 15 +- .../discover/search-tabs/SearchList.tsx | 17 +- .../modules/discover/search-tabs/hooks.tsx | 6 +- .../src/modules/entry-list/EntryListEmpty.tsx | 10 +- .../entry-list/templates/EntryPictureItem.tsx | 4 +- .../entry-list/templates/EntryVideoItem.tsx | 10 +- apps/mobile/src/modules/login/email.tsx | 30 +- apps/mobile/src/modules/login/index.tsx | 9 +- .../src/modules/settings/routes/General.tsx | 4 +- .../(modal)/DiscoverSettingsScreen.tsx | 12 +- locales/ai/fr-FR.json | 371 ++++++++ locales/ai/ja.json | 54 ++ locales/ai/zh-CN.json | 7 + locales/ai/zh-TW.json | 37 + locales/app/fr-FR.json | 545 ++++++++++++ locales/app/ja.json | 8 +- locales/app/zh-CN.json | 2 + locales/app/zh-TW.json | 16 + locales/common/en.json | 18 + locales/common/fr-FR.json | 111 +++ locales/common/ja.json | 19 + locales/common/zh-CN.json | 19 + locales/common/zh-TW.json | 19 + locales/errors/fr-FR.json | 88 ++ locales/external/fr-FR.json | 89 ++ locales/external/ja.json | 1 + locales/external/zh-CN.json | 4 + locales/lang/fr-FR.json | 3 + locales/mobile/default/en.json | 15 + locales/mobile/default/fr-FR.json | 96 +++ locales/mobile/default/ja.json | 15 + locales/mobile/default/zh-CN.json | 15 + locales/mobile/default/zh-TW.json | 15 + locales/native/fr-FR.json | 74 ++ locales/settings/fr-FR.json | 803 ++++++++++++++++++ locales/settings/ja.json | 6 + locales/settings/zh-CN.json | 7 + locales/shortcuts/fr-FR.json | 80 ++ locales/shortcuts/ja.json | 2 + locales/shortcuts/zh-CN.json | 4 + packages/internal/shared/src/language.ts | 20 +- .../internal/shared/src/settings/interface.ts | 2 +- .../store/src/modules/summary/store.ts | 3 +- .../store/src/modules/translation/store.ts | 3 +- 62 files changed, 2759 insertions(+), 106 deletions(-) create mode 100644 locales/ai/fr-FR.json create mode 100644 locales/app/fr-FR.json create mode 100644 locales/common/fr-FR.json create mode 100644 locales/errors/fr-FR.json create mode 100644 locales/external/fr-FR.json create mode 100644 locales/lang/fr-FR.json create mode 100644 locales/mobile/default/fr-FR.json create mode 100644 locales/native/fr-FR.json create mode 100644 locales/settings/fr-FR.json create mode 100644 locales/shortcuts/fr-FR.json diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 8f5eac449..c92e47a2a 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -2,10 +2,12 @@ ## Shiny new things +- Supported French localization. + ## Improvements ## No longer broken ## Thanks -Special thanks to volunteer contributors @ for their valuable contributions +Special thanks to volunteer contributors @AnthonyMahe for their valuable contributions diff --git a/apps/desktop/layer/renderer/src/@types/constants.ts b/apps/desktop/layer/renderer/src/@types/constants.ts index 2d5659421..a1ee08167 100644 --- a/apps/desktop/layer/renderer/src/@types/constants.ts +++ b/apps/desktop/layer/renderer/src/@types/constants.ts @@ -1,5 +1,5 @@ // DONT EDIT THIS FILE MANUALLY -const langs = ["en", "zh-CN", "zh-TW", "ja"] as const +const langs = ["en", "zh-CN", "zh-TW", "ja", "fr-FR"] as const export const currentSupportedLanguages = langs as readonly string[] export type RendererSupportedLanguages = (typeof langs)[number] @@ -8,6 +8,7 @@ export const dayjsLocaleImportMap = { ["zh-CN"]: ["zh-cn", () => import("dayjs/locale/zh-cn")], ["ja"]: ["ja", () => import("dayjs/locale/ja")], ["zh-TW"]: ["zh-tw", () => import("dayjs/locale/zh-tw")], + ["fr-FR"]: ["fr", () => import("dayjs/locale/fr")], } export const ns = ["common", "lang", "errors", "app", "settings", "shortcuts", "ai"] as const export const defaultNS = "app" as const diff --git a/apps/desktop/layer/renderer/src/@types/default-resource.electron.ts b/apps/desktop/layer/renderer/src/@types/default-resource.electron.ts index 11c07ff52..723e4c1ce 100644 --- a/apps/desktop/layer/renderer/src/@types/default-resource.electron.ts +++ b/apps/desktop/layer/renderer/src/@types/default-resource.electron.ts @@ -1,27 +1,34 @@ // DONT EDIT THIS FILE MANUALLY import ai_en from "@locales/ai/en.json" +import ai_frFR from "@locales/ai/fr-FR.json" import ai_ja from "@locales/ai/ja.json" import en from "@locales/app/en.json" +import app_frFR from "@locales/app/fr-FR.json" import app_ja from "@locales/app/ja.json" import app_zhCN from "@locales/app/zh-CN.json" import app_zhTW from "@locales/app/zh-TW.json" import common_en from "@locales/common/en.json" +import common_frFR from "@locales/common/fr-FR.json" import common_ja from "@locales/common/ja.json" import common_zhCN from "@locales/common/zh-CN.json" import common_zhTW from "@locales/common/zh-TW.json" import errors_en from "@locales/errors/en.json" +import errors_frFR from "@locales/errors/fr-FR.json" import errors_ja from "@locales/errors/ja.json" import errors_zhCN from "@locales/errors/zh-CN.json" import errors_zhTW from "@locales/errors/zh-TW.json" import lang_en from "@locales/lang/en.json" +import lang_frFR from "@locales/lang/fr-FR.json" import lang_ja from "@locales/lang/ja.json" import lang_zhCN from "@locales/lang/zh-CN.json" import lang_zhTW from "@locales/lang/zh-TW.json" import settings_en from "@locales/settings/en.json" +import settings_frFR from "@locales/settings/fr-FR.json" import settings_ja from "@locales/settings/ja.json" import settings_zhCN from "@locales/settings/zh-CN.json" import settings_zhTW from "@locales/settings/zh-TW.json" import shortcuts_en from "@locales/shortcuts/en.json" +import shortcuts_frFR from "@locales/shortcuts/fr-FR.json" import shortcuts_ja from "@locales/shortcuts/ja.json" import shortcuts_zhCN from "@locales/shortcuts/zh-CN.json" import shortcuts_zhTW from "@locales/shortcuts/zh-TW.json" @@ -70,6 +77,15 @@ export const defaultResources = { errors: errors_zhTW, ai: ai_en, // Fallback to English until Traditional Chinese translation is available }, + "fr-FR": { + app: app_frFR, + lang: lang_frFR, + common: common_frFR, + settings: settings_frFR, + shortcuts: shortcuts_frFR, + errors: errors_frFR, + ai: ai_frFR, + }, } satisfies Record< RendererSupportedLanguages, Partial>> diff --git a/apps/desktop/layer/renderer/src/@types/default-resource.ts b/apps/desktop/layer/renderer/src/@types/default-resource.ts index 946e97cdc..193d31b72 100644 --- a/apps/desktop/layer/renderer/src/@types/default-resource.ts +++ b/apps/desktop/layer/renderer/src/@types/default-resource.ts @@ -2,11 +2,13 @@ import ai_en from "@locales/ai/en.json" import en from "@locales/app/en.json" import common_en from "@locales/common/en.json" +import common_frFR from "@locales/common/fr-FR.json" import common_ja from "@locales/common/ja.json" import common_zhCN from "@locales/common/zh-CN.json" import common_zhTW from "@locales/common/zh-TW.json" import errors_en from "@locales/errors/en.json" import lang_en from "@locales/lang/en.json" +import lang_frFR from "@locales/lang/fr-FR.json" import lang_ja from "@locales/lang/ja.json" import lang_zhCN from "@locales/lang/zh-CN.json" import lang_zhTW from "@locales/lang/zh-TW.json" @@ -42,6 +44,7 @@ export const defaultResources = { common: common_ja, }, "zh-TW": { lang: lang_zhTW, common: common_zhTW }, + "fr-FR": { lang: lang_frFR, common: common_frFR }, } satisfies Record< RendererSupportedLanguages, Partial>> diff --git a/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx b/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx index d4d44783a..c8fc85498 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx @@ -18,13 +18,17 @@ const LanguageOptions = [ label: "words.english", value: "eng", }, + { + label: "words.french", + value: "fra", + }, { label: "words.chinese", value: "cmn", }, ] satisfies ResponsiveSelectItem[] -type Language = "all" | "eng" | "cmn" +type Language = "all" | "eng" | "cmn" | "fra" type DiscoveryView = "trending" | "categories" export function DiscoveryContent() { diff --git a/apps/desktop/layer/renderer/src/modules/trending/index.tsx b/apps/desktop/layer/renderer/src/modules/trending/index.tsx index 9fccb5668..231be9804 100644 --- a/apps/desktop/layer/renderer/src/modules/trending/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/trending/index.tsx @@ -25,9 +25,13 @@ const LanguageOptions = [ label: "words.chinese", value: "cmn", }, + { + label: "words.french", + value: "fra", + }, ] -type Language = "all" | "eng" | "cmn" +type Language = "all" | "eng" | "cmn" | "fra" type View = "all" | string @@ -64,6 +68,7 @@ export function Trending({ const { t: tCommon } = useTranslation("common") const lang = useUISettingKey("discoverLanguage") const { onUpdateMaxScroll } = useScrollElementUpdate() + const trendingLanguage = lang === "fra" ? "eng" : lang const [selectedView, setSelectedView] = useState("all") const viewOptions = useMemo(() => buildViewOptions(), []) @@ -72,7 +77,7 @@ export function Trending({ queryKey: ["trending", lang, selectedView], queryFn: async () => { return await followClient.api.trending.getFeeds({ - language: lang === "all" ? undefined : lang, + language: trendingLanguage === "all" ? undefined : trendingLanguage, view: selectedView === "all" ? undefined : Number(selectedView), limit, }) diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/category/[category].tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/category/[category].tsx index 6df48f2d7..2cb995238 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/category/[category].tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/category/[category].tsx @@ -26,6 +26,7 @@ const LanguageMap = { all: "all", eng: "en", cmn: "zh-CN", + fra: "fr-FR", } as const export const Component = () => { @@ -134,7 +135,7 @@ export const Component = () => { ) : items.length > 0 ? (
{ @@ -162,12 +163,10 @@ export const Component = () => {

- {t("common.noContent", { defaultValue: "No content found in this category" })} + {t("discover.empty.no_content", { ns: "common" })}

- {t("discover.tryAnotherCategory", { - defaultValue: "Try selecting another category or language", - })} + {t("discover.empty.try_another_category_or_language", { ns: "common" })}

)} diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index 000f858e3..255f6f190 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -2,10 +2,12 @@ ## Shiny new things +- Supported French localization. + ## Improvements ## No longer broken ## Thanks -Special thanks to volunteer contributors @ for their valuable contributions +Special thanks to volunteer contributors @AnthonyMahe for their valuable contributions diff --git a/apps/mobile/src/components/errors/GlobalErrorScreen.tsx b/apps/mobile/src/components/errors/GlobalErrorScreen.tsx index 332ec3433..c8f0ba87f 100644 --- a/apps/mobile/src/components/errors/GlobalErrorScreen.tsx +++ b/apps/mobile/src/components/errors/GlobalErrorScreen.tsx @@ -1,5 +1,6 @@ import { applicationName } from "expo-application" import * as React from "react" +import { useTranslation } from "react-i18next" import { TouchableOpacity, View } from "react-native" import { Text } from "@/src/components/ui/typography/Text" @@ -9,22 +10,23 @@ interface GlobalErrorScreenProps { resetError?: () => void } export const GlobalErrorScreen: React.FC = ({ error, resetError }) => { + const { t } = useTranslation("common") return ( 😕 - {applicationName} crashed! + {t("error_screen.crashed", { appName: applicationName ?? "App" })} - {error?.message || "An unexpected error occurred."} + {error?.message || t("error_screen.unexpected")} {resetError && ( - Try Again + {t("retry")} )} diff --git a/apps/mobile/src/components/errors/ListErrorView.tsx b/apps/mobile/src/components/errors/ListErrorView.tsx index c6613b81a..4f59cad03 100644 --- a/apps/mobile/src/components/errors/ListErrorView.tsx +++ b/apps/mobile/src/components/errors/ListErrorView.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next" import { TouchableOpacity, View } from "react-native" import { useColor } from "react-native-uikit-colors" @@ -7,6 +8,7 @@ import { AlertCuteFiIcon } from "@/src/icons/alert_cute_fi" import { MonoText } from "../ui/typography/MonoText" export const ListErrorView = ({ error, resetError }: { error: Error; resetError: () => void }) => { + const { t } = useTranslation("common") const red = useColor("red") return ( @@ -15,17 +17,17 @@ export const ListErrorView = ({ error, resetError }: { error: Error; resetError: - Unable to Load Content + {t("error_screen.list_unable_to_load")} - There was a problem loading the list. Please try again later. + {t("error_screen.list_try_later")} - {error.message || "Unknown Error"} + {error.message || t("error_screen.unknown")} - Try Again + {t("retry")}
diff --git a/apps/mobile/src/components/errors/ScreenErrorScreen.tsx b/apps/mobile/src/components/errors/ScreenErrorScreen.tsx index 6315ade76..f57b76c25 100644 --- a/apps/mobile/src/components/errors/ScreenErrorScreen.tsx +++ b/apps/mobile/src/components/errors/ScreenErrorScreen.tsx @@ -1,4 +1,5 @@ import * as React from "react" +import { useTranslation } from "react-i18next" import { TouchableOpacity, View } from "react-native" import { Text } from "@/src/components/ui/typography/Text" @@ -9,6 +10,7 @@ interface ScreenErrorScreenProps { resetError?: () => void } export const ScreenErrorScreen: React.FC = ({ error }) => { + const { t } = useTranslation("common") const navigation = useNavigation() const canGoBack = useCanBack() return ( @@ -16,10 +18,10 @@ export const ScreenErrorScreen: React.FC = ({ error }) = 😕 - This page went wrong, go back and try again. + {t("error_screen.page_failed")} - {error?.message || "An unexpected error occurred."} + {error?.message || t("error_screen.unexpected")} @@ -28,7 +30,9 @@ export const ScreenErrorScreen: React.FC = ({ error }) = className="min-w-[160px] rounded-xl bg-accent px-6 py-3" onPress={() => navigation.back()} > - Go Back + + {t("words.back")} + )} diff --git a/apps/mobile/src/modules/ai/summary.tsx b/apps/mobile/src/modules/ai/summary.tsx index 6881414f4..aef99f0e8 100644 --- a/apps/mobile/src/modules/ai/summary.tsx +++ b/apps/mobile/src/modules/ai/summary.tsx @@ -95,6 +95,7 @@ export const AISummary: FC<{ const upgradeTitle = t("ai.summary_upgrade_required_title") const upgradeDescription = t("ai.summary_upgrade_required_description") const upgradeCTA = t("ai.summary_upgrade_view_plans") + const summaryTitle = t("entry_actions.toggle_ai_summary") const handleUpgradePress = () => { void Haptics.selectionAsync() void navigateToPlanScreen() @@ -163,12 +164,12 @@ export const AISummary: FC<{ - AI Summary + {summaryTitle} } > - AI Summary + {summaryTitle} @@ -177,7 +178,7 @@ export const AISummary: FC<{ onPress={() => { Clipboard.setString(summaryTextForSheet) Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) - toast.success("Copied to clipboard") + toast.success(t("app.copied_to_clipboard", { ns: "common" })) }} onLongPress={() => setSheetOpen(true)} className="rounded-full bg-quaternary-system-fill p-1.5 active:opacity-70" @@ -230,6 +231,7 @@ const ErrorContent = ({ message: string onRetry?: () => void }) => { + const { t } = useTranslation() return ( @@ -238,14 +240,18 @@ const ErrorContent = ({ {onRetry && (forMeasurement ? ( - Retry + + {t("retry", { ns: "common" })} + ) : ( - Retry + + {t("retry", { ns: "common" })} + ))} @@ -312,6 +318,7 @@ const SelectableTextSheet: FC<{ onClose: () => void text: string }> = ({ visible, onClose, text }) => { + const { t } = useTranslation() const insets = useSafeAreaInsets() const textColor = useColor("label") const handleCopyAll = () => { @@ -334,7 +341,9 @@ const SelectableTextSheet: FC<{ > - AI Summary + + {t("entry_actions.toggle_ai_summary")} + { + const { t } = useTranslation("common") const navigation = useNavigation() const label = useColor("label") return ( @@ -25,7 +26,9 @@ export const Category = () => { - Categories + + {t("words.categories")} + { ) } -const LanguageMap = { - all: "all", - eng: "en", - cmn: "zh-CN", -} as const export const RecommendationTab: TabComponent<{ contentContainerStyle?: StyleProp insets?: { @@ -151,11 +146,11 @@ export const RecommendationTab: TabComponent<{ insets: customInsets, ...rest }) => { + const { t } = useTranslation("common") const discoverLanguage = useUISettingKey("discoverLanguage") const { data, isLoading } = useQuery({ queryKey: ["rsshub-popular", tab.value, discoverLanguage], - queryFn: () => - fetchRsshubPopular(tab.value, LanguageMap[discoverLanguage]).then((res) => res.data), + queryFn: () => fetchRsshubPopular(tab.value, discoverLanguage).then((res) => res.data), staleTime: 1000 * 60 * 60 * 24, // 1 day meta: { @@ -164,7 +159,7 @@ export const RecommendationTab: TabComponent<{ }) const { data: analysisData, isLoading: isAnalysisLoading } = useQuery({ queryKey: ["rsshub-analysis", discoverLanguage], - queryFn: () => fetchRsshubAnalysis(LanguageMap[discoverLanguage]).then((res) => res.data), + queryFn: () => fetchRsshubAnalysis(discoverLanguage).then((res) => res.data), staleTime: 1000 * 60 * 60 * 24, // 1 day meta: { @@ -259,7 +254,7 @@ export const RecommendationTab: TabComponent<{ if (keys.length === 0) { return ( - This is a barren wasteland of knowledge. + {t("search.empty.no_results")} ) } diff --git a/apps/mobile/src/modules/discover/SearchTabBar.tsx b/apps/mobile/src/modules/discover/SearchTabBar.tsx index 1388e040d..37706e8aa 100644 --- a/apps/mobile/src/modules/discover/SearchTabBar.tsx +++ b/apps/mobile/src/modules/discover/SearchTabBar.tsx @@ -1,5 +1,7 @@ import { useAtom } from "jotai" import type { FC } from "react" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" import type { Animated } from "react-native" import { TabBar } from "@/src/components/ui/tabview/TabBar" @@ -11,17 +13,26 @@ import { useSearchPageContext } from "./ctx" export const SearchTabBar: FC<{ animatedX: Animated.Value }> = ({ animatedX }) => { + const { t } = useTranslation("common") const { searchTypeAtom } = useSearchPageContext() const [searchType, setSearchType] = useAtom(searchTypeAtom) + const tabs = useMemo( + () => + SearchTabs.map((tab) => ({ + ...tab, + name: t(tab.name), + })), + [t], + ) return ( tab.value === searchType)} + tabs={tabs} + currentTab={tabs.findIndex((tab) => tab.value === searchType)} onTabItemPress={(index) => { - setSearchType(SearchTabs[index]!.value as SearchType) + setSearchType(tabs[index]!.value as SearchType) }} /> ) diff --git a/apps/mobile/src/modules/discover/Trending.tsx b/apps/mobile/src/modules/discover/Trending.tsx index 2f6e4a89e..1089e6da1 100644 --- a/apps/mobile/src/modules/discover/Trending.tsx +++ b/apps/mobile/src/modules/discover/Trending.tsx @@ -1,5 +1,6 @@ import { cn, formatNumber } from "@follow/utils" import { useQuery } from "@tanstack/react-query" +import { useTranslation } from "react-i18next" import { View } from "react-native" import { useColor } from "react-native-uikit-colors" @@ -24,13 +25,15 @@ export const Trending = ({ className?: string itemClassName?: string }) => { + const { t } = useTranslation("common") const label = useColor("label") const discoverLanguage = useUISettingKey("discoverLanguage") + const trendingLanguage = discoverLanguage === "fra" ? "eng" : discoverLanguage const { data, isLoading } = useQuery({ queryKey: ["trending", "feeds", discoverLanguage], queryFn: () => fetchFeedTrending({ - lang: discoverLanguage === "all" ? undefined : discoverLanguage, + lang: trendingLanguage === "all" ? undefined : trendingLanguage, limit: 20, }).then((res) => res.data), meta: { @@ -43,7 +46,9 @@ export const Trending = ({ - Trending + + {t("words.trending")} + { + const { t } = useTranslation("common") const { searchValueAtom } = useSearchPageContext() const searchValue = useAtomValue(searchValueAtom) const windowWidth = useWindowDimensions().width @@ -24,13 +26,18 @@ export const SearchFeed = () => { const skeleton = useDataSkeleton(isLoading, data) if (skeleton) return skeleton if (data === undefined) return null + const resultCount = data.data?.length ?? 0 + const resultLabel = + resultCount === 0 + ? t("discover.search.results_zero") + : t("discover.search.results_other", { count: resultCount }) return ( - Found {data.data?.length} feeds + {resultLabel} {data.data?.map((item) => ( diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx index 7aece2de1..3bfe540a5 100644 --- a/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx @@ -1,6 +1,7 @@ import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks" import { formatNumber } from "@follow/utils" import type { DiscoveryItem, TrendingFeedItem } from "@follow-app/client-sdk" +import { useTranslation } from "react-i18next" import { View } from "react-native" import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime" @@ -13,15 +14,17 @@ import { useColor } from "@/src/theme/colors" import { FeedSummary } from "../FeedSummary" export const SearchFeedCard = ({ item }: { item: TrendingFeedItem | DiscoveryItem }) => { + const { t } = useTranslation("common") const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? "") const iconColor = useColor("secondaryLabel") + const followerCount = item.analytics?.subscriptionCount || 0 return ( - {formatNumber(item.analytics?.subscriptionCount || 0)} followers + {formatNumber(followerCount)} {t("feed.follower_other")} @@ -29,13 +32,13 @@ export const SearchFeedCard = ({ item }: { item: TrendingFeedItem | DiscoveryIte <> - {item.analytics.updatesPerWeek} entries/week + {t("feed.entry_week_other", { count: item.analytics.updatesPerWeek })} ) : item.analytics?.latestEntryPublishedAt ? ( <> - Updated + {t("feed.updated_at")} {isSubscribed ? ( - Followed + + {t("feed.actions.followed")} + ) : ( - Follow + {t("feed.actions.follow")} )} diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx index 5572019c3..2fffd12c4 100644 --- a/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx @@ -3,6 +3,7 @@ import { formatNumber } from "@follow/utils" import { useQuery } from "@tanstack/react-query" import { useAtomValue } from "jotai" import { memo } from "react" +import { useTranslation } from "react-i18next" import { useWindowDimensions, View } from "react-native" import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon" @@ -26,6 +27,7 @@ type SearchResultItem = Awaited< ReturnType >["data"][number] export const SearchList = () => { + const { t } = useTranslation("common") const { searchValueAtom } = useSearchPageContext() const searchValue = useAtomValue(searchValueAtom) const windowWidth = useWindowDimensions().width @@ -37,13 +39,18 @@ export const SearchList = () => { const skeleton = useDataSkeleton(isLoading, data) if (skeleton) return skeleton if (data === undefined) return null + const resultCount = data.data?.length ?? 0 + const resultLabel = + resultCount === 0 + ? t("discover.search.results_zero") + : t("discover.search.results_other", { count: resultCount }) return ( - Found {data.data?.length} lists + {resultLabel} {data.data?.map((item) => ( @@ -56,9 +63,11 @@ export const SearchList = () => { ) } const SearchListCard = memo(({ item }: { item: SearchResultItem }) => { + const { t } = useTranslation("common") const isSubscribed = useSubscriptionByListId(item.list?.id ?? "") const navigation = useNavigation() const iconColor = useColor("text") + const followerCount = item.analytics?.subscriptionCount || 0 return ( { {isSubscribed ? ( - Followed + {t("feed.actions.followed")} ) : ( - Follow + {t("feed.actions.follow")} )} @@ -126,7 +135,7 @@ const SearchListCard = memo(({ item }: { item: SearchResultItem }) => { - {formatNumber(item.analytics?.subscriptionCount || 0)} followers + {formatNumber(followerCount)} {t("feed.follower_other")} diff --git a/apps/mobile/src/modules/discover/search-tabs/hooks.tsx b/apps/mobile/src/modules/discover/search-tabs/hooks.tsx index 0a9c94a87..36153c110 100644 --- a/apps/mobile/src/modules/discover/search-tabs/hooks.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/hooks.tsx @@ -1,5 +1,6 @@ import { withOpacity } from "@follow/utils" import { useMemo } from "react" +import { useTranslation } from "react-i18next" import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator" import { Text } from "@/src/components/ui/typography/Text" @@ -9,6 +10,7 @@ import { useColor } from "@/src/theme/colors" import { BaseSearchPageRootView } from "./__base" export const useDataSkeleton = (isLoading: boolean, data: any) => { + const { t } = useTranslation("common") const textColor = useColor("text") return useMemo(() => { if (isLoading) { @@ -22,10 +24,10 @@ export const useDataSkeleton = (isLoading: boolean, data: any) => { return ( - No results found + {t("search.empty.no_results")} ) } return null - }, [isLoading, data, textColor]) + }, [isLoading, data, t, textColor]) } diff --git a/apps/mobile/src/modules/entry-list/EntryListEmpty.tsx b/apps/mobile/src/modules/entry-list/EntryListEmpty.tsx index 3713be8ea..23bfbd57e 100644 --- a/apps/mobile/src/modules/entry-list/EntryListEmpty.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListEmpty.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next" import { View } from "react-native" import { useGeneralSettingKey } from "@/src/atoms/settings/general" @@ -6,6 +7,7 @@ import { CelebrateCuteReIcon } from "@/src/icons/celebrate_cute_re" import { useColor } from "@/src/theme/colors" export const EntryListEmpty = () => { + const { t } = useTranslation() const unreadOnly = useGeneralSettingKey("unreadOnly") const color = useColor("secondaryLabel") return ( @@ -13,10 +15,14 @@ export const EntryListEmpty = () => { {unreadOnly ? ( <> - Zero Unread + + {t("entry_list.zero_unread")} + ) : ( - No entries + + {t("search.empty.no_results", { ns: "common" })} + )} ) diff --git a/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx index 6ef497814..2c60a44c9 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx @@ -8,6 +8,7 @@ import { uniqBy } from "es-toolkit/compat" import type { ImageSource } from "expo-image" import type { Ref } from "react" import { useMemo } from "react" +import { useTranslation } from "react-i18next" import { View } from "react-native" import { measure, runOnJS, runOnUI, useAnimatedRef } from "react-native-reanimated" @@ -16,6 +17,7 @@ import { useLightboxControls } from "@/src/components/ui/lightbox/lightboxState" import { Text } from "@/src/components/ui/typography/Text" export function EntryPictureItem({ id }: { id: string }) { + const { t } = useTranslation() const { openLightbox } = useLightboxControls() const aviRef = useAnimatedRef() const item = useEntry(id, (state) => ({ @@ -36,7 +38,7 @@ export function EntryPictureItem({ id }: { id: string }) { aspectRatio: 16 / 9, }} > - No media available + {t("entry_content.no_content")} ) } diff --git a/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx index 9690ebacf..727daeb2f 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx @@ -4,6 +4,7 @@ import { unreadSyncService } from "@follow/store/unread/store" import { tracker } from "@follow/tracker" import { formatDuration, transformVideoUrl } from "@follow/utils" import { memo, useMemo } from "react" +import { useTranslation } from "react-i18next" import { Linking, View } from "react-native" import { getGeneralSettings } from "@/src/atoms/settings/general" @@ -18,6 +19,7 @@ import { VideoContextMenu } from "../../context-menu/video" import { EntryGridFooter } from "../../entry-content/EntryGridFooter" export const EntryVideoItem = memo(({ id }: { id: string }) => { + const { t } = useTranslation() const item = useEntry(id, (state) => ({ attachments: state.attachments, media: state.media, @@ -49,7 +51,7 @@ export const EntryVideoItem = memo(({ id }: { id: string }) => { entryId: id, }) if (!item.url) { - toast.error("No video URL found") + toast.error(t("entry_content.no_video_url")) return } openVideo(item.url) @@ -68,7 +70,7 @@ export const EntryVideoItem = memo(({ id }: { id: string }) => { }} /> ) : ( - + )} {!!duration && ( @@ -83,14 +85,14 @@ export const EntryVideoItem = memo(({ id }: { id: string }) => { ) }) EntryVideoItem.displayName = "EntryVideoItem" -const FallbackMedia = () => ( +const FallbackMedia = ({ text }: { text: string }) => ( - No media available + {text} ) const parseSchemeLink = (url: string) => { diff --git a/apps/mobile/src/modules/login/email.tsx b/apps/mobile/src/modules/login/email.tsx index fb24739b0..006e2d079 100644 --- a/apps/mobile/src/modules/login/email.tsx +++ b/apps/mobile/src/modules/login/email.tsx @@ -1,9 +1,11 @@ import { tracker } from "@follow/tracker" import { zodResolver } from "@hookform/resolvers/zod" import { useMutation } from "@tanstack/react-query" +import i18next from "i18next" import { useCallback, useRef } from "react" import type { Control } from "react-hook-form" import { useController, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" import type { TextInputProps } from "react-native" import { Alert, TouchableOpacity, View } from "react-native" import { KeyboardController } from "react-native-keyboard-controller" @@ -30,7 +32,7 @@ async function onSubmit(values: FormValue) { const result = formSchema.safeParse(values) if (!result.success) { const issue = result.error.issues[0] - Alert.alert("Invalid email or password", issue?.message) + Alert.alert(i18next.t("login.invalid_email_or_password"), issue?.message) return } await signIn @@ -60,6 +62,7 @@ async function onSubmit(values: FormValue) { }) } export function EmailLogin() { + const { t } = useTranslation() const emailValueRef = useRef("") const passwordValueRef = useRef("") const submitMutation = useMutation({ @@ -86,7 +89,7 @@ export function EmailLogin() { autoCorrect={false} keyboardType="email-address" autoComplete="email" - placeholder="Email" + placeholder={t("login.email")} className="flex-1 text-text" returnKeyType="next" onSubmitEditing={() => { @@ -105,7 +108,7 @@ export function EmailLogin() { autoCapitalize="none" autoCorrect={false} autoComplete="current-password" - placeholder="Password" + placeholder={t("login.password")} className="flex-1 text-text" secureTextEntry returnKeyType="go" @@ -118,9 +121,13 @@ export function EmailLogin() { className="mx-auto my-5" onPress={() => navigation.presentControllerView(ForgetPasswordScreen)} > - Forgot password? + {t("login.forget_password.note")} - + ) } @@ -134,7 +141,7 @@ const signupFormSchema = z confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", + message: i18next.t("login.passwords_do_not_match"), path: ["confirmPassword"], }) type SignupFormValue = z.infer @@ -160,6 +167,7 @@ function SignupInput({ ) } export function EmailSignUp() { + const { t } = useTranslation() const { control, handleSubmit, formState } = useForm({ resolver: zodResolver(signupFormSchema), defaultValues: { @@ -185,7 +193,7 @@ export function EmailSignUp() { if (res.error?.message) { toast.error(res.error.message) } else { - toast.success("Sign up successful") + toast.success(i18next.t("login.sign_up_successful")) tracker.register({ type: "email", }) @@ -209,7 +217,7 @@ export function EmailSignUp() { autoComplete="email" control={control} name="email" - placeholder="Email" + placeholder={t("login.email")} className="flex-1 text-text" returnKeyType="next" onSubmitEditing={() => { @@ -226,7 +234,7 @@ export function EmailSignUp() { autoComplete="password-new" control={control} name="password" - placeholder="Password" + placeholder={t("login.password")} className="flex-1 text-text" secureTextEntry returnKeyType="next" @@ -241,7 +249,7 @@ export function EmailSignUp() { autoComplete="password-new" control={control} name="confirmPassword" - placeholder="Confirm Password" + placeholder={t("login.confirm_password.label")} className="flex-1 text-text" secureTextEntry returnKeyType="go" @@ -255,7 +263,7 @@ export function EmailSignUp() { disabled={submitMutation.isPending || !formState.isValid} isLoading={submitMutation.isPending} onPress={signup} - title="Continue" + title={t("login.submit")} className="mt-8" /> diff --git a/apps/mobile/src/modules/login/index.tsx b/apps/mobile/src/modules/login/index.tsx index 38a23e3fa..a5b3b30fe 100644 --- a/apps/mobile/src/modules/login/index.tsx +++ b/apps/mobile/src/modules/login/index.tsx @@ -114,24 +114,23 @@ const TermsCheckBox = () => { ) } const TermsText = () => { + const { t } = useTranslation() return ( - - By continuing, you agree to our{" "} - + {t("login.agree_to")} Linking.openURL("https://folo.is/terms-of-service")} className="text-secondary-label" > - Terms of Service + {t("login.terms")}  &  Linking.openURL("https://folo.is/privacy-policy")} className="text-secondary-label" > - Privacy Policy + {t("login.privacy")} diff --git a/apps/mobile/src/modules/settings/routes/General.tsx b/apps/mobile/src/modules/settings/routes/General.tsx index 89affac9f..ece48755c 100644 --- a/apps/mobile/src/modules/settings/routes/General.tsx +++ b/apps/mobile/src/modules/settings/routes/General.tsx @@ -28,7 +28,9 @@ export function LanguageSelect({ settingKey }: { settingKey: "language" | "actio const languageKeys = settingKey === "language" ? (currentSupportedLanguages as MobileSupportedLanguages[]) - : ACTION_LANGUAGE_KEYS.sort( + : ACTION_LANGUAGE_KEYS.filter( + (key): key is MobileSupportedLanguages => key in defaultResources, + ).sort( (a, b) => currentSupportedLanguages.indexOf(a) - currentSupportedLanguages.indexOf(b), ) diff --git a/apps/mobile/src/screens/(modal)/DiscoverSettingsScreen.tsx b/apps/mobile/src/screens/(modal)/DiscoverSettingsScreen.tsx index 88391cccf..505729489 100644 --- a/apps/mobile/src/screens/(modal)/DiscoverSettingsScreen.tsx +++ b/apps/mobile/src/screens/(modal)/DiscoverSettingsScreen.tsx @@ -15,6 +15,7 @@ import { export const DiscoverSettingsScreen = () => { const { t } = useTranslation("settings") + const { t: tCommon } = useTranslation("common") const discoverLanguage = useUISettingKey("discoverLanguage") return ( @@ -25,16 +26,17 @@ export const DiscoverSettingsScreen = () => { - +