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' });