feat: Optimize WebRTC pipeline for 60fps screen sharing

- Implemented 'Overdrive' logic to force high bitrates on Chrome
- Fixed 'Auto' preset to default to stable 720p60
- Added persistent 'Triple-Kick' hammer to prevent bitrate throttling
- Fixed sidebar connection status sync
- Added comprehensive diagnostic logger
This commit is contained in:
Jannis Braun
2026-02-19 03:34:20 +01:00
parent 435d12e5b8
commit 7ae3e8c687
56 changed files with 3558 additions and 577 deletions
@@ -13,6 +13,7 @@ import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings';
import { NewDmModal } from '../modals/NewDmModal';
import { IncomingCallModal } from '../voice/IncomingCallModal';
import { UserProfilePopout } from '../ui/UserProfilePopout';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
@@ -36,23 +37,33 @@ export function AppLayout() {
const userProfilePopout = useUIStore((s) => s.userProfilePopout);
const closeUserProfile = useUIStore((s) => s.closeUserProfile);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const setParticipants = useVoiceStore((s) => s.setParticipants);
const { connect: connectVoice, disconnect: disconnectVoice, participants: voiceParticipants, } = useLiveKit();
const { connect: connectVoice, connectDm: connectDmVoice, disconnect: disconnectVoice, participants: voiceParticipants, } = useLiveKit();
// Initialize WebSocket
useWebSocket();
// Sync participants to store
useEffect(() => {
setParticipants(voiceParticipants);
}, [voiceParticipants, setParticipants]);
// Manage voice connection
// Manage voice connection (server voice channels)
useEffect(() => {
if (currentVoiceChannelId) {
connectVoice(currentVoiceChannelId);
}
else {
else if (!activeDmCall) {
disconnectVoice();
}
}, [currentVoiceChannelId, connectVoice, disconnectVoice]);
}, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall]);
// Manage DM call connection
useEffect(() => {
if (activeDmCall) {
connectDmVoice(activeDmCall.dmChannelId);
}
else if (!currentVoiceChannelId) {
disconnectVoice();
}
}, [activeDmCall, connectDmVoice, disconnectVoice, currentVoiceChannelId]);
// Responsive detection
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
@@ -89,5 +100,5 @@ export function AppLayout() {
if (isLoading || !user) {
return (_jsx("div", { className: "h-screen flex items-center justify-center bg-discord-bg-primary", children: _jsxs("div", { className: "text-center", children: [_jsxs("svg", { className: "animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }), _jsx("p", { className: "text-discord-text-muted", children: "Loading Opencord..." })] }) }));
}
return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we\u2019ll show it here!" })] })] }) })) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(ImagePreview, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] }));
return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we\u2019ll show it here!" })] })] }) })) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] }));
}
@@ -13,6 +13,7 @@ import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings';
import { NewDmModal } from '../modals/NewDmModal';
import { IncomingCallModal } from '../voice/IncomingCallModal';
import { UserProfilePopout } from '../ui/UserProfilePopout';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
@@ -38,9 +39,11 @@ export function AppLayout() {
const closeUserProfile = useUIStore((s) => s.closeUserProfile);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const setParticipants = useVoiceStore((s) => s.setParticipants);
const {
connect: connectVoice,
connectDm: connectDmVoice,
disconnect: disconnectVoice,
participants: voiceParticipants,
} = useLiveKit();
@@ -53,14 +56,23 @@ export function AppLayout() {
setParticipants(voiceParticipants);
}, [voiceParticipants, setParticipants]);
// Manage voice connection
// Manage voice connection (server voice channels)
useEffect(() => {
if (currentVoiceChannelId) {
connectVoice(currentVoiceChannelId);
} else {
} else if (!activeDmCall) {
disconnectVoice();
}
}, [currentVoiceChannelId, connectVoice, disconnectVoice]);
}, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall]);
// Manage DM call connection
useEffect(() => {
if (activeDmCall) {
connectDmVoice(activeDmCall.dmChannelId);
} else if (!currentVoiceChannelId) {
disconnectVoice();
}
}, [activeDmCall, connectDmVoice, disconnectVoice, currentVoiceChannelId]);
// Responsive detection
useEffect(() => {
@@ -147,6 +159,7 @@ export function AppLayout() {
<UserSettingsModal />
<ServerSettingsModal />
<NewDmModal />
<IncomingCallModal />
<ImagePreview />
{/* User Profile Popout */}
File diff suppressed because one or more lines are too long
@@ -77,26 +77,49 @@ export function ChannelSidebar() {
if (!server) {
return (
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none">
<div className="h-12 px-4 flex items-center shadow-header z-10">
<button className="flex-1 bg-discord-bg-tertiary text-discord-text-muted text-[14px] font-medium py-1 px-2 rounded-[4px] text-left hover:bg-discord-bg-tertiary/80 transition-colors">
<div className="h-12 px-[10px] flex items-center shadow-header z-10">
<button className="flex-1 bg-discord-bg-tertiary text-discord-text-muted text-[13px] font-medium py-[5px] px-2 rounded-[4px] text-left hover:bg-discord-bg-tertiary/80 transition-colors">
Find or start a conversation
</button>
</div>
<div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar">
<div
<div
onClick={handleHomeClick}
className={`flex items-center gap-3 px-2 h-10 rounded-[4px] cursor-pointer mb-0.5 transition-colors group ${
!currentChannelId
? 'bg-discord-modifier-selected text-white'
className={`flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer mb-[2px] transition-colors group ${
!currentChannelId
? 'bg-discord-modifier-selected text-white'
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'
}`}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className={`${!currentChannelId ? 'text-white' : 'opacity-70 group-hover:opacity-100'}`}>
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className={`flex-shrink-0 ${!currentChannelId ? 'text-white' : 'opacity-70 group-hover:opacity-100'}`}>
<path d="M13 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-2-4a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z" />
<path d="M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" />
<path d="M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" opacity=".5" />
</svg>
<span className="font-medium text-[16px]">Friends</span>
</div>
{/* Nitro */}
<div
className="flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer mb-[2px] transition-colors group text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-70 group-hover:opacity-100">
<path d="M2.98966977,9.35789159 C2.98966977,9.77582472 2.63542482,10.1300697 2.21749169,10.1300697 L2.21749169,10.1300697 C1.79955856,10.1300697 1.44531361,9.77582472 1.44531361,9.35789159 L1.44531361,9.35789159 C1.44531361,8.93995846 1.79955856,8.58571351 2.21749169,8.58571351 L2.21749169,8.58571351 C2.63542482,8.58571351 2.98966977,8.93995846 2.98966977,9.35789159 Z" transform="translate(12, 12) scale(1.2) translate(-12, -12)" />
<path d="M21.7,9.358 L12.957,2.46 C12.396,2.014 11.604,2.014 11.043,2.46 L2.3,9.358 C2.113,9.507 2,9.734 2,9.975 L2,19.5 C2,20.881 3.119,22 4.5,22 L19.5,22 C20.881,22 22,20.881 22,19.5 L22,9.975 C22,9.734 21.887,9.507 21.7,9.358 Z M12,17.5 C10.619,17.5 9.5,16.381 9.5,15 C9.5,13.619 10.619,12.5 12,12.5 C13.381,12.5 14.5,13.619 14.5,15 C14.5,16.381 13.381,17.5 12,17.5 Z" />
</svg>
<span className="font-medium text-[16px]">Nitro</span>
</div>
{/* Shop */}
<div
className="flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer mb-[2px] transition-colors group text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-70 group-hover:opacity-100">
<path d="M2 5.5A1.5 1.5 0 0 1 3.5 4h17A1.5 1.5 0 0 1 22 5.5V7H2V5.5ZM2 9v9.5A1.5 1.5 0 0 0 3.5 20h17a1.5 1.5 0 0 0 1.5-1.5V9H2Zm9.5 3a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5Zm-4-1h2a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1Z" />
</svg>
<span className="font-medium text-[16px]">Shop</span>
</div>
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
<span className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider">Direct Messages</span>
<button
@@ -133,14 +156,28 @@ export function ChannelSidebar() {
)}
<Avatar src={otherUser.avatar} name={otherUser.displayName ?? otherUser.username} size={32} status={otherUser.status as any} />
<div className="flex-1 min-w-0">
<div className={`text-[16px] truncate ${
<div className={`text-[15px] truncate leading-tight ${
currentChannelId === dm.id ? 'text-white font-medium'
: isDmUnread ? 'text-white font-bold'
: 'text-discord-text-muted group-hover:text-discord-text-secondary font-medium'
}`}>
{otherUser.displayName ?? otherUser.username}
</div>
{dm.lastMessage && (
<div className="text-[12px] text-discord-channels-default truncate leading-tight mt-0.5">
{dm.lastMessage.content}
</div>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); }}
className="opacity-0 group-hover:opacity-100 text-discord-text-muted hover:text-discord-text-primary transition-opacity flex-shrink-0 ml-1"
title="Close DM"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
);
})}
@@ -149,7 +186,10 @@ export function ChannelSidebar() {
)}
</div>
</div>
{/* Voice controls — visible when in a call, even in DM view */}
{currentVoiceChannelId && <VoiceControls />}
{/* User area at bottom */}
{user && (
<UserAreaPanel
@@ -225,7 +265,7 @@ export function ChannelSidebar() {
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-60">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
</svg>
<span className={`truncate text-[16px] ${isUnread ? 'font-bold' : 'font-medium'}`}>{channel.name}</span>
<span className={`truncate text-[15px] ${isUnread ? 'font-bold' : 'font-medium'}`}>{channel.name}</span>
</button>
);
})}
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
@@ -7,6 +7,9 @@ import { MessageList } from '../chat/MessageList';
import { MessageInput } from '../chat/MessageInput';
import { TypingIndicator } from '../chat/TypingIndicator';
import { VoiceGrid } from '../voice/VoiceGrid';
import { VoiceControlBar } from '../voice/VoiceControlBar';
import { VoiceChatPanel } from '../voice/VoiceChatPanel';
import { DmCallView } from '../voice/DmCallView';
import { FriendsPage } from '../chat/FriendsPage';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
@@ -18,10 +21,15 @@ export function MainContent() {
const currentServerId = useServerStore((s) => s.currentServerId);
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
const participants = useVoiceStore((s) => s.participants);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const showDms = useUIStore((s) => s.showDms);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const outgoingCall = useVoiceStore((s) => s.outgoingCall);
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
@@ -39,18 +47,113 @@ export function MainContent() {
const dmName = otherUser?.displayName ?? otherUser?.username ?? 'Direct Message';
const dmStatus = otherUser?.status as any;
// Show DmCallView if there's an active DM call for this channel
const isInDmCall = activeDmCall?.dmChannelId === currentChannelId;
const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId;
const handleStartVoiceCall = () => {
if (!currentChannelId) return;
useVoiceStore.getState().setOutgoingCall({ dmChannelId: currentChannelId });
wsSend({ type: 'dm_call_start', dmChannelId: currentChannelId });
};
const handleCancelCall = () => {
if (!currentChannelId) return;
useVoiceStore.getState().setOutgoingCall(null);
wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId });
};
// If in an active DM call, show the call view overlaid on top of the chat
if (isInDmCall) {
return (
<div className="flex-1 flex flex-col min-w-0 relative">
<DmCallView />
</div>
);
}
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative">
<div className="h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10">
{/* Outgoing call banner */}
{isCallingThisDm && (
<div className="bg-discord-green/10 border-b border-discord-green/20 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-green animate-pulse">
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
</svg>
<span className="text-discord-green text-sm font-medium">Calling {dmName}...</span>
</div>
<button
onClick={handleCancelCall}
className="px-3 py-1 bg-discord-red hover:bg-discord-red/80 text-white text-xs font-medium rounded transition-colors"
>
Cancel
</button>
</div>
)}
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary">
<div className="flex items-center gap-2 min-w-0">
<span className="text-discord-text-muted font-bold text-lg">@</span>
{otherUser && (
<Avatar src={otherUser.avatar} name={dmName} size={24} status={dmStatus} />
)}
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
<path d="M12.5 2A6.5 6.5 0 0 0 6 8.5c0 1.82.75 3.47 1.95 4.65A10.02 10.02 0 0 0 2 22h2c0-4.42 3.58-8 8-8 .35 0 .69.03 1.03.07A6.49 6.49 0 0 0 19 8.5 6.5 6.5 0 0 0 12.5 2Zm0 11A4.5 4.5 0 1 1 17 8.5a4.5 4.5 0 0 1-4.5 4.5Z" />
</svg>
<span className="font-bold text-discord-text-primary truncate">{dmName}</span>
{otherUser?.status && otherUser.status !== 'offline' && (
<span className="text-xs text-discord-text-muted capitalize">{otherUser.status}</span>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Voice Call */}
<button
onClick={handleStartVoiceCall}
disabled={!!outgoingCall || !!activeDmCall}
className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover disabled:opacity-50 disabled:cursor-not-allowed"
title="Start Voice Call"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
</svg>
</button>
{/* Video Call */}
<button
onClick={handleStartVoiceCall}
disabled={!!outgoingCall || !!activeDmCall}
className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover disabled:opacity-50 disabled:cursor-not-allowed"
title="Start Video Call"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z" />
</svg>
</button>
{/* Pinned Messages */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Pinned Messages">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" transform="rotate(45 12 12)" />
<path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" />
</svg>
</button>
{/* Add Friends to DM */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Add Friends to DM">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
</button>
{/* Divider */}
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
{/* Search */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Search">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" />
</svg>
</button>
{/* Inbox */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Inbox">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" />
</svg>
</button>
{/* Help */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Help">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
</svg>
</button>
</div>
</div>
<MessageList channelId={currentChannelId} />
@@ -77,26 +180,19 @@ export function MainContent() {
// Voice/Video channel view
if (isVoiceChannel) {
const isInThisChannel = currentVoiceChannelId === currentChannelId;
const isMuted = useVoiceStore.getState().isMuted;
const isCameraOn = useVoiceStore.getState().isCameraOn;
const isScreenSharing = useVoiceStore.getState().isScreenSharing;
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center justify-between shadow-header">
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
</svg>
<span className="font-bold text-discord-text-primary">{channel.name}</span>
{isInThisChannel && (
<span className="text-xs text-discord-green font-medium ml-2">Connected</span>
)}
// Not connected — show "Join Voice" prompt
if (!isInThisChannel) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center justify-between shadow-header">
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
</svg>
<span className="font-bold text-discord-text-primary">{channel.name}</span>
</div>
</div>
</div>
{isInThisChannel ? (
<VoiceGrid participants={participants} />
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-6">
<div className="text-center">
<svg width="80" height="80" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted mx-auto mb-4 opacity-40">
@@ -115,9 +211,38 @@ export function MainContent() {
Join Voice
</button>
</div>
)}
</div>
);
}
// Connected — full voice view with grid + chat panel + control bar
const voiceView = (
<div className={`flex-1 flex flex-col bg-[#111214] min-w-0 ${voiceFullscreen ? 'fixed inset-0 z-50' : ''}`}>
{/* Voice header */}
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#111214]">
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
</svg>
<span className="font-bold text-discord-text-primary">{channel.name}</span>
<span className="text-xs text-discord-green font-medium ml-2">Connected</span>
</div>
</div>
{/* Main content: grid + optional chat */}
<div className="flex-1 flex overflow-hidden">
<VoiceGrid participants={participants} />
{voiceChatOpen && (
<VoiceChatPanel channelId={currentChannelId} channelName={channel.name} />
)}
</div>
{/* Control bar at bottom */}
<VoiceControlBar />
</div>
);
return voiceView;
}
// Text channel view
@@ -137,10 +262,29 @@ export function MainContent() {
</>
)}
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<div className="flex items-center gap-1 flex-shrink-0">
{/* Threads */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Threads">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.43 21a.996.996 0 01-.98-.8l-.79-4.34H2.5a1 1 0 110-2h.93l-.55-3H1.5a1 1 0 010-2h1.15L1.87 4.86a1 1 0 011.96-.72L4.6 8.86h3.32l-.78-4.72a1 1 0 011.96-.28l.84 5H13.5a1 1 0 110 2h-3.33l.55 3H13.5a1 1 0 110 2h-2.55l.72 3.94a1 1 0 01-.79 1.16 1.034 1.034 0 01-.18.02.996.996 0 01-.98-.82L8.95 15.86H5.63l.72 3.94A1 1 0 015.43 21zM5.86 10.86l.55 3h3.32l-.55-3H5.86z" />
</svg>
</button>
{/* Notification Settings */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Notification Settings">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
</svg>
</button>
{/* Pinned Messages */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Pinned Messages">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" />
</svg>
</button>
{/* Member List Toggle */}
<button
onClick={toggleMemberList}
className={`p-1 transition-colors ${
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'
}`}
title="Toggle Member List"
@@ -149,6 +293,26 @@ export function MainContent() {
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
</button>
{/* Divider */}
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
{/* Search */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Search">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" />
</svg>
</button>
{/* Inbox */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Inbox">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" />
</svg>
</button>
{/* Help */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Help">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
</svg>
</button>
</div>
</div>
@@ -1,15 +1,31 @@
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useMemo } from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
const ROLE_ORDER = { owner: 0, admin: 1, member: 2 };
const ROLE_LABELS = { owner: 'OWNER', admin: 'ADMIN', member: 'MEMBER' };
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const { roleGroups, offlineMembers } = useMemo(() => {
const online = members.filter(m => m.user.status !== 'offline');
const offline = members.filter(m => m.user.status === 'offline');
// Group online members by role
const groups = new Map();
for (const m of online) {
const role = m.role || 'member';
if (!groups.has(role))
groups.set(role, []);
groups.get(role).push(m);
}
// Sort groups by role hierarchy
const sorted = [...groups.entries()].sort((a, b) => (ROLE_ORDER[a[0]] ?? 99) - (ROLE_ORDER[b[0]] ?? 99));
return { roleGroups: sorted, offlineMembers: offline };
}, [members]);
if (!memberListOpen)
return null;
const onlineMembers = members.filter(m => m.user.status !== 'offline');
const offlineMembers = members.filter(m => m.user.status === 'offline');
const roleColors = {
owner: 'text-discord-red',
admin: 'text-discord-blurple',
@@ -17,7 +33,6 @@ export function MemberSidebar() {
};
const getMemberColor = (member) => {
if (member.roles && member.roles.length > 0) {
// Return the color of the first role (already sorted by position)
return { color: member.roles[0].color };
}
return undefined;
@@ -27,14 +42,12 @@ export function MemberSidebar() {
const rect = e.currentTarget.getBoundingClientRect();
openUserProfile(user, {
top: Math.min(rect.top, window.innerHeight - 450),
left: rect.left - 316, // Open to the left of member sidebar
left: rect.left - 316,
});
};
return (_jsx("div", { className: "w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar", children: _jsxs("div", { className: "p-3", children: [onlineMembers.length > 0 && (_jsxs("div", { className: "mb-4", children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Online \u2014 ", onlineMembers.length] }), onlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { onClick: (e) => handleMemberClick(e, member.user), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: member.user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-[15px] font-medium truncate ${!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : ''}`, style: getMemberColor(member), children: displayName }), member.user.customStatus && (_jsx("div", { className: "text-[12px] text-discord-text-muted truncate", children: member.user.customStatus }))] })] }, member.userId));
})] })), offlineMembers.length > 0 && (_jsxs("div", { children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Offline \u2014 ", offlineMembers.length] }), offlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { onClick: (e) => handleMemberClick(e, member.user), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: "offline", className: "opacity-60" }), _jsx("div", { className: "flex-1 min-w-0", children: _jsx("div", { className: "text-[15px] font-medium truncate text-discord-text-muted", children: displayName }) })] }, member.userId));
})] }))] }) }));
const renderMember = (member, isOffline = false) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { onClick: (e) => handleMemberClick(e, member.user), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: isOffline ? 'offline' : member.user.status, className: isOffline ? 'opacity-60' : undefined }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-[15px] font-medium truncate ${isOffline ? 'text-discord-text-muted' : (!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : '')}`, style: isOffline ? undefined : getMemberColor(member), children: displayName }), !isOffline && member.user.customStatus && (_jsx("div", { className: "text-[12px] text-discord-text-muted truncate", children: member.user.customStatus }))] })] }, member.userId));
};
return (_jsx("div", { className: "w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar", children: _jsxs("div", { className: "p-3", children: [roleGroups.map(([role, groupMembers]) => (_jsxs("div", { className: "mb-4", children: [_jsxs("h3", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1", children: [ROLE_LABELS[role] ?? role.toUpperCase(), " \u2014 ", groupMembers.length] }), groupMembers.map((m) => renderMember(m))] }, role))), offlineMembers.length > 0 && (_jsxs("div", { children: [_jsxs("h3", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1", children: ["OFFLINE \u2014 ", offlineMembers.length] }), offlineMembers.map((m) => renderMember(m, true))] }))] }) }));
}
@@ -1,18 +1,38 @@
import React from 'react';
import React, { useMemo } from 'react';
import type { MemberWithUser } from '@opencord/shared';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
const ROLE_ORDER: Record<string, number> = { owner: 0, admin: 1, member: 2 };
const ROLE_LABELS: Record<string, string> = { owner: 'OWNER', admin: 'ADMIN', member: 'MEMBER' };
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile);
if (!memberListOpen) return null;
const { roleGroups, offlineMembers } = useMemo(() => {
const online = members.filter(m => m.user.status !== 'offline');
const offline = members.filter(m => m.user.status === 'offline');
const onlineMembers = members.filter(m => m.user.status !== 'offline');
const offlineMembers = members.filter(m => m.user.status === 'offline');
// Group online members by role
const groups = new Map<string, MemberWithUser[]>();
for (const m of online) {
const role = m.role || 'member';
if (!groups.has(role)) groups.set(role, []);
groups.get(role)!.push(m);
}
// Sort groups by role hierarchy
const sorted = [...groups.entries()].sort(
(a, b) => (ROLE_ORDER[a[0]] ?? 99) - (ROLE_ORDER[b[0]] ?? 99)
);
return { roleGroups: sorted, offlineMembers: offline };
}, [members]);
if (!memberListOpen) return null;
const roleColors: Record<string, string> = {
owner: 'text-discord-red',
@@ -22,7 +42,6 @@ export function MemberSidebar() {
const getMemberColor = (member: MemberWithUser) => {
if (member.roles && member.roles.length > 0) {
// Return the color of the first role (already sorted by position)
return { color: member.roles[0]!.color };
}
return undefined;
@@ -33,79 +52,60 @@ export function MemberSidebar() {
const rect = e.currentTarget.getBoundingClientRect();
openUserProfile(user, {
top: Math.min(rect.top, window.innerHeight - 450),
left: rect.left - 316, // Open to the left of member sidebar
left: rect.left - 316,
});
};
const renderMember = (member: MemberWithUser, isOffline = false) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div
key={member.userId}
onClick={(e) => handleMemberClick(e, member.user)}
className="flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status={isOffline ? 'offline' : member.user.status}
className={isOffline ? 'opacity-60' : undefined}
/>
<div className="flex-1 min-w-0">
<div
className={`text-[15px] font-medium truncate ${isOffline ? 'text-discord-text-muted' : (!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : '')}`}
style={isOffline ? undefined : getMemberColor(member)}
>
{displayName}
</div>
{!isOffline && member.user.customStatus && (
<div className="text-[12px] text-discord-text-muted truncate">{member.user.customStatus}</div>
)}
</div>
</div>
);
};
return (
<div className="w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar">
<div className="p-3">
{/* Online */}
{onlineMembers.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1">
Online {onlineMembers.length}
{/* Role-based groups */}
{roleGroups.map(([role, groupMembers]) => (
<div key={role} className="mb-4">
<h3 className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1">
{ROLE_LABELS[role] ?? role.toUpperCase()} {groupMembers.length}
</h3>
{onlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div
key={member.userId}
onClick={(e) => handleMemberClick(e, member.user)}
className="flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status={member.user.status}
/>
<div className="flex-1 min-w-0">
<div
className={`text-[15px] font-medium truncate ${!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : ''}`}
style={getMemberColor(member)}
>
{displayName}
</div>
{member.user.customStatus && (
<div className="text-[12px] text-discord-text-muted truncate">{member.user.customStatus}</div>
)}
</div>
</div>
);
})}
{groupMembers.map((m) => renderMember(m))}
</div>
)}
))}
{/* Offline */}
{offlineMembers.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1">
Offline {offlineMembers.length}
<h3 className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1">
OFFLINE {offlineMembers.length}
</h3>
{offlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div
key={member.userId}
onClick={(e) => handleMemberClick(e, member.user)}
className="flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status="offline"
className="opacity-60"
/>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-medium truncate text-discord-text-muted">
{displayName}
</div>
</div>
</div>
);
})}
{offlineMembers.map((m) => renderMember(m, true))}
</div>
)}
</div>
@@ -27,7 +27,7 @@ function SidebarItem({ name, icon, active, onClick, type = 'server', actionType,
}
return `${base} ${active ? 'bg-discord-blurple rounded-[16px] text-white' : 'bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:bg-discord-blurple hover:text-white'}`;
};
return (_jsxs("div", { className: "relative flex items-center mb-2 w-full justify-center", onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [(type === 'server' || type === 'dm') && (_jsx("div", { className: "absolute -left-0 w-2 h-12 flex items-center", children: _jsx("div", { className: `bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1` }) })), _jsx(Tooltip, { content: name, position: "right", children: _jsx("button", { onClick: onClick, className: getButtonClasses(), children: type === 'dm' ? (_jsx("svg", { width: "28", height: "20", viewBox: "0 0 28 20", fill: "currentColor", children: _jsx("path", { d: "M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z", transform: "scale(0.85) translate(0, 0)" }) })) : type === 'action' ? (actionType === 'add' ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" }) }))) : icon ? (_jsx("img", { src: icon.startsWith('http') ? icon : `/api/uploads/${icon}`, alt: name, className: "w-full h-full object-cover" })) : (_jsx("span", { className: "text-[16px] font-medium", children: firstLetter })) }) })] }));
return (_jsxs("div", { className: "relative flex items-center mb-2 w-full justify-center", onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [(type === 'server' || type === 'dm') && (_jsx("div", { className: "absolute -left-0 w-2 h-12 flex items-center", children: _jsx("div", { className: `bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1` }) })), _jsx(Tooltip, { content: name, position: "right", children: _jsx("button", { onClick: onClick, className: getButtonClasses(), children: type === 'dm' ? (_jsx("svg", { width: "28", height: "20", viewBox: "0 0 28 20", fill: "currentColor", children: _jsx("path", { d: "M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z", transform: "scale(0.85) translate(0, 0)" }) })) : type === 'action' ? (actionType === 'add' ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) })) : actionType === 'explore' ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.486 2 2 6.486 2 12s4.486 10 10 10 10-4.486 10-10S17.514 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm-3.146-5.351l2.78-1.042 1.042-2.78-2.78 1.042-1.042 2.78zM14.5 7.5l-2.5 5-5 2.5 2.5-5 5-2.5z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" }) }))) : icon ? (_jsx("img", { src: icon.startsWith('http') ? icon : `/api/uploads/${icon}`, alt: name, className: "w-full h-full object-cover" })) : (_jsx("span", { className: "text-[16px] font-medium", children: firstLetter })) }) })] }));
}
export function ServerSidebar() {
const servers = useServerStore((s) => s.servers);
@@ -40,17 +40,21 @@ export function ServerSidebar() {
const openModal = useUIStore((s) => s.openModal);
const unreadChannels = useChatStore((s) => s.unreadChannels);
const navigate = useNavigate();
// Compute which servers have unread channels
const unreadServerIds = useMemo(() => {
const ids = new Set();
for (const channelId of unreadChannels) {
const serverId = channelToServerMap.get(channelId);
if (serverId) ids.add(serverId);
if (serverId)
ids.add(serverId);
}
return ids;
}, [unreadChannels, channelToServerMap]);
// Check if any DM channels are unread
const hasDmUnread = useMemo(() => {
for (const dm of dmChannels) {
if (unreadChannels.has(dm.id)) return true;
if (unreadChannels.has(dm.id))
return true;
}
return false;
}, [unreadChannels, dmChannels]);
@@ -64,5 +68,5 @@ export function ServerSidebar() {
setCurrentServer(null);
navigate('/channels/@me');
};
return (_jsxs("nav", { className: "w-[72px] bg-discord-bg-server flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none", children: [_jsx(SidebarItem, { id: "@me", name: "Direct Messages", active: showDms, onClick: handleDmClick, type: "dm", hasUnread: hasDmUnread }), _jsx("div", { className: "w-8 h-[2px] bg-discord-modifier-accent rounded-full mb-2" }), servers.map((server) => (_jsx(SidebarItem, { id: server.id, name: server.name, icon: server.icon, active: currentServerId === server.id, onClick: () => handleServerClick(server.id), hasUnread: unreadServerIds.has(server.id) }, server.id))), _jsx(SidebarItem, { id: "add-server", name: "Add a Server", active: false, onClick: () => openModal('createServer'), type: "action", actionType: "add" }), _jsx(SidebarItem, { id: "join-server", name: "Join a Server", active: false, onClick: () => openModal('joinServer'), type: "action", actionType: "join" })] }));
return (_jsxs("nav", { className: "w-[72px] bg-discord-bg-server flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none", children: [_jsx(SidebarItem, { id: "@me", name: "Direct Messages", active: showDms, onClick: handleDmClick, type: "dm", hasUnread: hasDmUnread }), _jsx("div", { className: "w-8 h-[2px] bg-discord-modifier-accent rounded-full mb-2" }), servers.map((server) => (_jsx(SidebarItem, { id: server.id, name: server.name, icon: server.icon, active: currentServerId === server.id, onClick: () => handleServerClick(server.id), hasUnread: unreadServerIds.has(server.id) }, server.id))), _jsx(SidebarItem, { id: "add-server", name: "Add a Server", active: false, onClick: () => openModal('createServer'), type: "action", actionType: "add" }), _jsx(SidebarItem, { id: "join-server", name: "Join a Server", active: false, onClick: () => openModal('joinServer'), type: "action", actionType: "join" }), _jsx(SidebarItem, { id: "explore", name: "Explore Discoverable Servers", active: false, onClick: () => { }, type: "action", actionType: "explore" })] }));
}
@@ -12,7 +12,7 @@ interface SidebarItemProps {
active: boolean;
onClick: () => void;
type?: 'server' | 'dm' | 'action';
actionType?: 'add' | 'join';
actionType?: 'add' | 'join' | 'explore';
hasUnread?: boolean;
}
@@ -67,6 +67,10 @@ function SidebarItem({ name, icon, active, onClick, type = 'server', actionType,
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
) : actionType === 'explore' ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.486 2 2 6.486 2 12s4.486 10 10 10 10-4.486 10-10S17.514 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm-3.146-5.351l2.78-1.042 1.042-2.78-2.78 1.042-1.042 2.78zM14.5 7.5l-2.5 5-5 2.5 2.5-5 5-2.5z" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" />
@@ -171,6 +175,15 @@ export function ServerSidebar() {
type="action"
actionType="join"
/>
<SidebarItem
id="explore"
name="Explore Discoverable Servers"
active={false}
onClick={() => {}}
type="action"
actionType="explore"
/>
</nav>
);
}