Vibe coding with AI course Β· Module 9: Discord Clone

Voice Channels and Rich Presence

8 min read
In this lesson5

Before boarding an enemy ship, nobody has time to type in the chat: the crew has to talk live, and the captain wants to see who is on deck and what they are doing. Discord solves this with voice channels you walk into like a cabin, and with rich presence, an in-game activity visible to the whole guild.

Voice connection state

Every person in a voice channel has a voice state, described by the VoiceState type:

interface VoiceState {
  userId: string;
  channelId: string;
  serverId: string;

  isMuted: boolean; // mic muted
  isDeafened: boolean; // sound off
  isSelfMuted: boolean;
  isSelfDeafened: boolean;
  isSuppressed: boolean; // muted by server

  isStreaming: boolean; // screen share
  isVideo: boolean; // camera on

  sessionId: string;
  joinedAt: Date;
}

interface VoiceChannel extends Channel {
  type: 'voice';
  userLimit?: number; // 0 = unlimited
  bitrate: number; // kbps

  connectedUsers: VoiceState[];
}

VoiceState contains, among others, isMuted, isDeafened, isStreaming, isVideo and channelId. isMuted turns off the microphone, so others can't hear you, and isDeafened turns off the sound, so you can't hear others. The fields with the word Self mean muting by the user themselves, and isSuppressed muting by the server. In a voice channel, userLimit?: number is an optional limit of people, and 0 means no limit.

How joining works

Joining has four steps: click Join Voice on the channel, connect over WebRTC, add the user to the connectedUsers array and show the voice controls (mute, deafen). WebRTC is the browser technology for sending sound and video in real time. Before anything flows, the browser needs permission to use the microphone:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const [microphone] = stream.getAudioTracks();

microphone.enabled = false; // mute: others hear silence

getUserMedia asks the user for permission and works only on HTTPS pages or on localhost. A disabled track sends silence, and the connection stays open. In bigger channels the conversation doesn't flow directly between browsers but through an intermediate server, and that is how Discord works too.

The voice channel view

The component shows a header with the number of people, the list of connected users and the controls:

'use client';

import { useState } from 'react';

interface VoiceChannelViewProps {
  channel: VoiceChannel;
  currentUserId: string;
  onJoin: () => void;
  onLeave: () => void;
  onToggleMute: () => void;
  onToggleDeafen: () => void;
}

export default function VoiceChannelView({
  channel,
  currentUserId,
  onJoin,
  onLeave,
  onToggleMute,
  onToggleDeafen
}: VoiceChannelViewProps) {
  const currentUserVoice = channel.connectedUsers.find(v => v.userId === currentUserId);
  const isConnected = !!currentUserVoice;

  return (
    <div className="bg-gray-700 rounded-lg p-4">
      {/* Channel Header */}
      <div className="flex items-center justify-between mb-4">
        <div className="flex items-center gap-2">
          <span className="text-sm font-semibold">Sound</span>
          <div>
            <h3 className="font-bold text-white">{channel.name}</h3>
            <p className="text-xs text-gray-400">
              {channel.connectedUsers.length}
              {channel.userLimit ? ` / ${channel.userLimit}` : ''} connected
            </p>
          </div>
        </div>

        {!isConnected ? (
          <button
            onClick={onJoin}
            disabled={!!channel.userLimit && channel.connectedUsers.length >= channel.userLimit}
            className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded font-semibold"
          >
            Join Voice
          </button>
        ) : (
          <button
            onClick={onLeave}
            className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
          >
            Disconnect
          </button>
        )}
      </div>

      {/* Connected Users */}
      <div className="space-y-2">
        {channel.connectedUsers.map(voice => (
          <div
            key={voice.userId}
            className={`flex items-center gap-3 p-2 rounded ${
              voice.userId === currentUserId ? 'bg-gray-600' : 'bg-gray-800'
            }`}
          >
            <div className="relative">
              <img
                src={`/users/${voice.userId}/avatar.jpg`}
                alt="User avatar"
                className={`w-10 h-10 rounded-full ${
                  voice.isSpeaking ? 'ring-2 ring-green-500' : ''
                }`}
              />
              {voice.isStreaming && (
                <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-purple-600 rounded-full flex items-center justify-center">
                  <span className="text-xs">TV</span>
                </div>
              )}
              {voice.isVideo && (
                <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
                  <span className="text-xs">Video</span>
                </div>
              )}
            </div>

            <div className="flex-1 min-w-0">
              <div className="text-white text-sm font-medium truncate">
                {voice.username}
                {voice.userId === currentUserId && (
                  <span className="text-gray-400"> (You)</span>
                )}
              </div>
            </div>

            {/* Status Icons */}
            <div className="flex items-center gap-1 text-gray-400">
              {voice.isMuted || voice.isSelfMuted ? (
                <span className="text-red-500">Muted</span>
              ) : (
                <span>Mic</span>
              )}
              {voice.isDeafened || voice.isSelfDeafened ? (
                <span className="text-red-500">Muted</span>
              ) : (
                <span>Headphones</span>
              )}
            </div>
          </div>
        ))}
      </div>

      {/* Voice Controls (if connected) */}
      {isConnected && (
        <div className="mt-4 pt-4 border-t border-gray-600 flex items-center justify-center gap-3">
          <button
            onClick={onToggleMute}
            className={`p-3 rounded-full ${
              currentUserVoice.isSelfMuted
                ? 'bg-red-600 hover:bg-red-700'
                : 'bg-gray-600 hover:bg-gray-500'
            }`}
            title={currentUserVoice.isSelfMuted ? 'Unmute' : 'Mute'}
          >
            {currentUserVoice.isSelfMuted ? 'Muted' : 'Mic'}
          </button>

          <button
            onClick={onToggleDeafen}
            className={`p-3 rounded-full ${
              currentUserVoice.isSelfDeafened
                ? 'bg-red-600 hover:bg-red-700'
                : 'bg-gray-600 hover:bg-gray-500'
            }`}
            title={currentUserVoice.isSelfDeafened ? 'Undeafen' : 'Deafen'}
          >
            {currentUserVoice.isSelfDeafened ? 'Muted' : 'Headphones'}
          </button>

          <button
            className="p-3 rounded-full bg-gray-600 hover:bg-gray-500"
            title="Screen Share"
          >
            TV
          </button>

          <button
            className="p-3 rounded-full bg-gray-600 hover:bg-gray-500"
            title="Video"
          >
            Video
          </button>
        </div>
      )}
    </div>
  );
}

The Join Voice button is disabled when the channel has reached userLimit. A person who is speaking (isSpeaking) gets a green ring around the avatar thanks to the ring-2 ring-green-500 classes. The controls appear only after connecting. The view also reads voice.isSpeaking and voice.username, which are not in the VoiceState type, so in a full project add them to the type, otherwise TypeScript will report an error.

Rich presence

Rich presence is displaying a gaming activity with details, timestamps and party information:

interface Activity {
  type: 'playing' | 'streaming' | 'listening' | 'watching' | 'custom';
  name: string; // "Sea of Thieves"

  // Game specific
  details?: string; // "In a Battle"
  state?: string; // "4 players"
  timestamps?: {
    start?: number; // Unix timestamp
    end?: number;
  };

  // Assets (images)
  assets?: {
    largeImage?: string;
    largeText?: string;
    smallImage?: string;
    smallText?: string;
  };

  // Party info
  party?: {
    id: string;
    size: [number, number]; // [current, max]
  };

  // For streaming
  url?: string; // Twitch/YouTube URL
}

interface UserPresence {
  userId: string;
  status: 'online' | 'idle' | 'dnd' | 'offline';
  activities: Activity[];
  customStatus?: {
    text: string;
    emoji?: string;
    expiresAt?: Date;
  };
}

The type field in the Activity interface takes the values playing, streaming, listening, watching and custom, and Discord's API also has competing. party.size is a [current, max] array: the current and maximum number of players. You read the start time with activity.timestamps.start, in milliseconds since 1970. customStatus in UserPresence contains text, an optional emoji and an expiry date, expiresAt.

Activity and player profile

ActivityDisplay turns an activity into a card, and UserProfileModal builds a profile out of the cards:

