@ramonclaudio/create-vexpo 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/dist/index.js +8 -7
- package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
- package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
- package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
- package/dist/templates/default/.eas/workflows/release.yml +3 -7
- package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
- package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
- package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
- package/dist/templates/default/.github/workflows/check.yml +20 -12
- package/dist/templates/default/.maestro/launch.yaml +19 -10
- package/dist/templates/default/AGENTS.md +25 -8
- package/dist/templates/default/DESIGN.md +14 -10
- package/dist/templates/default/README.md +83 -78
- package/dist/templates/default/SETUP.md +159 -152
- package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
- package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
- package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
- package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
- package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
- package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
- package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
- package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
- package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
- package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
- package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
- package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
- package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
- package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
- package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
- package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
- package/dist/templates/default/_env.example +7 -7
- package/dist/templates/default/_gitattributes +1 -1
- package/dist/templates/default/_gitignore +17 -2
- package/dist/templates/default/_npmrc +7 -0
- package/dist/templates/default/_oxlintrc.json +1 -1
- package/dist/templates/default/app-store/accessibility.config.json +20 -0
- package/dist/templates/default/app-store/privacy.config.json +27 -0
- package/dist/templates/default/app.config.ts +105 -33
- package/dist/templates/default/app.json +1 -9
- package/dist/templates/default/convex/_generated/api.d.ts +12 -0
- package/dist/templates/default/convex/admin.ts +0 -13
- package/dist/templates/default/convex/appAttest.ts +467 -0
- package/dist/templates/default/convex/appAttestStore.ts +141 -0
- package/dist/templates/default/convex/apple.ts +53 -0
- package/dist/templates/default/convex/auth.ts +6 -45
- package/dist/templates/default/convex/constants.ts +2 -7
- package/dist/templates/default/convex/crons.ts +12 -5
- package/dist/templates/default/convex/email.ts +4 -24
- package/dist/templates/default/convex/env.ts +0 -4
- package/dist/templates/default/convex/errors.ts +0 -7
- package/dist/templates/default/convex/functions.ts +0 -26
- package/dist/templates/default/convex/http.ts +3 -5
- package/dist/templates/default/convex/log.ts +2 -25
- package/dist/templates/default/convex/pushSender.ts +145 -0
- package/dist/templates/default/convex/pushTokens.ts +110 -13
- package/dist/templates/default/convex/rateLimit.ts +8 -39
- package/dist/templates/default/convex/schema.ts +48 -5
- package/dist/templates/default/convex/tsconfig.json +1 -0
- package/dist/templates/default/convex/users.ts +143 -61
- package/dist/templates/default/convex/validators.ts +1 -38
- package/dist/templates/default/convex/webhook.ts +1 -31
- package/dist/templates/default/convex.json +1 -2
- package/dist/templates/default/metro.config.js +9 -1
- package/dist/templates/default/package.json +67 -70
- package/dist/templates/default/plugins/README.md +5 -1
- package/dist/templates/default/scripts/README.md +9 -9
- package/dist/templates/default/scripts/_run.mjs +3 -20
- package/dist/templates/default/scripts/clean.ts +81 -69
- package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
- package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
- package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
- package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
- package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
- package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
- package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
- package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
- package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
- package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
- package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
- package/dist/templates/default/src/app/+native-intent.tsx +25 -0
- package/dist/templates/default/src/app/+not-found.tsx +43 -0
- package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
- package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
- package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
- package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
- package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
- package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
- package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
- package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
- package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
- package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
- package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
- package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
- package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
- package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
- package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
- package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
- package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
- package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
- package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
- package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
- package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
- package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
- package/dist/templates/default/src/hooks/use-network.ts +34 -0
- package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
- package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
- package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
- package/dist/templates/default/src/lib/appAttest.ts +78 -0
- package/dist/templates/default/src/lib/assets.ts +9 -0
- package/dist/templates/default/src/lib/deep-link.ts +82 -0
- package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
- package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
- package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
- package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
- package/dist/templates/default/src/lib/masks.ts +21 -0
- package/dist/templates/default/src/lib/native-state.ts +20 -0
- package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
- package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
- package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
- package/dist/templates/default/src/lib/text-style.ts +20 -0
- package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
- package/dist/templates/default/store.config.json +1 -1
- package/dist/templates/default/tsconfig.json +3 -1
- package/dist/templates/default/vitest.config.ts +8 -1
- package/package.json +5 -5
- package/dist/templates/default/app/(app)/_layout.tsx +0 -73
- package/dist/templates/default/app/(app)/debug.tsx +0 -389
- package/dist/templates/default/app/(app)/sessions.tsx +0 -191
- package/dist/templates/default/app/(app)/welcome.tsx +0 -140
- package/dist/templates/default/app/+native-intent.tsx +0 -14
- package/dist/templates/default/app/+not-found.tsx +0 -51
- package/dist/templates/default/bun.lock +0 -1860
- package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
- package/dist/templates/default/components/ui/convex-error.tsx +0 -32
- package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
- package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
- package/dist/templates/default/components/ui/status-text.tsx +0 -49
- package/dist/templates/default/components/ui/update-banner.tsx +0 -82
- package/dist/templates/default/fingerprint.config.js +0 -9
- package/dist/templates/default/hooks/use-debounce.ts +0 -20
- package/dist/templates/default/hooks/use-deep-link.ts +0 -43
- package/dist/templates/default/hooks/use-network.ts +0 -11
- package/dist/templates/default/lib/assets.ts +0 -17
- package/dist/templates/default/lib/deep-link.ts +0 -71
- package/dist/templates/default/patches/PR-368.patch +0 -91
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
- /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/storage.ts +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { router, Stack } from "expo-router";
|
|
2
|
+
import { Host, VStack, Spacer } from "@expo/ui/swift-ui";
|
|
3
|
+
import { padding, tint } from "@expo/ui/swift-ui/modifiers";
|
|
4
|
+
import { ProminentButton } from "@/components/ui/prominent-button";
|
|
5
|
+
import { ContentUnavailable } from "@/components/ui/content-unavailable";
|
|
6
|
+
import { useColors } from "@/hooks/use-theme";
|
|
7
|
+
import { FontFamily } from "@/constants/layout";
|
|
8
|
+
|
|
9
|
+
export default function NotFoundScreen() {
|
|
10
|
+
const colors = useColors();
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<Stack.Header>
|
|
14
|
+
<Stack.Screen.Title
|
|
15
|
+
style={{ color: colors.foreground as string, fontFamily: FontFamily.semiBold }}
|
|
16
|
+
>
|
|
17
|
+
Lost?
|
|
18
|
+
</Stack.Screen.Title>
|
|
19
|
+
</Stack.Header>
|
|
20
|
+
<Host testID="not-found-screen" style={{ flex: 1 }}>
|
|
21
|
+
<VStack
|
|
22
|
+
spacing={20}
|
|
23
|
+
alignment="center"
|
|
24
|
+
modifiers={[padding({ horizontal: 24, vertical: 32 }), tint(colors.primary as string)]}
|
|
25
|
+
>
|
|
26
|
+
<Spacer />
|
|
27
|
+
<ContentUnavailable
|
|
28
|
+
testID="not-found-empty"
|
|
29
|
+
title="This page doesn't exist"
|
|
30
|
+
systemImage="questionmark.circle"
|
|
31
|
+
description="The page you were looking for moved or was never here."
|
|
32
|
+
/>
|
|
33
|
+
<ProminentButton
|
|
34
|
+
testID="not-found-home"
|
|
35
|
+
label="Take me home"
|
|
36
|
+
onPress={() => router.replace("/")}
|
|
37
|
+
/>
|
|
38
|
+
<Spacer />
|
|
39
|
+
</VStack>
|
|
40
|
+
</Host>
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -3,7 +3,6 @@ import { Stack, ThemeProvider as NavigationThemeProvider } from "expo-router";
|
|
|
3
3
|
import * as SplashScreen from "expo-splash-screen";
|
|
4
4
|
import { StatusBar } from "expo-status-bar";
|
|
5
5
|
import { Suspense, useEffect } from "react";
|
|
6
|
-
import { View } from "react-native";
|
|
7
6
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
8
7
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
|
9
8
|
import "react-native-reanimated";
|
|
@@ -15,10 +14,9 @@ import { assetModules } from "@/lib/assets";
|
|
|
15
14
|
import { useAssets } from "expo-asset";
|
|
16
15
|
import { env } from "@/lib/env";
|
|
17
16
|
import { useColorScheme, useColors } from "@/hooks/use-theme";
|
|
18
|
-
import {
|
|
17
|
+
import { useMotionScreenOptions } from "@/hooks/use-motion-screen-options";
|
|
19
18
|
import { useNotifications } from "@/hooks/use-notifications";
|
|
20
19
|
import { useNavigationTracking } from "@/hooks/use-navigation-tracking";
|
|
21
|
-
import { useDeepLinkHandler } from "@/hooks/use-deep-link";
|
|
22
20
|
import { OfflineBanner } from "@/components/ui/offline-banner";
|
|
23
21
|
import { UpdateBanner } from "@/components/ui/update-banner";
|
|
24
22
|
import { LoadingScreen } from "@/components/ui/loading-screen";
|
|
@@ -43,7 +41,7 @@ registerBackgroundTask();
|
|
|
43
41
|
export default function RootLayout() {
|
|
44
42
|
return (
|
|
45
43
|
<BetterAuthConvexProvider client={convex}>
|
|
46
|
-
<Suspense fallback={<LoadingScreen />}>
|
|
44
|
+
<Suspense fallback={<LoadingScreen testID="app-loading" />}>
|
|
47
45
|
<RootNavigator />
|
|
48
46
|
</Suspense>
|
|
49
47
|
</BetterAuthConvexProvider>
|
|
@@ -51,50 +49,43 @@ export default function RootLayout() {
|
|
|
51
49
|
}
|
|
52
50
|
|
|
53
51
|
function RootNavigator() {
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
const {
|
|
57
|
-
const isAuthenticated = !!session?.session;
|
|
58
|
-
const isLoading = isPending;
|
|
52
|
+
// Splash gates on both auth resolution and asset load. Auth gating itself
|
|
53
|
+
// lives in `(app)/_layout.tsx` so `(app)` stays mounted under the auth modal.
|
|
54
|
+
const { isPending } = authClient.useSession();
|
|
59
55
|
const colorScheme = useColorScheme();
|
|
60
56
|
const colors = useColors();
|
|
61
|
-
const
|
|
62
|
-
const [assets] = useAssets(assetModules);
|
|
57
|
+
const motion = useMotionScreenOptions("default");
|
|
58
|
+
const [assets, assetError] = useAssets(assetModules);
|
|
63
59
|
|
|
64
60
|
useNotifications();
|
|
65
61
|
useNavigationTracking();
|
|
66
|
-
useDeepLinkHandler();
|
|
67
62
|
|
|
68
63
|
useEffect(() => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
// Dismiss once auth resolves and assets either load or fail. Gating on
|
|
65
|
+
// `assets` alone hangs on the native splash forever if a bundled asset
|
|
66
|
+
// fails, since `useAssets` then returns `[undefined, error]` and never an
|
|
67
|
+
// asset array.
|
|
68
|
+
if (assetError && __DEV__) console.warn("[assets] failed to load:", assetError);
|
|
69
|
+
if (!isPending && (assets || assetError)) SplashScreen.hideAsync();
|
|
70
|
+
}, [isPending, assets, assetError]);
|
|
73
71
|
|
|
74
72
|
return (
|
|
75
|
-
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
73
|
+
<GestureHandlerRootView style={{ flex: 1, backgroundColor: colors.background as string }}>
|
|
76
74
|
<KeyboardProvider>
|
|
77
75
|
<NavigationThemeProvider value={colorScheme === "dark" ? NavigationDark : NavigationLight}>
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
</Stack.Protected>
|
|
92
|
-
<Stack.Screen name="+not-found" />
|
|
93
|
-
</Stack>
|
|
94
|
-
<StatusBar style="auto" />
|
|
95
|
-
<OfflineBanner />
|
|
96
|
-
<UpdateBanner />
|
|
97
|
-
</View>
|
|
76
|
+
<Stack
|
|
77
|
+
screenOptions={{
|
|
78
|
+
...motion,
|
|
79
|
+
headerShown: false,
|
|
80
|
+
contentStyle: { backgroundColor: colors.background as string },
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<Stack.Screen name="(app)" />
|
|
84
|
+
<Stack.Screen name="+not-found" />
|
|
85
|
+
</Stack>
|
|
86
|
+
<StatusBar style="auto" />
|
|
87
|
+
<OfflineBanner testID="offline-banner" />
|
|
88
|
+
<UpdateBanner testID="update-banner" />
|
|
98
89
|
</NavigationThemeProvider>
|
|
99
90
|
</KeyboardProvider>
|
|
100
91
|
</GestureHandlerRootView>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useWindowDimensions } from "react-native";
|
|
2
|
+
import * as AppleAuthentication from "expo-apple-authentication";
|
|
3
|
+
import { VStack, RNHostView } from "@expo/ui/swift-ui";
|
|
4
|
+
import { frame } from "@expo/ui/swift-ui/modifiers";
|
|
5
|
+
|
|
6
|
+
import { Button as ButtonTokens } from "@/constants/layout";
|
|
7
|
+
import { useColorScheme } from "@/hooks/use-theme";
|
|
8
|
+
|
|
9
|
+
// Apple sizes `ASAuthorizationAppleIDButton`'s label to the button's frame
|
|
10
|
+
// height, not to Dynamic Type, so unlike the rest of the form it won't grow on
|
|
11
|
+
// its own. Scale the frame with the user's text size so the label keeps up,
|
|
12
|
+
// clamped to [1, MAX_SCALE]: never below the 50pt baseline (HIG minimum size +
|
|
13
|
+
// the 44pt touch target hold at small text, matching the other buttons'
|
|
14
|
+
// `minHeight` floor), and capped on top because the single-line label is
|
|
15
|
+
// width-bound past this point, so a taller button just becomes a slab.
|
|
16
|
+
// `cornerRadius` tracks the height to stay an HIG-allowed pill at any size.
|
|
17
|
+
const MAX_SCALE = 1.6;
|
|
18
|
+
|
|
19
|
+
export function AppleButton({
|
|
20
|
+
type,
|
|
21
|
+
onPress,
|
|
22
|
+
disabled,
|
|
23
|
+
testID,
|
|
24
|
+
}: {
|
|
25
|
+
type: AppleAuthentication.AppleAuthenticationButtonType;
|
|
26
|
+
onPress: () => void;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
testID?: string;
|
|
29
|
+
}) {
|
|
30
|
+
const colorScheme = useColorScheme();
|
|
31
|
+
const { fontScale } = useWindowDimensions();
|
|
32
|
+
const height = ButtonTokens.height * Math.min(Math.max(fontScale, 1), MAX_SCALE);
|
|
33
|
+
return (
|
|
34
|
+
<VStack alignment="center" modifiers={[frame({ maxWidth: Infinity, height })]}>
|
|
35
|
+
<RNHostView>
|
|
36
|
+
<AppleAuthentication.AppleAuthenticationButton
|
|
37
|
+
testID={testID}
|
|
38
|
+
buttonType={type}
|
|
39
|
+
buttonStyle={
|
|
40
|
+
colorScheme === "dark"
|
|
41
|
+
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
|
42
|
+
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
|
|
43
|
+
}
|
|
44
|
+
cornerRadius={height / 2}
|
|
45
|
+
style={{ width: "100%", height: "100%", opacity: disabled ? 0.5 : 1 }}
|
|
46
|
+
onPress={disabled ? () => {} : onPress}
|
|
47
|
+
/>
|
|
48
|
+
</RNHostView>
|
|
49
|
+
</VStack>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { startTransition, useActionState, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
Host,
|
|
4
|
+
VStack,
|
|
5
|
+
HStack,
|
|
6
|
+
Text,
|
|
7
|
+
TextField,
|
|
8
|
+
Button,
|
|
9
|
+
Image,
|
|
10
|
+
Spacer,
|
|
11
|
+
useNativeState,
|
|
12
|
+
} from "@expo/ui/swift-ui";
|
|
13
|
+
import { runOnJS } from "react-native-worklets";
|
|
4
14
|
import {
|
|
5
15
|
foregroundStyle,
|
|
6
16
|
buttonStyle,
|
|
7
17
|
background,
|
|
8
18
|
clipShape,
|
|
19
|
+
contentShape,
|
|
9
20
|
disabled,
|
|
10
21
|
keyboardType,
|
|
11
22
|
monospacedDigit,
|
|
@@ -15,24 +26,28 @@ import {
|
|
|
15
26
|
submitLabel,
|
|
16
27
|
padding,
|
|
17
28
|
frame,
|
|
29
|
+
shapes,
|
|
30
|
+
accessibilityHidden,
|
|
18
31
|
accessibilityLabel,
|
|
19
32
|
accessibilityHint,
|
|
33
|
+
dynamicTypeSize,
|
|
20
34
|
tint,
|
|
35
|
+
textContentType,
|
|
21
36
|
textFieldStyle,
|
|
22
37
|
} from "@expo/ui/swift-ui/modifiers";
|
|
23
38
|
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
24
|
-
import {
|
|
39
|
+
import { useSymbolSize } from "@/lib/dynamic-symbol-size";
|
|
40
|
+
import { Button as ButtonTokens, TouchTarget } from "@/constants/layout";
|
|
41
|
+
import { DynamicType } from "@/constants/ui";
|
|
25
42
|
|
|
26
|
-
import { api } from "@/convex/_generated/api";
|
|
27
43
|
import { authClient } from "@/lib/auth-client";
|
|
28
44
|
import { haptics } from "@/lib/haptics";
|
|
29
45
|
import { useColors } from "@/hooks/use-theme";
|
|
46
|
+
import { maskOtp } from "@/lib/masks";
|
|
30
47
|
import { ProminentButton } from "@/components/ui/prominent-button";
|
|
31
48
|
import { ErrorText } from "@/components/ui/status-text";
|
|
32
49
|
import { announce } from "@/lib/a11y";
|
|
33
50
|
|
|
34
|
-
export type PendingAvatar = { uri: string; mimeType: string };
|
|
35
|
-
|
|
36
51
|
export type OtpFlow = "verify-email" | "sign-in";
|
|
37
52
|
|
|
38
53
|
type OtpVerificationProps = {
|
|
@@ -46,30 +61,18 @@ type OtpVerificationProps = {
|
|
|
46
61
|
* returning user in passwordlessly.
|
|
47
62
|
*/
|
|
48
63
|
flow?: OtpFlow;
|
|
49
|
-
/**
|
|
50
|
-
* Avatar picked during sign-up. Uploaded to Convex storage right after
|
|
51
|
-
* verifyEmail succeeds and autoSignInAfterVerification mints the session.
|
|
52
|
-
* Held in the parent's state so it's forgotten if the user backs out.
|
|
53
|
-
* Ignored when `flow` is "sign-in" (existing accounts already have an
|
|
54
|
-
* avatar configured from the profile screen).
|
|
55
|
-
*/
|
|
56
|
-
pendingAvatar?: PendingAvatar | null;
|
|
57
64
|
};
|
|
58
65
|
|
|
59
66
|
type OtpState = { error?: string; ok?: boolean };
|
|
60
67
|
const initialState: OtpState = {};
|
|
61
68
|
|
|
62
|
-
export function OtpVerification({
|
|
63
|
-
email,
|
|
64
|
-
onBack,
|
|
65
|
-
flow = "verify-email",
|
|
66
|
-
pendingAvatar,
|
|
67
|
-
}: OtpVerificationProps) {
|
|
69
|
+
export function OtpVerification({ email, onBack, flow = "verify-email" }: OtpVerificationProps) {
|
|
68
70
|
const dfont = useDynamicFont();
|
|
71
|
+
const symbolSize = useSymbolSize();
|
|
69
72
|
const colors = useColors();
|
|
73
|
+
const otpState = useNativeState("");
|
|
70
74
|
const [otp, setOtp] = useState("");
|
|
71
|
-
const
|
|
72
|
-
const updateAvatar = useMutation(api.users.updateAvatar);
|
|
75
|
+
const [lastAction, setLastAction] = useState<"verify" | "resend">("verify");
|
|
73
76
|
const isSignIn = flow === "sign-in";
|
|
74
77
|
|
|
75
78
|
const [verifyState, verify, isVerifying] = useActionState<OtpState, void>(async () => {
|
|
@@ -90,29 +93,6 @@ export function OtpVerification({
|
|
|
90
93
|
return { error: "Invalid or expired code. Please try again." };
|
|
91
94
|
}
|
|
92
95
|
|
|
93
|
-
// Upload the avatar picked at sign-up before this component unmounts.
|
|
94
|
-
// Stack.Protected swaps (auth) -> (app) on the next render once the
|
|
95
|
-
// session lands, but kicking off the requests here keeps them in flight
|
|
96
|
-
// server-side regardless of the unmount. Failures are non-fatal: the
|
|
97
|
-
// user is verified, they can set a photo from the profile screen.
|
|
98
|
-
if (!isSignIn && pendingAvatar) {
|
|
99
|
-
try {
|
|
100
|
-
const uploadUrl = await generateAvatarUploadUrl();
|
|
101
|
-
const blob = await (await fetch(pendingAvatar.uri)).blob();
|
|
102
|
-
const upload = await fetch(uploadUrl, {
|
|
103
|
-
method: "POST",
|
|
104
|
-
headers: { "Content-Type": pendingAvatar.mimeType },
|
|
105
|
-
body: blob,
|
|
106
|
-
});
|
|
107
|
-
if (upload.ok) {
|
|
108
|
-
const { storageId } = (await upload.json()) as { storageId: string };
|
|
109
|
-
await updateAvatar({ storageId: storageId as never });
|
|
110
|
-
}
|
|
111
|
-
} catch {
|
|
112
|
-
// Swallow: verification still succeeded.
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
96
|
haptics.success();
|
|
117
97
|
announce(isSignIn ? "Signed in" : "Email verified");
|
|
118
98
|
return { ok: true };
|
|
@@ -129,10 +109,17 @@ export function OtpVerification({
|
|
|
129
109
|
const [resendState, resend, isResending] = useActionState<OtpState, void>(async () => {
|
|
130
110
|
haptics.light();
|
|
131
111
|
try {
|
|
132
|
-
await authClient.emailOtp.sendVerificationOtp({
|
|
112
|
+
const response = await authClient.emailOtp.sendVerificationOtp({
|
|
133
113
|
email: email.trim(),
|
|
134
114
|
type: isSignIn ? "sign-in" : "email-verification",
|
|
135
115
|
});
|
|
116
|
+
// Better Auth surfaces a 429 (the send-verification-otp rate limit) as a
|
|
117
|
+
// returned error, not a throw, so announcing success unconditionally
|
|
118
|
+
// would tell the user a code was sent when none was.
|
|
119
|
+
if (response.error) {
|
|
120
|
+
haptics.error();
|
|
121
|
+
return { error: "Failed to send code. Please try again." };
|
|
122
|
+
}
|
|
136
123
|
haptics.success();
|
|
137
124
|
announce("New verification code sent");
|
|
138
125
|
return { ok: true };
|
|
@@ -142,7 +129,19 @@ export function OtpVerification({
|
|
|
142
129
|
}
|
|
143
130
|
}, initialState);
|
|
144
131
|
|
|
145
|
-
const
|
|
132
|
+
const runVerify = () => {
|
|
133
|
+
setLastAction("verify");
|
|
134
|
+
startTransition(() => verify());
|
|
135
|
+
};
|
|
136
|
+
const runResend = () => {
|
|
137
|
+
setLastAction("resend");
|
|
138
|
+
startTransition(() => resend());
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Show the error from the action the user last ran. A plain
|
|
142
|
+
// `verifyState.error ?? resendState.error` keeps a stale verify error on
|
|
143
|
+
// screen after a successful resend, since resend never clears verifyState.
|
|
144
|
+
const error = lastAction === "resend" ? resendState.error : verifyState.error;
|
|
146
145
|
|
|
147
146
|
return (
|
|
148
147
|
<Host style={{ flex: 1, backgroundColor: colors.background }}>
|
|
@@ -155,8 +154,9 @@ export function OtpVerification({
|
|
|
155
154
|
|
|
156
155
|
<Image
|
|
157
156
|
systemName={isSignIn ? "lock.shield" : "envelope.badge"}
|
|
158
|
-
size={56}
|
|
157
|
+
size={symbolSize(56)}
|
|
159
158
|
color={colors.primary}
|
|
159
|
+
modifiers={[accessibilityHidden(true)]}
|
|
160
160
|
/>
|
|
161
161
|
|
|
162
162
|
<Text modifiers={[dfont({ size: 28, weight: "bold" }), multilineTextAlignment("center")]}>
|
|
@@ -173,28 +173,41 @@ export function OtpVerification({
|
|
|
173
173
|
>
|
|
174
174
|
Enter the 6-digit code sent to
|
|
175
175
|
</Text>
|
|
176
|
-
<Text modifiers={[dfont({ size: 15, weight: "semibold" })]}>
|
|
176
|
+
<Text testID="otp-email-value" modifiers={[dfont({ size: 15, weight: "semibold" })]}>
|
|
177
|
+
{email}
|
|
178
|
+
</Text>
|
|
177
179
|
</VStack>
|
|
178
180
|
|
|
179
|
-
{error && <ErrorText>{error}</ErrorText>}
|
|
181
|
+
{error && <ErrorText testID="otp-error">{error}</ErrorText>}
|
|
180
182
|
|
|
181
183
|
<VStack spacing={12} modifiers={[frame({ maxWidth: Infinity })]}>
|
|
182
184
|
<TextField
|
|
185
|
+
testID="otp-field"
|
|
186
|
+
text={otpState}
|
|
183
187
|
placeholder="000000"
|
|
184
|
-
onTextChange={(text) =>
|
|
188
|
+
onTextChange={(text) => {
|
|
189
|
+
"worklet";
|
|
190
|
+
const digits = maskOtp(text);
|
|
191
|
+
otpState.value = digits;
|
|
192
|
+
runOnJS(setOtp)(digits);
|
|
193
|
+
}}
|
|
185
194
|
autoFocus
|
|
186
195
|
modifiers={[
|
|
187
196
|
textFieldStyle("plain"),
|
|
188
197
|
padding({ horizontal: 16 }),
|
|
189
|
-
frame({ maxWidth: Infinity,
|
|
198
|
+
frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
|
|
190
199
|
background(colors.muted as string),
|
|
191
200
|
clipShape("capsule"),
|
|
192
201
|
dfont({ size: 24, design: "monospaced" }),
|
|
193
202
|
monospacedDigit(),
|
|
194
203
|
kerning(8),
|
|
195
204
|
multilineTextAlignment("center"),
|
|
205
|
+
// upstream expo/expo#46540: cap Dynamic Type on the fixed-height
|
|
206
|
+
// capsule so six 24pt monospaced glyphs can't scale past the box.
|
|
207
|
+
dynamicTypeSize({ max: DynamicType.otp }),
|
|
196
208
|
keyboardType("numeric"),
|
|
197
|
-
|
|
209
|
+
textContentType("oneTimeCode"),
|
|
210
|
+
onSubmit(runVerify),
|
|
198
211
|
submitLabel("done"),
|
|
199
212
|
accessibilityLabel("Verification code"),
|
|
200
213
|
accessibilityHint("Enter the 6 digit code sent to your email"),
|
|
@@ -202,6 +215,7 @@ export function OtpVerification({
|
|
|
202
215
|
/>
|
|
203
216
|
|
|
204
217
|
<ProminentButton
|
|
218
|
+
testID="otp-verify"
|
|
205
219
|
label={
|
|
206
220
|
isVerifying
|
|
207
221
|
? isSignIn
|
|
@@ -211,17 +225,18 @@ export function OtpVerification({
|
|
|
211
225
|
? "Sign in"
|
|
212
226
|
: "Verify"
|
|
213
227
|
}
|
|
214
|
-
onPress={
|
|
228
|
+
onPress={runVerify}
|
|
215
229
|
disabled={isVerifying || otp.length !== 6}
|
|
216
230
|
/>
|
|
217
231
|
|
|
218
232
|
<Button
|
|
219
|
-
|
|
220
|
-
|
|
233
|
+
testID="otp-resend"
|
|
234
|
+
modifiers={[buttonStyle("plain"), frame({ maxWidth: Infinity }), disabled(isResending)]}
|
|
235
|
+
onPress={runResend}
|
|
221
236
|
>
|
|
222
237
|
<Text
|
|
223
238
|
modifiers={[
|
|
224
|
-
frame({ maxWidth:
|
|
239
|
+
frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
|
|
225
240
|
multilineTextAlignment("center"),
|
|
226
241
|
dfont({ size: ButtonTokens.fontSize, weight: ButtonTokens.secondaryFontWeight }),
|
|
227
242
|
foregroundStyle(colors.primary as string),
|
|
@@ -239,8 +254,14 @@ export function OtpVerification({
|
|
|
239
254
|
Wrong email?
|
|
240
255
|
</Text>
|
|
241
256
|
<Button
|
|
257
|
+
testID="otp-back"
|
|
242
258
|
label="Go back"
|
|
243
|
-
modifiers={[
|
|
259
|
+
modifiers={[
|
|
260
|
+
buttonStyle("plain"),
|
|
261
|
+
dfont({ size: 14, weight: "semibold" }),
|
|
262
|
+
frame({ minHeight: TouchTarget.min }),
|
|
263
|
+
contentShape(shapes.rectangle()),
|
|
264
|
+
]}
|
|
244
265
|
onPress={() => {
|
|
245
266
|
haptics.light();
|
|
246
267
|
onBack();
|
|
@@ -1,17 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Button, HStack, Image, SecureField, TextField, useNativeState } from "@expo/ui/swift-ui";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
2
|
import {
|
|
3
|
+
Button,
|
|
4
|
+
HStack,
|
|
5
|
+
Image,
|
|
6
|
+
SecureField,
|
|
7
|
+
type SecureFieldRef,
|
|
8
|
+
TextField,
|
|
9
|
+
type TextFieldRef,
|
|
10
|
+
useNativeState,
|
|
11
|
+
} from "@expo/ui/swift-ui";
|
|
12
|
+
import {
|
|
13
|
+
accessibilityHidden,
|
|
4
14
|
accessibilityHint,
|
|
5
15
|
accessibilityLabel,
|
|
6
16
|
autocorrectionDisabled,
|
|
7
17
|
background,
|
|
8
18
|
buttonStyle,
|
|
9
19
|
clipShape,
|
|
20
|
+
contentShape,
|
|
10
21
|
disabled as disabledMod,
|
|
11
22
|
frame,
|
|
12
23
|
onSubmit as onSubmitMod,
|
|
13
24
|
padding,
|
|
25
|
+
shapes,
|
|
14
26
|
submitLabel,
|
|
27
|
+
textContentType,
|
|
15
28
|
textFieldStyle,
|
|
16
29
|
textInputAutocapitalization,
|
|
17
30
|
} from "@expo/ui/swift-ui/modifiers";
|
|
@@ -19,47 +32,73 @@ import {
|
|
|
19
32
|
import { Button as ButtonTokens } from "@/constants/layout";
|
|
20
33
|
import { useColors } from "@/hooks/use-theme";
|
|
21
34
|
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
35
|
+
import { useSymbolSize } from "@/lib/dynamic-symbol-size";
|
|
22
36
|
import { haptics } from "@/lib/haptics";
|
|
23
37
|
|
|
24
|
-
type ObservableTextState = NonNullable<ComponentProps<typeof TextField>["text"]>;
|
|
25
38
|
type SubmitLabel = "next" | "done" | "send" | "go" | "search" | "join" | "route" | "continue";
|
|
39
|
+
type ContentType = "password" | "newPassword";
|
|
26
40
|
|
|
27
41
|
type Props = {
|
|
28
|
-
text?: ObservableTextState;
|
|
29
42
|
placeholder?: string;
|
|
30
43
|
onTextChange: (next: string) => void;
|
|
31
44
|
onSubmit?: () => void;
|
|
32
45
|
submitLabelType?: SubmitLabel;
|
|
46
|
+
/**
|
|
47
|
+
* iOS text content type. `"password"` (default) hooks into keychain
|
|
48
|
+
* autofill for existing credentials. `"newPassword"` triggers Strong
|
|
49
|
+
* Password generation and saves the new credential on success. Use it
|
|
50
|
+
* for the sign-up and reset flows.
|
|
51
|
+
*/
|
|
52
|
+
contentType?: ContentType;
|
|
33
53
|
disabled?: boolean;
|
|
34
54
|
accessibilityLabel?: string;
|
|
35
55
|
accessibilityHint?: string;
|
|
56
|
+
testID?: string;
|
|
36
57
|
};
|
|
37
58
|
|
|
38
59
|
/**
|
|
39
60
|
* Password input with an inline eye toggle to reveal what was typed.
|
|
40
61
|
*
|
|
41
|
-
* The toggle swaps between SecureField (masked) and TextField (visible)
|
|
42
|
-
*
|
|
43
|
-
* the swap.
|
|
44
|
-
* and the new field starts empty.
|
|
45
|
-
* via `text`, that's used instead (e.g. profile.tsx clears the field after
|
|
46
|
-
* submit by resetting the state from outside).
|
|
62
|
+
* The toggle swaps between SecureField (masked) and TextField (visible), two
|
|
63
|
+
* different native views. Both bind to the same `useNativeState`, so the native
|
|
64
|
+
* value survives the swap. Without it, React unmounts one and mounts the other
|
|
65
|
+
* and the new field starts empty.
|
|
47
66
|
*/
|
|
48
67
|
export function PasswordField({
|
|
49
|
-
text,
|
|
50
68
|
placeholder = "••••••••",
|
|
51
69
|
onTextChange,
|
|
52
70
|
onSubmit,
|
|
53
71
|
submitLabelType = "done",
|
|
72
|
+
contentType = "password",
|
|
54
73
|
disabled = false,
|
|
55
74
|
accessibilityLabel: a11yLabel = "Password",
|
|
56
75
|
accessibilityHint: a11yHint = "Enter your password",
|
|
76
|
+
testID,
|
|
57
77
|
}: Props) {
|
|
58
78
|
const dfont = useDynamicFont();
|
|
79
|
+
const symbolSize = useSymbolSize();
|
|
59
80
|
const colors = useColors();
|
|
60
81
|
const [visible, setVisible] = useState(false);
|
|
61
|
-
const
|
|
62
|
-
const
|
|
82
|
+
const state = useNativeState("");
|
|
83
|
+
const textRef = useRef<TextFieldRef>(null);
|
|
84
|
+
const secureRef = useRef<SecureFieldRef>(null);
|
|
85
|
+
const focused = useRef(false);
|
|
86
|
+
const pendingRefocus = useRef(false);
|
|
87
|
+
const didMount = useRef(false);
|
|
88
|
+
|
|
89
|
+
// The eye toggle swaps SecureField <-> TextField, two different native views.
|
|
90
|
+
// React unmounts the focused one and mounts the other, so iOS drops the
|
|
91
|
+
// keyboard and caret. When the field was focused at the tap, refocus the
|
|
92
|
+
// now-active field after the swap so typing isn't interrupted.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!didMount.current) {
|
|
95
|
+
didMount.current = true;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!pendingRefocus.current) return;
|
|
99
|
+
pendingRefocus.current = false;
|
|
100
|
+
(visible ? textRef.current : secureRef.current)?.focus();
|
|
101
|
+
}, [visible]);
|
|
63
102
|
|
|
64
103
|
const fieldModifiers = [
|
|
65
104
|
textFieldStyle("plain"),
|
|
@@ -67,6 +106,7 @@ export function PasswordField({
|
|
|
67
106
|
dfont({ size: 16 }),
|
|
68
107
|
autocorrectionDisabled(),
|
|
69
108
|
textInputAutocapitalization("never"),
|
|
109
|
+
textContentType(contentType),
|
|
70
110
|
disabledMod(disabled),
|
|
71
111
|
submitLabel(submitLabelType),
|
|
72
112
|
accessibilityLabel(a11yLabel),
|
|
@@ -79,41 +119,57 @@ export function PasswordField({
|
|
|
79
119
|
spacing={8}
|
|
80
120
|
modifiers={[
|
|
81
121
|
padding({ horizontal: 16 }),
|
|
82
|
-
frame({ maxWidth: Infinity,
|
|
122
|
+
frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
|
|
83
123
|
background(colors.muted as string),
|
|
84
124
|
clipShape("capsule"),
|
|
85
125
|
]}
|
|
86
126
|
>
|
|
87
127
|
{visible ? (
|
|
88
128
|
<TextField
|
|
89
|
-
|
|
90
|
-
|
|
129
|
+
ref={textRef}
|
|
130
|
+
testID={testID}
|
|
131
|
+
text={state}
|
|
132
|
+
placeholder={a11yLabel}
|
|
91
133
|
onTextChange={onTextChange}
|
|
134
|
+
onFocusChange={(f) => {
|
|
135
|
+
focused.current = f;
|
|
136
|
+
}}
|
|
92
137
|
modifiers={fieldModifiers}
|
|
93
138
|
/>
|
|
94
139
|
) : (
|
|
95
140
|
<SecureField
|
|
96
|
-
|
|
141
|
+
ref={secureRef}
|
|
142
|
+
testID={testID}
|
|
143
|
+
text={state}
|
|
97
144
|
placeholder={placeholder}
|
|
98
145
|
onTextChange={onTextChange}
|
|
146
|
+
onFocusChange={(f) => {
|
|
147
|
+
focused.current = f;
|
|
148
|
+
}}
|
|
99
149
|
modifiers={fieldModifiers}
|
|
100
150
|
/>
|
|
101
151
|
)}
|
|
102
152
|
<Button
|
|
153
|
+
testID={testID ? `${testID}-visibility` : undefined}
|
|
103
154
|
modifiers={[
|
|
104
155
|
buttonStyle("plain"),
|
|
156
|
+
frame({ width: 44, height: 44 }),
|
|
157
|
+
contentShape(shapes.rectangle()),
|
|
158
|
+
disabledMod(disabled),
|
|
105
159
|
accessibilityLabel(visible ? "Hide password" : "Show password"),
|
|
106
160
|
accessibilityHint(visible ? "Tap to mask the password" : "Tap to reveal the password"),
|
|
107
161
|
]}
|
|
108
162
|
onPress={() => {
|
|
109
163
|
haptics.light();
|
|
164
|
+
pendingRefocus.current = focused.current;
|
|
110
165
|
setVisible((v) => !v);
|
|
111
166
|
}}
|
|
112
167
|
>
|
|
113
168
|
<Image
|
|
114
169
|
systemName={visible ? "eye.slash" : "eye"}
|
|
115
|
-
size={18}
|
|
170
|
+
size={symbolSize(18)}
|
|
116
171
|
color={colors.mutedForeground as string}
|
|
172
|
+
modifiers={[accessibilityHidden(true)]}
|
|
117
173
|
/>
|
|
118
174
|
</Button>
|
|
119
175
|
</HStack>
|