Вставка изображений из буфера обмена, исправление цикла переподключений, сохранение файловых сообщений
- Вставка изображений через 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);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user