@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.
- package/README.md +89 -10
- package/package.json +31 -2
- package/src/ExpoWebRoot.tsx +81 -0
- package/src/ai/code-block.tsx +1 -1
- package/src/auth/AuthProviders.tsx +55 -0
- package/src/auth/AuthScreen.tsx +97 -0
- package/src/auth/GoogleSignInButton.tsx +116 -0
- package/src/auth/SignInScreen.tsx +302 -0
- package/src/auth/authErrors.ts +19 -0
- package/src/auth/index.ts +22 -0
- package/src/entries/auth.ts +5 -0
- package/src/entries/expo-web-root.ts +4 -0
- package/src/entries/layout.ts +18 -0
- package/src/examples/ParityGallery.tsx +11 -0
- package/src/index.ts +18 -0
- package/src/settings/MoreFromProjectV.tsx +105 -0
- package/src/settings/projectVMobileApps.ts +145 -0
- package/src/tailwind-preset.cjs +248 -0
- package/src/tailwind-preset.d.cts +50 -0
|
@@ -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";
|
package/src/entries/layout.ts
CHANGED
|
@@ -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
|
+
}
|