mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Merge branch 'feature/inline-reply'
This commit is contained in:
+1
-5
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -337,6 +345,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.message-profile-pic {
|
.message-profile-pic {
|
||||||
img {
|
img {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ export class DMPanel extends MessagePanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(content: string): Promise<void> {
|
async sendMessage(content: string, _replyToId?: number): Promise<void> {
|
||||||
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
|
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export abstract class MessagePanel {
|
|||||||
abstract activate(): Promise<void>;
|
abstract activate(): Promise<void>;
|
||||||
abstract deactivate(): void;
|
abstract deactivate(): void;
|
||||||
abstract loadMessages(): Promise<void>;
|
abstract loadMessages(): Promise<void>;
|
||||||
abstract sendMessage(content: string): Promise<void>;
|
abstract sendMessage(content: string, replyToId?: number): Promise<void>;
|
||||||
abstract isDm(): boolean;
|
abstract isDm(): boolean;
|
||||||
|
|
||||||
// Optional WebSocket message handler (can be overridden by subclasses)
|
// Optional WebSocket message handler (can be overridden by subclasses)
|
||||||
@@ -115,8 +115,8 @@ export abstract class MessagePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
handleSendMessage = (content: string): void => {
|
handleSendMessage = (content: string, replyToId?: number): void => {
|
||||||
this.sendMessage(content);
|
this.sendMessage(content, replyToId);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleEditMessage = (messageId: number, content: string): void => {
|
handleEditMessage = (messageId: number, content: string): void => {
|
||||||
|
|||||||
@@ -61,12 +61,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
|
||||||
|
|||||||
@@ -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: () => {}
|
||||||
|
|||||||
Reference in New Issue
Block a user