Добавлены закреплённые сообщения, сохранение истории и авто-переподключение
- Закрепление/открепление сообщений (сервер + клиент) - Сохранение истории сообщений в localStorage (до 50) - Сохранение логина/пароля в localStorage и авто-вход - Авто-переподключение при разрыве соединения WebSocket
This commit is contained in:
+13
-2
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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' });
|
||||||
|
|||||||
Reference in New Issue
Block a user