Vibe coding with AI course ยท Module 8: Slack Clone
Workspace and Channels - Team Communication
In this lesson6
Welcome aboard! On a ship, orders travel from mouth to mouth and half of them get lost on the way. We'll build Ship's Comms, a communication system for pirate crews modeled on Slack: with conversations in channels, direct messages, threads and mentions.
What you will learn
- model a workspace, members, channels and messages,
- tell public channels from private ones,
- build a sidebar with channels and statuses,
- create a channel header.
Slack vs other platforms
Twitter and Instagram are public social media: you broadcast to your followers, and they react, share and comment. LinkedIn is professional profiles, job offers and formal posts. Slack is a team's private workspace, with channels for projects and topics, real-time chat, threads, files, and mentions with notifications.
The workspace model
A workspace is the space of one crew, with its own address such as blackpearl.slack.com. Four interfaces describe it:
interface Workspace {
id: string;
name: string; // "Black Pearl Crew"
icon: string;
url: string; // "blackpearl.slack.com"
members: Member[];
channels: Channel[];
directMessages: DirectMessage[];
createdBy: string;
createdAt: Date;
}
interface Member {
id: string;
name: string;
avatar: string;
role: 'owner' | 'admin' | 'member' | 'guest';
status: 'online' | 'away' | 'offline';
statusText?: string; // "In a meeting"
timezone: string;
}
interface Channel {
id: string;
name: string; // "general", "treasure-hunts"
description: string;
isPrivate: boolean;
members: string[]; // member IDs
messages: Message[];
createdBy: string;
createdAt: Date;
}
interface DirectMessage {
id: string;
participants: string[]; // 2 member IDs
messages: Message[];
lastReadAt: { [userId: string]: Date };
}Workspace contains members (members), channels (channels) and direct messages (directMessages). Every Member has a role: owner, admin, member or guest, from the most permissions to the fewest, and a status of online, away or offline. A guest sees only the channels they were added to. Channel has a name, a description, an isPrivate flag, a list of members and messages. DirectMessage connects exactly two people, so participants holds two member ids. Slack also has group conversations of up to nine people, but our model only handles conversations between two. It's still just a description of data, with no view.
The message model
A message carries more than just text:
interface Message {
id: string;
channelId: string;
authorId: string;
content: string;
mentions: string[]; // @user IDs
attachments?: Attachment[];
reactions: Reaction[];
// Threading
threadId?: string; // parent message ID
replies: Message[];
replyCount: number;
isEdited: boolean;
isPinned: boolean;
timestamp: Date;
}
interface Attachment {
id: string;
type: 'image' | 'video' | 'file' | 'link';
url: string;
name: string;
size: number;
thumbnail?: string;
}
interface Reaction {
emoji: string;
users: string[]; // user IDs who reacted
count: number;
}Besides the content and the author (authorId), there are mentions, attachments, reactions and thread data: threadId points to the parent message, and replyCount counts the replies. A reaction is an emoji, a list of users and a count. You'll use these fields in the next lessons, for now they only describe data.
The sidebar
The sidebar is the ship's map. From the top: the workspace header with the name and the number of people online, quick links (Threads, All DMs), the channel list, direct messages, and at the bottom the user profile.
'use client';
import { useState } from 'react';
interface SidebarProps {
workspace: Workspace;
currentUserId: string;
onChannelSelect: (channelId: string) => void;
onDMSelect: (dmId: string) => void;
}
export default function Sidebar({
workspace,
currentUserId,
onChannelSelect,
onDMSelect
}: SidebarProps) {
const [showAllChannels, setShowAllChannels] = useState(false);
const unreadDMs = workspace.directMessages.filter(dm => {
const lastRead = dm.lastReadAt[currentUserId];
const lastMessage = dm.messages[dm.messages.length - 1];
return lastMessage && (!lastRead || lastMessage.timestamp > lastRead);
});
return (
<div className="w-64 bg-purple-900 text-white flex flex-col h-screen">
{/* Workspace Header */}
<div className="p-4 border-b border-purple-800">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{workspace.icon}</span>
<h2 className="font-bold text-lg">{workspace.name}</h2>
</div>
<div className="flex items-center gap-2 text-sm opacity-80">
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
<span>{workspace.members.filter(m => m.status === 'online').length} online</span>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto p-2">
{/* Quick Links */}
<div className="mb-4">
<button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
<span>Comment</span>
<span>Threads</span>
</button>
<button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
<span>Message</span>
<span>All DMs</span>
{unreadDMs.length > 0 && (
<span className="ml-auto bg-red-500 text-xs px-2 py-0.5 rounded-full">
{unreadDMs.length}
</span>
)}
</button>
<button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
<span>Saved</span>
<span>Saved items</span>
</button>
</div>
{/* Channels */}
<div className="mb-4">
<button
onClick={() => setShowAllChannels(!showAllChannels)}
className="w-full text-left px-3 py-1 text-sm font-semibold opacity-80 hover:opacity-100 flex items-center gap-1"
>
<span className={`transform transition ${showAllChannels ? 'rotate-90' : ''}`}>โถ</span>
Channels
</button>
{showAllChannels && (
<div className="mt-1">
{workspace.channels.map(channel => (
<button
key={channel.id}
onClick={() => onChannelSelect(channel.id)}
className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2"
>
<span>{channel.isPrivate ? '[private]' : '#'}</span>
<span>{channel.name}</span>
</button>
))}
<button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded text-sm opacity-80">
+ Add channels
</button>
</div>
)}
</div>
{/* Direct Messages */}
<div>
<div className="px-3 py-1 text-sm font-semibold opacity-80">
Direct Messages
</div>
<div className="mt-1">
{workspace.directMessages.slice(0, 5).map(dm => {
const otherUser = workspace.members.find(
m => dm.participants.includes(m.id) && m.id !== currentUserId
);
if (!otherUser) return null;
return (
<button
key={dm.id}
onClick={() => onDMSelect(dm.id)}
className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2"
>
<div className="relative">
<img
src={otherUser.avatar}
alt={otherUser.name}
className="w-6 h-6 rounded"
/>
<div
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border border-purple-900 ${
otherUser.status === 'online' ? 'bg-green-400' :
otherUser.status === 'away' ? 'bg-yellow-400' : 'bg-gray-400'
}`}
/>
</div>
<span>{otherUser.name}</span>
</button>
);
})}
</div>
</div>
</div>
{/* User Profile */}
<div className="p-3 border-t border-purple-800">
{(() => {
const currentUser = workspace.members.find(m => m.id === currentUserId);
if (!currentUser) return null;
return (
<div className="flex items-center gap-2">
<div className="relative">
<img
src={currentUser.avatar}
alt={currentUser.name}
className="w-8 h-8 rounded"
/>
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-purple-900"></div>
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate">{currentUser.name}</div>
<div className="text-xs opacity-80 truncate">
{currentUser.statusText || 'Active'}
</div>
</div>
<button className="hover:bg-purple-800 p-1 rounded">Settings</button>
</div>
);
})()}
</div>
</div>
);
}A public channel has a # sign before its name, so treasure-hunts is shown as "# treasure-hunts", and any workspace member can find it and join. A private one gets a padlock icon instead of #, and only invited members can see it. We picked the status colors ourselves: a green dot next to the avatar means online, yellow means away, and gray means offline. The header counts people online with filter(m => m.status === 'online').length, and the footer shows the current user's statusText or the label Active. unreadDMs picks conversations with messages newer than the last visit (lastReadAt), and their number lands on a red badge. The component only reads data and changes nothing in the workspace.
The channel header
Above the conversation hangs a header, like a sign on a cabin door:
interface ChannelHeaderProps {
channel: Channel;
memberCount: number;
onAddPeople: () => void;
onShowDetails: () => void;
}
export default function ChannelHeader({
channel,
memberCount,
onAddPeople,
onShowDetails
}: ChannelHeaderProps) {
return (
<div className="border-b bg-white px-6 py-3">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h2 className="text-xl font-bold">
{channel.isPrivate ? '[private]' : '#'} {channel.name}
</h2>
<button className="hover:bg-gray-100 p-1 rounded">Favorite</button>
</div>
<p className="text-sm text-gray-600">
{channel.description || 'No description'} โข {memberCount} members
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onAddPeople}
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-50 text-sm font-semibold"
>
+ Add people
</button>
<button
onClick={onShowDetails}
className="p-2 hover:bg-gray-100 rounded"
>
โน
</button>
</div>
</div>
</div>
);
}The header shows the name with # or a padlock, the description and the number of members. When a channel has no description, No description appears in its place. The + Add people button doesn't add anyone by itself, it only calls onAddPeople, and the parent makes the decision.
Captain Redbeard's tip: name channels in lowercase with hyphens, e.g. treasure-hunts or general. A short, readable name is half of the order on deck.
Remember: the workspace is the ship, channels are its rooms, and # or a padlock tells you who may enter. In the editor below you have a finished sidebar, so play with the statuses and move on to the tasks.
Code for this lesson: App.jsx
// Workspace Sidebar Navigation
import { useState } from 'react';
const workspace = {
name: 'Black Pearl Crew',
icon: '',
onlineCount: 12,
channels: [
{ id: 1, name: 'general', isPrivate: false },
{ id: 2, name: 'treasure-hunts', isPrivate: false },
{ id: 3, name: 'ship-repairs', isPrivate: false },
{ id: 4, name: 'secret-plans', isPrivate: true }
],
directMessages: [
{ id: 1, name: 'Jack Sparrow', avatar: '', status: 'online', unread: 2 },
{ id: 2, name: 'Will Turner', avatar: '', status: 'away', unread: 0 },
{ id: 3, name: 'Elizabeth Swan', avatar: '', status: 'offline', unread: 0 }
]
};
export default function App() {
const [showChannels, setShowChannels] = useState(true);
const [activeChannel, setActiveChannel] = useState(null);
const [activeDM, setActiveDM] = useState(null);
return (
<div className="flex h-screen bg-gray-100">
{/* Sidebar */}
<div className="w-64 bg-purple-900 text-white flex flex-col">
{/* Workspace Header */}
<div className="p-4 border-b border-purple-800">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{workspace.icon}</span>
<h2 className="font-bold text-lg">{workspace.name}</h2>
</div>
<div className="flex items-center gap-2 text-sm opacity-80">
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
<span>{workspace.onlineCount} online</span>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto p-2">
{/* Quick Links */}
<div className="mb-4">
<button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
<span>Comment</span>
<span>Threads</span>
</button>
<button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
<span>Message</span>
<span>All DMs</span>
<span className="ml-auto bg-red-500 text-xs px-2 py-0.5 rounded-full">2</span>
</button>
<button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded flex items-center gap-2">
<span>Saved</span>
<span>Saved items</span>
</button>
</div>
{/* Channels */}
<div className="mb-4">
<button
onClick={() => setShowChannels(!showChannels)}
className="w-full text-left px-3 py-1 text-sm font-semibold opacity-80 hover:opacity-100 flex items-center gap-1"
>
<span className={`transform transition ${showChannels ? 'rotate-90' : ''}`}>โถ</span>
Channels
</button>
{showChannels && (
<div className="mt-1">
{workspace.channels.map(channel => (
<button
key={channel.id}
onClick={() => setActiveChannel(channel.id)}
className={`w-full text-left px-3 py-1.5 rounded flex items-center gap-2 ${
activeChannel === channel.id ? 'bg-blue-600' : 'hover:bg-purple-800'
}`}
>
<span>{channel.isPrivate ? '[private]' : '#'}</span>
<span>{channel.name}</span>
</button>
))}
<button className="w-full text-left px-3 py-1.5 hover:bg-purple-800 rounded text-sm opacity-80">
+ Add channels
</button>
</div>
)}
</div>
{/* Direct Messages */}
<div>
<div className="px-3 py-1 text-sm font-semibold opacity-80">
Direct Messages
</div>
<div className="mt-1">
{workspace.directMessages.map(dm => (
<button
key={dm.id}
onClick={() => setActiveDM(dm.id)}
className={`w-full text-left px-3 py-1.5 rounded flex items-center gap-2 ${
activeDM === dm.id ? 'bg-blue-600' : 'hover:bg-purple-800'
}`}
>
<div className="relative text-xl">
{dm.avatar}
<div
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border border-purple-900 ${
dm.status === 'online' ? 'bg-green-400' :
dm.status === 'away' ? 'bg-yellow-400' : 'bg-gray-400'
}`}
/>
</div>
<span className="flex-1">{dm.name}</span>
{dm.unread > 0 && (
<span className="bg-red-500 text-xs px-1.5 py-0.5 rounded-full">
{dm.unread}
</span>
)}
</button>
))}
</div>
</div>
</div>
{/* User Profile */}
<div className="p-3 border-t border-purple-800">
<div className="flex items-center gap-2">
<div className="text-2xl">โ</div>
<div className="flex-1">
<div className="font-semibold text-sm">Captain Redbeard</div>
<div className="text-xs opacity-80">Active</div>
</div>
<button className="hover:bg-purple-800 p-1 rounded">Settings</button>
</div>
</div>
</div>
{/* Main Area */}
<div className="flex-1 flex items-center justify-center text-gray-500">
Select a channel or DM to start chatting
</div>
</div>
);
}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does a Workspace contain in Slack?
2. Difference between a public and a private channel?
These are 2 of 7 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Slack workspace navigation
- Click in order
Build a reference to the public channel treasure-hunts (click the tokens):
- Click in order
Build the private channel name the way the sidebar shows it (click only the elements you need):
- Vertical ordering
Order the workspace roles from highest permissions to lowest:
- Code editor
Channel header component
- Horizontal ordering
Arrange the workspace URL blackpearl.slack.com (one line, left to right):
- Vertical ordering
Arrange the Sidebar sections from top to bottom:
- Click in order
Build the displayed public channel name by clicking tokens: