@ramonclaudio/create-vexpo 0.1.0 → 0.1.1
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,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore-or-confirm surface for the account-deletion window.
|
|
3
|
+
*
|
|
4
|
+
* Reachable only when `getMe` returns a user with `deletedAt` set. The
|
|
5
|
+
* (app) layout's two-layer `Stack.Protected` switches the entire subtree
|
|
6
|
+
* to this screen and keeps the user from navigating elsewhere until they
|
|
7
|
+
* pick a path:
|
|
8
|
+
*
|
|
9
|
+
* Restore Account calls `users.restoreAccount`, clears the
|
|
10
|
+
* tombstone, drops us back at the home tab
|
|
11
|
+
* Sign Out hard sign-out; the 30-day cron continues to
|
|
12
|
+
* tick down toward permanent deletion
|
|
13
|
+
*
|
|
14
|
+
* Apple revoke does NOT happen here; the soft-delete pattern in
|
|
15
|
+
* `users.deleteAccount` defers Apple's `revokeRefreshToken` to the
|
|
16
|
+
* `hardDeleteExpired` cron so a restore within the window leaves SIWA
|
|
17
|
+
* authorization intact.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { router } from "expo-router";
|
|
21
|
+
import { useActionState, useState } from "react";
|
|
22
|
+
import { Image as ExpoImage } from "expo-image";
|
|
23
|
+
import { Button, Host, Spacer, Text, VStack } from "@expo/ui/swift-ui";
|
|
24
|
+
import {
|
|
25
|
+
background,
|
|
26
|
+
buttonStyle,
|
|
27
|
+
clipShape,
|
|
28
|
+
disabled,
|
|
29
|
+
foregroundStyle,
|
|
30
|
+
frame,
|
|
31
|
+
multilineTextAlignment,
|
|
32
|
+
padding,
|
|
33
|
+
} from "@expo/ui/swift-ui/modifiers";
|
|
34
|
+
import { useMutation, useQuery } from "convex/react";
|
|
35
|
+
|
|
36
|
+
import { ProminentButton } from "@/components/ui/prominent-button";
|
|
37
|
+
import { ErrorText } from "@/components/ui/status-text";
|
|
38
|
+
import { api } from "@/convex/_generated/api";
|
|
39
|
+
import { Button as ButtonTokens } from "@/constants/layout";
|
|
40
|
+
import { useColors, useThemedAsset } from "@/hooks/use-theme";
|
|
41
|
+
import { announce } from "@/lib/a11y";
|
|
42
|
+
import { assets } from "@/lib/assets";
|
|
43
|
+
import { authClient } from "@/lib/auth-client";
|
|
44
|
+
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
45
|
+
import { haptics } from "@/lib/haptics";
|
|
46
|
+
|
|
47
|
+
// Mirror of `ACCOUNT_DELETION_GRACE_MS` in `convex/users.ts`. Importing
|
|
48
|
+
// across the convex / app boundary costs a runtime require for a single
|
|
49
|
+
// constant, so inline it here. If you ever change the grace window,
|
|
50
|
+
// keep the two in sync.
|
|
51
|
+
const ACCOUNT_DELETION_GRACE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
type ActionState = { error?: string };
|
|
54
|
+
const initialState: ActionState = {};
|
|
55
|
+
|
|
56
|
+
export default function RestoreAccountScreen() {
|
|
57
|
+
const dfont = useDynamicFont();
|
|
58
|
+
const colors = useColors();
|
|
59
|
+
const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
|
|
60
|
+
const me = useQuery(api.users.getMe);
|
|
61
|
+
const restoreMutation = useMutation(api.users.restoreAccount);
|
|
62
|
+
|
|
63
|
+
const [signingOut, setSigningOut] = useState(false);
|
|
64
|
+
|
|
65
|
+
const [restoreState, restore, restorePending] = useActionState<ActionState, void>(async () => {
|
|
66
|
+
haptics.medium();
|
|
67
|
+
try {
|
|
68
|
+
await restoreMutation();
|
|
69
|
+
haptics.success();
|
|
70
|
+
announce("Account restored");
|
|
71
|
+
router.replace("/");
|
|
72
|
+
return {};
|
|
73
|
+
} catch (err) {
|
|
74
|
+
haptics.error();
|
|
75
|
+
return { error: err instanceof Error ? err.message : "Failed to restore account" };
|
|
76
|
+
}
|
|
77
|
+
}, initialState);
|
|
78
|
+
|
|
79
|
+
const handleSignOut = async () => {
|
|
80
|
+
if (signingOut || restorePending) return;
|
|
81
|
+
setSigningOut(true);
|
|
82
|
+
haptics.medium();
|
|
83
|
+
try {
|
|
84
|
+
await authClient.signOut();
|
|
85
|
+
} finally {
|
|
86
|
+
setSigningOut(false);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (!me) {
|
|
91
|
+
return (
|
|
92
|
+
<Host style={{ flex: 1, backgroundColor: colors.background }}>
|
|
93
|
+
<Spacer />
|
|
94
|
+
</Host>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Note: `deletedAt` cleared mid-mount is handled by the two-layer
|
|
99
|
+
// Stack.Protected in (app)/_layout.tsx, which switches us back to the
|
|
100
|
+
// authed subtree on the next render. No side-effect-in-render needed.
|
|
101
|
+
if (!me.deletedAt) {
|
|
102
|
+
return (
|
|
103
|
+
<Host style={{ flex: 1, backgroundColor: colors.background }}>
|
|
104
|
+
<Spacer />
|
|
105
|
+
</Host>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const permanentDeleteAt = new Date(me.deletedAt + ACCOUNT_DELETION_GRACE_MS);
|
|
110
|
+
const formattedDate = new Intl.DateTimeFormat(undefined, {
|
|
111
|
+
weekday: "long",
|
|
112
|
+
month: "long",
|
|
113
|
+
day: "numeric",
|
|
114
|
+
year: "numeric",
|
|
115
|
+
}).format(permanentDeleteAt);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<Host testID="restore-account-screen" style={{ flex: 1, backgroundColor: colors.background }}>
|
|
119
|
+
<VStack
|
|
120
|
+
spacing={24}
|
|
121
|
+
alignment="center"
|
|
122
|
+
modifiers={[
|
|
123
|
+
frame({ maxWidth: Infinity, maxHeight: Infinity }),
|
|
124
|
+
padding({ horizontal: 24, vertical: 48 }),
|
|
125
|
+
]}
|
|
126
|
+
>
|
|
127
|
+
<ExpoImage
|
|
128
|
+
source={brandIcon}
|
|
129
|
+
style={{ width: 72, height: 72 }}
|
|
130
|
+
contentFit="contain"
|
|
131
|
+
accessibilityLabel=""
|
|
132
|
+
/>
|
|
133
|
+
|
|
134
|
+
<VStack spacing={12} alignment="center">
|
|
135
|
+
<Text
|
|
136
|
+
testID="restore-account-title"
|
|
137
|
+
modifiers={[
|
|
138
|
+
dfont({ size: 24, weight: "bold" }),
|
|
139
|
+
foregroundStyle(colors.foreground as string),
|
|
140
|
+
multilineTextAlignment("center"),
|
|
141
|
+
]}
|
|
142
|
+
>
|
|
143
|
+
Account Scheduled for Deletion
|
|
144
|
+
</Text>
|
|
145
|
+
<Text
|
|
146
|
+
testID="restore-account-deletion-date"
|
|
147
|
+
modifiers={[
|
|
148
|
+
dfont({ size: 15 }),
|
|
149
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
150
|
+
multilineTextAlignment("center"),
|
|
151
|
+
]}
|
|
152
|
+
>
|
|
153
|
+
{`Your account is set to be permanently deleted on ${formattedDate}. Restore now to keep your account and all of its data.`}
|
|
154
|
+
</Text>
|
|
155
|
+
</VStack>
|
|
156
|
+
|
|
157
|
+
<VStack spacing={12} alignment="center" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
158
|
+
<ProminentButton
|
|
159
|
+
testID="restore-account-restore"
|
|
160
|
+
label={restorePending ? "Restoring…" : "Restore Account"}
|
|
161
|
+
onPress={() => restore()}
|
|
162
|
+
disabled={restorePending || signingOut}
|
|
163
|
+
/>
|
|
164
|
+
<Button
|
|
165
|
+
testID="restore-account-sign-out"
|
|
166
|
+
modifiers={[
|
|
167
|
+
buttonStyle("plain"),
|
|
168
|
+
frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
|
|
169
|
+
background(colors.muted as string),
|
|
170
|
+
clipShape("capsule"),
|
|
171
|
+
disabled(restorePending || signingOut),
|
|
172
|
+
]}
|
|
173
|
+
onPress={handleSignOut}
|
|
174
|
+
>
|
|
175
|
+
<Text
|
|
176
|
+
modifiers={[
|
|
177
|
+
dfont({ size: 16, weight: "medium" }),
|
|
178
|
+
foregroundStyle(colors.destructive as string),
|
|
179
|
+
]}
|
|
180
|
+
>
|
|
181
|
+
{signingOut ? "Signing Out…" : "Sign Out"}
|
|
182
|
+
</Text>
|
|
183
|
+
</Button>
|
|
184
|
+
</VStack>
|
|
185
|
+
|
|
186
|
+
{restoreState.error ? (
|
|
187
|
+
<ErrorText testID="restore-account-error">{restoreState.error}</ErrorText>
|
|
188
|
+
) : null}
|
|
189
|
+
</VStack>
|
|
190
|
+
</Host>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Host, ScrollView, Button, Text, VStack, HStack, Spacer, Alert } from "@expo/ui/swift-ui";
|
|
3
|
+
import {
|
|
4
|
+
background,
|
|
5
|
+
buttonStyle,
|
|
6
|
+
contentShape,
|
|
7
|
+
cornerRadius,
|
|
8
|
+
dynamicTypeSize,
|
|
9
|
+
shapes,
|
|
10
|
+
foregroundStyle,
|
|
11
|
+
frame,
|
|
12
|
+
multilineTextAlignment,
|
|
13
|
+
padding,
|
|
14
|
+
refreshable,
|
|
15
|
+
textSelection,
|
|
16
|
+
tint,
|
|
17
|
+
} from "@expo/ui/swift-ui/modifiers";
|
|
18
|
+
|
|
19
|
+
import { TouchTarget } from "@/constants/layout";
|
|
20
|
+
import { DynamicType } from "@/constants/ui";
|
|
21
|
+
import { ContentUnavailable } from "@/components/ui/content-unavailable";
|
|
22
|
+
import { SkeletonSessions } from "@/components/ui/skeleton";
|
|
23
|
+
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
24
|
+
|
|
25
|
+
import { authClient } from "@/lib/auth-client";
|
|
26
|
+
import { haptics } from "@/lib/haptics";
|
|
27
|
+
import { announce } from "@/lib/a11y";
|
|
28
|
+
import { useColors } from "@/hooks/use-theme";
|
|
29
|
+
|
|
30
|
+
type SessionRow = {
|
|
31
|
+
id: string;
|
|
32
|
+
token: string;
|
|
33
|
+
ipAddress?: string | null;
|
|
34
|
+
userAgent?: string | null;
|
|
35
|
+
createdAt: Date;
|
|
36
|
+
expiresAt: Date;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function formatRelative(date: Date): string {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const delta = Math.max(0, now - date.getTime());
|
|
42
|
+
const seconds = Math.floor(delta / 1000);
|
|
43
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
44
|
+
const minutes = Math.floor(seconds / 60);
|
|
45
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
46
|
+
const hours = Math.floor(minutes / 60);
|
|
47
|
+
if (hours < 24) return `${hours}h ago`;
|
|
48
|
+
const days = Math.floor(hours / 24);
|
|
49
|
+
if (days < 30) return `${days}d ago`;
|
|
50
|
+
const months = Math.floor(days / 30);
|
|
51
|
+
if (months < 12) return `${months}mo ago`;
|
|
52
|
+
return `${Math.floor(months / 12)}y ago`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function deviceLabel(userAgent?: string | null): string {
|
|
56
|
+
if (!userAgent) return "Unknown device";
|
|
57
|
+
if (/iPhone/i.test(userAgent)) return "iPhone";
|
|
58
|
+
if (/iPad/i.test(userAgent)) return "iPad";
|
|
59
|
+
if (/Mac/i.test(userAgent)) return "Mac";
|
|
60
|
+
if (/Android/i.test(userAgent)) return "Android";
|
|
61
|
+
if (/Windows/i.test(userAgent)) return "Windows";
|
|
62
|
+
if (/Linux/i.test(userAgent)) return "Linux";
|
|
63
|
+
return userAgent.slice(0, 40);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default function SessionsScreen() {
|
|
67
|
+
const dfont = useDynamicFont();
|
|
68
|
+
const colors = useColors();
|
|
69
|
+
const { data: current } = authClient.useSession();
|
|
70
|
+
const currentToken = current?.session?.token ?? null;
|
|
71
|
+
const [sessions, setSessions] = useState<SessionRow[] | null>(null);
|
|
72
|
+
const [loadError, setLoadError] = useState(false);
|
|
73
|
+
const [revoking, setRevoking] = useState<string | null>(null);
|
|
74
|
+
const [confirmToken, setConfirmToken] = useState<string | null>(null);
|
|
75
|
+
|
|
76
|
+
const load = async () => {
|
|
77
|
+
try {
|
|
78
|
+
const res = await authClient.listSessions();
|
|
79
|
+
if (res.error) {
|
|
80
|
+
setLoadError(true);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setLoadError(false);
|
|
84
|
+
const rows = (res.data ?? []).map((s) => ({
|
|
85
|
+
id: s.id,
|
|
86
|
+
token: s.token,
|
|
87
|
+
ipAddress: s.ipAddress ?? null,
|
|
88
|
+
userAgent: s.userAgent ?? null,
|
|
89
|
+
createdAt: new Date(s.createdAt),
|
|
90
|
+
expiresAt: new Date(s.expiresAt),
|
|
91
|
+
}));
|
|
92
|
+
setSessions(rows);
|
|
93
|
+
} catch {
|
|
94
|
+
setLoadError(true);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
load();
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const revoke = async (token: string) => {
|
|
103
|
+
haptics.medium();
|
|
104
|
+
setRevoking(token);
|
|
105
|
+
try {
|
|
106
|
+
// revokeSession resolves with an `error` object (not a throw) on a
|
|
107
|
+
// server-side failure, so check it before announcing success, matching
|
|
108
|
+
// load()'s `res.error` handling above.
|
|
109
|
+
const res = await authClient.revokeSession({ token });
|
|
110
|
+
if (res.error) {
|
|
111
|
+
haptics.error();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
haptics.success();
|
|
115
|
+
announce("Session revoked");
|
|
116
|
+
await load();
|
|
117
|
+
} catch {
|
|
118
|
+
haptics.error();
|
|
119
|
+
} finally {
|
|
120
|
+
setRevoking(null);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Host testID="sessions-screen" style={{ flex: 1, backgroundColor: colors.background }}>
|
|
126
|
+
{sessions === null ? (
|
|
127
|
+
loadError ? (
|
|
128
|
+
<ContentUnavailable
|
|
129
|
+
testID="sessions-error"
|
|
130
|
+
title="Couldn't load sessions"
|
|
131
|
+
systemImage="exclamationmark.triangle"
|
|
132
|
+
description="Check your connection and try again."
|
|
133
|
+
/>
|
|
134
|
+
) : (
|
|
135
|
+
<SkeletonSessions testID="sessions-loading" />
|
|
136
|
+
)
|
|
137
|
+
) : sessions.length === 0 ? (
|
|
138
|
+
<ContentUnavailable
|
|
139
|
+
testID="sessions-empty"
|
|
140
|
+
title="No active sessions"
|
|
141
|
+
systemImage="list.bullet.rectangle.portrait"
|
|
142
|
+
description="You have no other active sessions."
|
|
143
|
+
/>
|
|
144
|
+
) : (
|
|
145
|
+
<ScrollView modifiers={[tint(colors.primary as string), refreshable(load)]}>
|
|
146
|
+
<VStack
|
|
147
|
+
spacing={12}
|
|
148
|
+
alignment="leading"
|
|
149
|
+
modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
|
|
150
|
+
>
|
|
151
|
+
<Text
|
|
152
|
+
testID="sessions-heading"
|
|
153
|
+
modifiers={[
|
|
154
|
+
dfont({ size: 13, weight: "semibold" }),
|
|
155
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
156
|
+
]}
|
|
157
|
+
>
|
|
158
|
+
ACTIVE SESSIONS
|
|
159
|
+
</Text>
|
|
160
|
+
{sessions.map((s) => {
|
|
161
|
+
const isCurrent = s.token === currentToken;
|
|
162
|
+
return (
|
|
163
|
+
<HStack
|
|
164
|
+
key={s.id}
|
|
165
|
+
testID={`session-row-${s.token}`}
|
|
166
|
+
spacing={12}
|
|
167
|
+
alignment="center"
|
|
168
|
+
modifiers={[
|
|
169
|
+
frame({ maxWidth: Infinity }),
|
|
170
|
+
padding({ horizontal: 20, vertical: 14 }),
|
|
171
|
+
background(colors.muted as string),
|
|
172
|
+
cornerRadius(20),
|
|
173
|
+
]}
|
|
174
|
+
>
|
|
175
|
+
<VStack alignment="leading" spacing={2}>
|
|
176
|
+
<HStack spacing={8} alignment="center">
|
|
177
|
+
<Text
|
|
178
|
+
testID={`session-device-${s.token}`}
|
|
179
|
+
modifiers={[dfont({ size: 16, weight: "semibold" }), textSelection(true)]}
|
|
180
|
+
>
|
|
181
|
+
{deviceLabel(s.userAgent)}
|
|
182
|
+
</Text>
|
|
183
|
+
{isCurrent ? (
|
|
184
|
+
<Text
|
|
185
|
+
testID={`session-current-badge-${s.token}`}
|
|
186
|
+
modifiers={[
|
|
187
|
+
dfont({ size: 11, weight: "semibold" }),
|
|
188
|
+
foregroundStyle(colors.primaryForeground as string),
|
|
189
|
+
padding({ horizontal: 8, vertical: 2 }),
|
|
190
|
+
background(colors.primary as string),
|
|
191
|
+
cornerRadius(8),
|
|
192
|
+
// upstream expo/expo#46540: fixed pill, cap Dynamic
|
|
193
|
+
// Type so it can't balloon beside the device name.
|
|
194
|
+
dynamicTypeSize({ max: DynamicType.control }),
|
|
195
|
+
]}
|
|
196
|
+
>
|
|
197
|
+
This device
|
|
198
|
+
</Text>
|
|
199
|
+
) : null}
|
|
200
|
+
</HStack>
|
|
201
|
+
<Text
|
|
202
|
+
testID={`session-meta-${s.token}`}
|
|
203
|
+
modifiers={[
|
|
204
|
+
dfont({ size: 13 }),
|
|
205
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
206
|
+
textSelection(true),
|
|
207
|
+
]}
|
|
208
|
+
>
|
|
209
|
+
{s.ipAddress ?? "Unknown IP"} · {formatRelative(s.createdAt)}
|
|
210
|
+
</Text>
|
|
211
|
+
</VStack>
|
|
212
|
+
<Spacer />
|
|
213
|
+
{isCurrent ? null : (
|
|
214
|
+
<Alert
|
|
215
|
+
title="Revoke this session?"
|
|
216
|
+
isPresented={confirmToken === s.token}
|
|
217
|
+
onIsPresentedChange={(v) => setConfirmToken(v ? s.token : null)}
|
|
218
|
+
>
|
|
219
|
+
<Alert.Trigger>
|
|
220
|
+
<Button
|
|
221
|
+
testID={`session-revoke-${s.token}`}
|
|
222
|
+
modifiers={[
|
|
223
|
+
buttonStyle("plain"),
|
|
224
|
+
frame({ minHeight: TouchTarget.min }),
|
|
225
|
+
contentShape(shapes.rectangle()),
|
|
226
|
+
]}
|
|
227
|
+
onPress={() => {
|
|
228
|
+
haptics.warning();
|
|
229
|
+
setConfirmToken(s.token);
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
<Text
|
|
233
|
+
modifiers={[
|
|
234
|
+
dfont({ size: 14, weight: "medium" }),
|
|
235
|
+
foregroundStyle(colors.destructive as string),
|
|
236
|
+
]}
|
|
237
|
+
>
|
|
238
|
+
Revoke
|
|
239
|
+
</Text>
|
|
240
|
+
</Button>
|
|
241
|
+
</Alert.Trigger>
|
|
242
|
+
<Alert.Actions>
|
|
243
|
+
<Button
|
|
244
|
+
testID={`session-revoke-confirm-${s.token}`}
|
|
245
|
+
label="Revoke"
|
|
246
|
+
role="destructive"
|
|
247
|
+
onPress={() => {
|
|
248
|
+
setConfirmToken(null);
|
|
249
|
+
void revoke(s.token);
|
|
250
|
+
}}
|
|
251
|
+
/>
|
|
252
|
+
<Button
|
|
253
|
+
testID={`session-revoke-cancel-${s.token}`}
|
|
254
|
+
label="Cancel"
|
|
255
|
+
role="cancel"
|
|
256
|
+
/>
|
|
257
|
+
</Alert.Actions>
|
|
258
|
+
<Alert.Message>
|
|
259
|
+
<Text modifiers={[dfont({ size: 16 })]}>
|
|
260
|
+
Signing out {deviceLabel(s.userAgent)} ends the session everywhere it is
|
|
261
|
+
active.
|
|
262
|
+
</Text>
|
|
263
|
+
</Alert.Message>
|
|
264
|
+
</Alert>
|
|
265
|
+
)}
|
|
266
|
+
</HStack>
|
|
267
|
+
);
|
|
268
|
+
})}
|
|
269
|
+
{revoking ? (
|
|
270
|
+
<Text
|
|
271
|
+
testID="sessions-revoking"
|
|
272
|
+
modifiers={[
|
|
273
|
+
dfont({ size: 13 }),
|
|
274
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
275
|
+
multilineTextAlignment("center"),
|
|
276
|
+
frame({ maxWidth: Infinity }),
|
|
277
|
+
]}
|
|
278
|
+
>
|
|
279
|
+
Revoking session...
|
|
280
|
+
</Text>
|
|
281
|
+
) : null}
|
|
282
|
+
</VStack>
|
|
283
|
+
</ScrollView>
|
|
284
|
+
)}
|
|
285
|
+
</Host>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import { Image as ExpoImage } from "expo-image";
|
|
3
|
+
import { router } from "expo-router";
|
|
4
|
+
import {
|
|
5
|
+
Host,
|
|
6
|
+
VStack,
|
|
7
|
+
Spacer,
|
|
8
|
+
Text,
|
|
9
|
+
Button,
|
|
10
|
+
Image,
|
|
11
|
+
ProgressView,
|
|
12
|
+
RNHostView,
|
|
13
|
+
TabView,
|
|
14
|
+
} from "@expo/ui/swift-ui";
|
|
15
|
+
import {
|
|
16
|
+
foregroundStyle,
|
|
17
|
+
buttonStyle,
|
|
18
|
+
multilineTextAlignment,
|
|
19
|
+
progressViewStyle,
|
|
20
|
+
frame,
|
|
21
|
+
padding,
|
|
22
|
+
kerning,
|
|
23
|
+
tint,
|
|
24
|
+
accessibilityHidden,
|
|
25
|
+
accessibilityLabel,
|
|
26
|
+
accessibilityValue,
|
|
27
|
+
tabViewStyle,
|
|
28
|
+
} from "@expo/ui/swift-ui/modifiers";
|
|
29
|
+
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
30
|
+
import { useSymbolSize } from "@/lib/dynamic-symbol-size";
|
|
31
|
+
import { Button as ButtonTokens } from "@/constants/layout";
|
|
32
|
+
import { ProminentButton } from "@/components/ui/prominent-button";
|
|
33
|
+
|
|
34
|
+
import { assets } from "@/lib/assets";
|
|
35
|
+
import { haptics } from "@/lib/haptics";
|
|
36
|
+
import { useColors, useThemedAsset } from "@/hooks/use-theme";
|
|
37
|
+
import { useOnboarding } from "@/hooks/use-onboarding";
|
|
38
|
+
|
|
39
|
+
type WelcomeStep =
|
|
40
|
+
| { id: string; brand: true; title: string; subtitle: string }
|
|
41
|
+
| {
|
|
42
|
+
id: string;
|
|
43
|
+
icon: "hammer.fill" | "checkmark.circle.fill";
|
|
44
|
+
title: string;
|
|
45
|
+
subtitle: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const STEPS: readonly WelcomeStep[] = [
|
|
49
|
+
{ id: "welcome", brand: true, title: "Welcome", subtitle: "Your new app starts here." },
|
|
50
|
+
{
|
|
51
|
+
id: "built",
|
|
52
|
+
icon: "hammer.fill",
|
|
53
|
+
title: "Built with Expo",
|
|
54
|
+
subtitle: "Universal, fast, native.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "ready",
|
|
58
|
+
icon: "checkmark.circle.fill",
|
|
59
|
+
title: "Ready to Go",
|
|
60
|
+
subtitle: "Start building something great.",
|
|
61
|
+
},
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
export default function WelcomeScreen() {
|
|
65
|
+
const dfont = useDynamicFont();
|
|
66
|
+
const symbolSize = useSymbolSize();
|
|
67
|
+
const colors = useColors();
|
|
68
|
+
const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
|
|
69
|
+
const [step, setStep] = useState(0);
|
|
70
|
+
const { markSeen } = useOnboarding();
|
|
71
|
+
|
|
72
|
+
const handleContinue = useCallback(() => {
|
|
73
|
+
haptics.medium();
|
|
74
|
+
markSeen();
|
|
75
|
+
router.replace("/");
|
|
76
|
+
}, [markSeen]);
|
|
77
|
+
|
|
78
|
+
const handleNext = useCallback(() => {
|
|
79
|
+
haptics.light();
|
|
80
|
+
setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
// TabView page style drives selection by the step's id; a swipe reports the
|
|
84
|
+
// new id here and we mirror it back into `step` so the progress bar and the
|
|
85
|
+
// Next/Get Started button stay in sync.
|
|
86
|
+
const handlePageChange = useCallback((nextID: string) => {
|
|
87
|
+
const idx = STEPS.findIndex((s) => s.id === nextID);
|
|
88
|
+
if (idx < 0) return;
|
|
89
|
+
setStep((current) => {
|
|
90
|
+
if (current !== idx) haptics.selection();
|
|
91
|
+
return idx;
|
|
92
|
+
});
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
const isLast = step === STEPS.length - 1;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
// upstream expo/expo#45872: Host modifiers now apply, so the accent tint
|
|
99
|
+
// cascades from the Host into the ProgressView and buttons below.
|
|
100
|
+
<Host testID="welcome-screen" style={{ flex: 1 }} modifiers={[tint(colors.primary as string)]}>
|
|
101
|
+
<VStack spacing={0}>
|
|
102
|
+
<VStack spacing={12} modifiers={[padding({ horizontal: 24, top: 24 })]}>
|
|
103
|
+
<ProgressView
|
|
104
|
+
testID="welcome-progress"
|
|
105
|
+
value={(step + 1) / STEPS.length}
|
|
106
|
+
modifiers={[
|
|
107
|
+
progressViewStyle("linear"),
|
|
108
|
+
accessibilityLabel("Onboarding progress"),
|
|
109
|
+
accessibilityValue(`Step ${step + 1} of ${STEPS.length}`),
|
|
110
|
+
]}
|
|
111
|
+
/>
|
|
112
|
+
</VStack>
|
|
113
|
+
|
|
114
|
+
<TabView
|
|
115
|
+
selection={STEPS[step].id}
|
|
116
|
+
onSelectionChange={handlePageChange}
|
|
117
|
+
modifiers={[
|
|
118
|
+
frame({ maxWidth: Infinity, maxHeight: Infinity }),
|
|
119
|
+
tabViewStyle({ type: "page", indexDisplayMode: "never" }),
|
|
120
|
+
]}
|
|
121
|
+
>
|
|
122
|
+
{STEPS.map((s) => (
|
|
123
|
+
<TabView.Tab key={s.id} value={s.id}>
|
|
124
|
+
<VStack
|
|
125
|
+
spacing={20}
|
|
126
|
+
alignment="center"
|
|
127
|
+
modifiers={[
|
|
128
|
+
frame({ maxWidth: Infinity, maxHeight: Infinity }),
|
|
129
|
+
padding({ horizontal: 24 }),
|
|
130
|
+
]}
|
|
131
|
+
>
|
|
132
|
+
<Spacer />
|
|
133
|
+
{"brand" in s ? (
|
|
134
|
+
<RNHostView matchContents>
|
|
135
|
+
<ExpoImage
|
|
136
|
+
source={brandIcon}
|
|
137
|
+
style={{ width: 96, height: 96 }}
|
|
138
|
+
contentFit="contain"
|
|
139
|
+
accessibilityLabel="App icon"
|
|
140
|
+
/>
|
|
141
|
+
</RNHostView>
|
|
142
|
+
) : (
|
|
143
|
+
<Image
|
|
144
|
+
systemName={s.icon}
|
|
145
|
+
size={symbolSize(48)}
|
|
146
|
+
color={colors.primary as string}
|
|
147
|
+
modifiers={[frame({ width: 80, height: 80 }), accessibilityHidden(true)]}
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
150
|
+
<Text
|
|
151
|
+
testID={`welcome-step-${s.id}-title`}
|
|
152
|
+
modifiers={[dfont({ size: 34, weight: "bold" }), kerning(-0.5)]}
|
|
153
|
+
>
|
|
154
|
+
{s.title}
|
|
155
|
+
</Text>
|
|
156
|
+
<Text
|
|
157
|
+
testID={`welcome-step-${s.id}-subtitle`}
|
|
158
|
+
modifiers={[
|
|
159
|
+
dfont({ size: 17 }),
|
|
160
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
161
|
+
multilineTextAlignment("center"),
|
|
162
|
+
]}
|
|
163
|
+
>
|
|
164
|
+
{s.subtitle}
|
|
165
|
+
</Text>
|
|
166
|
+
<Spacer />
|
|
167
|
+
</VStack>
|
|
168
|
+
</TabView.Tab>
|
|
169
|
+
))}
|
|
170
|
+
</TabView>
|
|
171
|
+
|
|
172
|
+
<VStack spacing={12} modifiers={[padding({ horizontal: 24, bottom: 24 })]}>
|
|
173
|
+
<ProminentButton
|
|
174
|
+
testID="welcome-continue"
|
|
175
|
+
label={isLast ? "Get Started" : "Next"}
|
|
176
|
+
onPress={isLast ? handleContinue : handleNext}
|
|
177
|
+
/>
|
|
178
|
+
{!isLast && (
|
|
179
|
+
<Button
|
|
180
|
+
testID="welcome-skip"
|
|
181
|
+
label="Skip"
|
|
182
|
+
modifiers={[
|
|
183
|
+
buttonStyle("plain"),
|
|
184
|
+
dfont({ size: ButtonTokens.fontSize, weight: ButtonTokens.secondaryFontWeight }),
|
|
185
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
186
|
+
]}
|
|
187
|
+
onPress={handleContinue}
|
|
188
|
+
/>
|
|
189
|
+
)}
|
|
190
|
+
</VStack>
|
|
191
|
+
</VStack>
|
|
192
|
+
</Host>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NativeIntent } from "expo-router";
|
|
2
|
+
import { resolveDeepLink } from "@/lib/deep-link";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates and rewrites incoming system paths against the typed
|
|
6
|
+
* `DeepLinkRoutes` registry before the router matches. Unknown or malformed
|
|
7
|
+
* paths drop to `/`.
|
|
8
|
+
*
|
|
9
|
+
* This is the single entry point for deep-link navigation (expo-router runs
|
|
10
|
+
* `redirectSystemPath` and drives the navigator itself for both cold-start and
|
|
11
|
+
* warm links). The resolved query is reattached to the returned path so the
|
|
12
|
+
* router, which parses the returned string as a URL, delivers params to the
|
|
13
|
+
* destination's `useLocalSearchParams`. Returning the bare path here would
|
|
14
|
+
* strip the query and render the screen param-less.
|
|
15
|
+
*/
|
|
16
|
+
export const redirectSystemPath: NativeIntent["redirectSystemPath"] = ({ path }) => {
|
|
17
|
+
const { href, params } = resolveDeepLink(path);
|
|
18
|
+
if (!href) {
|
|
19
|
+
if (__DEV__) console.warn("[NativeIntent] Blocked:", path);
|
|
20
|
+
return "/";
|
|
21
|
+
}
|
|
22
|
+
const route = href as string;
|
|
23
|
+
const search = new URLSearchParams(params).toString();
|
|
24
|
+
return search ? `${route}?${search}` : route;
|
|
25
|
+
};
|