From 4f65ea9c860f1c7a441d5b751661a43fe5437ceb Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 18 Feb 2026 07:52:25 +0100 Subject: [PATCH] fix: resolve LiveKit voice/video stability issues - Guard Disconnected/ConnectionStateChanged event handlers against stale rooms: old room events no longer nuke the new room's state (root cause of buttons failing, mute getting stuck, DUPLICATE_IDENTITY cascades) - Reset media state (isMuted/isCameraOn/isScreenSharing) on connect to prevent desync after reconnects - Add voiceStates to WS ready payload so users see who's in voice on page load - Wire VoiceControls buttons to check getActiveRoom() before SDK calls - Guard ChannelSidebar against re-joining the same voice channel - Switch LIVEKIT_URL to wss://nova.ddns.net/livekit for HTTPS secure context (required for getUserMedia in Safari) --- packages/server/src/ws/handler.ts | 16 ++- packages/web/src/api/client.js | 2 + .../web/src/components/chat/FriendsPage.js | 37 +++-- .../web/src/components/layout/AppLayout.js | 4 +- .../web/src/components/layout/AppLayout.tsx | 15 +- .../src/components/layout/ChannelSidebar.js | 14 +- .../src/components/layout/ChannelSidebar.tsx | 30 ++-- .../web/src/components/layout/MainContent.js | 15 +- .../web/src/components/layout/MainContent.tsx | 33 ++++- .../web/src/components/modals/InviteModal.js | 10 +- .../web/src/components/modals/JoinServer.js | 23 +--- packages/web/src/components/ui/Avatar.js | 4 +- packages/web/src/components/ui/ContextMenu.js | 2 +- packages/web/src/components/ui/Tooltip.js | 2 +- .../web/src/components/voice/VoiceControls.js | 67 +++++++-- .../src/components/voice/VoiceControls.tsx | 78 ++++++++--- .../web/src/components/voice/VoiceUser.js | 9 ++ .../web/src/components/voice/VoiceUser.tsx | 10 ++ packages/web/src/hooks/useLiveKit.js | 124 +++++++++++++++-- packages/web/src/hooks/useLiveKit.ts | 129 ++++++++++++++++-- packages/web/src/hooks/useWebSocket.js | 19 +++ packages/web/src/hooks/useWebSocket.ts | 8 ++ packages/web/src/stores/chatStore.js | 9 +- packages/web/src/stores/serverStore.js | 9 ++ packages/web/src/stores/socialStore.js | 29 ++++ packages/web/src/stores/voiceStore.js | 7 + packages/web/src/stores/voiceStore.ts | 11 ++ 27 files changed, 573 insertions(+), 143 deletions(-) diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index a104fc6b..1a9b8a66 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -379,7 +379,21 @@ function buildReadyPayload(userId: string): { }); } - return { user, servers, dmChannels, folders }; + // Build voice states — tell the client who is currently in voice channels + // across all their servers + const voiceStates: Record = {}; + for (const srv of servers) { + for (const ch of srv.channels) { + if (ch.type === 'voice' || ch.type === 'video') { + const users = connectionManager.getVoiceUsers(ch.id); + if (users.size > 0) { + voiceStates[ch.id] = Array.from(users); + } + } + } + } + + return { user, servers, dmChannels, folders, voiceStates }; } export async function registerWebSocket(app: FastifyInstance): Promise { diff --git a/packages/web/src/api/client.js b/packages/web/src/api/client.js index 0dff53b7..9ecf564e 100644 --- a/packages/web/src/api/client.js +++ b/packages/web/src/api/client.js @@ -61,6 +61,7 @@ export const api = { delete: (id) => request('DELETE', `/servers/${id}`), invite: (id) => request('POST', `/servers/${id}/invite`), join: (id, data) => request('POST', `/servers/${id}/join`, data), + joinByCode: (inviteCode) => request('POST', '/servers/join', { inviteCode }), members: (id) => request('GET', `/servers/${id}/members`), updateMember: (serverId, userId, data) => request('PATCH', `/servers/${serverId}/members/${userId}`, data), removeMember: (serverId, userId) => request('DELETE', `/servers/${serverId}/members/${userId}`), @@ -105,6 +106,7 @@ export const api = { sendRequest: (username) => request('POST', '/social/requests', { username }), updateRequest: (id, status) => request('PATCH', `/social/requests/${id}`, { status }), removeFriend: (id) => request('DELETE', `/social/friends/${id}`), + cancelRequest: (id) => request('DELETE', `/social/requests/${id}`), search: (q) => request('GET', `/social/search?q=${encodeURIComponent(q)}`), }, livekit: { diff --git a/packages/web/src/components/chat/FriendsPage.js b/packages/web/src/components/chat/FriendsPage.js index 3d8f7f7e..aa636dc6 100644 --- a/packages/web/src/components/chat/FriendsPage.js +++ b/packages/web/src/components/chat/FriendsPage.js @@ -1,20 +1,25 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useSocialStore } from '../../stores/socialStore'; +import { useServerStore } from '../../stores/serverStore'; import { Avatar } from '../ui/Avatar'; import { LoadingSpinner } from '../ui/LoadingSpinner'; +import { api } from '../../api/client'; export function FriendsPage() { const [activeTab, setActiveTab] = useState('online'); const [addUsername, setAddUsername] = useState(''); const [addStatus, setAddStatus] = useState(null); - const { friends, requests, isLoading, loadFriends, loadRequests, sendFriendRequest, updateFriendRequest, removeFriend } = useSocialStore(); + const navigate = useNavigate(); + const addDmChannel = useServerStore((s) => s.addDmChannel); + const { friends, requests, isLoading, loadFriends, loadRequests, sendFriendRequest, updateFriendRequest, cancelFriendRequest, removeFriend } = useSocialStore(); useEffect(() => { loadFriends(); loadRequests(); }, [loadFriends, loadRequests]); const onlineFriends = friends.filter(f => f.status !== 'offline'); - const pendingIncoming = requests.filter(r => r.status === 'pending' && r.toId !== r.fromId && r.user?.id === r.fromId); - const pendingOutgoing = requests.filter(r => r.status === 'pending' && r.fromId !== r.toId && r.user?.id === r.toId); + const pendingIncoming = requests.filter(r => r.status === 'pending' && r.user?.id === r.fromId); + const pendingOutgoing = requests.filter(r => r.status === 'pending' && r.user?.id === r.toId); const handleAddFriend = async (e) => { e.preventDefault(); if (!addUsername.trim()) @@ -28,32 +33,42 @@ export function FriendsPage() { setAddStatus({ type: 'error', message: err.message }); } }; + const handleOpenDm = async (friendId) => { + try { + const dmChannel = await api.dm.create({ userId: friendId }); + addDmChannel(dmChannel); + navigate(`/channels/@me/${dmChannel.id}`); + } + catch (err) { + console.error('Failed to open DM:', err); + } + }; const renderTabContent = () => { if (isLoading && friends.length === 0 && requests.length === 0) { return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) })); } switch (activeTab) { case 'online': - return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["Online \u2014 ", onlineFriends.length] }), onlineFriends.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: [_jsx("img", { src: "/friends-empty.svg", alt: "", className: "w-64 h-64 mb-4", onError: (e) => e.target.style.display = 'none' }), _jsx("p", { className: "text-discord-text-muted", children: "No one's around to play with Wumpus." })] })) : (onlineFriends.map(friend => (_jsx(FriendItem, { friend: friend, onRemove: () => removeFriend(friend.id) }, friend.id))))] })); + return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["Online \u2014 ", onlineFriends.length] }), onlineFriends.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: [_jsx("img", { src: "/friends-empty.svg", alt: "", className: "w-64 h-64 mb-4", onError: (e) => e.target.style.display = 'none' }), _jsx("p", { className: "text-discord-text-muted", children: "No one's around to play with Wumpus." })] })) : (onlineFriends.map(friend => (_jsx(FriendItem, { friend: friend, onRemove: () => removeFriend(friend.id), onDm: () => handleOpenDm(friend.id) }, friend.id))))] })); case 'all': - return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["All Friends \u2014 ", friends.length] }), friends.length === 0 ? (_jsx("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: _jsx("p", { className: "text-discord-text-muted", children: "Wumpus is waiting on friends. You can add them!" }) })) : (friends.map(friend => (_jsx(FriendItem, { friend: friend, onRemove: () => removeFriend(friend.id) }, friend.id))))] })); + return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["All Friends \u2014 ", friends.length] }), friends.length === 0 ? (_jsx("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: _jsx("p", { className: "text-discord-text-muted", children: "Wumpus is waiting on friends. You can add them!" }) })) : (friends.map(friend => (_jsx(FriendItem, { friend: friend, onRemove: () => removeFriend(friend.id), onDm: () => handleOpenDm(friend.id) }, friend.id))))] })); case 'pending': - return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["Pending \u2014 ", pendingIncoming.length + pendingOutgoing.length] }), [...pendingIncoming, ...pendingOutgoing].length === 0 ? (_jsx("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: _jsx("p", { className: "text-discord-text-muted", children: "There are no pending friend requests. Here's Wumpus for now!" }) })) : (_jsxs(_Fragment, { children: [pendingIncoming.map(req => (_jsx(RequestItem, { request: req, type: "incoming", onAction: (status) => updateFriendRequest(req.id, status) }, req.id))), pendingOutgoing.map(req => (_jsx(RequestItem, { request: req, type: "outgoing", onAction: () => { } }, req.id)))] }))] })); + return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["Pending \u2014 ", pendingIncoming.length + pendingOutgoing.length] }), [...pendingIncoming, ...pendingOutgoing].length === 0 ? (_jsx("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: _jsx("p", { className: "text-discord-text-muted", children: "There are no pending friend requests. Here's Wumpus for now!" }) })) : (_jsxs(_Fragment, { children: [pendingIncoming.map(req => (_jsx(RequestItem, { request: req, type: "incoming", onAccept: () => updateFriendRequest(req.id, 'accepted'), onDecline: () => updateFriendRequest(req.id, 'declined') }, req.id))), pendingOutgoing.map(req => (_jsx(RequestItem, { request: req, type: "outgoing", onCancel: () => cancelFriendRequest(req.id) }, req.id)))] }))] })); case 'add': return (_jsxs("div", { className: "flex-1 p-8", children: [_jsx("h2", { className: "text-base font-bold text-discord-text-primary uppercase mb-2", children: "Add Friend" }), _jsx("p", { className: "text-sm text-discord-text-muted mb-4", children: "You can add friends with their Opencord username." }), _jsxs("form", { onSubmit: handleAddFriend, className: "relative mb-8", children: [_jsx("input", { type: "text", placeholder: "You can add a friend with their username", value: addUsername, onChange: (e) => setAddUsername(e.target.value), className: "w-full bg-discord-bg-tertiary text-discord-text-primary px-4 py-3 rounded-lg border border-transparent focus:border-discord-text-link outline-none transition-all placeholder:text-discord-text-muted/50" }), _jsx("button", { type: "submit", disabled: !addUsername.trim() || isLoading, className: "absolute right-2 top-1.5 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple-hover disabled:opacity-50 disabled:bg-discord-blurple text-white text-sm font-medium rounded transition-colors", children: "Send Friend Request" })] }), addStatus && (_jsx("div", { className: `text-sm p-3 rounded-lg border ${addStatus.type === 'success' ? 'text-discord-green border-discord-green/20 bg-discord-green/5' : 'text-discord-red border-discord-red/20 bg-discord-red/5'}`, children: addStatus.message }))] })); } }; - return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary h-full", children: [_jsxs("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary/50 shadow-sm flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 mr-4", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("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" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: "Friends" })] }), _jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsxs("div", { className: "flex items-center gap-4 ml-2", children: [_jsx(TabButton, { active: activeTab === 'online', onClick: () => setActiveTab('online'), children: "Online" }), _jsx(TabButton, { active: activeTab === 'all', onClick: () => setActiveTab('all'), children: "All" }), _jsxs(TabButton, { active: activeTab === 'pending', onClick: () => setActiveTab('pending'), children: ["Pending", (pendingIncoming.length > 0) && (_jsx("span", { className: "ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none", children: pendingIncoming.length }))] }), _jsx("button", { onClick: () => setActiveTab('add'), className: `px-2 py-0.5 rounded text-[14px] font-medium transition-all ${activeTab === 'add' ? 'text-discord-green bg-transparent' : 'bg-discord-green text-white hover:bg-discord-green/90'}`, children: "Add Friend" })] })] }), renderTabContent()] })); + return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary h-full", children: [_jsxs("div", { className: "h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 mr-4", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("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" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: "Friends" })] }), _jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsxs("div", { className: "flex items-center gap-4 ml-2", children: [_jsx(TabButton, { active: activeTab === 'online', onClick: () => setActiveTab('online'), children: "Online" }), _jsx(TabButton, { active: activeTab === 'all', onClick: () => setActiveTab('all'), children: "All" }), _jsxs(TabButton, { active: activeTab === 'pending', onClick: () => setActiveTab('pending'), children: ["Pending", (pendingIncoming.length > 0) && (_jsx("span", { className: "ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none", children: pendingIncoming.length }))] }), _jsx("button", { onClick: () => setActiveTab('add'), className: `px-2 py-0.5 rounded text-[14px] font-medium transition-all ${activeTab === 'add' ? 'text-discord-green bg-transparent' : 'bg-discord-green text-white hover:bg-discord-green/90'}`, children: "Add Friend" })] })] }), renderTabContent()] })); } function TabButton({ children, active, onClick }) { return (_jsx("button", { onClick: onClick, className: `px-2 py-0.5 rounded-[4px] text-[16px] font-medium transition-colors ${active ? 'bg-discord-modifier-selected text-white' : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: children })); } -function FriendItem({ friend, onRemove }) { - return (_jsxs("div", { className: "flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-discord-modifier-hover group transition-colors border-t border-discord-bg-modifier-accent mx-2", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { src: friend.avatar, name: friend.displayName ?? friend.username, size: 32, status: friend.status }), _jsxs("div", { className: "flex flex-col leading-tight", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-discord-text-header font-semibold text-[15px]", children: friend.displayName ?? friend.username }), _jsxs("span", { className: "text-discord-text-muted text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium", children: ["@", friend.username] })] }), _jsx("span", { className: "text-[12px] text-discord-text-muted font-medium uppercase", children: friend.status })] })] }), _jsxs("div", { className: "flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity pr-2", children: [_jsx("button", { className: "w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-text-primary transition-colors", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-5H6V7h12v2z" }) }) }), _jsx("button", { onClick: (e) => { e.stopPropagation(); onRemove(); }, className: "w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-colors", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) })] })] })); +function FriendItem({ friend, onRemove, onDm }) { + return (_jsxs("div", { className: "flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-discord-modifier-hover group transition-colors border-t border-discord-modifier-accent mx-2", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { src: friend.avatar, name: friend.displayName ?? friend.username, size: 32, status: friend.status }), _jsxs("div", { className: "flex flex-col leading-tight", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-discord-text-primary font-semibold text-[15px]", children: friend.displayName ?? friend.username }), _jsxs("span", { className: "text-discord-text-muted text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium", children: ["@", friend.username] })] }), _jsx("span", { className: "text-[12px] text-discord-text-muted font-medium uppercase", children: friend.status })] })] }), _jsxs("div", { className: "flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity pr-2", children: [_jsx("button", { onClick: (e) => { e.stopPropagation(); onDm(); }, className: "w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Message", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-5H6V7h12v2z" }) }) }), _jsx("button", { onClick: (e) => { e.stopPropagation(); onRemove(); }, className: "w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-colors", title: "Remove Friend", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) })] })] })); } -function RequestItem({ request, type, onAction }) { +function RequestItem({ request, type, onAccept, onDecline, onCancel }) { const user = request.user; if (!user) return null; - return (_jsxs("div", { className: "flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-discord-bg-hover/50 group transition-colors border-t border-transparent hover:border-discord-bg-tertiary/30", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-discord-text-primary font-bold text-sm", children: user.displayName ?? user.username }), _jsxs("span", { className: "text-discord-text-muted text-xs", children: ["@", user.username] })] }), _jsx("span", { className: "text-xs text-discord-text-muted", children: type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request' })] })] }), _jsx("div", { className: "flex items-center gap-2", children: type === 'incoming' ? (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAction('accepted'), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-green hover:bg-discord-green hover:text-white transition-all shadow-sm", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) }) }), _jsx("button", { onClick: () => onAction('declined'), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-red hover:bg-discord-red hover:text-white transition-all shadow-sm", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) })] })) : (_jsx("button", { className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-all shadow-sm", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) })) })] })); + return (_jsxs("div", { className: "flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-discord-modifier-hover group transition-colors border-t border-discord-modifier-accent mx-2", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-discord-text-primary font-bold text-sm", children: user.displayName ?? user.username }), _jsxs("span", { className: "text-discord-text-muted text-xs", children: ["@", user.username] })] }), _jsx("span", { className: "text-xs text-discord-text-muted", children: type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request' })] })] }), _jsx("div", { className: "flex items-center gap-2", children: type === 'incoming' ? (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAccept?.(), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-green hover:bg-discord-green hover:text-white transition-all", title: "Accept", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) }) }), _jsx("button", { onClick: () => onDecline?.(), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-red hover:bg-discord-red hover:text-white transition-all", title: "Decline", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) })] })) : (_jsx("button", { onClick: () => onCancel?.(), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-all", title: "Cancel Request", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) })) })] })); } diff --git a/packages/web/src/components/layout/AppLayout.js b/packages/web/src/components/layout/AppLayout.js index 262c5d01..b3e6edca 100644 --- a/packages/web/src/components/layout/AppLayout.js +++ b/packages/web/src/components/layout/AppLayout.js @@ -36,7 +36,7 @@ export function AppLayout() { const closeUserProfile = useUIStore((s) => s.closeUserProfile); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const setParticipants = useVoiceStore((s) => s.setParticipants); - const { connect: connectVoice, disconnect: disconnectVoice, participants: voiceParticipants, toggleMic, toggleCamera, toggleScreenShare } = useLiveKit(); + const { connect: connectVoice, disconnect: disconnectVoice, participants: voiceParticipants, } = useLiveKit(); // Initialize WebSocket useWebSocket(); // Sync participants to store @@ -88,5 +88,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, { onToggleMic: toggleMic, onToggleCamera: toggleCamera, onToggleScreenShare: toggleScreenShare })] }), _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 border-l border-discord-modifier-accent", 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(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(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 })] }))] })); } diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 5a2afd78..67677059 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -38,13 +38,10 @@ export function AppLayout() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const setParticipants = useVoiceStore((s) => s.setParticipants); - const { - connect: connectVoice, - disconnect: disconnectVoice, + const { + connect: connectVoice, + disconnect: disconnectVoice, participants: voiceParticipants, - toggleMic, - toggleCamera, - toggleScreenShare } = useLiveKit(); // Initialize WebSocket @@ -118,11 +115,7 @@ export function AppLayout() { {/* Server sidebar - always visible on desktop, toggled on mobile */}
- +
{/* Main content area */} diff --git a/packages/web/src/components/layout/ChannelSidebar.js b/packages/web/src/components/layout/ChannelSidebar.js index cff15bb5..cb01ab21 100644 --- a/packages/web/src/components/layout/ChannelSidebar.js +++ b/packages/web/src/components/layout/ChannelSidebar.js @@ -9,7 +9,7 @@ import { VoiceControls } from '../voice/VoiceControls'; import { useVoiceStore } from '../../stores/voiceStore'; import { Avatar } from '../ui/Avatar'; import { wsSend } from '../../hooks/useWebSocket'; -export function ChannelSidebar({ onToggleMic, onToggleCamera, onToggleScreenShare }) { +export function ChannelSidebar() { const servers = useServerStore((s) => s.servers); const currentServerId = useServerStore((s) => s.currentServerId); const channels = useServerStore((s) => s.channels); @@ -36,12 +36,14 @@ export function ChannelSidebar({ onToggleMic, onToggleCamera, onToggleScreenShar navigate('/channels/@me'); }; const handleVoiceJoin = (channelId) => { + // Don't re-join the same channel — prevents duplicate LiveKit connections + if (currentVoiceChannelId === channelId) { + navigate(`/channels/${currentServerId}/${channelId}`); + return; + } setCurrentVoiceChannel(channelId); wsSend({ type: 'voice_join', channelId }); - }; - const handleVoiceDisconnect = () => { - setCurrentVoiceChannel(null); - wsSend({ type: 'voice_leave' }); + navigate(`/channels/${currentServerId}/${channelId}`); }; if (!server) { return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none", children: [_jsx("div", { className: "h-12 px-4 flex items-center shadow-header z-10", children: _jsx("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", children: "Find or start a conversation" }) }), _jsxs("div", { className: "flex-1 overflow-y-auto pt-4 px-2 no-scrollbar", children: [_jsxs("div", { onClick: handleHomeClick, className: `flex items-center gap-3 px-2 h-10 rounded-[4px] cursor-pointer mb-0.5 transition-colors group ${!currentChannelId @@ -63,7 +65,7 @@ export function ChannelSidebar({ onToggleMic, onToggleCamera, onToggleScreenShar : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("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" }) }), _jsx("span", { className: "truncate font-medium text-[16px]", children: channel.name })] }, channel.id))) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1 group cursor-pointer", children: [_jsxs("div", { className: "flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-70", children: _jsx("path", { d: "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" }) }), _jsx("span", { className: "text-[12px] font-bold uppercase tracking-wider", children: "Voice Channels" })] }), isAdminUser && (_jsx("button", { onClick: (e) => { e.stopPropagation(); openModal('createChannel'); - }, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), _jsx("div", { className: "space-y-[2px]", children: voiceChannels.map((channel) => (_jsx(VoiceChannel, { channelId: channel.id, channelName: channel.name, onClick: () => handleVoiceJoin(channel.id) }, channel.id))) })] }), _jsx("div", { className: "pt-2", children: _jsxs("button", { onClick: () => openModal('invite'), className: "w-full flex items-center gap-2 px-2 h-8 rounded-[4px] text-[15px] font-medium text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover transition-colors", children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-60", children: _jsx("path", { d: "M13 13v5h-2v-5H6v-2h5V6h2v5h5v2h-5z" }) }), "Invite People"] }) })] }), currentVoiceChannelId && (_jsx(VoiceControls, { onDisconnect: handleVoiceDisconnect, onToggleMic: onToggleMic, onToggleCamera: onToggleCamera, onToggleScreenShare: onToggleScreenShare })), user && (_jsxs("div", { className: "h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-bold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx(UserAreaButton, { title: "Mute", children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" })] }) }), _jsx(UserAreaButton, { title: "Deafen", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }) }) }), _jsx(UserAreaButton, { title: "User Settings", onClick: () => openModal('userSettings'), children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] })] }))] })); + }, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), _jsx("div", { className: "space-y-[2px]", children: voiceChannels.map((channel) => (_jsx(VoiceChannel, { channelId: channel.id, channelName: channel.name, onClick: () => handleVoiceJoin(channel.id) }, channel.id))) })] }), _jsx("div", { className: "pt-2", children: _jsxs("button", { onClick: () => openModal('invite'), className: "w-full flex items-center gap-2 px-2 h-8 rounded-[4px] text-[15px] font-medium text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover transition-colors", children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-60", children: _jsx("path", { d: "M13 13v5h-2v-5H6v-2h5V6h2v5h5v2h-5z" }) }), "Invite People"] }) })] }), currentVoiceChannelId && _jsx(VoiceControls, {}), user && (_jsxs("div", { className: "h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-bold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx(UserAreaButton, { title: "Mute", children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" })] }) }), _jsx(UserAreaButton, { title: "Deafen", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }) }) }), _jsx(UserAreaButton, { title: "User Settings", onClick: () => openModal('userSettings'), children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] })] }))] })); } function UserAreaButton({ children, title, onClick }) { return (_jsx("button", { onClick: onClick, className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover rounded-[4px] transition-all", title: title, children: children })); diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 431978e5..03480a29 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -10,13 +10,7 @@ import { useVoiceStore } from '../../stores/voiceStore'; import { Avatar } from '../ui/Avatar'; import { wsSend } from '../../hooks/useWebSocket'; -interface ChannelSidebarProps { - onToggleMic: () => void; - onToggleCamera: () => void; - onToggleScreenShare: () => void; -} - -export function ChannelSidebar({ onToggleMic, onToggleCamera, onToggleScreenShare }: ChannelSidebarProps) { +export function ChannelSidebar() { const servers = useServerStore((s) => s.servers); const currentServerId = useServerStore((s) => s.currentServerId); const channels = useServerStore((s) => s.channels); @@ -48,13 +42,14 @@ export function ChannelSidebar({ onToggleMic, onToggleCamera, onToggleScreenShar }; const handleVoiceJoin = (channelId: string) => { + // Don't re-join the same channel — prevents duplicate LiveKit connections + if (currentVoiceChannelId === channelId) { + navigate(`/channels/${currentServerId}/${channelId}`); + return; + } setCurrentVoiceChannel(channelId); wsSend({ type: 'voice_join', channelId }); - }; - - const handleVoiceDisconnect = () => { - setCurrentVoiceChannel(null); - wsSend({ type: 'voice_leave' }); + navigate(`/channels/${currentServerId}/${channelId}`); }; if (!server) { @@ -262,15 +257,8 @@ export function ChannelSidebar({ onToggleMic, onToggleCamera, onToggleScreenShar - {/* Voice controls */} - {currentVoiceChannelId && ( - - )} + {/* Voice controls — VoiceControls reads state and calls LiveKit SDK directly */} + {currentVoiceChannelId && } {/* User area */} {user && ( diff --git a/packages/web/src/components/layout/MainContent.js b/packages/web/src/components/layout/MainContent.js index 07b19cc4..eba0dc6a 100644 --- a/packages/web/src/components/layout/MainContent.js +++ b/packages/web/src/components/layout/MainContent.js @@ -8,6 +8,7 @@ import { TypingIndicator } from '../chat/TypingIndicator'; import { VoiceGrid } from '../voice/VoiceGrid'; import { FriendsPage } from '../chat/FriendsPage'; import { useVoiceStore } from '../../stores/voiceStore'; +import { wsSend } from '../../hooks/useWebSocket'; export function MainContent() { const channels = useServerStore((s) => s.channels); const currentChannelId = useChatStore((s) => s.currentChannelId); @@ -15,6 +16,7 @@ export function MainContent() { const toggleMemberList = useUIStore((s) => s.toggleMemberList); const memberListOpen = useUIStore((s) => s.memberListOpen); const participants = useVoiceStore((s) => s.participants); + const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const showDms = useUIStore((s) => s.showDms); const channel = channels.find(c => c.id === currentChannelId); const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video'; @@ -23,15 +25,22 @@ export function MainContent() { if (!currentChannelId) { return _jsx(FriendsPage, {}); } - return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary/50 shadow-sm flex-shrink-0 z-10", children: _jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("span", { className: "text-discord-text-muted font-bold text-lg", children: "@" }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: "Direct Message" })] }) }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: "Direct Message" })] })); + return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsx("div", { className: "h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10", children: _jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("span", { className: "text-discord-text-muted font-bold text-lg", children: "@" }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: "Direct Message" })] }) }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: "Direct Message" })] })); } // No channel selected if (!currentChannelId || !channel) { - return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm", children: _jsx("span", { className: "text-discord-text-muted", children: "Select a channel" }) }), _jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsx("p", { children: "Select a text or voice channel to get started" }) })] })); + return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center shadow-header", children: _jsx("span", { className: "text-discord-text-muted", children: "Select a channel" }) }), _jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsx("p", { children: "Select a text or voice channel to get started" }) })] })); } // Voice/Video channel view if (isVoiceChannel) { - return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("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" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name })] }) }), _jsx(VoiceGrid, { participants: participants })] })); + const isInThisChannel = currentVoiceChannelId === currentChannelId; + const isMuted = useVoiceStore.getState().isMuted; + const isCameraOn = useVoiceStore.getState().isCameraOn; + const isScreenSharing = useVoiceStore.getState().isScreenSharing; + return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between shadow-header", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("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" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name }), isInThisChannel && (_jsx("span", { className: "text-xs text-discord-green font-medium ml-2", children: "Connected" }))] }) }), isInThisChannel ? (_jsx(VoiceGrid, { participants: participants })) : (_jsxs("div", { className: "flex-1 flex flex-col items-center justify-center gap-6", children: [_jsxs("div", { className: "text-center", children: [_jsx("svg", { width: "80", height: "80", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted mx-auto mb-4 opacity-40", children: _jsx("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.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" }) }), _jsx("h2", { className: "text-[24px] font-bold text-discord-text-header mb-2", children: channel.name }), _jsx("p", { className: "text-discord-text-muted text-[14px]", children: "No one is currently in this voice channel." })] }), _jsx("button", { onClick: () => { + useVoiceStore.getState().setCurrentVoiceChannel(currentChannelId); + wsSend({ type: 'voice_join', channelId: currentChannelId }); + }, className: "px-8 py-3 bg-discord-green hover:bg-discord-green/80 text-white font-medium rounded-[3px] transition-colors text-[14px]", children: "Join Voice" })] }))] })); } // Text channel view return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("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" }) }), _jsx("span", { className: "font-bold text-discord-text-primary truncate leading-tight", children: channel.name }), channel.topic && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsx("span", { className: "text-xs text-discord-text-muted truncate leading-tight", children: channel.topic })] }))] }), _jsx("div", { className: "flex items-center gap-4 flex-shrink-0", children: _jsx("button", { onClick: toggleMemberList, className: `p-1 transition-colors ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'}`, title: "Toggle Member List", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) }) })] }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: channel.name })] })); diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index d02d8524..739f73fb 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -8,6 +8,7 @@ import { TypingIndicator } from '../chat/TypingIndicator'; import { VoiceGrid } from '../voice/VoiceGrid'; import { FriendsPage } from '../chat/FriendsPage'; import { useVoiceStore } from '../../stores/voiceStore'; +import { wsSend } from '../../hooks/useWebSocket'; export function MainContent() { const channels = useServerStore((s) => s.channels); @@ -16,6 +17,7 @@ export function MainContent() { const toggleMemberList = useUIStore((s) => s.toggleMemberList); const memberListOpen = useUIStore((s) => s.memberListOpen); const participants = useVoiceStore((s) => s.participants); + const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const showDms = useUIStore((s) => s.showDms); const channel = channels.find(c => c.id === currentChannelId); @@ -58,6 +60,11 @@ 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 (
@@ -66,9 +73,33 @@ export function MainContent() { {channel.name} + {isInThisChannel && ( + Connected + )}
- + {isInThisChannel ? ( + + ) : ( +
+
+ + + +

{channel.name}

+

No one is currently in this voice channel.

+
+ +
+ )} ); } diff --git a/packages/web/src/components/modals/InviteModal.js b/packages/web/src/components/modals/InviteModal.js index d534cd2d..316f40f6 100644 --- a/packages/web/src/components/modals/InviteModal.js +++ b/packages/web/src/components/modals/InviteModal.js @@ -7,6 +7,7 @@ export function InviteModal() { const [inviteCode, setInviteCode] = useState(''); const [copied, setCopied] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); const activeModal = useUIStore((s) => s.activeModal); const closeModal = useUIStore((s) => s.closeModal); const generateInvite = useServerStore((s) => s.generateInvite); @@ -16,12 +17,16 @@ export function InviteModal() { useEffect(() => { if (isOpen && currentServerId) { setIsLoading(true); + setError(''); generateInvite(currentServerId) .then(code => { setInviteCode(code); setIsLoading(false); }) - .catch(() => setIsLoading(false)); + .catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to generate invite link'); + setIsLoading(false); + }); } }, [isOpen, currentServerId, generateInvite]); const handleCopy = async () => { @@ -33,7 +38,6 @@ export function InviteModal() { setTimeout(() => setCopied(false), 2000); } catch { - // Fallback: select the text const input = document.querySelector('.invite-code-input'); if (input) { input.select(); @@ -43,7 +47,7 @@ export function InviteModal() { } } }; - return (_jsxs(Modal, { isOpen: isOpen, onClose: closeModal, title: "Invite Friends", children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Share this invite link with friends to let them join your server." }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "text", value: isLoading ? 'Generating...' : inviteUrl, readOnly: true, className: "invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-xs" }), _jsx("button", { onClick: handleCopy, disabled: isLoading || !inviteUrl, className: `px-4 py-2 text-sm font-medium rounded transition-colors ${copied + return (_jsxs(Modal, { isOpen: isOpen, onClose: closeModal, title: "Invite Friends", children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Share this invite link with friends to let them join your server." }), error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "text", value: isLoading ? 'Generating...' : inviteUrl, readOnly: true, className: "invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-xs" }), _jsx("button", { onClick: handleCopy, disabled: isLoading || !inviteUrl, className: `px-4 py-2 text-sm font-medium rounded transition-colors ${copied ? 'bg-discord-green text-white' : 'bg-discord-blurple hover:bg-discord-blurple-hover text-white'}`, children: copied ? 'Copied!' : 'Copy' })] })] })); } diff --git a/packages/web/src/components/modals/JoinServer.js b/packages/web/src/components/modals/JoinServer.js index 8cc30b14..ad26681b 100644 --- a/packages/web/src/components/modals/JoinServer.js +++ b/packages/web/src/components/modals/JoinServer.js @@ -11,7 +11,7 @@ export function JoinServerModal() { const [isLoading, setIsLoading] = useState(false); const activeModal = useUIStore((s) => s.activeModal); const closeModal = useUIStore((s) => s.closeModal); - const loadServers = useServerStore((s) => s.loadServers); + const joinByCode = useServerStore((s) => s.joinByCode); const navigate = useNavigate(); const isOpen = activeModal === 'joinServer'; useEffect(() => { @@ -22,27 +22,14 @@ export function JoinServerModal() { const handleSubmit = async (e) => { e.preventDefault(); setError(''); - if (!inviteCode.trim()) { + const code = inviteCode.trim(); + if (!code) { setError('Invite code is required'); return; } setIsLoading(true); try { - const token = localStorage.getItem('opencord_token'); - const response = await fetch('/api/servers/join', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ inviteCode: inviteCode.trim() }), - }); - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Failed to join server'); - } - const server = await response.json(); - await loadServers(); + const server = await joinByCode(code); closeModal(); setInviteCode(''); navigate(`/channels/${server.id}`); @@ -54,5 +41,5 @@ export function JoinServerModal() { setIsLoading(false); } }; - return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Join a Server", children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Enter an invite code to join an existing server." }), error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Invite Code" }), _jsx("input", { type: "text", value: inviteCode, onChange: (e) => setInviteCode(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "e.g. abc123", autoFocus: true })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Joining...' : 'Join Server' })] })] }) })); + return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Join a Server", children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Enter an invite code to join an existing server." }), error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Invite Code" }), _jsx("input", { type: "text", value: inviteCode, onChange: (e) => setInviteCode(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "e.g. abc123", autoFocus: true })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Joining...' : 'Join Server' })] })] }) })); } diff --git a/packages/web/src/components/ui/Avatar.js b/packages/web/src/components/ui/Avatar.js index 07090714..44905f82 100644 --- a/packages/web/src/components/ui/Avatar.js +++ b/packages/web/src/components/ui/Avatar.js @@ -4,7 +4,7 @@ const statusColors = { online: 'bg-discord-green', idle: 'bg-discord-yellow', dnd: 'bg-discord-red', - offline: 'bg-gray-500', + offline: 'bg-discord-text-muted', }; export function Avatar({ src, name, size = 40, status, className = '', onClick, user }) { const openUserProfile = useUIStore((s) => s.openUserProfile); @@ -31,7 +31,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick, if (fallback) fallback.style.display = 'flex'; } - } })) : null, _jsx("div", { className: `avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`, style: src ? { display: 'none' } : undefined, children: initials }), status && (_jsx("div", { className: `absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-[#2b2d31] ${statusColors[status] ?? 'bg-gray-500'}`, style: { + } })) : null, _jsx("div", { className: `avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`, style: src ? { display: 'none' } : undefined, children: initials }), status && (_jsx("div", { className: `absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-[#2b2d31] ${statusColors[status] ?? 'bg-discord-text-muted'}`, style: { width: size * 0.35, height: size * 0.35, minWidth: 12, diff --git a/packages/web/src/components/ui/ContextMenu.js b/packages/web/src/components/ui/ContextMenu.js index 70bb1b6b..8376f36d 100644 --- a/packages/web/src/components/ui/ContextMenu.js +++ b/packages/web/src/components/ui/ContextMenu.js @@ -37,7 +37,7 @@ export function ContextMenu({ items, children }) { } } }, [isOpen, position]); - return (_jsxs(_Fragment, { children: [_jsx("div", { onContextMenu: handleContextMenu, children: children }), isOpen && (_jsx("div", { ref: menuRef, className: "fixed z-50 min-w-[180px] py-1.5 bg-[#111214] rounded-md shadow-xl border border-gray-800 animate-fade-in", style: { left: position.x, top: position.y }, children: items.map((item, i) => (_jsxs("button", { className: `w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 ${item.danger + return (_jsxs(_Fragment, { children: [_jsx("div", { onContextMenu: handleContextMenu, children: children }), isOpen && (_jsx("div", { ref: menuRef, className: "fixed z-50 min-w-[180px] py-1.5 bg-discord-bg-floating rounded-md shadow-elevation-high animate-fade-in", style: { left: position.x, top: position.y }, children: items.map((item, i) => (_jsxs("button", { className: `w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 ${item.danger ? 'text-discord-red hover:bg-discord-red hover:text-white' : 'text-discord-text-secondary hover:bg-discord-blurple hover:text-white'}`, style: { width: 'calc(100% - 12px)' }, onClick: (e) => { e.stopPropagation(); diff --git a/packages/web/src/components/ui/Tooltip.js b/packages/web/src/components/ui/Tooltip.js index e5ef16a4..8a4238d0 100644 --- a/packages/web/src/components/ui/Tooltip.js +++ b/packages/web/src/components/ui/Tooltip.js @@ -23,5 +23,5 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }) bottom: 'top-full left-1/2 -translate-x-1/2 mt-2', left: 'right-full top-1/2 -translate-y-1/2 mr-2', }; - return (_jsxs("div", { className: "relative inline-flex", onMouseEnter: show, onMouseLeave: hide, children: [children, isVisible && (_jsx("div", { className: `absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-gray-900 rounded-md shadow-lg whitespace-nowrap pointer-events-none ${positionClasses[position]}`, children: content }))] })); + return (_jsxs("div", { className: "relative inline-flex", onMouseEnter: show, onMouseLeave: hide, children: [children, isVisible && (_jsx("div", { className: `absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-discord-bg-floating rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`, children: content }))] })); } diff --git a/packages/web/src/components/voice/VoiceControls.js b/packages/web/src/components/voice/VoiceControls.js index 657d68db..2a96a3b0 100644 --- a/packages/web/src/components/voice/VoiceControls.js +++ b/packages/web/src/components/voice/VoiceControls.js @@ -1,7 +1,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { useVoiceStore } from '../../stores/voiceStore'; import { useServerStore } from '../../stores/serverStore'; -export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onToggleScreenShare }) { +import { getActiveRoom } from '../../hooks/useLiveKit'; +import { wsSend } from '../../hooks/useWebSocket'; +export function VoiceControls() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const isMuted = useVoiceStore((s) => s.isMuted); const isDeafened = useVoiceStore((s) => s.isDeafened); @@ -11,27 +13,68 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const toggleCamera = useVoiceStore((s) => s.toggleCamera); const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); + const connectionError = useVoiceStore((s) => s.connectionError); + const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected); const channels = useServerStore((s) => s.channels); if (!currentVoiceChannelId) return null; const channel = channels.find(c => c.id === currentVoiceChannelId); const channelName = channel?.name ?? 'Voice Channel'; - const handleMic = () => { - toggleMic(); - onToggleMic(); + const handleMic = async () => { + const room = getActiveRoom(); + if (!room) { + console.warn('[VoiceControls] handleMic: no active room'); + return; + } + try { + // isMuted is the pre-toggle value: if true → we want to unmute → enable mic + await room.localParticipant.setMicrophoneEnabled(isMuted); + toggleMic(); + } + catch (err) { + console.error('[VoiceControls] Failed to toggle mic:', err); + } }; const handleDeafen = () => { toggleDeafen(); }; - const handleCamera = () => { - toggleCamera(); - onToggleCamera(); + const handleCamera = async () => { + const room = getActiveRoom(); + if (!room) { + console.warn('[VoiceControls] handleCamera: no active room'); + return; + } + try { + // isCameraOn is pre-toggle: if false → enable camera + await room.localParticipant.setCameraEnabled(!isCameraOn); + toggleCamera(); + } + catch (err) { + console.error('[VoiceControls] Failed to toggle camera:', err); + } }; - const handleScreenShare = () => { - toggleScreenShare(); - onToggleScreenShare(); + const handleScreenShare = async () => { + const room = getActiveRoom(); + if (!room) { + console.warn('[VoiceControls] handleScreenShare: no active room'); + return; + } + try { + await room.localParticipant.setScreenShareEnabled(!isScreenSharing); + toggleScreenShare(); + } + catch (err) { + console.error('[VoiceControls] Failed to toggle screen share:', err); + } }; - return (_jsxs("div", { className: "bg-discord-bg-secondary border-t border-discord-bg-tertiary p-2", children: [_jsx("div", { className: "flex items-center justify-between px-1 mb-2", children: _jsxs("div", { children: [_jsxs("div", { className: "text-xs font-medium text-discord-green flex items-center gap-1", children: [_jsx("svg", { width: "12", height: "12", 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 2z" }) }), "Voice Connected"] }), _jsx("div", { className: "text-xs text-discord-text-muted truncate", children: channelName })] }) }), _jsxs("div", { className: "flex items-center justify-center gap-2", children: [_jsx("button", { onClick: handleMic, className: `p-2 rounded-full transition-colors ${isMuted + const handleDisconnect = () => { + // Send WS leave event + wsSend({ type: 'voice_leave' }); + // Reset the entire voice store (sets currentVoiceChannelId to null, + // which triggers AppLayout's useEffect to call useLiveKit.disconnect) + useVoiceStore.getState().reset(); + }; + return (_jsxs("div", { className: "bg-discord-bg-secondary border-t border-discord-bg-tertiary p-2", children: [_jsx("div", { className: "flex items-center justify-between px-1 mb-2", children: _jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: `text-xs font-medium flex items-center gap-1 ${connectionError ? 'text-discord-red' : isLiveKitConnected ? 'text-discord-green' : 'text-yellow-500'}`, children: [_jsx("svg", { width: "12", height: "12", 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 2z" }) }), connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'] }), _jsx("div", { className: "text-xs text-discord-text-muted truncate", children: connectionError ? connectionError : channelName })] }) }), _jsxs("div", { className: "flex items-center justify-center gap-2", children: [_jsx("button", { onClick: handleMic, className: `p-2 rounded-full transition-colors ${isMuted ? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30' : 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isMuted ? 'Unmute' : 'Mute', children: isMuted ? (_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("path", { d: "M2.1 2.1L1.4 2.8L7.6 9L7 12C7 14.8 9.2 17 12 17C12.9 17 13.7 16.7 14.4 16.3L16.2 18.1C15 18.9 13.6 19.4 12 19.5V22H14V24H10V22H12V19.5C8.4 19.1 5.6 16.1 5 12.5H7C7.5 14.8 9.5 16.5 12 16.5C12.5 16.5 13 16.4 13.5 16.2L14.7 17.4C13.9 17.8 13 18 12 18C8.7 18 6 15.3 6 12H4C4 15.7 7 18.8 11 19.4V22H10V24H14V22H13V19.4C14 19.3 14.9 18.9 15.7 18.4L21.9 24.6L22.6 23.9L2.1 2.1Z" })] })) : (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2ZM17 12C17 14.76 14.76 17 12 17S7 14.76 7 12H5C5 15.53 7.61 18.43 11 18.92V22H13V18.92C16.39 18.43 19 15.53 19 12H17Z" }) })) }), _jsx("button", { onClick: handleDeafen, className: `p-2 rounded-full transition-colors ${isDeafened ? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30' @@ -39,5 +82,5 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog ? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30' : 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }) }), _jsx("button", { onClick: handleScreenShare, className: `p-2 rounded-full transition-colors ${isScreenSharing ? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30' - : 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }) }) }), _jsx("button", { onClick: onDisconnect, className: "p-2 rounded-full bg-discord-red/20 text-discord-red hover:bg-discord-red/30 transition-colors", title: "Disconnect", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] })] })); + : 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }) }) }), _jsx("button", { onClick: handleDisconnect, className: "p-2 rounded-full bg-discord-red/20 text-discord-red hover:bg-discord-red/30 transition-colors", title: "Disconnect", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] })] })); } diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index 31a9b8f8..34b0b8bb 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -1,15 +1,10 @@ import React from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; import { useServerStore } from '../../stores/serverStore'; +import { getActiveRoom } from '../../hooks/useLiveKit'; +import { wsSend } from '../../hooks/useWebSocket'; -interface VoiceControlsProps { - onDisconnect: () => void; - onToggleMic: () => void; - onToggleCamera: () => void; - onToggleScreenShare: () => void; -} - -export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onToggleScreenShare }: VoiceControlsProps) { +export function VoiceControls() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const isMuted = useVoiceStore((s) => s.isMuted); const isDeafened = useVoiceStore((s) => s.isDeafened); @@ -19,6 +14,8 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const toggleCamera = useVoiceStore((s) => s.toggleCamera); const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); + const connectionError = useVoiceStore((s) => s.connectionError); + const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected); const channels = useServerStore((s) => s.channels); if (!currentVoiceChannelId) return null; @@ -26,37 +23,74 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog const channel = channels.find(c => c.id === currentVoiceChannelId); const channelName = channel?.name ?? 'Voice Channel'; - const handleMic = () => { - toggleMic(); - onToggleMic(); + const handleMic = async () => { + const room = getActiveRoom(); + if (!room) { + console.warn('[VoiceControls] handleMic: no active room'); + return; + } + try { + // isMuted is the pre-toggle value: if true → we want to unmute → enable mic + await room.localParticipant.setMicrophoneEnabled(isMuted); + toggleMic(); + } catch (err) { + console.error('[VoiceControls] Failed to toggle mic:', err); + } }; const handleDeafen = () => { toggleDeafen(); }; - const handleCamera = () => { - toggleCamera(); - onToggleCamera(); + const handleCamera = async () => { + const room = getActiveRoom(); + if (!room) { + console.warn('[VoiceControls] handleCamera: no active room'); + return; + } + try { + // isCameraOn is pre-toggle: if false → enable camera + await room.localParticipant.setCameraEnabled(!isCameraOn); + toggleCamera(); + } catch (err) { + console.error('[VoiceControls] Failed to toggle camera:', err); + } }; - const handleScreenShare = () => { - toggleScreenShare(); - onToggleScreenShare(); + const handleScreenShare = async () => { + const room = getActiveRoom(); + if (!room) { + console.warn('[VoiceControls] handleScreenShare: no active room'); + return; + } + try { + await room.localParticipant.setScreenShareEnabled(!isScreenSharing); + toggleScreenShare(); + } catch (err) { + console.error('[VoiceControls] Failed to toggle screen share:', err); + } + }; + + const handleDisconnect = () => { + // Send WS leave event + wsSend({ type: 'voice_leave' }); + // Reset the entire voice store (sets currentVoiceChannelId to null, + // which triggers AppLayout's useEffect to call useLiveKit.disconnect) + useVoiceStore.getState().reset(); }; return (
-
-
+
+
- Voice Connected + {connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
- {channelName} + {connectionError ? connectionError : channelName}
@@ -131,7 +165,7 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog {/* Disconnect */}