consolidate avatar
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user