diff --git a/backend/models.py b/backend/models.py
index 33b67cd..e3625c5 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -82,17 +82,13 @@ class RegisterRequest(BaseModel):
class SendMessageRequest(BaseModel):
content: str
+ reply_to_id: int | None
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 2868f59..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")
@@ -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/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss
index d342d08..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,34 +209,9 @@
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.reply-preview {
user-select: none;
-
- .reply-content {
- 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;
- }
- }
+ margin-bottom: 10px;
}
.message-time {
@@ -289,10 +281,27 @@
.input-group {
display: flex;
+ background-color: $color-dark-surface-container;
+ 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 {
- background-color: $color-dark-surface-container;
- border-radius: 30px;
flex: 1;
display: flex;
flex-direction: row;
@@ -313,24 +322,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..8bf680e 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,27 @@ button, input {
overflow-y: hidden;
background-color: transparent;
display: block;
+}
+
+.quote {
+ background-color: $color-dark-surface-primary-container-lightened;
+ 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;
+
+ .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 19f4dd3..8833746 100644
--- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx
+++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx
@@ -1,24 +1,74 @@
-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";
+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 }: 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 (editingMessage && onSaveEdit) {
+ onSaveEdit(message);
+ setMessage("");
+ if (onClearEdit) onClearEdit();
+ } else {
+ onSendMessage(message);
+ setMessage("");
+ if (onClearReply) onClearReply();
+ }
}
};
return (
diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx
index 357a102..048caf8 100644
--- a/frontend/src/ui/components/chat/ChatMessages.tsx
+++ b/frontend/src/ui/components/chat/ChatMessages.tsx
@@ -14,9 +14,11 @@ interface ChatMessagesProps {
messages?: MessageType[];
isDm?: boolean;
children?: ReactNode;
+ onReplySelect?: (message: MessageType) => void;
+ onEditSelect?: (message: MessageType) => void;
}
-export function ChatMessages({ messages: propMessages, children, isDm = false }: ChatMessagesProps) {
+export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect }: ChatMessagesProps) {
const { messages: hookMessages } = useChat();
const { user } = useAppState();
@@ -66,46 +68,12 @@ export function ChatMessages({ messages: propMessages, children, isDm = false }:
}));
};
- 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 = 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/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/Message.tsx b/frontend/src/ui/components/chat/Message.tsx
index 160c967..7002121 100644
--- a/frontend/src/ui/components/chat/Message.tsx
+++ b/frontend/src/ui/components/chat/Message.tsx
@@ -1,6 +1,7 @@
import { formatTime } from "../../../utils/utils";
import type { Message as MessageType } from "../../../core/types";
import defaultAvatar from "../../../resources/images/default-avatar.png";
+import Quote from "../core/Quote";
interface MessageProps {
message: MessageType;
@@ -53,12 +54,10 @@ 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}
-
-
+
+ {message.reply_to.username}
+ {message.reply_to.content}
+
)}
diff --git a/frontend/src/ui/components/chat/MessageContextMenu.tsx b/frontend/src/ui/components/chat/MessageContextMenu.tsx
index 493bcf2..8249ab7 100644
--- a/frontend/src/ui/components/chat/MessageContextMenu.tsx
+++ b/frontend/src/ui/components/chat/MessageContextMenu.tsx
@@ -1,7 +1,5 @@
import { useState, useEffect } from "react";
import type { Message, Size2D } from "../../../core/types";
-import { EditMessageDialog } from "./EditMessageDialog";
-import { ReplyMessageDialog } from "./ReplyMessageDialog";
interface MessageContextMenuProps {
message: Message;
@@ -30,9 +28,7 @@ export function MessageContextMenu({
isOpen,
onOpenChange
}: MessageContextMenuProps) {
- // Internal state for dialogs and closing animation
- const [editDialogOpen, setEditDialogOpen] = useState(false);
- const [replyDialogOpen, setReplyDialogOpen] = useState(false);
+ // Internal state for closing animation
const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState('entering');
@@ -78,7 +74,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) {
// Check if the click is on a context menu element
const target = event.target as Element;
if (!target.closest('.context-menu')) {
@@ -88,14 +84,14 @@ export function MessageContextMenu({
};
const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape' && isOpen && !isClosing && !editDialogOpen && !replyDialogOpen) {
+ if (event.key === 'Escape' && isOpen && !isClosing) {
handleClose();
}
};
const handleWindowBlur = () => {
// Close context menu when browser window loses focus
- if (isOpen && !isClosing && !editDialogOpen && !replyDialogOpen) {
+ if (isOpen && !isClosing) {
handleClose();
}
};
@@ -111,17 +107,16 @@ export function MessageContextMenu({
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleWindowBlur);
};
- }, [isOpen, isClosing, editDialogOpen, replyDialogOpen]);
+ }, [isOpen, isClosing]);
const handleAction = (action: string) => {
switch (action) {
case "reply":
- setReplyDialogOpen(true);
+ onReply(message);
+ handleClose();
break;
case "edit":
- if (isAuthor) {
- setEditDialogOpen(true);
- }
+ if (isAuthor) onEdit(message);
break;
case "delete":
if (isAuthor) {
@@ -146,20 +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);
- };
-
- 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);
- };
-
+ // Inline edit handled by parent via onEdit
const content = (
@@ -194,27 +176,11 @@ export function MessageContextMenu({
)
// Don't render if not open
- if (!isOpen && !editDialogOpen && !replyDialogOpen) return null;
+ if (!isOpen) return null;
return (
<>
{isOpen ? content : null}
-
- {/* Edit Dialog */}
-
-
- {/* Reply Dialog */}
-
>
);
}
diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx
index 313d307..062511c 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,23 @@ 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);
+ 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) {
+ setReplyToVisible(true);
+ }
+ }, [replyTo]);
+
+ useEffect(() => {
+ if (editMessage) {
+ setEditVisible(true);
+ }
+ }, [editMessage]);
// Handle panel state changes
useEffect(() => {
@@ -132,12 +150,68 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
): (
-
+ {
+ 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);
+ }
+ }}
+ >
)}
-
+ {
+ panel.handleSendMessage(text, replyTo?.id);
+ setReplyTo(null);
+ }}
+ onSaveEdit={(content) => {
+ if (editMessage) {
+ panel.handleEditMessage(editMessage.id, content);
+ setEditMessage(null);
+ }
+ }}
+ replyTo={replyTo}
+ replyToVisible={replyToVisible}
+ 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/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 (
+
+ )
+}
\ 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
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..dd5f2f7 100644
--- a/frontend/src/ui/panels/PublicChatPanel.ts
+++ b/frontend/src/ui/panels/PublicChatPanel.ts
@@ -61,12 +61,15 @@ 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(),
+ reply_to_id: replyToId ?? null
+ },
credentials: {
scheme: "Bearer",
credentials: this.currentUser.authToken
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: () => {}