Real-time Chat Application

Learn how to build a real-time chat application with Query-2jz, including WebSocket connections, message persistence, and user presence.

Project Overview

Build a full-featured real-time chat application with rooms, direct messages, file sharing, and user presence indicators.

Features

  • • Real-time messaging with WebSockets
  • • Chat rooms and direct messages
  • • User presence and typing indicators
  • • Message history and search
  • • File and image sharing
  • • Message reactions and replies
  • • User authentication and authorization
  • • Message encryption and security

Project Structure

chat-app/
├── src/
│   ├── models/
│   │   ├── User.ts
│   │   ├── Room.ts
│   │   ├── Message.ts
│   │   ├── Participant.ts
│   │   └── Reaction.ts
│   ├── services/
│   │   ├── ChatService.ts
│   │   ├── PresenceService.ts
│   │   ├── NotificationService.ts
│   │   └── FileService.ts
│   ├── controllers/
│   │   ├── ChatController.ts
│   │   ├── RoomController.ts
│   │   └── UserController.ts
│   ├── websocket/
│   │   ├── ChatHandler.ts
│   │   ├── PresenceHandler.ts
│   │   └── TypingHandler.ts
│   ├── config/
│   │   └── query-2jz.config.ts
│   └── index.ts
├── package.json
├── tsconfig.json
└── README.md

Data Models

Define comprehensive data models for the chat application.

Message Model

// src/models/Message.ts
export interface Message {
  id: string;
  roomId: string;
  userId: string;
  content: string;
  type: 'text' | 'image' | 'file' | 'system';
  replyToId?: string;
  editedAt?: Date;
  deletedAt?: Date;
  metadata?: {
    fileName?: string;
    fileSize?: number;
    mimeType?: string;
    imageUrl?: string;
    thumbnailUrl?: string;
  };
  reactions: Reaction[];
  isEncrypted: boolean;
  createdAt: Date;
  updatedAt: Date;
  room?: Room;
  user?: User;
  replyTo?: Message;
  replies?: Message[];
}

export interface Reaction {
  id: string;
  messageId: string;
  userId: string;
  emoji: string;
  createdAt: Date;
  message?: Message;
  user?: User;
}

export interface Room {
  id: string;
  name: string;
  description?: string;
  type: 'public' | 'private' | 'direct';
  createdBy: string;
  isActive: boolean;
  settings: {
    allowFileUploads: boolean;
    maxFileSize: number;
    allowedFileTypes: string[];
    messageRetention: number; // days
    encryption: boolean;
  };
  createdAt: Date;
  updatedAt: Date;
  createdByUser?: User;
  participants?: Participant[];
  messages?: Message[];
}

export interface Participant {
  id: string;
  roomId: string;
  userId: string;
  role: 'admin' | 'moderator' | 'member';
  joinedAt: Date;
  lastReadAt?: Date;
  isActive: boolean;
  room?: Room;
  user?: User;
}

User Model

// src/models/User.ts
export interface User {
  id: string;
  username: string;
  email: string;
  displayName: string;
  avatar?: string;
  status: 'online' | 'away' | 'busy' | 'offline';
  lastSeenAt: Date;
  preferences: {
    theme: 'light' | 'dark';
    notifications: {
      sound: boolean;
      desktop: boolean;
      email: boolean;
    };
    privacy: {
      showOnlineStatus: boolean;
      allowDirectMessages: boolean;
    };
  };
  isActive: boolean;
  createdAt: Date;
  updatedAt: Date;
  rooms?: Participant[];
  messages?: Message[];
  reactions?: Reaction[];
}

export interface UserPresence {
  userId: string;
  status: 'online' | 'away' | 'busy' | 'offline';
  lastSeenAt: Date;
  currentRoom?: string;
  isTyping?: boolean;
  typingIn?: string;
}

Query-2jz Configuration

Configure Query-2jz with real-time features and chat-specific models.

// src/config/query-2jz.config.ts
import { Query-2jzConfig } from 'query-2jz';

