From 16a584c6cb72b06b57bbfe66aba22c3caa1df767 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 00:01:22 +0300 Subject: [PATCH] feat(web): add telegram-like message status indicators - optimistic sending state with pending clock icon - transition statuses sent -> delivered -> read via realtime events - render checkmarks next to outgoing message timestamps --- web/src/chat/types.ts | 4 ++ web/src/components/MessageComposer.tsx | 40 +++++++++--- web/src/components/MessageList.tsx | 20 +++++- web/src/hooks/useRealtime.ts | 27 +++++++- web/src/store/chatStore.ts | 88 +++++++++++++++++++++++++- 5 files changed, 163 insertions(+), 16 deletions(-) diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 9095afd..5e4e829 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -1,5 +1,6 @@ export type ChatType = "private" | "group" | "channel"; export type MessageType = "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video"; +export type DeliveryStatus = "sending" | "sent" | "delivered" | "read"; export interface Chat { id: number; @@ -16,6 +17,9 @@ export interface Message { text: string | null; created_at: string; updated_at: string; + client_message_id?: string; + delivery_status?: DeliveryStatus; + is_pending?: boolean; } export interface AuthUser { diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index 96465f3..a59936e 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -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(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); diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 8b6f809..8a2790d 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -31,7 +31,10 @@ export function MessageList() { ) : ( renderContent(message.type, message.text) )} -

{formatTime(message.created_at)}

+

+ {formatTime(message.created_at)} + {message.sender_id === me?.id ? {renderStatus(message.delivery_status)} : null} +

))} @@ -61,6 +64,19 @@ export function MessageList() { Open file ); - } + } return

{text}

