Vibe coding with AI course ยท Module 8: Slack Clone

Workspace and Channels - Team Communication

7 min read
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. 1. What does a Workspace contain in Slack?

  2. 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:

Useful articles