Start inline reply

This commit is contained in:
2025-09-18 18:23:24 +03:00
Unverified
parent 4345546a64
commit 88835e65ae
7 changed files with 45 additions and 54 deletions
@@ -1,11 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { RichTextArea } from "../core/RichTextArea"; import { RichTextArea } from "../core/RichTextArea";
import type { Message } from "../../../core/types";
interface ChatInputWrapperProps { interface ChatInputWrapperProps {
onSendMessage: (message: string) => void; onSendMessage: (message: string) => void | ((message: string) => void);
replyTo?: Message | null;
onClearReply?: () => void;
} }
export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) { export function ChatInputWrapper({ onSendMessage, replyTo, onClearReply }: ChatInputWrapperProps) {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const handleSubmit = async (e: React.FormEvent | Event) => { const handleSubmit = async (e: React.FormEvent | Event) => {
@@ -13,6 +16,7 @@ export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) {
if (message.trim()) { if (message.trim()) {
onSendMessage(message); onSendMessage(message);
setMessage(""); setMessage("");
if (onClearReply) onClearReply();
} }
}; };
@@ -20,6 +24,17 @@ export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) {
<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}>
<div className="chat-input"> <div className="chat-input">
{replyTo && (
<div className="reply-preview">
<div className="reply-content">
<span className="reply-username">{replyTo.username}</span>
<span className="reply-text">{replyTo.content}</span>
</div>
<button type="button" className="reply-cancel" onClick={onClearReply}>
<span className="material-symbols">close</span>
</button>
</div>
)}
<RichTextArea <RichTextArea
className="message-input" className="message-input"
id="message-input" id="message-input"
@@ -14,9 +14,10 @@ interface ChatMessagesProps {
messages?: MessageType[]; messages?: MessageType[];
isDm?: boolean; isDm?: boolean;
children?: ReactNode; children?: ReactNode;
onReplySelect?: (message: MessageType) => void;
} }
export function ChatMessages({ messages: propMessages, children, isDm = false }: ChatMessagesProps) { export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect }: ChatMessagesProps) {
const { messages: hookMessages } = useChat(); const { messages: hookMessages } = useChat();
const { user } = useAppState(); const { user } = useAppState();
@@ -87,25 +88,8 @@ export function ChatMessages({ messages: propMessages, children, isDm = false }:
} }
}; };
const handleReply = async (message: MessageType) => { const handleReply = (message: MessageType) => {
// This will be called when the reply dialog is sent if (onReplySelect) onReplySelect(message);
if (!user.authToken) return;
try {
await request({
type: "replyMessage",
data: {
content: message.content, // This should be the reply content from the dialog
reply_to_id: message.id
},
credentials: {
scheme: "Bearer",
credentials: user.authToken
}
});
} catch (error) {
console.error("Failed to send reply:", error);
}
}; };
const handleDelete = async (message: MessageType) => { const handleDelete = async (message: MessageType) => {
@@ -1,7 +1,6 @@
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"; import { EditMessageDialog } from "./EditMessageDialog";
import { ReplyMessageDialog } from "./ReplyMessageDialog";
interface MessageContextMenuProps { interface MessageContextMenuProps {
message: Message; message: Message;
@@ -32,7 +31,6 @@ export function MessageContextMenu({
}: MessageContextMenuProps) { }: MessageContextMenuProps) {
// Internal state for dialogs and closing animation // Internal state for dialogs and closing animation
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
const [replyDialogOpen, setReplyDialogOpen] = 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');
@@ -78,7 +76,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 && !replyDialogOpen) { if (isOpen && !isClosing && !editDialogOpen) {
// 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')) {
@@ -88,14 +86,14 @@ export function MessageContextMenu({
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen && !isClosing && !editDialogOpen && !replyDialogOpen) { if (event.key === 'Escape' && isOpen && !isClosing && !editDialogOpen) {
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 && !replyDialogOpen) { if (isOpen && !isClosing && !editDialogOpen) {
handleClose(); handleClose();
} }
}; };
@@ -111,12 +109,13 @@ export function MessageContextMenu({
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleWindowBlur); window.removeEventListener('blur', handleWindowBlur);
}; };
}, [isOpen, isClosing, editDialogOpen, replyDialogOpen]); }, [isOpen, isClosing, editDialogOpen]);
const handleAction = (action: string) => { const handleAction = (action: string) => {
switch (action) { switch (action) {
case "reply": case "reply":
setReplyDialogOpen(true); onReply(message);
handleClose();
break; break;
case "edit": case "edit":
if (isAuthor) { if (isAuthor) {
@@ -153,14 +152,6 @@ export function MessageContextMenu({
setEditDialogOpen(false); setEditDialogOpen(false);
}; };
const handleSendReply = (content: string, replyToId: number) => {
// Create a temporary message object with the reply content
const replyMessage = { ...message, content, id: replyToId };
onReply(replyMessage);
setReplyDialogOpen(false);
};
const content = ( const content = (
<div <div
@@ -194,7 +185,7 @@ export function MessageContextMenu({
) )
// Don't render if not open // Don't render if not open
if (!isOpen && !editDialogOpen && !replyDialogOpen) return null; if (!isOpen && !editDialogOpen) return null;
return ( return (
<> <>
@@ -207,14 +198,6 @@ export function MessageContextMenu({
message={message} message={message}
onSave={handleEditSave} onSave={handleEditSave}
/> />
{/* Reply Dialog */}
<ReplyMessageDialog
isOpen={replyDialogOpen}
onOpenChange={setReplyDialogOpen}
replyToMessage={message}
onSendReply={handleSendReply}
/>
</> </>
); );
} }
@@ -3,6 +3,7 @@ import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel"
import { ChatMessages } from "./ChatMessages"; import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper"; import { ChatInputWrapper } from "./ChatInputWrapper";
import { setGlobalMessageHandler } from "../../../core/websocket"; import { setGlobalMessageHandler } from "../../../core/websocket";
import type { Message } from "../../../core/types";
import defaultAvatar from "../../../resources/images/default-avatar.png"; import defaultAvatar from "../../../resources/images/default-avatar.png";
interface MessagePanelRendererProps { interface MessagePanelRendererProps {
@@ -15,6 +16,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
const [switchIn, setSwitchIn] = useState(false); const [switchIn, setSwitchIn] = useState(false);
const [switchOut, setSwitchOut] = useState(false); const [switchOut, setSwitchOut] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [replyTo, setReplyTo] = useState<Message | null>(null);
// Handle panel state changes // Handle panel state changes
useEffect(() => { useEffect(() => {
@@ -132,12 +134,19 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
</div> </div>
</div> </div>
): ( ): (
<ChatMessages messages={panelState.messages} isDm={panel.isDm()}> <ChatMessages messages={panelState.messages} isDm={panel.isDm()} onReplySelect={(m) => setReplyTo(m)}>
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</ChatMessages> </ChatMessages>
)} )}
<ChatInputWrapper onSendMessage={panel.handleSendMessage} /> <ChatInputWrapper
onSendMessage={(text) => {
panel.handleSendMessage(text, replyTo?.id);
setReplyTo(null);
}}
replyTo={replyTo}
onClearReply={() => setReplyTo(null)}
/>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -88,7 +88,7 @@ export class DMPanel extends MessagePanel {
} }
} }
async sendMessage(content: string): Promise<void> { async sendMessage(content: string, _replyToId?: number): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
try { try {
+3 -3
View File
@@ -48,7 +48,7 @@ export abstract class MessagePanel {
abstract activate(): Promise<void>; abstract activate(): Promise<void>;
abstract deactivate(): void; abstract deactivate(): void;
abstract loadMessages(): Promise<void>; abstract loadMessages(): Promise<void>;
abstract sendMessage(content: string): Promise<void>; abstract sendMessage(content: string, replyToId?: number): Promise<void>;
abstract isDm(): boolean; abstract isDm(): boolean;
// Optional WebSocket message handler (can be overridden by subclasses) // Optional WebSocket message handler (can be overridden by subclasses)
@@ -115,8 +115,8 @@ export abstract class MessagePanel {
} }
// Event handlers // Event handlers
handleSendMessage = (content: string): void => { handleSendMessage = (content: string, replyToId?: number): void => {
this.sendMessage(content); this.sendMessage(content, replyToId);
}; };
handleEditMessage = (messageId: number, content: string): void => { handleEditMessage = (messageId: number, content: string): void => {
+2 -2
View File
@@ -61,12 +61,12 @@ export class PublicChatPanel extends MessagePanel {
} }
} }
async sendMessage(content: string): Promise<void> { async sendMessage(content: string, replyToId?: number): Promise<void> {
if (!this.currentUser.authToken || !content.trim()) return; if (!this.currentUser.authToken || !content.trim()) return;
try { try {
const response = await request({ const response = await request({
data: { content: content.trim() }, data: { content: content.trim(), ...(replyToId ? { reply_to_id: replyToId } : {}) },
credentials: { credentials: {
scheme: "Bearer", scheme: "Bearer",
credentials: this.currentUser.authToken credentials: this.currentUser.authToken