Skip to content

Commit 62fd80b

Browse files
feat: add avatar image picker with IPFS upload support (#859)
Add editable mode to Avatar component that allows users to select a profile photo from their device library, compress it client-side, and upload to the IPFS/S3 backend proxy. Includes file validation, permission handling, loading states, and graceful error reporting. New service: avatarService.ts handles the multipart upload flow and profile URI persistence via the existing apiFetch client. 🤖 Generated with Codebuff Co-authored-by: Codebuff <noreply@codebuff.com>
1 parent 9e3f605 commit 62fd80b

2 files changed

Lines changed: 340 additions & 13 deletions

File tree

Lines changed: 164 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,58 @@
1-
import React from "react";
2-
import { View, Text, Image, StyleSheet } from "react-native";
1+
/**
2+
* Avatar.tsx
3+
*
4+
* Displays a user's profile picture (or initials fallback).
5+
*
6+
* When `editable` is true the component becomes tappable and opens the
7+
* device photo library via expo-image-picker. The selected image is
8+
* compressed client-side, uploaded to the IPFS/S3 backend proxy, and the
9+
* resulting URI is passed back through `onAvatarUploaded`.
10+
*
11+
* Backward-compatible: all existing usages continue to render read-only
12+
* avatars without any changes.
13+
*/
14+
15+
import React, { useState, useCallback } from "react";
16+
import {
17+
View,
18+
Text,
19+
Image,
20+
StyleSheet,
21+
TouchableOpacity,
22+
ActivityIndicator,
23+
Alert,
24+
} from "react-native";
25+
import * as ImagePicker from "expo-image-picker";
26+
import { Ionicons } from "@expo/vector-icons";
327
import { COLORS } from "../constants/colors";
28+
import { uploadAvatar } from "../services/avatarService";
29+
30+
// ── Types ─────────────────────────────────────────────────────────────────────
431

532
interface AvatarProps {
633
uri?: string | null;
734
name: string;
835
size?: number;
36+
/** Enable image-picker mode. Makes the avatar tappable. */
37+
editable?: boolean;
38+
/** Bearer token used for the upload & profile-update calls. */
39+
authToken?: string;
40+
/** Called with the new IPFS/S3 URI after a successful upload. */
41+
onAvatarUploaded?: (newUri: string) => void;
942
}
1043

44+
// ── Component ─────────────────────────────────────────────────────────────────
45+
1146
export const Avatar = React.memo(function Avatar({
1247
uri,
1348
name,
1449
size = 64,
50+
editable = false,
51+
authToken,
52+
onAvatarUploaded,
1553
}: AvatarProps) {
54+
const [uploading, setUploading] = useState(false);
55+
1656
const initials = name
1757
.split(/[\s._-]+/)
1858
.filter(Boolean)
@@ -22,31 +62,129 @@ export const Avatar = React.memo(function Avatar({
2262

2363
const fontSize = Math.round(size * 0.38);
2464

25-
if (uri) {
26-
return (
65+
// ── Image picker handler ────────────────────────────────────────────────
66+
67+
const pickImage = useCallback(async () => {
68+
if (uploading) return;
69+
70+
// Request photo-library permission (Android needs this at runtime).
71+
const { status } =
72+
await ImagePicker.requestMediaLibraryPermissionsAsync();
73+
if (status !== "granted") {
74+
Alert.alert(
75+
"Permission required",
76+
"Please grant photo library access to change your avatar."
77+
);
78+
return;
79+
}
80+
81+
const result = await ImagePicker.launchImageLibraryAsync({
82+
mediaTypes: ["images"],
83+
allowsEditing: true,
84+
aspect: [1, 1],
85+
quality: 0.7, // Client-side JPEG compression (~70 % quality)
86+
});
87+
88+
if (result.canceled || !result.assets?.[0]?.uri) return;
89+
90+
setUploading(true);
91+
try {
92+
const { avatarUrl } = await uploadAvatar(
93+
result.assets[0].uri,
94+
authToken
95+
);
96+
onAvatarUploaded?.(avatarUrl);
97+
} catch (err: any) {
98+
const message =
99+
err?.message ?? "Something went wrong while uploading your photo.";
100+
Alert.alert("Upload failed", message);
101+
} finally {
102+
setUploading(false);
103+
}
104+
}, [uploading, authToken, onAvatarUploaded]);
105+
106+
// ── Inner content (shared between editable & read-only modes) ───────────
107+
108+
const content =
109+
uri && !uploading ? (
27110
<Image
28111
source={{ uri }}
29-
style={[styles.image, { width: size, height: size, borderRadius: size / 2 }]}
112+
style={[
113+
styles.image,
114+
{ width: size, height: size, borderRadius: size / 2 },
115+
]}
30116
accessibilityLabel={`Avatar for ${name}`}
31117
/>
118+
) : (
119+
<View
120+
style={[
121+
styles.fallback,
122+
{ width: size, height: size, borderRadius: size / 2 },
123+
]}
124+
accessibilityLabel={`Avatar placeholder for ${name}`}
125+
>
126+
{uploading ? (
127+
<ActivityIndicator
128+
size="small"
129+
color={COLORS.secondary}
130+
accessibilityLabel="Uploading avatar"
131+
/>
132+
) : (
133+
<Text style={[styles.initials, { fontSize }]}>
134+
{initials || "?"}
135+
</Text>
136+
)}
137+
</View>
32138
);
139+
140+
// ── Read-only mode ──────────────────────────────────────────────────────
141+
142+
if (!editable) {
143+
return content;
33144
}
34145

146+
// ── Editable mode ───────────────────────────────────────────────────────
147+
148+
const editIconSize = Math.max(18, Math.round(size * 0.28));
149+
35150
return (
36-
<View
151+
<TouchableOpacity
152+
onPress={pickImage}
153+
activeOpacity={0.7}
154+
disabled={uploading}
155+
accessibilityRole="button"
156+
accessibilityLabel={`Change avatar for ${name}`}
157+
accessibilityHint="Opens photo library to select a new profile picture"
37158
style={[
38-
styles.fallback,
39-
{ width: size, height: size, borderRadius: size / 2 },
159+
styles.editableContainer,
160+
{ width: size, height: size },
40161
]}
41-
accessibilityLabel={`Avatar placeholder for ${name}`}
42162
>
43-
<Text style={[styles.initials, { fontSize }]}>
44-
{initials || "?"}
45-
</Text>
46-
</View>
163+
{content}
164+
165+
{/* Camera badge overlay */}
166+
<View
167+
style={[
168+
styles.editBadge,
169+
{
170+
width: editIconSize + 10,
171+
height: editIconSize + 10,
172+
borderRadius: (editIconSize + 10) / 2,
173+
},
174+
]}
175+
>
176+
<Ionicons
177+
name="camera-outline"
178+
size={editIconSize}
179+
color={COLORS.white}
180+
/>
181+
</View>
182+
</TouchableOpacity>
47183
);
48184
});
49185

186+
// ── Styles ────────────────────────────────────────────────────────────────────
187+
50188
const styles = StyleSheet.create({
51189
image: {
52190
backgroundColor: COLORS.gray,
@@ -60,4 +198,17 @@ const styles = StyleSheet.create({
60198
fontFamily: "Outfit_700Bold",
61199
color: COLORS.secondary,
62200
},
201+
editableContainer: {
202+
position: "relative",
203+
},
204+
editBadge: {
205+
position: "absolute",
206+
bottom: 0,
207+
right: 0,
208+
backgroundColor: COLORS.primary,
209+
justifyContent: "center",
210+
alignItems: "center",
211+
borderWidth: 2,
212+
borderColor: COLORS.white,
213+
},
63214
});
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* avatarService.ts
3+
*
4+
* Handles profile avatar selection, client-side compression, upload to the
5+
* IPFS/S3 backend proxy, and profile-avatar-URI update.
6+
*
7+
* Flow:
8+
* 1. Caller provides a local image URI from expo-image-picker.
9+
* 2. File size is validated, then the URI is wrapped in a FormData
10+
* multipart payload.
11+
* 3. The payload is POSTed to the backend proxy which pins to IPFS
12+
* (or S3) and returns the resulting content-addressed URI.
13+
* 4. The new URI is written back to the user's profile via the
14+
* standard profile-update endpoint.
15+
*/
16+
17+
import { getInfoAsync } from "expo-file-system/legacy";
18+
import { apiFetch } from "./api";
19+
20+
// ── Config ────────────────────────────────────────────────────────────────────
21+
22+
const API_BASE = process.env.EXPO_PUBLIC_API_URL ?? "https://api.zaps.app";
23+
24+
/** Maximum file size in bytes before we reject the upload (5 MB). */
25+
const MAX_FILE_SIZE = 5 * 1024 * 1024;
26+
27+
// ── Types ─────────────────────────────────────────────────────────────────────
28+
29+
export interface AvatarUploadResult {
30+
/** The new avatar URI (IPFS gateway or S3 URL). */
31+
avatarUrl: string;
32+
}
33+
34+
export interface AvatarUploadError extends Error {
35+
code:
36+
| "FILE_TOO_LARGE"
37+
| "READ_FAILED"
38+
| "UPLOAD_FAILED"
39+
| "PROFILE_UPDATE_FAILED"
40+
| "UNKNOWN_ERROR";
41+
}
42+
43+
// ── Helpers ───────────────────────────────────────────────────────────────────
44+
45+
function createAvatarError(
46+
message: string,
47+
code: AvatarUploadError["code"]
48+
): AvatarUploadError {
49+
const err = new Error(message) as AvatarUploadError;
50+
err.code = code;
51+
return err;
52+
}
53+
54+
/**
55+
* Resolve a MIME type from the image URI extension.
56+
* expo-image-picker returns URIs ending in `.jpg`, `.png`, `.heic`, etc.
57+
*/
58+
function resolveMimeType(uri: string): string {
59+
const ext = uri.split(".").pop()?.toLowerCase() ?? "";
60+
const map: Record<string, string> = {
61+
jpg: "image/jpeg",
62+
jpeg: "image/jpeg",
63+
png: "image/png",
64+
heic: "image/heic",
65+
heif: "image/heif",
66+
webp: "image/webp",
67+
};
68+
return map[ext] ?? "image/jpeg";
69+
}
70+
71+
// ── Public API ────────────────────────────────────────────────────────────────
72+
73+
/**
74+
* Upload a locally-selected avatar image to the IPFS/S3 backend proxy
75+
* and update the user's profile with the new avatar URI.
76+
*
77+
* @param localUri - The local file URI returned by `expo-image-picker`.
78+
* @param authToken - The user's bearer token (from SecureStore / Privy).
79+
* @returns The new avatar URI on success.
80+
*
81+
* @example
82+
* ```ts
83+
* const result = await uploadAvatar(pickerResult.uri, token);
84+
* console.log(result.avatarUrl); // ipfs://Qm… or https://s3…
85+
* ```
86+
*/
87+
export async function uploadAvatar(
88+
localUri: string,
89+
authToken?: string
90+
): Promise<AvatarUploadResult> {
91+
// ── 1. Check file size ──────────────────────────────────────────────────
92+
try {
93+
const info = await getInfoAsync(localUri);
94+
if (info.exists && info.size > MAX_FILE_SIZE) {
95+
throw createAvatarError(
96+
"Image exceeds the 5 MB limit. Please choose a smaller photo.",
97+
"FILE_TOO_LARGE"
98+
);
99+
}
100+
} catch (err: any) {
101+
if (err?.code) throw err; // re-throw our typed error
102+
throw createAvatarError(
103+
"Unable to read the selected image.",
104+
"READ_FAILED"
105+
);
106+
}
107+
108+
// ── 2. Build multipart form data ─────────────────────────────────────────
109+
const mimeType = resolveMimeType(localUri);
110+
const filename = localUri.split("/").pop() ?? "avatar.jpg";
111+
112+
const formData = new FormData();
113+
formData.append("file", {
114+
uri: localUri,
115+
name: filename,
116+
type: mimeType,
117+
} as unknown as Blob);
118+
119+
// ── 3. Upload to the backend proxy (IPFS / S3) ──────────────────────────
120+
let uploadRes: Response;
121+
try {
122+
uploadRes = await fetch(`${API_BASE}/api/avatar/upload`, {
123+
method: "POST",
124+
headers: {
125+
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
126+
// NOTE: Do NOT set Content-Type manually — the boundary must be
127+
// auto-generated by the runtime for multipart/form-data.
128+
},
129+
body: formData,
130+
});
131+
} catch {
132+
throw createAvatarError(
133+
"Network error while uploading avatar. Please try again.",
134+
"UPLOAD_FAILED"
135+
);
136+
}
137+
138+
if (!uploadRes.ok) {
139+
const detail = await uploadRes.text().catch(() => "Unknown error");
140+
throw createAvatarError(
141+
`Avatar upload failed (${uploadRes.status}): ${detail}`,
142+
"UPLOAD_FAILED"
143+
);
144+
}
145+
146+
const { avatarUrl } = (await uploadRes.json()) as { avatarUrl: string };
147+
148+
if (!avatarUrl) {
149+
throw createAvatarError(
150+
"Server did not return an avatar URL.",
151+
"UPLOAD_FAILED"
152+
);
153+
}
154+
155+
// ── 4. Persist the new URI on the user profile ───────────────────────────
156+
try {
157+
const profileRes = await apiFetch(`${API_BASE}/api/users/me`, {
158+
method: "PATCH",
159+
body: JSON.stringify({ avatar_url: avatarUrl }),
160+
});
161+
162+
if (!profileRes.ok) {
163+
throw new Error(`HTTP ${profileRes.status}`);
164+
}
165+
} catch {
166+
// The upload succeeded but the profile update failed. The avatar is
167+
// already pinned on IPFS, so we return the URL and let the caller
168+
// decide whether to retry the profile update later.
169+
console.warn(
170+
"Avatar uploaded but profile update failed. URI:",
171+
avatarUrl
172+
);
173+
}
174+
175+
return { avatarUrl };
176+
}

0 commit comments

Comments
 (0)