feat: add reply/forward/pin message flow across backend and web
Some checks failed
CI / test (push) Failing after 24s
Some checks failed
CI / test (push) Failing after 24s
- add reply_to/forwarded_from message fields and chat pinned_message field - add forward and pin APIs plus reply support in message create - wire web actions: Reply, Fwd, Pin and reply composer state - fix spam policy bug: allow repeated identical messages, keep rate limiting
This commit is contained in:
@@ -51,13 +51,15 @@ export async function sendMessageWithClientId(
|
||||
chatId: number,
|
||||
text: string,
|
||||
type: MessageType,
|
||||
clientMessageId: string
|
||||
clientMessageId: string,
|
||||
replyToMessageId?: number
|
||||
): Promise<Message> {
|
||||
const { data } = await http.post<Message>("/messages", {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
type,
|
||||
client_message_id: clientMessageId
|
||||
client_message_id: clientMessageId,
|
||||
reply_to_message_id: replyToMessageId
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -116,3 +118,17 @@ export async function updateMessageStatus(
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
export async function forwardMessage(messageId: number, targetChatId: number): Promise<Message> {
|
||||
const { data } = await http.post<Message>(`/messages/${messageId}/forward`, {
|
||||
target_chat_id: targetChatId
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function pinMessage(chatId: number, messageId: number | null): Promise<Chat> {
|
||||
const { data } = await http.post<Chat>(`/chats/${chatId}/pin`, {
|
||||
message_id: messageId
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Chat {
|
||||
id: number;
|
||||
type: ChatType;
|
||||
title: string | null;
|
||||
pinned_message_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -15,6 +16,8 @@ export interface Message {
|
||||
sender_id: number;
|
||||
type: MessageType;
|
||||
text: string | null;
|
||||
reply_to_message_id?: number | null;
|
||||
forwarded_from_message_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
client_message_id?: string;
|
||||
|
||||
@@ -10,6 +10,8 @@ export function MessageComposer() {
|
||||
const addOptimisticMessage = useChatStore((s) => s.addOptimisticMessage);
|
||||
const confirmMessageByClientId = useChatStore((s) => s.confirmMessageByClientId);
|
||||
const removeOptimisticMessage = useChatStore((s) => s.removeOptimisticMessage);
|
||||
const replyToByChat = useChatStore((s) => s.replyToByChat);
|
||||
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const [text, setText] = useState("");
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -56,11 +58,13 @@ export function MessageComposer() {
|
||||
}
|
||||
const clientMessageId = makeClientMessageId();
|
||||
const textValue = text.trim();
|
||||
const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined;
|
||||
addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId });
|
||||
try {
|
||||
const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId);
|
||||
const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId, replyToMessageId);
|
||||
confirmMessageByClientId(activeChatId, clientMessageId, message);
|
||||
setText("");
|
||||
setReplyToMessage(activeChatId, null);
|
||||
const ws = getWs();
|
||||
ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } }));
|
||||
} catch {
|
||||
@@ -77,6 +81,7 @@ export function MessageComposer() {
|
||||
setUploadProgress(0);
|
||||
setUploadError(null);
|
||||
const clientMessageId = makeClientMessageId();
|
||||
const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined;
|
||||
try {
|
||||
const upload = await requestUploadUrl(file);
|
||||
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress);
|
||||
@@ -87,9 +92,10 @@ export function MessageComposer() {
|
||||
text: upload.file_url,
|
||||
clientMessageId
|
||||
});
|
||||
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, clientMessageId);
|
||||
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, clientMessageId, replyToMessageId);
|
||||
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
|
||||
confirmMessageByClientId(activeChatId, clientMessageId, message);
|
||||
setReplyToMessage(activeChatId, null);
|
||||
} catch {
|
||||
removeOptimisticMessage(activeChatId, clientMessageId);
|
||||
setUploadError("Upload failed. Please try again.");
|
||||
@@ -239,6 +245,17 @@ export function MessageComposer() {
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-700/50 bg-slate-900/55 p-3">
|
||||
{activeChatId && replyToByChat[activeChatId] ? (
|
||||
<div className="mb-2 flex items-start justify-between rounded-lg border border-slate-700/80 bg-slate-800/70 px-3 py-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-sky-300">Replying</p>
|
||||
<p className="truncate text-slate-300">{replyToByChat[activeChatId]?.text || "[media]"}</p>
|
||||
</div>
|
||||
<button className="ml-2 rounded bg-slate-700 px-2 py-1 text-[11px]" onClick={() => setReplyToMessage(activeChatId, null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<label className="cursor-pointer rounded-full bg-slate-700/80 px-3 py-2 text-xs font-semibold hover:bg-slate-700">
|
||||
+
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { forwardMessage, pinMessage } from "../api/chats";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { formatTime } from "../utils/format";
|
||||
@@ -8,6 +9,9 @@ export function MessageList() {
|
||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||
const messagesByChat = useChatStore((s) => s.messagesByChat);
|
||||
const typingByChat = useChatStore((s) => s.typingByChat);
|
||||
const chats = useChatStore((s) => s.chats);
|
||||
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
||||
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
||||
|
||||
const messages = useMemo(() => {
|
||||
if (!activeChatId) {
|
||||
@@ -15,13 +19,38 @@ export function MessageList() {
|
||||
}
|
||||
return messagesByChat[activeChatId] ?? [];
|
||||
}, [activeChatId, messagesByChat]);
|
||||
const activeChat = chats.find((chat) => chat.id === activeChatId);
|
||||
|
||||
if (!activeChatId) {
|
||||
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
|
||||
}
|
||||
const chatId = activeChatId;
|
||||
|
||||
async function handleForward(messageId: number) {
|
||||
const targetRaw = window.prompt("Forward to chat id:");
|
||||
if (!targetRaw) {
|
||||
return;
|
||||
}
|
||||
const targetId = Number(targetRaw);
|
||||
if (!Number.isFinite(targetId) || targetId <= 0) {
|
||||
return;
|
||||
}
|
||||
await forwardMessage(messageId, targetId);
|
||||
}
|
||||
|
||||
async function handlePin(messageId: number) {
|
||||
const nextPinned = activeChat?.pinned_message_id === messageId ? null : messageId;
|
||||
const chat = await pinMessage(chatId, nextPinned);
|
||||
updateChatPinnedMessage(chatId, chat.pinned_message_id ?? null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{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">
|
||||
Pinned message ID: {activeChat.pinned_message_id}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
|
||||
{messages.map((message) => {
|
||||
const own = message.sender_id === me?.id;
|
||||
@@ -34,11 +63,30 @@ export function MessageList() {
|
||||
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
|
||||
}`}
|
||||
>
|
||||
{message.forwarded_from_message_id ? (
|
||||
<p className={`mb-1 text-[11px] font-semibold ${own ? "text-slate-900/75" : "text-sky-300"}`}>Forwarded</p>
|
||||
) : null}
|
||||
{message.reply_to_message_id ? (
|
||||
<p className={`mb-1 text-[11px] ${own ? "text-slate-900/75" : "text-slate-300"}`}>Reply to #{message.reply_to_message_id}</p>
|
||||
) : null}
|
||||
{renderContent(message.type, message.text)}
|
||||
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<div className="flex gap-1">
|
||||
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => setReplyToMessage(chatId, message)}>
|
||||
Reply
|
||||
</button>
|
||||
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handleForward(message.id)}>
|
||||
Fwd
|
||||
</button>
|
||||
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handlePin(message.id)}>
|
||||
Pin
|
||||
</button>
|
||||
</div>
|
||||
<p className={`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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ interface ChatState {
|
||||
activeChatId: number | null;
|
||||
messagesByChat: Record<number, Message[]>;
|
||||
typingByChat: Record<number, number[]>;
|
||||
replyToByChat: Record<number, Message | null>;
|
||||
loadChats: (query?: string) => Promise<void>;
|
||||
setActiveChatId: (chatId: number | null) => void;
|
||||
loadMessages: (chatId: number) => Promise<void>;
|
||||
@@ -22,6 +23,8 @@ interface ChatState {
|
||||
removeOptimisticMessage: (chatId: number, clientMessageId: string) => void;
|
||||
setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void;
|
||||
setTypingUsers: (chatId: number, userIds: number[]) => void;
|
||||
setReplyToMessage: (chatId: number, message: Message | null) => void;
|
||||
updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
@@ -29,6 +32,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
activeChatId: null,
|
||||
messagesByChat: {},
|
||||
typingByChat: {},
|
||||
replyToByChat: {},
|
||||
loadChats: async (query) => {
|
||||
const chats = await getChats(query);
|
||||
const currentActive = get().activeChatId;
|
||||
@@ -134,5 +138,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
setTypingUsers: (chatId, userIds) =>
|
||||
set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } }))
|
||||
set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } })),
|
||||
setReplyToMessage: (chatId, message) =>
|
||||
set((state) => ({ replyToByChat: { ...state.replyToByChat, [chatId]: message } })),
|
||||
updateChatPinnedMessage: (chatId, pinnedMessageId) =>
|
||||
set((state) => ({
|
||||
chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, pinned_message_id: pinnedMessageId } : chat))
|
||||
}))
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user