@vvedantb/native-ui 0.6.1 → 0.8.0

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.
@@ -0,0 +1,302 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Text, View } from "react-native";
3
+ import { useSignIn, useSignUp } from "@clerk/clerk-expo";
4
+ import { useRouter } from "expo-router";
5
+ import { Button } from "../Button";
6
+ import { textStyle } from "../controlStyles";
7
+ import { Input } from "../Input";
8
+ import { toast } from "../toastStore";
9
+ import { useScheme } from "../useScheme";
10
+ import { AuthScreen, FieldError, OrDivider } from "./AuthScreen";
11
+ import { authErrorMessage } from "./authErrors";
12
+ import { GoogleSignInButton, type AuthNavigationOptions } from "./GoogleSignInButton";
13
+
14
+ export type SignInMode = "sign-in" | "sign-up";
15
+
16
+ /** The messages the screen can show. Generic: none names a provider. */
17
+ export const AUTH_MESSAGES = {
18
+ missingFields: "Enter your email and password",
19
+ signInFailed: "Sign in failed",
20
+ invalidCredentials: "Invalid email or password",
21
+ signUpFailed: "Could not create account",
22
+ verifyFailed: "Invalid code",
23
+ missingCode: "Enter the code from the email",
24
+ needsAnotherStep: "This account needs another step to sign in. Finish it on the web.",
25
+ } as const;
26
+
27
+ export type SignInScreenProps = AuthNavigationOptions & {
28
+ /** Default `"sign-in"`. `"sign-up"` creates the account and checks an emailed code. */
29
+ mode?: SignInMode;
30
+ /** Default "Welcome back", or "Create account" in `sign-up`. */
31
+ title?: string;
32
+ /** Default "Sign in to continue", or "Sign up to get started" in `sign-up`. */
33
+ subtitle?: string;
34
+ /** The app URL scheme, such as `"vibot"`, for the Google redirect `scheme://sso-callback`. */
35
+ scheme: string;
36
+ /** Default `"sso-callback"`. */
37
+ redirectPath?: string;
38
+ /** Default `true`. */
39
+ showGoogle?: boolean;
40
+ /** Default "Continue with Google". */
41
+ googleLabel?: string;
42
+ /** In `sign-in`: the "Sign up" link in the footer. No link without it. */
43
+ signUpHref?: string;
44
+ /** In `sign-up`: the "Sign in" link in the footer. No link without it. */
45
+ signInHref?: string;
46
+ /** Sign-up rejects a shorter password before sending it. Default: no check. */
47
+ minPasswordLength?: number;
48
+ /** Default `"toast"`. `"inline"` shows errors under the fields. */
49
+ errorDisplay?: "toast" | "inline";
50
+ /** Prefix for every `testID`: `sign-in-email`, `sign-in-password`, `sign-in-submit` and the rest. Default the mode. */
51
+ testIDPrefix?: string;
52
+ /** Above the title. App brand chrome goes here. */
53
+ header?: ReactNode;
54
+ /** Below the footer link row, such as an app credit. */
55
+ footer?: ReactNode;
56
+ };
57
+
58
+ /**
59
+ * Email and password, and Google, sign-in or sign-up. Sign-up sends an email
60
+ * code and checks it before the session starts. On success it calls
61
+ * `onSuccess`, or `router.replace(afterAuthHref, replaceOptions)`.
62
+ *
63
+ * Forgot-password is not here: an app that needs it keeps its own screen.
64
+ */
65
+ export function SignInScreen({
66
+ mode = "sign-in",
67
+ title,
68
+ subtitle,
69
+ scheme,
70
+ redirectPath,
71
+ afterAuthHref = "/",
72
+ replaceOptions,
73
+ onSuccess,
74
+ showGoogle = true,
75
+ googleLabel,
76
+ signUpHref,
77
+ signInHref,
78
+ minPasswordLength,
79
+ errorDisplay = "toast",
80
+ testIDPrefix,
81
+ header,
82
+ footer,
83
+ }: SignInScreenProps) {
84
+ const signInState = useSignIn();
85
+ const signUpState = useSignUp();
86
+ const router = useRouter();
87
+ const colorScheme = useScheme();
88
+ const [email, setEmail] = useState("");
89
+ const [password, setPassword] = useState("");
90
+ const [code, setCode] = useState("");
91
+ const [pendingVerification, setPendingVerification] = useState(false);
92
+ const [error, setError] = useState("");
93
+ const [submitting, setSubmitting] = useState(false);
94
+ const signUp = mode === "sign-up";
95
+ const id = (name: string) => `${testIDPrefix ?? mode}-${name}`;
96
+
97
+ const fail = (heading: string, message: string) => {
98
+ if (errorDisplay === "inline") setError(message);
99
+ else toast.error(heading, message === heading ? undefined : { description: message });
100
+ };
101
+
102
+ const done = () => {
103
+ if (onSuccess) onSuccess();
104
+ else router.replace(afterAuthHref, replaceOptions);
105
+ };
106
+
107
+ const onSignIn = async () => {
108
+ const { isLoaded, signIn, setActive } = signInState;
109
+ if (!isLoaded || !signIn || !setActive || submitting) return;
110
+ if (!email || !password) return fail(AUTH_MESSAGES.signInFailed, AUTH_MESSAGES.missingFields);
111
+ setSubmitting(true);
112
+ setError("");
113
+ try {
114
+ const attempt = await signIn.create({ identifier: email, password });
115
+ if (attempt.status === "complete" && attempt.createdSessionId) {
116
+ await setActive({ session: attempt.createdSessionId });
117
+ done();
118
+ } else {
119
+ fail(AUTH_MESSAGES.signInFailed, AUTH_MESSAGES.needsAnotherStep);
120
+ }
121
+ } catch (caught) {
122
+ fail(AUTH_MESSAGES.signInFailed, authErrorMessage(caught, AUTH_MESSAGES.invalidCredentials));
123
+ } finally {
124
+ setSubmitting(false);
125
+ }
126
+ };
127
+
128
+ const onSignUp = async () => {
129
+ const { isLoaded, signUp: attempt } = signUpState;
130
+ if (!isLoaded || !attempt || submitting) return;
131
+ if (!email || !password) return fail(AUTH_MESSAGES.signUpFailed, AUTH_MESSAGES.missingFields);
132
+ if (minPasswordLength && password.length < minPasswordLength) {
133
+ return fail(AUTH_MESSAGES.signUpFailed, `Use at least ${minPasswordLength} characters for the password`);
134
+ }
135
+ setSubmitting(true);
136
+ setError("");
137
+ try {
138
+ await attempt.create({ emailAddress: email, password });
139
+ await attempt.prepareEmailAddressVerification({ strategy: "email_code" });
140
+ setPendingVerification(true);
141
+ } catch (caught) {
142
+ fail(AUTH_MESSAGES.signUpFailed, authErrorMessage(caught, AUTH_MESSAGES.signUpFailed));
143
+ } finally {
144
+ setSubmitting(false);
145
+ }
146
+ };
147
+
148
+ const onVerify = async () => {
149
+ const { isLoaded, signUp: attempt, setActive } = signUpState;
150
+ if (!isLoaded || !attempt || !setActive || submitting) return;
151
+ if (!code) return fail(AUTH_MESSAGES.verifyFailed, AUTH_MESSAGES.missingCode);
152
+ setSubmitting(true);
153
+ setError("");
154
+ try {
155
+ const result = await attempt.attemptEmailAddressVerification({ code });
156
+ if (result.status === "complete" && result.createdSessionId) {
157
+ await setActive({ session: result.createdSessionId });
158
+ done();
159
+ } else {
160
+ fail(AUTH_MESSAGES.verifyFailed, AUTH_MESSAGES.needsAnotherStep);
161
+ }
162
+ } catch (caught) {
163
+ fail(AUTH_MESSAGES.verifyFailed, authErrorMessage(caught, AUTH_MESSAGES.verifyFailed));
164
+ } finally {
165
+ setSubmitting(false);
166
+ }
167
+ };
168
+
169
+ const linkHref = signUp ? signInHref : signUpHref;
170
+ const footerRow = (
171
+ <>
172
+ {linkHref && !pendingVerification ? (
173
+ <>
174
+ <Text className="font-instrument-sans text-sm" style={textStyle("muted", colorScheme)}>
175
+ {signUp ? "Already have an account?" : "Don't have an account?"}
176
+ </Text>
177
+ <Button
178
+ testID={id(signUp ? "sign-in-link" : "sign-up-link")}
179
+ variant="link"
180
+ size="sm"
181
+ fullWidth={false}
182
+ onPress={() => router.push(linkHref)}
183
+ >
184
+ {signUp ? "Sign in" : "Sign up"}
185
+ </Button>
186
+ </>
187
+ ) : null}
188
+ {footer}
189
+ </>
190
+ );
191
+ const hasFooter = Boolean((linkHref && !pendingVerification) || footer);
192
+
193
+ if (pendingVerification) {
194
+ return (
195
+ <AuthScreen
196
+ testID={id("screen")}
197
+ title="Check your email"
198
+ subtitle="Enter the code we sent to your email."
199
+ header={header}
200
+ footer={hasFooter ? footerRow : undefined}
201
+ >
202
+ <Input
203
+ testID={id("code")}
204
+ accessibilityLabel="Verification code"
205
+ value={code}
206
+ placeholder="Verification code"
207
+ keyboardType="number-pad"
208
+ autoComplete="one-time-code"
209
+ textContentType="oneTimeCode"
210
+ autoFocus
211
+ returnKeyType="go"
212
+ onChangeText={setCode}
213
+ onSubmitEditing={() => void onVerify()}
214
+ />
215
+ <FieldError testID={id("error")} message={error} />
216
+ <Button testID={id("verify")} loading={submitting} onPress={() => void onVerify()}>
217
+ Verify
218
+ </Button>
219
+ <Button
220
+ testID={id("back")}
221
+ variant="ghost"
222
+ onPress={() => {
223
+ setPendingVerification(false);
224
+ setCode("");
225
+ setError("");
226
+ }}
227
+ >
228
+ Use a different email
229
+ </Button>
230
+ </AuthScreen>
231
+ );
232
+ }
233
+
234
+ return (
235
+ <AuthScreen
236
+ testID={id("screen")}
237
+ title={title ?? (signUp ? "Create account" : "Welcome back")}
238
+ subtitle={subtitle ?? (signUp ? "Sign up to get started" : "Sign in to continue")}
239
+ header={header}
240
+ footer={hasFooter ? footerRow : undefined}
241
+ >
242
+ {showGoogle ? (
243
+ <>
244
+ <GoogleSignInButton
245
+ testID={id("google")}
246
+ scheme={scheme}
247
+ redirectPath={redirectPath}
248
+ afterAuthHref={afterAuthHref}
249
+ replaceOptions={replaceOptions}
250
+ onSuccess={onSuccess}
251
+ onError={(message) => fail(message, message)}
252
+ label={googleLabel}
253
+ disabled={submitting}
254
+ />
255
+ <OrDivider />
256
+ </>
257
+ ) : null}
258
+ <Input
259
+ testID={id("email")}
260
+ accessibilityLabel="Email"
261
+ autoCapitalize="none"
262
+ autoComplete="email"
263
+ textContentType="emailAddress"
264
+ keyboardType="email-address"
265
+ returnKeyType="next"
266
+ value={email}
267
+ placeholder="Email"
268
+ onChangeText={setEmail}
269
+ />
270
+ <Input
271
+ testID={id("password")}
272
+ accessibilityLabel="Password"
273
+ autoCapitalize="none"
274
+ autoComplete={signUp ? "new-password" : "password"}
275
+ textContentType={signUp ? "newPassword" : "password"}
276
+ secureTextEntry
277
+ returnKeyType="go"
278
+ value={password}
279
+ placeholder="Password"
280
+ onChangeText={setPassword}
281
+ onSubmitEditing={() => void (signUp ? onSignUp() : onSignIn())}
282
+ />
283
+ <FieldError testID={id("error")} message={error} />
284
+ <Button
285
+ testID={id("submit")}
286
+ loading={submitting}
287
+ onPress={() => void (signUp ? onSignUp() : onSignIn())}
288
+ >
289
+ {signUp ? "Create account" : "Sign in"}
290
+ </Button>
291
+ {/* Bot protection mounts its widget here on web sign-up. */}
292
+ {signUp ? <View nativeID="clerk-captcha" /> : null}
293
+ </AuthScreen>
294
+ );
295
+ }
296
+
297
+ export type SignUpScreenProps = Omit<SignInScreenProps, "mode">;
298
+
299
+ /** `SignInScreen` with `mode="sign-up"`. */
300
+ export function SignUpScreen(props: SignUpScreenProps) {
301
+ return <SignInScreen {...props} mode="sign-up" />;
302
+ }
@@ -0,0 +1,19 @@
1
+ import { isClerkAPIResponseError } from "@clerk/clerk-expo";
2
+
3
+ /** Words that must not reach the user: the auth provider and stack names. */
4
+ const PRODUCT_NAMES = /\b(clerk|convex)\b/i;
5
+
6
+ /**
7
+ * A message to show for an auth error. An API error gives its first
8
+ * `longMessage` or `message` (such as "Password is incorrect."); anything
9
+ * else, or a message that names the provider, gives `fallback`. Never shows
10
+ * a raw exception message.
11
+ */
12
+ export function authErrorMessage(error: unknown, fallback: string): string {
13
+ if (isClerkAPIResponseError(error)) {
14
+ const first = error.errors[0];
15
+ const message = first?.longMessage ?? first?.message;
16
+ if (message && !PRODUCT_NAMES.test(message)) return message;
17
+ }
18
+ return fallback;
19
+ }
@@ -0,0 +1,22 @@
1
+ // Clerk + Convex providers and the shared sign-in and sign-up screens. On
2
+ // `@vvedantb/native-ui/auth` only: they import `@clerk/clerk-expo`, `convex`,
3
+ // `expo-auth-session`, `expo-web-browser` and `expo-router`, and Metro does not
4
+ // tree-shake.
5
+ export { AuthProviders } from "./AuthProviders";
6
+ export type { AuthProvidersProps } from "./AuthProviders";
7
+ export { AuthScreen, OrDivider, FieldError } from "./AuthScreen";
8
+ export type { AuthScreenProps } from "./AuthScreen";
9
+ export { authErrorMessage } from "./authErrors";
10
+ export {
11
+ GoogleSignInButton,
12
+ useGoogleSignIn,
13
+ useWarmUpBrowser,
14
+ GOOGLE_ERROR_MESSAGE,
15
+ } from "./GoogleSignInButton";
16
+ export type {
17
+ AuthNavigationOptions,
18
+ GoogleSignInOptions,
19
+ GoogleSignInButtonProps,
20
+ } from "./GoogleSignInButton";
21
+ export { SignInScreen, SignUpScreen, AUTH_MESSAGES } from "./SignInScreen";
22
+ export type { SignInMode, SignInScreenProps, SignUpScreenProps } from "./SignInScreen";
@@ -0,0 +1,5 @@
1
+ // `@vvedantb/native-ui/auth`: `AuthProviders`, `SignInScreen`, `SignUpScreen`,
2
+ // `GoogleSignInButton` and the auth frame. Not on the root entry: it imports
3
+ // `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser` and
4
+ // `expo-router`, and Metro does not tree-shake.
5
+ export * from "../auth";
@@ -0,0 +1,4 @@
1
+ // `@vvedantb/native-ui/expo-web-root`: `ExpoWebRoot` for `app/+html.tsx`. Not on
2
+ // the root entry: it imports `expo-router/html`, and Metro does not tree-shake.
3
+ export { ExpoWebRoot, EXPO_WEB_VIEWPORT, expoWebBackgroundCss } from "../ExpoWebRoot";
4
+ export type { ExpoWebRootProps, ExpoWebRootViewport } from "../ExpoWebRoot";
@@ -12,3 +12,21 @@ export { SettingsSection } from "../settings/SettingsSection";
12
12
  export { SettingsField } from "../settings/SettingsField";
