diff --git a/client/src/App.jsx b/client/src/App.jsx index 0be5fea..01f5f0e 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -4,11 +4,22 @@ import Chat from './components/Chat'; import useWebSocket from './hooks/useWebSocket'; export default function App() { - const { login, loggedIn, error, ws, loginToServer, logout } = useWebSocket(); + const { login, loggedIn, error, ws, loginToServer, logout, reconnect, connecting } = useWebSocket(); + + if (connecting) { + return ( +
+
+

Chat Login

+

Connecting...

+
+
+ ); + } if (!loggedIn) { return ; } - return ; + return ; } diff --git a/client/src/components/Chat.css b/client/src/components/Chat.css index 045bb82..66b6a59 100644 --- a/client/src/components/Chat.css +++ b/client/src/components/Chat.css @@ -50,3 +50,41 @@ text-align: center; font-size: 0.9rem; } + +.pinned-banner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: #1e3a5f; + border-bottom: 1px solid #2a4a7a; + font-size: 0.85rem; + color: #c0d8f0; +} + +.pinned-icon { + font-size: 0.9rem; + flex-shrink: 0; +} + +.pinned-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pinned-unpin-btn { + background: none; + border: none; + color: #8ab8ff; + cursor: pointer; + font-size: 0.9rem; + padding: 0.2rem; + flex-shrink: 0; + transition: color 0.2s; +} + +.pinned-unpin-btn:hover { + color: #ff6a6a; +} diff --git a/client/src/components/Chat.jsx b/client/src/components/Chat.jsx index 3ad90c8..ab7c23a 100644 --- a/client/src/components/Chat.jsx +++ b/client/src/components/Chat.jsx @@ -2,10 +2,11 @@ import React from 'react'; import MessageList from './MessageList'; import MessageInput from './MessageInput'; import useChatMessages from '../hooks/useChatMessages'; +import { linkifyText } from '../utils/linkify.jsx'; import './Chat.css'; -export default function Chat({ ws, login, onLogout }) { - const { messages, connected, error } = useChatMessages(ws, login); +export default function Chat({ ws, login, onLogout, onDisconnect }) { + const { messages, pinnedMessage, connected, error, sendPin, sendUnpin } = useChatMessages(ws, login, onDisconnect); return (
@@ -14,7 +15,17 @@ export default function Chat({ ws, login, onLogout }) {
{error &&
{error}
} - + {pinnedMessage && ( +
+ 📌 + + {pinnedMessage.from}:{' '} + {linkifyText(pinnedMessage.message.text || pinnedMessage.message.filename || '')} + + +
+ )} + ); diff --git a/client/src/components/Message.css b/client/src/components/Message.css index 166a10b..9c1e407 100644 --- a/client/src/components/Message.css +++ b/client/src/components/Message.css @@ -147,3 +147,23 @@ cursor: pointer; background: #000; } + +.message-pin-btn { + background: none; + border: none; + color: #6a6a8a; + cursor: pointer; + font-size: 0.75rem; + padding: 0 0.2rem; + opacity: 0; + transition: opacity 0.2s, color 0.2s; + line-height: 1; +} + +.message:hover .message-pin-btn { + opacity: 1; +} + +.message-pin-btn:hover { + color: #f0c040; +} diff --git a/client/src/components/Message.jsx b/client/src/components/Message.jsx index 46016ae..d89f7fd 100644 --- a/client/src/components/Message.jsx +++ b/client/src/components/Message.jsx @@ -9,7 +9,7 @@ function formatTime(ts) { const FILE_ICON = '\u{1F4CE}'; -export default function Message({ message, isOwn }) { +export default function Message({ message, isOwn, onPin }) { const [videoExpanded, setVideoExpanded] = useState(false); const mime = message.mime; const isSystem = message.type === 'system'; @@ -28,6 +28,17 @@ export default function Message({ message, isOwn }) { return null; }, [isFile, message.data, mime]); + const handlePin = () => { + const data = { type: message.type }; + if (message.type === 'text') { + data.text = message.text; + } else if (message.type === 'file') { + data.filename = message.filename; + data.mime = message.mime; + } + onPin(data); + }; + if (isSystem) { return
{message.text}
; } @@ -37,6 +48,9 @@ export default function Message({ message, isOwn }) {
{message.from} {formatTime(message.timestamp)} + {onPin && ( + + )}
{message.text &&

{linkifyText(message.text)}

} diff --git a/client/src/components/MessageList.jsx b/client/src/components/MessageList.jsx index 305d78d..5b099e6 100644 --- a/client/src/components/MessageList.jsx +++ b/client/src/components/MessageList.jsx @@ -1,7 +1,7 @@ import React, { useRef, useEffect } from 'react'; import Message from './Message'; -export default function MessageList({ messages, currentUser }) { +export default function MessageList({ messages, currentUser, onPin }) { const bottomRef = useRef(null); useEffect(() => { @@ -14,7 +14,7 @@ export default function MessageList({ messages, currentUser }) {
No messages yet. Start chatting!
)} {messages.map((msg) => ( - + ))}
diff --git a/client/src/hooks/useChatMessages.js b/client/src/hooks/useChatMessages.js index a242f18..bc2f7ea 100644 --- a/client/src/hooks/useChatMessages.js +++ b/client/src/hooks/useChatMessages.js @@ -1,14 +1,36 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { playBeep, drawFavicon } from '../utils/notify'; -export default function useChatMessages(ws, login) { - const [messages, setMessages] = useState([]); +const MAX_MESSAGES = 50; + +function storageKey(login) { + return `chat_messages_${login}`; +} + +function loadMessages(login) { + try { + const raw = localStorage.getItem(storageKey(login)); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function saveMessages(login, messages) { + try { + localStorage.setItem(storageKey(login), JSON.stringify(messages)); + } catch { /* storage full */ } +} + +export default function useChatMessages(ws, login, onDisconnect) { + const [messages, setMessages] = useState(() => loadMessages(login)); + const [pinnedMessage, setPinnedMessage] = useState(null); const [connected, setConnected] = useState(true); const [error, setError] = useState(''); const hasNotifRef = useRef(false); const addMessage = useCallback((msg) => { - setMessages(prev => [...prev, { ...msg, id: Date.now() + Math.random() }]); + setMessages(prev => [...prev, { ...msg, id: Date.now() + Math.random() }].slice(-MAX_MESSAGES)); if ( document.hidden && msg.from && @@ -23,6 +45,14 @@ export default function useChatMessages(ws, login) { } }, [login]); + const sendPin = useCallback((messageData) => { + ws.send(JSON.stringify({ type: 'pin', message: messageData })); + }, [ws]); + + const sendUnpin = useCallback(() => { + ws.send(JSON.stringify({ type: 'unpin' })); + }, [ws]); + useEffect(() => { drawFavicon(false); const onVisible = () => { @@ -35,9 +65,16 @@ export default function useChatMessages(ws, login) { return () => document.removeEventListener('visibilitychange', onVisible); }, []); + useEffect(() => { + saveMessages(login, messages); + }, [login, messages]); + useEffect(() => { if (!ws) return; + setConnected(true); + setError(''); + ws.onmessage = (event) => { let msg; try { @@ -48,6 +85,10 @@ export default function useChatMessages(ws, login) { if (msg.type === 'text' || msg.type === 'file' || msg.type === 'system') { addMessage(msg); + } else if (msg.type === 'pinned') { + setPinnedMessage(msg); + } else if (msg.type === 'unpinned') { + setPinnedMessage(null); } else if (msg.type === 'error') { setError(msg.message); } @@ -56,13 +97,14 @@ export default function useChatMessages(ws, login) { ws.onclose = () => { setConnected(false); setError('Connection lost'); + if (onDisconnect) onDisconnect(); }; ws.onerror = () => { setConnected(false); setError('Connection error'); }; - }, [ws, addMessage]); + }, [ws, addMessage, onDisconnect]); - return { messages, connected, error }; + return { messages, pinnedMessage, connected, error, sendPin, sendUnpin }; } diff --git a/client/src/hooks/useWebSocket.js b/client/src/hooks/useWebSocket.js index f1a619d..f38b638 100644 --- a/client/src/hooks/useWebSocket.js +++ b/client/src/hooks/useWebSocket.js @@ -1,16 +1,24 @@ import { useState, useRef, useCallback, useEffect } from 'react'; const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8080'; +const CREDENTIALS_KEY = 'chat_credentials'; export default function useWebSocket() { const [login, setLogin] = useState(''); const [loggedIn, setLoggedIn] = useState(false); const [error, setError] = useState(''); const [ws, setWs] = useState(null); + const [connecting, setConnecting] = useState(false); const loggedInRef = useRef(false); + const savedCreds = useRef(null); const loginToServer = useCallback((userLogin, password) => { setError(''); + savedCreds.current = { login: userLogin, password }; + try { + localStorage.setItem(CREDENTIALS_KEY, JSON.stringify({ login: userLogin, password })); + } catch { /* storage full */ } + const socket = new WebSocket(WS_URL); socket.onopen = () => { @@ -30,9 +38,11 @@ export default function useWebSocket() { setLogin(userLogin); setLoggedIn(true); loggedInRef.current = true; + setConnecting(false); setWs(socket); } else { setError(msg.reason || 'Authentication failed'); + setConnecting(false); socket.close(); } } @@ -41,26 +51,79 @@ export default function useWebSocket() { socket.onerror = () => { if (!loggedInRef.current) { setError('Cannot connect to server'); + setConnecting(false); } }; socket.onclose = () => { if (!loggedInRef.current) { setError('Connection closed'); + setConnecting(false); } }; }, []); + const reconnect = useCallback(() => { + if (!loggedInRef.current) return; + const creds = savedCreds.current; + if (!creds) return; + + const 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) { + setWs(socket); + } else { + socket.close(); + } + } + }; + + socket.onerror = () => {}; + + socket.onclose = () => {}; + }, []); + const logout = useCallback(() => { + loggedInRef.current = false; if (ws) { ws.close(); } setWs(null); setLogin(''); setLoggedIn(false); - loggedInRef.current = false; + savedCreds.current = null; + try { + localStorage.removeItem(CREDENTIALS_KEY); + } catch { /* ignore */ } }, [ws]); + useEffect(() => { + try { + const raw = localStorage.getItem(CREDENTIALS_KEY); + if (raw) { + const creds = JSON.parse(raw); + if (creds.login && creds.password) { + savedCreds.current = creds; + setConnecting(true); + loginToServer(creds.login, creds.password); + } + } + } catch { /* ignore */ } + }, [loginToServer]); + useEffect(() => { return () => { if (ws) { @@ -69,5 +132,5 @@ export default function useWebSocket() { }; }, [ws]); - return { login, loggedIn, error, ws, loginToServer, logout }; + return { login, loggedIn, error, ws, loginToServer, logout, reconnect, connecting }; } diff --git a/server/server.js b/server/server.js index 0f830fc..316541c 100644 --- a/server/server.js +++ b/server/server.js @@ -6,6 +6,8 @@ const { createJsonSender, createClientHandlers, validateFile, broadcast } = requ const server = new WebSocketServer({ port: config.port }); console.log(`WebSocket server running on ws://0.0.0.0:${config.port}`); +let pinnedMessage = null; + server.on('connection', (ws, req) => { const clientIp = req.socket.remoteAddress; console.log(`Connection from ${clientIp}`); @@ -17,11 +19,28 @@ server.on('connection', (ws, req) => { case 'auth': { const user = authenticate(parsed.login, parsed.password); if (user) { - return client.handleAuth(user.login); + const sysMsg = client.handleAuth(user.login); + if (pinnedMessage) { + send({ type: 'pinned', message: pinnedMessage.message, pinnedBy: pinnedMessage.from }); + } + return sysMsg; } client.handleAuthFail('Invalid login or password'); return null; } + case 'pin': { + pinnedMessage = { + type: 'pinned', from: client.getLogin(), timestamp: Date.now(), + message: parsed.message, + }; + console.log(`${client.getLogin()} pinned a message`); + return pinnedMessage; + } + case 'unpin': { + pinnedMessage = null; + console.log(`${client.getLogin()} unpinned the message`); + return { type: 'unpinned', from: client.getLogin(), timestamp: Date.now() }; + } case 'text': { if (!parsed.text || typeof parsed.text !== 'string') { send({ type: 'error', message: 'Invalid text message' });