export const query-2jzConfig: Query-2jzConfig = {
  database: {
    type: 'postgresql',
    connection: process.env.DATABASE_URL
  },
  cache: {
    type: 'redis',
    connection: process.env.REDIS_URL
  },
  realtime: {
    enabled: true,
    transport: 'websocket',
    port: 3001,
    cors: {
      origin: process.env.ALLOWED_ORIGINS?.split(','),
      credentials: true
    }
  },
  models: [
    {
      name: 'User',
      fields: {
        id: { type: 'id' },
        username: { type: 'string', required: true, unique: true },
        email: { type: 'email', required: true, unique: true },
        displayName: { type: 'string', required: true },
        avatar: { type: 'string' },
        status: { type: 'enum', values: ['online', 'away', 'busy', 'offline'], default: 'offline' },
        lastSeenAt: { type: 'date', default: 'now' },
        preferences: { type: 'json', default: {} },
        isActive: { type: 'boolean', default: true },
        createdAt: { type: 'date', default: 'now' },
        updatedAt: { type: 'date', default: 'now' },
        rooms: { type: 'relation', model: 'Participant', many: true, foreignKey: 'userId' },
        messages: { type: 'relation', model: 'Message', many: true, foreignKey: 'userId' }
      },
      indexes: ['username', 'email', 'status']
    },
    {
      name: 'Room',
      fields: {
        id: { type: 'id' },
        name: { type: 'string', required: true },
        description: { type: 'string' },
        type: { type: 'enum', values: ['public', 'private', 'direct'], default: 'public' },
        createdBy: { type: 'string', required: true },
        isActive: { type: 'boolean', default: true },
        settings: { type: 'json', default: {} },
        createdAt: { type: 'date', default: 'now' },
        updatedAt: { type: 'date', default: 'now' },
        createdByUser: { type: 'relation', model: 'User', foreignKey: 'createdBy' },
        participants: { type: 'relation', model: 'Participant', many: true, foreignKey: 'roomId' },
        messages: { type: 'relation', model: 'Message', many: true, foreignKey: 'roomId' }
      },
      indexes: ['type', 'isActive', 'createdBy']
    },
    {
      name: 'Message',
      fields: {
        id: { type: 'id' },
        roomId: { type: 'string', required: true },
        userId: { type: 'string', required: true },
        content: { type: 'string', required: true },
        type: { type: 'enum', values: ['text', 'image', 'file', 'system'], default: 'text' },
        replyToId: { type: 'string' },
        editedAt: { type: 'date' },
        deletedAt: { type: 'date' },
        metadata: { type: 'json', default: {} },
        isEncrypted: { type: 'boolean', default: false },
        createdAt: { type: 'date', default: 'now' },
        updatedAt: { type: 'date', default: 'now' },
        room: { type: 'relation', model: 'Room', foreignKey: 'roomId' },
        user: { type: 'relation', model: 'User', foreignKey: 'userId' },
        replyTo: { type: 'relation', model: 'Message', foreignKey: 'replyToId' },
        replies: { type: 'relation', model: 'Message', many: true, foreignKey: 'replyToId' },
        reactions: { type: 'relation', model: 'Reaction', many: true, foreignKey: 'messageId' }
      },
      indexes: ['roomId', 'userId', 'type', 'createdAt']
    },
    {
      name: 'Participant',
      fields: {
        id: { type: 'id' },
        roomId: { type: 'string', required: true },
        userId: { type: 'string', required: true },
        role: { type: 'enum', values: ['admin', 'moderator', 'member'], default: 'member' },
        joinedAt: { type: 'date', default: 'now' },
        lastReadAt: { type: 'date' },
        isActive: { type: 'boolean', default: true },
        room: { type: 'relation', model: 'Room', foreignKey: 'roomId' },
        user: { type: 'relation', model: 'User', foreignKey: 'userId' }
      },
      indexes: ['roomId', 'userId', 'role', 'isActive']
    },
    {
      name: 'Reaction',
      fields: {
        id: { type: 'id' },
        messageId: { type: 'string', required: true },
        userId: { type: 'string', required: true },
        emoji: { type: 'string', required: true },
        createdAt: { type: 'date', default: 'now' },
        message: { type: 'relation', model: 'Message', foreignKey: 'messageId' },
        user: { type: 'relation', model: 'User', foreignKey: 'userId' }
      },
      indexes: ['messageId', 'userId', 'emoji']
    }
  ]
};

