ui: show sender names in group bubbles with stable colors
All checks were successful
CI / test (push) Successful in 26s
All checks were successful
CI / test (push) Successful in 26s
This commit is contained in:
@@ -14,7 +14,7 @@ Legend:
|
|||||||
5. Chat List - `DONE` (all/pinned/archive/sort/unread)
|
5. Chat List - `DONE` (all/pinned/archive/sort/unread)
|
||||||
6. Chat Types - `DONE` (private/group/channel)
|
6. Chat Types - `DONE` (private/group/channel)
|
||||||
7. Chat Creation - `DONE` (private/group/channel)
|
7. Chat Creation - `DONE` (private/group/channel)
|
||||||
8. Messages (base) - `DONE` (send/read/edit/delete/delete for all)
|
8. Messages (base) - `DONE` (send/read/edit/delete/delete for all; group UI shows sender names over bubbles)
|
||||||
9. Message Types - `PARTIAL` (text/photo/video/docs/audio/voice/circle; GIF/stickers via dedicated system missing)
|
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)
|
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)
|
11. Forwarding - `PARTIAL` (single + bulk; "without author" missing)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { useUiStore } from "../store/uiStore";
|
|||||||
import { formatTime } from "../utils/format";
|
import { formatTime } from "../utils/format";
|
||||||
import { formatMessageHtml } from "../utils/formatMessage";
|
import { formatMessageHtml } from "../utils/formatMessage";
|
||||||
import { MediaViewer } from "./MediaViewer";
|
import { MediaViewer } from "./MediaViewer";
|
||||||
|
import { getUserById } from "../api/users";
|
||||||
|
import type { AuthUser } from "../chat/types";
|
||||||
|
|
||||||
type ContextMenuState = {
|
type ContextMenuState = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -78,6 +80,7 @@ export function MessageList() {
|
|||||||
const [threadLoading, setThreadLoading] = useState(false);
|
const [threadLoading, setThreadLoading] = useState(false);
|
||||||
const [threadError, setThreadError] = useState<string | null>(null);
|
const [threadError, setThreadError] = useState<string | null>(null);
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
|
const [senderProfiles, setSenderProfiles] = useState<Record<number, Pick<AuthUser, "id" | "name" | "username">>>({});
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
@@ -179,8 +182,39 @@ export function MessageList() {
|
|||||||
setThreadError(null);
|
setThreadError(null);
|
||||||
setReactionsByMessage({});
|
setReactionsByMessage({});
|
||||||
setAttachmentsByMessage({});
|
setAttachmentsByMessage({});
|
||||||
|
setSenderProfiles({});
|
||||||
}, [activeChatId, setEditingMessage]);
|
}, [activeChatId, setEditingMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeChatId || activeChat?.type !== "group") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const senderIds = [...new Set(messages.map((message) => message.sender_id).filter((id) => id !== me?.id))];
|
||||||
|
const missing = senderIds.filter((id) => !senderProfiles[id]);
|
||||||
|
if (!missing.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const rows = await Promise.allSettled(missing.map((id) => getUserById(id)));
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const patch: Record<number, Pick<AuthUser, "id" | "name" | "username">> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length) {
|
||||||
|
setSenderProfiles((prev) => ({ ...prev, ...patch }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeChat?.type, activeChatId, me?.id, messages, senderProfiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeChatId) {
|
if (!activeChatId) {
|
||||||
setAttachmentsByMessage({});
|
setAttachmentsByMessage({});
|
||||||
@@ -483,6 +517,9 @@ export function MessageList() {
|
|||||||
Math.abs(new Date(message.created_at).getTime() - new Date(prev.created_at).getTime()) < 4 * 60 * 1000
|
Math.abs(new Date(message.created_at).getTime() - new Date(prev.created_at).getTime()) < 4 * 60 * 1000
|
||||||
);
|
);
|
||||||
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
|
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
|
||||||
|
const showSenderName = !own && activeChat?.type === "group" && !groupedWithPrev;
|
||||||
|
const senderName = formatSenderName(message.sender_id, senderProfiles);
|
||||||
|
const senderColor = senderNameColor(message.sender_id);
|
||||||
const isSelected = selectedIds.has(message.id);
|
const isSelected = selectedIds.has(message.id);
|
||||||
const messageReactions = reactionsByMessage[message.id] ?? [];
|
const messageReactions = reactionsByMessage[message.id] ?? [];
|
||||||
|
|
||||||
@@ -553,11 +590,22 @@ export function MessageList() {
|
|||||||
: "border-sky-400 bg-slate-800/60 text-slate-300"
|
: "border-sky-400 bg-slate-800/60 text-slate-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="truncate font-semibold">{replySource.sender_id === me?.id ? "You" : "Reply"}</p>
|
<p
|
||||||
|
className="truncate font-semibold"
|
||||||
|
style={replySource.sender_id === me?.id ? undefined : activeChat?.type === "group" ? { color: senderNameColor(replySource.sender_id) } : undefined}
|
||||||
|
>
|
||||||
|
{replySource.sender_id === me?.id ? "You" : formatSenderName(replySource.sender_id, senderProfiles)}
|
||||||
|
</p>
|
||||||
<p className="truncate">{replySource.text || "[media]"}</p>
|
<p className="truncate">{replySource.text || "[media]"}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showSenderName ? (
|
||||||
|
<p className="mb-1 truncate text-[12px] font-semibold" style={{ color: senderColor }}>
|
||||||
|
{senderName}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{renderMessageContent(message, {
|
{renderMessageContent(message, {
|
||||||
attachments: attachmentsByMessage[message.id] ?? [],
|
attachments: attachmentsByMessage[message.id] ?? [],
|
||||||
onAttachmentContextMenu: (event, url) => {
|
onAttachmentContextMenu: (event, url) => {
|
||||||
@@ -1441,3 +1489,22 @@ function formatAudioTime(seconds: number): string {
|
|||||||
const rem = total % 60;
|
const rem = total % 60;
|
||||||
return `${minutes}:${String(rem).padStart(2, "0")}`;
|
return `${minutes}:${String(rem).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSenderName(
|
||||||
|
senderId: number,
|
||||||
|
profiles: Record<number, Pick<AuthUser, "id" | "name" | "username">>
|
||||||
|
): string {
|
||||||
|
const profile = profiles[senderId];
|
||||||
|
if (profile?.name?.trim()) {
|
||||||
|
return profile.name.trim();
|
||||||
|
}
|
||||||
|
if (profile?.username?.trim()) {
|
||||||
|
return `@${profile.username.trim()}`;
|
||||||
|
}
|
||||||
|
return `User #${senderId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function senderNameColor(senderId: number): string {
|
||||||
|
const hue = (senderId * 67) % 360;
|
||||||
|
return `hsl(${hue} 85% 65%)`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user