13
13
  export { SettingsToggleRow } from "../settings/SettingsToggleRow";
14
14
  export { SettingsStack } from "../settings/SettingsStack";
15
+ export { MoreFromProjectV, FromProjectV } from "../settings/MoreFromProjectV";
16
+ export type { MoreFromProjectVProps, FromProjectVProps } from "../settings/MoreFromProjectV";
17
+ export {
18
+ PROJECT_V_MOBILE_APPS,
19
+ projectVMobileApp,
20
+ otherProjectVMobileApps,
21
+ openProjectVApp,
22
+ projectVAppOpenSteps,
23
+ VSTORE_HOME_URL,
24
+ androidIntentUrl,
25
+ } from "../settings/projectVMobileApps";
26
+ export type {
27
+ ProjectVMobileApp,
28
+ ProjectVMobileAppId,
29
+ ProjectVOpenTarget,
30
+ ProjectVOpenStep,
31
+ ProjectVLinking,
32
+ } from "../settings/projectVMobileApps";
@@ -54,12 +54,14 @@ import {
54
54
  DialogTitle,
55
55
  DialogTrigger,
56
56
  EmptyState,
57
+ FromProjectV,
57
58
  GoogleMark,
58
59
  IconButton,
59
60
  Input,
60
61
  Label,
61
62
  LabeledSwitchRow,
62
63
  ListRow,
64
+ MoreFromProjectV,
63
65
  NEUTRAL_900,
64
66
  NativeUiProvider,
65
67
  PageHeader,
@@ -682,6 +684,15 @@ function GalleryScreen() {
682
684
  </AmbientBackground>
683
685
  </Section>
684
686
 
687
+ <Section title="More from Project V">
688
+ {/* As in Velth's settings: the other four phone apps. A row opens the app, else V Store, else the website. */}
689
+ <MoreFromProjectV
690
+ current="velth"
691
+ onOpen={(id, target) => toast(target ? `Opened ${id} (${target})` : `Could not open ${id}`)}
692
+ />
693
+ <FromProjectV />
694
+ </Section>
695
+
685
696
  <Section title="Camera chrome">
686
697
  {/* A dark stand-in for the live preview. */}
687
698
  <View className="h-72 justify-between overflow-hidden rounded-lg p-4" style={{ backgroundColor: NEUTRAL_900 }}>
package/src/index.ts CHANGED
@@ -164,6 +164,24 @@ export { SettingsSection } from "./settings/SettingsSection";
164
164
  export { SettingsField } from "./settings/SettingsField";
165
165
  export { SettingsToggleRow } from "./settings/SettingsToggleRow";
166
166
  export { SettingsStack } from "./settings/SettingsStack";
167
+ export { MoreFromProjectV, FromProjectV } from "./settings/MoreFromProjectV";
168
+ export type { MoreFromProjectVProps, FromProjectVProps } from "./settings/MoreFromProjectV";
169
+ export {
170
+ PROJECT_V_MOBILE_APPS,
171
+ projectVMobileApp,
172
+ otherProjectVMobileApps,
173
+ openProjectVApp,
174
+ projectVAppOpenSteps,
175
+ VSTORE_HOME_URL,
176
+ androidIntentUrl,
177
+ } from "./settings/projectVMobileApps";
178
+ export type {
179
+ ProjectVMobileApp,
180
+ ProjectVMobileAppId,
181
+ ProjectVOpenTarget,
182
+ ProjectVOpenStep,
183
+ ProjectVLinking,
184
+ } from "./settings/projectVMobileApps";
167
185
  export { PINK_50, PINK_100, PINK_600, PINK_700, NEUTRAL_100, NEUTRAL_500, NEUTRAL_900, Colors } from "./colors";
168
186
  // Family app marks and their pure data (copied from `@vvedantb/ui/brand`), and `GoogleMark`.
169
187
  export * from "./brand";
@@ -0,0 +1,105 @@
1
+ import { Linking, Platform, Text, View } from "react-native";
2
+ import { APP_ICONS } from "../brand/AppIcons";
3
+ import { AppMark } from "../brand/AppMark";
4
+ import type { AppName } from "../brand/svg";
5
+ import { cn } from "../cn";
6
+ import { textStyle, typeStyle } from "../controlStyles";
7
+ import { ListRow } from "../ListRow";
8
+ import { tokens } from "../tokens";
9
+ import { useScheme } from "../useScheme";
10
+ import { SettingsSection } from "./SettingsSection";
11
+ import {
12
+ openProjectVApp,
13
+ otherProjectVMobileApps,
14
+ type ProjectVMobileAppId,
15
+ type ProjectVOpenTarget,
16
+ } from "./projectVMobileApps";
17
+
18
+ /**
19
+ * The mark for each phone app. V Store has no mark of its own (it was left out
20
+ * of the brand marks in 0.3.9), so it shows the Project V mark.
21
+ */
22
+ const MARK: Record<ProjectVMobileAppId, AppName> = {
23
+ vibot: "vibot",
24
+ verve: "verve",
25
+ velth: "velth",
26
+ vmem: "vmem",
27
+ vstore: "projectv",
28
+ };
29
+
30
+ export type FromProjectVProps = {
31
+ className?: string;
32
+ testID?: string;
33
+ };
34
+
35
+ /** A small muted "from Project V" line with the Project V mark, for the end of a settings screen. */
36
+ export function FromProjectV({ className, testID }: FromProjectVProps) {
37
+ const scheme = useScheme();
38
+ const muted = tokens.color.muted[scheme];
39
+ const ProjectVMark = APP_ICONS.projectv;
40
+ return (
41
+ <View
42
+ testID={testID}
43
+ accessible
44
+ accessibilityLabel="from Project V"
45
+ className={cn("flex-row items-center justify-center gap-1.5 py-2", className)}
46
+ >
47
+ <Text className="text-sm font-instrument-sans" style={textStyle("muted", scheme)}>
48
+ from
49
+ </Text>
50
+ <ProjectVMark size={14} color={muted} />
51
+ <Text
52
+ className="text-sm font-instrument-sans-semibold"
53
+ style={[textStyle("muted", scheme), typeStyle("semibold")]}
54
+ >
55
+ Project V
56
+ </Text>
57
+ </View>
58
+ );
59
+ }
60
+
61
+ export type MoreFromProjectVProps = {
62
+ /** The app showing the section. It is left out of the list. */
63
+ current: ProjectVMobileAppId;
64
+ /** `"section"` (default): a settings section listing the other apps. `"footer"`: only the small "from Project V" line. */
65
+ variant?: "section" | "footer";
66
+ /** Section title. Default "More from Project V". */
67
+ title?: string;
68
+ /** Called after a row opens something: the app, V Store or its website (`null` if nothing opened). */
69
+ onOpen?: (id: ProjectVMobileAppId, target: ProjectVOpenTarget | null) => void;
70
+ className?: string;
71
+ };
72
+
73
+ /**
74
+ * Settings section listing the other Project V phone apps, each with its mark,
75
+ * name and one line. A row opens the app if it is installed, else V Store's
76
+ * home, else its website (`openProjectVApp`).
77
+ */
78
+ export function MoreFromProjectV({
79
+ current,
80
+ variant = "section",
81
+ title = "More from Project V",
82
+ onOpen,
83
+ className,
84
+ }: MoreFromProjectVProps) {
85
+ if (variant === "footer") return <FromProjectV className={className} />;
86
+ return (
87
+ <SettingsSection title={title} bodyVariant="list" className={className}>
88
+ <View className="flex-col gap-1 p-1">
89
+ {otherProjectVMobileApps(current).map((app) => (
90
+ <ListRow
91
+ key={app.id}
92
+ testID={`more-from-project-v-${app.id}`}
93
+ title={app.name}
94
+ meta={app.description}
95
+ leading={<AppMark app={MARK[app.id]} size={28} />}
96
+ accessibilityLabel={`${app.name}, ${app.description}`}
97
+ onPress={() => {
98
+ void openProjectVApp(app.id, Linking, Platform.OS).then((target) => onOpen?.(app.id, target));
99
+ }}
100
+ />
101
+ ))}
102
+ </View>
103
+ </SettingsSection>
104
+ );
105
+ }
@@ -0,0 +1,145 @@
1
+ import { familyApp } from "../brand/familyApps";
2
+
3
+ /** The Project V phone apps, in display order. */
4
+ export type ProjectVMobileAppId = "vibot" | "verve" | "velth" | "vmem" | "vstore";
5
+
6
+ export type ProjectVMobileApp = {
7
+ id: ProjectVMobileAppId;
8
+ /** Display name. */
9
+ name: string;
10
+ /** One line for a settings row. */
11
+ description: string;
12
+ /** Android application id. */
13
+ androidPackage: string;
14
+ /** The app's URL scheme, without `://`. */
15
+ scheme: string;
16
+ /** Home page of the app: the fallback when neither the app nor V Store is installed. */
17
+ website: string;
18
+ };
19
+
20
+ /**
21
+ * V Store's home screen. V Store's `app/[id]` route takes the app's Convex
22
+ * document `_id`, not its catalog id or Android package, and native-ui cannot
23
+ * know live Convex ids. So the store step opens V Store's home, not the app's page.
24
+ */
25
+ export const VSTORE_HOME_URL = "vstore://";
26
+
27
+ // Names and sites come from `FAMILY_APPS` where it has the app. V Store is not
28
+ // a family app there (that file is a byte-identical copy of `@vvedantb/ui`).
29
+ const vibot = familyApp("vibot");
30
+ const verve = familyApp("verve");
31
+ const velth = familyApp("velth");
32
+ const vmem = familyApp("vmem");
33
+
34
+ /** The five Project V phone apps, in display order. */
35
+ export const PROJECT_V_MOBILE_APPS: readonly ProjectVMobileApp[] = [
36
+ {
37
+ id: "vibot",
38
+ name: vibot.name,
39
+ description: "AI chat, agents and tools",
40
+ androidPackage: "com.projectvdotuk.vibot",
41
+ scheme: "vibot",
42
+ website: vibot.url,
43
+ },
44
+ {
45
+ id: "verve",
46
+ name: verve.name,
47
+ description: "Notes, pages and writing",
48
+ androidPackage: "com.projectvdotuk.verve",
49
+ scheme: "verve",
50
+ website: verve.url,
51
+ },
52
+ {
53
+ id: "velth",
54
+ name: velth.name,
55
+ description: "Health insights and camera scans",
56
+ androidPackage: "com.projectvdotuk.velth",
57
+ scheme: "velth",
58
+ website: velth.url,
59
+ },
60
+ {
61
+ id: "vmem",
62
+ name: vmem.name,
63
+ description: "Your personal memory",
64
+ androidPackage: "com.projectvdotuk.vmem",
65
+ scheme: "vmem",
66
+ website: vmem.url,
67
+ },
68
+ {
69
+ id: "vstore",
70
+ name: "V Store",
71
+ description: "Install Project V apps",
72
+ androidPackage: "com.projectvdotuk.vstore",
73
+ scheme: "vstore",
74
+ website: "https://v-store-chi.vercel.app",
75
+ },
76
+ ];
77
+
78
+ /** Entry of `PROJECT_V_MOBILE_APPS` for an app. */
79
+ export function projectVMobileApp(id: ProjectVMobileAppId): ProjectVMobileApp {
80
+ return PROJECT_V_MOBILE_APPS.find((app) => app.id === id)!;
81
+ }
82
+
83
+ /** The phone apps other than `current`, in display order. */
84
+ export function otherProjectVMobileApps(current?: ProjectVMobileAppId): ProjectVMobileApp[] {
85
+ return PROJECT_V_MOBILE_APPS.filter((app) => app.id !== current);
86
+ }
87
+
88
+ /** Android intent URL that opens the installed app with this package. */
89
+ export function androidIntentUrl(app: ProjectVMobileApp): string {
90
+ return `intent://#Intent;scheme=${app.scheme};package=${app.androidPackage};end`;
91
+ }
92
+
93
+ /** How `openProjectVApp` opened an app. */
94
+ export type ProjectVOpenTarget = "app" | "store" | "website";
95
+
96
+ /** One URL to try, in order. `check`: ask `canOpenURL` first; otherwise try `openURL` and move on if it fails. */
97
+ export type ProjectVOpenStep = {
98
+ target: ProjectVOpenTarget;
99
+ url: string;
100
+ check: boolean;
101
+ };
102
+
103
+ /**
104
+ * The URLs `openProjectVApp` tries, in order: the app's scheme; on Android an
105
+ * intent to its package; V Store's home (`VSTORE_HOME_URL`); the website.
106
+ */
107
+ export function projectVAppOpenSteps(id: ProjectVMobileAppId, os: string): ProjectVOpenStep[] {
108
+ const app = projectVMobileApp(id);
109
+ const steps: ProjectVOpenStep[] = [{ target: "app", url: `${app.scheme}://`, check: true }];
110
+ if (os === "android") steps.push({ target: "app", url: androidIntentUrl(app), check: false });
111
+ if (id !== "vstore") steps.push({ target: "store", url: VSTORE_HOME_URL, check: true });
112
+ steps.push({ target: "website", url: app.website, check: false });
113
+ return steps;
114
+ }
115
+
116
+ /** The part of React Native `Linking` that `openProjectVApp` uses. */
117
+ export type ProjectVLinking = {
118
+ canOpenURL(url: string): Promise<boolean>;
119
+ openURL(url: string): Promise<unknown>;
120
+ };
121
+
122
+ /**
123
+ * Opens a Project V app: the app if installed, else V Store's home if V Store
124
+ * is installed, else its website. Resolves to what it opened, or
125
+ * `null` when every URL failed. Pass React Native `Linking` and `Platform.OS`.
126
+ *
127
+ * `canOpenURL` needs the schemes declared by the calling app:
128
+ * `LSApplicationQueriesSchemes` on iOS and `<queries>` on Android 11+.
129
+ */
130
+ export async function openProjectVApp(
131
+ id: ProjectVMobileAppId,
132
+ linking: ProjectVLinking,
133
+ os: string,
134
+ ): Promise<ProjectVOpenTarget | null> {
135
+ for (const step of projectVAppOpenSteps(id, os)) {
136
+ try {
137
+ if (step.check && !(await linking.canOpenURL(step.url))) continue;
138
+ await linking.openURL(step.url);
139
+ return step.target;
140
+ } catch {
141
+ // Not installed, or no handler: try the next URL.
142
+ }
143
+ }
144
+ return null;
145
+ }