Вставка изображений из буфера обмена, исправление цикла переподключений, сохранение файловых сообщений
- Вставка изображений через Ctrl+V в MessageInput (onPaste на контейнере) - Inline auto-login с cleanup для StrictMode + readyState guard (fix цикла реконнекта) - Удаление data файлов при сохранении в localStorage (избегаем QuotaExceededError) - Показ имени файла вместо ошибки для сообщений из истории без data
This commit is contained in:
@@ -1 +1 @@
|
||||
VITE_WS_URL=ws://localhost:8080
|
||||
VITE_WS_URL=ws://localhost:8081
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.message-file-link.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ export default function Message({ message, isOwn, onPin }) {
|
||||
<a href={fileUrl} target="_blank" rel="noreferrer">
|
||||
<img className="message-image" src={fileUrl} alt={message.filename} />
|
||||
</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>)}
|
||||
{isFile && typeGroup === 'audio' && (fileUrl ? (
|
||||
<div className="message-audio">
|
||||
@@ -67,6 +69,8 @@ export default function Message({ message, isOwn, onPin }) {
|
||||
{message.filename}
|
||||
</a>
|
||||
</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>)}
|
||||
{isFile && typeGroup === 'video' && (fileUrl ? (
|
||||
<div className="message-video">
|
||||
@@ -82,12 +86,16 @@ export default function Message({ message, isOwn, onPin }) {
|
||||
{message.filename}
|
||||
</a>
|
||||
</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>)}
|
||||
{isFile && typeGroup === 'file' && (fileUrl ? (
|
||||
<a className="message-file-link" href={fileUrl} download={message.filename}>
|
||||
<span className="file-icon">{FILE_ICON}</span>
|
||||
{message.filename}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import VoiceRecorder from './VoiceRecorder';
|
||||
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 }) {
|
||||
const [text, setText] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
@@ -24,31 +42,34 @@ export default function MessageInput({ ws }) {
|
||||
setText('');
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
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');
|
||||
}
|
||||
sendFile(ws, 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 (
|
||||
<div className="message-input-container">
|
||||
<div className="message-input-container" onPaste={handlePaste}>
|
||||
<form className="message-input-form" onSubmit={sendText}>
|
||||
<input
|
||||
className="message-input-field"
|
||||
|
||||
@@ -18,7 +18,14 @@ function loadMessages(login) {
|
||||
|
||||
function saveMessages(login, messages) {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,8 @@ export default function useWebSocket() {
|
||||
}, [ws]);
|
||||
|
||||
useEffect(() => {
|
||||
let socket;
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(CREDENTIALS_KEY);
|
||||
if (raw) {
|
||||
@@ -118,15 +120,58 @@ export default function useWebSocket() {
|
||||
if (creds.login && creds.password) {
|
||||
savedCreds.current = creds;
|
||||
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 */ }
|
||||
}, [loginToServer]);
|
||||
|
||||
return () => {
|
||||
if (socket) {
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ws) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user