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 { cn } from '@/lib/utils';
type Size = 'xs' | 'sm' | 'md';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
interface AvatarProps {
humanId: string | null | undefined;
@@ -14,6 +14,8 @@ interface AvatarProps {
online?: boolean;
/** Background ring used to separate stacked avatars from the chrome. */
stackBg?: string;
/** Initials shown when the human can't be resolved (e.g. a group stream). */
fallbackInitials?: 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 },
sm: { box: 'h-9 w-9', text: 'text-xs', 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',
online = false,
stackBg,
fallbackInitials,
className,
}: AvatarProps) {
const { initials } = resolveHumanDisplay(humanId, humans);
const display = resolveHumanDisplay(humanId, humans);
const human = humanId ? humans?.find((h) => h.id === humanId) : undefined;
const avatarUrl = useAvatarUrl(human?.avatar_object_id);
const dims = sizeMap[size];
const initials =
display.exists || fallbackInitials === undefined
? display.initials
: fallbackInitials;
return (
<View
@@ -1,6 +1,7 @@
import { useCallback, useEffect } from 'react';
import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
import type { Human } from '@/api/types';
import { Avatar } from '@/components/Avatar';
import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import {
@@ -215,7 +216,6 @@ function Tile({
const identity = tile.participant.identity;
const display = resolveHumanDisplay(identity, humans);
const name = tile.participant.name || display.displayName;
const initials = display.initials;
const isSpeaking = tile.participant.isSpeaking;
const muted =
tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ??
@@ -234,9 +234,7 @@ function Tile({
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
) : (
<View className="flex-1 items-center justify-center">
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
<Text className="text-white text-xl font-semibold">{initials}</Text>
</View>
<Avatar humanId={identity} humans={humans} size="lg" />
</View>
)}
<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,
SafeAreaView,
} from 'react-native-safe-area-context';
import { Avatar } from '@/components/Avatar';
import { useAuthStore } from '@/stores/auth-store';
const SCREEN_WIDTH = Dimensions.get('window').width;
@@ -58,8 +59,6 @@ export function Drawer({
const signOut = useAuthStore((s) => s.signOut);
const isSigningOut = useAuthStore((s) => s.isSigningOut);
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
return (
<Modal
visible={open}
@@ -90,11 +89,11 @@ export function Drawer({
>
<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="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
{initials}
</Text>
</View>
<Avatar
humanId={user?.id}
humans={user ? [user] : undefined}
size="md"
/>
<View className="flex-1">
<Text
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 { toUserMessage } from '@/lib/errors';
import type { RootStackScreenProps } from '@/navigation/types';
import { Avatar } from '@/components/Avatar';
import { FlowyLogo } from '@/components/FlowyLogo';
import { ListSeparator } from '@/components/ListSeparator';
import { Drawer } from './Drawer';
@@ -29,7 +30,6 @@ export function NetworkListScreen({
const { data, isLoading, refetch, error } = useNetworks();
const { data: invitations, refetch: refetchInvitations } = useMyInvitations();
const user = useAuthStore((s) => s.user);
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
// Local refreshing state — driving RefreshControl from react-query's
// isRefetching can leave the native spinner visually stuck after the
@@ -52,11 +52,12 @@ export function NetworkListScreen({
<Pressable
onPress={() => setDrawerOpen(true)}
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">
{initials}
</Text>
<Avatar
humanId={user?.id}
humans={user ? [user] : undefined}
size="sm"
/>
</Pressable>
<FlowyLogo />
<Pressable
@@ -1,27 +1,18 @@
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Image,
Pressable,
Text,
View,
} from 'react-native';
import { ActivityIndicator, Alert, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker';
import { Camera } from 'lucide-react-native';
import { toast } from 'sonner-native';
import { apiClient } from '@/api/client';
import { Avatar } from '@/components/Avatar';
import { useAuthStore } from '@/stores/auth-store';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { toUserMessage } from '@/lib/errors';
import type { RootStackScreenProps } from '@/navigation/types';
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
const user = useAuthStore((s) => s.user);
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 pickAndUpload = async () => {
@@ -92,17 +83,18 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
accessibilityLabel="Change profile picture"
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 />
) : avatarUrl ? (
<Image source={{ uri: avatarUrl }} className="h-full w-full" />
) : (
<Text className="text-foreground text-3xl font-medium">
{initials}
</Text>
)}
</View>
</View>
) : (
<Avatar
humanId={user?.id}
humans={user ? [user] : undefined}
size="xl"
className="bg-muted"
/>
)}
<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" />
</View>
@@ -3,7 +3,7 @@ import { Pressable, Text, View } from 'react-native';
import { Plus } from 'lucide-react-native';
import * as Haptics from 'expo-haptics';
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';
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
@@ -77,7 +77,6 @@ export function ReactionStack({
{activeTextKeys.map((text) => {
const reactors = reactions?.[text] ?? [];
const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(reactors[0], humans);
return (
<Pressable
key={text}
@@ -93,11 +92,7 @@ export function ReactionStack({
: null,
]}
>
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full">
<Text className="text-white text-[9px] font-semibold">
{firstReactor.initials}
</Text>
</View>
<Avatar humanId={reactors[0]} humans={humans} size="xs" />
<Text className="text-white/90 text-xs" numberOfLines={1}>
{text}
</Text>
@@ -579,7 +579,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
/>
</View>
</View>
</View>
</GestureDetector>
+16 -38
View File
@@ -3,11 +3,12 @@ import { Pressable, Text, View } from 'react-native';
import { Headphones } from 'lucide-react-native';
import type { Particle, StreamProperties } from '@/api/types';
import { isParticleDeleted } from '@/api/types';
import { Avatar } from '@/components/Avatar';
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
import { useLiveLatestChild } from '@/hooks/use-particle';
import { useNetwork } from '@/hooks/use-networks';
import { particlePath } from '@/lib/particle-path';
import { cn, getInitials } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth-store';
interface StreamCardProps {
@@ -35,34 +36,20 @@ export const StreamCard = memo(function StreamCard({
particle.visible_to.length === 2 &&
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) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`,
);
if (otherEntry) {
const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
if (otherEntry) return otherEntry.replace('human:', '');
}
return latestChild?.created_by_human_id ?? null;
}, [isDM, particle.visible_to, userId, latestChild]);
if (latestChild) {
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 fallbackInitials = particle.properties.name.slice(0, 2).toUpperCase();
const isUnseen = useMemo(() => {
if (!latestChild) return false;
@@ -102,21 +89,12 @@ export const StreamCard = memo(function StreamCard({
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"
>
<View
className={cn(
'h-10 w-10 items-center justify-center rounded-full',
isUnseen ? 'bg-primary' : 'bg-muted',
)}
>
<Text
className={cn(
'text-xs font-semibold',
isUnseen ? 'text-primary-foreground' : 'text-muted-foreground',
)}
>
{initials}
</Text>
</View>
<Avatar
humanId={avatarHumanId}
humans={network?.humans}
size="md"
fallbackInitials={fallbackInitials}
/>
<View className="flex-1">
<Text