function ActivityDisplay({ activity }: { activity: Activity }) {
  const getActivityIcon = () => {
    switch (activity.type) {
      case 'playing': return '';
      case 'streaming': return '';
      case 'listening': return '';
      case 'watching': return '';
      case 'custom': return activity.customStatus?.emoji || '';
    }
  };

  const getActivityPrefix = () => {
    switch (activity.type) {
      case 'playing': return 'Playing';
      case 'streaming': return 'Streaming';
      case 'listening': return 'Listening to';
      case 'watching': return 'Watching';
      default: return '';
    }
  };

  const getElapsedTime = () => {
    if (!activity.timestamps?.start) return null;

    const elapsed = Date.now() - activity.timestamps.start;
    const minutes = Math.floor(elapsed / 60000);
    const hours = Math.floor(minutes / 60);

    if (hours > 0) {
      return `${hours}:${(minutes % 60).toString().padStart(2, '0')} elapsed`;
    }
    return `${minutes}:${Math.floor((elapsed % 60000) / 1000).toString().padStart(2, '0')} elapsed`;
  };

  return (
    <div className="bg-gray-700 rounded p-3 mb-2">
      <div className="flex items-start gap-3">
        {activity.assets?.largeImage && (
          <img
            src={activity.assets.largeImage}
            alt={activity.assets.largeText || activity.name}
            className="w-16 h-16 rounded"
          />
        )}

        <div className="flex-1 min-w-0">
          <div className="text-xs font-semibold text-gray-400 mb-1">
            {getActivityIcon()} {getActivityPrefix()}
          </div>

          <div className="font-semibold text-white text-sm mb-1">
            {activity.name}
          </div>

          {activity.details && (
            <div className="text-xs text-gray-400">{activity.details}</div>
          )}

          {activity.state && (
            <div className="text-xs text-gray-400">{activity.state}</div>
          )}

          {activity.party && (
            <div className="text-xs text-gray-400">
              {activity.party.size[0]} of {activity.party.size[1]} in party
            </div>
          )}

          {getElapsedTime() && (
            <div className="text-xs text-gray-400 mt-1">
              {getElapsedTime()}
            </div>
          )}
        </div>
      </div>

      {activity.type === 'streaming' && activity.url && (
        <button className="w-full mt-2 py-1.5 bg-purple-600 hover:bg-purple-700 rounded text-sm font-semibold text-white">
          Watch Stream
        </button>
      )}
    </div>
  );
}

export default function UserProfileModal({ user, presence }: {
  user: Member;
  presence: UserPresence;
}) {
  return (
    <div className="w-80 bg-gray-800 rounded-lg overflow-hidden">
      {/* Banner */}
      {user.user.banner && (
        <div
          className="h-24"
          style={{
            backgroundImage: `url(${user.user.banner})`,
            backgroundSize: 'cover'
          }}
        />
      )}

      {/* Profile */}
      <div className="p-4">
        <div className="flex items-start gap-3 -mt-8 mb-4">
          <div className="relative">
            <img
              src={user.user.avatar}
              alt={user.user.username}
              className="w-20 h-20 rounded-full border-4 border-gray-800"
            />
            <div
              className={`absolute bottom-0 right-0 w-6 h-6 rounded-full border-4 border-gray-800 ${
                presence.status === 'online' ? 'bg-green-500' :
                presence.status === 'idle' ? 'bg-yellow-500' :
                presence.status === 'dnd' ? 'bg-red-500' : 'bg-gray-500'
              }`}
            />
          </div>
        </div>

        <h3 className="text-xl font-bold text-white mb-1">
          {user.nickname || user.user.username}
        </h3>
        <p className="text-sm text-gray-400 mb-4">
          {user.user.username}#{user.user.discriminator}
        </p>

        {/* Custom Status */}
        {presence.customStatus && (
          <div className="bg-gray-700 rounded p-2 mb-4 flex items-center gap-2">
            {presence.customStatus.emoji && (
              <span className="text-xl">{presence.customStatus.emoji}</span>
            )}
            <span className="text-sm text-white">{presence.customStatus.text}</span>
          </div>
        )}

        {/* Activities */}
        {presence.activities.length > 0 && (
          <div className="mb-4">
            <h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">
              Activity
            </h4>
            {presence.activities.map((activity, idx) => (
              <ActivityDisplay key={idx} activity={activity} />
            ))}
          </div>
        )}

        {/* Member Since */}
        <div className="text-xs text-gray-400">
          Member since {user.joinedAt.toLocaleDateString()}
        </div>
      </div>
    </div>
  );
}

getElapsedTime reads activity.timestamps.start, calculates elapsed = Date.now() - start, converts the result into minutes and hours and returns text in the X:YY elapsed format. The Watch Stream button appears only when activity.type === 'streaming' and activity.url exists. The sections of the modal go from the top: banner, avatar with status, custom status and activities, that is, rich presence.

Captain Redbeard's tip: store the moment of the start, not how much time has passed. The start doesn't change, so every pirate, even after a page refresh, calculates the same playing time from it.

