From 88835e65aea9e2d787598a99da5d404f8de4ae06 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 18 Sep 2025 18:23:24 +0300 Subject: [PATCH 1/7] Start inline reply --- .../ui/components/chat/ChatInputWrapper.tsx | 19 ++++++++++-- .../src/ui/components/chat/ChatMessages.tsx | 24 +++----------- .../ui/components/chat/MessageContextMenu.tsx | 31 +++++-------------- .../components/chat/MessagePanelRenderer.tsx | 13 ++++++-- frontend/src/ui/panels/DMPanel.ts | 2 +- frontend/src/ui/panels/MessagePanel.ts | 6 ++-- frontend/src/ui/panels/PublicChatPanel.ts | 4 +-- 7 files changed, 45 insertions(+), 54 deletions(-) diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index 19f4dd3..3912ece 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -1,11 +1,14 @@ import { useState } from "react"; import { RichTextArea } from "../core/RichTextArea"; +import type { Message } from "../../../core/types"; 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 handleSubmit = async (e: React.FormEvent | Event) => { @@ -13,6 +16,7 @@ export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) { if (message.trim()) { onSendMessage(message); setMessage(""); + if (onClearReply) onClearReply(); } }; @@ -20,6 +24,17 @@ export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) {
+ {replyTo && ( +
+
+ {replyTo.username} + {replyTo.content} +
+ +
+ )} 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 { user } = useAppState(); @@ -87,25 +88,8 @@ export function ChatMessages({ messages: propMessages, children, isDm = false }: } }; - const handleReply = async (message: MessageType) => { - // This will be called when the reply dialog is sent - 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 handleReply = (message: MessageType) => { + if (onReplySelect) onReplySelect(message); }; const handleDelete = async (message: MessageType) => { diff --git a/frontend/src/ui/components/chat/MessageContextMenu.tsx b/frontend/src/ui/components/chat/MessageContextMenu.tsx index 493bcf2..5374239 100644 --- a/frontend/src/ui/components/chat/MessageContextMenu.tsx +++ b/frontend/src/ui/components/chat/MessageContextMenu.tsx @@ -1,7 +1,6 @@ import { useState, useEffect } from "react"; import type { Message, Size2D } from "../../../core/types"; import { EditMessageDialog } from "./EditMessageDialog"; -import { ReplyMessageDialog } from "./ReplyMessageDialog"; interface MessageContextMenuProps { message: Message; @@ -32,7 +31,6 @@ export function MessageContextMenu({ }: MessageContextMenuProps) { // Internal state for dialogs and closing animation const [editDialogOpen, setEditDialogOpen] = useState(false); - const [replyDialogOpen, setReplyDialogOpen] = useState(false); const [isClosing, setIsClosing] = useState(false); const [calculatedPosition, setCalculatedPosition] = useState(position); const [animationClass, setAnimationClass] = useState('entering'); @@ -78,7 +76,7 @@ export function MessageContextMenu({ // Effect to handle clicks outside the context menu useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (isOpen && !isClosing && !editDialogOpen && !replyDialogOpen) { + if (isOpen && !isClosing && !editDialogOpen) { // Check if the click is on a context menu element const target = event.target as Element; if (!target.closest('.context-menu')) { @@ -88,14 +86,14 @@ export function MessageContextMenu({ }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isOpen && !isClosing && !editDialogOpen && !replyDialogOpen) { + if (event.key === 'Escape' && isOpen && !isClosing && !editDialogOpen) { handleClose(); } }; const handleWindowBlur = () => { // Close context menu when browser window loses focus - if (isOpen && !isClosing && !editDialogOpen && !replyDialogOpen) { + if (isOpen && !isClosing && !editDialogOpen) { handleClose(); } }; @@ -111,12 +109,13 @@ export function MessageContextMenu({ document.removeEventListener('keydown', handleKeyDown); window.removeEventListener('blur', handleWindowBlur); }; - }, [isOpen, isClosing, editDialogOpen, replyDialogOpen]); + }, [isOpen, isClosing, editDialogOpen]); const handleAction = (action: string) => { switch (action) { case "reply": - setReplyDialogOpen(true); + onReply(message); + handleClose(); break; case "edit": if (isAuthor) { @@ -153,14 +152,6 @@ export function MessageContextMenu({ 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 = (
@@ -207,14 +198,6 @@ export function MessageContextMenu({ message={message} onSave={handleEditSave} /> - - {/* Reply Dialog */} - ); } diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 313d307..a6ad7f7 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -3,6 +3,7 @@ import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel" import { ChatMessages } from "./ChatMessages"; import { ChatInputWrapper } from "./ChatInputWrapper"; import { setGlobalMessageHandler } from "../../../core/websocket"; +import type { Message } from "../../../core/types"; import defaultAvatar from "../../../resources/images/default-avatar.png"; interface MessagePanelRendererProps { @@ -15,6 +16,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen const [switchIn, setSwitchIn] = useState(false); const [switchOut, setSwitchOut] = useState(false); const messagesEndRef = useRef(null); + const [replyTo, setReplyTo] = useState(null); // Handle panel state changes useEffect(() => { @@ -132,12 +134,19 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
): ( - + setReplyTo(m)}>
)} - + { + panel.handleSendMessage(text, replyTo?.id); + setReplyTo(null); + }} + replyTo={replyTo} + onClearReply={() => setReplyTo(null)} + />
); diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index 5b3cd90..d97d968 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -88,7 +88,7 @@ export class DMPanel extends MessagePanel { } } - async sendMessage(content: string): Promise { + async sendMessage(content: string, _replyToId?: number): Promise { if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; try { diff --git a/frontend/src/ui/panels/MessagePanel.ts b/frontend/src/ui/panels/MessagePanel.ts index 146e23c..da704c3 100644 --- a/frontend/src/ui/panels/MessagePanel.ts +++ b/frontend/src/ui/panels/MessagePanel.ts @@ -48,7 +48,7 @@ export abstract class MessagePanel { abstract activate(): Promise; abstract deactivate(): void; abstract loadMessages(): Promise; - abstract sendMessage(content: string): Promise; + abstract sendMessage(content: string, replyToId?: number): Promise; abstract isDm(): boolean; // Optional WebSocket message handler (can be overridden by subclasses) @@ -115,8 +115,8 @@ export abstract class MessagePanel { } // Event handlers - handleSendMessage = (content: string): void => { - this.sendMessage(content); + handleSendMessage = (content: string, replyToId?: number): void => { + this.sendMessage(content, replyToId); }; handleEditMessage = (messageId: number, content: string): void => { diff --git a/frontend/src/ui/panels/PublicChatPanel.ts b/frontend/src/ui/panels/PublicChatPanel.ts index 457e762..d32cf79 100644 --- a/frontend/src/ui/panels/PublicChatPanel.ts +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -61,12 +61,12 @@ export class PublicChatPanel extends MessagePanel { } } - async sendMessage(content: string): Promise { + async sendMessage(content: string, replyToId?: number): Promise { if (!this.currentUser.authToken || !content.trim()) return; try { const response = await request({ - data: { content: content.trim() }, + data: { content: content.trim(), ...(replyToId ? { reply_to_id: replyToId } : {}) }, credentials: { scheme: "Bearer", credentials: this.currentUser.authToken From e77ba2421ca5810fe55e76cd2761f39d38c5d7d4 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 18 Sep 2025 20:13:51 +0300 Subject: [PATCH 2/7] Use proper button component --- .../ui/components/chat/ChatInputWrapper.tsx | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index 3912ece..e7c93cc 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -23,18 +23,16 @@ export function ChatInputWrapper({ onSendMessage, replyTo, onClearReply }: ChatI return (
-
- {replyTo && ( -
-
- {replyTo.username} - {replyTo.content} -
- + {replyTo && ( +
+
+ {replyTo.username} + {replyTo.content}
- )} + +
+ )} +
Date: Thu, 18 Sep 2025 20:11:53 +0300 Subject: [PATCH 3/7] Improve design --- frontend/src/resources/css/_chat.scss | 49 ++++++------ .../src/resources/css/common/_components.scss | 20 +++++ .../src/resources/css/common/_material.scss | 3 + .../ui/components/chat/ChatInputWrapper.tsx | 26 ++++--- frontend/src/ui/components/chat/Message.tsx | 11 ++- .../components/chat/MessagePanelRenderer.tsx | 13 +++- frontend/src/ui/components/core/Quote.tsx | 17 +++++ .../core/animations/AnimatedHeight.tsx | 74 +++++++++++++++++++ 8 files changed, 169 insertions(+), 44 deletions(-) create mode 100644 frontend/src/ui/components/core/Quote.tsx create mode 100644 frontend/src/ui/components/core/animations/AnimatedHeight.tsx diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index d342d08..0731741 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -192,15 +192,11 @@ white-space: pre-wrap; } - .message-reply { - background-color: rgba(255, 255, 255, 0.1); - border-radius: 8px; - padding: 0.5rem; - margin-bottom: 0.5rem; - border-left: 3px solid $color-dark-primary; + .quote.message-reply { user-select: none; + margin-bottom: 10px; - .reply-content { + .quote-inner { display: flex; flex-direction: column; gap: 0.2rem; @@ -289,10 +285,11 @@ .input-group { display: flex; + background-color: $color-dark-surface-container; + border-radius: 30px; + flex-direction: column; .chat-input { - background-color: $color-dark-surface-container; - border-radius: 30px; flex: 1; display: flex; flex-direction: row; @@ -313,24 +310,24 @@ height: 100%; width: 100%; } - } - .send-btn { - margin: 10px; - width: 50px; - height: 50px; - border-radius: 50%; - background-color: $color-dark-primary; - color: $color-dark-on-primary; - border: none; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.25s ease; - align-self: flex-end; - - @include hoverStateLayer($background: $color-dark-primary); + .send-btn { + margin: 10px; + width: 50px; + height: 50px; + border-radius: 50%; + background-color: $color-dark-primary; + color: $color-dark-on-primary; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.25s ease; + align-self: flex-end; + + @include hoverStateLayer($background: $color-dark-primary); + } } } } diff --git a/frontend/src/resources/css/common/_components.scss b/frontend/src/resources/css/common/_components.scss index cecb551..d2581f4 100644 --- a/frontend/src/resources/css/common/_components.scss +++ b/frontend/src/resources/css/common/_components.scss @@ -1,4 +1,5 @@ @use "material" as *; +@use "sass:color"; .text-center { text-align: center; @@ -123,4 +124,23 @@ button, input { overflow-y: hidden; background-color: transparent; display: block; +} + +.quote { + background-color: $color-dark-surface-primary-container-lightened; + border-radius: 8px; + overflow: hidden; + + &.bg-surfaceContainer { + background-color: $color-dark-secondary-container; + + .quote-inner { + border-left: 3px solid $color-dark-secondary; + } + } + + .quote-inner { + border-left: 3px solid $color-dark-primary; + padding: 0.5rem; + } } \ No newline at end of file diff --git a/frontend/src/resources/css/common/_material.scss b/frontend/src/resources/css/common/_material.scss index d2a5079..5317104 100644 --- a/frontend/src/resources/css/common/_material.scss +++ b/frontend/src/resources/css/common/_material.scss @@ -50,6 +50,9 @@ $color-dark-surface-container-low: rgb(24 28 31); $color-dark-surface-container: rgb(28 32 36); $color-dark-surface-container-high: rgb(38 43 46); $color-dark-surface-container-highest: rgb(49 53 57); +$color-dark-surface-primary-container-lightened: color.adjust($color-dark-primary-container, $lightness: 5%); +$color-dark-surface-container-lightened: color.adjust($color-dark-surface-container, $lightness: 5%); + // custom colors $color-1: rgb(82, 109, 246); $color-2: rgb(65, 11, 113); diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index e7c93cc..065cdbf 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -1,14 +1,18 @@ import { useState } from "react"; import { RichTextArea } from "../core/RichTextArea"; import type { Message } from "../../../core/types"; +import Quote from "../core/Quote"; +import AnimatedHeight from "../core/animations/AnimatedHeight"; interface ChatInputWrapperProps { - onSendMessage: (message: string) => void | ((message: string) => void); + onSendMessage: (message: string) => void; replyTo?: Message | null; + replyToVisible: boolean; onClearReply?: () => void; + onCloseReply?: () => void; } -export function ChatInputWrapper({ onSendMessage, replyTo, onClearReply }: ChatInputWrapperProps) { +export function ChatInputWrapper({ onSendMessage, replyTo, replyToVisible, onClearReply, onCloseReply }: ChatInputWrapperProps) { const [message, setMessage] = useState(""); const handleSubmit = async (e: React.FormEvent | Event) => { @@ -23,15 +27,17 @@ export function ChatInputWrapper({ onSendMessage, replyTo, onClearReply }: ChatI return (
- {replyTo && ( -
-
- {replyTo.username} - {replyTo.content} + + {replyTo && ( +
+ + {replyTo!.username} + {replyTo!.content} + +
- -
- )} + )} +
-
- {message.reply_to.username} - {message.reply_to.content} -
-
+ + {message.reply_to.username} + {message.reply_to.content} + )}
diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index a6ad7f7..b442436 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -17,6 +17,13 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen const [switchOut, setSwitchOut] = useState(false); const messagesEndRef = useRef(null); const [replyTo, setReplyTo] = useState(null); + const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo)); + + useEffect(() => { + if (replyTo) { + setReplyToVisible(true); + } + }, [replyTo]); // Handle panel state changes useEffect(() => { @@ -144,8 +151,10 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen panel.handleSendMessage(text, replyTo?.id); setReplyTo(null); }} - replyTo={replyTo} - onClearReply={() => setReplyTo(null)} + replyTo={replyTo} + replyToVisible={replyToVisible} + onClearReply={() => setReplyToVisible(false)} + onCloseReply={() => setReplyTo(null)} />
diff --git a/frontend/src/ui/components/core/Quote.tsx b/frontend/src/ui/components/core/Quote.tsx new file mode 100644 index 0000000..39c9e77 --- /dev/null +++ b/frontend/src/ui/components/core/Quote.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; + +export interface QuoteProps { + className?: string; + children?: ReactNode; + background?: "surfaceContainer" | "primaryContainer" +} + +export default function Quote({ className, children, background = "primaryContainer" }: QuoteProps) { + return ( +
+
+ {children} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx new file mode 100644 index 0000000..b4436bc --- /dev/null +++ b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState, useRef, type ReactNode } from "react"; + +export interface AnimatedHeightProps { + visible: any; + duration?: number; + onFinish?: () => void + children?: ReactNode; +} + +export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children }: AnimatedHeightProps) { + const [height, setHeight] = useState("0px"); + const [shouldRender, setShouldRender] = useState(visible); + const [isAnimating, setIsAnimating] = useState(false); + const contentRef = useRef(null); + const measureRef = useRef(null); + + useEffect(() => { + if (visible) { + setShouldRender(true); + setIsAnimating(true); + // Wait for content to render, then measure + setTimeout(() => { + if (measureRef.current) { + const contentHeight = measureRef.current.scrollHeight; + setHeight(`${contentHeight}px`); + } + // Animation complete + setTimeout(() => { + setIsAnimating(false); + }, duration * 1000); + }, 0); + } else { + if (shouldRender) { + setIsAnimating(true); + if (measureRef.current) { + const contentHeight = measureRef.current.scrollHeight; + setHeight(`${contentHeight}px`); + // Force a reflow before animating to 0 + requestAnimationFrame(() => { + setHeight("0px"); + }); + } + // Hide content after animation completes + setTimeout(() => { + setShouldRender(false); + setIsAnimating(false); + if (onFinish) { + onFinish(); + } + }, duration * 1000); + } + } + }, [visible, duration, shouldRender]); + + // Don't render if not visible and not animating + if (!visible && !shouldRender && !isAnimating) { + return null; + } + + return ( +
+
+ {shouldRender && children} +
+
+ ); +} \ No newline at end of file From a5e1139a6c2feab0662d02fff285fa29841e93a6 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 19 Sep 2025 16:10:09 +0300 Subject: [PATCH 4/7] Implement actual reply --- backend/models.py | 1 + backend/routes/messaging.py | 52 +++-------------------- frontend/src/ui/panels/PublicChatPanel.ts | 5 ++- 3 files changed, 12 insertions(+), 46 deletions(-) diff --git a/backend/models.py b/backend/models.py index 33b67cd..b8a1b62 100644 --- a/backend/models.py +++ b/backend/models.py @@ -82,6 +82,7 @@ class RegisterRequest(BaseModel): class SendMessageRequest(BaseModel): content: str + reply_to_id: int | None class EditMessageRequest(BaseModel): diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 2868f59..7c07165 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -29,6 +29,12 @@ async def send_message( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): + if request.reply_to_id: + # Check if the message being replied to exists + original_message = db.query(Message).filter(Message.id == request.reply_to_id).first() + if not original_message: + raise HTTPException(status_code=404, detail="Original message not found") + if not request.content.strip(): raise HTTPException( status_code=400, @@ -44,6 +50,7 @@ async def send_message( new_message = Message( content=request.content.strip(), user_id=current_user.id, + reply_to_id=request.reply_to_id, timestamp=datetime.now() ) @@ -191,35 +198,6 @@ async def delete_message( return {"status": "success", "message_id": message_id} - -@router.post("/reply_message") -async def reply_message( - request: ReplyMessageRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - # Check if the message being replied to exists - original_message = db.query(Message).filter(Message.id == request.reply_to_id).first() - if not original_message: - raise HTTPException(status_code=404, detail="Original message not found") - - if not request.content.strip(): - raise HTTPException(status_code=400, detail="No content provided") - - new_message = Message( - content=request.content.strip(), - user_id=current_user.id, - timestamp=datetime.now(), - reply_to_id=request.reply_to_id - ) - - db.add(new_message) - db.commit() - db.refresh(new_message) - - return {"status": "success", "message": convert_message(new_message)} - - class MessaggingSocketManager: def __init__(self) -> None: self.connections: list[WebSocket] = [] @@ -356,22 +334,6 @@ class MessaggingSocketManager: "data": {"message_id": message_id} }) - await websocket.send_json({"type": type, "data": response}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "replyMessage": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - request: ReplyMessageRequest = ReplyMessageRequest.model_validate(data["data"]) - response = await reply_message(request, current_user, db) - await self.broadcast({ - "type": "newMessage", - "data": response["message"] - }) - await websocket.send_json({"type": type, "data": response}) except HTTPException as e: await self.send_error(websocket, type, e) diff --git a/frontend/src/ui/panels/PublicChatPanel.ts b/frontend/src/ui/panels/PublicChatPanel.ts index d32cf79..dd5f2f7 100644 --- a/frontend/src/ui/panels/PublicChatPanel.ts +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -66,7 +66,10 @@ export class PublicChatPanel extends MessagePanel { try { const response = await request({ - data: { content: content.trim(), ...(replyToId ? { reply_to_id: replyToId } : {}) }, + data: { + content: content.trim(), + reply_to_id: replyToId ?? null + }, credentials: { scheme: "Bearer", credentials: this.currentUser.authToken From 318e15001fa832d37c46195e6f3b85840d4d1839 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 19 Sep 2025 16:29:41 +0300 Subject: [PATCH 5/7] Remove useless model --- backend/models.py | 5 ----- backend/routes/messaging.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/models.py b/backend/models.py index b8a1b62..e3625c5 100644 --- a/backend/models.py +++ b/backend/models.py @@ -89,11 +89,6 @@ class EditMessageRequest(BaseModel): content: str -class ReplyMessageRequest(BaseModel): - content: str - reply_to_id: int - - class DeleteMessageRequest(BaseModel): message_id: int diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 7c07165..927aac3 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -5,7 +5,7 @@ from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db from constants import OWNER_USERNAME -from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User, DMEnvelope +from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope router = APIRouter() logger = logging.getLogger("uvicorn.error") From 5cf6af925d6e0c191dc95891a39d72be43f5e318 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 19 Sep 2025 16:54:48 +0300 Subject: [PATCH 6/7] Improve quote styling --- frontend/src/resources/css/_chat.scss | 56 +++++++++++-------- .../src/resources/css/common/_components.scss | 4 ++ .../ui/components/chat/ChatInputWrapper.tsx | 5 +- frontend/src/ui/components/chat/Message.tsx | 2 +- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index 0731741..2eb1864 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -128,6 +128,23 @@ } } + .quote.contextual-content > .quote-inner { + display: flex; + flex-direction: column; + gap: 4px; + + .reply-username { + font-weight: 600; + color: $color-dark-on-surface; + font-size: 0.85rem; + } + + .reply-text { + overflow: hidden; + text-overflow: ellipsis; + } + } + .chat-messages { flex: 1; padding: 1rem; @@ -192,30 +209,9 @@ white-space: pre-wrap; } - .quote.message-reply { + .quote.reply-preview { user-select: none; margin-bottom: 10px; - - .quote-inner { - display: flex; - flex-direction: column; - gap: 0.2rem; - - .reply-username { - font-weight: 600; - font-size: 0.8rem; - color: $color-dark-primary; - } - - .reply-text { - font-size: 0.85rem; - color: $color-dark-on-surface-variant; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 200px; - } - } } .message-time { @@ -289,6 +285,22 @@ border-radius: 30px; flex-direction: column; + .contextual-preview { + padding: 12px 16px 0 16px; + display: flex; + align-items: flex-start; + gap: 16px; + + mdui-icon { + align-self: center; + box-sizing: content-box; + } + + .reply-cancel { + margin-left: auto; + } + } + .chat-input { flex: 1; display: flex; diff --git a/frontend/src/resources/css/common/_components.scss b/frontend/src/resources/css/common/_components.scss index d2581f4..8bf680e 100644 --- a/frontend/src/resources/css/common/_components.scss +++ b/frontend/src/resources/css/common/_components.scss @@ -131,6 +131,10 @@ button, input { border-radius: 8px; overflow: hidden; + color: $color-dark-on-surface-variant; + font-size: 0.9rem; + line-height: 1.4; + &.bg-surfaceContainer { background-color: $color-dark-secondary-container; diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index 065cdbf..0f5ad47 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -29,8 +29,9 @@ export function ChatInputWrapper({ onSendMessage, replyTo, replyToVisible, onCle {replyTo && ( -
- +
+ + {replyTo!.username} {replyTo!.content} diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index 954157f..7002121 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -54,7 +54,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo {/* Add reply preview if this is a reply */} {message.reply_to && ( - + {message.reply_to.username} {message.reply_to.content} From 8f2e5b2c7e875b575ce12d878528e68dde2334f2 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 19 Sep 2025 17:06:37 +0300 Subject: [PATCH 7/7] Implement inline edit --- .../ui/components/chat/ChatInputWrapper.tsx | 42 +++++++++++-- .../src/ui/components/chat/ChatMessages.tsx | 24 ++----- .../ui/components/chat/EditMessageDialog.tsx | 54 ---------------- .../ui/components/chat/MessageContextMenu.tsx | 33 +++------- .../components/chat/MessagePanelRenderer.tsx | 62 ++++++++++++++++++- frontend/src/ui/state.ts | 19 +++++- 6 files changed, 125 insertions(+), 109 deletions(-) delete mode 100644 frontend/src/ui/components/chat/EditMessageDialog.tsx diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index 0f5ad47..8833746 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -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()) { - onSendMessage(message); - setMessage(""); - if (onClearReply) onClearReply(); + if (editingMessage && onSaveEdit) { + onSaveEdit(message); + setMessage(""); + if (onClearEdit) onClearEdit(); + } else { + onSendMessage(message); + setMessage(""); + if (onClearReply) onClearReply(); + } } }; return (
+ + {editingMessage && ( +
+ + + {editingMessage!.username} + {editingMessage!.content} + + +
+ )} +
{replyTo && (
@@ -50,7 +80,7 @@ export function ChatInputWrapper({ onSendMessage, replyTo, replyToVisible, onCle onTextChange={(value) => setMessage(value)} onEnter={handleSubmit} />
diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index 80f824c..048caf8 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -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) => { diff --git a/frontend/src/ui/components/chat/EditMessageDialog.tsx b/frontend/src/ui/components/chat/EditMessageDialog.tsx deleted file mode 100644 index 9ff7b20..0000000 --- a/frontend/src/ui/components/chat/EditMessageDialog.tsx +++ /dev/null @@ -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 ( - -
-

Edit Message

- setEditContent((e.target as HTMLInputElement).value)} - label="Edit Message" - variant="outlined" - placeholder="Edit your message..." - maxlength={1000}> - -
- Cancel - Save -
-
-
- ); -} diff --git a/frontend/src/ui/components/chat/MessageContextMenu.tsx b/frontend/src/ui/components/chat/MessageContextMenu.tsx index 5374239..8249ab7 100644 --- a/frontend/src/ui/components/chat/MessageContextMenu.tsx +++ b/frontend/src/ui/components/chat/MessageContextMenu.tsx @@ -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 */} - ); } diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index b442436..062511c 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -18,6 +18,9 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen const messagesEndRef = useRef(null); const [replyTo, setReplyTo] = useState(null); const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo)); + const [editMessage, setEditMessage] = useState(null); + const [editVisible, setEditVisible] = useState(Boolean(editMessage)); + const [pendingAction, setPendingAction] = useState(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
): ( - setReplyTo(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); + } + }} + >
)} @@ -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); + } + }} />
diff --git a/frontend/src/ui/state.ts b/frontend/src/ui/state.ts index 335dcf3..cbf1879 100644 --- a/frontend/src/ui/state.ts +++ b/frontend/src/ui/state.ts @@ -248,7 +248,24 @@ export const useAppState = create((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: () => {}