A comprehensive guide to the real-time chat system in VBase, covering architecture, data flow, and implementation details.
- Overview
- Architecture
- Data Model
- Backend (Convex)
- Frontend (React Components)
- Message Lifecycle
- Feature Details
- Performance Optimizations
- File References
The VBase chat system provides real-time messaging capabilities within workspaces. It supports:
- Workspace-wide chat (
#generalchannel) - Direct Messages (DMs) between two users
- Message reactions (like, dislike, haha)
- Unread count tracking with cursor-based read receipts
- Real-time updates via Convex subscriptions
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ChatSystem Component ││
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
│ │ │ Chat Bubble │ │ Chat Window │ │ NewDM Modal │ ││
│ │ └─────────────┘ └─────────────┘ └─────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
└───────────────────────────┬─────────────────────────────────┘
│ Convex React Hooks
│ (useQuery, useMutation)
▼
┌─────────────────────────────────────────────────────────────┐
│ Backend (Convex) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ channels.ts │ │ messages.ts │ │
│ │ - createGeneral│ │ - sendMessage │ │
│ │ - createOrGetDM│ │ - getMessages │ │
│ │ - getChannels │ │ - toggleReact │ │
│ └─────────────────┘ │ - markAsRead │ │
│ │ - getUnread │ │
│ └─────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Database Tables ││
│ │ channels │ messages │ lastRead │ users ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
| Layer | Technology | Purpose |
|---|---|---|
| Frontend | React + Next.js | UI components |
| State Management | Convex React Hooks | Real-time subscriptions |
| Backend | Convex | Serverless functions + Database |
| Authentication | Clerk | User identity |
| Animation | Framer Motion | UI animations |
- User initiates action (send message, react, etc.)
- useMutation hook calls Convex mutation
- Convex mutation validates auth, writes to DB
- Convex subscriptions automatically propagate changes
- useQuery hooks receive updated data
- React re-renders UI with new state
Stores chat channels (general, direct, group).
channels: defineTable({
workspaceId: v.id("workspaces"),
name: v.string(), // "general" or "dm-userId1-userId2"
type: v.union(
v.literal("general"), // Workspace-wide channel
v.literal("direct"), // 1-on-1 DM
v.literal("group") // Future: custom group channels
),
participantIds: v.optional(v.array(v.string())), // For DM: [userId1, userId2]
createdAt: v.number(),
createdBy: v.string(), // Clerk User ID
})
.index("by_workspace", ["workspaceId"])
.index("by_participants", ["participantIds"])Stores all chat messages.
messages: defineTable({
channelId: v.id("channels"),
workspaceId: v.id("workspaces"), // Denormalized for faster queries
authorId: v.string(), // Clerk User ID
authorName: v.string(), // Cached for backwards compatibility
content: v.string(),
timestamp: v.number(),
reactions: v.optional(v.object({
like: v.optional(v.array(v.string())),
dislike: v.optional(v.array(v.string())),
haha: v.optional(v.array(v.string())),
})),
attachments: v.optional(v.array(v.object({...}))),
parentMessageId: v.optional(v.id("messages")), // For threaded replies (future)
})
.index("by_channel", ["channelId", "timestamp"])
.index("by_workspace", ["workspaceId"])Tracks when each user last read each channel (cursor-based unread tracking).
lastRead: defineTable({
userId: v.string(), // Clerk User ID
channelId: v.id("channels"),
lastReadAt: v.number(), // Timestamp of last read
})
.index("by_user_channel", ["userId", "channelId"])
.index("by_user", ["userId"])
.index("by_channel", ["channelId"])File: convex/channels.ts
Creates the #general channel for a workspace. Called automatically when a workspace is created.
export const createGeneralChannel = mutation({
args: {
workspaceId: v.id("workspaces"),
createdBy: v.string(),
},
handler: async (ctx, args) => {
// 1. Authenticate user
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
// 2. Check if general channel already exists
const existingGeneral = await ctx.db
.query("channels")
.withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
.filter((q) => q.eq(q.field("type"), "general"))
.first();
if (existingGeneral) return existingGeneral._id;
// 3. Create general channel
return await ctx.db.insert("channels", {
workspaceId: args.workspaceId,
name: "general",
type: "general",
createdAt: Date.now(),
createdBy: args.createdBy,
});
},
});Creates or retrieves an existing DM channel between two users.
export const createOrGetDirectChannel = mutation({
args: {
workspaceId: v.id("workspaces"),
otherUserId: v.string(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const currentUserId = identity.subject;
// Cannot DM yourself
if (currentUserId === args.otherUserId) {
throw new Error("Cannot create DM with yourself");
}
// Sort user IDs for consistent channel naming
const participantIds = [currentUserId, args.otherUserId].sort();
// Check if DM already exists
const existingDM = await ctx.db
.query("channels")
.withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
.filter((q) => q.eq(q.field("type"), "direct"))
.collect();
const matchingDM = existingDM.find((channel) => {
const channelParticipants = channel.participantIds?.sort() || [];
return (
channelParticipants.length === 2 &&
channelParticipants[0] === participantIds[0] &&
channelParticipants[1] === participantIds[1]
);
});
if (matchingDM) return matchingDM._id;
// Create new DM channel
return await ctx.db.insert("channels", {
workspaceId: args.workspaceId,
name: `dm-${participantIds[0]}-${participantIds[1]}`,
type: "direct",
participantIds,
createdAt: Date.now(),
createdBy: currentUserId,
});
},
});Returns all non-DM channels in a workspace.
export const getWorkspaceChannels = query({
args: { workspaceId: v.id("workspaces") },
handler: async (ctx, args) => {
return await ctx.db
.query("channels")
.withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
.filter((q) => q.neq(q.field("type"), "direct"))
.order("asc")
.collect();
},
});Returns all DM channels for the current user, enriched with other user's info.
export const getDirectChannels = query({
args: { workspaceId: v.id("workspaces") },
handler: async (ctx, args) => {
const currentUserId = identity.subject;
// Get all DMs in workspace
const allDMs = await ctx.db
.query("channels")
.withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
.filter((q) => q.eq(q.field("type"), "direct"))
.collect();
// Filter to user's DMs only
const userDMs = allDMs.filter((channel) =>
channel.participantIds?.includes(currentUserId)
);
// Enrich with other user's name
return await Promise.all(
userDMs.map(async (dm) => {
const otherUserId = dm.participantIds?.find(id => id !== currentUserId);
const otherUser = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", otherUserId))
.first();
return {
...dm,
otherUserId,
otherUserName: otherUser?.name || "Unknown User",
};
})
);
},
});File: convex/messages.ts
Creates a new message in a channel.
export const sendMessage = mutation({
args: {
channelId: v.id("channels"),
content: v.string(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const currentUserId = identity.subject;
// Get channel to verify access
const channel = await ctx.db.get(args.channelId);
if (!channel) throw new Error("Channel not found");
// Check DM access
if (channel.type === "direct" && !channel.participantIds?.includes(currentUserId)) {
throw new Error("You don't have access to this channel");
}
// Get author name
const user = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", currentUserId))
.first();
// Create message
return await ctx.db.insert("messages", {
channelId: args.channelId,
workspaceId: channel.workspaceId,
authorId: currentUserId,
authorName: user?.name || "Unknown User",
content: args.content,
timestamp: Date.now(),
});
},
});Fetches messages with author names resolved at query time (ensures names are always current).
export const getMessagesWithAuthors = query({
args: {
channelId: v.id("channels"),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
// Verify access...
// Get messages (most recent first, then reverse)
const limit = args.limit || 100;
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(limit);
// Batch fetch unique authors
const authorIds = [...new Set(messages.map((m) => m.authorId))];
const authors = await Promise.all(
authorIds.map(async (authorId) => {
const user = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", authorId))
.first();
return { authorId, name: user?.name || "Unknown User" };
})
);
const authorMap = new Map(authors.map((a) => [a.authorId, a.name]));
// Return in chronological order
return messages.reverse().map((msg) => ({
...msg,
authorName: authorMap.get(msg.authorId) || msg.authorName,
}));
},
});Adds or removes a reaction from a message.
export const toggleReaction = mutation({
args: {
messageId: v.id("messages"),
reactionType: v.union(v.literal("like"), v.literal("dislike"), v.literal("haha")),
},
handler: async (ctx, args) => {
const currentUserId = identity.subject;
const message = await ctx.db.get(args.messageId);
// Verify channel access...
const reactions = message.reactions || {};
const reactionArray = reactions[args.reactionType] || [];
// Toggle: remove if present, add if not
const userIndex = reactionArray.indexOf(currentUserId);
const updatedReactionArray = userIndex > -1
? reactionArray.filter((id) => id !== currentUserId)
: [...reactionArray, currentUserId];
await ctx.db.patch(args.messageId, {
reactions: {
...reactions,
[args.reactionType]: updatedReactionArray,
},
});
return { added: userIndex === -1 };
},
});Only the message author can delete or edit their own messages.
export const deleteMessage = mutation({
args: { messageId: v.id("messages") },
handler: async (ctx, args) => {
const message = await ctx.db.get(args.messageId);
if (message.authorId !== currentUserId) {
throw new Error("You can only delete your own messages");
}
await ctx.db.delete(args.messageId);
},
});
export const editMessage = mutation({
args: { messageId: v.id("messages"), newContent: v.string() },
handler: async (ctx, args) => {
const message = await ctx.db.get(args.messageId);
if (message.authorId !== currentUserId) {
throw new Error("You can only edit your own messages");
}
await ctx.db.patch(args.messageId, { content: args.newContent });
},
});File: convex/messages.ts
Updates the user's last read timestamp for a channel. Uses cursor-based tracking for efficiency.
export const markChannelAsRead = mutation({
args: { channelId: v.id("channels") },
handler: async (ctx, args) => {
const currentUserId = identity.subject;
const now = Date.now();
// Check if lastRead record exists
const existingLastRead = await ctx.db
.query("lastRead")
.withIndex("by_user_channel", (q) =>
q.eq("userId", currentUserId).eq("channelId", args.channelId)
)
.first();
if (existingLastRead) {
// Update existing record (single DB write)
await ctx.db.patch(existingLastRead._id, { lastReadAt: now });
} else {
// Create new record (single DB write)
await ctx.db.insert("lastRead", {
userId: currentUserId,
channelId: args.channelId,
lastReadAt: now,
});
}
},
});Aggregates unread counts and message previews for all channels. This is a key performance optimization that replaces N individual subscriptions with a single query.
export const getUnreadCounts = query({
args: { workspaceId: v.id("workspaces") },
handler: async (ctx, args) => {
const currentUserId = identity.subject;
// 1. Get all accessible channels
const allChannels = await ctx.db
.query("channels")
.withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
.collect();
const accessibleChannels = allChannels.filter((channel) => {
if (channel.type === "direct") {
return channel.participantIds?.includes(currentUserId);
}
return true;
});
// 2. Get all lastRead records for user
const lastReadRecords = await ctx.db
.query("lastRead")
.withIndex("by_user", (q) => q.eq("userId", currentUserId))
.collect();
const lastReadMap = new Map(
lastReadRecords.map((r) => [r.channelId, r.lastReadAt])
);
// 3. Run all channel queries in PARALLEL
const countPromises = accessibleChannels.map(async (channel) => {
const lastReadAt = lastReadMap.get(channel._id) || 0;
// Count unread (messages after lastRead, not by current user)
const unreadMessages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channel._id))
.filter((q) =>
q.and(
q.gt(q.field("timestamp"), lastReadAt),
q.neq(q.field("authorId"), currentUserId)
)
)
.collect();
// Get latest message for preview
const latestMsg = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channel._id))
.order("desc")
.first();
return {
id: channel._id,
count: unreadMessages.length,
preview: latestMsg ? latestMsg.content.substring(0, 50) : "",
};
});
const results = await Promise.all(countPromises);
// 4. Aggregate results
const channels: Record<string, number> = {};
const previews: Record<string, string> = {};
let total = 0;
for (const res of results) {
if (res.count > 0) {
channels[res.id] = res.count;
total += res.count;
}
if (res.preview) {
previews[res.id] = res.preview;
}
}
return { channels, total, previews };
},
});File: components/chat/chat-system.tsx
ChatSystem (main container)
├── Chat Bubbles
│ ├── ChatBubbleComponent (base bubble)
│ └── ChatBubbleWithPreview (enriched bubble)
├── ChatWindow (open conversation)
├── NewDmModal (create DM)
└── Message Preview Popup
Main container that manages chat state and renders all sub-components.
export function ChatSystem({ workspaceId }: ChatSystemProps) {
const { user } = useUser();
const [chatBubbles, setChatBubbles] = useState<ChatBubble[]>([]);
const [openChatWindow, setOpenChatWindow] = useState<Id<"channels"> | null>(null);
const [showBubbles, setShowBubbles] = useState(false);
// Convex queries (real-time subscriptions)
const workspaceChannels = useQuery(api.channels.getWorkspaceChannels, { workspaceId });
const directChannels = useQuery(api.channels.getDirectChannels, { workspaceId });
const unreadCountsData = useQuery(api.messages.getUnreadCounts, { workspaceId });
// Convert to Maps for efficient lookup
const unreadCounts = new Map(/* ... */);
const previews = new Map(/* ... */);
const totalUnreadCount = unreadCountsData?.total || 0;
// Auto-add general channel when bubbles open
useEffect(() => {
if (generalChannel && showBubbles && !hasInitializedGeneral.current) {
setChatBubbles((prev) => [{
channelId: generalChannel._id,
name: "General",
type: "general",
}, ...prev]);
}
}, [generalChannel, showBubbles]);
// Auto-add DM bubbles when new messages arrive
useEffect(() => {
directChannels?.forEach((dm) => {
const hasUnread = (unreadCounts.get(dm._id) || 0) > 0;
// Add bubble if not exists and has unread messages
if (!exists && hasUnread) {
setChatBubbles((prev) => [...prev, {
channelId: dm._id,
name: dm.otherUserName,
type: "direct",
otherUserId: dm.otherUserId,
}]);
}
});
}, [directChannels, unreadCounts]);
return (
<>
{/* Chat Window */}
{openChatWindow && <ChatWindow chat={currentChat} onClose={closeChat} />}
{/* Chat Bubbles */}
{showBubbles && (
<div className="fixed bottom-24 right-6">
<ChatBubbleComponent type="new-dm" onClick={handleNewDmClick} />
{chatBubbles.map((bubble) => (
<ChatBubbleWithPreview
bubble={bubble}
unreadCount={unreadCounts.get(bubble.channelId)}
preview={previews.get(bubble.channelId)}
/>
))}
</div>
)}
{/* Floating Action Button */}
<button onClick={toggleBubbles} className="fixed bottom-6 right-6">
<MessageCircle />
{totalUnreadCount > 0 && <Badge>{totalUnreadCount}</Badge>}
</button>
{/* New DM Modal */}
{showNewDmModal && <NewDmModal onChatCreated={addDmBubble} />}
</>
);
}Displays the conversation for an open channel.
function ChatWindow({ chat, onClose, workspaceId }: ChatWindowProps) {
const { user } = useUser();
const [message, setMessage] = useState("");
// Convex hooks
const messages = useQuery(api.messages.getMessagesWithAuthors, {
channelId: chat.channelId,
});
const sendMessage = useMutation(api.messages.sendMessage);
const toggleReaction = useMutation(api.messages.toggleReaction);
const markAsRead = useMutation(api.messages.markChannelAsRead);
// Mark channel as read when window opens
useEffect(() => {
markAsRead({ channelId: chat.channelId });
}, [chat.channelId]);
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSendMessage = async () => {
if (!message.trim()) return;
await sendMessage({ channelId: chat.channelId, content: message.trim() });
setMessage("");
};
return (
<div className="fixed bottom-6 right-24 w-80 h-[500px]">
{/* Header */}
<div className="p-3 bg-gradient-to-r from-blue-600 to-indigo-600">
<span>{chat.name}</span>
<button onClick={onClose}><X /></button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
{messages?.map((msg) => (
<MessageBubble
key={msg._id}
message={msg}
isOwn={msg.authorId === user?.id}
onReaction={handleReaction}
/>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t">
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSendMessage()}
/>
<button onClick={handleSendMessage}><Send /></button>
</div>
</div>
);
}Allows users to start a new direct message conversation.
function NewDmModal({ workspaceId, onClose, onChatCreated }: NewDmModalProps) {
const [searchTerm, setSearchTerm] = useState("");
const { memberships } = useOrganization({ memberships: { pageSize: 50 } });
const createOrGetDM = useMutation(api.channels.createOrGetDirectChannel);
const handleSelectUser = async (userId: string, userName: string) => {
// Create or get existing DM channel
const channelId = await createOrGetDM({
workspaceId,
otherUserId: userId,
});
// Add chat bubble and open window
onChatCreated({
channelId,
name: userName,
type: "direct",
otherUserId: userId,
});
};
return (
<div className="modal">
<h2>New Direct Message</h2>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search members..."
/>
<div className="member-list">
{filteredMembers.map((member) => (
<button onClick={() => handleSelectUser(member.userId, member.name)}>
{member.name}
</button>
))}
</div>
</div>
);
}User types message → clicks Send/presses Enter
│
▼
handleSendMessage() called
│
▼
sendMessage mutation invoked
│
▼
┌─────────────────────────────┐
│ Convex Backend │
│ 1. Authenticate user │
│ 2. Verify channel access │
│ 3. Get author name │
│ 4. Insert into messages │
│ table with timestamp │
└─────────────────────────────┘
│
▼
Convex subscription triggers
│
▼
All connected clients receive update
│
▼
UI re-renders with new message
New message inserted in DB
│
▼
Convex detects change to messages table
│
▼
All active useQuery subscriptions notified
│
▼
getMessagesWithAuthors query re-runs
│
▼
React component receives new messages array
│
▼
UI re-renders, auto-scrolls to bottom
Message sent by User A
│
▼
User B's getUnreadCounts query re-runs
│
▼
Query calculates: messages.timestamp > lastRead.lastReadAt
│
▼
Unread count incremented
│
▼
Badge updates on chat bubble
│
▼
User B opens chat window
│
▼
markChannelAsRead mutation called
│
▼
lastRead.lastReadAt = Date.now()
│
▼
getUnreadCounts re-runs, count = 0
│
▼
Badge disappears
User clicks "New Message" → searches for member
│
▼
User selects member → handleSelectUser()
│
▼
createOrGetDirectChannel mutation
│
▼
┌─────────────────────────────────────┐
│ 1. Get current user ID │
│ 2. Sort [currentUser, otherUser] │
│ 3. Check if DM already exists │
│ 4. If exists: return existing ID │
│ 5. If not: create new channel │
└─────────────────────────────────────┘
│
▼
Channel ID returned to frontend
│
▼
Chat bubble added, window opens
Messages support three reaction types:
- 👍 Like
- 👎 Dislike
- 😂 Haha
How it works:
-
Reactions stored as object with arrays of user IDs:
reactions: { like: ["user_1", "user_3"], haha: ["user_2"], }
-
Toggle behavior: clicking a reaction adds/removes the user from the array
-
UI shows count and highlights if current user has reacted
Why cursor-based?
- Only stores ONE timestamp per user per channel
- O(1) write on read (update single timestamp)
- Counts unread by comparing message.timestamp > lastReadAt
- Much more efficient than tracking individual message read status
Alternative rejected: Per-message read tracking would require N writes per message (one per recipient), which doesn't scale.
- General channel: Always appears when bubbles are shown, cannot be removed
- DM bubbles: Auto-appear when new messages arrive
- Closed bubbles: Tracked in
closedBubblesRefto prevent re-appearing unless new messages arrive - Bubble removal: Removes from state but not from database (channel still exists)
Before: N subscriptions (one per channel) for unread counts
After: Single getUnreadCounts query returns all counts + previews
// One query replaces many:
const unreadCountsData = useQuery(api.messages.getUnreadCounts, { workspaceId });
// Returns:
{
channels: { "channel_1": 5, "channel_2": 2 },
total: 7,
previews: { "channel_1": "Hey, are you...", "channel_2": "Meeting at..." }
}In getUnreadCounts, all channel queries run in parallel using Promise.all:
const countPromises = accessibleChannels.map(async (channel) => {
// These run in parallel!
const unread = await ctx.db.query("messages")...
const latest = await ctx.db.query("messages")...
return { count, preview };
});
const results = await Promise.all(countPromises);getMessagesWithAuthors batches author lookups:
// Get unique authors (deduplication)
const authorIds = [...new Set(messages.map((m) => m.authorId))];
// Single batch lookup instead of N queries
const authors = await Promise.all(authorIds.map(...));workspaceIdis stored onmessagesfor faster workspace-level queriesauthorNameis cached on messages for backwards compatibility- This trades storage for read performance
| File | Purpose |
|---|---|
| convex/schema.ts | Database schema definitions |
| convex/channels.ts | Channel CRUD operations |
| convex/messages.ts | Message operations + unread tracking |
| components/chat/chat-system.tsx | Main chat UI components |
The VBase chat system is a real-time messaging solution built on Convex's reactive database. Key characteristics:
- Real-time by default: All data uses Convex subscriptions (useQuery)
- Cursor-based read tracking: Efficient unread count calculation
- Permission-aware: DMs check participant access, general channels open to all
- Optimized queries: Single aggregated query for unread counts
- Floating bubble UI: Non-intrusive chat interface with message previews
The system is designed for collaborative workspaces and handles both group (general) and private (direct) messaging with support for reactions and future extensibility (threaded replies, attachments).