We use cookies to enhance your experience on the site
CodeWorlds

Voice Channels and Rich Presence

Discord is not just about text chat - voice channels, screen share, and gaming integrations are essential!

Voice Channel State

1interface VoiceState {
2  userId: string;
3  channelId: string;
4  serverId: string;
5
6  isMuted: boolean; // mic muted
7  isDeafened: boolean; // sound off
8  isSelfMuted: boolean;
9  isSelfDeafened: boolean;
10  isSuppressed: boolean; // muted by server
11
12  isStreaming: boolean; // screen share
13  isVideo: boolean; // camera on
14
15  sessionId: string;
16  joinedAt: Date;
17}
18
19interface VoiceChannel extends Channel {
20  type: 'voice';
21  userLimit?: number; // 0 = unlimited
22  bitrate: number; // kbps
23
24  connectedUsers: VoiceState[];
25}

Voice Channel UI

1'use client';
2
3import { useState } from 'react';
4
5interface VoiceChannelViewProps {
6  channel: VoiceChannel;
7  currentUserId: string;
8  onJoin: () => void;
9  onLeave: () => void;
10  onToggleMute: () => void;
11  onToggleDeafen: () => void;
12}
13
14export default function VoiceChannelView({
15  channel,
16  currentUserId,
17  onJoin,
18  onLeave,
19  onToggleMute,
20  onToggleDeafen
21}: VoiceChannelViewProps) {
22  const currentUserVoice = channel.connectedUsers.find(v => v.userId === currentUserId);
23  const isConnected = !!currentUserVoice;
24
25  return (
26    <div className="bg-gray-700 rounded-lg p-4">
27      {/* Channel Header */}
28      <div className="flex items-center justify-between mb-4">
29        <div className="flex items-center gap-2">
30          <span className="text-2xl">🔊</span>
31          <div>
32            <h3 className="font-bold text-white">{channel.name}</h3>
33            <p className="text-xs text-gray-400">
34              {channel.connectedUsers.length}
35              {channel.userLimit ? ` / ${channel.userLimit}` : ''} connected
36            </p>
37          </div>
38        </div>
39
40        {!isConnected ? (
41          <button
42            onClick={onJoin}
43            disabled={!!channel.userLimit && channel.connectedUsers.length >= channel.userLimit}
44            className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded font-semibold"
45          >
46            Join Voice
47          </button>
48        ) : (
49          <button
50            onClick={onLeave}
51            className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
52          >
53            Disconnect
54          </button>
55        )}
56      </div>
57
58      {/* Connected Users */}
59      <div className="space-y-2">
60        {channel.connectedUsers.map(voice => (
61          <div
62            key={voice.userId}
63            className={`flex items-center gap-3 p-2 rounded ${
64              voice.userId === currentUserId ? 'bg-gray-600' : 'bg-gray-800'
65            }`}
66          >
67            <div className="relative">
68              <img
69                src={`/users/${voice.userId}/avatar.jpg`}
70                alt="User avatar"
71                className={`w-10 h-10 rounded-full ${
72                  voice.isSpeaking ? 'ring-2 ring-green-500' : ''
73                }`}
74              />
75              {voice.isStreaming && (
76                <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-purple-600 rounded-full flex items-center justify-center">
77                  <span className="text-xs">📺</span>
78                </div>
79              )}
80              {voice.isVideo && (
81                <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
82                  <span className="text-xs">📹</span>
83                </div>
84              )}
85            </div>
86
87            <div className="flex-1 min-w-0">
88              <div className="text-white text-sm font-medium truncate">
89                {voice.username}
90                {voice.userId === currentUserId && (
91                  <span className="text-gray-400"> (You)</span>
92                )}
93              </div>
94            </div>
95
96            {/* Status Icons */}
97            <div className="flex items-center gap-1 text-gray-400">
98              {voice.isMuted || voice.isSelfMuted ? (
99                <span className="text-red-500">🔇</span>
100              ) : (
101                <span>🎤</span>
102              )}
103              {voice.isDeafened || voice.isSelfDeafened ? (
104                <span className="text-red-500">🔇</span>
105              ) : (
106                <span>🎧</span>
107              )}
108            </div>
109          </div>
110        ))}
111      </div>
112
113      {/* Voice Controls (if connected) */}
114      {isConnected && (
115        <div className="mt-4 pt-4 border-t border-gray-600 flex items-center justify-center gap-3">
116          <button
117            onClick={onToggleMute}
118            className={`p-3 rounded-full ${
119              currentUserVoice.isSelfMuted
120                ? 'bg-red-600 hover:bg-red-700'
121                : 'bg-gray-600 hover:bg-gray-500'
122            }`}
123            title={currentUserVoice.isSelfMuted ? 'Unmute' : 'Mute'}
124          >
125            {currentUserVoice.isSelfMuted ? '🔇' : '🎤'}
126          </button>
127
128          <button
129            onClick={onToggleDeafen}
130            className={`p-3 rounded-full ${
131              currentUserVoice.isSelfDeafened
132                ? 'bg-red-600 hover:bg-red-700'
133                : 'bg-gray-600 hover:bg-gray-500'
134            }`}
135            title={currentUserVoice.isSelfDeafened ? 'Undeafen' : 'Deafen'}
136          >
137            {currentUserVoice.isSelfDeafened ? '🔇' : '🎧'}
138          </button>
139
140          <button
141            className="p-3 rounded-full bg-gray-600 hover:bg-gray-500"
142            title="Screen Share"
143          >
144            📺
145          </button>
146
147          <button
148            className="p-3 rounded-full bg-gray-600 hover:bg-gray-500"
149            title="Video"
150          >
151            📹
152          </button>
153        </div>
154      )}
155    </div>
156  );
157}

