mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Start inline reply
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user