Вставка изображений из буфера обмена, исправление цикла переподключений, сохранение файловых сообщений

- Вставка изображений через Ctrl+V в MessageInput (onPaste на контейнере)
- Inline auto-login с cleanup для StrictMode + readyState guard (fix цикла реконнекта)
- Удаление data файлов при сохранении в localStorage (избегаем QuotaExceededError)
- Показ имени файла вместо ошибки для сообщений из истории без data
This commit is contained in:
Комаров Данил Анатольевич 6
2026-05-29 16:25:15 +03:00
parent 5a6f92ede6
commit 0028362e16
7 changed files with 112 additions and 26 deletions
+1 -1
View File
@@ -1 +1 @@
VITE_WS_URL=ws://localhost:8080 VITE_WS_URL=ws://localhost:8081
+5
View File
@@ -118,6 +118,11 @@
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
.message-file-link.disabled {
opacity: 0.5;
cursor: default;
}
.file-icon { .file-icon {
font-size: 1.1rem; font-size: 1.1rem;
} }
+8
View File
@@ -58,6 +58,8 @@ export default function Message({ message, isOwn, onPin }) {
<a href={fileUrl} target="_blank" rel="noreferrer"> <a href={fileUrl} target="_blank" rel="noreferrer">
<img className="message-image" src={fileUrl} alt={message.filename} /> <img className="message-image" src={fileUrl} alt={message.filename} />
</a> </a>
) : message.filename ? (
<span className="message-file-link disabled"><span className="file-icon">{FILE_ICON}</span>{message.filename}</span>
) : <div className="message-file-error">Failed to load image</div>)} ) : <div className="message-file-error">Failed to load image</div>)}
{isFile && typeGroup === 'audio' && (fileUrl ? ( {isFile && typeGroup === 'audio' && (fileUrl ? (
<div className="message-audio"> <div className="message-audio">
@@ -67,6 +69,8 @@ export default function Message({ message, isOwn, onPin }) {
{message.filename} {message.filename}
</a> </a>
</div> </div>
) : message.filename ? (
<span className="message-file-link disabled"><span className="file-icon">{FILE_ICON}</span>{message.filename}</span>
) : <div className="message-file-error">Failed to load audio</div>)} ) : <div className="message-file-error">Failed to load audio</div>)}
{isFile && typeGroup === 'video' && (fileUrl ? ( {isFile && typeGroup === 'video' && (fileUrl ? (
<div className="message-video"> <div className="message-video">
@@ -82,12 +86,16 @@ export default function Message({ message, isOwn, onPin }) {
{message.filename} {message.filename}
</a> </a>
</div> </div>
) : message.filename ? (
<span className="message-file-link disabled"><span className="file-icon">{FILE_ICON}</span>{message.filename}</span>
) : <div className="message-file-error">Failed to load video</div>)} ) : <div className="message-file-error">Failed to load video</div>)}
{isFile && typeGroup === 'file' && (fileUrl ? ( {isFile && typeGroup === 'file' && (fileUrl ? (
<a className="message-file-link" href={fileUrl} download={message.filename}> <a className="message-file-link" href={fileUrl} download={message.filename}>
<span className="file-icon">{FILE_ICON}</span> <span className="file-icon">{FILE_ICON}</span>
{message.filename} {message.filename}
</a> </a>
) : message.filename ? (
<span className="message-file-link disabled"><span className="file-icon">{FILE_ICON}</span>{message.filename}</span>
) : <div className="message-file-error">Failed to load file</div>)} ) : <div className="message-file-error">Failed to load file</div>)}
</div> </div>
</div> </div>
+41 -20
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useCallback } from 'react';
import VoiceRecorder from './VoiceRecorder'; import VoiceRecorder from './VoiceRecorder';
import './MessageInput.css'; import './MessageInput.css';
@@ -13,6 +13,24 @@ function readFileAsBase64(file) {
}); });
} }
function sendFile(ws, file) {
if (file.size > MAX_FILE_SIZE) {
alert('File too large. Maximum is 10 MB.');
return;
}
readFileAsBase64(file).then((data) => {
ws.send(JSON.stringify({
type: 'file',
filename: file.name,
mime: file.type || 'application/octet-stream',
data,
}));
}).catch(() => {
alert('Failed to read file');
});
}
export default function MessageInput({ ws }) { export default function MessageInput({ ws }) {
const [text, setText] = useState(''); const [text, setText] = useState('');
const fileRef = useRef(null); const fileRef = useRef(null);
@@ -24,31 +42,34 @@ export default function MessageInput({ ws }) {
setText(''); setText('');
}; };
const handleFileSelect = async (e) => { const handleFileSelect = (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
e.target.value = ''; e.target.value = '';
if (!file) return; if (!file) return;
sendFile(ws, file);
if (file.size > MAX_FILE_SIZE) {
alert('File too large. Maximum is 10 MB.');
return;
}
try {
const data = await readFileAsBase64(file);
ws.send(JSON.stringify({
type: 'file',
filename: file.name,
mime: file.type || 'application/octet-stream',
data,
}));
} catch {
alert('Failed to read file');
}
}; };
const handlePaste = useCallback((e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file' && item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (!file) continue;
const ext = file.type.split('/')[1] || 'png';
const blob = file.slice(0, file.size, file.type);
const pastedFile = new File([blob], `pasted-image-${Date.now()}.${ext}`, { type: file.type });
sendFile(ws, pastedFile);
break;
}
}
}, [ws]);
return ( return (
<div className="message-input-container"> <div className="message-input-container" onPaste={handlePaste}>
<form className="message-input-form" onSubmit={sendText}> <form className="message-input-form" onSubmit={sendText}>
<input <input
className="message-input-field" className="message-input-field"
+8 -1
View File
@@ -18,7 +18,14 @@ function loadMessages(login) {
function saveMessages(login, messages) { function saveMessages(login, messages) {
try { try {
localStorage.setItem(storageKey(login), JSON.stringify(messages)); const slim = messages.map(m => {
if (m.type === 'file') {
const { data, ...rest } = m;
return rest;
}
return m;
});
localStorage.setItem(storageKey(login), JSON.stringify(slim));
} catch { /* storage full */ } } catch { /* storage full */ }
} }
+48 -3
View File
@@ -111,6 +111,8 @@ export default function useWebSocket() {
}, [ws]); }, [ws]);
useEffect(() => { useEffect(() => {
let socket;
try { try {
const raw = localStorage.getItem(CREDENTIALS_KEY); const raw = localStorage.getItem(CREDENTIALS_KEY);
if (raw) { if (raw) {
@@ -118,15 +120,58 @@ export default function useWebSocket() {
if (creds.login && creds.password) { if (creds.login && creds.password) {
savedCreds.current = creds; savedCreds.current = creds;
setConnecting(true); setConnecting(true);
loginToServer(creds.login, creds.password);
socket = new WebSocket(WS_URL);
socket.onopen = () => {
socket.send(JSON.stringify({ type: 'auth', login: creds.login, password: creds.password }));
};
socket.onmessage = (event) => {
let msg;
try {
msg = JSON.parse(event.data);
} catch { return; }
if (msg.type === 'auth_result') {
if (msg.success) {
setLogin(creds.login);
setLoggedIn(true);
loggedInRef.current = true;
setConnecting(false);
setWs(socket);
} else {
setConnecting(false);
socket.close();
}
}
};
socket.onerror = () => {
if (!loggedInRef.current) {
setConnecting(false);
}
};
socket.onclose = () => {
if (!loggedInRef.current) {
setConnecting(false);
}
};
} }
} }
} catch { /* ignore */ } } catch { /* ignore */ }
}, [loginToServer]);
return () => {
if (socket) {
socket.close();
}
};
}, []);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (ws) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(); ws.close();
} }
}; };
+1 -1
View File
@@ -1,5 +1,5 @@
const config = { const config = {
port: parseInt(process.env.PORT, 10) || 8080, port: parseInt(process.env.PORT, 10) || 8081,
maxFileSize: parseInt(process.env.MAX_FILE_SIZE, 10) || 10 * 1024 * 1024, maxFileSize: parseInt(process.env.MAX_FILE_SIZE, 10) || 10 * 1024 * 1024,
usersFile: process.env.USERS_FILE || 'users.json', usersFile: process.env.USERS_FILE || 'users.json',
}; };