mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
* 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>
254 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|