mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
Implement file sending
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user