feat: real-time user profile updates and propagate avatarColor to all Avatar callsites

Broadcast user_updated events over WebSocket when profile fields change,
updating members, DM participants, friends, and cached messages in real time.
Widen useVoiceParticipantMeta to return the full user object and add a
standalone avatarColor prop to Avatar so all ~16 callsites now resolve
the user's chosen gradient color instead of falling back to hash-based colors.
This commit is contained in:
Jannis Braun
2026-03-11 01:12:12 +01:00
parent 9255683d8c
commit 3790386a5f
16 changed files with 131 additions and 12 deletions
@@ -245,7 +245,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
return (
<div className="flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
<div className="flex items-center gap-3">
<Avatar src={friend.avatar} name={friend.displayName ?? friend.username} size={32} status={friend.status} userId={friend.homeUserId ?? friend.id} />
<Avatar src={friend.avatar} name={friend.displayName ?? friend.username} size={32} status={friend.status} userId={friend.homeUserId ?? friend.id} avatarColor={friend.avatarColor} />
<div className="flex flex-col leading-tight">
<div className="flex items-center gap-1.5">
<span className="text-txt-primary font-semibold text-[15px]">{friend.displayName ?? friend.username}</span>
@@ -297,7 +297,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
return (
<div className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
<div className="flex items-center gap-3">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} userId={user.homeUserId ?? user.id} />
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} userId={user.homeUserId ?? user.id} avatarColor={user.avatarColor} />
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="text-txt-primary font-bold text-sm">{user.displayName ?? user.username}</span>
@@ -82,6 +82,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
size={24}
status={member.user.status}
userId={member.user.homeUserId ?? member.user.id}
user={member.user}
/>
<span
className="text-[14px] font-medium truncate"
@@ -68,6 +68,7 @@ export function ActivityPanel() {
status={isOffline ? 'offline' : friend.status}
className={isOffline ? 'opacity-60' : undefined}
userId={friend.homeUserId ?? friend.id}
avatarColor={friend.avatarColor}
/>
<div className="flex-1 min-w-0">
<Username
@@ -236,12 +236,12 @@ export function ChannelSidebar() {
zIndex: 2 - i,
}}
>
<Avatar src={m.avatar} name={m.displayName ?? parseFederatedUsername(m.username).baseName} size={22} userId={m.homeUserId ?? m.id} />
<Avatar src={m.avatar} name={m.displayName ?? parseFederatedUsername(m.username).baseName} size={22} userId={m.homeUserId ?? m.id} user={m} />
</div>
))}
</div>
) : (
<Avatar src={otherMembers[0]?.avatar} name={otherMembers[0]?.displayName ?? parseFederatedUsername(otherMembers[0]?.username ?? '').baseName} size={32} status={otherMembers[0]?.status as any} userId={otherMembers[0]?.homeUserId ?? otherMembers[0]?.id} />
<Avatar src={otherMembers[0]?.avatar} name={otherMembers[0]?.displayName ?? parseFederatedUsername(otherMembers[0]?.username ?? '').baseName} size={32} status={otherMembers[0]?.status as any} userId={otherMembers[0]?.homeUserId ?? otherMembers[0]?.id} user={otherMembers[0]} />
)}
<div className="flex-1 min-w-0">
<Username
+3 -2
View File
@@ -13,6 +13,7 @@ interface AvatarProps {
user?: User;
userId?: string;
ring?: { width: number; color: string };
avatarColor?: string | null;
}
const statusColors: Record<string, string> = {
@@ -54,12 +55,12 @@ function getDotMetrics(avatarSize: number, ringWidth: number = 0) {
return { dot, gap, inset: avatarInset + ringWidth };
}
export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId, ring }: AvatarProps) {
export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId, ring, avatarColor }: AvatarProps) {
const openUserProfile = useUIStore((s) => s.openUserProfile);
const initials = name.charAt(0).toUpperCase();
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18;
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, user?.avatarColor);
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, avatarColor ?? user?.avatarColor);
const ringWidth = ring?.width ?? 0;
const outerSize = size + ringWidth * 2;
@@ -322,7 +322,7 @@ export function PictureInPicture() {
[selectedStream, fallbackParticipant],
);
const { displayName: resolvedName, avatar: resolvedAvatar } =
const { displayName: resolvedName, avatar: resolvedAvatar, user: resolvedUser } =
useVoiceParticipantMeta(displayParticipant ?? EMPTY_PARTICIPANT);
if (!shouldShow) return null;
@@ -370,6 +370,7 @@ export function PictureInPicture() {
name={resolvedName}
size={64}
userId={displayParticipant.homeUserId ?? displayParticipant.userId}
user={resolvedUser ?? undefined}
/>
{speakingParticipantIds.has(displayParticipant.identity) && (
<div className="absolute -inset-1 rounded-full ring-2 ring-status-online animate-pulse" />
@@ -20,7 +20,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
const isLocal = participant.isLocal;
const userId = participant.userId;
const avatarUserId = participant.homeUserId ?? userId;
const { displayName, avatar } = useVoiceParticipantMeta(participant);
const { displayName, avatar, user } = useVoiceParticipantMeta(participant);
const isWatching = watchingStreams.has(userId);
@@ -110,7 +110,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-surface-channel">
<div className="relative">
<Avatar src={avatar} name={displayName} size={large ? 80 : 48} userId={avatarUserId} />
<Avatar src={avatar} name={displayName} size={large ? 80 : 48} userId={avatarUserId} user={user ?? undefined} />
</div>
<div className="text-center px-4">
<p className="text-txt-primary text-sm font-semibold">
@@ -113,6 +113,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
size={24}
status={status}
userId={member?.user.homeUserId ?? userId}
user={member?.user}
/>
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */}
@@ -29,7 +29,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const isLocal = participant.isLocal;
const avatarUserId = participant.homeUserId ?? participant.userId;
const { displayName, avatar } = useVoiceParticipantMeta(participant);
const { displayName, avatar, user } = useVoiceParticipantMeta(participant);
// --- VIDEO & UI ---
@@ -98,6 +98,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
name={displayName}
size={large ? 100 : 64}
userId={avatarUserId}
user={user ?? undefined}
/>
{isSpeaking && (
<div className="absolute -inset-1.5 rounded-full ring-[3px] ring-status-online animate-pulse" />