mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement file sending
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mdui": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mdui/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,21 @@ class Message(Base):
|
||||
|
||||
author = relationship("User", back_populates="messages")
|
||||
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):
|
||||
|
||||
+219
-7
@@ -1,16 +1,35 @@
|
||||
from datetime import datetime
|
||||
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 sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
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 PIL import Image
|
||||
import io
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
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:
|
||||
return {
|
||||
"id": msg.id,
|
||||
@@ -20,16 +39,40 @@ def convert_message(msg: Message) -> dict:
|
||||
"is_edited": msg.is_edited,
|
||||
"username": msg.author.username,
|
||||
"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")
|
||||
async def send_message(
|
||||
request: SendMessageRequest,
|
||||
request: SendMessageRequest | None = None,
|
||||
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:
|
||||
# Check if the message being replied to exists
|
||||
original_message = db.query(Message).filter(Message.id == request.reply_to_id).first()
|
||||
@@ -59,12 +102,80 @@ async def send_message(
|
||||
db.commit()
|
||||
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
|
||||
try:
|
||||
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
|
||||
except Exception as 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)}
|
||||
|
||||
|
||||
@@ -83,11 +194,30 @@ async def get_messages(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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"]
|
||||
for key in required:
|
||||
if key not in payload:
|
||||
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
||||
|
||||
env = DMEnvelope(
|
||||
sender_id=current_user.id,
|
||||
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.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
|
||||
try:
|
||||
await push_service.send_dm_notification(db, env, current_user)
|
||||
except Exception as 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}
|
||||
|
||||
|
||||
@@ -284,7 +475,7 @@ class MessaggingSocketManager:
|
||||
|
||||
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({
|
||||
"type": "newMessage",
|
||||
"data": response["message"]
|
||||
@@ -415,3 +606,24 @@ async def chat_websocket(
|
||||
db: Session = Depends(get_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))
|
||||
Vendored
+9
@@ -60,6 +60,7 @@ export interface Message {
|
||||
timestamp: string;
|
||||
profile_picture?: string;
|
||||
reply_to?: Message;
|
||||
files?: Attachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,6 +231,14 @@ export interface WebSocketCredentials {
|
||||
credentials: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
path: string;
|
||||
encrypted: boolean;
|
||||
filename?: string;
|
||||
content_type?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// -----------
|
||||
// React types
|
||||
// -----------
|
||||
|
||||
@@ -172,28 +172,7 @@
|
||||
align-items: flex-end;
|
||||
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 {
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
@@ -203,9 +182,43 @@
|
||||
max-width: 100%;
|
||||
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 {
|
||||
word-wrap: break-word;
|
||||
margin-bottom: 10px;
|
||||
margin: 10px 10px 0 10px;
|
||||
white-space: pre-wrap;
|
||||
|
||||
> p:first-child {
|
||||
@@ -222,12 +235,22 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message-attachments {
|
||||
padding: 5px 0 0 0;
|
||||
|
||||
.attachment {
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
margin-top: 0.3rem;
|
||||
text-align: right;
|
||||
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 {
|
||||
@@ -309,6 +320,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.attachments-preview {
|
||||
align-items: center;
|
||||
|
||||
.attachments-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -331,22 +352,29 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
margin: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: $color-dark-primary;
|
||||
color: $color-dark-on-primary;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.25s ease;
|
||||
.buttons {
|
||||
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 { MaterialDialog } from "../core/Dialog";
|
||||
import { RichTextArea } from "../core/RichTextArea";
|
||||
import type { Message } from "../../../core/types";
|
||||
import Quote from "../core/Quote";
|
||||
import AnimatedHeight from "../core/animations/AnimatedHeight";
|
||||
|
||||
interface ChatInputWrapperProps {
|
||||
onSendMessage: (message: string) => void;
|
||||
onSendMessage: (message: string, files: File[]) => void;
|
||||
onSaveEdit?: (content: string) => void;
|
||||
replyTo?: Message | null;
|
||||
replyToVisible: boolean;
|
||||
@@ -19,6 +20,9 @@ interface ChatInputWrapperProps {
|
||||
|
||||
export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVisible, onClearReply, onCloseReply, editingMessage, editVisible = false, onClearEdit, onCloseEdit }: ChatInputWrapperProps) {
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -29,21 +33,47 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi
|
||||
}
|
||||
}, [editingMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
setAttachmentsVisible(true);
|
||||
}
|
||||
}, [selectedFiles])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent | Event) => {
|
||||
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) {
|
||||
onSaveEdit(message);
|
||||
setMessage("");
|
||||
if (onClearEdit) onClearEdit();
|
||||
} else {
|
||||
onSendMessage(message);
|
||||
onSendMessage(message, selectedFiles);
|
||||
setMessage("");
|
||||
setAttachmentsVisible(false);
|
||||
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 (
|
||||
<div className="chat-input-wrapper">
|
||||
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
|
||||
@@ -71,6 +101,28 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<RichTextArea
|
||||
className="message-input"
|
||||
@@ -81,11 +133,19 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi
|
||||
rows={1}
|
||||
onTextChange={(value) => setMessage(value)}
|
||||
onEnter={handleSubmit} />
|
||||
<button type="submit" className="send-btn">
|
||||
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
|
||||
</button>
|
||||
<div className="buttons">
|
||||
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
|
||||
<button type="submit" className="send-btn">
|
||||
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,25 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
|
||||
<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">
|
||||
{formatTime(message.timestamp)}
|
||||
{message.is_edited ? " (edited)" : undefined}
|
||||
|
||||
@@ -175,8 +175,8 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
|
||||
)}
|
||||
|
||||
<ChatInputWrapper
|
||||
onSendMessage={(text) => {
|
||||
panel.handleSendMessage(text, replyTo?.id);
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
onSaveEdit={(content) => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from
|
||||
import {
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket
|
||||
sendDMViaWebSocket,
|
||||
sendDmWithFiles
|
||||
} from "../../api/dmApi";
|
||||
import type { Message, WebSocketMessage } from "../../core/types";
|
||||
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;
|
||||
|
||||
try {
|
||||
await sendDMViaWebSocket(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
content,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
if (files.length === 0) {
|
||||
await sendDMViaWebSocket(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
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) {
|
||||
console.error("Failed to send DM:", error);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface MessagePanelState {
|
||||
}
|
||||
|
||||
export interface MessagePanelCallbacks {
|
||||
onSendMessage: (content: string) => void;
|
||||
onSendMessage: (content: string, files: File[]) => void;
|
||||
onEditMessage: (messageId: number, content: string) => void;
|
||||
onDeleteMessage: (messageId: number) => void;
|
||||
onReplyToMessage: (messageId: number, content: string) => void;
|
||||
@@ -48,7 +48,7 @@ export abstract class MessagePanel {
|
||||
abstract activate(): Promise<void>;
|
||||
abstract deactivate(): 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;
|
||||
|
||||
// Optional WebSocket message handler (can be overridden by subclasses)
|
||||
@@ -115,8 +115,8 @@ export abstract class MessagePanel {
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
handleSendMessage = (content: string, replyToId?: number): void => {
|
||||
this.sendMessage(content, replyToId);
|
||||
handleSendMessage = (content: string, replyToId?: number, files: File[] = []): void => {
|
||||
this.sendMessage(content, replyToId, files);
|
||||
};
|
||||
|
||||
handleEditMessage = (messageId: number, content: string): void => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
try {
|
||||
const response = await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
console.error("Error sending message:", response.error);
|
||||
if (files.length === 0) {
|
||||
const response = await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
});
|
||||
if (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) {
|
||||
console.error("Error sending message:", error);
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'mdui/components/button-icon';
|
||||
import 'mdui/components/top-app-bar';
|
||||
import 'mdui/components/top-app-bar-title';
|
||||
import 'mdui/components/switch';
|
||||
import 'mdui/components/chip';
|
||||
|
||||
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user