Merge branch 'feature/inline-reply'

This commit is contained in:
2025-09-19 17:25:49 +03:00
Unverified
17 changed files with 362 additions and 254 deletions
+1 -5
View File
@@ -82,17 +82,13 @@ class RegisterRequest(BaseModel):
class SendMessageRequest(BaseModel): class SendMessageRequest(BaseModel):
content: str content: str
reply_to_id: int | None
class EditMessageRequest(BaseModel): class EditMessageRequest(BaseModel):
content: str content: str
class ReplyMessageRequest(BaseModel):
content: str
reply_to_id: int
class DeleteMessageRequest(BaseModel): class DeleteMessageRequest(BaseModel):
message_id: int message_id: int
+8 -46
View File
@@ -5,7 +5,7 @@ from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User, DMEnvelope from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope
router = APIRouter() router = APIRouter()
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@@ -29,6 +29,12 @@ async def send_message(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) 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(): if not request.content.strip():
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@@ -44,6 +50,7 @@ async def send_message(
new_message = Message( new_message = Message(
content=request.content.strip(), content=request.content.strip(),
user_id=current_user.id, user_id=current_user.id,
reply_to_id=request.reply_to_id,
timestamp=datetime.now() timestamp=datetime.now()
) )
@@ -191,35 +198,6 @@ async def delete_message(
return {"status": "success", "message_id": message_id} 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: class MessaggingSocketManager:
def __init__(self) -> None: def __init__(self) -> None:
self.connections: list[WebSocket] = [] self.connections: list[WebSocket] = []
@@ -356,22 +334,6 @@ class MessaggingSocketManager:
"data": {"message_id": message_id} "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}) await websocket.send_json({"type": type, "data": response})
except HTTPException as e: except HTTPException as e:
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
+39 -30
View File
@@ -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 { .chat-messages {
flex: 1; flex: 1;
padding: 1rem; padding: 1rem;
@@ -192,34 +209,9 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.message-reply { .quote.reply-preview {
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;
user-select: none; user-select: none;
margin-bottom: 10px;
.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;
}
}
} }
.message-time { .message-time {
@@ -289,10 +281,27 @@
.input-group { .input-group {
display: flex; display: flex;
.chat-input {
background-color: $color-dark-surface-container; background-color: $color-dark-surface-container;
border-radius: 30px; 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; flex: 1;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -313,7 +322,6 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
}
.send-btn { .send-btn {
margin: 10px; margin: 10px;
@@ -336,6 +344,7 @@
} }
} }
} }
}
} }
.message-profile-pic { .message-profile-pic {
@@ -1,4 +1,5 @@
@use "material" as *; @use "material" as *;
@use "sass:color";
.text-center { .text-center {
text-align: center; text-align: center;
@@ -124,3 +125,26 @@ button, input {
background-color: transparent; background-color: transparent;
display: block; 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;
}
}
@@ -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: rgb(28 32 36);
$color-dark-surface-container-high: rgb(38 43 46); $color-dark-surface-container-high: rgb(38 43 46);
$color-dark-surface-container-highest: rgb(49 53 57); $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 // custom colors
$color-1: rgb(82, 109, 246); $color-1: rgb(82, 109, 246);
$color-2: rgb(65, 11, 113); $color-2: rgb(65, 11, 113);
@@ -1,24 +1,74 @@
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 Quote from "../core/Quote";
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;
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(""); 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()) {
if (editingMessage && onSaveEdit) {
onSaveEdit(message);
setMessage("");
if (onClearEdit) onClearEdit();
} else {
onSendMessage(message); onSendMessage(message);
setMessage(""); 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}>
{replyTo && (
<div className="reply-preview contextual-preview">
<mdui-icon name="reply" />
<Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{replyTo!.username}</span>
<span className="reply-text">{replyTo!.content}</span>
</Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon>
</div>
)}
</AnimatedHeight>
<div className="chat-input"> <div className="chat-input">
<RichTextArea <RichTextArea
className="message-input" className="message-input"
@@ -30,7 +80,7 @@ export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) {
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>
@@ -14,9 +14,11 @@ interface ChatMessagesProps {
messages?: MessageType[]; messages?: MessageType[];
isDm?: boolean; isDm?: boolean;
children?: ReactNode; 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 { messages: hookMessages } = useChat();
const { user } = useAppState(); const { user } = useAppState();
@@ -66,46 +68,12 @@ export function ChatMessages({ messages: propMessages, children, isDm = false }:
})); }));
}; };
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 = 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,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>
);
}
+3 -4
View File
@@ -1,6 +1,7 @@
import { formatTime } from "../../../utils/utils"; import { formatTime } from "../../../utils/utils";
import type { Message as MessageType } from "../../../core/types"; import type { Message as MessageType } from "../../../core/types";
import defaultAvatar from "../../../resources/images/default-avatar.png"; import defaultAvatar from "../../../resources/images/default-avatar.png";
import Quote from "../core/Quote";
interface MessageProps { interface MessageProps {
message: MessageType; message: MessageType;
@@ -53,12 +54,10 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
{/* Add reply preview if this is a reply */} {/* Add reply preview if this is a reply */}
{message.reply_to && ( {message.reply_to && (
<div className="message-reply"> <Quote className="reply-preview contextual-content">
<div className="reply-content">
<span className="reply-username">{message.reply_to.username}</span> <span className="reply-username">{message.reply_to.username}</span>
<span className="reply-text">{message.reply_to.content}</span> <span className="reply-text">{message.reply_to.content}</span>
</div> </Quote>
</div>
)} )}
<div className="message-content"> <div className="message-content">
@@ -1,7 +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";
import { ReplyMessageDialog } from "./ReplyMessageDialog";
interface MessageContextMenuProps { interface MessageContextMenuProps {
message: Message; message: Message;
@@ -30,9 +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 [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 +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 && !replyDialogOpen) { 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')) {
@@ -88,14 +84,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) {
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) {
handleClose(); handleClose();
} }
}; };
@@ -111,17 +107,16 @@ 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]);
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) onEdit(message);
setEditDialogOpen(true);
}
break; break;
case "delete": case "delete":
if (isAuthor) { if (isAuthor) {
@@ -146,20 +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 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 = (
@@ -194,27 +176,11 @@ export function MessageContextMenu({
) )
// Don't render if not open // Don't render if not open
if (!isOpen && !editDialogOpen && !replyDialogOpen) 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}
/>
{/* 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,23 @@ 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);
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(() => {
if (replyTo) {
setReplyToVisible(true);
}
}, [replyTo]);
useEffect(() => {
if (editMessage) {
setEditVisible(true);
}
}, [editMessage]);
// Handle panel state changes // Handle panel state changes
useEffect(() => { useEffect(() => {
@@ -132,12 +150,68 @@ 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) => {
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>
)} )}
<ChatInputWrapper onSendMessage={panel.handleSendMessage} /> <ChatInputWrapper
onSendMessage={(text) => {
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);
}
}}
/>
</div> </div>
</div> </div>
); );
+17
View File
@@ -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 (
<div className={`quote bg-${background} ${className}`}>
<div className="quote-inner">
{children}
</div>
</div>
)
}
@@ -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<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(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 (
<div
ref={contentRef}
style={{
height,
transition: `height ${duration}s ease`,
overflow: "hidden"
}}
>
<div ref={measureRef} style={{ height: "auto" }}>
{shouldRender && children}
</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 => {
+5 -2
View File
@@ -61,12 +61,15 @@ 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(),
reply_to_id: replyToId ?? null
},
credentials: { credentials: {
scheme: "Bearer", scheme: "Bearer",
credentials: this.currentUser.authToken credentials: this.currentUser.authToken
+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: () => {}