From fe47fef71b1db7054928c8772a6273f363c1ac63 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 10 Jun 2026 20:43:04 +0500 Subject: [PATCH] =?UTF-8?q?fix(ux):=20P1=20=E2=80=94=20i18n=20the=20Apps,?= =?UTF-8?q?=20Notifications,=20More=20&=20AI=20Assistant=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These screens shipped hardcoded English, so a Chinese (or Arabic) user saw a mix of localized chrome and English body text. Wire every user-facing string (titles, empty states, section headers, menu labels, the assistant's header / empty state / example prompts / input / actions, plus a11y labels) through i18next with new `apps`/`notifications`/`more`/`ai` namespaces in en, zh and ar. Apps uses an i18next plural for the installed-count. Home is intentionally left to its own PR (already i18n'd there) to avoid a merge conflict. The AIAssistant test now resolves `t(key)` against the real en bundle, so its English assertions hold while the screen reads from i18n. Verified in the browser (zh): Apps ("已安装 1 个应用"), Notifications, More, and the AI Assistant empty state all render Chinese. 1266 tests pass, lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/components/AIAssistant.test.tsx | 24 ++++++++ app/(tabs)/apps.tsx | 18 +++--- app/(tabs)/more.tsx | 38 ++++++------ app/(tabs)/notifications.tsx | 14 +++-- app/ai.tsx | 72 +++++++++++------------ locales/ar.json | 65 ++++++++++++++++++++ locales/en.json | 66 +++++++++++++++++++++ locales/zh.json | 65 ++++++++++++++++++++ 8 files changed, 294 insertions(+), 68 deletions(-) diff --git a/__tests__/components/AIAssistant.test.tsx b/__tests__/components/AIAssistant.test.tsx index 1c9762a..5bd36b6 100644 --- a/__tests__/components/AIAssistant.test.tsx +++ b/__tests__/components/AIAssistant.test.tsx @@ -8,6 +8,30 @@ jest.mock("~/hooks/useAIChat"); import { useAIChat } from "~/hooks/useAIChat"; const mockUseAIChat = useAIChat as jest.Mock; +// Resolve `t(key)` against the real English bundle so the screen renders its +// actual copy (the assertions below match the English strings) without spinning +// up the full i18n runtime that `_layout` initializes. +jest.mock("react-i18next", () => { + const en = jest.requireActual("~/locales/en.json") as Record; + const lookup = (key: string): unknown => + key.split(".").reduce( + (o, k) => (o as Record | undefined)?.[k], + en, + ); + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + const value = lookup(key); + if (opts?.returnObjects) return Array.isArray(value) ? value : []; + if (typeof value !== "string") return key; + return opts + ? value.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(opts[name] ?? "")) + : value; + }, + }), + }; +}); + const mockSetString = jest.fn().mockResolvedValue(undefined); jest.mock("expo-clipboard", () => ({ setStringAsync: (...args: unknown[]) => mockSetString(...args), diff --git a/app/(tabs)/apps.tsx b/app/(tabs)/apps.tsx index b3e02ae..d353f43 100644 --- a/app/(tabs)/apps.tsx +++ b/app/(tabs)/apps.tsx @@ -1,6 +1,7 @@ import { View, Text, ScrollView, RefreshControl } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; import { useCallback, useState } from "react"; import { LayoutGrid, ChevronRight } from "lucide-react-native"; import { PressableCard } from "~/components/ui/PressableCard"; @@ -13,6 +14,7 @@ import { getUserErrorMessage } from "~/lib/error-handling"; export default function AppsScreen() { const { apps, isLoading, error, refetch } = useApps(); const router = useRouter(); + const { t } = useTranslation(); const [isRefreshing, setIsRefreshing] = useState(false); const handleAppPress = (appName: string) => { @@ -38,11 +40,11 @@ export default function AppsScreen() { } > - Apps + {t("apps.title")} {showSkeleton - ? "Loading your apps…" - : `${apps.length} app${apps.length !== 1 ? "s" : ""} installed`} + ? t("apps.loading") + : t("apps.installed", { count: apps.length })} @@ -53,9 +55,9 @@ export default function AppsScreen() { @@ -63,8 +65,8 @@ export default function AppsScreen() { ) : ( @@ -77,7 +79,7 @@ export default function AppsScreen() { className="flex-row items-center p-4" onPress={() => handleAppPress(app.name)} accessibilityRole="button" - accessibilityLabel={`Open ${app.label}`} + accessibilityLabel={t("apps.openA11y", { name: app.label })} > diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx index 363c636..11ff2d4 100644 --- a/app/(tabs)/more.tsx +++ b/app/(tabs)/more.tsx @@ -11,6 +11,7 @@ import { Sparkles, } from "lucide-react-native"; import { useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; import { authClient } from "~/lib/auth-client"; import { useToast } from "~/components/ui/Toast"; import { useConfirm } from "~/components/ui/ConfirmDialog"; @@ -55,6 +56,7 @@ function SectionHeader({ title }: { title: string }) { export default function MoreScreen() { const { data: session } = authClient.useSession(); const router = useRouter(); + const { t } = useTranslation(); const { toastError } = useToast(); const confirm = useConfirm(); @@ -63,15 +65,15 @@ export default function MoreScreen() { await authClient.signOut(); router.replace("/(auth)/sign-in"); } catch { - toastError("Failed to sign out. Please try again."); + toastError(t("more.signOutFailed")); } }; const handleSignOut = async () => { const ok = await confirm({ - title: "Sign Out", - message: "Are you sure you want to sign out?", - confirmLabel: "Sign Out", + title: t("more.signOutTitle"), + message: t("more.signOutConfirm"), + confirmLabel: t("more.signOutTitle"), destructive: true, }); if (ok) void performSignOut(); @@ -84,7 +86,7 @@ export default function MoreScreen() { router.push("/account")} - accessibilityLabel="View profile" + accessibilityLabel={t("more.viewProfile")} accessibilityRole="button" > @@ -92,54 +94,54 @@ export default function MoreScreen() { - {session?.user.name ?? "User"} + {session?.user.name ?? t("more.profileFallbackName")} - {session?.user.email ?? "View profile"} + {session?.user.email ?? t("more.viewProfile")} {/* Account */} - + } - label="Account & Security" + label={t("more.accountSecurity")} onPress={() => router.push("/account")} /> } - label="Notifications" + label={t("more.notifications")} onPress={() => router.push("/(tabs)/notifications")} /> {/* Assistant */} - + } - label="AI Assistant" + label={t("more.aiAssistant")} onPress={() => router.push("/ai")} /> {/* Automation */} - + } - label="Approvals" + label={t("more.approvals")} onPress={() => router.push("/approvals")} /> } - label="Flows" + label={t("more.flows")} onPress={() => router.push("/flows")} /> {/* Preferences */} - + } - label="Language" + label={t("more.language")} onPress={() => router.push("/language")} /> @@ -147,7 +149,7 @@ export default function MoreScreen() { } - label="Sign Out" + label={t("more.signOut")} onPress={handleSignOut} showChevron={false} destructive diff --git a/app/(tabs)/notifications.tsx b/app/(tabs)/notifications.tsx index 0692e11..ef122cf 100644 --- a/app/(tabs)/notifications.tsx +++ b/app/(tabs)/notifications.tsx @@ -3,6 +3,7 @@ import { View, Text, ScrollView, Pressable } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Bell, CheckCheck, Circle } from "lucide-react-native"; import { useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; import { cn } from "~/lib/utils"; import { EmptyState } from "~/components/ui/EmptyState"; import { ListSkeleton } from "~/components/ui/ListSkeleton"; @@ -71,6 +72,7 @@ export default function NotificationsScreen() { markAllRead, } = useNotifications(); const router = useRouter(); + const { t } = useTranslation(); const handlePress = (n: NotificationItem) => { if (!n.read) { @@ -87,10 +89,10 @@ export default function NotificationsScreen() { {/* Title header */} - Notifications + {t("notifications.title")} {unreadCount > 0 && ( - {unreadCount} unread + {t("notifications.unread", { count: unreadCount })} )} @@ -99,10 +101,10 @@ export default function NotificationsScreen() { className="flex-row items-center gap-1.5 rounded-lg px-3 py-1.5 active:bg-muted" onPress={() => void markAllRead()} accessibilityRole="button" - accessibilityLabel="Mark all notifications read" + accessibilityLabel={t("notifications.markAllReadA11y")} > - Mark all read + {t("notifications.markAllRead")} )} @@ -118,8 +120,8 @@ export default function NotificationsScreen() { {!isLoading && notifications.length === 0 && ( )} diff --git a/app/ai.tsx b/app/ai.tsx index 95ea632..9328f6b 100644 --- a/app/ai.tsx +++ b/app/ai.tsx @@ -10,6 +10,7 @@ import { ActivityIndicator, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { useTranslation } from "react-i18next"; import * as Clipboard from "expo-clipboard"; import { Send, @@ -34,18 +35,13 @@ import { Reasoning } from "~/components/ui/Reasoning"; import { cn } from "~/lib/utils"; import { useAIChat, type AIChatMessage } from "~/hooks/useAIChat"; -const EXAMPLE_PROMPTS = [ - "What objects can I ask about?", - "Show me the most recent records", - "Summarize what's in my workspace", -]; - /* ------------------------------------------------------------------ */ /* Message bubble */ /* ------------------------------------------------------------------ */ /** Copy-to-clipboard affordance shown under an assistant message. */ function CopyButton({ text }: { text: string }) { + const { t } = useTranslation(); const [copied, setCopied] = useState(false); const onCopy = useCallback(() => { void Clipboard.setStringAsync(text).then(() => { @@ -57,18 +53,19 @@ function CopyButton({ text }: { text: string }) { {copied ? : } - {copied ? "Copied" : "Copy"} + {copied ? t("ai.copied") : t("ai.copy")} ); } function MessageBubble({ message }: { message: AIChatMessage }) { + const { t } = useTranslation(); const isUser = message.role === "user"; // An assistant turn with no text yet = the reply is still streaming in. const isPending = !isUser && message.content.trim() === ""; @@ -91,7 +88,7 @@ function MessageBubble({ message }: { message: AIChatMessage }) { ) : isPending ? ( - Thinking… + {t("ai.thinking")} ) : ( // Assistant replies are markdown (bold, lists, code, links). @@ -125,6 +122,9 @@ export default function AIAssistantScreen() { removeConversation, renameConversation, } = useAIChat(); + const { t } = useTranslation(); + const rawExamples = t("ai.examples", { returnObjects: true }); + const examplePrompts: string[] = Array.isArray(rawExamples) ? rawExamples : []; const [draft, setDraft] = useState(""); const [drawerOpen, setDrawerOpen] = useState(false); const [renaming, setRenaming] = useState<{ id: string; title: string } | null>(null); @@ -157,7 +157,7 @@ export default function AIAssistantScreen() { return ( {serverBacked ? ( @@ -165,7 +165,7 @@ export default function AIAssistantScreen() { void newConversation()} accessibilityRole="button" - accessibilityLabel="New chat" + accessibilityLabel={t("ai.newChat")} className="h-9 w-9 items-center justify-center rounded-lg active:bg-muted" > @@ -173,7 +173,7 @@ export default function AIAssistantScreen() { setDrawerOpen(true)} accessibilityRole="button" - accessibilityLabel="Conversation history" + accessibilityLabel={t("ai.history")} className="h-9 w-9 items-center justify-center rounded-lg active:bg-muted" > @@ -184,7 +184,7 @@ export default function AIAssistantScreen() { @@ -196,7 +196,7 @@ export default function AIAssistantScreen() { /> {/* Conversations drawer (server-backed mode) */} - + { @@ -204,14 +204,14 @@ export default function AIAssistantScreen() { void newConversation(); }} accessibilityRole="button" - accessibilityLabel="Start a new chat" + accessibilityLabel={t("ai.startNewChat")} > - New chat + {t("ai.newChat")} {conversations.length === 0 ? ( - No saved conversations yet. + {t("ai.noConversations")} ) : ( conversations.map((c) => ( @@ -226,10 +226,10 @@ export default function AIAssistantScreen() { void loadConversation(c.id); }} accessibilityRole="button" - accessibilityLabel={c.title ?? "Untitled conversation"} + accessibilityLabel={c.title ?? t("ai.untitledA11y")} > - {c.title ?? "New conversation"} + {c.title ?? t("ai.newConversation")} @@ -246,7 +246,7 @@ export default function AIAssistantScreen() { void removeConversation(c.id)} accessibilityRole="button" - accessibilityLabel="Delete conversation" + accessibilityLabel={t("ai.delete")} className="h-9 w-9 items-center justify-center rounded-lg active:bg-muted" > @@ -260,15 +260,15 @@ export default function AIAssistantScreen() { !o && setRenaming(null)} - title="Rename conversation" + title={t("ai.rename")} > {renaming && ( setRenaming({ ...renaming, title: t })} - placeholder="Conversation title" + onChangeText={(text) => setRenaming({ ...renaming, title: text })} + placeholder={t("ai.titlePlaceholder")} placeholderTextColor="#9ca3af" autoFocus onSubmitEditing={() => { @@ -276,11 +276,11 @@ export default function AIAssistantScreen() { setRenaming(null); void renameConversation(r.id, r.title); }} - accessibilityLabel="Conversation title" + accessibilityLabel={t("ai.titlePlaceholder")} /> @@ -315,11 +315,11 @@ export default function AIAssistantScreen() { } - title="Ask the assistant" - description="Ask a question in plain language and the assistant will answer using your data." + title={t("ai.emptyTitle")} + description={t("ai.emptyDesc")} /> - {EXAMPLE_PROMPTS.map((p) => ( + {examplePrompts.map((p) => ( - Retry + {t("common.retry")} )} @@ -362,19 +362,19 @@ export default function AIAssistantScreen() { className="max-h-28 flex-1 rounded-2xl border border-input bg-background px-4 py-2.5 text-base text-foreground" value={draft} onChangeText={setDraft} - placeholder="Ask a question…" + placeholder={t("ai.inputPlaceholder")} placeholderTextColor="#9ca3af" multiline onSubmitEditing={() => submit(draft)} blurOnSubmit={false} - accessibilityLabel="Message" + accessibilityLabel={t("ai.message")} /> {isLoading ? ( @@ -387,7 +387,7 @@ export default function AIAssistantScreen() { canSend ? "bg-primary active:opacity-80" : "bg-muted", )} accessibilityRole="button" - accessibilityLabel="Send" + accessibilityLabel={t("ai.send")} accessibilityState={{ disabled: !canSend }} > diff --git a/locales/ar.json b/locales/ar.json index 3f542d2..701b6bd 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -136,6 +136,71 @@ "more": "المزيد", "profile": "الملف الشخصي" }, + "apps": { + "title": "التطبيقات", + "loading": "جارٍ تحميل تطبيقاتك…", + "installed_other": "{{count}} تطبيق مثبّت", + "loadErrorTitle": "تعذّر تحميل التطبيقات", + "emptyTitle": "لا توجد تطبيقات", + "emptyDesc": "ستظهر تطبيقات مؤسستك هنا بمجرد تثبيتها.", + "openA11y": "فتح {{name}}" + }, + "notifications": { + "title": "الإشعارات", + "unread": "{{count}} غير مقروء", + "markAllRead": "تحديد الكل كمقروء", + "markAllReadA11y": "تحديد جميع الإشعارات كمقروءة", + "emptyTitle": "لا توجد إشعارات", + "emptyDesc": "لقد اطّلعت على كل شيء. ستظهر الإشعارات الجديدة هنا." + }, + "more": { + "profileFallbackName": "مستخدم", + "viewProfile": "عرض الملف الشخصي", + "signOutFailed": "تعذّر تسجيل الخروج. حاول مرة أخرى.", + "signOutTitle": "تسجيل الخروج", + "signOutConfirm": "هل أنت متأكد أنك تريد تسجيل الخروج؟", + "sectionAccount": "الحساب", + "sectionAssistant": "المساعد", + "sectionAutomation": "الأتمتة", + "sectionPreferences": "التفضيلات", + "accountSecurity": "الحساب والأمان", + "notifications": "الإشعارات", + "aiAssistant": "المساعد الذكي", + "approvals": "الموافقات", + "flows": "التدفقات", + "language": "اللغة", + "signOut": "تسجيل الخروج" + }, + "ai": { + "title": "المساعد الذكي", + "thinking": "يفكّر…", + "copy": "نسخ", + "copied": "تم النسخ", + "copyA11y": "نسخ الرسالة", + "newChat": "محادثة جديدة", + "history": "سجل المحادثات", + "clear": "مسح المحادثة", + "conversations": "المحادثات", + "startNewChat": "بدء محادثة جديدة", + "noConversations": "لا توجد محادثات محفوظة بعد.", + "newConversation": "محادثة جديدة", + "untitledA11y": "محادثة بدون عنوان", + "rename": "إعادة تسمية المحادثة", + "delete": "حذف المحادثة", + "titlePlaceholder": "عنوان المحادثة", + "emptyTitle": "اسأل المساعد", + "emptyDesc": "اطرح سؤالاً بلغة طبيعية وسيجيب المساعد باستخدام بياناتك.", + "retryA11y": "إعادة المحاولة", + "message": "رسالة", + "stop": "إيقاف التوليد", + "send": "إرسال", + "inputPlaceholder": "اطرح سؤالاً…", + "examples": [ + "ما الكائنات التي يمكنني السؤال عنها؟", + "أظهر لي أحدث السجلات", + "لخّص ما يوجد في مساحة العمل" + ] + }, "search": { "title": "بحث", "placeholder": "ابحث في كل السجلات…", diff --git a/locales/en.json b/locales/en.json index c0e6825..2f3da1e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -132,6 +132,72 @@ "more": "More", "profile": "Profile" }, + "apps": { + "title": "Apps", + "loading": "Loading your apps…", + "installed_one": "{{count}} app installed", + "installed_other": "{{count}} apps installed", + "loadErrorTitle": "Unable to Load Apps", + "emptyTitle": "No Apps", + "emptyDesc": "Your enterprise applications will appear here once installed.", + "openA11y": "Open {{name}}" + }, + "notifications": { + "title": "Notifications", + "unread": "{{count}} unread", + "markAllRead": "Mark all read", + "markAllReadA11y": "Mark all notifications read", + "emptyTitle": "No Notifications", + "emptyDesc": "You're all caught up. New notifications will appear here." + }, + "more": { + "profileFallbackName": "User", + "viewProfile": "View profile", + "signOutFailed": "Failed to sign out. Please try again.", + "signOutTitle": "Sign Out", + "signOutConfirm": "Are you sure you want to sign out?", + "sectionAccount": "Account", + "sectionAssistant": "Assistant", + "sectionAutomation": "Automation", + "sectionPreferences": "Preferences", + "accountSecurity": "Account & Security", + "notifications": "Notifications", + "aiAssistant": "AI Assistant", + "approvals": "Approvals", + "flows": "Flows", + "language": "Language", + "signOut": "Sign Out" + }, + "ai": { + "title": "AI Assistant", + "thinking": "Thinking…", + "copy": "Copy", + "copied": "Copied", + "copyA11y": "Copy message", + "newChat": "New chat", + "history": "Conversation history", + "clear": "Clear conversation", + "conversations": "Conversations", + "startNewChat": "Start a new chat", + "noConversations": "No saved conversations yet.", + "newConversation": "New conversation", + "untitledA11y": "Untitled conversation", + "rename": "Rename conversation", + "delete": "Delete conversation", + "titlePlaceholder": "Conversation title", + "emptyTitle": "Ask the assistant", + "emptyDesc": "Ask a question in plain language and the assistant will answer using your data.", + "retryA11y": "Retry", + "message": "Message", + "stop": "Stop generating", + "send": "Send", + "inputPlaceholder": "Ask a question…", + "examples": [ + "What objects can I ask about?", + "Show me the most recent records", + "Summarize what's in my workspace" + ] + }, "search": { "title": "Search", "placeholder": "Search across all records…", diff --git a/locales/zh.json b/locales/zh.json index d43730b..cbdbc52 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -131,6 +131,71 @@ "more": "更多", "profile": "个人资料" }, + "apps": { + "title": "应用", + "loading": "正在加载你的应用…", + "installed_other": "已安装 {{count}} 个应用", + "loadErrorTitle": "无法加载应用", + "emptyTitle": "暂无应用", + "emptyDesc": "安装后,你的企业应用将显示在这里。", + "openA11y": "打开 {{name}}" + }, + "notifications": { + "title": "通知", + "unread": "{{count}} 条未读", + "markAllRead": "全部标为已读", + "markAllReadA11y": "将所有通知标为已读", + "emptyTitle": "暂无通知", + "emptyDesc": "你已全部处理完毕。新通知将显示在这里。" + }, + "more": { + "profileFallbackName": "用户", + "viewProfile": "查看个人资料", + "signOutFailed": "退出登录失败,请重试。", + "signOutTitle": "退出登录", + "signOutConfirm": "确定要退出登录吗?", + "sectionAccount": "账户", + "sectionAssistant": "助手", + "sectionAutomation": "自动化", + "sectionPreferences": "偏好设置", + "accountSecurity": "账户与安全", + "notifications": "通知", + "aiAssistant": "AI 助手", + "approvals": "审批", + "flows": "流程", + "language": "语言", + "signOut": "退出登录" + }, + "ai": { + "title": "AI 助手", + "thinking": "正在思考…", + "copy": "复制", + "copied": "已复制", + "copyA11y": "复制消息", + "newChat": "新对话", + "history": "对话历史", + "clear": "清空对话", + "conversations": "对话", + "startNewChat": "开始新对话", + "noConversations": "暂无已保存的对话。", + "newConversation": "新对话", + "untitledA11y": "未命名对话", + "rename": "重命名对话", + "delete": "删除对话", + "titlePlaceholder": "对话标题", + "emptyTitle": "向助手提问", + "emptyDesc": "用自然语言提问,助手将根据你的数据作答。", + "retryA11y": "重试", + "message": "消息", + "stop": "停止生成", + "send": "发送", + "inputPlaceholder": "输入你的问题…", + "examples": [ + "我可以查询哪些对象?", + "显示最近的记录", + "总结一下我的工作区里有什么" + ] + }, "search": { "title": "搜索", "placeholder": "搜索所有记录…",