1
0
peddlers-of-ketran/examples/chat-room-example.md

12 KiB

Example: Video Chat Room Using the Pluggable Architecture

This example shows how to create a simple video chat room application using the reusable Room/WebRTC infrastructure.

Step 1: Define Your Metadata Types

// chat-app/types.ts
import type { Session, Room } from '../server/routes/room/types';

/**
 * Chat-specific session metadata
 * Extends base Session with chat-specific data
 */
export interface ChatSessionMetadata {
  status: 'online' | 'away' | 'busy';
  customStatus?: string;
  joinedAt: number;
  messageCount: number;
}

/**
 * Chat-specific room metadata
 * Extends base Room with chat-specific data
 */
export interface ChatRoomMetadata {
  topic: string;
  messages: ChatMessage[];
  pinnedMessages: string[];
  createdBy: string;
  maxParticipants: number;
}

export interface ChatMessage {
  id: string;
  senderId: string;
  senderName: string;
  text: string;
  timestamp: number;
}

// Type aliases for convenience
export type ChatSession = Session<ChatSessionMetadata>;
export type ChatRoom = Room<ChatRoomMetadata>;

// Extended participant with chat-specific fields
export interface ChatParticipant {
  session_id: string;
  name: string | null;
  live: boolean;
  has_media: boolean;
  status: 'online' | 'away' | 'busy';
  customStatus?: string;
  messageCount: number;
}

Step 2: Create Room Helpers

// chat-app/helpers.ts
import { getParticipants as getBaseParticipants } from '../server/routes/room/helpers';
import type { ChatRoom, ChatParticipant } from './types';

/**
 * Get participants with chat-specific data
 */
export function getChatParticipants(room: ChatRoom): ChatParticipant[] {
  // Get base participant data from reusable helper
  const baseParticipants = getBaseParticipants(room.sessions);

  // Extend with chat-specific metadata
  return baseParticipants.map(p => {
    const session = room.sessions[p.session_id];
    const metadata = session.metadata;

    return {
      ...p,
      status: metadata?.status || 'online',
      customStatus: metadata?.customStatus,
      messageCount: metadata?.messageCount || 0,
    };
  });
}

/**
 * Create a new chat room
 */
export function createChatRoom(roomId: string, roomName: string, creatorId: string): ChatRoom {
  return {
    id: roomId,
    name: roomName,
    sessions: {},
    state: 'active',
    created: Date.now(),
    lastActivity: Date.now(),
    metadata: {
      topic: 'General Chat',
      messages: [],
      pinnedMessages: [],
      createdBy: creatorId,
      maxParticipants: 50,
    },
  };
}

/**
 * Add a message to the chat room
 */
export function addMessage(room: ChatRoom, senderId: string, text: string): void {
  const session = room.sessions[senderId];
  if (!session) return;

  const message = {
    id: `${Date.now()}-${senderId}`,
    senderId,
    senderName: session.name || 'Anonymous',
    text,
    timestamp: Date.now(),
  };

  room.metadata.messages.push(message);

  // Update sender's message count
  if (session.metadata) {
    session.metadata.messageCount++;
  }
}

Step 3: Server WebSocket Handler

// chat-app/server.ts
import express from 'express';
import expressWs from 'express-ws';
import type { ChatRoom, ChatSession } from './types';
import { createBaseSession } from '../server/routes/room/helpers';
import { getChatParticipants, createChatRoom, addMessage } from './helpers';

const app = expressWs(express()).app;
const rooms: Record<string, ChatRoom> = {};

// WebSocket endpoint for chat room
app.ws('/chat/:roomId', async (ws, req) => {
  const { roomId } = req.params;
  const sessionId = req.cookies?.session || generateSessionId();

  // Get or create room
  let room = rooms[roomId];
  if (!room) {
    room = createChatRoom(roomId, `Chat Room ${roomId}`, sessionId);
    rooms[roomId] = room;
  }

  // Create or get session
  let session: ChatSession = room.sessions[sessionId];
  if (!session) {
    session = {
      ...createBaseSession(sessionId),
      metadata: {
        status: 'online',
        joinedAt: Date.now(),
        messageCount: 0,
      },
    };
    room.sessions[sessionId] = session;
  }

  // Attach WebSocket
  session.ws = ws;
  session.live = true;
  session.connected = true;

  // Notify all participants
  broadcastUpdate(room, {
    type: 'participants',
    participants: getChatParticipants(room),
  });

  // Handle incoming messages
  ws.on('message', (msg: string) => {
    const data = JSON.parse(msg);

    switch (data.type) {
      case 'set-name':
        session.name = data.name;
        broadcastUpdate(room, {
          type: 'participants',
          participants: getChatParticipants(room),
        });
        break;

      case 'set-status':
        if (session.metadata) {
          session.metadata.status = data.status;
          session.metadata.customStatus = data.customStatus;
        }
        broadcastUpdate(room, {
          type: 'participants',
          participants: getChatParticipants(room),
        });
        break;

      case 'send-message':
        addMessage(room, sessionId, data.text);
        broadcastUpdate(room, {
          type: 'new-message',
          messages: room.metadata.messages.slice(-50), // Last 50 messages
        });
        break;

      case 'get-messages':
        ws.send(JSON.stringify({
          type: 'messages',
          messages: room.metadata.messages.slice(-50),
        }));
        break;

      case 'get-participants':
        ws.send(JSON.stringify({
          type: 'participants',
          participants: getChatParticipants(room),
        }));
        break;

      // WebRTC signaling messages (handled by reusable code)
      case 'join':
      case 'part':
      case 'relayICECandidate':
      case 'relaySessionDescription':
        // Use the same WebRTC handlers as the game
        // (This code is application-agnostic)
        handleWebRTCMessage(room, session, data);
        break;
    }
  });

  ws.on('close', () => {
    session.live = false;
    session.connected = false;

    // Clean up after timeout
    setTimeout(() => {
      if (!session.live) {
        delete room.sessions[sessionId];
        broadcastUpdate(room, {
          type: 'participants',
          participants: getChatParticipants(room),
        });
      }
    }, 60000); // 1 minute grace period
  });
});

