Skip to content

Commit c17d1e9

Browse files
authored
feat(onboarding): implement username on onboarding and profile contact card (#1619)
1 parent 592e3a8 commit c17d1e9

File tree

13 files changed

+243
-143
lines changed

13 files changed

+243
-143
lines changed

features/authentication/authentication.api.ts

+47-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
1+
import { z } from "zod";
2+
import { profileValidationSchema } from "@/features/profiles/schemas/profile-validation.schema";
13
import { api } from "@/utils/api/api";
24
import { handleApiError } from "@/utils/api/api.error";
35
import { buildDeviceMetadata } from "@/utils/device-metadata";
4-
import { z } from "zod";
56

67
const deviceOSEnum = z.enum(["android", "ios", "web"]);
78

9+
const createUserRequestSchema = z
10+
.object({
11+
privyUserId: z.string(),
12+
device: z.object({
13+
os: deviceOSEnum,
14+
name: z.string().nullable(),
15+
}),
16+
identity: z.object({
17+
privyAddress: z.string(),
18+
xmtpId: z.string(),
19+
}),
20+
profile: profileValidationSchema.pick({
21+
name: true,
22+
username: true,
23+
avatar: true,
24+
}),
25+
})
26+
.strict();
27+
828
const createUserResponseSchema = z
929
.object({
1030
id: z.string(),
@@ -22,6 +42,7 @@ const createUserResponseSchema = z
2242
profile: z.object({
2343
id: z.string(),
2444
name: z.string(),
45+
username: z.string(),
2546
description: z.string().nullable(),
2647
}),
2748
})
@@ -35,23 +56,45 @@ export const createUser = async (args: {
3556
inboxId: string;
3657
profile: {
3758
name: string;
59+
username: string;
3860
avatar?: string;
3961
};
4062
}): Promise<CreateUserResponse> => {
4163
const { privyUserId, smartContractWalletAddress, inboxId, profile } = args;
4264

4365
try {
44-
const response = await api.post<CreateUserResponse>("/api/v1/users", {
66+
const requestPayload = {
4567
privyUserId,
4668
device: buildDeviceMetadata(),
4769
identity: {
4870
privyAddress: smartContractWalletAddress,
4971
xmtpId: inboxId,
5072
},
5173
profile,
52-
});
74+
};
75+
76+
const validationResult = createUserRequestSchema.safeParse(requestPayload);
77+
if (!validationResult.success) {
78+
throw new Error(
79+
`Invalid request data: ${validationResult.error.message}`
80+
);
81+
}
82+
83+
const response = await api.post<CreateUserResponse>(
84+
"/api/v1/users",
85+
validationResult.data
86+
);
87+
88+
const responseValidation = createUserResponseSchema.safeParse(
89+
response.data
90+
);
91+
if (!responseValidation.success) {
92+
throw new Error(
93+
`Response validation failed: ${responseValidation.error.message}`
94+
);
95+
}
5396

54-
return createUserResponseSchema.parse(response.data);
97+
return responseValidation.data;
5598
} catch (error) {
5699
throw handleApiError(error, "createUser");
57100
}

features/conversation/conversation-message/conversation-message-reactions/conversation-message-reaction-drawer/conversation-message-reaction-drawer.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,9 @@ const BottomSheetContent = memo(function BottomSheetContent() {
185185
<Avatar
186186
size={theme.avatarSize.md}
187187
uri={item.reactor.avatar}
188-
name={item.reactor.userName}
188+
name={item.reactor.username}
189189
/>
190-
<Text style={themed($userName)}>{item.reactor.userName}</Text>
190+
<Text style={themed($username)}>{item.reactor.username}</Text>
191191
<Text style={themed($reactionContent)}>{item.content}</Text>
192192
</HStack>
193193
</ReactionContainer>
@@ -233,7 +233,7 @@ const $reactionInner: ThemedStyle<ViewStyle> = ({ spacing }) => ({
233233
flex: 1,
234234
});
235235

236-
const $userName: ThemedStyle<TextStyle> = ({ spacing, colors }) => ({
236+
const $username: ThemedStyle<TextStyle> = ({ spacing, colors }) => ({
237237
flex: 1,
238238
display: "flex",
239239
alignItems: "flex-end",

features/conversation/conversation-message/conversation-message-reactions/conversation-message-reactions.types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ export type SortedReaction = {
3434
*/
3535
export type ReactorDetails = {
3636
address: string;
37-
userName: Nullable<string>;
37+
username: Nullable<string>;
3838
avatar: Nullable<string>;
3939
};

features/conversation/conversation-message/conversation-message-reactions/use-conversation-message-reactions-rolled-up.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function useConversationMessageReactionsRolledUp(args: {
6565
isOwnReaction,
6666
reactor: {
6767
address: reaction.senderInboxId,
68-
userName: profile?.name,
68+
username: profile?.name,
6969
avatar: profile?.avatar,
7070
},
7171
});

features/current-user/use-create-user.ts

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type ICreateUserArgs = {
1313
inboxId: string;
1414
profile: {
1515
name: string;
16+
username: string;
1617
avatar?: string;
1718
description?: string;
1819
};
@@ -35,6 +36,7 @@ const buildOptimisticUser = (args: ICreateUserArgs): CreateUserResponse => {
3536
profile: {
3637
id: "123",
3738
name: args.profile.name,
39+
username: args.profile.username,
3840
description: args.profile.description ?? null,
3941
},
4042
};
@@ -102,6 +104,7 @@ export function useCreateUser() {
102104
data: {
103105
id: data.profile.id,
104106
name: data.profile.name,
107+
username: data.profile.username,
105108
description: data.profile.description ?? null,
106109
},
107110
});

features/onboarding/screens/onboarding-contact-card-screen.tsx

+116-76
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import { usePrivy } from "@privy-io/expo";
22
import React, { memo, useCallback, useEffect, useState } from "react";
33
import { Alert, TextStyle, ViewStyle } from "react-native";
44
import { create } from "zustand";
5-
import { Screen } from "@/components/screen/screen";
5+
import { z } from "zod";
6+
67
import { Center } from "@/design-system/Center";
78
import { VStack } from "@/design-system/VStack";
8-
// import { useProfile } from "../hooks/useProfile";
9+
import { ThemedStyle, useAppTheme } from "@/theme/use-app-theme";
10+
import { captureErrorWithToast } from "@/utils/capture-error";
11+
import { ValidationError } from "@/utils/api/api.error";
12+
import { useAddPfp } from "../../../hooks/use-add-pfp";
13+
import { isAxiosError } from "axios";
14+
15+
import { Screen } from "@/components/screen/screen";
916
import { useAuthStore } from "@/features/authentication/authentication.store";
1017
import { useMultiInboxStore } from "@/features/authentication/multi-inbox.store";
1118
import { useCreateUser } from "@/features/current-user/use-create-user";
@@ -17,10 +24,47 @@ import { ProfileContactCardEditableNameInput } from "@/features/profiles/compone
1724
import { ProfileContactCardLayout } from "@/features/profiles/components/profile-contact-card/profile-contact-card-layout";
1825
import { validateProfileName } from "@/features/profiles/utils/validate-profile-name";
1926
import { useHeader } from "@/navigation/use-header";
20-
import { ThemedStyle, useAppTheme } from "@/theme/use-app-theme";
21-
import { ValidationError } from "@/utils/api/api.error";
22-
import { captureErrorWithToast } from "@/utils/capture-error";
23-
import { useAddPfp } from "../../../hooks/use-add-pfp";
27+
import { formatRandomUserName } from "@/features/onboarding/utils/format-random-user-name";
28+
import { profileValidationSchema } from "@/features/profiles/schemas/profile-validation.schema";
29+
30+
// Request validation schema
31+
const createUserRequestSchema = z.object({
32+
inboxId: z.string(),
33+
privyUserId: z.string(),
34+
smartContractWalletAddress: z.string(),
35+
profile: profileValidationSchema.pick({ name: true, username: true }),
36+
});
37+
38+
type IOnboardingContactCardStore = {
39+
name: string;
40+
username: string;
41+
nameValidationError: string;
42+
avatar: string;
43+
actions: {
44+
setName: (name: string) => void;
45+
setUsername: (username: string) => void;
46+
setNameValidationError: (nameValidationError: string) => void;
47+
setAvatar: (avatar: string) => void;
48+
reset: () => void;
49+
};
50+
};
51+
52+
const useOnboardingContactCardStore = create<IOnboardingContactCardStore>(
53+
(set) => ({
54+
name: "",
55+
username: "",
56+
nameValidationError: "",
57+
avatar: "",
58+
actions: {
59+
setName: (name: string) => set({ name }),
60+
setUsername: (username: string) => set({ username }),
61+
setNameValidationError: (nameValidationError: string) =>
62+
set({ nameValidationError }),
63+
setAvatar: (avatar: string) => set({ avatar }),
64+
reset: () => set({ name: "", username: "", nameValidationError: "", avatar: "" }),
65+
},
66+
})
67+
);
2468

2569
export function OnboardingContactCardScreen() {
2670
const { themed } = useAppTheme();
@@ -32,6 +76,19 @@ export function OnboardingContactCardScreen() {
3276
const handleRealContinue = useCallback(async () => {
3377
try {
3478
const currentSender = useMultiInboxStore.getState().currentSender;
79+
const store = useOnboardingContactCardStore.getState();
80+
81+
// Validate profile data first using profileValidationSchema
82+
const profileValidation = profileValidationSchema.safeParse({
83+
name: store.name,
84+
username: store.username,
85+
});
86+
87+
if (!profileValidation.success) {
88+
const errorMessage =
89+
profileValidation.error.errors[0]?.message || "Invalid profile data";
90+
throw new ValidationError({ message: errorMessage });
91+
}
3592

3693
if (!currentSender) {
3794
throw new Error("No current sender found, please logout");
@@ -41,15 +98,25 @@ export function OnboardingContactCardScreen() {
4198
throw new Error("No Privy user found, please logout");
4299
}
43100

44-
await createUserAsync({
45-
inboxId: currentSender?.inboxId,
46-
privyUserId: privyUser?.id,
47-
smartContractWalletAddress: currentSender?.ethereumAddress,
101+
// Create and validate the request payload
102+
const payload = {
103+
inboxId: currentSender.inboxId,
104+
privyUserId: privyUser.id,
105+
smartContractWalletAddress: currentSender.ethereumAddress,
48106
profile: {
49-
name: useOnboardingContactCardStore.getState().name,
107+
name: store.name,
108+
username: store.username,
50109
},
51-
});
110+
};
111+
112+
// Validate the payload against our schema
113+
const validationResult = createUserRequestSchema.safeParse(payload);
114+
115+
if (!validationResult.success) {
116+
throw new Error("Invalid request data. Please check your input.");
117+
}
52118

119+
await createUserAsync(validationResult.data);
53120
useAuthStore.getState().actions.setStatus("signedIn");
54121

55122
// TODO: Notification permissions screen
@@ -62,14 +129,20 @@ export function OnboardingContactCardScreen() {
62129
// }
63130
} catch (error) {
64131
if (error instanceof ValidationError) {
65-
useOnboardingContactCardStore
66-
.getState()
67-
.actions.setNameValidationError(Object.values(error.errors)[0]);
68132
captureErrorWithToast(error, {
69-
message: Object.values(error.errors)[0],
133+
message: error.message,
70134
});
135+
} else if (isAxiosError(error)) {
136+
const userMessage =
137+
error.response?.status === 409
138+
? "This username is already taken"
139+
: "Failed to create profile. Please try again.";
140+
141+
captureErrorWithToast(error, { message: userMessage });
71142
} else {
72-
captureErrorWithToast(error);
143+
captureErrorWithToast(error, {
144+
message: "An unexpected error occurred. Please try again.",
145+
});
73146
}
74147
}
75148
}, [createUserAsync, privyUser]);
@@ -173,32 +246,35 @@ const $subtitleStyle: ThemedStyle<TextStyle> = ({ spacing }) => ({
173246
marginBottom: spacing.sm,
174247
});
175248

176-
const ProfileContactCardNameInput = memo(
177-
function ProfileContactCardNameInput() {
178-
const [nameValidationError, setNameValidationError] = useState<string>();
249+
const ProfileContactCardNameInput = memo(function ProfileContactCardNameInput() {
250+
const [nameValidationError, setNameValidationError] = useState<string>();
179251

180-
const handleDisplayNameChange = useCallback((text: string) => {
181-
const { isValid, error } = validateProfileName(text);
252+
const handleDisplayNameChange = useCallback((text: string) => {
253+
const { isValid, error } = validateProfileName(text);
182254

183-
if (!isValid) {
184-
setNameValidationError(error);
185-
} else {
186-
setNameValidationError(undefined);
187-
}
255+
if (!isValid) {
256+
setNameValidationError(error);
257+
useOnboardingContactCardStore.getState().actions.setUsername("");
258+
return;
259+
}
188260

189-
useOnboardingContactCardStore.getState().actions.setName(text);
190-
}, []);
191-
192-
return (
193-
<ProfileContactCardEditableNameInput
194-
defaultValue={useOnboardingContactCardStore.getState().name}
195-
onChangeText={handleDisplayNameChange}
196-
status={nameValidationError ? "error" : undefined}
197-
helper={nameValidationError}
198-
/>
199-
);
200-
},
201-
);
261+
setNameValidationError(undefined);
262+
const username = formatRandomUserName({ displayName: text });
263+
264+
const store = useOnboardingContactCardStore.getState();
265+
store.actions.setName(text);
266+
store.actions.setUsername(username);
267+
}, []);
268+
269+
return (
270+
<ProfileContactCardEditableNameInput
271+
defaultValue={useOnboardingContactCardStore.getState().name}
272+
onChangeText={handleDisplayNameChange}
273+
status={nameValidationError ? "error" : undefined}
274+
helper={nameValidationError}
275+
/>
276+
);
277+
});
202278

203279
const ProfileContactCardAvatar = memo(function ProfileContactCardAvatar() {
204280
const { asset, addPFP } = useAddPfp();
@@ -221,39 +297,3 @@ const ProfileContactCardAvatar = memo(function ProfileContactCardAvatar() {
221297
/>
222298
);
223299
});
224-
225-
type IOnboardingContactCardState = {
226-
name: string;
227-
nameValidationError: string;
228-
avatar: string;
229-
};
230-
231-
type IOnboardingContactCardActions = {
232-
setName: (name: string) => void;
233-
setNameValidationError: (nameValidationError: string) => void;
234-
setAvatar: (avatar: string) => void;
235-
reset: () => void;
236-
};
237-
238-
type IOnboardingContactCardStore = IOnboardingContactCardState & {
239-
actions: IOnboardingContactCardActions;
240-
};
241-
242-
const initialState: IOnboardingContactCardState = {
243-
name: "",
244-
nameValidationError: "",
245-
avatar: "",
246-
};
247-
248-
const useOnboardingContactCardStore = create<IOnboardingContactCardStore>(
249-
(set, get) => ({
250-
...initialState,
251-
actions: {
252-
setName: (name: string) => set({ name }),
253-
setNameValidationError: (nameValidationError: string) =>
254-
set({ nameValidationError }),
255-
setAvatar: (avatar: string) => set({ avatar }),
256-
reset: () => set(initialState),
257-
},
258-
}),
259-
);

0 commit comments

Comments
 (0)