feat(web): add telegram-like message status indicators
All checks were successful
CI / test (push) Successful in 21s

- optimistic sending state with pending clock icon

- transition statuses sent -> delivered -> read via realtime events

- render checkmarks next to outgoing message timestamps
This commit is contained in:
2026-03-08 00:01:22 +03:00
parent f6ad480973
commit 16a584c6cb
5 changed files with 163 additions and 16 deletions

View File

@@ -6,7 +6,10 @@ import { buildWsUrl } from "../utils/ws";
export function MessageComposer() {
const activeChatId = useChatStore((s) => s.activeChatId);
const prependMessage = useChatStore((s) => s.prependMessage);
const me = useAuthStore((s) => s.me);
const addOptimisticMessage = useChatStore((s) => s.addOptimisticMessage);
const confirmMessageByClientId = useChatStore((s) => s.confirmMessageByClientId);
const removeOptimisticMessage = useChatStore((s) => s.removeOptimisticMessage);
const accessToken = useAuthStore((s) => s.accessToken);
const [text, setText] = useState("");
const wsRef = useRef<WebSocket | null>(null);
@@ -48,30 +51,47 @@ export function MessageComposer() {
}
async function handleSend() {
if (!activeChatId || !text.trim()) {
if (!activeChatId || !text.trim() || !me) {
return;
}
const message = await sendMessageWithClientId(activeChatId, text.trim(), "text", makeClientMessageId());
prependMessage(activeChatId, message);
setText("");
const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } }));
const clientMessageId = makeClientMessageId();
const textValue = text.trim();
addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId });
try {
const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId);
confirmMessageByClientId(activeChatId, clientMessageId, message);
setText("");
const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } }));
} catch {
removeOptimisticMessage(activeChatId, clientMessageId);
setUploadError("Message send failed. Please try again.");
}
}
async function handleUpload(file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file") {
if (!activeChatId) {
if (!activeChatId || !me) {
return;
}
setIsUploading(true);
setUploadProgress(0);
setUploadError(null);
const clientMessageId = makeClientMessageId();
try {
const upload = await requestUploadUrl(file);
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress);
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, makeClientMessageId());
addOptimisticMessage({
chatId: activeChatId,
senderId: me.id,
type: messageType,
text: upload.file_url,
clientMessageId
});
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, clientMessageId);
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
prependMessage(activeChatId, message);
confirmMessageByClientId(activeChatId, clientMessageId, message);
} catch {
removeOptimisticMessage(activeChatId, clientMessageId);
setUploadError("Upload failed. Please try again.");
} finally {
setIsUploading(false);

View File

@@ -31,7 +31,10 @@ export function MessageList() {
) : (
renderContent(message.type, message.text)
)}
<p className="mt-1 text-right text-[11px] text-slate-400">{formatTime(message.created_at)}</p>
<p className="mt-1 flex items-center justify-end gap-1 text-right text-[11px] text-slate-400">
<span>{formatTime(message.created_at)}</span>
{message.sender_id === me?.id ? <span className={message.delivery_status === "read" ? "text-sky-400" : ""}>{renderStatus(message.delivery_status)}</span> : null}
</p>
</div>
</div>
))}
@@ -61,6 +64,19 @@ export function MessageList() {
Open file
</a>
);
}
}
return <p className="whitespace-pre-wrap break-words">{text}</p>;
}
function renderStatus(status: string | undefined): string {
if (status === "sending") {
return "⌛";
}
if (status === "delivered") {
return "✓✓";
}
if (status === "read") {
return "✓✓";
}
return "✓";
}