feat(web): add message edit flow in context menu and composer
All checks were successful
CI / test (push) Successful in 24s
All checks were successful
CI / test (push) Successful in 24s
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user