@ramonclaudio/create-vexpo 0.1.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 +50 -0
- package/dist/index.js +183 -0
- package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
- package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
- package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
- package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
- package/dist/templates/default/.eas/workflows/release.yml +44 -0
- package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
- package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
- package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
- package/dist/templates/default/.github/workflows/check.yml +28 -0
- package/dist/templates/default/.maestro/launch.yaml +18 -0
- package/dist/templates/default/AGENTS.md +79 -0
- package/dist/templates/default/DESIGN.md +331 -0
- package/dist/templates/default/LICENSE +21 -0
- package/dist/templates/default/README.md +153 -0
- package/dist/templates/default/SETUP.md +618 -0
- package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
- package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
- package/dist/templates/default/_easignore +22 -0
- package/dist/templates/default/_editorconfig +9 -0
- package/dist/templates/default/_env.example +34 -0
- package/dist/templates/default/_fingerprintignore +24 -0
- package/dist/templates/default/_gitattributes +7 -0
- package/dist/templates/default/_gitignore +69 -0
- package/dist/templates/default/_oxfmtrc.json +3 -0
- package/dist/templates/default/_oxlintrc.json +34 -0
- package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
- package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
- package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
- package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
- package/dist/templates/default/app/(app)/_layout.tsx +73 -0
- package/dist/templates/default/app/(app)/debug.tsx +389 -0
- package/dist/templates/default/app/(app)/help.tsx +254 -0
- package/dist/templates/default/app/(app)/linked.tsx +116 -0
- package/dist/templates/default/app/(app)/privacy.tsx +159 -0
- package/dist/templates/default/app/(app)/profile.tsx +915 -0
- package/dist/templates/default/app/(app)/sessions.tsx +191 -0
- package/dist/templates/default/app/(app)/welcome.tsx +140 -0
- package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
- package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
- package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
- package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
- package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
- package/dist/templates/default/app/+native-intent.tsx +14 -0
- package/dist/templates/default/app/+not-found.tsx +51 -0
- package/dist/templates/default/app/_layout.tsx +102 -0
- package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
- package/dist/templates/default/app-store/screenshots/README.md +13 -0
- package/dist/templates/default/app.config.ts +201 -0
- package/dist/templates/default/app.json +11 -0
- package/dist/templates/default/assets/brand-icon-dark.png +0 -0
- package/dist/templates/default/assets/brand-icon-light.png +0 -0
- package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
- package/dist/templates/default/assets/icon.png +0 -0
- package/dist/templates/default/assets/sounds/notification.wav +0 -0
- package/dist/templates/default/assets/splash-image-dark.png +0 -0
- package/dist/templates/default/assets/splash-image-light.png +0 -0
- package/dist/templates/default/bun.lock +1860 -0
- package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
- package/dist/templates/default/components/auth/password-field.tsx +121 -0
- package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
- package/dist/templates/default/components/ui/convex-error.tsx +32 -0
- package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
- package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
- package/dist/templates/default/components/ui/material.tsx +94 -0
- package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
- package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
- package/dist/templates/default/components/ui/skeleton.tsx +107 -0
- package/dist/templates/default/components/ui/status-text.tsx +49 -0
- package/dist/templates/default/components/ui/update-banner.tsx +82 -0
- package/dist/templates/default/constants/layout.ts +102 -0
- package/dist/templates/default/constants/theme.ts +401 -0
- package/dist/templates/default/constants/ui.ts +77 -0
- package/dist/templates/default/convex/_generated/api.d.ts +77 -0
- package/dist/templates/default/convex/_generated/api.js +23 -0
- package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
- package/dist/templates/default/convex/_generated/server.d.ts +143 -0
- package/dist/templates/default/convex/_generated/server.js +93 -0
- package/dist/templates/default/convex/admin.ts +102 -0
- package/dist/templates/default/convex/auth.config.ts +6 -0
- package/dist/templates/default/convex/auth.ts +335 -0
- package/dist/templates/default/convex/constants.ts +46 -0
- package/dist/templates/default/convex/convex.config.ts +11 -0
- package/dist/templates/default/convex/crons.ts +42 -0
- package/dist/templates/default/convex/email.ts +109 -0
- package/dist/templates/default/convex/env.ts +31 -0
- package/dist/templates/default/convex/errors.ts +33 -0
- package/dist/templates/default/convex/functions.ts +54 -0
- package/dist/templates/default/convex/http.ts +176 -0
- package/dist/templates/default/convex/log.ts +81 -0
- package/dist/templates/default/convex/pushTokens.ts +114 -0
- package/dist/templates/default/convex/rateLimit.ts +92 -0
- package/dist/templates/default/convex/schema.ts +28 -0
- package/dist/templates/default/convex/tsconfig.json +18 -0
- package/dist/templates/default/convex/users.ts +279 -0
- package/dist/templates/default/convex/validators.ts +74 -0
- package/dist/templates/default/convex/webhook.ts +193 -0
- package/dist/templates/default/convex.json +6 -0
- package/dist/templates/default/eas.json +56 -0
- package/dist/templates/default/fingerprint.config.js +9 -0
- package/dist/templates/default/hooks/use-debounce.ts +20 -0
- package/dist/templates/default/hooks/use-deep-link.ts +43 -0
- package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
- package/dist/templates/default/hooks/use-network.ts +11 -0
- package/dist/templates/default/hooks/use-notifications.ts +107 -0
- package/dist/templates/default/hooks/use-onboarding.ts +15 -0
- package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
- package/dist/templates/default/hooks/use-theme.ts +53 -0
- package/dist/templates/default/hooks/use-updates.ts +86 -0
- package/dist/templates/default/lib/a11y.ts +5 -0
- package/dist/templates/default/lib/app.ts +14 -0
- package/dist/templates/default/lib/assets.ts +17 -0
- package/dist/templates/default/lib/auth-client.ts +21 -0
- package/dist/templates/default/lib/convex-auth.tsx +79 -0
- package/dist/templates/default/lib/deep-link.ts +71 -0
- package/dist/templates/default/lib/dev-menu.ts +119 -0
- package/dist/templates/default/lib/device.ts +40 -0
- package/dist/templates/default/lib/dynamic-font.ts +49 -0
- package/dist/templates/default/lib/env.ts +10 -0
- package/dist/templates/default/lib/haptics.ts +24 -0
- package/dist/templates/default/lib/notifications.ts +276 -0
- package/dist/templates/default/lib/preferences.ts +45 -0
- package/dist/templates/default/lib/schemas.ts +137 -0
- package/dist/templates/default/lib/storage.ts +47 -0
- package/dist/templates/default/lib/updates.ts +107 -0
- package/dist/templates/default/metro.config.js +14 -0
- package/dist/templates/default/package.json +129 -0
- package/dist/templates/default/patches/PR-368.patch +91 -0
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- package/dist/templates/default/plugins/README.md +9 -0
- package/dist/templates/default/plugins/with-auto-signing.js +45 -0
- package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
- package/dist/templates/default/scripts/README.md +36 -0
- package/dist/templates/default/scripts/_run.mjs +77 -0
- package/dist/templates/default/scripts/clean.ts +543 -0
- package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
- package/dist/templates/default/store.config.json +58 -0
- package/dist/templates/default/tsconfig.json +13 -0
- package/dist/templates/default/vitest.config.ts +21 -0
- package/package.json +69 -0
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
import { startTransition, useActionState, useEffect, useState } from "react";
|
|
2
|
+
import { Image as ExpoImage, useImage } from "expo-image";
|
|
3
|
+
import * as ImagePicker from "expo-image-picker";
|
|
4
|
+
import * as LocalAuthentication from "expo-local-authentication";
|
|
5
|
+
import { Stack } from "expo-router";
|
|
6
|
+
import { useMutation, useQuery } from "convex/react";
|
|
7
|
+
import {
|
|
8
|
+
Host,
|
|
9
|
+
ScrollView,
|
|
10
|
+
Text,
|
|
11
|
+
TextField,
|
|
12
|
+
Button,
|
|
13
|
+
HStack,
|
|
14
|
+
VStack,
|
|
15
|
+
Spacer,
|
|
16
|
+
Image,
|
|
17
|
+
RNHostView,
|
|
18
|
+
ConfirmationDialog,
|
|
19
|
+
BottomSheet,
|
|
20
|
+
Group,
|
|
21
|
+
ProgressView,
|
|
22
|
+
useNativeState,
|
|
23
|
+
} from "@expo/ui/swift-ui";
|
|
24
|
+
import {
|
|
25
|
+
autocorrectionDisabled,
|
|
26
|
+
background,
|
|
27
|
+
buttonStyle,
|
|
28
|
+
clipShape,
|
|
29
|
+
cornerRadius,
|
|
30
|
+
foregroundStyle,
|
|
31
|
+
disabled,
|
|
32
|
+
keyboardType,
|
|
33
|
+
lineLimit,
|
|
34
|
+
onSubmit,
|
|
35
|
+
submitLabel,
|
|
36
|
+
textFieldStyle,
|
|
37
|
+
textInputAutocapitalization,
|
|
38
|
+
monospacedDigit,
|
|
39
|
+
kerning,
|
|
40
|
+
multilineTextAlignment,
|
|
41
|
+
padding,
|
|
42
|
+
frame,
|
|
43
|
+
presentationDetents,
|
|
44
|
+
presentationDragIndicator,
|
|
45
|
+
progressViewStyle,
|
|
46
|
+
scrollDismissesKeyboard,
|
|
47
|
+
onTapGesture,
|
|
48
|
+
accessibilityLabel,
|
|
49
|
+
accessibilityHint,
|
|
50
|
+
tint,
|
|
51
|
+
} from "@expo/ui/swift-ui/modifiers";
|
|
52
|
+
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
53
|
+
import { Button as ButtonTokens } from "@/constants/layout";
|
|
54
|
+
|
|
55
|
+
import { api } from "@/convex/_generated/api";
|
|
56
|
+
import { authClient } from "@/lib/auth-client";
|
|
57
|
+
import { haptics } from "@/lib/haptics";
|
|
58
|
+
import { firstError, profileUpdateSchema } from "@/lib/schemas";
|
|
59
|
+
import { validateBio } from "@/convex/validators";
|
|
60
|
+
import { useColors } from "@/hooks/use-theme";
|
|
61
|
+
import { PasswordField } from "@/components/auth/password-field";
|
|
62
|
+
import { ProminentButton } from "@/components/ui/prominent-button";
|
|
63
|
+
import { ErrorText, SuccessText } from "@/components/ui/status-text";
|
|
64
|
+
import { formatError } from "@/components/ui/convex-error";
|
|
65
|
+
import { SkeletonProfile } from "@/components/ui/skeleton";
|
|
66
|
+
import { announce } from "@/lib/a11y";
|
|
67
|
+
|
|
68
|
+
const AVATAR_SIZE = 96;
|
|
69
|
+
|
|
70
|
+
type SaveState = { error?: string; success?: string; pendingEmail?: string };
|
|
71
|
+
type OtpState = { error?: string; success?: string };
|
|
72
|
+
type PasswordState = { error?: string; success?: string };
|
|
73
|
+
|
|
74
|
+
export default function ProfileScreen() {
|
|
75
|
+
const dfont = useDynamicFont();
|
|
76
|
+
const colors = useColors();
|
|
77
|
+
const me = useQuery(api.users.getMe);
|
|
78
|
+
const hasPasswordResult = useQuery(api.auth.hasPassword);
|
|
79
|
+
// Email change requires the email-OTP flow which requires Resend. In lite
|
|
80
|
+
// mode (`REQUIRE_EMAIL_VERIFICATION` unset) the email field is read-only
|
|
81
|
+
//. no way to send a verification code to the new address.
|
|
82
|
+
const providers = useQuery(api.auth.getEnabledProviders);
|
|
83
|
+
const emailFeatures = providers?.emailFeatures === true;
|
|
84
|
+
const updateProfile = useMutation(api.users.updateProfile);
|
|
85
|
+
const generateAvatarUploadUrl = useMutation(api.users.generateAvatarUploadUrl);
|
|
86
|
+
const updateAvatar = useMutation(api.users.updateAvatar);
|
|
87
|
+
const deleteAvatar = useMutation(api.users.deleteAvatar);
|
|
88
|
+
const removeAllTokens = useMutation(api.pushTokens.removeAll);
|
|
89
|
+
const deleteAccountMutation = useMutation(api.users.deleteAccount);
|
|
90
|
+
|
|
91
|
+
// SwiftUI source of truth via useNativeState; mirrored to React state via
|
|
92
|
+
// onTextChange so derived values like `hasChanges` stay reactive.
|
|
93
|
+
const nameState = useNativeState(me?.name ?? "");
|
|
94
|
+
const usernameState = useNativeState(me?.username ?? "");
|
|
95
|
+
const emailState = useNativeState(me?.email ?? "");
|
|
96
|
+
const bioState = useNativeState(me?.bio ?? "");
|
|
97
|
+
const [name, setName] = useState(me?.name ?? "");
|
|
98
|
+
const [username, setUsername] = useState(me?.username ?? "");
|
|
99
|
+
const [email, setEmail] = useState(me?.email ?? "");
|
|
100
|
+
const [bio, setBio] = useState(me?.bio ?? "");
|
|
101
|
+
|
|
102
|
+
// `currentKey` collapses `(me._id, me.updatedAt)` into one stable dep so the
|
|
103
|
+
// effect re-runs only when the row actually changes, not on every render
|
|
104
|
+
// where `me` is a new object reference. Every read inside the effect is
|
|
105
|
+
// derived from `me`, so depending on `me` itself would cause unwanted resets
|
|
106
|
+
// when other render-triggered fields (e.g. unrelated query refetches) change.
|
|
107
|
+
const currentKey = me ? `${me._id}:${me.updatedAt}` : null;
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!me) return;
|
|
110
|
+
nameState.value = me.name;
|
|
111
|
+
usernameState.value = me.username ?? "";
|
|
112
|
+
emailState.value = me.email;
|
|
113
|
+
bioState.value = me.bio ?? "";
|
|
114
|
+
setName(me.name);
|
|
115
|
+
setUsername(me.username ?? "");
|
|
116
|
+
setEmail(me.email);
|
|
117
|
+
setBio(me.bio ?? "");
|
|
118
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
119
|
+
}, [currentKey]);
|
|
120
|
+
|
|
121
|
+
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
|
|
122
|
+
const otpCodeState = useNativeState("");
|
|
123
|
+
const [otp, setOtp] = useState("");
|
|
124
|
+
const [avatarPicker, setAvatarPicker] = useState(false);
|
|
125
|
+
const [signOutConfirm, setSignOutConfirm] = useState(false);
|
|
126
|
+
const [deleteAccountConfirm, setDeleteAccountConfirm] = useState(false);
|
|
127
|
+
const [passwordSheet, setPasswordSheet] = useState(false);
|
|
128
|
+
const currentPasswordState = useNativeState("");
|
|
129
|
+
const newPasswordState = useNativeState("");
|
|
130
|
+
const confirmPasswordState = useNativeState("");
|
|
131
|
+
const [currentPassword, setCurrentPassword] = useState("");
|
|
132
|
+
const [newPassword, setNewPassword] = useState("");
|
|
133
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
134
|
+
|
|
135
|
+
const hasChanges =
|
|
136
|
+
!!me &&
|
|
137
|
+
(name.trim() !== me.name ||
|
|
138
|
+
username.trim().toLowerCase() !== (me.username ?? "") ||
|
|
139
|
+
email.trim().toLowerCase() !== me.email.toLowerCase() ||
|
|
140
|
+
bio !== (me.bio ?? ""));
|
|
141
|
+
|
|
142
|
+
const [saveState, save, isSaving] = useActionState<SaveState, void>(async () => {
|
|
143
|
+
if (!me) return { error: "Not loaded" };
|
|
144
|
+
haptics.light();
|
|
145
|
+
|
|
146
|
+
const parsed = profileUpdateSchema.safeParse({ name, username, email });
|
|
147
|
+
if (!parsed.success) {
|
|
148
|
+
haptics.error();
|
|
149
|
+
return { error: firstError(parsed)! };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const trimmedBio = bio.trim();
|
|
153
|
+
const bioCheck = validateBio(trimmedBio);
|
|
154
|
+
if (!bioCheck.valid) {
|
|
155
|
+
haptics.error();
|
|
156
|
+
return { error: bioCheck.error! };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { name: nextName, username: nextUsername, email: nextEmail } = parsed.data;
|
|
160
|
+
const nameChanged = nextName !== me.name;
|
|
161
|
+
const usernameChanged = nextUsername !== (me.username ?? "");
|
|
162
|
+
const emailChanged = nextEmail !== me.email.toLowerCase();
|
|
163
|
+
const bioChanged = trimmedBio !== (me.bio ?? "");
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
if (nameChanged || usernameChanged) {
|
|
167
|
+
const updates: Record<string, string> = {};
|
|
168
|
+
if (nameChanged) updates.name = nextName;
|
|
169
|
+
if (usernameChanged) updates.username = nextUsername;
|
|
170
|
+
const res = await authClient.updateUser(updates);
|
|
171
|
+
if (res.error) {
|
|
172
|
+
haptics.error();
|
|
173
|
+
return { error: res.error.message ?? "Failed to update profile" };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (bioChanged) {
|
|
178
|
+
await updateProfile({ bio: trimmedBio.length === 0 ? undefined : trimmedBio });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (emailChanged) {
|
|
182
|
+
const res = await authClient.changeEmail({ newEmail: nextEmail });
|
|
183
|
+
if (res.error) {
|
|
184
|
+
haptics.error();
|
|
185
|
+
return { error: res.error.message ?? "Failed to update email" };
|
|
186
|
+
}
|
|
187
|
+
haptics.light();
|
|
188
|
+
setPendingEmail(nextEmail);
|
|
189
|
+
setOtp("");
|
|
190
|
+
return { pendingEmail: nextEmail };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
haptics.success();
|
|
194
|
+
announce("Profile saved");
|
|
195
|
+
return { success: "Saved" };
|
|
196
|
+
} catch (err) {
|
|
197
|
+
haptics.error();
|
|
198
|
+
return { error: formatError(err) };
|
|
199
|
+
}
|
|
200
|
+
}, {} as SaveState);
|
|
201
|
+
|
|
202
|
+
const [otpState, verifyOtp, isVerifying] = useActionState<OtpState, void>(async () => {
|
|
203
|
+
haptics.light();
|
|
204
|
+
if (!pendingEmail || otp.length !== 6) {
|
|
205
|
+
haptics.error();
|
|
206
|
+
return { error: "Enter the 6-digit code" };
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const res = await authClient.emailOtp.verifyEmail({ email: pendingEmail, otp });
|
|
210
|
+
if (res.error) {
|
|
211
|
+
haptics.error();
|
|
212
|
+
return { error: "Invalid or expired code" };
|
|
213
|
+
}
|
|
214
|
+
haptics.success();
|
|
215
|
+
announce("Email updated");
|
|
216
|
+
setPendingEmail(null);
|
|
217
|
+
setOtp("");
|
|
218
|
+
return { success: "Email updated" };
|
|
219
|
+
} catch {
|
|
220
|
+
haptics.error();
|
|
221
|
+
return { error: "Verification failed" };
|
|
222
|
+
}
|
|
223
|
+
}, {} as OtpState);
|
|
224
|
+
|
|
225
|
+
const [avatarUpdating, setAvatarUpdating] = useState(false);
|
|
226
|
+
const [avatarError, setAvatarError] = useState<string | null>(null);
|
|
227
|
+
|
|
228
|
+
const pickAvatar = async (source: "library" | "camera") => {
|
|
229
|
+
setAvatarPicker(false);
|
|
230
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
231
|
+
const perm =
|
|
232
|
+
source === "camera"
|
|
233
|
+
? await ImagePicker.requestCameraPermissionsAsync()
|
|
234
|
+
: await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
235
|
+
if (!perm.granted) {
|
|
236
|
+
setAvatarError(source === "camera" ? "Camera access denied" : "Photos access denied");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
haptics.light();
|
|
240
|
+
const options: ImagePicker.ImagePickerOptions = {
|
|
241
|
+
mediaTypes: ["images"],
|
|
242
|
+
allowsEditing: true,
|
|
243
|
+
aspect: [1, 1],
|
|
244
|
+
quality: 0.8,
|
|
245
|
+
};
|
|
246
|
+
const result =
|
|
247
|
+
source === "camera"
|
|
248
|
+
? await ImagePicker.launchCameraAsync(options)
|
|
249
|
+
: await ImagePicker.launchImageLibraryAsync(options);
|
|
250
|
+
|
|
251
|
+
if (result.canceled) return;
|
|
252
|
+
const asset = result.assets[0];
|
|
253
|
+
if (!asset) return;
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
setAvatarError(null);
|
|
257
|
+
setAvatarUpdating(true);
|
|
258
|
+
const uploadUrl = await generateAvatarUploadUrl();
|
|
259
|
+
const blob = await (await fetch(asset.uri)).blob();
|
|
260
|
+
const upload = await fetch(uploadUrl, {
|
|
261
|
+
method: "POST",
|
|
262
|
+
headers: { "Content-Type": asset.mimeType ?? "image/jpeg" },
|
|
263
|
+
body: blob,
|
|
264
|
+
});
|
|
265
|
+
if (!upload.ok) throw new Error(`Upload failed: ${upload.status}`);
|
|
266
|
+
const { storageId } = (await upload.json()) as { storageId: string };
|
|
267
|
+
await updateAvatar({ storageId: storageId as never });
|
|
268
|
+
haptics.success();
|
|
269
|
+
announce("Profile photo updated");
|
|
270
|
+
} catch (err) {
|
|
271
|
+
haptics.error();
|
|
272
|
+
setAvatarError(formatError(err));
|
|
273
|
+
} finally {
|
|
274
|
+
setAvatarUpdating(false);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const removeAvatar = async () => {
|
|
279
|
+
setAvatarPicker(false);
|
|
280
|
+
haptics.medium();
|
|
281
|
+
try {
|
|
282
|
+
setAvatarError(null);
|
|
283
|
+
setAvatarUpdating(true);
|
|
284
|
+
await deleteAvatar();
|
|
285
|
+
haptics.success();
|
|
286
|
+
announce("Profile photo removed");
|
|
287
|
+
} catch (err) {
|
|
288
|
+
haptics.error();
|
|
289
|
+
setAvatarError(formatError(err));
|
|
290
|
+
} finally {
|
|
291
|
+
setAvatarUpdating(false);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const handleSignOut = async () => {
|
|
296
|
+
haptics.medium();
|
|
297
|
+
// Push-token cleanup is best-effort. A stale token gets garbage-collected
|
|
298
|
+
// by `pushTokens.cleanupStale` after 30 days, so don't gate sign-out on it.
|
|
299
|
+
try {
|
|
300
|
+
await removeAllTokens();
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if (__DEV__) console.warn("[signOut] removeAllTokens failed:", err);
|
|
303
|
+
}
|
|
304
|
+
await authClient.signOut();
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const handleDeleteAccount = async () => {
|
|
308
|
+
haptics.error();
|
|
309
|
+
const result = await LocalAuthentication.authenticateAsync({
|
|
310
|
+
promptMessage: "Confirm with Face ID",
|
|
311
|
+
});
|
|
312
|
+
if (!result.success) return;
|
|
313
|
+
await deleteAccountMutation();
|
|
314
|
+
await authClient.signOut();
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const [passwordState, changePassword, isChangingPassword] = useActionState<PasswordState, void>(
|
|
318
|
+
async () => {
|
|
319
|
+
haptics.light();
|
|
320
|
+
if (!currentPassword || !newPassword || !confirmPassword) {
|
|
321
|
+
haptics.error();
|
|
322
|
+
return { error: "Fill in every field" };
|
|
323
|
+
}
|
|
324
|
+
if (newPassword.length < 10 || newPassword.length > 128) {
|
|
325
|
+
haptics.error();
|
|
326
|
+
return { error: "Password must be 10-128 characters" };
|
|
327
|
+
}
|
|
328
|
+
if (newPassword !== confirmPassword) {
|
|
329
|
+
haptics.error();
|
|
330
|
+
return { error: "Passwords do not match" };
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const res = await authClient.changePassword({
|
|
334
|
+
currentPassword,
|
|
335
|
+
newPassword,
|
|
336
|
+
revokeOtherSessions: true,
|
|
337
|
+
});
|
|
338
|
+
if (res.error) {
|
|
339
|
+
haptics.error();
|
|
340
|
+
return { error: res.error.message ?? "Failed to change password" };
|
|
341
|
+
}
|
|
342
|
+
haptics.success();
|
|
343
|
+
announce("Password changed. Other sessions have been signed out.");
|
|
344
|
+
currentPasswordState.value = "";
|
|
345
|
+
newPasswordState.value = "";
|
|
346
|
+
confirmPasswordState.value = "";
|
|
347
|
+
setCurrentPassword("");
|
|
348
|
+
setNewPassword("");
|
|
349
|
+
setConfirmPassword("");
|
|
350
|
+
setPasswordSheet(false);
|
|
351
|
+
return { success: "Password updated. Other sessions have been signed out." };
|
|
352
|
+
} catch {
|
|
353
|
+
haptics.error();
|
|
354
|
+
return { error: "An unexpected error occurred" };
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
{} as PasswordState,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const error = saveState.error ?? otpState.error ?? passwordState.error ?? avatarError;
|
|
361
|
+
const success = saveState.success ?? otpState.success ?? passwordState.success;
|
|
362
|
+
|
|
363
|
+
if (!me) {
|
|
364
|
+
return (
|
|
365
|
+
<Host style={{ flex: 1, backgroundColor: colors.background }}>
|
|
366
|
+
<SkeletonProfile />
|
|
367
|
+
</Host>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const labelModifiers = [dfont({ size: 17, weight: "semibold" })];
|
|
372
|
+
const helperModifiers = [dfont({ size: 13 }), foregroundStyle(colors.mutedForeground as string)];
|
|
373
|
+
const inputModifiers = [
|
|
374
|
+
textFieldStyle("plain"),
|
|
375
|
+
padding({ horizontal: 16 }),
|
|
376
|
+
frame({ maxWidth: Infinity, height: ButtonTokens.height }),
|
|
377
|
+
background(colors.muted as string),
|
|
378
|
+
clipShape("capsule"),
|
|
379
|
+
dfont({ size: 16 }),
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<>
|
|
384
|
+
<Stack.Toolbar placement="right">
|
|
385
|
+
<Stack.Toolbar.Button
|
|
386
|
+
icon="checkmark.circle.fill"
|
|
387
|
+
onPress={() => startTransition(() => save())}
|
|
388
|
+
disabled={!hasChanges || isSaving}
|
|
389
|
+
tintColor={colors.primary}
|
|
390
|
+
accessibilityLabel="Save"
|
|
391
|
+
/>
|
|
392
|
+
</Stack.Toolbar>
|
|
393
|
+
|
|
394
|
+
<Host style={{ flex: 1, backgroundColor: colors.background }}>
|
|
395
|
+
<ScrollView
|
|
396
|
+
modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
|
|
397
|
+
>
|
|
398
|
+
<VStack
|
|
399
|
+
spacing={20}
|
|
400
|
+
alignment="leading"
|
|
401
|
+
modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
|
|
402
|
+
>
|
|
403
|
+
<ConfirmationDialog
|
|
404
|
+
title="Profile photo"
|
|
405
|
+
isPresented={avatarPicker}
|
|
406
|
+
onIsPresentedChange={setAvatarPicker}
|
|
407
|
+
titleVisibility="visible"
|
|
408
|
+
>
|
|
409
|
+
<ConfirmationDialog.Trigger>
|
|
410
|
+
<HStack
|
|
411
|
+
spacing={16}
|
|
412
|
+
alignment="center"
|
|
413
|
+
modifiers={[
|
|
414
|
+
frame({ maxWidth: 10000 }),
|
|
415
|
+
onTapGesture(() => {
|
|
416
|
+
haptics.light();
|
|
417
|
+
setAvatarPicker(true);
|
|
418
|
+
}),
|
|
419
|
+
accessibilityLabel("Change profile photo"),
|
|
420
|
+
]}
|
|
421
|
+
>
|
|
422
|
+
<AvatarView avatarUrl={me.avatarUrl} loading={avatarUpdating} />
|
|
423
|
+
<VStack alignment="leading" spacing={4}>
|
|
424
|
+
<Text modifiers={[dfont({ size: 17, weight: "semibold" })]}>{me.name}</Text>
|
|
425
|
+
<Text
|
|
426
|
+
modifiers={[
|
|
427
|
+
dfont({ size: 14 }),
|
|
428
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
429
|
+
]}
|
|
430
|
+
>
|
|
431
|
+
{me.email}
|
|
432
|
+
</Text>
|
|
433
|
+
</VStack>
|
|
434
|
+
<Spacer />
|
|
435
|
+
<Image
|
|
436
|
+
systemName="camera.circle.fill"
|
|
437
|
+
size={28}
|
|
438
|
+
color={colors.primary as string}
|
|
439
|
+
/>
|
|
440
|
+
</HStack>
|
|
441
|
+
</ConfirmationDialog.Trigger>
|
|
442
|
+
<ConfirmationDialog.Actions>
|
|
443
|
+
<Button
|
|
444
|
+
label="Choose Photo"
|
|
445
|
+
systemImage="photo.on.rectangle"
|
|
446
|
+
onPress={() => pickAvatar("library")}
|
|
447
|
+
/>
|
|
448
|
+
<Button
|
|
449
|
+
label="Take Photo"
|
|
450
|
+
systemImage="camera"
|
|
451
|
+
onPress={() => pickAvatar("camera")}
|
|
452
|
+
/>
|
|
453
|
+
{me.hasUploadedAvatar && (
|
|
454
|
+
<Button label="Remove Photo" role="destructive" onPress={removeAvatar} />
|
|
455
|
+
)}
|
|
456
|
+
<Button label="Cancel" role="cancel" />
|
|
457
|
+
</ConfirmationDialog.Actions>
|
|
458
|
+
</ConfirmationDialog>
|
|
459
|
+
|
|
460
|
+
{error ? <ErrorText>{error}</ErrorText> : null}
|
|
461
|
+
{success && !pendingEmail ? <SuccessText>{success}</SuccessText> : null}
|
|
462
|
+
|
|
463
|
+
{pendingEmail ? (
|
|
464
|
+
<>
|
|
465
|
+
<VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
466
|
+
<Text modifiers={labelModifiers}>Verify new email</Text>
|
|
467
|
+
<TextField
|
|
468
|
+
text={otpCodeState}
|
|
469
|
+
placeholder="000000"
|
|
470
|
+
onTextChange={(text) => {
|
|
471
|
+
const digits = text.replace(/\D/g, "").slice(0, 6);
|
|
472
|
+
if (digits !== text) otpCodeState.value = digits;
|
|
473
|
+
setOtp(digits);
|
|
474
|
+
}}
|
|
475
|
+
autoFocus
|
|
476
|
+
modifiers={[
|
|
477
|
+
...inputModifiers,
|
|
478
|
+
keyboardType("numeric"),
|
|
479
|
+
onSubmit(() => startTransition(() => verifyOtp())),
|
|
480
|
+
dfont({ size: 24, design: "monospaced" }),
|
|
481
|
+
monospacedDigit(),
|
|
482
|
+
kerning(8),
|
|
483
|
+
multilineTextAlignment("center"),
|
|
484
|
+
submitLabel("done"),
|
|
485
|
+
disabled(isVerifying),
|
|
486
|
+
accessibilityLabel("Verification code"),
|
|
487
|
+
accessibilityHint("Enter the 6 digit code sent to your new email"),
|
|
488
|
+
]}
|
|
489
|
+
/>
|
|
490
|
+
<Text modifiers={helperModifiers}>
|
|
491
|
+
A 6-digit code was sent to {pendingEmail}.
|
|
492
|
+
</Text>
|
|
493
|
+
</VStack>
|
|
494
|
+
|
|
495
|
+
<ProminentButton
|
|
496
|
+
label={isVerifying ? "Verifying..." : "Verify"}
|
|
497
|
+
onPress={() => startTransition(() => verifyOtp())}
|
|
498
|
+
disabled={isVerifying || otp.length !== 6}
|
|
499
|
+
/>
|
|
500
|
+
|
|
501
|
+
<VStack alignment="center" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
502
|
+
<Button
|
|
503
|
+
label="Cancel"
|
|
504
|
+
modifiers={[
|
|
505
|
+
buttonStyle("plain"),
|
|
506
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
507
|
+
dfont({ size: 14, weight: "semibold" }),
|
|
508
|
+
disabled(isVerifying),
|
|
509
|
+
]}
|
|
510
|
+
onPress={() => {
|
|
511
|
+
setPendingEmail(null);
|
|
512
|
+
setOtp("");
|
|
513
|
+
}}
|
|
514
|
+
/>
|
|
515
|
+
</VStack>
|
|
516
|
+
</>
|
|
517
|
+
) : (
|
|
518
|
+
<>
|
|
519
|
+
<VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
520
|
+
<Text modifiers={labelModifiers}>Name</Text>
|
|
521
|
+
<TextField
|
|
522
|
+
text={nameState}
|
|
523
|
+
placeholder="Name"
|
|
524
|
+
onTextChange={setName}
|
|
525
|
+
modifiers={[
|
|
526
|
+
...inputModifiers,
|
|
527
|
+
textInputAutocapitalization("words"),
|
|
528
|
+
disabled(isSaving),
|
|
529
|
+
submitLabel("next"),
|
|
530
|
+
accessibilityLabel("Name"),
|
|
531
|
+
accessibilityHint("Edit the display name on your account"),
|
|
532
|
+
]}
|
|
533
|
+
/>
|
|
534
|
+
</VStack>
|
|
535
|
+
|
|
536
|
+
<VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
537
|
+
<Text modifiers={labelModifiers}>Username</Text>
|
|
538
|
+
<TextField
|
|
539
|
+
text={usernameState}
|
|
540
|
+
placeholder="johndoe"
|
|
541
|
+
onTextChange={(v) => setUsername(v.toLowerCase())}
|
|
542
|
+
modifiers={[
|
|
543
|
+
...inputModifiers,
|
|
544
|
+
keyboardType("ascii-capable"),
|
|
545
|
+
autocorrectionDisabled(),
|
|
546
|
+
textInputAutocapitalization("never"),
|
|
547
|
+
disabled(isSaving),
|
|
548
|
+
submitLabel("next"),
|
|
549
|
+
accessibilityLabel("Username"),
|
|
550
|
+
accessibilityHint("Edit the username for your profile"),
|
|
551
|
+
]}
|
|
552
|
+
/>
|
|
553
|
+
<Text modifiers={helperModifiers}>
|
|
554
|
+
Name and username are visible to other users.
|
|
555
|
+
</Text>
|
|
556
|
+
</VStack>
|
|
557
|
+
|
|
558
|
+
<VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
559
|
+
<Text modifiers={labelModifiers}>Email</Text>
|
|
560
|
+
<TextField
|
|
561
|
+
text={emailState}
|
|
562
|
+
placeholder="you@example.com"
|
|
563
|
+
onTextChange={setEmail}
|
|
564
|
+
modifiers={[
|
|
565
|
+
...inputModifiers,
|
|
566
|
+
keyboardType("email-address"),
|
|
567
|
+
autocorrectionDisabled(),
|
|
568
|
+
textInputAutocapitalization("never"),
|
|
569
|
+
disabled(isSaving || !emailFeatures),
|
|
570
|
+
submitLabel("next"),
|
|
571
|
+
accessibilityLabel("Email address"),
|
|
572
|
+
accessibilityHint(
|
|
573
|
+
emailFeatures
|
|
574
|
+
? "Edit the email address for your account"
|
|
575
|
+
: "Email change is disabled until email verification is configured",
|
|
576
|
+
),
|
|
577
|
+
]}
|
|
578
|
+
/>
|
|
579
|
+
<Text modifiers={helperModifiers}>
|
|
580
|
+
{emailFeatures
|
|
581
|
+
? "Changing your email requires verifying the new address with a 6-digit code."
|
|
582
|
+
: "Email change requires Resend setup. Run `bunx vexpo full` to enable."}
|
|
583
|
+
</Text>
|
|
584
|
+
</VStack>
|
|
585
|
+
|
|
586
|
+
<VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
587
|
+
<Text modifiers={labelModifiers}>Bio</Text>
|
|
588
|
+
<TextField
|
|
589
|
+
text={bioState}
|
|
590
|
+
placeholder="Tell others about yourself"
|
|
591
|
+
onTextChange={setBio}
|
|
592
|
+
axis="vertical"
|
|
593
|
+
modifiers={[
|
|
594
|
+
textFieldStyle("plain"),
|
|
595
|
+
padding({ horizontal: 16, vertical: 12 }),
|
|
596
|
+
frame({ maxWidth: Infinity }),
|
|
597
|
+
background(colors.muted as string),
|
|
598
|
+
cornerRadius(20),
|
|
599
|
+
dfont({ size: 16 }),
|
|
600
|
+
lineLimit({ min: 1, max: 4 }),
|
|
601
|
+
disabled(isSaving),
|
|
602
|
+
submitLabel("done"),
|
|
603
|
+
accessibilityLabel("Bio"),
|
|
604
|
+
accessibilityHint("Up to 500 characters describing yourself"),
|
|
605
|
+
]}
|
|
606
|
+
/>
|
|
607
|
+
<Text modifiers={helperModifiers}>
|
|
608
|
+
Up to 500 characters. Visible on your public profile.
|
|
609
|
+
</Text>
|
|
610
|
+
</VStack>
|
|
611
|
+
|
|
612
|
+
<VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
613
|
+
<Text modifiers={labelModifiers}>Member since</Text>
|
|
614
|
+
<Text
|
|
615
|
+
modifiers={[
|
|
616
|
+
dfont({ size: 16 }),
|
|
617
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
618
|
+
]}
|
|
619
|
+
>
|
|
620
|
+
{formatDate(me.createdAt)}
|
|
621
|
+
</Text>
|
|
622
|
+
</VStack>
|
|
623
|
+
|
|
624
|
+
{hasChanges ? (
|
|
625
|
+
<ProminentButton
|
|
626
|
+
label={isSaving ? "Saving..." : "Save changes"}
|
|
627
|
+
onPress={() => startTransition(() => save())}
|
|
628
|
+
disabled={isSaving}
|
|
629
|
+
/>
|
|
630
|
+
) : null}
|
|
631
|
+
|
|
632
|
+
{hasPasswordResult ? (
|
|
633
|
+
<Button
|
|
634
|
+
modifiers={[
|
|
635
|
+
buttonStyle("plain"),
|
|
636
|
+
frame({ maxWidth: 10000 }),
|
|
637
|
+
background(colors.muted as string),
|
|
638
|
+
clipShape("capsule"),
|
|
639
|
+
]}
|
|
640
|
+
onPress={() => {
|
|
641
|
+
haptics.light();
|
|
642
|
+
setPasswordSheet(true);
|
|
643
|
+
}}
|
|
644
|
+
>
|
|
645
|
+
<Text
|
|
646
|
+
modifiers={[
|
|
647
|
+
frame({ maxWidth: 10000, height: ButtonTokens.height }),
|
|
648
|
+
multilineTextAlignment("center"),
|
|
649
|
+
dfont({
|
|
650
|
+
size: ButtonTokens.fontSize,
|
|
651
|
+
weight: ButtonTokens.secondaryFontWeight,
|
|
652
|
+
}),
|
|
653
|
+
foregroundStyle(colors.foreground as string),
|
|
654
|
+
]}
|
|
655
|
+
>
|
|
656
|
+
Change password
|
|
657
|
+
</Text>
|
|
658
|
+
</Button>
|
|
659
|
+
) : null}
|
|
660
|
+
|
|
661
|
+
<ConfirmationDialog
|
|
662
|
+
title="Sign out?"
|
|
663
|
+
isPresented={signOutConfirm}
|
|
664
|
+
onIsPresentedChange={setSignOutConfirm}
|
|
665
|
+
titleVisibility="visible"
|
|
666
|
+
>
|
|
667
|
+
<ConfirmationDialog.Trigger>
|
|
668
|
+
<Button
|
|
669
|
+
modifiers={[
|
|
670
|
+
buttonStyle("plain"),
|
|
671
|
+
frame({ maxWidth: 10000 }),
|
|
672
|
+
background(colors.muted as string),
|
|
673
|
+
clipShape("capsule"),
|
|
674
|
+
]}
|
|
675
|
+
onPress={() => setSignOutConfirm(true)}
|
|
676
|
+
>
|
|
677
|
+
<Text
|
|
678
|
+
modifiers={[
|
|
679
|
+
frame({ maxWidth: 10000, height: ButtonTokens.height }),
|
|
680
|
+
multilineTextAlignment("center"),
|
|
681
|
+
dfont({
|
|
682
|
+
size: ButtonTokens.fontSize,
|
|
683
|
+
weight: ButtonTokens.secondaryFontWeight,
|
|
684
|
+
}),
|
|
685
|
+
foregroundStyle(colors.destructive as string),
|
|
686
|
+
]}
|
|
687
|
+
>
|
|
688
|
+
Sign out
|
|
689
|
+
</Text>
|
|
690
|
+
</Button>
|
|
691
|
+
</ConfirmationDialog.Trigger>
|
|
692
|
+
<ConfirmationDialog.Actions>
|
|
693
|
+
<Button label="Sign Out" role="destructive" onPress={handleSignOut} />
|
|
694
|
+
<Button label="Cancel" role="cancel" />
|
|
695
|
+
</ConfirmationDialog.Actions>
|
|
696
|
+
<ConfirmationDialog.Message>
|
|
697
|
+
<Text modifiers={[dfont({ size: 16 })]}>
|
|
698
|
+
You will need to sign in again to access your account.
|
|
699
|
+
</Text>
|
|
700
|
+
</ConfirmationDialog.Message>
|
|
701
|
+
</ConfirmationDialog>
|
|
702
|
+
|
|
703
|
+
<ConfirmationDialog
|
|
704
|
+
title="Delete account?"
|
|
705
|
+
isPresented={deleteAccountConfirm}
|
|
706
|
+
onIsPresentedChange={setDeleteAccountConfirm}
|
|
707
|
+
titleVisibility="visible"
|
|
708
|
+
>
|
|
709
|
+
<ConfirmationDialog.Trigger>
|
|
710
|
+
<Button
|
|
711
|
+
modifiers={[
|
|
712
|
+
buttonStyle("plain"),
|
|
713
|
+
frame({ maxWidth: 10000 }),
|
|
714
|
+
clipShape("capsule"),
|
|
715
|
+
]}
|
|
716
|
+
onPress={() => setDeleteAccountConfirm(true)}
|
|
717
|
+
>
|
|
718
|
+
<Text
|
|
719
|
+
modifiers={[
|
|
720
|
+
frame({ maxWidth: 10000, height: ButtonTokens.height }),
|
|
721
|
+
multilineTextAlignment("center"),
|
|
722
|
+
dfont({
|
|
723
|
+
size: ButtonTokens.fontSize,
|
|
724
|
+
weight: ButtonTokens.secondaryFontWeight,
|
|
725
|
+
}),
|
|
726
|
+
foregroundStyle(colors.destructive as string),
|
|
727
|
+
]}
|
|
728
|
+
>
|
|
729
|
+
Delete account
|
|
730
|
+
</Text>
|
|
731
|
+
</Button>
|
|
732
|
+
</ConfirmationDialog.Trigger>
|
|
733
|
+
<ConfirmationDialog.Actions>
|
|
734
|
+
<Button
|
|
735
|
+
label="Delete Forever"
|
|
736
|
+
role="destructive"
|
|
737
|
+
onPress={handleDeleteAccount}
|
|
738
|
+
/>
|
|
739
|
+
<Button label="Cancel" role="cancel" />
|
|
740
|
+
</ConfirmationDialog.Actions>
|
|
741
|
+
<ConfirmationDialog.Message>
|
|
742
|
+
<Text modifiers={[dfont({ size: 16 })]}>
|
|
743
|
+
This permanently deletes your account and all data. This cannot be undone.
|
|
744
|
+
</Text>
|
|
745
|
+
</ConfirmationDialog.Message>
|
|
746
|
+
</ConfirmationDialog>
|
|
747
|
+
</>
|
|
748
|
+
)}
|
|
749
|
+
</VStack>
|
|
750
|
+
</ScrollView>
|
|
751
|
+
|
|
752
|
+
<BottomSheet isPresented={passwordSheet} onIsPresentedChange={setPasswordSheet}>
|
|
753
|
+
<Group
|
|
754
|
+
modifiers={[presentationDetents(["medium"]), presentationDragIndicator("visible")]}
|
|
755
|
+
>
|
|
756
|
+
<Host style={{ flex: 1, backgroundColor: colors.background }}>
|
|
757
|
+
<ScrollView
|
|
758
|
+
modifiers={[
|
|
759
|
+
scrollDismissesKeyboard("interactively"),
|
|
760
|
+
tint(colors.primary as string),
|
|
761
|
+
]}
|
|
762
|
+
>
|
|
763
|
+
<VStack
|
|
764
|
+
spacing={20}
|
|
765
|
+
alignment="leading"
|
|
766
|
+
modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
|
|
767
|
+
>
|
|
768
|
+
<VStack spacing={6} alignment="leading">
|
|
769
|
+
<Text modifiers={[dfont({ size: 22, weight: "bold" })]}>Change password</Text>
|
|
770
|
+
<Text
|
|
771
|
+
modifiers={[
|
|
772
|
+
dfont({ size: 14 }),
|
|
773
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
774
|
+
]}
|
|
775
|
+
>
|
|
776
|
+
Other devices will be signed out.
|
|
777
|
+
</Text>
|
|
778
|
+
</VStack>
|
|
779
|
+
|
|
780
|
+
<VStack
|
|
781
|
+
spacing={6}
|
|
782
|
+
alignment="leading"
|
|
783
|
+
modifiers={[frame({ maxWidth: Infinity })]}
|
|
784
|
+
>
|
|
785
|
+
<Text modifiers={labelModifiers}>Current password</Text>
|
|
786
|
+
<PasswordField
|
|
787
|
+
text={currentPasswordState}
|
|
788
|
+
onTextChange={setCurrentPassword}
|
|
789
|
+
disabled={isChangingPassword}
|
|
790
|
+
submitLabelType="next"
|
|
791
|
+
accessibilityLabel="Current password"
|
|
792
|
+
accessibilityHint="Enter your existing password"
|
|
793
|
+
/>
|
|
794
|
+
</VStack>
|
|
795
|
+
|
|
796
|
+
<VStack
|
|
797
|
+
spacing={6}
|
|
798
|
+
alignment="leading"
|
|
799
|
+
modifiers={[frame({ maxWidth: Infinity })]}
|
|
800
|
+
>
|
|
801
|
+
<Text modifiers={labelModifiers}>New password</Text>
|
|
802
|
+
<PasswordField
|
|
803
|
+
text={newPasswordState}
|
|
804
|
+
onTextChange={setNewPassword}
|
|
805
|
+
disabled={isChangingPassword}
|
|
806
|
+
submitLabelType="next"
|
|
807
|
+
accessibilityLabel="New password"
|
|
808
|
+
accessibilityHint="Choose a new password with at least 10 characters"
|
|
809
|
+
/>
|
|
810
|
+
<Text modifiers={helperModifiers}>At least 10 characters.</Text>
|
|
811
|
+
</VStack>
|
|
812
|
+
|
|
813
|
+
<VStack
|
|
814
|
+
spacing={6}
|
|
815
|
+
alignment="leading"
|
|
816
|
+
modifiers={[frame({ maxWidth: Infinity })]}
|
|
817
|
+
>
|
|
818
|
+
<Text modifiers={labelModifiers}>Confirm new password</Text>
|
|
819
|
+
<PasswordField
|
|
820
|
+
text={confirmPasswordState}
|
|
821
|
+
onTextChange={setConfirmPassword}
|
|
822
|
+
onSubmit={() => startTransition(() => changePassword())}
|
|
823
|
+
disabled={isChangingPassword}
|
|
824
|
+
accessibilityLabel="Confirm new password"
|
|
825
|
+
accessibilityHint="Re-enter the new password to confirm"
|
|
826
|
+
/>
|
|
827
|
+
</VStack>
|
|
828
|
+
|
|
829
|
+
{passwordState.error ? <ErrorText>{passwordState.error}</ErrorText> : null}
|
|
830
|
+
|
|
831
|
+
<ProminentButton
|
|
832
|
+
label={isChangingPassword ? "Updating..." : "Update password"}
|
|
833
|
+
onPress={() => startTransition(() => changePassword())}
|
|
834
|
+
disabled={isChangingPassword}
|
|
835
|
+
/>
|
|
836
|
+
|
|
837
|
+
<VStack alignment="center" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
838
|
+
<Button
|
|
839
|
+
label="Cancel"
|
|
840
|
+
modifiers={[
|
|
841
|
+
buttonStyle("plain"),
|
|
842
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
843
|
+
dfont({ size: 14, weight: "semibold" }),
|
|
844
|
+
disabled(isChangingPassword),
|
|
845
|
+
]}
|
|
846
|
+
onPress={() => setPasswordSheet(false)}
|
|
847
|
+
/>
|
|
848
|
+
</VStack>
|
|
849
|
+
</VStack>
|
|
850
|
+
</ScrollView>
|
|
851
|
+
</Host>
|
|
852
|
+
</Group>
|
|
853
|
+
</BottomSheet>
|
|
854
|
+
</Host>
|
|
855
|
+
</>
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function AvatarView({ avatarUrl, loading }: { avatarUrl: string | null; loading: boolean }) {
|
|
860
|
+
const colors = useColors();
|
|
861
|
+
if (loading) {
|
|
862
|
+
return (
|
|
863
|
+
<VStack
|
|
864
|
+
alignment="center"
|
|
865
|
+
modifiers={[frame({ width: AVATAR_SIZE, height: AVATAR_SIZE }), clipShape("circle")]}
|
|
866
|
+
>
|
|
867
|
+
<ProgressView modifiers={[progressViewStyle("circular")]} />
|
|
868
|
+
</VStack>
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
if (avatarUrl) {
|
|
872
|
+
return <RemoteAvatar key={avatarUrl} url={avatarUrl} size={AVATAR_SIZE} />;
|
|
873
|
+
}
|
|
874
|
+
return (
|
|
875
|
+
<Image
|
|
876
|
+
systemName="person.crop.circle.fill"
|
|
877
|
+
size={AVATAR_SIZE}
|
|
878
|
+
color={colors.mutedForeground as string}
|
|
879
|
+
modifiers={[frame({ width: AVATAR_SIZE, height: AVATAR_SIZE })]}
|
|
880
|
+
/>
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function RemoteAvatar({ url, size }: { url: string; size: number }) {
|
|
885
|
+
const colors = useColors();
|
|
886
|
+
const image = useImage(url, { maxWidth: size * 4 });
|
|
887
|
+
if (!image) {
|
|
888
|
+
return (
|
|
889
|
+
<Image
|
|
890
|
+
systemName="person.crop.circle.fill"
|
|
891
|
+
size={size}
|
|
892
|
+
color={colors.mutedForeground as string}
|
|
893
|
+
modifiers={[frame({ width: size, height: size })]}
|
|
894
|
+
/>
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
return (
|
|
898
|
+
<RNHostView matchContents>
|
|
899
|
+
<ExpoImage
|
|
900
|
+
source={image}
|
|
901
|
+
style={{ width: size, height: size, borderRadius: size / 2 }}
|
|
902
|
+
contentFit="cover"
|
|
903
|
+
accessibilityLabel="Profile photo"
|
|
904
|
+
/>
|
|
905
|
+
</RNHostView>
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function formatDate(ms: number): string {
|
|
910
|
+
return new Date(ms).toLocaleDateString(undefined, {
|
|
911
|
+
year: "numeric",
|
|
912
|
+
month: "long",
|
|
913
|
+
day: "numeric",
|
|
914
|
+
});
|
|
915
|
+
}
|