Skip to content

Commit 1ac8c75

Browse files
maxznlclaude
andcommitted
feat(voice): rebuild voice channel system with Discord-style UX
Voice Channel Redesign: - Refactor voice-client.ts to follow SFU debug page patterns - Add local stream storage (audio, video, screen) - Fix consume flow to map consumerId to id (critical fix) - Add getRemoteStreams() method for stream mapping - Auto-join flow: join -> device -> transports -> consume -> audio - Add heartbeat handler for connection keepalive - Update voiceStore.ts with stream management - Add audioStream, videoStream, screenStream to VoiceUser - Add local stream state for self view - Add updateUserStream and clearUserStreams actions - Update VideoGrid.tsx to wire actual streams - Attach MediaStreams to video/audio elements - Show speaking indicator ring - Profile picture fallback when no video - Support self video with localVideoStream - Create VoiceChannelView.tsx component - Dedicated voice view with video grid - Header with disconnect button - Auto-join on mount, cleanup on disconnect - Wire remote stream callbacks to store - Update channel page routing - Type-based routing: voice -> VoiceChannelView, text -> MessageList - Update ChannelSidebar.tsx - Single-click voice channel entry (join + navigate) - Remove separate VoiceJoinButton - Keep VoiceChannelUsers for avatar stack - Highlight active voice channel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f9749ff commit 1ac8c75

8 files changed

Lines changed: 1015 additions & 195 deletions

File tree

packages/web/app/app/servers/[serverId]/channels/[channelId]/page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useEffect, useRef } from 'react';
44
import { useParams } from 'next/navigation';
55
import { MessageList, MessageInput } from '@/components/messaging';
6+
import VoiceChannelView from '@/components/voice/VoiceChannelView';
67
import { useChannelStore } from '@/stores/channelStore';
78
import { useServerStore } from '@/stores/serverStore';
89

@@ -42,6 +43,14 @@ export default function ChannelPage() {
4243
);
4344
}
4445

