diff --git a/client/.env.development b/client/.env.development index 2ec742f..0b11248 100644 --- a/client/.env.development +++ b/client/.env.development @@ -1 +1 @@ -VITE_WS_URL=ws://localhost:8080 +VITE_WS_URL=ws://localhost:8081 diff --git a/client/src/components/Message.css b/client/src/components/Message.css index 9c1e407..1b2e927 100644 --- a/client/src/components/Message.css +++ b/client/src/components/Message.css @@ -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; } diff --git a/client/src/components/Message.jsx b/client/src/components/Message.jsx index d89f7fd..3f3bbf4 100644 --- a/client/src/components/Message.jsx +++ b/client/src/components/Message.jsx @@ -58,6 +58,8 @@ export default function Message({ message, isOwn, onPin }) { {message.filename} + ) : message.filename ? ( + {FILE_ICON}{message.filename} ) :
Failed to load image
)} {isFile && typeGroup === 'audio' && (fileUrl ? (
@@ -67,6 +69,8 @@ export default function Message({ message, isOwn, onPin }) { {message.filename}
+ ) : message.filename ? ( + {FILE_ICON}{message.filename} ) :
Failed to load audio
)} {isFile && typeGroup === 'video' && (fileUrl ? (
@@ -82,12 +86,16 @@ export default function Message({ message, isOwn, onPin }) { {message.filename}
+ ) : message.filename ? ( + {FILE_ICON}{message.filename} ) :
Failed to load video
)} {isFile && typeGroup === 'file' && (fileUrl ? ( {FILE_ICON} {message.filename} + ) : message.filename ? ( + {FILE_ICON}{message.filename} ) :
Failed to load file
)} diff --git a/client/src/components/MessageInput.jsx b/client/src/components/MessageInput.jsx index f9b70cc..97cd76e 100644 --- a/client/src/components/MessageInput.jsx +++ b/client/src/components/MessageInput.jsx @@ -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 ( -
+
{ + if (m.type === 'file') { + const { data, ...rest } = m; + return rest; + } + return m; + }); + localStorage.setItem(storageKey(login), JSON.stringify(slim)); } catch { /* storage full */ } } diff --git a/client/src/hooks/useWebSocket.js b/client/src/hooks/useWebSocket.js index f38b638..eee6806 100644 --- a/client/src/hooks/useWebSocket.js +++ b/client/src/hooks/useWebSocket.js @@ -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(); } }; diff --git a/server/config.js b/server/config.js index 563e93e..3fbf35d 100644 --- a/server/config.js +++ b/server/config.js @@ -1,5 +1,5 @@ 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, usersFile: process.env.USERS_FILE || 'users.json', };