feat(web): add message edit flow in context menu and composer
All checks were successful
CI / test (push) Successful in 24s

This commit is contained in:
2026-03-08 13:22:57 +03:00
parent 041f7ac171
commit 704781e359
4 changed files with 82 additions and 5 deletions

View File

@@ -258,6 +258,11 @@ export async function deleteMessage(messageId: number, forAll = false): Promise<
await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } }); await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } });
} }
export async function editMessage(messageId: number, text: string): Promise<Message> {
const { data } = await http.put<Message>(`/messages/${messageId}`, { text });
return data;
}
export async function discoverChats(query?: string): Promise<DiscoverChat[]> { export async function discoverChats(query?: string): Promise<DiscoverChat[]> {
const { data } = await http.get<DiscoverChat[]>("/chats/discover", { const { data } = await http.get<DiscoverChat[]>("/chats/discover", {
params: query?.trim() ? { query: query.trim() } : undefined params: query?.trim() ? { query: query.trim() } : undefined

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats"; import { attachFile, editMessage, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { buildWsUrl } from "../utils/ws"; import { buildWsUrl } from "../utils/ws";
@@ -19,6 +19,9 @@ export function MessageComposer() {
const removeOptimisticMessage = useChatStore((s) => s.removeOptimisticMessage); const removeOptimisticMessage = useChatStore((s) => s.removeOptimisticMessage);
const replyToByChat = useChatStore((s) => s.replyToByChat); const replyToByChat = useChatStore((s) => s.replyToByChat);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage); const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const editingByChat = useChatStore((s) => s.editingByChat);
const setEditingMessage = useChatStore((s) => s.setEditingMessage);
const upsertMessage = useChatStore((s) => s.upsertMessage);
const accessToken = useAuthStore((s) => s.accessToken); const accessToken = useAuthStore((s) => s.accessToken);
const [text, setText] = useState(""); const [text, setText] = useState("");
@@ -54,6 +57,7 @@ export function MessageComposer() {
const [dragHint, setDragHint] = useState<"idle" | "lock" | "cancel">("idle"); const [dragHint, setDragHint] = useState<"idle" | "lock" | "cancel">("idle");
const hasTextToSend = text.trim().length > 0; const hasTextToSend = text.trim().length > 0;
const activeChat = chats.find((chat) => chat.id === activeChatId); const activeChat = chats.find((chat) => chat.id === activeChatId);
const editingMessage = activeChatId ? (editingByChat[activeChatId] ?? null) : null;
const canSendInActiveChat = Boolean( const canSendInActiveChat = Boolean(
activeChatId && activeChatId &&
activeChat && activeChat &&
@@ -71,11 +75,18 @@ export function MessageComposer() {
} }
return; return;
} }
if (editingMessage) {
const editingText = editingMessage.text ?? "";
if (text !== editingText) {
setText(editingText);
}
return;
}
const draft = draftsByChat[activeChatId] ?? ""; const draft = draftsByChat[activeChatId] ?? "";
if (draft !== text) { if (draft !== text) {
setText(draft); setText(draft);
} }
}, [activeChatId, draftsByChat, text]); }, [activeChatId, draftsByChat, text, editingMessage]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -156,6 +167,18 @@ export function MessageComposer() {
if (!activeChatId || !text.trim() || !me || !canSendInActiveChat) { if (!activeChatId || !text.trim() || !me || !canSendInActiveChat) {
return; return;
} }
if (editingMessage) {
try {
const updated = await editMessage(editingMessage.id, text.trim());
upsertMessage(activeChatId, updated);
setEditingMessage(activeChatId, null);
setText("");
clearDraft(activeChatId);
} catch {
setUploadError("Edit failed. Message may be older than 7 days.");
}
return;
}
const clientMessageId = makeClientMessageId(); const clientMessageId = makeClientMessageId();
const textValue = text.trim(); const textValue = text.trim();
const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined; const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined;
@@ -650,6 +673,25 @@ export function MessageComposer() {
</div> </div>
) : null} ) : null}
{activeChatId && editingMessage ? (
<div className="mb-2 flex items-start justify-between rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
<div className="min-w-0">
<p className="font-semibold text-amber-200">Editing message</p>
<p className="truncate text-amber-100/80">{editingMessage.text || "[empty]"}</p>
</div>
<button
className="ml-2 rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={() => {
setEditingMessage(activeChatId, null);
setText(draftsByChat[activeChatId] ?? "");
}}
type="button"
>
Cancel
</button>
</div>
) : null}
{recordingState !== "idle" ? ( {recordingState !== "idle" ? (
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs text-slate-200"> <div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs text-slate-200">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -801,9 +843,9 @@ export function MessageComposer() {
disabled={recordingState !== "idle" || !activeChatId || !canSendInActiveChat} disabled={recordingState !== "idle" || !activeChatId || !canSendInActiveChat}
onClick={handleSend} onClick={handleSend}
type="button" type="button"
title="Send message" title={editingMessage ? "Save edit" : "Send message"}
> >
{editingMessage ? "✓" : "↑"}
</button> </button>
) : ( ) : (
<button <button

View File

@@ -51,6 +51,7 @@ export function MessageList() {
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage); const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
const chats = useChatStore((s) => s.chats); const chats = useChatStore((s) => s.chats);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage); const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const setEditingMessage = useChatStore((s) => s.setEditingMessage);
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage); const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const removeMessage = useChatStore((s) => s.removeMessage); const removeMessage = useChatStore((s) => s.removeMessage);
const restoreMessages = useChatStore((s) => s.restoreMessages); const restoreMessages = useChatStore((s) => s.restoreMessages);
@@ -125,9 +126,12 @@ export function MessageList() {
setDeleteMessageId(null); setDeleteMessageId(null);
setForwardMessageId(null); setForwardMessageId(null);
setForwardSelectedChatIds(new Set()); setForwardSelectedChatIds(new Set());
if (activeChatId) {
setEditingMessage(activeChatId, null);
}
setReactionsByMessage({}); setReactionsByMessage({});
setAttachmentsByMessage({}); setAttachmentsByMessage({});
}, [activeChatId]); }, [activeChatId, setEditingMessage]);
useEffect(() => { useEffect(() => {
if (!activeChatId) { if (!activeChatId) {
@@ -587,6 +591,21 @@ export function MessageList() {
> >
Reply Reply
</button> </button>
{canEditMessage(messagesMap.get(ctx.messageId), me?.id) ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
const msg = messagesMap.get(ctx.messageId);
if (msg) {
setReplyToMessage(chatId, null);
setEditingMessage(chatId, msg);
}
setCtx(null);
}}
>
Edit
</button>
) : null}
<button <button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => { onClick={() => {
@@ -1031,6 +1050,12 @@ function canDeleteForEveryone(
return message.sender_id === meId; return message.sender_id === meId;
} }
function canEditMessage(message: Message | undefined, meId: number | undefined): boolean {
if (!message || !meId) return false;
if (message.sender_id !== meId) return false;
return message.type === "text";
}
function guessFileTypeByMessageType(messageType: Message["type"]): string { function guessFileTypeByMessageType(messageType: Message["type"]): string {
if (messageType === "image") return "image/jpeg"; if (messageType === "image") return "image/jpeg";
if (messageType === "video" || messageType === "circle_video") return "video/mp4"; if (messageType === "video" || messageType === "circle_video") return "video/mp4";

View File

@@ -62,6 +62,7 @@ interface ChatState {
loadingMoreByChat: Record<number, boolean>; loadingMoreByChat: Record<number, boolean>;
typingByChat: Record<number, number[]>; typingByChat: Record<number, number[]>;
replyToByChat: Record<number, Message | null>; replyToByChat: Record<number, Message | null>;
editingByChat: Record<number, Message | null>;
unreadBoundaryByChat: Record<number, number>; unreadBoundaryByChat: Record<number, number>;
focusedMessageIdByChat: Record<number, number | null>; focusedMessageIdByChat: Record<number, number | null>;
loadChats: (query?: string) => Promise<void>; loadChats: (query?: string) => Promise<void>;
@@ -93,6 +94,7 @@ interface ChatState {
clearUnread: (chatId: number) => void; clearUnread: (chatId: number) => void;
setTypingUsers: (chatId: number, userIds: number[]) => void; setTypingUsers: (chatId: number, userIds: number[]) => void;
setReplyToMessage: (chatId: number, message: Message | null) => void; setReplyToMessage: (chatId: number, message: Message | null) => void;
setEditingMessage: (chatId: number, message: Message | null) => void;
updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void; updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void;
applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void; applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void;
setDraft: (chatId: number, text: string) => void; setDraft: (chatId: number, text: string) => void;
@@ -109,6 +111,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
loadingMoreByChat: {}, loadingMoreByChat: {},
typingByChat: {}, typingByChat: {},
replyToByChat: {}, replyToByChat: {},
editingByChat: {},
unreadBoundaryByChat: {}, unreadBoundaryByChat: {},
focusedMessageIdByChat: {}, focusedMessageIdByChat: {},
loadChats: async (query) => { loadChats: async (query) => {
@@ -395,6 +398,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } })), set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } })),
setReplyToMessage: (chatId, message) => setReplyToMessage: (chatId, message) =>
set((state) => ({ replyToByChat: { ...state.replyToByChat, [chatId]: message } })), set((state) => ({ replyToByChat: { ...state.replyToByChat, [chatId]: message } })),
setEditingMessage: (chatId, message) =>
set((state) => ({ editingByChat: { ...state.editingByChat, [chatId]: message } })),
updateChatPinnedMessage: (chatId, pinnedMessageId) => updateChatPinnedMessage: (chatId, pinnedMessageId) =>
set((state) => ({ set((state) => ({
chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, pinned_message_id: pinnedMessageId } : chat)) chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, pinned_message_id: pinnedMessageId } : chat))