diff --git a/js/mobile/src/components/Avatar.tsx b/js/mobile/src/components/Avatar.tsx index d52df07..4654909 100644 --- a/js/mobile/src/components/Avatar.tsx +++ b/js/mobile/src/components/Avatar.tsx @@ -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 = { 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 ( ) : ( - - {initials} - + )} diff --git a/js/mobile/src/features/networks/Drawer.tsx b/js/mobile/src/features/networks/Drawer.tsx index 91b686f..6a7258b 100644 --- a/js/mobile/src/features/networks/Drawer.tsx +++ b/js/mobile/src/features/networks/Drawer.tsx @@ -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 ( - - - {initials} - - + 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({ setDrawerOpen(true)} accessibilityLabel="Open menu" - className="bg-muted h-9 w-9 items-center justify-center rounded-full" > - - {initials} - + ) { 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" > - - {busy ? ( + {busy ? ( + - ) : avatarUrl ? ( - - ) : ( - - {initials} - - )} - + + ) : ( + + )} diff --git a/js/mobile/src/features/stream-view/ReactionStack.tsx b/js/mobile/src/features/stream-view/ReactionStack.tsx index c88afb0..63db52a 100644 --- a/js/mobile/src/features/stream-view/ReactionStack.tsx +++ b/js/mobile/src/features/stream-view/ReactionStack.tsx @@ -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(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 ( - - - {firstReactor.initials} - - + {text} diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index 6601803..18609fc 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -579,7 +579,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { /> - diff --git a/js/mobile/src/features/streams/StreamCard.tsx b/js/mobile/src/features/streams/StreamCard.tsx index dcdaf2a..73bae88 100644 --- a/js/mobile/src/features/streams/StreamCard.tsx +++ b/js/mobile/src/features/streams/StreamCard.tsx @@ -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" > - - - {initials} - - +