Skip to content

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

RoomFormatPurpose
User Roomuser:{userId}Personal notifications
Conversationconversation:{convId}Chat messages
Presencepresence:{userId}Online status updates

Message Socket

File: src/sockets/message.socket.ts

Events Overview

EventDirectionPurpose
join:conversationClient → ServerJoin conversation room
leave:conversationClient → ServerLeave conversation room
message:sendClient → ServerSend new message
message:newServer → ClientNew message received
message:readClient → ServerMark messages as read
typing:startClient → ServerStart typing indicator
typing:stopClient → ServerStop typing indicator
notification:messageServer → ClientMessage 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

EventDirectionPurpose
user:onlineServer → ClientUser came online
user:offlineServer → ClientUser went offline
presence:checkClient → ServerCheck if user is online
presence:bulk-checkClient → ServerCheck multiple users
presence:subscribeClient → ServerSubscribe to user's status
screenshot:detectedClient → ServerReport screenshot
screenshot:alertServer → ClientScreenshot 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 showOnlineStatus is 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:

  1. User connects → onlineUsers.set(userId, socketId)
  2. 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

  • onlineUsers Map 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

One chat. Everything done.