WebSocket Handler

Implement WebSocket handlers for real-time chat functionality.

Chat Handler

// src/websocket/ChatHandler.ts
import { WebSocket } from 'ws';
import { Query-2jz } from 'query-2jz';
import { ChatService } from '../services/ChatService';
import { PresenceService } from '../services/PresenceService';

export class ChatHandler {
  private connections = new Map<string, WebSocket>();
  private userRooms = new Map<string, Set<string>>();

  constructor(
    private query-2jz: Query-2jz,
    private chatService: ChatService,
    private presenceService: PresenceService
  ) {}

  handleConnection(ws: WebSocket, userId: string) {
    this.connections.set(userId, ws);
    
    // Update user presence
    this.presenceService.setUserOnline(userId);
    
    // Send user's active rooms
    this.sendUserRooms(userId);
    
    ws.on('message', (data) => {
      try {
        const message = JSON.parse(data.toString());
        this.handleMessage(userId, message);
      } catch (error) {
        ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' }));
      }
    });

    ws.on('close', () => {
      this.connections.delete(userId);
      this.presenceService.setUserOffline(userId);
      this.leaveAllRooms(userId);
    });

    ws.on('error', (error) => {
      console.error('WebSocket error:', error);
      this.connections.delete(userId);
      this.presenceService.setUserOffline(userId);
    });
  }

  private async handleMessage(userId: string, message: any) {
    switch (message.type) {
      case 'join_room':
        await this.handleJoinRoom(userId, message.roomId);
        break;
      case 'leave_room':
        await this.handleLeaveRoom(userId, message.roomId);
        break;
      case 'send_message':
        await this.handleSendMessage(userId, message);
        break;
      case 'typing_start':
        await this.handleTypingStart(userId, message.roomId);
        break;
      case 'typing_stop':
        await this.handleTypingStop(userId, message.roomId);
        break;
      case 'add_reaction':
        await this.handleAddReaction(userId, message);
        break;
      case 'remove_reaction':
        await this.handleRemoveReaction(userId, message);
        break;
      default:
        this.sendToUser(userId, { type: 'error', message: 'Unknown message type' });
    }
  }

  private async handleJoinRoom(userId: string, roomId: string) {
    try {
      // Check if user has access to room
      const hasAccess = await this.chatService.userHasAccessToRoom(userId, roomId);
      if (!hasAccess) {
        this.sendToUser(userId, { type: 'error', message: 'Access denied' });
        return;
      }

      // Add user to room
      if (!this.userRooms.has(userId)) {
        this.userRooms.set(userId, new Set());
      }
      this.userRooms.get(userId)!.add(roomId);

      // Send room messages
      const messages = await this.chatService.getRoomMessages(roomId, 50);
      this.sendToUser(userId, {
        type: 'room_messages',
        roomId,
        messages: messages.data
      });

      // Notify other users in room
      this.broadcastToRoom(roomId, {
        type: 'user_joined',
        userId,
        roomId
      }, userId);

      // Update presence
      this.presenceService.setUserInRoom(userId, roomId);
    } catch (error) {
      this.sendToUser(userId, { type: 'error', message: error.message });
    }
  }

  private async handleLeaveRoom(userId: string, roomId: string) {
    if (this.userRooms.has(userId)) {
      this.userRooms.get(userId)!.delete(roomId);
    }

    // Notify other users in room
    this.broadcastToRoom(roomId, {
      type: 'user_left',
      userId,
      roomId
    }, userId);

    // Update presence
    this.presenceService.setUserInRoom(userId, null);
  }

  private async handleSendMessage(userId: string, message: any) {
    try {
      const { roomId, content, type = 'text', replyToId, metadata } = message;

      // Check if user has access to room
      const hasAccess = await this.chatService.userHasAccessToRoom(userId, roomId);
      if (!hasAccess) {
        this.sendToUser(userId, { type: 'error', message: 'Access denied' });
        return;
      }

      // Create message
      const newMessage = await this.chatService.createMessage({
        roomId,
        userId,
        content,
        type,
        replyToId,
        metadata
      });

      // Broadcast to all users in room
      this.broadcastToRoom(roomId, {
        type: 'new_message',
        message: newMessage
      });

      // Update last read time for sender
      await this.chatService.updateLastReadTime(userId, roomId);
    } catch (error) {
      this.sendToUser(userId, { type: 'error', message: error.message });
    }
  }

