mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement inline edit
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { RichTextArea } from "../core/RichTextArea";
|
||||
import type { Message } from "../../../core/types";
|
||||
import Quote from "../core/Quote";
|
||||
@@ -6,27 +6,57 @@ import AnimatedHeight from "../core/animations/AnimatedHeight";
|
||||
|
||||
interface ChatInputWrapperProps {
|
||||
onSendMessage: (message: string) => void;
|
||||
onSaveEdit?: (content: string) => void;
|
||||
replyTo?: Message | null;
|
||||
replyToVisible: boolean;
|
||||
onClearReply?: () => void;
|
||||
onCloseReply?: () => void;
|
||||
editingMessage?: Message | null;
|
||||
editVisible?: boolean;
|
||||
onClearEdit?: () => void;
|
||||
onCloseEdit?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputWrapper({ onSendMessage, replyTo, replyToVisible, onClearReply, onCloseReply }: ChatInputWrapperProps) {
|
||||
export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVisible, onClearReply, onCloseReply, editingMessage, editVisible = false, onClearEdit, onCloseEdit }: ChatInputWrapperProps) {
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
// When entering edit mode, preload the message content
|
||||
useEffect(() => {
|
||||
if (editingMessage) {
|
||||
setMessage(editingMessage.content || "");
|
||||
}
|
||||
}, [editingMessage]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent | Event) => {
|
||||
e.preventDefault();
|
||||
if (message.trim()) {
|
||||
if (editingMessage && onSaveEdit) {
|
||||
onSaveEdit(message);
|
||||
setMessage("");
|
||||
if (onClearEdit) onClearEdit();
|
||||
} else {
|
||||
onSendMessage(message);
|
||||
setMessage("");
|
||||
if (onClearReply) onClearReply();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-input-wrapper">
|
||||
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
|
||||
<AnimatedHeight visible={editVisible} onFinish={onCloseEdit}>
|
||||
{editingMessage && (
|
||||
<div className="reply-preview contextual-preview">
|
||||
<mdui-icon name="edit" />
|
||||
<Quote className="reply-content contextual-content" background="surfaceContainer">
|
||||
<span className="reply-username">{editingMessage!.username}</span>
|
||||
<span className="reply-text">{editingMessage!.content}</span>
|
||||
</Quote>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
<AnimatedHeight visible={replyToVisible} onFinish={onCloseReply}>
|
||||
{replyTo && (
|
||||
<div className="reply-preview contextual-preview">
|
||||
@@ -50,7 +80,7 @@ export function ChatInputWrapper({ onSendMessage, replyTo, replyToVisible, onCle
|
||||
onTextChange={(value) => setMessage(value)}
|
||||
onEnter={handleSubmit} />
|
||||
<button type="submit" className="send-btn">
|
||||
<span className="material-symbols filled">send</span>
|
||||
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -15,9 +15,10 @@ interface ChatMessagesProps {
|
||||
isDm?: boolean;
|
||||
children?: ReactNode;
|
||||
onReplySelect?: (message: MessageType) => void;
|
||||
onEditSelect?: (message: MessageType) => void;
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect }: ChatMessagesProps) {
|
||||
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect }: ChatMessagesProps) {
|
||||
const { messages: hookMessages } = useChat();
|
||||
const { user } = useAppState();
|
||||
|
||||
@@ -67,25 +68,8 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEdit = async (message: MessageType) => {
|
||||
// This will be called when the edit dialog is saved
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
await request({
|
||||
type: "editMessage",
|
||||
data: {
|
||||
message_id: message.id,
|
||||
content: message.content // This should be updated content from the dialog
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: user.authToken
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to edit message:", error);
|
||||
}
|
||||
const handleEdit = (message: MessageType) => {
|
||||
if (onEditSelect) onEditSelect(message);
|
||||
};
|
||||
|
||||
const handleReply = (message: MessageType) => {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Message } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
|
||||
interface EditMessageDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
message: Message | null;
|
||||
onSave: (messageId: number, newContent: string) => void;
|
||||
}
|
||||
|
||||
export function EditMessageDialog({ isOpen, onOpenChange, message, onSave }: EditMessageDialogProps) {
|
||||
const [editContent, setEditContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
setEditContent(message.content);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (message && editContent.trim()) {
|
||||
onSave(message.id, editContent.trim());
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
setEditContent("");
|
||||
};
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc>
|
||||
<div className="dialog-content">
|
||||
<h3>Edit Message</h3>
|
||||
<mdui-text-field
|
||||
value={editContent}
|
||||
onInput={(e) => setEditContent((e.target as HTMLInputElement).value)}
|
||||
label="Edit Message"
|
||||
variant="outlined"
|
||||
placeholder="Edit your message..."
|
||||
maxlength={1000}>
|
||||
</mdui-text-field>
|
||||
<div className="dialog-actions">
|
||||
<mdui-button onClick={handleCancel} variant="outlined">Cancel</mdui-button>
|
||||
<mdui-button onClick={handleSave}>Save</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Message, Size2D } from "../../../core/types";
|
||||
import { EditMessageDialog } from "./EditMessageDialog";
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
message: Message;
|
||||
@@ -29,8 +28,7 @@ export function MessageContextMenu({
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: MessageContextMenuProps) {
|
||||
// Internal state for dialogs and closing animation
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
// Internal state for closing animation
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
||||
const [animationClass, setAnimationClass] = useState('entering');
|
||||
@@ -76,7 +74,7 @@ export function MessageContextMenu({
|
||||
// Effect to handle clicks outside the context menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isOpen && !isClosing && !editDialogOpen) {
|
||||
if (isOpen && !isClosing) {
|
||||
// Check if the click is on a context menu element
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('.context-menu')) {
|
||||
@@ -86,14 +84,14 @@ export function MessageContextMenu({
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen && !isClosing && !editDialogOpen) {
|
||||
if (event.key === 'Escape' && isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
// Close context menu when browser window loses focus
|
||||
if (isOpen && !isClosing && !editDialogOpen) {
|
||||
if (isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
@@ -109,7 +107,7 @@ export function MessageContextMenu({
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
};
|
||||
}, [isOpen, isClosing, editDialogOpen]);
|
||||
}, [isOpen, isClosing]);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
switch (action) {
|
||||
@@ -118,9 +116,7 @@ export function MessageContextMenu({
|
||||
handleClose();
|
||||
break;
|
||||
case "edit":
|
||||
if (isAuthor) {
|
||||
setEditDialogOpen(true);
|
||||
}
|
||||
if (isAuthor) onEdit(message);
|
||||
break;
|
||||
case "delete":
|
||||
if (isAuthor) {
|
||||
@@ -145,12 +141,7 @@ export function MessageContextMenu({
|
||||
}, 200); // Match the animation duration from _animations.scss
|
||||
};
|
||||
|
||||
const handleEditSave = (_messageId: number, newContent: string) => {
|
||||
// Create a temporary message object with the updated content
|
||||
const updatedMessage = { ...message, content: newContent };
|
||||
onEdit(updatedMessage);
|
||||
setEditDialogOpen(false);
|
||||
};
|
||||
// Inline edit handled by parent via onEdit
|
||||
|
||||
|
||||
const content = (
|
||||
@@ -185,19 +176,11 @@ export function MessageContextMenu({
|
||||
)
|
||||
|
||||
// Don't render if not open
|
||||
if (!isOpen && !editDialogOpen) return null;
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen ? content : null}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<EditMessageDialog
|
||||
isOpen={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
message={message}
|
||||
onSave={handleEditSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [replyTo, setReplyTo] = useState<Message | null>(null);
|
||||
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
|
||||
const [editMessage, setEditMessage] = useState<Message | null>(null);
|
||||
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (replyTo) {
|
||||
@@ -25,6 +28,12 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
|
||||
}
|
||||
}, [replyTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMessage) {
|
||||
setEditVisible(true);
|
||||
}
|
||||
}, [editMessage]);
|
||||
|
||||
// Handle panel state changes
|
||||
useEffect(() => {
|
||||
if (panel) {
|
||||
@@ -141,7 +150,26 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
|
||||
</div>
|
||||
</div>
|
||||
): (
|
||||
<ChatMessages messages={panelState.messages} isDm={panel.isDm()} onReplySelect={(m) => setReplyTo(m)}>
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
onReplySelect={(m) => {
|
||||
if (editMessage || editVisible) {
|
||||
setPendingAction({ type: "reply", message: m });
|
||||
setEditVisible(false); // onCloseEdit will apply pending
|
||||
} else {
|
||||
setReplyTo(m);
|
||||
}
|
||||
}}
|
||||
onEditSelect={(m) => {
|
||||
if (replyTo || replyToVisible) {
|
||||
setPendingAction({ type: "edit", message: m });
|
||||
setReplyToVisible(false); // onCloseReply will apply pending
|
||||
} else {
|
||||
setEditMessage(m);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
)}
|
||||
@@ -151,10 +179,38 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
|
||||
panel.handleSendMessage(text, replyTo?.id);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
onSaveEdit={(content) => {
|
||||
if (editMessage) {
|
||||
panel.handleEditMessage(editMessage.id, content);
|
||||
setEditMessage(null);
|
||||
}
|
||||
}}
|
||||
replyTo={replyTo}
|
||||
replyToVisible={replyToVisible}
|
||||
onClearReply={() => setReplyToVisible(false)}
|
||||
onCloseReply={() => setReplyTo(null)}
|
||||
onClearReply={() => {
|
||||
setPendingAction(null);
|
||||
setReplyToVisible(false);
|
||||
}}
|
||||
onCloseReply={() => {
|
||||
setReplyTo(null);
|
||||
if (pendingAction && pendingAction.type === "edit") {
|
||||
setEditMessage(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
editingMessage={editMessage}
|
||||
editVisible={editVisible}
|
||||
onClearEdit={() => {
|
||||
setPendingAction(null);
|
||||
setEditVisible(false);
|
||||
}}
|
||||
onCloseEdit={() => {
|
||||
setEditMessage(null);
|
||||
if (pendingAction && pendingAction.type === "reply") {
|
||||
setReplyTo(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,7 +248,24 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
if (!publicChatPanel) {
|
||||
const callbacks = {
|
||||
onSendMessage: (_content: string) => {},
|
||||
onEditMessage: (_messageId: number, _content: string) => {},
|
||||
onEditMessage: async (messageId: number, content: string) => {
|
||||
if (!user.authToken) return;
|
||||
try {
|
||||
await request({
|
||||
type: "editMessage",
|
||||
data: {
|
||||
message_id: messageId,
|
||||
content: content
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: user.authToken
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to edit message:", error);
|
||||
}
|
||||
},
|
||||
onDeleteMessage: (_messageId: number) => {},
|
||||
onReplyToMessage: (_messageId: number, _content: string) => {},
|
||||
onProfileClick: () => {}
|
||||
|
||||
Reference in New Issue
Block a user