web(group-ui): show sender avatars on incoming clusters
All checks were successful
CI / test (push) Successful in 28s

This commit is contained in:
2026-03-08 18:54:55 +03:00
parent 0db741cb8e
commit 661f8acf63
2 changed files with 45 additions and 6 deletions

View File

@@ -14,7 +14,7 @@ Legend:
5. Chat List - `DONE` (all/pinned/archive/sort/unread)
6. Chat Types - `DONE` (private/group/channel)
7. Chat Creation - `DONE` (private/group/channel)
8. Messages (base) - `DONE` (send/read/edit/delete/delete for all; group UI shows sender names over bubbles)
8. Messages (base) - `DONE` (send/read/edit/delete/delete for all; group UI shows sender names over bubbles + sender avatars on incoming message clusters)
9. Message Types - `PARTIAL` (text/photo/video/docs/audio/voice/circle; GIF/stickers via dedicated system missing)
10. Reply/Quote/Threads - `PARTIAL` (reply + quote-like UI + thread panel with nested replies, no dedicated full thread navigation yet)
11. Forwarding - `PARTIAL` (single + bulk; "without author" missing)

View File

@@ -80,7 +80,7 @@ export function MessageList() {
const [threadLoading, setThreadLoading] = useState(false);
const [threadError, setThreadError] = useState<string | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [senderProfiles, setSenderProfiles] = useState<Record<number, Pick<AuthUser, "id" | "name" | "username">>>({});
const [senderProfiles, setSenderProfiles] = useState<Record<number, Pick<AuthUser, "id" | "name" | "username" | "avatar_url">>>({});
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const messages = useMemo(() => {
@@ -200,10 +200,15 @@ export function MessageList() {
if (cancelled) {
return;
}
const patch: Record<number, Pick<AuthUser, "id" | "name" | "username">> = {};
const patch: Record<number, Pick<AuthUser, "id" | "name" | "username" | "avatar_url">> = {};
for (const row of rows) {
if (row.status === "fulfilled") {
patch[row.value.id] = { id: row.value.id, name: row.value.name, username: row.value.username };
patch[row.value.id] = {
id: row.value.id,
name: row.value.name,
username: row.value.username,
avatar_url: row.value.avatar_url ?? null,
};
}
}
if (Object.keys(patch).length) {
@@ -522,6 +527,8 @@ export function MessageList() {
const senderColor = senderNameColor(message.sender_id);
const isSelected = selectedIds.has(message.id);
const messageReactions = reactionsByMessage[message.id] ?? [];
const showSenderAvatar = !own && activeChat?.type === "group" && !groupedWithPrev;
const senderAvatarUrl = senderProfiles[message.sender_id]?.avatar_url ?? null;
return (
<div key={`${message.id}-${message.client_message_id ?? ""}`}>
@@ -535,7 +542,27 @@ export function MessageList() {
</div>
) : null}
<div className={`${groupedWithPrev ? "mb-1" : "mb-2"} flex ${own ? "justify-end" : "justify-start"}`}>
<div className={`${groupedWithPrev ? "mb-1" : "mb-2"} flex items-end gap-2 ${own ? "justify-end" : "justify-start"}`}>
{!own && activeChat?.type === "group" ? (
showSenderAvatar ? (
senderAvatarUrl ? (
<img
alt={senderName}
className="h-8 w-8 rounded-full border border-slate-700/70 object-cover"
src={senderAvatarUrl}
/>
) : (
<div
className="flex h-8 w-8 items-center justify-center rounded-full border border-slate-700/70 bg-slate-800 text-[11px] font-semibold text-slate-200"
title={senderName}
>
{senderInitials(senderName)}
</div>
)
) : (
<div className="h-8 w-8 shrink-0" />
)
) : null}
<div
id={`message-${message.id}`}
className={`max-w-[90%] px-3 py-2.5 shadow-sm md:max-w-[70%] ${
@@ -1510,7 +1537,7 @@ function formatAudioTime(seconds: number): string {
function formatSenderName(
senderId: number,
profiles: Record<number, Pick<AuthUser, "id" | "name" | "username">>
profiles: Record<number, Pick<AuthUser, "id" | "name" | "username" | "avatar_url">>
): string {
const profile = profiles[senderId];
if (profile?.name?.trim()) {
@@ -1526,3 +1553,15 @@ function senderNameColor(senderId: number): string {
const hue = (senderId * 67) % 360;
return `hsl(${hue} 85% 65%)`;
}
function senderInitials(name: string): string {
const words = name
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (!words.length) {
return "?";
}
return words.map((word) => word[0]?.toUpperCase() ?? "").join("");
}