Implement file sending

This commit is contained in:
2025-09-24 14:14:59 +03:00
Unverified
parent 230b8d7e56
commit 6066ec9767
13 changed files with 472 additions and 95 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"mdui": {
"command": "npx",
"args": ["-y", "@mdui/mcp"]
}
}
}
+15
View File
@@ -36,6 +36,21 @@ class Message(Base):
author = relationship("User", back_populates="messages") author = relationship("User", back_populates="messages")
reply_to = relationship("Message", remote_side=[id]) reply_to = relationship("Message", remote_side=[id])
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
class MessageFile(Base):
__tablename__ = "message_file"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
path = Column(Text, nullable=False)
encrypted = Column(Boolean, default=False, nullable=False)
filename = Column(String(255), nullable=True)
content_type = Column(String(255), nullable=True)
size = Column(Integer, nullable=True)
message = relationship("Message", back_populates="files")
class CryptoPublicKey(Base): class CryptoPublicKey(Base):
+219 -7
View File
@@ -1,16 +1,35 @@
from datetime import datetime from datetime import datetime
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from pathlib import Path
import os
import re
import uuid
from typing import Iterable
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials 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, User, DMEnvelope from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile
from push_service import push_service from push_service import push_service
from PIL import Image
import io
import json
router = APIRouter() router = APIRouter()
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
FILES_BASE_DIR = Path("data/uploads/files")
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
def convert_message(msg: Message) -> dict: def convert_message(msg: Message) -> dict:
return { return {
"id": msg.id, "id": msg.id,
@@ -20,16 +39,40 @@ def convert_message(msg: Message) -> dict:
"is_edited": msg.is_edited, "is_edited": msg.is_edited,
"username": msg.author.username, "username": msg.author.username,
"profile_picture": msg.author.profile_picture, "profile_picture": msg.author.profile_picture,
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None "reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
"files": [
{
"path": f"/api/files/{'encrypted' if f.encrypted else 'normal'}/{Path(f.path).name}",
"encrypted": f.encrypted,
"filename": f.filename,
"content_type": f.content_type,
"size": f.size,
}
for f in (msg.files or [])
]
} }
@router.post("/send_message") @router.post("/send_message")
async def send_message( async def send_message(
request: SendMessageRequest, request: SendMessageRequest | None = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db),
# Optional multipart form support
payload: str | None = Form(default=None),
files: list[UploadFile] = File(default=[]),
): ):
# If payload is provided, prefer it for multipart requests
if payload and request is None:
# Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null}
try:
obj = json.loads(payload)
content = obj.get("data", {}).get("content", "")
reply_to_id = obj.get("reply_to_id", None)
request = SendMessageRequest(content=content, reply_to_id=reply_to_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid payload JSON")
if request.reply_to_id: if request.reply_to_id:
# Check if the message being replied to exists # Check if the message being replied to exists
original_message = db.query(Message).filter(Message.id == request.reply_to_id).first() original_message = db.query(Message).filter(Message.id == request.reply_to_id).first()
@@ -59,12 +102,80 @@ async def send_message(
db.commit() db.commit()
db.refresh(new_message) db.refresh(new_message)
# Handle files if provided (normal, not encrypted)
if files:
total_size = 0
for up in files:
# Accumulate size if available
if hasattr(up, "size") and up.size is not None:
total_size += int(up.size)
else:
# If size unknown, read into memory to determine
data = await up.read()
up.file.seek(0)
total_size += len(data)
if total_size > MAX_TOTAL_SIZE:
raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB")
for up in files:
# Sanitize filename
original_name = Path(up.filename or "file").name
ext = Path(original_name).suffix.lower()
uid = uuid.uuid4().hex
safe_name = f"{new_message.id}_{uid}{ext or ''}"
out_path = FILES_NORMAL_DIR / safe_name
content = await up.read()
up.file.seek(0)
# If image, try lossless optimization
try:
if up.content_type and up.content_type.startswith("image/"):
image = Image.open(io.BytesIO(content))
img_format = image.format or ("PNG" if ext == ".png" else "JPEG")
buf = io.BytesIO()
save_kwargs = {"optimize": True}
if img_format.upper() == "JPEG":
# Use quality=95 with optimize to keep high quality (not truly lossless but near)
save_kwargs["quality"] = 95
image.save(buf, format=img_format, **save_kwargs)
buf.seek(0)
content = buf.read()
except Exception:
# Fallback to original content
pass
with open(out_path, "wb") as f:
f.write(content)
mf = MessageFile(
message_id=new_message.id,
path=str(out_path),
encrypted=False,
filename=original_name,
content_type=up.content_type,
size=len(content),
)
db.add(mf)
db.commit()
db.refresh(new_message)
# Send push notifications for public messages # Send push notifications for public messages
try: try:
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id) await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
except Exception as e: except Exception as e:
logger.error(f"Failed to send push notification for message {new_message.id}: {e}") logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
# Realtime broadcast for HTTP uploads as well
try:
from .messaging import messagingManager # self import safe here
await messagingManager.broadcast({
"type": "newMessage",
"data": convert_message(new_message)
})
except Exception:
pass
return {"status": "success", "message": convert_message(new_message)} return {"status": "success", "message": convert_message(new_message)}
@@ -83,11 +194,30 @@ async def get_messages(db: Session = Depends(get_db)):
@router.post("/dm/send") @router.post("/dm/send")
async def dm_send(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): async def dm_send(
payload: dict | None = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
# Multipart support
dm_payload: str | None = Form(default=None),
files: list[UploadFile] = File(default=[]),
fileNames: str | None = Form(default=None), # JSON array of filenames corresponding to files
):
import json
if dm_payload and payload is None:
try:
payload = json.loads(dm_payload)
except Exception:
raise HTTPException(status_code=400, detail="Invalid dm_payload JSON")
if payload is None:
raise HTTPException(status_code=400, detail="Missing payload")
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"] required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
for key in required: for key in required:
if key not in payload: if key not in payload:
raise HTTPException(status_code=400, detail=f"Missing {key}") raise HTTPException(status_code=400, detail=f"Missing {key}")
env = DMEnvelope( env = DMEnvelope(
sender_id=current_user.id, sender_id=current_user.id,
recipient_id=int(payload["recipientId"]), recipient_id=int(payload["recipientId"]),
@@ -101,12 +231,73 @@ async def dm_send(payload: dict, current_user: User = Depends(get_current_user),
db.commit() db.commit()
db.refresh(env) db.refresh(env)
# Save encrypted files if any (no processing)
if files:
# Validate total size
total_size = 0
for up in files:
if hasattr(up, "size") and up.size is not None:
total_size += int(up.size)
else:
data = await up.read()
up.file.seek(0)
total_size += len(data)
if total_size > MAX_TOTAL_SIZE:
raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB")
names: list[str] = []
if fileNames:
try:
decoded = json.loads(fileNames)
if isinstance(decoded, list):
names = [str(x) for x in decoded]
except Exception:
names = []
for idx, up in enumerate(files):
provided = names[idx] if idx < len(names) else None
# Sanitize provided name to avoid path traversal
if provided and not re.match(r"^[A-Za-z0-9._-]{1,200}$", provided):
provided = None
original_name = provided or Path(up.filename or "file").name
# Save using provided/original name to allow client to reference path directly
safe_name = original_name
out_path = FILES_ENCRYPTED_DIR / safe_name
content = await up.read()
with open(out_path, "wb") as f:
f.write(content)
# We do not store linkage to public messages for DMs; paths will be referenced inside encrypted JSON
# Send push notification for DM # Send push notification for DM
try: try:
await push_service.send_dm_notification(db, env, current_user) await push_service.send_dm_notification(db, env, current_user)
except Exception as e: except Exception as e:
logger.error(f"Failed to send push notification for DM {env.id}: {e}") logger.error(f"Failed to send push notification for DM {env.id}: {e}")
# Realtime notify both users for HTTP requests
try:
from .messaging import messagingManager # self import
payload_ws = {
"type": "dmNew",
"data": {
"id": env.id,
"senderId": env.sender_id,
"recipientId": env.recipient_id,
"iv": env.iv_b64,
"ciphertext": env.ciphertext_b64,
"salt": env.salt_b64,
"iv2": env.iv2_b64,
"wrappedMk": env.wrapped_mk_b64,
"timestamp": env.timestamp.isoformat(),
}
}
await messagingManager.send_to_user(env.recipient_id, payload_ws)
await messagingManager.send_to_user(env.sender_id, payload_ws)
except Exception:
pass
return {"status": "ok", "id": env.id} return {"status": "ok", "id": env.id}
@@ -284,7 +475,7 @@ class MessaggingSocketManager:
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
response = await send_message(request, current_user, db) response = await send_message(request, current_user, db, None, [])
await self.broadcast({ await self.broadcast({
"type": "newMessage", "type": "newMessage",
"data": response["message"] "data": response["message"]
@@ -415,3 +606,24 @@ async def chat_websocket(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
await messagingManager.connect(websocket, db) await messagingManager.connect(websocket, db)
# File serving endpoints
@router.get("/files/normal/{filename}")
async def get_file_normal(filename: str):
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
raise HTTPException(status_code=400, detail="Invalid file name")
path = FILES_NORMAL_DIR / filename
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(str(path))
@router.get("/files/encrypted/{filename}")
async def get_file_encrypted(filename: str):
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
raise HTTPException(status_code=400, detail="Invalid file name")
path = FILES_ENCRYPTED_DIR / filename
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(str(path))
+9
View File
@@ -60,6 +60,7 @@ export interface Message {
timestamp: string; timestamp: string;
profile_picture?: string; profile_picture?: string;
reply_to?: Message; reply_to?: Message;
files?: Attachment[];
} }
/** /**
@@ -230,6 +231,14 @@ export interface WebSocketCredentials {
credentials: string; credentials: string;
} }
export interface Attachment {
path: string;
encrypted: boolean;
filename?: string;
content_type?: string;
size?: number;
}
// ----------- // -----------
// React types // React types
// ----------- // -----------
+76 -48
View File
@@ -172,28 +172,7 @@
align-items: flex-end; align-items: flex-end;
gap: 8px; gap: 8px;
.message-profile-pic {
width: 32px;
height: 32px;
flex-shrink: 0;
margin-bottom: 4px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
}
.message-inner { .message-inner {
padding: 0.8rem 1rem;
border-radius: 12px; border-radius: 12px;
position: relative; position: relative;
word-wrap: break-word; word-wrap: break-word;
@@ -203,9 +182,43 @@
max-width: 100%; max-width: 100%;
display: inline-block; display: inline-block;
.message-profile-pic {
width: 32px;
height: 32px;
flex-shrink: 0;
margin-bottom: 4px;
margin: 8px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
}
.message-username {
font-weight: 600;
margin-bottom: 0.3rem;
font-size: 0.9rem;
transition: color 0.2s ease;
margin: 8px;
&:hover {
color: $color-dark-primary;
text-decoration: underline;
}
}
.message-content { .message-content {
word-wrap: break-word; word-wrap: break-word;
margin-bottom: 10px; margin: 10px 10px 0 10px;
white-space: pre-wrap; white-space: pre-wrap;
> p:first-child { > p:first-child {
@@ -222,12 +235,22 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.message-attachments {
padding: 5px 0 0 0;
.attachment {
a {
text-decoration: none;
}
}
}
.message-time { .message-time {
font-size: 0.7rem; font-size: 0.7rem;
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
margin-top: 0.3rem; margin-top: 0.3rem;
text-align: right; text-align: right;
user-select: none; user-select: none;
margin: 4px 8px 8px 8px;
} }
} }
@@ -252,18 +275,6 @@
} }
} }
} }
.message-username {
font-weight: 600;
margin-bottom: 0.3rem;
font-size: 0.9rem;
transition: color 0.2s ease;
&:hover {
color: $color-dark-primary;
text-decoration: underline;
}
}
} }
.chat-input-wrapper { .chat-input-wrapper {
@@ -309,6 +320,16 @@
} }
} }
.attachments-preview {
align-items: center;
.attachments-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.chat-input { .chat-input {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -331,22 +352,29 @@
width: 100%; width: 100%;
} }
.send-btn { .buttons {
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; align-self: flex-end;
display: flex;
flex-direction: row;
align-items: center;
@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);
}
} }
} }
} }
@@ -1,11 +1,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { MaterialDialog } from "../core/Dialog";
import { RichTextArea } from "../core/RichTextArea"; import { RichTextArea } from "../core/RichTextArea";
import type { Message } from "../../../core/types"; import type { Message } from "../../../core/types";
import Quote from "../core/Quote"; import Quote from "../core/Quote";
import AnimatedHeight from "../core/animations/AnimatedHeight"; import AnimatedHeight from "../core/animations/AnimatedHeight";
interface ChatInputWrapperProps { interface ChatInputWrapperProps {
onSendMessage: (message: string) => void; onSendMessage: (message: string, files: File[]) => void;
onSaveEdit?: (content: string) => void; onSaveEdit?: (content: string) => void;
replyTo?: Message | null; replyTo?: Message | null;
replyToVisible: boolean; replyToVisible: boolean;
@@ -19,6 +20,9 @@ interface ChatInputWrapperProps {
export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVisible, onClearReply, onCloseReply, editingMessage, editVisible = false, onClearEdit, onCloseEdit }: ChatInputWrapperProps) { export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVisible, onClearReply, onCloseReply, editingMessage, editVisible = false, onClearEdit, onCloseEdit }: ChatInputWrapperProps) {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
const [errorOpen, setErrorOpen] = useState(false);
// When entering edit mode, preload the message content // When entering edit mode, preload the message content
useEffect(() => { useEffect(() => {
@@ -29,21 +33,47 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi
} }
}, [editingMessage]); }, [editingMessage]);
useEffect(() => {
if (selectedFiles.length > 0) {
setAttachmentsVisible(true);
}
}, [selectedFiles])
const handleSubmit = async (e: React.FormEvent | Event) => { const handleSubmit = async (e: React.FormEvent | Event) => {
e.preventDefault(); e.preventDefault();
if (message.trim()) { const hasText = Boolean(message.trim());
const hasFiles = selectedFiles.length > 0;
if (hasText || hasFiles) {
const totalSize = selectedFiles.reduce((acc, f) => acc + f.size, 0);
const limit = 4 * 1024 * 1024 * 1024; // 4GB
if (totalSize > limit) {
setErrorOpen(true);
return;
}
if (editingMessage && onSaveEdit) { if (editingMessage && onSaveEdit) {
onSaveEdit(message); onSaveEdit(message);
setMessage(""); setMessage("");
if (onClearEdit) onClearEdit(); if (onClearEdit) onClearEdit();
} else { } else {
onSendMessage(message); onSendMessage(message, selectedFiles);
setMessage(""); setMessage("");
setAttachmentsVisible(false);
if (onClearReply) onClearReply(); if (onClearReply) onClearReply();
} }
} }
}; };
function handleAttachClick() {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.onchange = () => {
const files = Array.from(input.files || []);
setSelectedFiles(files);
};
input.click();
}
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}>
@@ -71,6 +101,28 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi
</div> </div>
)} )}
</AnimatedHeight> </AnimatedHeight>
<AnimatedHeight visible={attachmentsVisible} onFinish={() => setSelectedFiles([])}>
{selectedFiles.length > 0 && (
<div className="attachments-preview contextual-preview">
<mdui-icon name="attach_file" />
<div className="attachments-chips">
{selectedFiles.map((f, i) => (
<mdui-chip
key={i}
variant="input"
end-icon="close"
title={`${f.name} (${Math.round(f.size/1024/1024)} MB)`}
onClick={() => setSelectedFiles(prev => prev.filter((_, idx) => idx !== i))}
>
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
<span className="name">{f.name}</span>
</mdui-chip>
))}
</div>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
</div>
)}
</AnimatedHeight>
<div className="chat-input"> <div className="chat-input">
<RichTextArea <RichTextArea
className="message-input" className="message-input"
@@ -81,11 +133,19 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi
rows={1} rows={1}
onTextChange={(value) => setMessage(value)} onTextChange={(value) => setMessage(value)}
onEnter={handleSubmit} /> onEnter={handleSubmit} />
<button type="submit" className="send-btn"> <div className="buttons">
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span> <mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
</button> <button type="submit" className="send-btn">
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button>
</div>
</div> </div>
</form> </form>
<MaterialDialog open={errorOpen} onOpenChange={setErrorOpen} close-on-overlay-click close-on-esc>
<div slot="headline">Ошибка</div>
<div>Общий размер вложений превышает 4 ГБ.</div>
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
</MaterialDialog>
</div> </div>
); );
} }
@@ -77,6 +77,25 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
<div className="message-content" dangerouslySetInnerHTML={formattedMessage} /> <div className="message-content" dangerouslySetInnerHTML={formattedMessage} />
{message.files && message.files.length > 0 && (
<mdui-list className="message-attachments">
{message.files.map((file, idx) => {
const isImage = !file.encrypted && (file.content_type?.startsWith("image/") || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.filename || ""));
return (
<div className="attachment" key={idx}>
{isImage ? (
<img src={file.path} alt={file.filename || "image"} style={{ maxWidth: "200px", borderRadius: "8px" }} />
) : (
<a href={file.path} download target="_blank" rel="noreferrer">
<mdui-list-item icon="download--filled">{file.filename || file.path.split("/").pop()}</mdui-list-item>
</a>
)}
</div>
);
})}
</mdui-list>
)}
<div className="message-time"> <div className="message-time">
{formatTime(message.timestamp)} {formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined} {message.is_edited ? " (edited)" : undefined}
@@ -175,8 +175,8 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
)} )}
<ChatInputWrapper <ChatInputWrapper
onSendMessage={(text) => { onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id); panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null); setReplyTo(null);
}} }}
onSaveEdit={(content) => { onSaveEdit={(content) => {
+20 -8
View File
@@ -2,7 +2,8 @@ import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from
import { import {
fetchDMHistory, fetchDMHistory,
decryptDm, decryptDm,
sendDMViaWebSocket sendDMViaWebSocket,
sendDmWithFiles
} from "../../api/dmApi"; } from "../../api/dmApi";
import type { Message, WebSocketMessage } from "../../core/types"; import type { Message, WebSocketMessage } from "../../core/types";
import type { UserState } from "../state"; import type { UserState } from "../state";
@@ -88,16 +89,27 @@ export class DMPanel extends MessagePanel {
} }
} }
async sendMessage(content: string, _replyToId?: number): Promise<void> { async sendMessage(content: string, _replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
try { try {
await sendDMViaWebSocket( if (files.length === 0) {
this.dmData.userId, await sendDMViaWebSocket(
this.dmData.publicKey, this.dmData.userId,
content, this.dmData.publicKey,
this.currentUser.authToken content,
); this.currentUser.authToken
);
} else {
const json = JSON.stringify({ type: "text", data: { content: content.trim() } });
await sendDmWithFiles(
this.dmData.userId,
this.dmData.publicKey,
json,
files,
this.currentUser.authToken
);
}
} catch (error) { } catch (error) {
console.error("Failed to send DM:", error); console.error("Failed to send DM:", error);
} }
+4 -4
View File
@@ -12,7 +12,7 @@ export interface MessagePanelState {
} }
export interface MessagePanelCallbacks { export interface MessagePanelCallbacks {
onSendMessage: (content: string) => void; onSendMessage: (content: string, files: File[]) => void;
onEditMessage: (messageId: number, content: string) => void; onEditMessage: (messageId: number, content: string) => void;
onDeleteMessage: (messageId: number) => void; onDeleteMessage: (messageId: number) => void;
onReplyToMessage: (messageId: number, content: string) => void; onReplyToMessage: (messageId: number, content: string) => void;
@@ -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, replyToId?: number): Promise<void>; abstract sendMessage(content: string, replyToId?: number, files?: File[]): 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, replyToId?: number): void => { handleSendMessage = (content: string, replyToId?: number, files: File[] = []): void => {
this.sendMessage(content, replyToId); this.sendMessage(content, replyToId, files);
}; };
handleEditMessage = (messageId: number, content: string): void => { handleEditMessage = (messageId: number, content: string): void => {
+28 -15
View File
@@ -61,24 +61,37 @@ export class PublicChatPanel extends MessagePanel {
} }
} }
async sendMessage(content: string, replyToId?: number): Promise<void> { async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !content.trim()) return; if (!this.currentUser.authToken || !content.trim()) return;
try { try {
const response = await request({ if (files.length === 0) {
data: { const response = await request({
content: content.trim(), data: {
reply_to_id: replyToId ?? null content: content.trim(),
}, reply_to_id: replyToId ?? null
credentials: { },
scheme: "Bearer", credentials: {
credentials: this.currentUser.authToken scheme: "Bearer",
}, credentials: this.currentUser.authToken
type: "sendMessage" },
}); type: "sendMessage"
});
if (response.error) { if (response.error) {
console.error("Error sending message:", response.error); console.error("Error sending message:", response.error);
}
} else {
const form = new FormData();
form.append("payload", JSON.stringify({ type: "text", data: { content: content.trim() }, reply_to_id: replyToId ?? null }));
for (const f of files) form.append("files", f, f.name);
const res = await fetch(`${API_BASE_URL}/send_message`, {
method: "POST",
headers: getAuthHeaders(this.currentUser.authToken, false),
body: form
});
if (!res.ok) {
console.error("Error sending message with files", await res.text());
}
} }
} catch (error) { } catch (error) {
console.error("Error sending message:", error); console.error("Error sending message:", error);
+1
View File
@@ -20,6 +20,7 @@ import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar'; import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title'; import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch'; import 'mdui/components/switch';
import 'mdui/components/chip';
import { setColorScheme } from 'mdui/functions/setColorScheme.js'; import { setColorScheme } from 'mdui/functions/setColorScheme.js';