; } + +function renderStatus(status: string | undefined): string { + if (status === "sending") { + return "⌛"; + } + if (status === "delivered") { + return "✓✓"; + } + if (status === "read") { + return "✓✓"; + } + return "✓"; +} diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts index f709912..315795e 100644 --- a/web/src/hooks/useRealtime.ts +++ b/web/src/hooks/useRealtime.ts @@ -14,6 +14,8 @@ export function useRealtime() { const accessToken = useAuthStore((s) => s.accessToken); const me = useAuthStore((s) => s.me); const prependMessage = useChatStore((s) => s.prependMessage); + const confirmMessageByClientId = useChatStore((s) => s.confirmMessageByClientId); + const setMessageDeliveryStatus = useChatStore((s) => s.setMessageDeliveryStatus); const loadChats = useChatStore((s) => s.loadChats); const chats = useChatStore((s) => s.chats); const activeChatId = useChatStore((s) => s.activeChatId); @@ -34,7 +36,12 @@ export function useRealtime() { if (event.event === "receive_message") { const chatId = Number(event.payload.chat_id); const message = event.payload.message as Message; - prependMessage(chatId, message); + const clientMessageId = event.payload.client_message_id as string | undefined; + if (clientMessageId && message.sender_id === me?.id) { + confirmMessageByClientId(chatId, clientMessageId, message); + } else { + prependMessage(chatId, message); + } if (message.sender_id !== me?.id) { ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } })); if (chatId === activeChatId) { @@ -63,10 +70,26 @@ export function useRealtime() { typingByChat.current[chatId]?.delete(userId); useChatStore.getState().setTypingUsers(chatId, [...(typingByChat.current[chatId] ?? [])]); } + if (event.event === "message_delivered") { + const chatId = Number(event.payload.chat_id); + const messageId = Number(event.payload.message_id); + const userId = Number(event.payload.user_id); + if (userId !== me?.id) { + setMessageDeliveryStatus(chatId, messageId, "delivered"); + } + } + if (event.event === "message_read") { + const chatId = Number(event.payload.chat_id); + const messageId = Number(event.payload.message_id); + const userId = Number(event.payload.user_id); + if (userId !== me?.id) { + setMessageDeliveryStatus(chatId, messageId, "read"); + } + } }; return () => ws.close(); - }, [wsUrl, prependMessage, loadChats, chats, me?.id, activeChatId]); + }, [wsUrl, prependMessage, confirmMessageByClientId, setMessageDeliveryStatus, loadChats, chats, me?.id, activeChatId]); return null; } diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index 544a68b..d6275b1 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { getChats, getMessages, updateMessageStatus } from "../api/chats"; -import type { Chat, Message } from "../chat/types"; +import type { Chat, DeliveryStatus, Message, MessageType } from "../chat/types"; interface ChatState { chats: Chat[]; @@ -11,6 +11,16 @@ interface ChatState { setActiveChatId: (chatId: number | null) => void; loadMessages: (chatId: number) => Promise; prependMessage: (chatId: number, message: Message) => void; + addOptimisticMessage: (params: { + chatId: number; + senderId: number; + type: MessageType; + text: string | null; + clientMessageId: string; + }) => void; + confirmMessageByClientId: (chatId: number, clientMessageId: string, message: Message) => void; + removeOptimisticMessage: (chatId: number, clientMessageId: string) => void; + setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void; setTypingUsers: (chatId: number, userIds: number[]) => void; } @@ -40,13 +50,87 @@ export const useChatStore = create((set, get) => ({ }, prependMessage: (chatId, message) => { const old = get().messagesByChat[chatId] ?? []; - if (old.some((m) => m.id === message.id)) { + if (old.some((m) => m.id === message.id || (message.client_message_id && m.client_message_id === message.client_message_id))) { return; } set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: [...old, message] } })); }, + addOptimisticMessage: ({ chatId, senderId, type, text, clientMessageId }) => { + const old = get().messagesByChat[chatId] ?? []; + if (old.some((m) => m.client_message_id === clientMessageId)) { + return; + } + const now = new Date().toISOString(); + const optimistic: Message = { + id: -(Date.now() + Math.floor(Math.random() * 10000)), + chat_id: chatId, + sender_id: senderId, + type, + text, + created_at: now, + updated_at: now, + client_message_id: clientMessageId, + delivery_status: "sending", + is_pending: true + }; + set((state) => ({ + messagesByChat: { ...state.messagesByChat, [chatId]: [...old, optimistic] } + })); + }, + confirmMessageByClientId: (chatId, clientMessageId, message) => { + const old = get().messagesByChat[chatId] ?? []; + const idx = old.findIndex((m) => m.client_message_id === clientMessageId); + if (idx === -1) { + if (!old.some((m) => m.id === message.id)) { + set((state) => ({ + messagesByChat: { + ...state.messagesByChat, + [chatId]: [...old, { ...message, client_message_id: clientMessageId, delivery_status: "sent", is_pending: false }] + } + })); + } + return; + } + const next = [...old]; + next[idx] = { + ...message, + client_message_id: clientMessageId, + delivery_status: "sent", + is_pending: false + }; + set((state) => ({ + messagesByChat: { ...state.messagesByChat, [chatId]: next } + })); + }, + removeOptimisticMessage: (chatId, clientMessageId) => { + const old = get().messagesByChat[chatId] ?? []; + set((state) => ({ + messagesByChat: { + ...state.messagesByChat, + [chatId]: old.filter((m) => m.client_message_id !== clientMessageId) + } + })); + }, + setMessageDeliveryStatus: (chatId, messageId, status) => { + const old = get().messagesByChat[chatId] ?? []; + const idx = old.findIndex((m) => m.id === messageId); + if (idx === -1) { + return; + } + const current = old[idx]; + const order: Record = { sending: 1, sent: 2, delivered: 3, read: 4 }; + const currentStatus = current.delivery_status ?? "sent"; + if (order[status] <= order[currentStatus]) { + return; + } + const next = [...old]; + next[idx] = { ...current, delivery_status: status, is_pending: false }; + set((state) => ({ + messagesByChat: { ...state.messagesByChat, [chatId]: next } + })); + }, setTypingUsers: (chatId, userIds) => set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } })) }));