Implement inline edit

This commit is contained in:
2025-09-19 17:06:37 +03:00
Unverified
parent 5cf6af925d
commit 8f2e5b2c7e
6 changed files with 125 additions and 109 deletions
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { RichTextArea } from "../core/RichTextArea"; import { RichTextArea } from "../core/RichTextArea";
import type { Message } from "../../../core/types"; import type { Message } from "../../../core/types";
import Quote from "../core/Quote"; import Quote from "../core/Quote";
@@ -6,27 +6,57 @@ import AnimatedHeight from "../core/animations/AnimatedHeight";
interface ChatInputWrapperProps { interface ChatInputWrapperProps {
onSendMessage: (message: string) => void; onSendMessage: (message: string) => void;
onSaveEdit?: (content: string) => void;
replyTo?: Message | null; replyTo?: Message | null;
replyToVisible: boolean; replyToVisible: boolean;
onClearReply?: () => void; onClearReply?: () => void;
onCloseReply?: () => 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(""); 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) => { const handleSubmit = async (e: React.FormEvent | Event) => {
e.preventDefault(); e.preventDefault();
if (message.trim()) { if (message.trim()) {
onSendMessage(message); if (editingMessage && onSaveEdit) {
setMessage(""); onSaveEdit(message);
if (onClearReply) onClearReply(); setMessage("");
if (onClearEdit) onClearEdit();
} else {
onSendMessage(message);
setMessage("");
if (onClearReply) onClearReply();
}
} }
}; };
return ( return (
<div className="chat-input-wrapper"> <div className="chat-input-wrapper">
<form className="input-group" id="message-form" onSubmit={handleSubmit}> <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}> <AnimatedHeight visible={replyToVisible} onFinish={onCloseReply}>
{replyTo && ( {replyTo && (
<div className="reply-preview contextual-preview"> <div className="reply-preview contextual-preview">
@@ -50,7 +80,7 @@ export function ChatInputWrapper({ onSendMessage, replyTo, replyToVisible, onCle
onTextChange={(value) => setMessage(value)} onTextChange={(value) => setMessage(value)}
onEnter={handleSubmit} /> onEnter={handleSubmit} />
<button type="submit" className="send-btn"> <button type="submit" className="send-btn">
<span className="material-symbols filled">send</span> <span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button> </button>
</div> </div>
</form> </form>
@@ -15,9 +15,10 @@ interface ChatMessagesProps {
isDm?: boolean; isDm?: boolean;
children?: ReactNode; children?: ReactNode;
onReplySelect?: (message: MessageType) => void; 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 { messages: hookMessages } = useChat();
const { user } = useAppState(); const { user } = useAppState();
@@ -67,25 +68,8 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
})); }));
}; };
const handleEdit = async (message: MessageType) => { const handleEdit = (message: MessageType) => {
// This will be called when the edit dialog is saved if (onEditSelect) onEditSelect(message);
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 handleReply = (message: MessageType) => { 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 { useState, useEffect } from "react";
import type { Message, Size2D } from "../../../core/types"; import type { Message, Size2D } from "../../../core/types";
import { EditMessageDialog } from "./EditMessageDialog";
interface MessageContextMenuProps { interface MessageContextMenuProps {
message: Message; message: Message;
@@ -29,8 +28,7 @@ export function MessageContextMenu({
isOpen, isOpen,
onOpenChange onOpenChange
}: MessageContextMenuProps) { }: MessageContextMenuProps) {
// Internal state for dialogs and closing animation // Internal state for closing animation
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position); const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState('entering'); const [animationClass, setAnimationClass] = useState('entering');
@@ -76,7 +74,7 @@ export function MessageContextMenu({
// Effect to handle clicks outside the context menu // Effect to handle clicks outside the context menu
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (isOpen && !isClosing && !editDialogOpen) { if (isOpen && !isClosing) {
// Check if the click is on a context menu element // Check if the click is on a context menu element
const target = event.target as Element; const target = event.target as Element;
if (!target.closest('.context-menu')) { if (!target.closest('.context-menu')) {
@@ -86,14 +84,14 @@ export function MessageContextMenu({
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen && !isClosing && !editDialogOpen) { if (event.key === 'Escape' && isOpen && !isClosing) {
handleClose(); handleClose();
} }
}; };
const handleWindowBlur = () => { const handleWindowBlur = () => {
// Close context menu when browser window loses focus // Close context menu when browser window loses focus
if (isOpen && !isClosing && !editDialogOpen) { if (isOpen && !isClosing) {
handleClose(); handleClose();
} }
}; };
@@ -109,7 +107,7 @@ export function MessageContextMenu({
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleWindowBlur); window.removeEventListener('blur', handleWindowBlur);
}; };
}, [isOpen, isClosing, editDialogOpen]); }, [isOpen, isClosing]);
const handleAction = (action: string) => { const handleAction = (action: string) => {
switch (action) { switch (action) {
@@ -118,9 +116,7 @@ export function MessageContextMenu({
handleClose(); handleClose();
break; break;
case "edit": case "edit":
if (isAuthor) { if (isAuthor) onEdit(message);
setEditDialogOpen(true);
}
break; break;
case "delete": case "delete":
if (isAuthor) { if (isAuthor) {
@@ -145,12 +141,7 @@ export function MessageContextMenu({
}, 200); // Match the animation duration from _animations.scss }, 200); // Match the animation duration from _animations.scss
}; };
const handleEditSave = (_messageId: number, newContent: string) => { // Inline edit handled by parent via onEdit
// Create a temporary message object with the updated content
const updatedMessage = { ...message, content: newContent };
onEdit(updatedMessage);
setEditDialogOpen(false);
};
const content = ( const content = (
@@ -185,19 +176,11 @@ export function MessageContextMenu({
) )
// Don't render if not open // Don't render if not open
if (!isOpen && !editDialogOpen) return null; if (!isOpen) return null;
return ( return (
<> <>
{isOpen ? content : null} {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 messagesEndRef = useRef<HTMLDivElement>(null);
const [replyTo, setReplyTo] = useState<Message | null>(null); const [replyTo, setReplyTo] = useState<Message | null>(null);
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo)); 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(() => { useEffect(() => {
if (replyTo) { if (replyTo) {
@@ -25,6 +28,12 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
} }
}, [replyTo]); }, [replyTo]);
useEffect(() => {
if (editMessage) {
setEditVisible(true);
}
}, [editMessage]);
// Handle panel state changes // Handle panel state changes
useEffect(() => { useEffect(() => {
if (panel) { if (panel) {
@@ -141,7 +150,26 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
</div> </div>
</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} /> <div ref={messagesEndRef} />
</ChatMessages> </ChatMessages>
)} )}
@@ -151,10 +179,38 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
panel.handleSendMessage(text, replyTo?.id); panel.handleSendMessage(text, replyTo?.id);
setReplyTo(null); setReplyTo(null);
}} }}
onSaveEdit={(content) => {
if (editMessage) {
panel.handleEditMessage(editMessage.id, content);
setEditMessage(null);
}
}}
replyTo={replyTo} replyTo={replyTo}
replyToVisible={replyToVisible} replyToVisible={replyToVisible}
onClearReply={() => setReplyToVisible(false)} onClearReply={() => {
onCloseReply={() => setReplyTo(null)} 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>
</div> </div>
+18 -1
View File
@@ -248,7 +248,24 @@ export const useAppState = create<AppState>((set, get) => ({
if (!publicChatPanel) { if (!publicChatPanel) {
const callbacks = { const callbacks = {
onSendMessage: (_content: string) => {}, 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) => {}, onDeleteMessage: (_messageId: number) => {},
onReplyToMessage: (_messageId: number, _content: string) => {}, onReplyToMessage: (_messageId: number, _content: string) => {},
onProfileClick: () => {} onProfileClick: () => {}