consolidate avatar

This commit is contained in:
talksik
2026-06-21 10:32:44 -07:00
parent 18d4836622
commit 9ed1c949fd
8 changed files with 56 additions and 85 deletions
+11 -2
View File
@@ -4,7 +4,7 @@ import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { resolveHumanDisplay } from '@/lib/humans'; import { resolveHumanDisplay } from '@/lib/humans';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Size = 'xs' | 'sm' | 'md'; type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
interface AvatarProps { interface AvatarProps {
humanId: string | null | undefined; humanId: string | null | undefined;
@@ -14,6 +14,8 @@ interface AvatarProps {
online?: boolean; online?: boolean;
/** Background ring used to separate stacked avatars from the chrome. */ /** Background ring used to separate stacked avatars from the chrome. */
stackBg?: string; stackBg?: string;
/** Initials shown when the human can't be resolved (e.g. a group stream). */
fallbackInitials?: string;
className?: string; className?: string;
} }
@@ -21,6 +23,8 @@ const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 }, xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 },
sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 }, sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 },
md: { box: 'h-10 w-10', text: 'text-sm', ring: 2 }, md: { box: 'h-10 w-10', text: 'text-sm', ring: 2 },
lg: { box: 'h-16 w-16', text: 'text-xl', ring: 2.5 },
xl: { box: 'h-24 w-24', text: 'text-3xl', ring: 3 },
}; };
/** /**
@@ -35,12 +39,17 @@ export function Avatar({
size = 'sm', size = 'sm',
online = false, online = false,
stackBg, stackBg,
fallbackInitials,
className, className,
}: AvatarProps) { }: AvatarProps) {
const { initials } = resolveHumanDisplay(humanId, humans); const display = resolveHumanDisplay(humanId, humans);
const human = humanId ? humans?.find((h) => h.id === humanId) : undefined; const human = humanId ? humans?.find((h) => h.id === humanId) : undefined;
const avatarUrl = useAvatarUrl(human?.avatar_object_id); const avatarUrl = useAvatarUrl(human?.avatar_object_id);
const dims = sizeMap[size]; const dims = sizeMap[size];
const initials =
display.exists || fallbackInitials === undefined
? display.initials
: fallbackInitials;
return ( return (
<View <View
@@ -1,6 +1,7 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { Alert, Dimensions, Pressable, Text, View } from 'react-native'; import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
import type { Human } from '@/api/types'; import type { Human } from '@/api/types';
import { Avatar } from '@/components/Avatar';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { import {
@@ -215,7 +216,6 @@ function Tile({
const identity = tile.participant.identity; const identity = tile.participant.identity;
const display = resolveHumanDisplay(identity, humans); const display = resolveHumanDisplay(identity, humans);
const name = tile.participant.name || display.displayName; const name = tile.participant.name || display.displayName;
const initials = display.initials;
const isSpeaking = tile.participant.isSpeaking; const isSpeaking = tile.participant.isSpeaking;
const muted = const muted =
tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ?? tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ??
@@ -234,9 +234,7 @@ function Tile({
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" /> <VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
) : ( ) : (
<View className="flex-1 items-center justify-center"> <View className="flex-1 items-center justify-center">
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700"> <Avatar humanId={identity} humans={humans} size="lg" />
<Text className="text-white text-xl font-semibold">{initials}</Text>
</View>
</View> </View>
)} )}
<View className="absolute left-2 bottom-2 flex-row items-center gap-1 rounded-full bg-black/60 px-2 py-1"> <View className="absolute left-2 bottom-2 flex-row items-center gap-1 rounded-full bg-black/60 px-2 py-1">
+6 -7
View File
@@ -13,6 +13,7 @@ import {
SafeAreaProvider, SafeAreaProvider,
SafeAreaView, SafeAreaView,
} from 'react-native-safe-area-context'; } from 'react-native-safe-area-context';
import { Avatar } from '@/components/Avatar';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
const SCREEN_WIDTH = Dimensions.get('window').width; const SCREEN_WIDTH = Dimensions.get('window').width;
@@ -58,8 +59,6 @@ export function Drawer({
const signOut = useAuthStore((s) => s.signOut); const signOut = useAuthStore((s) => s.signOut);
const isSigningOut = useAuthStore((s) => s.isSigningOut); const isSigningOut = useAuthStore((s) => s.isSigningOut);
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
return ( return (
<Modal <Modal
visible={open} visible={open}
@@ -90,11 +89,11 @@ export function Drawer({
> >
<SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1"> <SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1">
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3"> <View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full"> <Avatar
<Text className="text-sidebar-accent-foreground text-sm font-semibold"> humanId={user?.id}
{initials} humans={user ? [user] : undefined}
</Text> size="md"
</View> />
<View className="flex-1"> <View className="flex-1">
<Text <Text
className="text-sidebar-foreground text-base font-medium" className="text-sidebar-foreground text-base font-medium"
@@ -16,6 +16,7 @@ import { useAcceptInvitation, useMyInvitations } from '@/hooks/use-invitations';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { toUserMessage } from '@/lib/errors'; import { toUserMessage } from '@/lib/errors';
import type { RootStackScreenProps } from '@/navigation/types'; import type { RootStackScreenProps } from '@/navigation/types';
import { Avatar } from '@/components/Avatar';
import { FlowyLogo } from '@/components/FlowyLogo'; import { FlowyLogo } from '@/components/FlowyLogo';
import { ListSeparator } from '@/components/ListSeparator'; import { ListSeparator } from '@/components/ListSeparator';
import { Drawer } from './Drawer'; import { Drawer } from './Drawer';
@@ -29,7 +30,6 @@ export function NetworkListScreen({
const { data, isLoading, refetch, error } = useNetworks(); const { data, isLoading, refetch, error } = useNetworks();
const { data: invitations, refetch: refetchInvitations } = useMyInvitations(); const { data: invitations, refetch: refetchInvitations } = useMyInvitations();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
// Local refreshing state — driving RefreshControl from react-query's // Local refreshing state — driving RefreshControl from react-query's
// isRefetching can leave the native spinner visually stuck after the // isRefetching can leave the native spinner visually stuck after the
@@ -52,11 +52,12 @@ export function NetworkListScreen({
<Pressable <Pressable
onPress={() => setDrawerOpen(true)} onPress={() => setDrawerOpen(true)}
accessibilityLabel="Open menu" accessibilityLabel="Open menu"
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
> >
<Text className="text-muted-foreground text-xs font-semibold"> <Avatar
{initials} humanId={user?.id}
</Text> humans={user ? [user] : undefined}
size="sm"
/>
</Pressable> </Pressable>
<FlowyLogo /> <FlowyLogo />
<Pressable <Pressable
@@ -1,27 +1,18 @@
import { useState } from 'react'; import { useState } from 'react';
import { import { ActivityIndicator, Alert, Pressable, Text, View } from 'react-native';
ActivityIndicator,
Alert,
Image,
Pressable,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { Camera } from 'lucide-react-native'; import { Camera } from 'lucide-react-native';
import { toast } from 'sonner-native'; import { toast } from 'sonner-native';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { Avatar } from '@/components/Avatar';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { toUserMessage } from '@/lib/errors'; import { toUserMessage } from '@/lib/errors';
import type { RootStackScreenProps } from '@/navigation/types'; import type { RootStackScreenProps } from '@/navigation/types';
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) { export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const refreshUser = useAuthStore((s) => s.refreshUser); const refreshUser = useAuthStore((s) => s.refreshUser);
const avatarUrl = useAvatarUrl(user?.avatar_object_id);
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?';
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const pickAndUpload = async () => { const pickAndUpload = async () => {
@@ -92,17 +83,18 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
accessibilityLabel="Change profile picture" accessibilityLabel="Change profile picture"
className="relative" className="relative"
> >
<View className="h-24 w-24 items-center justify-center overflow-hidden rounded-full bg-muted"> {busy ? (
{busy ? ( <View className="h-24 w-24 items-center justify-center rounded-full bg-muted">
<ActivityIndicator /> <ActivityIndicator />
) : avatarUrl ? ( </View>
<Image source={{ uri: avatarUrl }} className="h-full w-full" /> ) : (
) : ( <Avatar
<Text className="text-foreground text-3xl font-medium"> humanId={user?.id}
{initials} humans={user ? [user] : undefined}
</Text> size="xl"
)} className="bg-muted"
</View> />
)}
<View className="absolute bottom-0 right-0 h-7 w-7 items-center justify-center rounded-full bg-primary border-2 border-background"> <View className="absolute bottom-0 right-0 h-7 w-7 items-center justify-center rounded-full bg-primary border-2 border-background">
<Camera size={13} color="#000000" /> <Camera size={13} color="#000000" />
</View> </View>
@@ -3,7 +3,7 @@ import { Pressable, Text, View } from 'react-native';
import { Plus } from 'lucide-react-native'; import { Plus } from 'lucide-react-native';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types'; import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
import { resolveHumanDisplay } from '@/lib/humans'; import { Avatar } from '@/components/Avatar';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const EMOJI_SET = new Set<string>(REACTION_EMOJIS); const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
@@ -77,7 +77,6 @@ export function ReactionStack({
{activeTextKeys.map((text) => { {activeTextKeys.map((text) => {
const reactors = reactions?.[text] ?? []; const reactors = reactions?.[text] ?? [];
const isMine = reactors.includes(currentHumanId); const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(reactors[0], humans);
return ( return (
<Pressable <Pressable
key={text} key={text}
@@ -93,11 +92,7 @@ export function ReactionStack({
: null, : null,
]} ]}
> >
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full"> <Avatar humanId={reactors[0]} humans={humans} size="xs" />
<Text className="text-white text-[9px] font-semibold">
{firstReactor.initials}
</Text>
</View>
<Text className="text-white/90 text-xs" numberOfLines={1}> <Text className="text-white/90 text-xs" numberOfLines={1}>
{text} {text}
</Text> </Text>
@@ -579,7 +579,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
/> />
</View> </View>
</View> </View>
</View> </View>
</GestureDetector> </GestureDetector>
+16 -38
View File
@@ -3,11 +3,12 @@ import { Pressable, Text, View } from 'react-native';
import { Headphones } from 'lucide-react-native'; import { Headphones } from 'lucide-react-native';
import type { Particle, StreamProperties } from '@/api/types'; import type { Particle, StreamProperties } from '@/api/types';
import { isParticleDeleted } from '@/api/types'; import { isParticleDeleted } from '@/api/types';
import { Avatar } from '@/components/Avatar';
import { RelativeTimestamp } from '@/components/RelativeTimestamp'; import { RelativeTimestamp } from '@/components/RelativeTimestamp';
import { useLiveLatestChild } from '@/hooks/use-particle'; import { useLiveLatestChild } from '@/hooks/use-particle';
import { useNetwork } from '@/hooks/use-networks'; import { useNetwork } from '@/hooks/use-networks';
import { particlePath } from '@/lib/particle-path'; import { particlePath } from '@/lib/particle-path';
import { cn, getInitials } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
interface StreamCardProps { interface StreamCardProps {
@@ -35,34 +36,20 @@ export const StreamCard = memo(function StreamCard({
particle.visible_to.length === 2 && particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith('human:')); particle.visible_to.every((v) => v.startsWith('human:'));
const initials = useMemo(() => { // The human represented by the card: the other party in a DM, otherwise the
// author of the latest message. Group streams with no messages resolve to
// null and fall back to the stream-name initials below.
const avatarHumanId = useMemo(() => {
if (isDM) { if (isDM) {
const otherEntry = particle.visible_to.find( const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`, (v) => v !== `human:${userId}`,
); );
if (otherEntry) { if (otherEntry) return otherEntry.replace('human:', '');
const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
} }
return latestChild?.created_by_human_id ?? null;
}, [isDM, particle.visible_to, userId, latestChild]);
if (latestChild) { const fallbackInitials = particle.properties.name.slice(0, 2).toUpperCase();
const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id,
);
if (creator) return getInitials(creator.email);
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [
isDM,
particle.visible_to,
particle.properties.name,
userId,
latestChild,
network,
]);
const isUnseen = useMemo(() => { const isUnseen = useMemo(() => {
if (!latestChild) return false; if (!latestChild) return false;
@@ -102,21 +89,12 @@ export const StreamCard = memo(function StreamCard({
android_ripple={{ color: 'rgba(0,0,0,0.05)' }} android_ripple={{ color: 'rgba(0,0,0,0.05)' }}
className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent" className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent"
> >
<View <Avatar
className={cn( humanId={avatarHumanId}
'h-10 w-10 items-center justify-center rounded-full', humans={network?.humans}
isUnseen ? 'bg-primary' : 'bg-muted', size="md"
)} fallbackInitials={fallbackInitials}
> />
<Text
className={cn(
'text-xs font-semibold',
isUnseen ? 'text-primary-foreground' : 'text-muted-foreground',
)}
>
{initials}
</Text>
</View>
<View className="flex-1"> <View className="flex-1">
<Text <Text