feat: add message reliability foundation
All checks were successful
CI / test (push) Successful in 23s
All checks were successful
CI / test (push) Successful in 23s
- implement idempotent message creation via client_message_id - add persistent delivered/read receipts - expose /messages/status and wire websocket receipt events - update web client to send client ids and auto-ack delivered/read
This commit is contained in:
@@ -35,6 +35,21 @@ export async function sendMessage(chatId: number, text: string, type: MessageTyp
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendMessageWithClientId(
|
||||
chatId: number,
|
||||
text: string,
|
||||
type: MessageType,
|
||||
clientMessageId: string
|
||||
): Promise<Message> {
|
||||
const { data } = await http.post<Message>("/messages", {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
type,
|
||||
client_message_id: clientMessageId
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface UploadUrlResponse {
|
||||
upload_url: string;
|
||||
file_url: string;
|
||||
@@ -77,3 +92,15 @@ export async function attachFile(messageId: number, fileUrl: string, fileType: s
|
||||
file_size: fileSize
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMessageStatus(
|
||||
chatId: number,
|
||||
messageId: number,
|
||||
status: "message_delivered" | "message_read"
|
||||
): Promise<void> {
|
||||
await http.post("/messages/status", {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { attachFile, requestUploadUrl, sendMessage, uploadToPresignedUrl } from "../api/chats";
|
||||
import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { buildWsUrl } from "../utils/ws";
|
||||
@@ -28,6 +28,13 @@ export function MessageComposer() {
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
function makeClientMessageId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function getWs(): WebSocket | null {
|
||||
if (!accessToken || !activeChatId) {
|
||||
return null;
|
||||
@@ -44,7 +51,7 @@ export function MessageComposer() {
|
||||
if (!activeChatId || !text.trim()) {
|
||||
return;
|
||||
}
|
||||
const message = await sendMessage(activeChatId, text.trim(), "text");
|
||||
const message = await sendMessageWithClientId(activeChatId, text.trim(), "text", makeClientMessageId());
|
||||
prependMessage(activeChatId, message);
|
||||
setText("");
|
||||
const ws = getWs();
|
||||
@@ -61,7 +68,7 @@ export function MessageComposer() {
|
||||
try {
|
||||
const upload = await requestUploadUrl(file);
|
||||
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress);
|
||||
const message = await sendMessage(activeChatId, upload.file_url, messageType);
|
||||
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, makeClientMessageId());
|
||||
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
|
||||
prependMessage(activeChatId, message);
|
||||
} catch {
|
||||
|
||||
@@ -16,6 +16,7 @@ export function useRealtime() {
|
||||
const prependMessage = useChatStore((s) => s.prependMessage);
|
||||
const loadChats = useChatStore((s) => s.loadChats);
|
||||
const chats = useChatStore((s) => s.chats);
|
||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||
const typingByChat = useRef<Record<number, Set<number>>>({});
|
||||
|
||||
const wsUrl = useMemo(() => {
|
||||
@@ -34,6 +35,12 @@ export function useRealtime() {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const message = event.payload.message as Message;
|
||||
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) {
|
||||
ws.send(JSON.stringify({ event: "message_read", payload: { chat_id: chatId, message_id: message.id } }));
|
||||
}
|
||||
}
|
||||
if (!chats.some((chat) => chat.id === chatId)) {
|
||||
void loadChats();
|
||||
}
|
||||
@@ -59,7 +66,7 @@ export function useRealtime() {
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
}, [wsUrl, prependMessage, loadChats, chats, me?.id]);
|
||||
}, [wsUrl, prependMessage, loadChats, chats, me?.id, activeChatId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { getChats, getMessages } from "../api/chats";
|
||||
import { getChats, getMessages, updateMessageStatus } from "../api/chats";
|
||||
import type { Chat, Message } from "../chat/types";
|
||||
|
||||
interface ChatState {
|
||||
@@ -26,12 +26,17 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
setActiveChatId: (chatId) => set({ activeChatId: chatId }),
|
||||
loadMessages: async (chatId) => {
|
||||
const messages = await getMessages(chatId);
|
||||
const sorted = [...messages].reverse();
|
||||
set((state) => ({
|
||||
messagesByChat: {
|
||||
...state.messagesByChat,
|
||||
[chatId]: [...messages].reverse()
|
||||
[chatId]: sorted
|
||||
}
|
||||
}));
|
||||
const lastMessage = sorted[sorted.length - 1];
|
||||
if (lastMessage) {
|
||||
void updateMessageStatus(chatId, lastMessage.id, "message_read");
|
||||
}
|
||||
},
|
||||
prependMessage: (chatId, message) => {
|
||||
const old = get().messagesByChat[chatId] ?? [];
|
||||
|
||||
Reference in New Issue
Block a user