  private async handleTypingStart(userId: string, roomId: string) {
    this.broadcastToRoom(roomId, {
      type: 'user_typing',
      userId,
      roomId,
      isTyping: true
    }, userId);
  }

  private async handleTypingStop(userId: string, roomId: string) {
    this.broadcastToRoom(roomId, {
      type: 'user_typing',
      userId,
      roomId,
      isTyping: false
    }, userId);
  }

  private async handleAddReaction(userId: string, message: any) {
    try {
      const { messageId, emoji } = message;
      
      const reaction = await this.chatService.addReaction(messageId, userId, emoji);
      
      // Broadcast reaction to room
      const messageData = await this.query-2jz.query({
        model: 'Message',
        where: { id: messageId },
        include: ['room']
      });

      if (messageData.data.length > 0) {
        const roomId = messageData.data[0].roomId;
        this.broadcastToRoom(roomId, {
          type: 'reaction_added',
          messageId,
          reaction
        });
      }
    } catch (error) {
      this.sendToUser(userId, { type: 'error', message: error.message });
    }
  }

  private async handleRemoveReaction(userId: string, message: any) {
    try {
      const { messageId, emoji } = message;
      
      await this.chatService.removeReaction(messageId, userId, emoji);
      
      // Broadcast reaction removal to room
      const messageData = await this.query-2jz.query({
        model: 'Message',
        where: { id: messageId },
        include: ['room']
      });

      if (messageData.data.length > 0) {
        const roomId = messageData.data[0].roomId;
        this.broadcastToRoom(roomId, {
          type: 'reaction_removed',
          messageId,
          userId,
          emoji
        });
      }
    } catch (error) {
      this.sendToUser(userId, { type: 'error', message: error.message });
    }
  }

  private sendToUser(userId: string, data: any) {
    const ws = this.connections.get(userId);
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(data));
    }
  }

  private broadcastToRoom(roomId: string, data: any, excludeUserId?: string) {
    for (const [userId, userRooms] of this.userRooms.entries()) {
      if (userRooms.has(roomId) && userId !== excludeUserId) {
        this.sendToUser(userId, data);
      }
    }
  }

  private async sendUserRooms(userId: string) {
    const rooms = await this.chatService.getUserRooms(userId);
    this.sendToUser(userId, {
      type: 'user_rooms',
      rooms: rooms.data
    });
  }

  private leaveAllRooms(userId: string) {
    if (this.userRooms.has(userId)) {
      const rooms = this.userRooms.get(userId)!;
      for (const roomId of rooms) {
        this.broadcastToRoom(roomId, {
          type: 'user_left',
          userId,
          roomId
        }, userId);
      }
      this.userRooms.delete(userId);
    }
  }
}

Chat Service

Implement business logic for chat operations.

Chat Service Implementation

// src/services/ChatService.ts
import { Query-2jz } from 'query-2jz';

export class ChatService {
  constructor(private query-2jz: Query-2jz) {}

  async createMessage(data: {
    roomId: string;
    userId: string;
    content: string;
    type?: string;
    replyToId?: string;
    metadata?: any;
  }) {
    const message = await this.query-2jz.create({
      model: 'Message',
      data: {
        roomId: data.roomId,
        userId: data.userId,
        content: data.content,
        type: data.type || 'text',
        replyToId: data.replyToId,
        metadata: data.metadata || {}
      }
    });

    // Get message with relations
    const messageWithRelations = await this.query-2jz.query({
      model: 'Message',
      where: { id: message.id },
      include: ['user', 'room', 'replyTo', 'reactions']
    });

    return messageWithRelations.data[0];
  }

  async getRoomMessages(roomId: string, limit = 50, offset = 0) {
    return this.query-2jz.query({
      model: 'Message',
      where: { 
        roomId,
        deletedAt: null
      },
      include: ['user', 'replyTo', 'reactions', 'reactions.user'],
      orderBy: 'createdAt:desc',
      limit,
      offset
    });
  }

