Zaptam WebSocket System
Deep dive into real-time messaging, presence, typing indicators, and screenshot detection.
Architecture
┌──────────────────────────────────────────────────────────────┐
│ Socket.IO Server │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ Auth Layer │ │ Message Socket │ │Presence Socket │ │
│ │ │ │ │ │ │ │
│ │ - JWT verify │ │ - Send/receive │ │ - Online status│ │
│ │ - User lookup │ │ - Read receipts│ │ - Screenshot │ │
│ │ - Room join │ │ - Typing │ │ - Subscribe │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
│ │ │ │ │
│ └──────────────────┼──────────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Online Users Map │ │
│ │ Map<userId, socketId> │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘Connection Setup
File: src/sockets/index.ts
Server Configuration
typescript
import { Server as SocketServer, Socket } from 'socket.io';
import { verifyAccessToken } from '../utils/jwt.utils.js';
import { updateLastActive } from '../services/user.service.js';
import { setupMessageHandlers } from './message.socket.js';
import { setupPresenceHandlers } from './presence.socket.js';
interface AuthenticatedSocket extends Socket {
userId?: string;
}
// Track online users: userId -> socketId
const onlineUsers = new Map<string, string>();Authentication Middleware
typescript
io.use((socket: AuthenticatedSocket, next) => {
const token = socket.handshake.auth.token as string | undefined;
if (!token) {
return next(new Error('Authentication required'));
}
const payload = verifyAccessToken(token);
if (!payload) {
return next(new Error('Invalid or expired token'));
}
// Attach userId to socket for later use
socket.userId = payload.userId;
next();
});Client Connection:
typescript
const socket = io('wss://api.zaptam.com', {
auth: {
token: accessToken // JWT access token
}
});Connection Handler
typescript
io.on('connection', (socket: AuthenticatedSocket) => {
const userId = socket.userId!;
console.log(`User connected: ${userId}`);
// Track online status
onlineUsers.set(userId, socket.id);
// Join personal room for notifications
socket.join(`user:${userId}`);
// Update last active timestamp
updateLastActive(userId).catch(console.error);
// Broadcast online status to others
socket.broadcast.emit('user:online', { userId });
// Setup handlers
setupMessageHandlers(io, socket, userId);
setupPresenceHandlers(io, socket, userId, onlineUsers);
// Handle disconnect
socket.on('disconnect', () => {
console.log(`User disconnected: ${userId}`);
onlineUsers.delete(userId);
socket.broadcast.emit('user:offline', { userId });
});
});Room Structure
| Room | Format | Purpose |
|---|---|---|
| User Room | user:{userId} | Personal notifications |
| Conversation | conversation:{convId} | Chat messages |
| Presence | presence:{userId} | Online status updates |
Message Socket
File: src/sockets/message.socket.ts
Events Overview
| Event | Direction | Purpose |
|---|---|---|
join:conversation | Client → Server | Join conversation room |
leave:conversation | Client → Server | Leave conversation room |
message:send | Client → Server | Send new message |
message:new | Server → Client | New message received |
message:read | Client → Server | Mark messages as read |
typing:start | Client → Server | Start typing indicator |
typing:stop | Client → Server | Stop typing indicator |
notification:message | Server → Client | Message notification |
Join Conversation
typescript
socket.on('join:conversation', async (conversationId: string) => {
try {
// Verify user is part of this conversation
const conversation = await prisma.conversation.findUnique({
where: { id: conversationId },
});
if (
conversation &&
(conversation.participant1Id === userId ||
conversation.participant2Id === userId)
) {
socket.join(`conversation:${conversationId}`);
}
} catch (error) {
console.error('Error joining conversation:', error);
}
});Client Usage:
typescript
// When opening a chat
socket.emit('join:conversation', conversationId);
// When closing/leaving chat
socket.emit('leave:conversation', conversationId);Send Message
typescript
interface SendMessagePayload {
conversationId?: string;
recipientId?: string;
content?: string;
mediaUrl?: string;
expiresIn?: number; // Hours until message expires
}
socket.on('message:send', async (payload: SendMessagePayload, callback) => {
try {
let conversationId = payload.conversationId;
// If no conversationId, create or get conversation with recipient
if (!conversationId && payload.recipientId) {
conversationId = await getOrCreateConversation(userId, payload.recipientId);
}
if (!conversationId) {
callback?.({ error: 'Conversation ID or recipient required' });
return;
}
// Send message via service
const message = await sendMessage(
userId,
conversationId,
payload.content,
payload.mediaUrl,
payload.expiresIn
);
// Emit to conversation room (both participants)
io.to(`conversation:${conversationId}`).emit('message:new', {
...message,
conversationId,
});
// Also emit notification to recipient's user room
io.to(`user:${message.recipientId}`).emit('notification:message', {
conversationId,
senderId: userId,
preview: payload.content?.substring(0, 50) || 'New message',
});
callback?.({ success: true, message });
} catch (error) {
console.error('Error sending message:', error);
callback?.({ error: (error as Error).message });
}
});Client Usage:
typescript
// Send with callback
socket.emit('message:send', {
conversationId: 'conv-uuid',
content: 'Hello!',
}, (response) => {
if (response.success) {
console.log('Message sent:', response.message);
} else {
console.error('Error:', response.error);
}
});
// Listen for new messages
socket.on('message:new', (message) => {
// Add to UI
addMessageToChat(message);
});
// Listen for notifications (when not in conversation)
socket.on('notification:message', (notification) => {
showNotification(notification.preview);
});Read Receipts
typescript
socket.on('message:read', async (conversationId: string) => {
try {
// Mark all unread messages as read
await prisma.message.updateMany({
where: {
conversationId,
recipientId: userId,
isRead: false,
},
data: { isRead: true },
});
// Notify sender that messages were read
const conversation = await prisma.conversation.findUnique({
where: { id: conversationId },
});
if (conversation) {
const otherId = conversation.participant1Id === userId
? conversation.participant2Id
: conversation.participant1Id;
io.to(`user:${otherId}`).emit('message:read', {
conversationId,
readBy: userId,
});
}
} catch (error) {
console.error('Error marking messages as read:', error);
}
});Client Usage:
typescript
// When viewing conversation
socket.emit('message:read', conversationId);
// Listen for read receipts
socket.on('message:read', ({ conversationId, readBy }) => {
// Update UI to show "read" status
markMessagesAsRead(conversationId, readBy);
});Typing Indicators
typescript
socket.on('typing:start', (conversationId: string) => {
socket.to(`conversation:${conversationId}`).emit('typing:start', {
conversationId,
userId,
});
});
socket.on('typing:stop', (conversationId: string) => {
socket.to(`conversation:${conversationId}`).emit('typing:stop', {
conversationId,
userId,
});
});Client Implementation:
typescript
let typingTimeout: NodeJS.Timeout;
function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
setMessage(e.target.value);
// Start typing indicator
socket.emit('typing:start', conversationId);
// Clear existing timeout
clearTimeout(typingTimeout);
// Stop after 2 seconds of no typing
typingTimeout = setTimeout(() => {
socket.emit('typing:stop', conversationId);
}, 2000);
}
// Listen for typing events
socket.on('typing:start', ({ conversationId, userId }) => {
showTypingIndicator(userId);
});
socket.on('typing:stop', ({ conversationId, userId }) => {
hideTypingIndicator(userId);
});Presence Socket
File: src/sockets/presence.socket.ts
Events Overview
| Event | Direction | Purpose |
|---|---|---|
user:online | Server → Client | User came online |
user:offline | Server → Client | User went offline |
presence:check | Client → Server | Check if user is online |
presence:bulk-check | Client → Server | Check multiple users |
presence:subscribe | Client → Server | Subscribe to user's status |
screenshot:detected | Client → Server | Report screenshot |
screenshot:alert | Server → Client | Screenshot notification |
Check Online Status
typescript
socket.on('presence:check', async (targetUserId: string, callback) => {
try {
// Check if target user allows online status visibility
const targetSettings = await prisma.userSettings.findUnique({
where: { userId: targetUserId },
select: { showOnlineStatus: true },
});
if (!targetSettings?.showOnlineStatus) {
callback?.({ online: false, hidden: true });
return;
}
const isOnline = onlineUsers.has(targetUserId);
callback?.({ online: isOnline });
} catch (error) {
console.error('Error checking presence:', error);
callback?.({ online: false, error: true });
}
});Privacy Respecting:
- If
showOnlineStatusis false, always returns{ online: false, hidden: true } - Client can differentiate between "offline" and "hidden"
Bulk Status Check
typescript
socket.on('presence:bulk-check', async (userIds: string[], callback) => {
try {
const results: Record<string, boolean> = {};
for (const targetUserId of userIds) {
// Check privacy settings
const targetSettings = await prisma.userSettings.findUnique({
where: { userId: targetUserId },
select: { showOnlineStatus: true },
});
if (targetSettings?.showOnlineStatus) {
results[targetUserId] = onlineUsers.has(targetUserId);
} else {
results[targetUserId] = false;
}
}
callback?.(results);
} catch (error) {
console.error('Error bulk checking presence:', error);
callback?.({});
}
});Client Usage:
typescript
// Check status for all matches
const matchUserIds = matches.map(m => m.user.id);
socket.emit('presence:bulk-check', matchUserIds, (results) => {
// results = { 'user-1': true, 'user-2': false, ... }
updateOnlineIndicators(results);
});Presence Subscription
typescript
socket.on('presence:subscribe', (targetUserId: string) => {
socket.join(`presence:${targetUserId}`);
});
socket.on('presence:unsubscribe', (targetUserId: string) => {
socket.leave(`presence:${targetUserId}`);
});Use Case: Subscribe to a match's online status to get real-time updates.
Screenshot Detection
typescript
socket.on('screenshot:detected', async (context: { conversationId?: string }) => {
console.log(`Screenshot detected by user ${userId}`, context);
// Apply trust penalty
const { applyTrustPenalty } = await import('../services/trust.service.js');
await applyTrustPenalty(userId, 10, 'screenshot_detected');
// Notify the other party if in a conversation
if (context.conversationId) {
const conversation = await prisma.conversation.findUnique({
where: { id: context.conversationId },
});
if (conversation) {
const otherId = conversation.participant1Id === userId
? conversation.participant2Id
: conversation.participant1Id;
io.to(`user:${otherId}`).emit('screenshot:alert', {
conversationId: context.conversationId,
userId,
});
}
}
});Client Detection (React Native example):
typescript
import { addScreenshotListener } from 'react-native-screenshot-manager';
useEffect(() => {
const subscription = addScreenshotListener(() => {
// Report to server
socket.emit('screenshot:detected', {
conversationId: currentConversationId,
});
// Show warning
Alert.alert('Screenshot Detected',
'Screenshots violate privacy and affect your trust score.');
});
return () => subscription.remove();
}, [currentConversationId]);
// Listen for alerts
socket.on('screenshot:alert', ({ conversationId, userId }) => {
showAlert(`User took a screenshot of your conversation`);
});Online Users Map
In-memory storage of connected users:
typescript
const onlineUsers = new Map<string, string>(); // userId -> socketId
export function getOnlineUsers(): Map<string, string> {
return onlineUsers;
}
export function isUserOnline(userId: string): boolean {
return onlineUsers.has(userId);
}Lifecycle:
- User connects →
onlineUsers.set(userId, socketId) - User disconnects →
onlineUsers.delete(userId)
Note: This is server-local. For horizontal scaling, use Redis pub/sub.
Event Flow Diagrams
Sending a Message
User A Server User B
│ │ │
│ message:send │ │
│───────────────────────▶│ │
│ │ │
│ │ Save to DB │
│ │ ───────── │
│ │ │
│ callback({success}) │ │
│◀───────────────────────│ │
│ │ │
│ │ message:new │
│ │────────────────────────▶│
│ │ │
│ message:new │ notification:message │
│◀───────────────────────│────────────────────────▶│
│ │ │Typing Indicator
User A Server User B
│ │ │
│ (starts typing) │ │
│ typing:start │ │
│───────────────────────▶│ │
│ │ typing:start │
│ │────────────────────────▶│
│ │ │
│ (stops typing) │ │
│ typing:stop │ │
│───────────────────────▶│ │
│ │ typing:stop │
│ │────────────────────────▶│Screenshot Detection
User A Server User B
│ │ │
│ (takes screenshot) │ │
│ screenshot:detected │ │
│───────────────────────▶│ │
│ │ │
│ │ Apply -10 trust penalty │
│ │ ─────────────────────── │
│ │ │
│ │ screenshot:alert │
│ │────────────────────────▶│
│ │ │Client Connection Example
React Hook:
typescript
import { useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAuthStore } from '@/store/auth';
export function useSocket() {
const socketRef = useRef<Socket | null>(null);
const { accessToken } = useAuthStore();
useEffect(() => {
if (!accessToken) return;
// Connect with auth token
socketRef.current = io(process.env.API_URL, {
auth: { token: accessToken },
transports: ['websocket'],
});
const socket = socketRef.current;
socket.on('connect', () => {
console.log('Socket connected');
});
socket.on('connect_error', (error) => {
console.error('Socket error:', error.message);
});
// Cleanup
return () => {
socket.disconnect();
};
}, [accessToken]);
return socketRef.current;
}Error Handling
Connection Errors
typescript
socket.on('connect_error', (error) => {
if (error.message === 'Authentication required') {
// Redirect to login
} else if (error.message === 'Invalid or expired token') {
// Refresh token and reconnect
refreshToken().then(() => {
socket.connect();
});
}
});Event Errors
All event callbacks include error field:
typescript
socket.emit('message:send', payload, (response) => {
if (response.error) {
showToast(response.error);
}
});Scaling Considerations
Current Limitations
onlineUsersMap is server-local- Single server can't share presence with others
Redis Adapter (Future)
typescript
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const pubClient = createClient({ url: REDIS_URL });
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));This enables:
- Presence sync across servers
- Event broadcasting to all instances
- Horizontal scaling