Добавлены закреплённые сообщения, сохранение истории и авто-переподключение

- Закрепление/открепление сообщений (сервер + клиент)
- Сохранение истории сообщений в localStorage (до 50)
- Сохранение логина/пароля в localStorage и авто-вход
- Авто-переподключение при разрыве соединения WebSocket
This commit is contained in:
Комаров Данил Анатольевич 6
2026-05-29 15:57:18 +03:00
parent 63bf73d2c4
commit 5a6f92ede6
9 changed files with 234 additions and 16 deletions
+13 -2
View File
@@ -4,11 +4,22 @@ import Chat from './components/Chat';
import useWebSocket from './hooks/useWebSocket'; import useWebSocket from './hooks/useWebSocket';
export default function App() { 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 (
<div className="login-container">
<div className="login-form">
<h1>Chat Login</h1>
<p style={{ textAlign: 'center', color: '#888' }}>Connecting...</p>
</div>
</div>
);
}
if (!loggedIn) { if (!loggedIn) {
return <Login onLogin={loginToServer} error={error} />; return <Login onLogin={loginToServer} error={error} />;
} }
return <Chat ws={ws} login={login} onLogout={logout} />; return <Chat ws={ws} login={login} onLogout={logout} onDisconnect={reconnect} />;
} }
+38
View File
@@ -50,3 +50,41 @@
text-align: center; text-align: center;
font-size: 0.9rem; 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;
}
+14 -3
View File
@@ -2,10 +2,11 @@ import React from 'react';
import MessageList from './MessageList'; import MessageList from './MessageList';
import MessageInput from './MessageInput'; import MessageInput from './MessageInput';
import useChatMessages from '../hooks/useChatMessages'; import useChatMessages from '../hooks/useChatMessages';
import { linkifyText } from '../utils/linkify.jsx';
import './Chat.css'; import './Chat.css';
export default function Chat({ ws, login, onLogout }) { export default function Chat({ ws, login, onLogout, onDisconnect }) {
const { messages, connected, error } = useChatMessages(ws, login); const { messages, pinnedMessage, connected, error, sendPin, sendUnpin } = useChatMessages(ws, login, onDisconnect);
return ( return (
<div className="chat-container"> <div className="chat-container">
@@ -14,7 +15,17 @@ export default function Chat({ ws, login, onLogout }) {
<button className="logout-btn" onClick={onLogout}>Logout</button> <button className="logout-btn" onClick={onLogout}>Logout</button>
</div> </div>
{error && <div className={connected ? 'chat-notification' : 'chat-error'}>{error}</div>} {error && <div className={connected ? 'chat-notification' : 'chat-error'}>{error}</div>}
<MessageList messages={messages} currentUser={login} /> {pinnedMessage && (
<div className="pinned-banner">
<span className="pinned-icon">📌</span>
<span className="pinned-text">
<strong>{pinnedMessage.from}</strong>:{' '}
{linkifyText(pinnedMessage.message.text || pinnedMessage.message.filename || '')}
</span>
<button className="pinned-unpin-btn" onClick={sendUnpin} title="Unpin message"></button>
</div>
)}
<MessageList messages={messages} currentUser={login} onPin={sendPin} />
<MessageInput ws={ws} /> <MessageInput ws={ws} />
</div> </div>
); );
+20
View File
@@ -147,3 +147,23 @@
cursor: pointer; cursor: pointer;
background: #000; 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;
}
+15 -1
View File
@@ -9,7 +9,7 @@ function formatTime(ts) {
const FILE_ICON = '\u{1F4CE}'; const FILE_ICON = '\u{1F4CE}';
export default function Message({ message, isOwn }) { export default function Message({ message, isOwn, onPin }) {
const [videoExpanded, setVideoExpanded] = useState(false); const [videoExpanded, setVideoExpanded] = useState(false);
const mime = message.mime; const mime = message.mime;
const isSystem = message.type === 'system'; const isSystem = message.type === 'system';
@@ -28,6 +28,17 @@ export default function Message({ message, isOwn }) {
return null; return null;
}, [isFile, message.data, mime]); }, [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) { if (isSystem) {
return <div className="message-system"><span>{message.text}</span></div>; return <div className="message-system"><span>{message.text}</span></div>;
} }
@@ -37,6 +48,9 @@ export default function Message({ message, isOwn }) {
<div className="message-header"> <div className="message-header">
<span className="message-author">{message.from}</span> <span className="message-author">{message.from}</span>
<span className="message-time">{formatTime(message.timestamp)}</span> <span className="message-time">{formatTime(message.timestamp)}</span>
{onPin && (
<button className="message-pin-btn" onClick={handlePin} title="Pin message">📌</button>
)}
</div> </div>
<div className="message-body"> <div className="message-body">
{message.text && <p>{linkifyText(message.text)}</p>} {message.text && <p>{linkifyText(message.text)}</p>}
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import Message from './Message'; import Message from './Message';
export default function MessageList({ messages, currentUser }) { export default function MessageList({ messages, currentUser, onPin }) {
const bottomRef = useRef(null); const bottomRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -14,7 +14,7 @@ export default function MessageList({ messages, currentUser }) {
<div className="message-list-empty">No messages yet. Start chatting!</div> <div className="message-list-empty">No messages yet. Start chatting!</div>
)} )}
{messages.map((msg) => ( {messages.map((msg) => (
<Message key={msg.id} message={msg} isOwn={msg.from === currentUser} /> <Message key={msg.id} message={msg} isOwn={msg.from === currentUser} onPin={onPin} />
))} ))}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
+47 -5
View File
@@ -1,14 +1,36 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { playBeep, drawFavicon } from '../utils/notify'; import { playBeep, drawFavicon } from '../utils/notify';
export default function useChatMessages(ws, login) { const MAX_MESSAGES = 50;
const [messages, setMessages] = useState([]);
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 [connected, setConnected] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const hasNotifRef = useRef(false); const hasNotifRef = useRef(false);
const addMessage = useCallback((msg) => { 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 ( if (
document.hidden && document.hidden &&
msg.from && msg.from &&
@@ -23,6 +45,14 @@ export default function useChatMessages(ws, login) {
} }
}, [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(() => { useEffect(() => {
drawFavicon(false); drawFavicon(false);
const onVisible = () => { const onVisible = () => {
@@ -35,9 +65,16 @@ export default function useChatMessages(ws, login) {
return () => document.removeEventListener('visibilitychange', onVisible); return () => document.removeEventListener('visibilitychange', onVisible);
}, []); }, []);
useEffect(() => {
saveMessages(login, messages);
}, [login, messages]);
useEffect(() => { useEffect(() => {
if (!ws) return; if (!ws) return;
setConnected(true);
setError('');
ws.onmessage = (event) => { ws.onmessage = (event) => {
let msg; let msg;
try { try {
@@ -48,6 +85,10 @@ export default function useChatMessages(ws, login) {
if (msg.type === 'text' || msg.type === 'file' || msg.type === 'system') { if (msg.type === 'text' || msg.type === 'file' || msg.type === 'system') {
addMessage(msg); addMessage(msg);
} else if (msg.type === 'pinned') {
setPinnedMessage(msg);
} else if (msg.type === 'unpinned') {
setPinnedMessage(null);
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
setError(msg.message); setError(msg.message);
} }
@@ -56,13 +97,14 @@ export default function useChatMessages(ws, login) {
ws.onclose = () => { ws.onclose = () => {
setConnected(false); setConnected(false);
setError('Connection lost'); setError('Connection lost');
if (onDisconnect) onDisconnect();
}; };
ws.onerror = () => { ws.onerror = () => {
setConnected(false); setConnected(false);
setError('Connection error'); setError('Connection error');
}; };
}, [ws, addMessage]); }, [ws, addMessage, onDisconnect]);
return { messages, connected, error }; return { messages, pinnedMessage, connected, error, sendPin, sendUnpin };
} }
+65 -2
View File
@@ -1,16 +1,24 @@
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8080'; const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8080';
const CREDENTIALS_KEY = 'chat_credentials';
export default function useWebSocket() { export default function useWebSocket() {
const [login, setLogin] = useState(''); const [login, setLogin] = useState('');
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [ws, setWs] = useState(null); const [ws, setWs] = useState(null);
const [connecting, setConnecting] = useState(false);
const loggedInRef = useRef(false); const loggedInRef = useRef(false);
const savedCreds = useRef(null);
const loginToServer = useCallback((userLogin, password) => { const loginToServer = useCallback((userLogin, password) => {
setError(''); 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); const socket = new WebSocket(WS_URL);
socket.onopen = () => { socket.onopen = () => {
@@ -30,9 +38,11 @@ export default function useWebSocket() {
setLogin(userLogin); setLogin(userLogin);
setLoggedIn(true); setLoggedIn(true);
loggedInRef.current = true; loggedInRef.current = true;
setConnecting(false);
setWs(socket); setWs(socket);
} else { } else {
setError(msg.reason || 'Authentication failed'); setError(msg.reason || 'Authentication failed');
setConnecting(false);
socket.close(); socket.close();
} }
} }
@@ -41,26 +51,79 @@ export default function useWebSocket() {
socket.onerror = () => { socket.onerror = () => {
if (!loggedInRef.current) { if (!loggedInRef.current) {
setError('Cannot connect to server'); setError('Cannot connect to server');
setConnecting(false);
} }
}; };
socket.onclose = () => { socket.onclose = () => {
if (!loggedInRef.current) { if (!loggedInRef.current) {
setError('Connection closed'); 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(() => { const logout = useCallback(() => {
loggedInRef.current = false;
if (ws) { if (ws) {
ws.close(); ws.close();
} }
setWs(null); setWs(null);
setLogin(''); setLogin('');
setLoggedIn(false); setLoggedIn(false);
loggedInRef.current = false; savedCreds.current = null;
try {
localStorage.removeItem(CREDENTIALS_KEY);
} catch { /* ignore */ }
}, [ws]); }, [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(() => { useEffect(() => {
return () => { return () => {
if (ws) { if (ws) {
@@ -69,5 +132,5 @@ export default function useWebSocket() {
}; };
}, [ws]); }, [ws]);
return { login, loggedIn, error, ws, loginToServer, logout }; return { login, loggedIn, error, ws, loginToServer, logout, reconnect, connecting };
} }
+20 -1
View File
@@ -6,6 +6,8 @@ const { createJsonSender, createClientHandlers, validateFile, broadcast } = requ
const server = new WebSocketServer({ port: config.port }); const server = new WebSocketServer({ port: config.port });
console.log(`WebSocket server running on ws://0.0.0.0:${config.port}`); console.log(`WebSocket server running on ws://0.0.0.0:${config.port}`);
let pinnedMessage = null;
server.on('connection', (ws, req) => { server.on('connection', (ws, req) => {
const clientIp = req.socket.remoteAddress; const clientIp = req.socket.remoteAddress;
console.log(`Connection from ${clientIp}`); console.log(`Connection from ${clientIp}`);
@@ -17,11 +19,28 @@ server.on('connection', (ws, req) => {
case 'auth': { case 'auth': {
const user = authenticate(parsed.login, parsed.password); const user = authenticate(parsed.login, parsed.password);
if (user) { 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'); client.handleAuthFail('Invalid login or password');
return null; 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': { case 'text': {
if (!parsed.text || typeof parsed.text !== 'string') { if (!parsed.text || typeof parsed.text !== 'string') {
send({ type: 'error', message: 'Invalid text message' }); send({ type: 'error', message: 'Invalid text message' });