Skip to main content

Building a Real-time Chat App

Learn how to build a full-featured real-time chat application with direct messaging, group chats, typing indicators, read receipts, and file sharing using Ductape Client SDKs.

What You'll Build

  • One-on-one messaging
  • Group chats with multiple participants
  • Real-time message delivery
  • Typing indicators
  • Read receipts and message status
  • File and image sharing
  • Message reactions
  • Search conversations
  • Unread message counters

Prerequisites

  • Basic understanding of JavaScript/TypeScript
  • Node.js and npm installed
  • Ductape account and API credentials
  • Understanding of WebSocket/real-time features

Setup

npm install @ductape/client

Database Schema

This schema setup is done on the backend using the Ductape SDK:

import { Ductape } from '@ductape/sdk';

const ductape = new Ductape({
apiKey: process.env.DUCTAPE_API_KEY
});

// Conversations table
await ductape.databases.schema.create('conversations', {
type: { type: 'String', required: true }, // 'direct' or 'group'
name: { type: 'String' }, // For group chats
avatar_url: { type: 'String' }, // For group chats
participant_ids: { type: 'Array', required: true },
last_message_id: { type: 'String' },
last_message_at: { type: 'Date' },
created_by: { type: 'String', required: true },
created_at: { type: 'Date', default: 'now' },
updated_at: { type: 'Date', default: 'now' }
});

// Messages table
await ductape.databases.schema.create('messages', {
conversation_id: { type: 'String', required: true },
sender_id: { type: 'String', required: true },
content: { type: 'String' },
type: { type: 'String', default: 'text' }, // 'text', 'image', 'file', 'system'
file_url: { type: 'String' },
file_name: { type: 'String' },
file_size: { type: 'Number' },
file_type: { type: 'String' },
reactions: { type: 'JSON', default: {} },
reply_to_id: { type: 'String' },
is_edited: { type: 'Boolean', default: false },
edited_at: { type: 'Date' },
created_at: { type: 'Date', default: 'now' },
updated_at: { type: 'Date', default: 'now' }
});

// Message status table (for read receipts)
await ductape.databases.schema.create('message_status', {
message_id: { type: 'String', required: true },
user_id: { type: 'String', required: true },
status: { type: 'String', required: true }, // 'sent', 'delivered', 'read'
read_at: { type: 'Date' },
delivered_at: { type: 'Date' },
created_at: { type: 'Date', default: 'now' }
});

// Typing indicators table
await ductape.databases.schema.create('typing_indicators', {
conversation_id: { type: 'String', required: true },
user_id: { type: 'String', required: true },
is_typing: { type: 'Boolean', default: true },
updated_at: { type: 'Date', default: 'now' }
});

// Create indexes
await ductape.databases.schema.createIndex('conversations', ['participant_ids']);
await ductape.databases.schema.createIndex('conversations', ['last_message_at']);
await ductape.databases.schema.createIndex('messages', ['conversation_id']);
await ductape.databases.schema.createIndex('messages', ['created_at']);
await ductape.databases.schema.createIndex('message_status', ['message_id', 'user_id']);
await ductape.databases.schema.createIndex('typing_indicators', ['conversation_id']);

Initialize Client

import { DuctapeClient } from '@ductape/client';

const client = new DuctapeClient({
apiKey: process.env.DUCTAPE_API_KEY
});

Chat Window Component

import { DuctapeClient } from '@ductape/client';

const client = new DuctapeClient({
apiKey: process.env.DUCTAPE_API_KEY
});

class ChatWindow {
private conversationId: string;
private userId: string;
private messages: any[] = [];
private typingUsers: any[] = [];
private unsubscribers: Array<() => void> = [];

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

async init() {
// Load messages
await this.loadMessages();

// Subscribe to real-time updates
this.subscribeToMessages();
this.subscribeToTyping();
}

async loadMessages() {
const result = await client.databases.find({
table: 'messages',
where: { conversation_id: this.conversationId },
orderBy: { created_at: 'asc' },
limit: 100
});

this.messages = result.rows;
this.renderMessages();
}

subscribeToMessages() {
const unsubscribe = client.databases.subscribe({
table: 'messages',
where: { conversation_id: this.conversationId },
onInsert: (message) => {
this.messages.push(message);
this.renderMessages();

// Mark as read if not own message
if (message.sender_id !== this.userId) {
this.markAsRead(message.id);
}
},
onUpdate: (message) => {
const index = this.messages.findIndex(m => m.id === message.id);
if (index !== -1) {
this.messages[index] = message;
this.renderMessages();
}
}
});

this.unsubscribers.push(unsubscribe);
}

subscribeToTyping() {
const unsubscribe = client.databases.subscribe({
table: 'typing_indicators',
where: {
conversation_id: this.conversationId,
user_id: { $ne: this.userId },
is_typing: true
},
onInsert: (indicator) => {
if (!this.typingUsers.find(u => u.user_id === indicator.user_id)) {
this.typingUsers.push(indicator);
this.renderTypingIndicator();
}
},
onUpdate: (indicator) => {
if (!indicator.is_typing) {
this.typingUsers = this.typingUsers.filter(u => u.user_id !== indicator.user_id);
this.renderTypingIndicator();
}
}
});

this.unsubscribers.push(unsubscribe);
}

async sendMessage(content: string) {
if (!content.trim()) return;

// Create message
await client.databases.insert({
table: 'messages',
data: {
conversation_id: this.conversationId,
sender_id: this.userId,
content,
type: 'text'
}
});

// Update conversation
await client.databases.update({
table: 'conversations',
where: { id: this.conversationId },
data: {
last_message_at: new Date(),
updated_at: new Date()
}
});

// Clear typing indicator
this.setTyping(false);
}

async setTyping(isTyping: boolean) {
const existing = await client.databases.findOne({
table: 'typing_indicators',
where: { conversation_id: this.conversationId, user_id: this.userId }
});

if (existing.row) {
await client.databases.update({
table: 'typing_indicators',
where: { id: existing.row.id },
data: { is_typing: isTyping, updated_at: new Date() }
});
} else {
await client.databases.insert({
table: 'typing_indicators',
data: {
conversation_id: this.conversationId,
user_id: this.userId,
is_typing: isTyping
}
});
}
}

async markAsRead(messageId: string) {
await client.databases.update({
table: 'message_status',
where: { message_id: messageId, user_id: this.userId },
data: {
status: 'read',
read_at: new Date()
}
});
}

renderMessages() {
const container = document.getElementById('messages-container');
if (!container) return;

container.innerHTML = this.messages.map(msg => `
<div class="message ${msg.sender_id === this.userId ? 'own' : 'other'}">
<p>${msg.content}</p>
<span class="timestamp">${new Date(msg.created_at).toLocaleTimeString()}</span>
</div>
`).join('');

container.scrollTop = container.scrollHeight;
}

renderTypingIndicator() {
const indicator = document.getElementById('typing-indicator');
if (!indicator) return;

if (this.typingUsers.length > 0) {
indicator.textContent = `${this.typingUsers.length} user(s) typing...`;
indicator.style.display = 'block';
} else {
indicator.style.display = 'none';
}
}

destroy() {
this.unsubscribers.forEach(unsub => unsub());
}
}

// Usage
const chat = new ChatWindow('conversation-id', 'user-id');

Sending Files

async function sendFileMessage(
conversationId: string,
senderId: string,
file: File,
caption?: string
) {
// Upload file
const upload = await client.storage.upload({
file,
path: `chat/${conversationId}/${Date.now()}`,
onProgress: (progress) => {
console.log(`Upload progress: ${progress}%`);
}
});

// Determine file type
const isImage = file.type.startsWith('image/');
const messageType = isImage ? 'image' : 'file';

// Create message
const message = await client.databases.insert({
table: 'messages',
data: {
conversation_id: conversationId,
sender_id: senderId,
content: caption || '',
type: messageType,
file_url: upload.url,
file_name: file.name,
file_size: file.size,
file_type: file.type
}
});

// Update conversation
await client.databases.update({
table: 'conversations',
where: { id: conversationId },
data: {
last_message_id: message.rows[0].id,
last_message_at: new Date()
}
});

return message.rows[0];
}

Group Chats

// Create group conversation
async function createGroupConversation(
userId: string,
participantIds: string[],
name: string,
avatarUrl?: string
) {
const allParticipants = [userId, ...participantIds];

const conversation = await client.databases.insert({
table: 'conversations',
data: {
type: 'group',
name,
avatar_url: avatarUrl,
participant_ids: allParticipants,
created_by: userId
}
});

// Send system message
await client.databases.insert({
table: 'messages',
data: {
conversation_id: conversation.rows[0].id,
sender_id: 'system',
content: `${userId} created the group "${name}"`,
type: 'system'
}
});

return conversation.rows[0];
}

// Add participant
async function addParticipant(
conversationId: string,
userId: string,
newParticipantId: string
) {
const conversation = await client.databases.findOne({
table: 'conversations',
where: { id: conversationId }
});

if (!conversation.row || conversation.row.type !== 'group') {
throw new Error('Invalid group conversation');
}

// Add participant
await client.databases.update({
table: 'conversations',
where: { id: conversationId },
data: {
participant_ids: [...conversation.row.participant_ids, newParticipantId],
updated_at: new Date()
}
});

// System message
await client.databases.insert({
table: 'messages',
data: {
conversation_id: conversationId,
sender_id: 'system',
content: `${userId} added ${newParticipantId} to the group`,
type: 'system'
}
});
}

Next Steps

  • Add voice and video calling
  • Implement message search
  • Add emoji picker
  • Create message forwarding
  • Add message pinning
  • Implement chat archiving
  • Add mute/notification settings
  • Create end-to-end encryption