  async getUserRooms(userId: string) {
    return this.query-2jz.query({
      model: 'Participant',
      where: { 
        userId,
        isActive: true
      },
      include: ['room', 'room.participants', 'room.participants.user']
    });
  }

  async createRoom(data: {
    name: string;
    description?: string;
    type: 'public' | 'private' | 'direct';
    createdBy: string;
    settings?: any;
  }) {
    const room = await this.query-2jz.create({
      model: 'Room',
      data: {
        name: data.name,
        description: data.description,
        type: data.type,
        createdBy: data.createdBy,
        settings: data.settings || {}
      }
    });

    // Add creator as admin
    await this.query-2jz.create({
      model: 'Participant',
      data: {
        roomId: room.id,
        userId: data.createdBy,
        role: 'admin'
      }
    });

    return room;
  }

  async addUserToRoom(roomId: string, userId: string, role = 'member') {
    // Check if user is already in room
    const existing = await this.query-2jz.query({
      model: 'Participant',
      where: { roomId, userId }
    });

    if (existing.data.length > 0) {
      // Update existing participant
      return this.query-2jz.update({
        model: 'Participant',
        id: existing.data[0].id,
        data: { isActive: true, role }
      });
    }

    // Create new participant
    return this.query-2jz.create({
      model: 'Participant',
      data: {
        roomId,
        userId,
        role
      }
    });
  }

  async removeUserFromRoom(roomId: string, userId: string) {
    const participant = await this.query-2jz.query({
      model: 'Participant',
      where: { roomId, userId }
    });

    if (participant.data.length > 0) {
      return this.query-2jz.update({
        model: 'Participant',
        id: participant.data[0].id,
        data: { isActive: false }
      });
    }
  }

  async userHasAccessToRoom(userId: string, roomId: string): Promise<boolean> {
    const participant = await this.query-2jz.query({
      model: 'Participant',
      where: { 
        roomId, 
        userId,
        isActive: true
      }
    });

    return participant.data.length > 0;
  }

  async updateLastReadTime(userId: string, roomId: string) {
    const participant = await this.query-2jz.query({
      model: 'Participant',
      where: { roomId, userId }
    });

    if (participant.data.length > 0) {
      return this.query-2jz.update({
        model: 'Participant',
        id: participant.data[0].id,
        data: { lastReadAt: new Date() }
      });
    }
  }

  async addReaction(messageId: string, userId: string, emoji: string) {
    // Check if reaction already exists
    const existing = await this.query-2jz.query({
      model: 'Reaction',
      where: { messageId, userId, emoji }
    });

    if (existing.data.length > 0) {
      return existing.data[0];
    }

    return this.query-2jz.create({
      model: 'Reaction',
      data: {
        messageId,
        userId,
        emoji
      }
    });
  }

  async removeReaction(messageId: string, userId: string, emoji: string) {
    const reaction = await this.query-2jz.query({
      model: 'Reaction',
      where: { messageId, userId, emoji }
    });

    if (reaction.data.length > 0) {
      return this.query-2jz.delete({
        model: 'Reaction',
        id: reaction.data[0].id
      });
    }
  }

  async searchMessages(query: string, roomId?: string, userId?: string) {
    const where: any = {
      content: { $like: `%${query}%` },
      deletedAt: null
    };

    if (roomId) {
      where.roomId = roomId;
    }

    if (userId) {
      where.userId = userId;
    }

    return this.query-2jz.query({
      model: 'Message',
      where,
      include: ['user', 'room'],
      orderBy: 'createdAt:desc',
      limit: 100
    });
  }
}

Client Implementation

Create a client-side implementation for the chat application.

WebSocket Client

// client/chat-client.ts
export class ChatClient {
  private ws: WebSocket | null = null;
  private userId: string;
  private currentRoom: string | null = null;
  private typingTimeout: NodeJS.Timeout | null = null;

  constructor(userId: string) {
    this.userId = userId;
  }

  connect() {
    this.ws = new WebSocket(`ws://localhost:3001?userId=${this.userId}`);
    
    this.ws.onopen = () => {
      console.log('Connected to chat server');
    };

    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.handleMessage(message);
    };

