feat(web): improve message UX, voice gestures, and attachments
Some checks failed
CI / test (push) Failing after 21s

This commit is contained in:
2026-03-08 10:20:52 +03:00
parent 52c41b6958
commit 6a96a99775
9 changed files with 857 additions and 212 deletions

View File

@@ -1,6 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import { deleteMessage, forwardMessageBulk, listMessageReactions, pinMessage, toggleMessageReaction } from "../api/chats";
import {
deleteMessage,
forwardMessageBulk,
listMessageReactions,
pinMessage,
toggleMessageReaction
} from "../api/chats";
import type { Message, MessageReaction } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
@@ -13,6 +19,12 @@ type ContextMenuState = {
messageId: number;
} | null;
type AttachmentMenuState = {
x: number;
y: number;
url: string;
} | null;
type PendingDeleteState = {
chatId: number;
messages: Message[];
@@ -20,6 +32,8 @@ type PendingDeleteState = {
timerId: number;
} | null;
const QUICK_REACTIONS = ["👍", "❤️", "🔥", "😂", "😮", "😢"];
export function MessageList() {
const me = useAuthStore((s) => s.me);
const activeChatId = useChatStore((s) => s.activeChatId);
@@ -36,7 +50,9 @@ export function MessageList() {
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const removeMessage = useChatStore((s) => s.removeMessage);
const restoreMessages = useChatStore((s) => s.restoreMessages);
const [ctx, setCtx] = useState<ContextMenuState>(null);
const [attachmentCtx, setAttachmentCtx] = useState<AttachmentMenuState>(null);
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
const [forwardQuery, setForwardQuery] = useState("");
const [forwardError, setForwardError] = useState<string | null>(null);
@@ -72,10 +88,7 @@ export function MessageList() {
const focusedMessageId = activeChatId ? (focusedMessageIdByChat[activeChatId] ?? null) : null;
const hasMore = Boolean(activeChatId && hasMoreByChat[activeChatId]);
const isLoadingMore = Boolean(activeChatId && loadingMoreByChat[activeChatId]);
const selectedMessages = useMemo(
() => messages.filter((m) => selectedIds.has(m.id)),
[messages, selectedIds]
);
const selectedMessages = useMemo(() => messages.filter((m) => selectedIds.has(m.id)), [messages, selectedIds]);
const canDeleteAllForSelection = useMemo(
() => selectedMessages.length > 0 && selectedMessages.every((m) => canDeleteForEveryone(m, activeChat, me?.id)),
[selectedMessages, activeChat, me?.id]
@@ -87,6 +100,7 @@ export function MessageList() {
return;
}
setCtx(null);
setAttachmentCtx(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setDeleteMessageId(null);
@@ -99,6 +113,7 @@ export function MessageList() {
useEffect(() => {
setSelectedIds(new Set());
setCtx(null);
setAttachmentCtx(null);
setDeleteMessageId(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
@@ -131,6 +146,27 @@ export function MessageList() {
}
const chatId = activeChatId;
async function ensureReactionsLoaded(messageId: number) {
if (reactionsByMessage[messageId]) {
return;
}
try {
const rows = await listMessageReactions(messageId);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
async function handleToggleReaction(messageId: number, emoji: string) {
try {
const rows = await toggleMessageReaction(messageId, emoji);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
async function handleForwardSubmit() {
if (!forwardMessageId) return;
const targetChatIds = [...forwardSelectedChatIds];
@@ -174,27 +210,6 @@ export function MessageList() {
}
}
async function ensureReactionsLoaded(messageId: number) {
if (reactionsByMessage[messageId]) {
return;
}
try {
const rows = await listMessageReactions(messageId);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
async function handleToggleReaction(messageId: number, emoji: string) {
try {
const rows = await toggleMessageReaction(messageId, emoji);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
function toggleSelected(messageId: number) {
setSelectedIds((prev) => {
const next = new Set(prev);
@@ -283,7 +298,7 @@ export function MessageList() {
}
return (
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
<div className="flex h-full flex-col" onClick={() => { setCtx(null); setAttachmentCtx(null); }}>
{activeChat?.pinned_message_id ? (
<div className="border-b border-slate-700/50 bg-slate-900/60 px-4 py-2 text-xs text-sky-300">
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
@@ -307,7 +322,8 @@ export function MessageList() {
</div>
</div>
) : null}
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
<div className="tg-scrollbar flex-1 overflow-auto px-2 py-4 md:px-5">
{hasMore ? (
<div className="mb-3 flex justify-center">
<button
@@ -319,10 +335,13 @@ export function MessageList() {
</button>
</div>
) : null}
{messages.map((message, messageIndex) => {
const own = message.sender_id === me?.id;
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
const isSelected = selectedIds.has(message.id);
const messageReactions = reactionsByMessage[message.id] ?? [];
return (
<div key={`${message.id}-${message.client_message_id ?? ""}`}>
{unreadBoundaryIndex === messageIndex ? (
@@ -334,143 +353,222 @@ export function MessageList() {
<span className="h-px flex-1 bg-slate-700/60" />
</div>
) : null}
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`}>
<div
id={`message-${message.id}`}
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
} ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`}
onClick={() => {
if (selectedIds.size > 0) {
toggleSelected(message.id);
}
}}
onContextMenu={(e) => {
e.preventDefault();
void ensureReactionsLoaded(message.id);
const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108);
setCtx({ x: pos.x, y: pos.y, messageId: message.id });
}}
>
{selectedIds.size > 0 ? (
<div className={`mb-1 inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] ${isSelected ? "border-sky-300 bg-sky-300 text-slate-900" : "border-slate-400 text-slate-300"}`}>
{isSelected ? "✓" : ""}
</div>
) : null}
{message.forwarded_from_message_id ? (
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-sky-300"}`}>
Forwarded message
</div>
) : null}
{replySource ? (
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "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">{replySource.text || "[media]"}</p>
</div>
) : null}
{renderContent(message.type, message.text)}
<div className="mt-1 flex flex-wrap gap-1">
{["👍", "❤️", "🔥"].map((emoji) => {
const items = reactionsByMessage[message.id] ?? [];
const item = items.find((reaction) => reaction.emoji === emoji);
return (
<button
className={`rounded-full border px-2 py-0.5 text-[11px] ${
item?.reacted ? "border-sky-300 bg-sky-500/30" : "border-slate-500/60 bg-slate-700/40"
}`}
key={`${message.id}-${emoji}`}
onClick={() => void handleToggleReaction(message.id, emoji)}
type="button"
>
{emoji}{item ? ` ${item.count}` : ""}
</button>
);
<div
id={`message-${message.id}`}
className={`max-w-[90%] rounded-2xl px-3 py-2.5 shadow-sm md:max-w-[70%] ${
own
? "rounded-br-md bg-gradient-to-b from-sky-500/95 to-sky-600/90 text-slate-950"
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
} ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`}
onClick={() => {
if (selectedIds.size > 0) {
toggleSelected(message.id);
}
}}
onContextMenu={(event) => {
event.preventDefault();
void ensureReactionsLoaded(message.id);
const pos = getSafeContextPosition(event.clientX, event.clientY, 220, 220);
setCtx({ x: pos.x, y: pos.y, messageId: message.id });
}}
>
{selectedIds.size > 0 ? (
<div
className={`mb-1 inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] ${
isSelected ? "border-sky-300 bg-sky-300 text-slate-900" : "border-slate-400 text-slate-300"
}`}
>
{isSelected ? "✓" : ""}
</div>
) : null}
{message.forwarded_from_message_id ? (
<div
className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${
own
? "border-slate-900/60 bg-slate-900/10 text-slate-900/75"
: "border-sky-400 bg-slate-800/60 text-sky-300"
}`}
>
Forwarded message
</div>
) : null}
{replySource ? (
<div
className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${
own
? "border-slate-900/60 bg-slate-900/10 text-slate-900/75"
: "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">{replySource.text || "[media]"}</p>
</div>
) : null}
{renderMessageContent(message.type, message.text, {
onAttachmentContextMenu: (event, url) => {
event.preventDefault();
const pos = getSafeContextPosition(event.clientX, event.clientY, 180, 110);
setAttachmentCtx({ x: pos.x, y: pos.y, url });
}
})}
{messageReactions.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{messageReactions.map((reaction) => (
<span
className={`rounded-full border px-2 py-0.5 text-[11px] ${
reaction.reacted
? "border-sky-300 bg-sky-500/30"
: own
? "border-slate-900/30 bg-slate-900/10"
: "border-slate-600/60 bg-slate-800/60"
}`}
key={`${message.id}-${reaction.emoji}`}
>
{reaction.emoji} {reaction.count}
</span>
))}
</div>
) : null}
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span>
{own ? (
<span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span>
) : null}
</p>
</div>
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span>
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
</p>
</div>
</div>
</div>
);
})}
</div>
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
{ctx
? createPortal(
<div
className="fixed z-[100] w-40 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: ctx.x, top: ctx.y }}
onClick={(e) => e.stopPropagation()}
>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
const msg = messagesMap.get(ctx.messageId);
if (msg) {
setReplyToMessage(chatId, msg);
}
setCtx(null);
}}
<div
className="fixed z-[110] w-56 rounded-xl border border-slate-700/90 bg-slate-900/95 p-1.5 shadow-2xl"
style={{ left: ctx.x, top: ctx.y }}
onClick={(event) => event.stopPropagation()}
>
Reply
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
setForwardMessageId(ctx.messageId);
setForwardQuery("");
setForwardError(null);
setForwardSelectedChatIds(new Set());
setCtx(null);
}}
<div className="mb-1 flex flex-wrap gap-1 rounded-lg bg-slate-800/80 p-1">
{QUICK_REACTIONS.map((emoji) => (
<button
className="rounded-md px-1.5 py-1 text-sm hover:bg-slate-700"
key={emoji}
onClick={() => void handleToggleReaction(ctx.messageId, emoji)}
type="button"
>
{emoji}
</button>
))}
</div>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
const msg = messagesMap.get(ctx.messageId);
if (msg) {
setReplyToMessage(chatId, msg);
}
setCtx(null);
}}
>
Reply
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
setForwardMessageId(ctx.messageId);
setForwardQuery("");
setForwardError(null);
setForwardSelectedChatIds(new Set());
setCtx(null);
}}
>
Forward
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
setSelectedIds(new Set([ctx.messageId]));
setCtx(null);
}}
>
Select
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={() => {
setDeleteMessageId(ctx.messageId);
setDeleteError(null);
setCtx(null);
}}
>
Delete
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handlePin(ctx.messageId)}>
Pin / Unpin
</button>
</div>,
document.body
)
: null}
{attachmentCtx
? createPortal(
<div
className="fixed z-[111] w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: attachmentCtx.x, top: attachmentCtx.y }}
onClick={(event) => event.stopPropagation()}
>
Forward
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
setSelectedIds(new Set([ctx.messageId]));
setCtx(null);
}}
>
Select
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={() => {
setDeleteMessageId(ctx.messageId);
setDeleteError(null);
setCtx(null);
}}
>
Delete
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handlePin(ctx.messageId)}>
Pin / Unpin
</button>
</div>,
document.body
)
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} rel="noreferrer" target="_blank">
Open
</a>
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} download>
Download
</a>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await navigator.clipboard.writeText(attachmentCtx.url);
} catch {
return;
}
}}
type="button"
>
Copy link
</button>
</div>,
document.body
)
: null}
{forwardMessageId ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setForwardMessageId(null)}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(e) => e.stopPropagation()}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
<p className="mb-2 text-sm font-semibold">Forward message</p>
<input
className="mb-2 w-full rounded-lg border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="Search chats"
value={forwardQuery}
onChange={(e) => setForwardQuery(e.target.value)}
onChange={(event) => setForwardQuery(event.target.value)}
/>
<div className="tg-scrollbar max-h-56 space-y-1 overflow-auto">
{forwardTargets.map((chat) => (
<button
className={`block w-full rounded-lg px-3 py-2 text-left text-sm disabled:opacity-60 ${forwardSelectedChatIds.has(chat.id) ? "bg-sky-500/30" : "bg-slate-800 hover:bg-slate-700"}`}
className={`block w-full rounded-lg px-3 py-2 text-left text-sm disabled:opacity-60 ${
forwardSelectedChatIds.has(chat.id) ? "bg-sky-500/30" : "bg-slate-800 hover:bg-slate-700"
}`}
disabled={isForwarding}
key={chat.id}
onClick={() => {
@@ -506,7 +604,7 @@ export function MessageList() {
{deleteMessageId ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(e) => e.stopPropagation()}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
<p className="mb-2 text-sm font-semibold">Delete message</p>
<p className="mb-3 text-xs text-slate-400">Choose how to delete this message.</p>
<div className="space-y-2">
@@ -544,18 +642,70 @@ export function MessageList() {
);
}
function renderContent(messageType: string, text: string | null) {
function renderMessageContent(
messageType: string,
text: string | null,
opts: { onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void }
) {
if (!text) return <p className="opacity-80">[empty]</p>;
if (messageType === "image") return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
if (messageType === "video" || messageType === "circle_video") return <video className="max-h-72 rounded-lg" controls src={text} />;
if (messageType === "audio" || messageType === "voice") return <audio controls src={text} />;
if (messageType === "file") {
if (messageType === "image") {
return (
<a className="underline" href={text} rel="noreferrer" target="_blank">
Open file
</a>
<div className="overflow-hidden rounded-xl bg-slate-950/30" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={text} />
</div>
);
}
if (messageType === "video" || messageType === "circle_video") {
return (
<div className="overflow-hidden rounded-xl bg-slate-950/30" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
<video className="max-h-80 rounded-xl" controls src={text} />
</div>
);
}
if (messageType === "voice") {
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎤</span>
<span className="font-semibold">Voice message</span>
</div>
<audio className="w-full" controls preload="metadata" src={text} />
</div>
);
}
if (messageType === "audio") {
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎵</span>
<div className="min-w-0">
<p className="truncate font-semibold text-slate-200">{extractFileName(text)}</p>
<p className="text-[11px] text-slate-400">Audio file</p>
</div>
</div>
<audio className="w-full" controls preload="metadata" src={text} />
</div>
);
}
if (messageType === "file") {
return (
<button
className="w-full rounded-xl border border-slate-600/60 bg-slate-800/70 px-3 py-2 text-left"
onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}
onClick={() => window.open(text, "_blank", "noopener,noreferrer")}
type="button"
>
<p className="text-xs uppercase tracking-wide text-slate-400">Document</p>
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(text)}</p>
</button>
);
}
return <p className="whitespace-pre-wrap break-words" dangerouslySetInnerHTML={{ __html: formatMessageHtml(text) }} />;
}
@@ -595,3 +745,14 @@ function canDeleteForEveryone(
if (chat.type === "private") return true;
return message.sender_id === meId;
}
function extractFileName(url: string): string {
try {
const parsed = new URL(url);
const value = parsed.pathname.split("/").pop();
return decodeURIComponent(value || "file");
} catch {
const value = url.split("/").pop();
return value ? decodeURIComponent(value) : "file";
}
}