- Add expo-localization, i18next, react-i18next (versions aligned with Expo 54 / RN 0.81) - src/lib/i18n/locale-resolve.ts: pure locale-resolution helpers (system tag -> supported catalog, with en fallback), unit-tested via node --test with no RN imports - src/lib/i18n/config.ts: i18next init wired to expo-localization device detection, en.json + zh-Hans.json catalogs - Persist a locale preference (system | en | zh-Hans) in the settings zustand store, applied immediately via i18next.changeLanguage - Wire I18nextProvider in app/_layout.tsx - Localize the Settings screen (~28 strings) as the reference pattern for extracting user-facing strings, with a language picker row and reviewed Simplified Chinese translations Other screens (session/[id], connection/*, index, chat components) are deferred follow-up — issue #68 stays open for that work. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
3
app.json
3
app.json
@@ -79,7 +79,8 @@
|
|||||||
"microphonePermission": "OpenCode uses the microphone to capture your voice when using speech-to-text input.",
|
"microphonePermission": "OpenCode uses the microphone to capture your voice when using speech-to-text input.",
|
||||||
"speechRecognitionPermission": "OpenCode uses speech recognition so you can dictate prompts to your AI coding agent hands-free."
|
"speechRecognitionPermission": "OpenCode uses speech recognition so you can dictate prompts to your AI coding agent hands-free."
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"expo-localization"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
} from "react-native"
|
} from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { useAuth } from "../../src/stores/auth"
|
import { useAuth } from "../../src/stores/auth"
|
||||||
import { useSettings } from "../../src/stores/settings"
|
import { useSettings } from "../../src/stores/settings"
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
import type { Category } from "../../src/lib/notifications"
|
import type { Category } from "../../src/lib/notifications"
|
||||||
import { hasTelemetryConsent, setTelemetryConsent } from "../../src/lib/telemetry"
|
import { hasTelemetryConsent, setTelemetryConsent } from "../../src/lib/telemetry"
|
||||||
import { PRIVACY_POLICY_URL } from "../../src/lib/links"
|
import { PRIVACY_POLICY_URL } from "../../src/lib/links"
|
||||||
|
import type { LocalePreference } from "../../src/lib/i18n/locale-resolve"
|
||||||
|
|
||||||
function SettingRow({
|
function SettingRow({
|
||||||
icon,
|
icon,
|
||||||
@@ -70,9 +72,10 @@ function SettingSection({ title, children, isDark }: { title: string; children:
|
|||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { settings, hasBiometrics, updateSettings, lock } = useAuth()
|
const { settings, hasBiometrics, updateSettings, lock } = useAuth()
|
||||||
const { notifications, setNotification } = useSettings()
|
const { notifications, setNotification, locale, setLocale } = useSettings()
|
||||||
const [osGranted, setOsGranted] = useState<boolean | null>(null)
|
const [osGranted, setOsGranted] = useState<boolean | null>(null)
|
||||||
const [telemetryUpdating, setTelemetryUpdating] = useState(false)
|
const [telemetryUpdating, setTelemetryUpdating] = useState(false)
|
||||||
|
|
||||||
@@ -80,21 +83,21 @@ export default function SettingsScreen() {
|
|||||||
// We initialise local state from in-memory value; updates call setTelemetryConsent().
|
// We initialise local state from in-memory value; updates call setTelemetryConsent().
|
||||||
const [crashReporting, setCrashReporting] = useState<boolean>(hasTelemetryConsent() ?? false)
|
const [crashReporting, setCrashReporting] = useState<boolean>(hasTelemetryConsent() ?? false)
|
||||||
|
|
||||||
const handleCrashReportingToggle = useCallback(async (value: boolean) => {
|
const handleCrashReportingToggle = useCallback(
|
||||||
setTelemetryUpdating(true)
|
async (value: boolean) => {
|
||||||
try {
|
setTelemetryUpdating(true)
|
||||||
await setTelemetryConsent(value)
|
try {
|
||||||
setCrashReporting(value)
|
await setTelemetryConsent(value)
|
||||||
} catch {
|
setCrashReporting(value)
|
||||||
setCrashReporting(hasTelemetryConsent() ?? false)
|
} catch {
|
||||||
Alert.alert(
|
setCrashReporting(hasTelemetryConsent() ?? false)
|
||||||
"Privacy Setting Not Saved",
|
Alert.alert(t("settings.alerts.privacyNotSavedTitle"), t("settings.alerts.privacyNotSavedMessage"))
|
||||||
"Crash reporting is off for this session, but your choice could not be saved. Please try again.",
|
} finally {
|
||||||
)
|
setTelemetryUpdating(false)
|
||||||
} finally {
|
}
|
||||||
setTelemetryUpdating(false)
|
},
|
||||||
}
|
[t],
|
||||||
}, [])
|
)
|
||||||
|
|
||||||
// Check OS permission state on first toggle attempt
|
// Check OS permission state on first toggle attempt
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
@@ -103,16 +106,13 @@ export default function SettingsScreen() {
|
|||||||
const ok = await setupNotifications()
|
const ok = await setupNotifications()
|
||||||
setOsGranted(ok)
|
setOsGranted(ok)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
Alert.alert(
|
Alert.alert(t("settings.alerts.notificationsDisabledTitle"), t("settings.alerts.notificationsDisabledMessage"))
|
||||||
"Notifications Disabled",
|
|
||||||
"Enable notifications for OpenCode in your device settings to receive alerts.",
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setNotification(category, enabled)
|
setNotification(category, enabled)
|
||||||
},
|
},
|
||||||
[setNotification],
|
[setNotification, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Lazy-check OS permission for status display
|
// Lazy-check OS permission for status display
|
||||||
@@ -122,14 +122,31 @@ export default function SettingsScreen() {
|
|||||||
.catch(() => setOsGranted(false))
|
.catch(() => setOsGranted(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localeLabels: Record<LocalePreference, string> = {
|
||||||
|
system: t("settings.language.system"),
|
||||||
|
en: t("settings.language.en"),
|
||||||
|
"zh-Hans": t("settings.language.zhHans"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLanguagePress = useCallback(() => {
|
||||||
|
Alert.alert(t("settings.language.title"), undefined, [
|
||||||
|
{ text: localeLabels.system, onPress: () => setLocale("system") },
|
||||||
|
{ text: localeLabels.en, onPress: () => setLocale("en") },
|
||||||
|
{ text: localeLabels["zh-Hans"], onPress: () => setLocale("zh-Hans") },
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
])
|
||||||
|
}, [t, setLocale, localeLabels])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={[styles.container, isDark && styles.containerDark]} contentContainerStyle={styles.content}>
|
<ScrollView style={[styles.container, isDark && styles.containerDark]} contentContainerStyle={styles.content}>
|
||||||
<SettingSection title="Security" isDark={isDark}>
|
<SettingSection title={t("settings.sections.security")} isDark={isDark}>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="finger-print"
|
icon="finger-print"
|
||||||
label="Require Biometric to Open"
|
label={t("settings.security.biometricOpen.label")}
|
||||||
description={
|
description={
|
||||||
hasBiometrics ? "Use Face ID or Touch ID to unlock the app" : "Biometric authentication not available"
|
hasBiometrics
|
||||||
|
? t("settings.security.biometricOpen.descriptionEnabled")
|
||||||
|
: t("settings.security.biometricOpen.descriptionUnavailable")
|
||||||
}
|
}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
right={
|
right={
|
||||||
@@ -143,8 +160,8 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="lock-closed"
|
icon="lock-closed"
|
||||||
label="Require Biometric to Send"
|
label={t("settings.security.biometricSend.label")}
|
||||||
description="Authenticate before sending messages"
|
description={t("settings.security.biometricSend.description")}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
right={
|
right={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -158,8 +175,8 @@ export default function SettingsScreen() {
|
|||||||
{settings.requireBiometric && (
|
{settings.requireBiometric && (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="exit"
|
icon="exit"
|
||||||
label="Lock App Now"
|
label={t("settings.security.lockNow.label")}
|
||||||
description="Require authentication to reopen"
|
description={t("settings.security.lockNow.description")}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
onPress={lock}
|
onPress={lock}
|
||||||
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
|
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||||
@@ -167,7 +184,7 @@ export default function SettingsScreen() {
|
|||||||
)}
|
)}
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<SettingSection title="Notifications" isDark={isDark}>
|
<SettingSection title={t("settings.sections.notifications")} isDark={isDark}>
|
||||||
{categories.map((category) => {
|
{categories.map((category) => {
|
||||||
const meta = categoryMeta[category]
|
const meta = categoryMeta[category]
|
||||||
return (
|
return (
|
||||||
@@ -190,17 +207,17 @@ export default function SettingsScreen() {
|
|||||||
{osGranted === false && (
|
{osGranted === false && (
|
||||||
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
|
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
|
||||||
<Text style={[styles.settingDescription, { color: "#ef4444", paddingLeft: 48 }]}>
|
<Text style={[styles.settingDescription, { color: "#ef4444", paddingLeft: 48 }]}>
|
||||||
Notifications are disabled at the system level. Enable them in Settings to receive alerts.
|
{t("settings.notifications.disabledNotice")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<SettingSection title="Privacy" isDark={isDark}>
|
<SettingSection title={t("settings.sections.privacy")} isDark={isDark}>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="shield-checkmark"
|
icon="shield-checkmark"
|
||||||
label="Crash Reporting"
|
label={t("settings.privacy.crashReporting.label")}
|
||||||
description="Share anonymous crash reports via Sentry to help improve OpenCode. No code or prompts are included."
|
description={t("settings.privacy.crashReporting.description")}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
right={
|
right={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -213,28 +230,36 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="document-text"
|
icon="document-text"
|
||||||
label="Privacy Policy"
|
label={t("settings.privacy.privacyPolicy.label")}
|
||||||
description="What data we collect and how"
|
description={t("settings.privacy.privacyPolicy.description")}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}
|
onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}
|
||||||
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||||
/>
|
/>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<SettingSection title="About" isDark={isDark}>
|
<SettingSection title={t("settings.sections.about")} isDark={isDark}>
|
||||||
<SettingRow icon="information-circle" label="Version" description="1.0.0" isDark={isDark} />
|
<SettingRow
|
||||||
|
icon="language"
|
||||||
|
label={t("settings.language.label")}
|
||||||
|
description={localeLabels[locale]}
|
||||||
|
isDark={isDark}
|
||||||
|
onPress={handleLanguagePress}
|
||||||
|
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||||
|
/>
|
||||||
|
<SettingRow icon="information-circle" label={t("settings.about.version")} description="1.0.0" isDark={isDark} />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="logo-github"
|
icon="logo-github"
|
||||||
label="GitHub"
|
label={t("settings.about.github.label")}
|
||||||
description="View source code"
|
description={t("settings.about.github.description")}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
onPress={() => Linking.openURL("https://github.com/anomalyco/opencode")}
|
onPress={() => Linking.openURL("https://github.com/anomalyco/opencode")}
|
||||||
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||||
/>
|
/>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="document-text"
|
icon="document-text"
|
||||||
label="Documentation"
|
label={t("settings.about.docs.label")}
|
||||||
description="Learn how to use OpenCode"
|
description={t("settings.about.docs.description")}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
onPress={() => Linking.openURL("https://opencode.ai/docs")}
|
onPress={() => Linking.openURL("https://opencode.ai/docs")}
|
||||||
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||||
@@ -242,10 +267,8 @@ export default function SettingsScreen() {
|
|||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<Text style={[styles.footerText, isDark && styles.metaDark]}>OpenCode Mobile</Text>
|
<Text style={[styles.footerText, isDark && styles.metaDark]}>{t("settings.footer.appName")}</Text>
|
||||||
<Text style={[styles.footerText, isDark && styles.metaDark]}>
|
<Text style={[styles.footerText, isDark && styles.metaDark]}>{t("settings.footer.tagline")}</Text>
|
||||||
Connect to your AI coding assistant from anywhere
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useColorScheme, View, ActivityIndicator } from "react-native"
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler"
|
import { GestureHandlerRootView } from "react-native-gesture-handler"
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"
|
||||||
|
import { I18nextProvider } from "react-i18next"
|
||||||
|
import i18n from "../src/lib/i18n/config"
|
||||||
import { useAuth } from "../src/stores/auth"
|
import { useAuth } from "../src/stores/auth"
|
||||||
import { useConnections } from "../src/stores/connections"
|
import { useConnections } from "../src/stores/connections"
|
||||||
import { useEvents } from "../src/stores/events"
|
import { useEvents } from "../src/stores/events"
|
||||||
@@ -107,6 +109,7 @@ function RootLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<BottomSheetModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -162,6 +165,7 @@ function RootLayout() {
|
|||||||
setConsentState("decided")
|
setConsentState("decided")
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</I18nextProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
95
package-lock.json
generated
95
package-lock.json
generated
@@ -22,14 +22,17 @@
|
|||||||
"expo-image-picker": "17.0.10",
|
"expo-image-picker": "17.0.10",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-local-authentication": "~17.0.8",
|
"expo-local-authentication": "~17.0.8",
|
||||||
|
"expo-localization": "~17.0.9",
|
||||||
"expo-notifications": "~0.32.16",
|
"expo-notifications": "~0.32.16",
|
||||||
"expo-router": "~6.0.22",
|
"expo-router": "~6.0.22",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-speech-recognition": "3.0.1",
|
"expo-speech-recognition": "3.0.1",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-store-review": "~9.0.9",
|
"expo-store-review": "~9.0.9",
|
||||||
|
"i18next": "^26.3.6",
|
||||||
"posthog-react-native": "^4.57.0",
|
"posthog-react-native": "^4.57.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-i18next": "^17.0.10",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-marked": "8.0.0",
|
"react-native-marked": "8.0.0",
|
||||||
@@ -4921,6 +4924,19 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-localization": {
|
||||||
|
"version": "17.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.9.tgz",
|
||||||
|
"integrity": "sha512-k5eYHr7iLMob3M8cHH6bgDrDRmqnZG8xKGLhpx8ST+LsFXmSgYpJ/t0VwWm0gz64C/K669XhObdG3D6QwCGqhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"rtl-detect": "^1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-modules-autolinking": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.25",
|
"version": "3.0.25",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
||||||
@@ -6043,6 +6059,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"void-elements": "3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -6085,6 +6110,34 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "26.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||||
|
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com/i18next"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -8347,6 +8400,33 @@
|
|||||||
"react": ">=17.0.0"
|
"react": ">=17.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "17.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||||
|
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.2",
|
||||||
|
"html-parse-stringify": "^3.0.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 26.2.0",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
|
||||||
@@ -8979,6 +9059,12 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rtl-detect": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -10200,6 +10286,15 @@
|
|||||||
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
|
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/void-elements": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/walker": {
|
"node_modules/walker": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||||
|
|||||||
@@ -26,14 +26,17 @@
|
|||||||
"expo-image-picker": "17.0.10",
|
"expo-image-picker": "17.0.10",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-local-authentication": "~17.0.8",
|
"expo-local-authentication": "~17.0.8",
|
||||||
|
"expo-localization": "~17.0.9",
|
||||||
"expo-notifications": "~0.32.16",
|
"expo-notifications": "~0.32.16",
|
||||||
"expo-router": "~6.0.22",
|
"expo-router": "~6.0.22",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-speech-recognition": "3.0.1",
|
"expo-speech-recognition": "3.0.1",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-store-review": "~9.0.9",
|
"expo-store-review": "~9.0.9",
|
||||||
|
"i18next": "^26.3.6",
|
||||||
"posthog-react-native": "^4.57.0",
|
"posthog-react-native": "^4.57.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-i18next": "^17.0.10",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-marked": "8.0.0",
|
"react-native-marked": "8.0.0",
|
||||||
|
|||||||
35
src/lib/i18n/config.ts
Normal file
35
src/lib/i18n/config.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// i18next setup. Kept separate from locale-resolve.ts (the pure, testable
|
||||||
|
// piece) because this module pulls in expo-localization + i18next, which
|
||||||
|
// have no resolver outside Metro/RN — the same split used for buildAuth()
|
||||||
|
// in auth.ts and the settings store's clamp/merge helpers.
|
||||||
|
import i18next from "i18next"
|
||||||
|
import { initReactI18next } from "react-i18next"
|
||||||
|
import * as Localization from "expo-localization"
|
||||||
|
import en from "./en.json"
|
||||||
|
import zhHans from "./zh-Hans.json"
|
||||||
|
import { resolveLocale, FALLBACK_LOCALE, type LocalePreference } from "./locale-resolve"
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
en: { translation: en },
|
||||||
|
"zh-Hans": { translation: zhHans },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Device locale tags in priority order, e.g. ["zh-Hans-CN", "en-US"]. */
|
||||||
|
export function deviceLocaleTags(): string[] {
|
||||||
|
return Localization.getLocales().map((locale) => locale.languageTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
i18next.use(initReactI18next).init({
|
||||||
|
resources,
|
||||||
|
lng: resolveLocale("system", deviceLocaleTags()),
|
||||||
|
fallbackLng: FALLBACK_LOCALE,
|
||||||
|
interpolation: { escapeValue: false }, // React already escapes output
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Apply a locale preference immediately. Persisting it is the caller's job. */
|
||||||
|
export function setAppLocale(preference: LocalePreference): void {
|
||||||
|
const resolved = resolveLocale(preference, deviceLocaleTags())
|
||||||
|
if (i18next.language !== resolved) i18next.changeLanguage(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18next
|
||||||
69
src/lib/i18n/en.json
Normal file
69
src/lib/i18n/en.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"sections": {
|
||||||
|
"security": "Security",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"privacy": "Privacy",
|
||||||
|
"about": "About"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"biometricOpen": {
|
||||||
|
"label": "Require Biometric to Open",
|
||||||
|
"descriptionEnabled": "Use Face ID or Touch ID to unlock the app",
|
||||||
|
"descriptionUnavailable": "Biometric authentication not available"
|
||||||
|
},
|
||||||
|
"biometricSend": {
|
||||||
|
"label": "Require Biometric to Send",
|
||||||
|
"description": "Authenticate before sending messages"
|
||||||
|
},
|
||||||
|
"lockNow": {
|
||||||
|
"label": "Lock App Now",
|
||||||
|
"description": "Require authentication to reopen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"disabledNotice": "Notifications are disabled at the system level. Enable them in Settings to receive alerts."
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"crashReporting": {
|
||||||
|
"label": "Crash Reporting",
|
||||||
|
"description": "Share anonymous crash reports via Sentry to help improve OpenCode. No code or prompts are included."
|
||||||
|
},
|
||||||
|
"privacyPolicy": {
|
||||||
|
"label": "Privacy Policy",
|
||||||
|
"description": "What data we collect and how"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"version": "Version",
|
||||||
|
"github": {
|
||||||
|
"label": "GitHub",
|
||||||
|
"description": "View source code"
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"label": "Documentation",
|
||||||
|
"description": "Learn how to use OpenCode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"label": "Language",
|
||||||
|
"title": "Choose Language",
|
||||||
|
"system": "System Default",
|
||||||
|
"en": "English",
|
||||||
|
"zhHans": "Simplified Chinese"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"appName": "OpenCode Mobile",
|
||||||
|
"tagline": "Connect to your AI coding assistant from anywhere"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"privacyNotSavedTitle": "Privacy Setting Not Saved",
|
||||||
|
"privacyNotSavedMessage": "Crash reporting is off for this session, but your choice could not be saved. Please try again.",
|
||||||
|
"notificationsDisabledTitle": "Notifications Disabled",
|
||||||
|
"notificationsDisabledMessage": "Enable notifications for OpenCode in your device settings to receive alerts."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/lib/i18n/locale-resolve.test.ts
Normal file
38
src/lib/i18n/locale-resolve.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { test } from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { matchSupportedLocale, resolveLocale, FALLBACK_LOCALE } from "./locale-resolve.ts"
|
||||||
|
|
||||||
|
test("matchSupportedLocale maps zh variants to zh-Hans", () => {
|
||||||
|
assert.equal(matchSupportedLocale("zh"), "zh-Hans")
|
||||||
|
assert.equal(matchSupportedLocale("zh-CN"), "zh-Hans")
|
||||||
|
assert.equal(matchSupportedLocale("zh-Hans"), "zh-Hans")
|
||||||
|
assert.equal(matchSupportedLocale("zh-Hans-CN"), "zh-Hans")
|
||||||
|
assert.equal(matchSupportedLocale("zh-Hant-TW"), "zh-Hans") // no zh-Hant catalog yet — closest match
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matchSupportedLocale maps en variants to en", () => {
|
||||||
|
assert.equal(matchSupportedLocale("en"), "en")
|
||||||
|
assert.equal(matchSupportedLocale("en-US"), "en")
|
||||||
|
assert.equal(matchSupportedLocale("EN-GB"), "en") // case-insensitive
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matchSupportedLocale returns null for unsupported languages", () => {
|
||||||
|
assert.equal(matchSupportedLocale("fr-FR"), null)
|
||||||
|
assert.equal(matchSupportedLocale("ja"), null)
|
||||||
|
assert.equal(matchSupportedLocale("es-ES"), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveLocale: explicit preference always wins over device tags", () => {
|
||||||
|
assert.equal(resolveLocale("en", ["zh-Hans-CN"]), "en")
|
||||||
|
assert.equal(resolveLocale("zh-Hans", ["en-US"]), "zh-Hans")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveLocale: system preference picks first supported device tag", () => {
|
||||||
|
assert.equal(resolveLocale("system", ["fr-FR", "zh-Hans-CN", "en-US"]), "zh-Hans")
|
||||||
|
assert.equal(resolveLocale("system", ["en-US", "zh-Hans-CN"]), "en")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveLocale: system preference falls back when nothing matches", () => {
|
||||||
|
assert.equal(resolveLocale("system", ["fr-FR", "ja-JP"]), FALLBACK_LOCALE)
|
||||||
|
assert.equal(resolveLocale("system", []), FALLBACK_LOCALE)
|
||||||
|
})
|
||||||
43
src/lib/i18n/locale-resolve.ts
Normal file
43
src/lib/i18n/locale-resolve.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Pure locale-resolution logic, kept free of expo-localization/i18next imports
|
||||||
|
// so it's unit-testable with plain `node --test` (same split as
|
||||||
|
// settings-merge.ts / store-review-policy.ts).
|
||||||
|
|
||||||
|
export const SUPPORTED_LOCALES = ["en", "zh-Hans"] as const
|
||||||
|
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||||
|
|
||||||
|
export const FALLBACK_LOCALE: SupportedLocale = "en"
|
||||||
|
|
||||||
|
// User-facing preference: "system" defers to the device's locale list.
|
||||||
|
export type LocalePreference = "system" | SupportedLocale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one device locale tag (e.g. "zh-Hans-CN", "zh-CN", "en-US", "fr-FR") to
|
||||||
|
* one of our supported catalogs, matching by language subtag since we don't
|
||||||
|
* ship per-region variants. Returns null if we have no catalog for it.
|
||||||
|
*/
|
||||||
|
export function matchSupportedLocale(tag: string): SupportedLocale | null {
|
||||||
|
const lower = tag.toLowerCase()
|
||||||
|
if (lower.startsWith("zh")) return "zh-Hans"
|
||||||
|
if (lower.startsWith("en")) return "en"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective app locale from a user preference plus the device's
|
||||||
|
* ranked list of locale tags (as returned by expo-localization's
|
||||||
|
* `getLocales().map(l => l.languageTag)`).
|
||||||
|
* - An explicit preference (anything but "system") always wins.
|
||||||
|
* - "system" walks the device list in priority order and uses the first tag
|
||||||
|
* we have a catalog for.
|
||||||
|
* - If nothing matches (or the list is empty), falls back to FALLBACK_LOCALE.
|
||||||
|
*/
|
||||||
|
export function resolveLocale(preference: LocalePreference, deviceTags: readonly string[]): SupportedLocale {
|
||||||
|
if (preference !== "system") return preference
|
||||||
|
|
||||||
|
for (const tag of deviceTags) {
|
||||||
|
const match = matchSupportedLocale(tag)
|
||||||
|
if (match) return match
|
||||||
|
}
|
||||||
|
|
||||||
|
return FALLBACK_LOCALE
|
||||||
|
}
|
||||||
69
src/lib/i18n/zh-Hans.json
Normal file
69
src/lib/i18n/zh-Hans.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"sections": {
|
||||||
|
"security": "安全",
|
||||||
|
"notifications": "通知",
|
||||||
|
"privacy": "隐私",
|
||||||
|
"about": "关于"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"biometricOpen": {
|
||||||
|
"label": "解锁时需要生物识别",
|
||||||
|
"descriptionEnabled": "使用 Face ID 或 Touch ID 解锁应用",
|
||||||
|
"descriptionUnavailable": "生物识别功能不可用"
|
||||||
|
},
|
||||||
|
"biometricSend": {
|
||||||
|
"label": "发送消息时需要生物识别",
|
||||||
|
"description": "发送消息前需要验证身份"
|
||||||
|
},
|
||||||
|
"lockNow": {
|
||||||
|
"label": "立即锁定应用",
|
||||||
|
"description": "重新打开时需要验证身份"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"disabledNotice": "通知已在系统层面关闭。请在系统设置中启用通知以接收提醒。"
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"crashReporting": {
|
||||||
|
"label": "崩溃报告",
|
||||||
|
"description": "通过 Sentry 分享匿名崩溃报告,帮助改进 OpenCode。报告不包含代码或提示词内容。"
|
||||||
|
},
|
||||||
|
"privacyPolicy": {
|
||||||
|
"label": "隐私政策",
|
||||||
|
"description": "我们收集哪些数据及收集方式"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"version": "版本",
|
||||||
|
"github": {
|
||||||
|
"label": "GitHub",
|
||||||
|
"description": "查看源代码"
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"label": "文档",
|
||||||
|
"description": "了解如何使用 OpenCode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"label": "语言",
|
||||||
|
"title": "选择语言",
|
||||||
|
"system": "跟随系统",
|
||||||
|
"en": "English",
|
||||||
|
"zhHans": "简体中文"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"appName": "OpenCode Mobile",
|
||||||
|
"tagline": "随时随地连接您的 AI 编程助手"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"privacyNotSavedTitle": "隐私设置未保存",
|
||||||
|
"privacyNotSavedMessage": "本次会话已关闭崩溃报告,但您的选择未能保存。请重试。",
|
||||||
|
"notificationsDisabledTitle": "通知已关闭",
|
||||||
|
"notificationsDisabledMessage": "请在设备设置中为 OpenCode 启用通知以接收提醒。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,21 @@ import { create } from "zustand"
|
|||||||
import * as SecureStore from "expo-secure-store"
|
import * as SecureStore from "expo-secure-store"
|
||||||
import { type Category, defaultPreferences } from "../lib/notifications"
|
import { type Category, defaultPreferences } from "../lib/notifications"
|
||||||
import { clampPageSize, mergeStoredSettings } from "../lib/settings-merge"
|
import { clampPageSize, mergeStoredSettings } from "../lib/settings-merge"
|
||||||
|
import { setAppLocale } from "../lib/i18n/config"
|
||||||
|
import type { LocalePreference } from "../lib/i18n/locale-resolve"
|
||||||
|
|
||||||
const SETTINGS_KEY = "opencode_settings"
|
const SETTINGS_KEY = "opencode_settings"
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
pageSize: number
|
pageSize: number
|
||||||
notifications: Record<Category, boolean>
|
notifications: Record<Category, boolean>
|
||||||
|
locale: LocalePreference
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: Settings = {
|
const DEFAULTS: Settings = {
|
||||||
pageSize: 25,
|
pageSize: 25,
|
||||||
notifications: { ...defaultPreferences },
|
notifications: { ...defaultPreferences },
|
||||||
|
locale: "system",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsState extends Settings {
|
interface SettingsState extends Settings {
|
||||||
@@ -20,10 +24,11 @@ interface SettingsState extends Settings {
|
|||||||
load: () => Promise<void>
|
load: () => Promise<void>
|
||||||
setPageSize: (size: number) => Promise<void>
|
setPageSize: (size: number) => Promise<void>
|
||||||
setNotification: (category: Category, enabled: boolean) => Promise<void>
|
setNotification: (category: Category, enabled: boolean) => Promise<void>
|
||||||
|
setLocale: (locale: LocalePreference) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot(get: () => SettingsState): Settings {
|
function snapshot(get: () => SettingsState): Settings {
|
||||||
return { pageSize: get().pageSize, notifications: get().notifications }
|
return { pageSize: get().pageSize, notifications: get().notifications, locale: get().locale }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persist(settings: Settings) {
|
async function persist(settings: Settings) {
|
||||||
@@ -39,7 +44,9 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
|||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw) as Partial<Settings>
|
const parsed = JSON.parse(raw) as Partial<Settings>
|
||||||
// Merge stored settings with defaults so new fields/categories get their default
|
// Merge stored settings with defaults so new fields/categories get their default
|
||||||
set({ ...mergeStoredSettings(DEFAULTS, parsed), loaded: true })
|
const merged = mergeStoredSettings(DEFAULTS, parsed)
|
||||||
|
set({ ...merged, loaded: true })
|
||||||
|
setAppLocale(merged.locale)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
set({ loaded: true })
|
set({ loaded: true })
|
||||||
@@ -56,4 +63,10 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
|||||||
set({ notifications })
|
set({ notifications })
|
||||||
await persist({ ...snapshot(get), notifications })
|
await persist({ ...snapshot(get), notifications })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setLocale: async (locale) => {
|
||||||
|
set({ locale })
|
||||||
|
setAppLocale(locale) // applies immediately
|
||||||
|
await persist({ ...snapshot(get), locale })
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user