feat(web): add telegram-like message status indicators
All checks were successful
CI / test (push) Successful in 21s
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:
@@ -1,5 +1,6 @@
|
|||||||
export type ChatType = "private" | "group" | "channel";
|
export type ChatType = "private" | "group" | "channel";
|
||||||
export type MessageType = "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video";
|
export type MessageType = "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video";
|
||||||
|
export type DeliveryStatus = "sending" | "sent" | "delivered" | "read";
|
||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,6 +17,9 @@ export interface Message {
|
|||||||
text: string | null;
|
text: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
client_message_id?: string;
|
||||||
|
delivery_status?: DeliveryStatus;
|
||||||
|
is_pending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { buildWsUrl } from "../utils/ws";
|
|||||||
|
|
||||||
export function MessageComposer() {
|
export function MessageComposer() {
|
||||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
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 accessToken = useAuthStore((s) => s.accessToken);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
@@ -48,30 +51,47 @@ export function MessageComposer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
if (!activeChatId || !text.trim()) {
|
if (!activeChatId || !text.trim() || !me) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = await sendMessageWithClientId(activeChatId, text.trim(), "text", makeClientMessageId());
|
const clientMessageId = makeClientMessageId();
|
||||||
prependMessage(activeChatId, message);
|
const textValue = text.trim();
|
||||||
setText("");
|
addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId });
|
||||||
const ws = getWs();
|
try {
|
||||||
ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } }));
|
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") {
|
async function handleUpload(file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file") {
|
||||||
if (!activeChatId) {
|
if (!activeChatId || !me) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
const clientMessageId = makeClientMessageId();
|
||||||
try {
|
try {
|
||||||
const upload = await requestUploadUrl(file);
|
const upload = await requestUploadUrl(file);
|
||||||
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress);
|
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);
|
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
|
||||||
prependMessage(activeChatId, message);
|
confirmMessageByClientId(activeChatId, clientMessageId, message);
|
||||||
} catch {
|
} catch {
|
||||||
|
removeOptimisticMessage(activeChatId, clientMessageId);
|
||||||
setUploadError("Upload failed. Please try again.");
|
setUploadError("Upload failed. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export function MessageList() {
|
|||||||
) : (
|
) : (
|
||||||
renderContent(message.type, message.text)
|
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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -61,6 +64,19 @@ export function MessageList() {
|
|||||||
Open file
|
Open file
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <p className="whitespace-pre-wrap break-words">{text}</p>;
|
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 "✓";
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export function useRealtime() {
|
|||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
const me = useAuthStore((s) => s.me);
|
const me = useAuthStore((s) => s.me);
|
||||||
const prependMessage = useChatStore((s) => s.prependMessage);
|
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 loadChats = useChatStore((s) => s.loadChats);
|
||||||
const chats = useChatStore((s) => s.chats);
|
const chats = useChatStore((s) => s.chats);
|
||||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||||
@@ -34,7 +36,12 @@ export function useRealtime() {
|
|||||||
if (event.event === "receive_message") {
|
if (event.event === "receive_message") {
|
||||||
const chatId = Number(event.payload.chat_id);
|
const chatId = Number(event.payload.chat_id);
|
||||||
const message = event.payload.message as Message;
|
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) {
|
if (message.sender_id !== me?.id) {
|
||||||
ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } }));
|
ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } }));
|
||||||
if (chatId === activeChatId) {
|
if (chatId === activeChatId) {
|
||||||
@@ -63,10 +70,26 @@ export function useRealtime() {
|
|||||||
typingByChat.current[chatId]?.delete(userId);
|
typingByChat.current[chatId]?.delete(userId);
|
||||||
useChatStore.getState().setTypingUsers(chatId, [...(typingByChat.current[chatId] ?? [])]);
|
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();
|
return () => ws.close();
|
||||||
}, [wsUrl, prependMessage, loadChats, chats, me?.id, activeChatId]);
|
}, [wsUrl, prependMessage, confirmMessageByClientId, setMessageDeliveryStatus, loadChats, chats, me?.id, activeChatId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { getChats, getMessages, updateMessageStatus } from "../api/chats";
|
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 {
|
interface ChatState {
|
||||||
chats: Chat[];
|
chats: Chat[];
|
||||||
@@ -11,6 +11,16 @@ interface ChatState {
|
|||||||
setActiveChatId: (chatId: number | null) => void;
|
setActiveChatId: (chatId: number | null) => void;
|
||||||
loadMessages: (chatId: number) => Promise<void>;
|
loadMessages: (chatId: number) => Promise<void>;
|
||||||
prependMessage: (chatId: number, message: Message) => void;
|
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;
|
setTypingUsers: (chatId: number, userIds: number[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,13 +50,87 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
prependMessage: (chatId, message) => {
|
prependMessage: (chatId, message) => {
|
||||||
const old = get().messagesByChat[chatId] ?? [];
|
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;
|
return;
|
||||||
}
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messagesByChat: { ...state.messagesByChat, [chatId]: [...old, message] }
|
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<DeliveryStatus, number> = { 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) =>
|
setTypingUsers: (chatId, userIds) =>
|
||||||
set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } }))
|
set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } }))
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user