Discord to nie tylko text chat - voice channels, screen share i gaming integrations są kluczowe!
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}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}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}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}✅ 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 z details ✅ Activity types - playing, streaming, listening, watching ✅ Timestamps - elapsed time tracking ✅ Party info - player counts ✅ Custom status - emoji + text
W następnym ćwiczeniu dodamy server roles i permissions!
Do zobaczenia! 🚀