Overview
Chat events enable real-time messaging and typing indicators within Watch N Chill rooms. All authenticated users in a room can send messages and see when others are typing.
Client Events
Events emitted by the client to the server.
send-message
Sends a chat message to all users in the room.
The message content. Must be 1-1000 characters. Trimmed before sending.
TypeScript Interface:
interface SendMessageData {
roomId: string;
message: string;
}
Example:
import { useSocket } from '@/hooks/use-socket';
const { socket } = useSocket();
socket.emit('send-message', {
roomId: 'ABC123',
message: 'Hello everyone!'
});
Response Events:
new-message - Broadcast to all users in the room (including sender)
error - Sent if message sending fails (not authenticated, message too long, etc.)
Validation:
- User must be authenticated (joined the room)
- Message must be 1-1000 characters after trimming
- Message is automatically trimmed before storage
typing-start
Notifies other users that you started typing. Should be called when the user begins typing in the message input.
TypeScript Interface:
interface RoomActionData {
roomId: string;
}
Example:
const handleInputChange = (text: string) => {
setMessage(text);
// Notify others you're typing
if (text.length > 0 && !isTyping) {
socket?.emit('typing-start', { roomId: 'ABC123' });
setIsTyping(true);
}
};
Response Events:
user-typing - Broadcast to all other users (not sender) in the room
error - Sent if the request fails (not authenticated)
typing-stop
Notifies other users that you stopped typing. Should be called when:
- User clears the message input
- User sends the message
- User hasn’t typed for a few seconds (debounced)
TypeScript Interface:
interface RoomActionData {
roomId: string;
}
Example:
const handleSendMessage = () => {
if (message.trim()) {
// Send the message
socket?.emit('send-message', {
roomId: 'ABC123',
message: message
});
// Stop typing indicator
socket?.emit('typing-stop', { roomId: 'ABC123' });
setIsTyping(false);
setMessage('');
}
};
// Auto-stop typing after 3 seconds of inactivity
useEffect(() => {
if (isTyping) {
const timeout = setTimeout(() => {
socket?.emit('typing-stop', { roomId: 'ABC123' });
setIsTyping(false);
}, 3000);
return () => clearTimeout(timeout);
}
}, [message, isTyping]);
Response Events:
user-stopped-typing - Broadcast to all other users (not sender) in the room
error - Sent if the request fails (not authenticated)
Server Events
Events emitted by the server to the client.
new-message
Broadcast to all users (including sender) when a message is sent in the room.
Complete message object with all details.
TypeScript Interface:
interface NewMessageResponse {
message: ChatMessage;
}
interface ChatMessage {
id: string; // UUID
userId: string; // UUID of sender
userName: string; // Display name of sender
message: string; // Message content (trimmed)
timestamp: Date; // When message was sent
roomId: string; // Room ID (6 characters)
isRead: boolean; // Whether message has been read (default: false)
}
Example:
import { ChatMessage } from '@/types';
const [messages, setMessages] = useState<ChatMessage[]>([]);
socket.on('new-message', ({ message }) => {
console.log(`${message.userName}: ${message.message}`);
// Add message to chat
setMessages(prev => [...prev, message]);
// Show notification if not from current user
if (message.userId !== currentUser.id) {
toast.info(`${message.userName}: ${message.message}`);
}
});
user-typing
Broadcast to all users (except sender) when someone starts typing.
UUID of the user who started typing.
Display name of the user who started typing.
TypeScript Interface:
interface TypingEventResponse {
userId: string;
userName: string;
}
Example:
import { TypingUser } from '@/types';
const [typingUsers, setTypingUsers] = useState<TypingUser[]>([]);
socket.on('user-typing', ({ userId, userName }) => {
console.log(`${userName} is typing...`);
// Add user to typing list with timestamp
setTypingUsers(prev => {
// Remove existing entry if present
const filtered = prev.filter(u => u.userId !== userId);
// Add new entry
return [...filtered, {
userId,
userName,
timestamp: Date.now()
}];
});
});
user-stopped-typing
Broadcast to all users (except sender) when someone stops typing.
UUID of the user who stopped typing.
TypeScript Interface:
interface UserLeftResponse {
userId: string;
}
Example:
socket.on('user-stopped-typing', ({ userId }) => {
// Remove user from typing list
setTypingUsers(prev => prev.filter(u => u.userId !== userId));
});
Message Data Structure
All chat messages follow a consistent structure:
interface ChatMessage {
id: string; // UUID - unique message identifier
userId: string; // UUID - sender's user ID
userName: string; // Display name of sender (2-50 chars)
message: string; // Message content (1-1000 chars, trimmed)
timestamp: Date; // When message was sent
roomId: string; // Room ID (6 characters)
isRead: boolean; // Whether message has been read (default: false)
}
Example Message:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"userId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"userName": "John Doe",
"message": "Let's watch this together!",
"timestamp": "2024-03-15T10:30:00.000Z",
"roomId": "ABC123",
"isRead": false
}
Typing User Data Structure
Typing indicators track users currently typing:
interface TypingUser {
userId: string; // UUID - user ID
userName: string; // Display name (2-50 chars)
timestamp: number; // Milliseconds when typing started
}
Example Typing User:
{
"userId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"userName": "Jane Smith",
"timestamp": 1710497400000
}
Complete Example
Here’s a complete example of a chat component with typing indicators:
import { useEffect, useState, useRef } from 'react';
import { useSocket } from '@/hooks/use-socket';
import { ChatMessage, TypingUser, User } from '@/types';
import { toast } from 'sonner';
interface ChatProps {
roomId: string;
currentUser: User;
}
function Chat({ roomId, currentUser }: ChatProps) {
const { socket } = useSocket();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [typingUsers, setTypingUsers] = useState<TypingUser[]>([]);
const [message, setMessage] = useState('');
const [isTyping, setIsTyping] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Listen for chat events
useEffect(() => {
if (!socket) return;
// New message received
socket.on('new-message', ({ message }) => {
setMessages(prev => [...prev, message]);
// Notification for messages from others
if (message.userId !== currentUser.id) {
toast.info(`${message.userName}: ${message.message}`);
}
});
// User started typing
socket.on('user-typing', ({ userId, userName }) => {
setTypingUsers(prev => {
const filtered = prev.filter(u => u.userId !== userId);
return [...filtered, { userId, userName, timestamp: Date.now() }];
});
});
// User stopped typing
socket.on('user-stopped-typing', ({ userId }) => {
setTypingUsers(prev => prev.filter(u => u.userId !== userId));
});
return () => {
socket.off('new-message');
socket.off('user-typing');
socket.off('user-stopped-typing');
};
}, [socket, currentUser.id]);
// Auto-cleanup stale typing indicators (5 seconds)
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
setTypingUsers(prev =>
prev.filter(u => now - u.timestamp < 5000)
);
}, 1000);
return () => clearInterval(interval);
}, []);
// Handle message input change
const handleInputChange = (text: string) => {
setMessage(text);
// Start typing indicator
if (text.length > 0 && !isTyping) {
socket?.emit('typing-start', { roomId });
setIsTyping(true);
}
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Stop typing after 3 seconds of inactivity
if (text.length > 0) {
typingTimeoutRef.current = setTimeout(() => {
socket?.emit('typing-stop', { roomId });
setIsTyping(false);
}, 3000);
} else {
// Empty input - stop typing immediately
socket?.emit('typing-stop', { roomId });
setIsTyping(false);
}
};
// Send message
const handleSendMessage = () => {
const trimmed = message.trim();
if (!trimmed) return;
if (trimmed.length > 1000) {
toast.error('Message too long (max 1000 characters)');
return;
}
// Send message
socket?.emit('send-message', {
roomId,
message: trimmed
});
// Stop typing indicator
if (isTyping) {
socket?.emit('typing-stop', { roomId });
setIsTyping(false);
}
// Clear timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Clear input
setMessage('');
};
// Handle Enter key
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// Cleanup on unmount
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (isTyping) {
socket?.emit('typing-stop', { roomId });
}
};
}, [socket, roomId, isTyping]);
// Format typing indicator text
const getTypingText = () => {
const count = typingUsers.length;
if (count === 0) return null;
if (count === 1) return `${typingUsers[0].userName} is typing...`;
if (count === 2) return `${typingUsers[0].userName} and ${typingUsers[1].userName} are typing...`;
return `${count} people are typing...`;
};
return (
<div className="chat-container">
{/* Messages */}
<div className="messages">
{messages.map(msg => (
<div
key={msg.id}
className={msg.userId === currentUser.id ? 'message-own' : 'message-other'}
>
<div className="message-author">{msg.userName}</div>
<div className="message-content">{msg.message}</div>
<div className="message-time">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
))}
</div>
{/* Typing indicator */}
{typingUsers.length > 0 && (
<div className="typing-indicator">
{getTypingText()}
</div>
)}
{/* Input */}
<div className="message-input">
<input
type="text"
value={message}
onChange={(e) => handleInputChange(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
maxLength={1000}
/>
<button onClick={handleSendMessage}>Send</button>
</div>
</div>
);
}
export default Chat;
Best Practices
Typing Indicators
-
Debounce typing-stop: Don’t stop typing immediately after each keystroke. Wait 2-3 seconds of inactivity.
-
Cleanup stale indicators: Remove typing indicators that are older than 5 seconds to handle disconnections gracefully.
-
Stop on send: Always emit
typing-stop when sending a message.
-
Stop on unmount: Clear typing indicators when component unmounts.
Message Display
-
Auto-scroll: Scroll to bottom when new messages arrive.
-
Group messages: Consider grouping consecutive messages from the same user.
-
Timestamps: Display relative timestamps (“just now”, “2 minutes ago”) for better UX.
-
Read receipts: Use the
isRead field to implement read receipts if needed.
-
Limit history: Only load recent messages initially, implement pagination for history.
-
Virtualization: Use virtual scrolling for rooms with many messages.
-
Debounce input: Debounce the typing indicator to avoid excessive events.
Error Handling
socket.on('error', ({ error }) => {
if (error.includes('message')) {
toast.error('Failed to send message. Please try again.');
}
});