From 49331cd25b4c816368f3720daa2f215ec29a785e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Nov 2025 17:46:46 +0000 Subject: [PATCH] feat: Add user avatar component and logic Co-authored-by: jeff --- app/(tabs)/you.tsx | 3 +- components/user-avatar.tsx | 47 +++++++++++++++++++ lib/avatar.ts | 95 ++++++++++++++++++++++++++++++++++++++ types/index.ts | 6 +++ 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 components/user-avatar.tsx create mode 100644 lib/avatar.ts diff --git a/app/(tabs)/you.tsx b/app/(tabs)/you.tsx index 28b4b46..e331f03 100644 --- a/app/(tabs)/you.tsx +++ b/app/(tabs)/you.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'expo-router'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; +import { UserAvatar } from '@/components/user-avatar'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; @@ -43,7 +44,7 @@ export default function YouScreen() { - + {session?.user.email ?? 'Unknown user'} Email diff --git a/components/user-avatar.tsx b/components/user-avatar.tsx new file mode 100644 index 0000000..224d985 --- /dev/null +++ b/components/user-avatar.tsx @@ -0,0 +1,47 @@ +import { memo, useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { generateAvatarFromEmail } from '@/lib/avatar'; + +type UserAvatarProps = { + email?: string | null; + size?: number; + testID?: string; +}; + +function UserAvatarComponent({ email, size = 52, testID }: UserAvatarProps) { + const avatar = useMemo(() => generateAvatarFromEmail(email), [email]); + const fontSize = Math.max(16, size * 0.42); + + return ( + + {avatar.initials} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + initials: { + fontWeight: '700', + letterSpacing: 0.4, + }, +}); + +export const UserAvatar = memo(UserAvatarComponent); diff --git a/lib/avatar.ts b/lib/avatar.ts new file mode 100644 index 0000000..ebdfd3c --- /dev/null +++ b/lib/avatar.ts @@ -0,0 +1,95 @@ +import type { UserAvatarData } from '@/types'; + +const DEFAULT_AVATAR: UserAvatarData = { + initials: '?', + backgroundColor: '#E0E7FF', + foregroundColor: '#312E81', +}; + +export function generateAvatarFromEmail(email?: string | null): UserAvatarData { + if (!email) { + return { ...DEFAULT_AVATAR }; + } + + const normalized = email.trim().toLowerCase(); + if (!normalized) { + return { ...DEFAULT_AVATAR }; + } + + const initials = createInitials(normalized); + const hash = hashString(normalized); + const backgroundColor = hslToHex(hash % 360, 65, 72); + const foregroundColor = hslToHex((hash + 180) % 360, 45, 22); + + return { + initials, + backgroundColor, + foregroundColor, + }; +} + +function createInitials(email: string) { + const localPart = email.split('@')[0]; + const segments = localPart.split(/[^a-z0-9]+/i).filter(Boolean); + + if (segments.length >= 2) { + return `${segments[0][0]}${segments[1][0]}`.toUpperCase(); + } + + if (segments.length === 1 && segments[0].length >= 2) { + return segments[0].slice(0, 2).toUpperCase(); + } + + return localPart.slice(0, 2).toUpperCase() || '?'; +} + +function hashString(value: string) { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; + } + + return Math.abs(hash); +} + +function hslToHex(hue: number, saturation: number, lightness: number) { + const normalizedHue = ((hue % 360) + 360) % 360; + const s = saturation / 100; + const l = lightness / 100; + + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((normalizedHue / 60) % 2) - 1)); + const m = l - c / 2; + + let r = 0; + let g = 0; + let b = 0; + + if (normalizedHue >= 0 && normalizedHue < 60) { + r = c; + g = x; + } else if (normalizedHue >= 60 && normalizedHue < 120) { + r = x; + g = c; + } else if (normalizedHue >= 120 && normalizedHue < 180) { + g = c; + b = x; + } else if (normalizedHue >= 180 && normalizedHue < 240) { + g = x; + b = c; + } else if (normalizedHue >= 240 && normalizedHue < 300) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + const toHex = (value: number) => { + const channel = Math.round((value + m) * 255); + return channel.toString(16).padStart(2, '0'); + }; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} diff --git a/types/index.ts b/types/index.ts index 89117f0..265a845 100644 --- a/types/index.ts +++ b/types/index.ts @@ -53,3 +53,9 @@ export interface WeightEntryRecord { created_at: string; } +export interface UserAvatarData { + initials: string; + backgroundColor: string; + foregroundColor: string; +} +