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" ;
327import { COLORS } from "../constants/colors" ;
28+ import { uploadAvatar } from "../services/avatarService" ;
29+
30+ // ── Types ─────────────────────────────────────────────────────────────────────
431
532interface 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+
1146export 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+
50188const 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} ) ;
0 commit comments