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
+1 -1
View File
@@ -78,4 +78,4 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
},
data: payload
});
}
}
+9
View File
@@ -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
// -----------
+77 -49
View File
@@ -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;
@include hoverStateLayer($background: $color-dark-primary);
display: flex;
flex-direction: row;
align-items: center;
.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) => {
+20 -8
View File
@@ -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);
}
+4 -4
View File
@@ -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 => {
+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;
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);
+1
View File
@@ -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';