function broadcastUpdate(room: ChatRoom, update: any) {
  const message = JSON.stringify(update);
  Object.values(room.sessions).forEach(session => {
    if (session.ws && session.connected) {
      session.ws.send(message);
    }
  });
}

// WebRTC handlers (reusable from game implementation)
function handleWebRTCMessage(room: ChatRoom, session: ChatSession, data: any) {
  // Same join/part/ICE/SDP handling as in games.ts
  // This code doesn't care about chat vs. game - it's pure WebRTC
}

Step 4: Client Component

// chat-app/client/ChatRoom.tsx
import React, { useState, useEffect } from 'react';
import { MediaAgent, MediaControl, Peer } from './MediaControl';
import useWebSocket from 'react-use-websocket';
import type { ChatParticipant, ChatMessage } from './types';

interface ChatRoomProps {
  roomId: string;
  session: { id: string; name: string | null; has_media: boolean };
}

export function ChatRoom({ roomId, session }: ChatRoomProps) {
  const [participants, setParticipants] = useState<ChatParticipant[]>([]);
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [messageText, setMessageText] = useState('');
  const [peers, setPeers] = useState<Record<string, Peer>>({});

  const socketUrl = `ws://localhost:3000/chat/${roomId}`;
  const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(socketUrl);

  // Handle WebSocket messages
  useEffect(() => {
    if (!lastJsonMessage) return;

    const data: any = lastJsonMessage;

    switch (data.type) {
      case 'participants':
        setParticipants(data.participants);
        break;

      case 'messages':
      case 'new-message':
        setMessages(data.messages);
        break;
    }
  }, [lastJsonMessage]);

  // Send message
  const handleSendMessage = () => {
    if (!messageText.trim()) return;

    sendJsonMessage({
      type: 'send-message',
      text: messageText,
    });

    setMessageText('');
  };

  return (
    <div className="chat-room">
      {/* MediaAgent handles WebRTC (reusable component) */}
      <MediaAgent
        socketUrl={socketUrl}
        session={session}
        peers={peers}
        setPeers={setPeers}
      />

      <div className="layout">
        {/* Participant list with video feeds */}
        <aside className="participants">
          <h3>Participants ({participants.length})</h3>
          {participants.map(p => (
            <div key={p.session_id} className="participant">
              <div className="info">
                <strong>{p.name || p.session_id}</strong>
                <span className={`status ${p.status}`}>{p.status}</span>
                {p.customStatus && <div>{p.customStatus}</div>}
              </div>

              {/* MediaControl for video (reusable component) */}
              {peers[p.session_id] && (
                <MediaControl
                  peer={peers[p.session_id]}
                  isSelf={p.session_id === session.id}
                  sendJsonMessage={p.session_id === session.id ? sendJsonMessage : undefined}
                  remoteAudioMuted={peers[p.session_id].muted}
                  remoteVideoOff={!peers[p.session_id].video_on}
                />
              )}
            </div>
          ))}
        </aside>

        {/* Chat messages */}
        <main className="messages">
          {messages.map(msg => (
            <div key={msg.id} className="message">
              <strong>{msg.senderName}</strong>
              <span>{new Date(msg.timestamp).toLocaleTimeString()}</span>
              <p>{msg.text}</p>
            </div>
          ))}

          <div className="input">
            <input
              value={messageText}
              onChange={e => setMessageText(e.target.value)}
              onKeyPress={e => e.key === 'Enter' && handleSendMessage()}
              placeholder="Type a message..."
            />
            <button onClick={handleSendMessage}>Send</button>
          </div>
        </main>
      </div>
    </div>
  );
}

What's Reused (No Changes Needed)

MediaControl.tsx - Entire component works as-is MediaAgent - All WebRTC signaling logic Room helpers - Session management, participant lists WebSocket infrastructure - Connection handling, reconnection Type definitions - Base Session, Room, Participant types

What's Application-Specific (Your Code)

🎯 ChatSessionMetadata - Your session data (status, message count) 🎯 ChatRoomMetadata - Your room data (messages, topic) 🎯 getChatParticipants() - Extends base participants with chat data 🎯 Message handlers - Your business logic 🎯 UI - Your specific interface design

Benefits

  • ~90% code reuse from the game infrastructure
  • Video chat works immediately with zero WebRTC code
  • Type-safe metadata for your application
  • Well-tested infrastructure from the game
  • Clean separation between framework and app

Summary

By separating infrastructure from application logic, the entire Room/WebRTC system becomes a reusable framework that provides:

  • WebSocket room management
  • Session/participant tracking
  • WebRTC video/audio signaling
  • Peer connection management
  • UI components (MediaControl)

All you need to do is:

  1. Define your metadata types
  2. Extend participant helper with your data
  3. Handle your application messages
  4. Use the provided components

Result: Professional video chat functionality in ~200 lines of your code!