Remember: voice is a connection state plus a stream from the microphone, and rich presence is activity data that the server sends to the rest of the guild. In the editor below you have the Battle Coordination voice channel: join it, mute the microphone and turn off the sound, then solve the tasks.

Code for this lesson: App.jsx
// Voice Channel with Connected Users
import { useState } from 'react';

const channel = {
  name: 'Battle Coordination',
  type: 'voice',
  userLimit: 10,
  connectedUsers: [
    { id: 1, username: 'Jack Sparrow', avatar: '', isMuted: false, isDeafened: false, isSpeaking: true },
    { id: 2, username: 'Will Turner', avatar: '', isMuted: false, isDeafened: false, isSpeaking: false },
    { id: 3, username: 'Elizabeth Swan', avatar: '', isMuted: true, isDeafened: false, isSpeaking: false }
  ]
};

export default function App() {
  const [isConnected, setIsConnected] = useState(false);
  const [isMuted, setIsMuted] = useState(false);
  const [isDeafened, setIsDeafened] = useState(false);

  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-2xl mx-auto">
        <div className="bg-gray-700 rounded-lg p-4">
          {/* Channel Header */}
          <div className="flex items-center justify-between mb-4">
            <div className="flex items-center gap-2">
              <span className="text-sm font-semibold">Sound</span>
              <div>
                <h3 className="font-bold text-white">{channel.name}</h3>
                <p className="text-xs text-gray-400">
                  {channel.connectedUsers.length} / {channel.userLimit} connected
                </p>
              </div>
            </div>

            {!isConnected ? (
              <button
                onClick={() => setIsConnected(true)}
                className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded font-semibold"
              >
                Join Voice
              </button>
            ) : (
              <button
                onClick={() => setIsConnected(false)}
                className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
              >
                Disconnect
              </button>
            )}
          </div>

          {/* Connected Users */}
          <div className="space-y-2">
            {channel.connectedUsers.map(user => (
              <div
                key={user.id}
                className="flex items-center gap-3 p-2 rounded bg-gray-800"
              >
                <div className="relative text-4xl">
                  {user.avatar}
                  {user.isSpeaking && (
                    <div className="absolute inset-0 rounded-full ring-2 ring-green-500"></div>
                  )}
                </div>

                <div className="flex-1">
                  <div className="text-white text-sm font-medium">{user.username}</div>
                </div>

                {/* Status Icons */}
                <div className="flex items-center gap-1 text-gray-400">
                  {user.isMuted ? (
                    <span className="text-red-500">Muted</span>
                  ) : (
                    <span>Mic</span>
                  )}
                  {user.isDeafened ? (
                    <span className="text-red-500">Muted</span>
                  ) : (
                    <span>Headphones</span>
                  )}
                </div>
              </div>
            ))}
          </div>

          {/* Voice Controls */}
          {isConnected && (
            <div className="mt-4 pt-4 border-t border-gray-600 flex items-center justify-center gap-3">
              <button
                onClick={() => setIsMuted(!isMuted)}
                className={`p-3 rounded-full ${
                  isMuted ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-600 hover:bg-gray-500'
                }`}
                title={isMuted ? 'Unmute' : 'Mute'}
              >
                {isMuted ? 'Muted' : 'Mic'}
              </button>

              <button
                onClick={() => setIsDeafened(!isDeafened)}
                className={`p-3 rounded-full ${
                  isDeafened ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-600 hover:bg-gray-500'
                }`}
                title={isDeafened ? 'Undeafen' : 'Deafen'}
              >
                {isDeafened ? 'Muted' : 'Headphones'}
              </button>

              <button className="p-3 rounded-full bg-gray-600 hover:bg-gray-500" title="Screen Share">
                TV
              </button>

              <button className="p-3 rounded-full bg-gray-600 hover:bg-gray-500" title="Video">
                Video
              </button>
            </div>
          )}
        </div>
      </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 VoiceState contain?

  2. 2. How does isMuted differ from isDeafened in VoiceState?

These are 2 of 8 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the steps for joining a voice channel:

  • Click in order

    Click to build the definition of the optional userLimit field:

  • Code editor

    Voice channel component

  • Vertical ordering

    Arrange the sections of the user profile modal (UserProfileModal) top to bottom:

  • Click in order

    Click to build access to the activity's start time:

  • Code editor

    Rich presence component

  • Vertical ordering

    Arrange the steps for computing elapsed time in getElapsedTime():

  • Horizontal ordering

    Arrange the call to the method that gets the current timestamp:

Useful articles