46+
// Type-based routing: Voice channels render VoiceChannelView
47+
if (channel.type === 'voice') {
48+
return (
49+
<VoiceChannelView channelId={channelId} serverId={serverId} />
50+
);
51+
}
52+
53+
// Text channels render MessageList + MessageInput
4554
return (
4655
<div className="flex-1 flex flex-col">
4756
<MessageList channelId={channelId} serverId={serverId} />

packages/web/components/app/ChannelSidebar.tsx

Lines changed: 119 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,29 @@ import {
1313
User,
1414
FolderPlus,
1515
} from 'lucide-react';
16+
import {
17+
DndContext,
18+
closestCenter,
19+
KeyboardSensor,
20+
PointerSensor,
21+
useSensor,
22+
useSensors,
23+
DragEndEvent,
24+
} from '@dnd-kit/core';
25+
import {
26+
arrayMove,
27+
SortableContext,
28+
sortableKeyboardCoordinates,
29+
useSortable,
30+
verticalListSortingStrategy,
31+
} from '@dnd-kit/sortable';
32+
import { CSS } from '@dnd-kit/utilities';
1633
import { useServerStore } from '@/stores/serverStore';
1734
import { useChannelStore, Channel, Category } from '@/stores/channelStore';
1835
import { useUIStore } from '@/stores/uiStore';
1936
import { useVoiceStore } from '@/stores/voiceStore';
20-
import { VoiceChannelUsers, VoiceJoinButton } from '@/components/voice';
37+
import { VoiceChannelUsers } from '@/components/voice';
38+
import { apiClient } from '@/lib/api-client';
2139

2240
function ChannelIcon({ type }: { type: Channel['type'] }) {
2341
switch (type) {
@@ -30,24 +48,40 @@ function ChannelIcon({ type }: { type: Channel['type'] }) {
3048
}
3149
}
3250

33-
interface ChannelItemProps {
51+
interface SortableChannelItemProps {
3452
channel: Channel;
3553
isSelected: boolean;
3654
onClick: () => void;
3755
onSettingsClick: () => void;
3856
serverId: string;
3957
}
4058

41-
function ChannelItem({ channel, isSelected, onClick, onSettingsClick, serverId }: ChannelItemProps) {
59+
function SortableChannelItem({ channel, isSelected, onClick, onSettingsClick, serverId }: SortableChannelItemProps) {
60+
const {
61+
attributes,
62+
listeners,
63+
setNodeRef,
64+
transform,
65+
transition,
66+
isDragging,
67+
} = useSortable({ id: channel.id });
68+
69+
const style = {
70+
transform: CSS.Transform.toString(transform),
71+
transition,
72+
opacity: isDragging ? 0.5 : 1,
73+
zIndex: isDragging ? 1000 : undefined,
74+
};
75+
4276
const hasUnread = channel.unreadCount && channel.unreadCount > 0;
4377
const { isConnected, currentChannelId, getUsersByChannel } = useVoiceStore();
4478
const voiceUsers = channel.type === 'voice' ? getUsersByChannel(channel.id) : [];
4579
const isInVoiceChannel = isConnected && currentChannelId === channel.id;
4680

47-
// For voice channels, show users and join button
81+
// For voice channels, show users - click navigates to voice view
4882
if (channel.type === 'voice') {
4983
return (
50-
<div className="w-full">
84+
<div ref={setNodeRef} style={style} className="w-full">
5185
<div
5286
onClick={onClick}
5387
onContextMenu={(e) => {
@@ -59,9 +93,14 @@ function ChannelItem({ channel, isSelected, onClick, onSettingsClick, serverId }
5993
? 'bg-accent-muted text-accent'
6094
: 'text-foreground-muted hover:bg-background-surface hover:text-foreground'
6195
}`}
96+
{...attributes}
97+
{...listeners}
6298
>
6399
<ChannelIcon type={channel.type} />
64100
<span className="truncate flex-1 text-left">{channel.name}</span>
101+
{isInVoiceChannel && (
102+
<span className="w-2 h-2 rounded-full bg-success animate-pulse" />
103+
)}
65104
<div className="hidden group-hover:flex items-center gap-0.5">
66105
<button
67106
onClick={(e) => {
@@ -76,35 +115,24 @@ function ChannelItem({ channel, isSelected, onClick, onSettingsClick, serverId }
76115
</div>
77116
</div>
78117

79-
{/* Voice users in this channel */}
118+
{/* Voice users in this channel - avatar stack */}
80119
{voiceUsers.length > 0 && (
81120
<VoiceChannelUsers channelId={channel.id} />
82121
)}
83-
84-
{/* Join button if not connected */}
85-
{!isInVoiceChannel && (
86-
<VoiceJoinButton channelId={channel.id} serverId={serverId} />
87-
)}
88122
</div>
89123
);
90124
}
91125

92126
// Text channels
93127
return (
94128
<div
129+
ref={setNodeRef}
130+
style={style}
95131
onClick={onClick}
96132
onContextMenu={(e) => {
97133
e.preventDefault();
98134
onSettingsClick();
99135
}}
100-
onKeyDown={(e) => {
101-
if (e.key === 'Enter' || e.key === ' ') {
102-
e.preventDefault();
103-
onClick();
104-
}
105-
}}
106-
role="button"
107-
tabIndex={0}
108136
className={`w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group transition-colors cursor-pointer ${
109137
isSelected
110138
? 'bg-accent-muted text-accent'
@@ -113,6 +141,8 @@ function ChannelItem({ channel, isSelected, onClick, onSettingsClick, serverId }
113141
: 'text-foreground-muted hover:bg-background-surface hover:text-foreground'
114142
}`}
115143
aria-current={isSelected ? 'page' : undefined}
144+
{...attributes}
145+
{...listeners}
116146
>
117147
<ChannelIcon type={channel.type} />
118148
<span className="truncate flex-1 text-left">{channel.name}</span>
@@ -154,6 +184,7 @@ interface CategorySectionProps {
154184
serverId: string;
155185
onChannelClick: (channel: Channel) => void;
156186
onChannelSettings: (channel: Channel) => void;
187+
onDragEnd: (event: DragEndEvent, categoryId: string) => void;
157188
}
158189

159190
function CategorySection({
@@ -163,12 +194,26 @@ function CategorySection({
163194
serverId,
164195
onChannelClick,
165196
onChannelSettings,
197+
onDragEnd,
166198
}: CategorySectionProps) {
167199
const { toggleCategoryCollapse } = useChannelStore();
168200
const { openCreateChannelModal } = useUIStore();
169201

202+
const sensors = useSensors(
203+
useSensor(PointerSensor, {
204+
activationConstraint: {
205+
distance: 8,
206+
},
207+
}),
208+
useSensor(KeyboardSensor, {
209+
coordinateGetter: sortableKeyboardCoordinates,
210+
})
211+
);
212+
170213
if (channels.length === 0) return null;
171214

215+
const channelIds = channels.map((c) => c.id);
216+
172217
return (
173218
<div className="mb-2">
174219
<button
@@ -195,18 +240,26 @@ function CategorySection({
195240
</button>
196241

197242
{!category.isCollapsed && (
198-
<div className="space-y-0.5">
199-
{channels.map((channel) => (
200-
<ChannelItem
201-
key={channel.id}
202-
channel={channel}
203-
isSelected={currentChannelId === channel.id}
204-
onClick={() => onChannelClick(channel)}
205-
onSettingsClick={() => onChannelSettings(channel)}
206-
serverId={serverId}
207-
/>
208-
))}
209-
</div>
243+
<DndContext
244+
sensors={sensors}
245+
collisionDetection={closestCenter}
246+
onDragEnd={(event) => onDragEnd(event, category.id)}
247+
>
248+
<SortableContext items={channelIds} strategy={verticalListSortingStrategy}>
249+
<div className="space-y-0.5">
250+
{channels.map((channel) => (
251+
<SortableChannelItem
252+
key={channel.id}
253+
channel={channel}
254+
isSelected={currentChannelId === channel.id}
255+
onClick={() => onChannelClick(channel)}
256+
onSettingsClick={() => onChannelSettings(channel)}
257+
serverId={serverId}
258+
/>
259+
))}
260+
</div>
261+
</SortableContext>
262+
</DndContext>
210263
)}
211264
</div>
212265
);
@@ -235,9 +288,9 @@ export default function ChannelSidebar() {
235288
(channel: Channel) => {
236289
setCurrentChannel(channel.id);
237290
clearChannelUnread(channel.id);
238-
if (channel.type === 'text') {
239-
router.push(`/app/servers/${currentServerId}/channels/${channel.id}`);
240-
}
291+
// Navigate for both text and voice channels
292+
// Voice channels will render VoiceChannelView which handles joining
293+
router.push(`/app/servers/${currentServerId}/channels/${channel.id}`);
241294
},
242295
[setCurrentChannel, clearChannelUnread, router, currentServerId]
243296
);
@@ -249,6 +302,34 @@ export default function ChannelSidebar() {
249302
[openEditChannelModal]
250303
);
251304

305+
const handleDragEnd = useCallback(
306+
async (event: DragEndEvent, categoryId: string) => {
307+
const { active, over } = event;
308+
309+
if (over && active.id !== over.id && currentServerId) {
310+
const categoryChannels = getChannelsForCategory(categoryId);
311+
const channelIds = categoryChannels.map((c) => c.id);
312+
const oldIndex = channelIds.indexOf(active.id as string);
313+
const newIndex = channelIds.indexOf(over.id as string);
314+
315+
const newOrder = arrayMove(channelIds, oldIndex, newIndex);
316+
317+
// Persist to backend
318+
try {
319+
const positions = newOrder.map((id, index) => ({
320+
id,
321+
position: index,
322+
categoryId: categoryId || null,
323+
}));
324+
await apiClient.updateChannelPositions(currentServerId, positions);
325+
} catch (error) {
326+
console.error('Failed to update channel positions:', error);
327+
}
328+
}
329+
},
330+
[currentServerId, channels]
331+
);
332+
252333
if (!isChannelSidebarOpen) return null;
253334

254335
// No server selected - show DMs view
@@ -293,11 +374,11 @@ export default function ChannelSidebar() {
293374
</div>
294375
) : (
295376
<>
296-
{/* Uncategorized channels */}
377+
{/* Uncategorized channels - not sortable for simplicity */}
297378
{getChannelsForCategory(null).length > 0 && (
298379
<div className="space-y-0.5 mb-2">
299380
{getChannelsForCategory(null).map((channel) => (
300-
<ChannelItem
381+
<SortableChannelItem
301382
key={channel.id}
302383
channel={channel}
303384
isSelected={currentChannelId === channel.id}
@@ -309,7 +390,7 @@ export default function ChannelSidebar() {
309390
</div>
310391
)}
311392

312-
{/* Categories */}
393+
{/* Categories with sortable channels */}
313394
{categories
314395
.sort((a, b) => a.position - b.position)
315396
.map((category) => (
@@ -321,6 +402,7 @@ export default function ChannelSidebar() {
321402
serverId={currentServerId}
322403
onChannelClick={handleChannelClick}
323404
onChannelSettings={handleChannelSettings}
405+
onDragEnd={handleDragEnd}
324406
/>
325407
))}
326408

0 commit comments

Comments
 (0)