    this.ws.onclose = () => {
      console.log('Disconnected from chat server');
      // Attempt to reconnect
      setTimeout(() => this.connect(), 5000);
    };

    this.ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };
  }

  private handleMessage(message: any) {
    switch (message.type) {
      case 'new_message':
        this.onNewMessage(message.message);
        break;
      case 'user_joined':
        this.onUserJoined(message.userId, message.roomId);
        break;
      case 'user_left':
        this.onUserLeft(message.userId, message.roomId);
        break;
      case 'user_typing':
        this.onUserTyping(message.userId, message.roomId, message.isTyping);
        break;
      case 'reaction_added':
        this.onReactionAdded(message.messageId, message.reaction);
        break;
      case 'reaction_removed':
        this.onReactionRemoved(message.messageId, message.userId, message.emoji);
        break;
      case 'room_messages':
        this.onRoomMessages(message.roomId, message.messages);
        break;
      case 'user_rooms':
        this.onUserRooms(message.rooms);
        break;
      case 'error':
        this.onError(message.message);
        break;
    }
  }

  joinRoom(roomId: string) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.currentRoom = roomId;
      this.ws.send(JSON.stringify({
        type: 'join_room',
        roomId
      }));
    }
  }

  leaveRoom(roomId: string) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({
        type: 'leave_room',
        roomId
      }));
      
      if (this.currentRoom === roomId) {
        this.currentRoom = null;
      }
    }
  }

  sendMessage(content: string, type = 'text', replyToId?: string, metadata?: any) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentRoom) {
      this.ws.send(JSON.stringify({
        type: 'send_message',
        roomId: this.currentRoom,
        content,
        type,
        replyToId,
        metadata
      }));
    }
  }

  startTyping() {
    if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentRoom) {
      this.ws.send(JSON.stringify({
        type: 'typing_start',
        roomId: this.currentRoom
      }));

      // Auto-stop typing after 3 seconds
      if (this.typingTimeout) {
        clearTimeout(this.typingTimeout);
      }
      this.typingTimeout = setTimeout(() => {
        this.stopTyping();
      }, 3000);
    }
  }

  stopTyping() {
    if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentRoom) {
      this.ws.send(JSON.stringify({
        type: 'typing_stop',
        roomId: this.currentRoom
      }));
    }

    if (this.typingTimeout) {
      clearTimeout(this.typingTimeout);
      this.typingTimeout = null;
    }
  }

  addReaction(messageId: string, emoji: string) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({
        type: 'add_reaction',
        messageId,
        emoji
      }));
    }
  }

  removeReaction(messageId: string, emoji: string) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({
        type: 'remove_reaction',
        messageId,
        emoji
      }));
    }
  }

  // Event handlers (to be implemented by the application)
  onNewMessage(message: any) {
    console.log('New message:', message);
  }

  onUserJoined(userId: string, roomId: string) {
    console.log('User joined:', userId, roomId);
  }

  onUserLeft(userId: string, roomId: string) {
    console.log('User left:', userId, roomId);
  }

  onUserTyping(userId: string, roomId: string, isTyping: boolean) {
    console.log('User typing:', userId, roomId, isTyping);
  }

  onReactionAdded(messageId: string, reaction: any) {
    console.log('Reaction added:', messageId, reaction);
  }

  onReactionRemoved(messageId: string, userId: string, emoji: string) {
    console.log('Reaction removed:', messageId, userId, emoji);
  }

  onRoomMessages(roomId: string, messages: any[]) {
    console.log('Room messages:', roomId, messages);
  }

  onUserRooms(rooms: any[]) {
    console.log('User rooms:', rooms);
  }

  onError(message: string) {
    console.error('Chat error:', message);
  }

  disconnect() {
    if (this.ws) {
      this.ws.close();
      this.ws = null;
    }
  }
}

Next Steps

Enhance your chat application with additional features.

Advanced Features

  • • Message encryption and security
  • • File and image sharing
  • • Voice and video calls
  • • Message search and filtering
  • • Custom emojis and reactions
  • • Message threading and replies
  • • Bot integration and commands