Files
sofa/apps/native/src/app/_layout.tsx
T
ad0497ccd6 feat: add i18n with LingUI, Crowdin integration, and typed error codes (#16)
* feat: add i18n with LingUI, Crowdin integration, and typed error codes

- Add `@sofa/i18n` shared package with LingUI v5 (v6-ready config using
  `defineConfig` + `@lingui/format-po`), eager English + lazy-loaded
  fr/de/es/it/pt catalogs, `Intl`-based date/number format utilities,
  and test helpers
- Wire `@lingui/vite-plugin` + babel macro plugin for web, and
  `@lingui/metro-transformer` + babel config for native
- Add `I18nProvider` to both app roots with locale auto-detection
  (navigator.language / expo-localization) and persistence
  (localStorage / MMKV)
- Wrap all ~512 user-facing strings across web and native with LingUI
  macros (`<Trans>`, `useLingui`/`t`, `i18n._(msg`...`)`, `plural`)
- Add language switcher to Settings in both apps
- Add `@sofa/api/errors` with 13 typed `AppErrorCode` values and
  `appErrorData()` helper for contract `.errors()` schemas
- Update all oRPC contract error definitions with typed `data` fields;
  convert import procedure `throw new Error()` to `ORPCError` with codes
- Add per-app `error-messages.ts` utilities that map error codes to
  localized strings; update global `QueryCache.onError` handlers to stop
  leaking raw `error.message` to users
- Add `crowdin.yml` config and `.github/workflows/crowdin.yml` for
  automated source upload on merge and translation pull via dispatch
- Add `lingui.config.ts` at repo root with `bun run i18n:extract` and
  `bun run i18n:compile` convenience scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add Crowdin language mapping and ignore context file

* fix(i18n): address 20 localization quality issues

- Stop leaking raw error.message to users in change-password and
  account-section; use localized fallbacks instead
- Add typed error data to discover contract's .errors() schema
- Replace all manual English plural suffixes with LingUI plural() macro
  in episode counts, backup counts, star ratings, title/image counts
- Replace date-fns formatDistanceToNow and hardcoded date patterns with
  locale-aware formatDate/formatRelativeTime from @sofa/i18n/format
- Convert integration-configs from module-scope translations (frozen at
  import time) to lazy getIntegrationConfigs(i18n) function
- Combine split Trans fragments into single translatable units in
  backup-schedule retention sentence and stats-display period labels
- Localize TV media type badge and full episode accessibilityLabel
- Replace Android-incompatible Alert.alert language picker (max 3
  buttons) with zeego DropdownMenu
- Await async locale initialization before hiding splash screen so
  non-English users don't see English flash on cold start
- Add @lingui/core as direct native app dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(i18n): add Intl polyfills for Hermes, replace DropdownMenu language picker, and remove date-fns
- Add `@formatjs/intl-*` polyfills loaded at app startup in `intl-polyfills.ts` so Hermes on Android has full `Intl` support for locale-aware formatting
- Alias `@formatjs/icu-messageformat-parser` to its `no-parser` variant in metro config to reduce bundle size
- Replace the zeego `DropdownMenu` language picker in Settings with a new `SelectModal` component that works reliably on all platforms without the Android 3-button limit
- Replace `date-fns` `format`/`parseISO` in `person/[id].tsx` with `formatDate` from `@sofa/i18n/format`; remove `date-fns` from native and web `package.json`
- Refactor `StatusActionButton` to use a `StatusLabel` component with `<Trans>` rather than a `getStatusConfig(t)` factory so labels are always reactive to locale changes
- Fix crowdin workflow to use root `bun run i18n:compile` script instead of `cd packages/i18n && bun run compile`
- Wrap root layout `GestureHandlerRootView` in `SafeAreaProvider` and add `.catch` to locale-ready promise to prevent unhandled rejections blocking the splash screen

* fix(i18n): localize integration status helpers, fix stats-display Trans wrapping, and clean up SelectModal
- Localize `webhookStatus` and `listStatus` in `integration-card.tsx` using `i18n._(msg`...`)` so status strings are translated instead of always rendering in English
- Wrap "Movies {select}" and "Episodes {select}" in a single `<Trans>` in `stats-display.tsx` so the period selector element is embedded inside the translatable unit rather than concatenated outside it
- Fix locale activation order in Settings: close the modal first, then `activateLocale`, and only call `setPersistedLocale` on success to avoid persisting a locale that failed to load
- Remove the redundant `SafeAreaProvider`/`SafeAreaView` wrapper from `SelectModal` — the root layout already provides `SafeAreaProvider`
- Recompile all six `.po`/`.ts` catalogs to pick up new and updated message strings

* chore(i18n): crowdin sync

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:16:10 -04:00

254 lines
7.6 KiB
TypeScript

import "@/lib/intl-polyfills";
import "@/global.css";
import { I18nProvider } from "@lingui/react";
import { ThemeProvider } from "@react-navigation/native";
import { i18n } from "@sofa/i18n";
import { QueryClientProvider } from "@tanstack/react-query";
import {
persistQueryClientRestore,
persistQueryClientSubscribe,
} from "@tanstack/react-query-persist-client";
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import {
getAdvertisingId,
getTrackingPermissionsAsync,
requestTrackingPermissionsAsync,
} from "expo-tracking-transparency";
import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
import { useEffect, useRef, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { enableFreeze } from "react-native-screens";
import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
import { useServerConnection } from "@/hooks/use-server-connection";
import { initLocale } from "@/lib/i18n";
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import {
getScopeKey,
initialize,
onStorageScopeChange,
queryPersister,
} from "@/lib/server";
import { sofaTheme } from "@/lib/theme";
SplashScreen.preventAutoHideAsync();
enableFreeze(true);
initialize();
const localeReady = initLocale();
export const unstable_settings = {
initialRouteName: "(tabs)",
};
const changePasswordOptions =
process.env.EXPO_OS === "ios"
? {
headerShown: true,
presentation: "formSheet" as const,
sheetAllowedDetents: "fitToContents" as const,
sheetGrabberVisible: true,
headerLargeTitle: false,
headerTransparent: true,
headerBlurEffect: "none" as const,
}
: {
headerShown: true,
presentation: "modal" as const,
headerLargeTitle: false,
headerTransparent: false,
headerBlurEffect: "none" as const,
};
function AppContent() {
const contentStyle = useResolveClassNames("bg-background");
const { session, isPending, hasServerUrl } = useServerConnection();
// --- Locale readiness (wait for async catalog load before showing UI) ---
const [isLocaleReady, setLocaleReady] = useState(false);
useEffect(() => {
localeReady
.then(() => setLocaleReady(true))
.catch(() => setLocaleReady(true));
}, []);
// --- App Tracking Transparency (must resolve before screen tracking) ---
const [trackingReady, setTrackingReady] = useState(false);
useEffect(() => {
(async () => {
const { status } = await getTrackingPermissionsAsync();
const granted =
status === "undetermined"
? (await requestTrackingPermissionsAsync()).granted
: status === "granted";
const enabled = applyTrackingTransparency(granted);
// Use the platform advertising ID (IDFA / AAID) as the PostHog
// distinct ID, but only when the resolved state is actually enabled
// (respects both ATT result and the user's settings override).
if (enabled && posthog) {
const advertisingId = await getAdvertisingId();
if (advertisingId) {
posthog.identify(advertisingId);
}
}
setTrackingReady(true);
})();
}, []);
// --- PostHog screen tracking (waits for ATT to resolve) ---
const pathname = usePathname();
const params = useGlobalSearchParams();
useEffect(() => {
if (trackingReady && posthog && pathname) {
posthog.screen(pathname, params);
}
}, [trackingReady, pathname, params]);
useEffect(() => {
Uniwind.setTheme("dark");
}, []);
// --- Safety splash timeout (belt-and-suspenders if seeding fails) ---
useEffect(() => {
const timer = setTimeout(() => SplashScreen.hideAsync(), 3000);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (isLocaleReady && (!isPending || !hasServerUrl)) {
SplashScreen.hideAsync();
}
}, [isPending, hasServerUrl, isLocaleReady]);
return (
<ThemeProvider value={sofaTheme}>
<StatusBar style="light" />
<OfflineBanner />
<ServerUnreachableBanner />
<Stack
screenOptions={{
headerShown: false,
contentStyle,
animation: "slide_from_right",
}}
>
<Stack.Protected guard={!session}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
<Stack.Protected guard={!!session}>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="change-password"
options={changePasswordOptions}
/>
<Stack.Screen
name="title/[id]"
dangerouslySingular
options={{
presentation: "modal",
}}
/>
<Stack.Screen
name="person/[id]"
dangerouslySingular
options={{
presentation: "modal",
}}
/>
</Stack.Protected>
</Stack>
</ThemeProvider>
);
}
/**
* Always renders a single QueryClientProvider so the React tree is never torn
* down. Cache persistence is managed imperatively: when scoped storage becomes
* ready we restore from MMKV and subscribe to cache mutations; when the scope
* changes (different server/user) we unsubscribe, restore from the new
* partition, and re-subscribe.
*/
function QueryProvider({ children }: { children: React.ReactNode }) {
const [, setScopeVersion] = useState(0);
useEffect(() => {
return onStorageScopeChange(() => setScopeVersion((n) => n + 1));
}, []);
const scopeKey = getScopeKey();
const prevScopeKeyRef = useRef<string | null>(null);
useEffect(() => {
const prev = prevScopeKeyRef.current;
prevScopeKeyRef.current = scopeKey;
// Only clear when switching away from an active scope (user switch/logout).
// Don't clear on initial activation (null → value) — preserve data
// from queries that started before the scope was ready.
// queryClient.clear() calls query.destroy() which silently cancels
// in-flight fetches without notifying observers, permanently stalling
// any queries that were mid-flight.
if (prev != null && prev !== scopeKey) {
queryClient.clear();
}
if (!scopeKey) return;
const options = { queryClient, persister: queryPersister };
let unsubscribe: (() => void) | undefined;
let aborted = false;
persistQueryClientRestore(options).then(() => {
if (aborted) return;
unsubscribe = persistQueryClientSubscribe(options);
});
return () => {
aborted = true;
unsubscribe?.();
};
}, [scopeKey]);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
export default function RootLayout() {
const inner = (
<I18nProvider i18n={i18n}>
<QueryProvider>
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<AppContent />
</KeyboardProvider>
</GestureHandlerRootView>
</SafeAreaProvider>
</QueryProvider>
</I18nProvider>
);
if (!posthog) return inner;
return (
<PostHogProvider client={posthog} autocapture={{ captureScreens: false }}>
<PostHogErrorBoundary>{inner}</PostHogErrorBoundary>
</PostHogProvider>
);
}