Files
sofa/apps/native/src/app/change-password.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

229 lines
8.0 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import { useForm } from "@tanstack/react-form";
import { Stack, useRouter } from "expo-router";
import { useRef, useState } from "react";
import { Alert, ScrollView, type TextInput, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { useResolveClassNames } from "uniwind";
import { z } from "zod";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Text } from "@/components/ui/text";
import {
FieldError,
Input,
Label,
TextField,
} from "@/components/ui/text-field";
import { authClient } from "@/lib/server";
import { toast } from "@/lib/toast";
import * as Haptics from "@/utils/haptics";
const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
function formatFormErrors(errors: unknown): string | null {
if (!errors) return null;
if (typeof errors === "string") return errors;
if (typeof errors === "object") {
const first = Object.values(errors as Record<string, { message: string }[]>)
.flat()
.find((e) => e.message);
if (first) return first.message;
}
return null;
}
export default function ChangePasswordScreen() {
const { t } = useLingui();
const { back } = useRouter();
const newPasswordRef = useRef<TextInput>(null);
const confirmPasswordRef = useRef<TextInput>(null);
const [revokeOtherSessions, setRevokeOtherSessions] = useState(false);
const headerTitleStyle = useResolveClassNames("font-display text-xl");
const form = useForm({
defaultValues: {
currentPassword: "",
newPassword: "",
confirmPassword: "",
},
validators: { onSubmit: changePasswordSchema },
onSubmit: async ({ value, formApi }) => {
try {
const result = await authClient.changePassword({
currentPassword: value.currentPassword,
newPassword: value.newPassword,
revokeOtherSessions,
});
if (result.error) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert(t`Error`, t`Failed to change password`);
return;
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
toast.success(t`Password updated`);
formApi.reset();
back();
} catch {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert(t`Error`, t`Something went wrong`);
}
},
});
return (
<ScrollView
className="flex-1 bg-background"
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 24,
}}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
<Trans>Change Password</Trans>
</Stack.Screen.Title>
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
validationError: formatFormErrors(state.errorMap.onSubmit),
})}
>
{({ isSubmitting, validationError }) => (
<View className="gap-4">
{validationError && (
<FieldError isInvalid className="mb-1">
{validationError}
</FieldError>
)}
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<form.Field name="currentPassword">
{(field) => (
<TextField>
<Label>
<Trans>Current password</Trans>
</Label>
<Input
value={field.state.value}
accessibilityLabel="Current password"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="current-password"
textContentType="password"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => newPasswordRef.current?.focus()}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<form.Field name="newPassword">
{(field) => (
<TextField>
<Label>
<Trans>New password</Trans>
</Label>
<Input
ref={newPasswordRef}
value={field.state.value}
accessibilityLabel="New password"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() =>
confirmPasswordRef.current?.focus()
}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
<form.Field name="confirmPassword">
{(field) => (
<TextField>
<Label>
<Trans>Confirm new password</Trans>
</Label>
<Input
ref={confirmPasswordRef}
value={field.state.value}
accessibilityLabel="Confirm new password"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="go"
onSubmitEditing={form.handleSubmit}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="flex-row items-center justify-between rounded-xl border border-border bg-input px-3.5 py-3"
style={{ borderCurve: "continuous" }}
>
<Text className="text-base text-foreground">
<Trans>Sign out of other sessions</Trans>
</Text>
<Switch
value={revokeOtherSessions}
onValueChange={setRevokeOtherSessions}
accessibilityLabel="Sign out of other sessions"
/>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
<Button
onPress={form.handleSubmit}
disabled={isSubmitting}
className="mt-2 bg-primary"
>
{isSubmitting ? (
<Spinner size="sm" />
) : (
<ButtonLabel>
<Trans>Update Password</Trans>
</ButtonLabel>
)}
</Button>
</Animated.View>
</View>
)}
</form.Subscribe>
</ScrollView>
);
}