Rich Presence (Gaming Activity)

1interface Activity {
2  type: 'playing' | 'streaming' | 'listening' | 'watching' | 'custom';
3  name: string; // "Sea of Thieves"
4
5  // Game specific
6  details?: string; // "In a Battle"
7  state?: string; // "4 players"
8  timestamps?: {
9    start?: number; // Unix timestamp
10    end?: number;
11  };
12
13  // Assets (images)
14  assets?: {
15    largeImage?: string;
16    largeText?: string;
17    smallImage?: string;
18    smallText?: string;
19  };
20
21  // Party info
22  party?: {
23    id: string;
24    size: [number, number]; // [current, max]
25  };
26
27  // For streaming
28  url?: string; // Twitch/YouTube URL
29}
30
31interface UserPresence {
32  userId: string;
33  status: 'online' | 'idle' | 'dnd' | 'offline';
34  activities: Activity[];
35  customStatus?: {
36    text: string;
37    emoji?: string;
38    expiresAt?: Date;
39  };
40}

Activity Display

1function ActivityDisplay({ activity }: { activity: Activity }) {
2  const getActivityIcon = () => {
3    switch (activity.type) {
4      case 'playing': return '🎮';
5      case 'streaming': return '📡';
6      case 'listening': return '🎵';
7      case 'watching': return '📺';
8      case 'custom': return activity.customStatus?.emoji || '💬';
9    }
10  };
11
12  const getActivityPrefix = () => {
13    switch (activity.type) {
14      case 'playing': return 'Playing';
15      case 'streaming': return 'Streaming';
16      case 'listening': return 'Listening to';
17      case 'watching': return 'Watching';
18      default: return '';
19    }
20  };
21
22  const getElapsedTime = () => {
23    if (!activity.timestamps?.start) return null;
24
25    const elapsed = Date.now() - activity.timestamps.start;
26    const minutes = Math.floor(elapsed / 60000);
27    const hours = Math.floor(minutes / 60);
28
29    if (hours > 0) {
30      return `${hours}:${(minutes % 60).toString().padStart(2, '0')} elapsed`;
31    }
32    return `${minutes}:${Math.floor((elapsed % 60000) / 1000).toString().padStart(2, '0')} elapsed`;
33  };
34
35  return (
36    <div className="bg-gray-700 rounded p-3 mb-2">
37      <div className="flex items-start gap-3">
38        {activity.assets?.largeImage && (
39          <img
40            src={activity.assets.largeImage}
41            alt={activity.assets.largeText || activity.name}
42            className="w-16 h-16 rounded"
43          />
44        )}
45
46        <div className="flex-1 min-w-0">
47          <div className="text-xs font-semibold text-gray-400 mb-1">
48            {getActivityIcon()} {getActivityPrefix()}
49          </div>
50
51          <div className="font-semibold text-white text-sm mb-1">
52            {activity.name}
53          </div>
54
55          {activity.details && (
56            <div className="text-xs text-gray-400">{activity.details}</div>
57          )}
58
59          {activity.state && (
60            <div className="text-xs text-gray-400">{activity.state}</div>
61          )}
62
63          {activity.party && (
64            <div className="text-xs text-gray-400">
65              {activity.party.size[0]} of {activity.party.size[1]} in party
66            </div>
67          )}
68
69          {getElapsedTime() && (
70            <div className="text-xs text-gray-400 mt-1">
71              {getElapsedTime()}
72            </div>
73          )}
74        </div>
75      </div>
76
77      {activity.type === 'streaming' && activity.url && (
78        <button className="w-full mt-2 py-1.5 bg-purple-600 hover:bg-purple-700 rounded text-sm font-semibold text-white">
79          Watch Stream
80        </button>
81      )}
82    </div>
83  );
84}
85
86export default function UserProfileModal({ user, presence }: {
87  user: Member;
88  presence: UserPresence;
89}) {
90  return (
91    <div className="w-80 bg-gray-800 rounded-lg overflow-hidden">
92      {/* Banner */}
93      {user.user.banner && (
94        <div
95          className="h-24"
96          style={{
97            backgroundImage: `url(${user.user.banner})`,
98            backgroundSize: 'cover'
99          }}
100        />
101      )}
102
103      {/* Profile */}
104      <div className="p-4">
105        <div className="flex items-start gap-3 -mt-8 mb-4">
106          <div className="relative">
107            <img
108              src={user.user.avatar}
109              alt={user.user.username}
110              className="w-20 h-20 rounded-full border-4 border-gray-800"
111            />
112            <div
113              className={`absolute bottom-0 right-0 w-6 h-6 rounded-full border-4 border-gray-800 ${
114                presence.status === 'online' ? 'bg-green-500' :
115                presence.status === 'idle' ? 'bg-yellow-500' :
116                presence.status === 'dnd' ? 'bg-red-500' : 'bg-gray-500'
117              }`}
118            />
119          </div>
120        </div>
121
122        <h3 className="text-xl font-bold text-white mb-1">
123          {user.nickname || user.user.username}
124        </h3>
125        <p className="text-sm text-gray-400 mb-4">
126          {user.user.username}#{user.user.discriminator}
127        </p>
128
129        {/* Custom Status */}
130        {presence.customStatus && (
131          <div className="bg-gray-700 rounded p-2 mb-4 flex items-center gap-2">
132            {presence.customStatus.emoji && (
133              <span className="text-xl">{presence.customStatus.emoji}</span>
134            )}
135            <span className="text-sm text-white">{presence.customStatus.text}</span>
136          </div>
137        )}
138
139        {/* Activities */}
140        {presence.activities.length > 0 && (
141          <div className="mb-4">
142            <h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">
143              Activity
144            </h4>
145            {presence.activities.map((activity, idx) => (
146              <ActivityDisplay key={idx} activity={activity} />
147            ))}
148          </div>
149        )}
150
151        {/* Member Since */}
152        <div className="text-xs text-gray-400">
153          Member since {user.joinedAt.toLocaleDateString()}
154        </div>
155      </div>
156    </div>
157  );
158}

Summary 🎓

Voice channels - join, leave, connected users list ✅ Voice state - muted, deafened, streaming, video ✅ Voice controls - mute, deafen, screen share, camera ✅ Speaking indicator - ring around avatar ✅ Rich presence - gaming activity with details ✅ Activity types - playing, streaming, listening, watching ✅ Timestamps - elapsed time tracking ✅ Party info - player counts ✅ Custom status - emoji + text

In the next exercise we'll add server roles and permissions!

See you next time! 🚀

Go to CodeWorlds