diff --git a/concord-client/bun.lock b/concord-client/bun.lock index dbd2a8c..89b916f 100644 --- a/concord-client/bun.lock +++ b/concord-client/bun.lock @@ -29,6 +29,7 @@ "react-markdown": "^10.1.0", "react-router": "^7.9.3", "react-syntax-highlighter": "^15.6.6", + "socket.io-client": "^4.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", @@ -40,6 +41,7 @@ "@types/react": "^18.2.64", "@types/react-dom": "^18.2.21", "@types/react-syntax-highlighter": "^15.5.13", + "@types/socket.io-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", "@vitejs/plugin-react": "^4.2.1", @@ -331,6 +333,8 @@ "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.13", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.18", "source-map-js": "^1.2.1", "tailwindcss": "4.1.13" } }, "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw=="], @@ -419,6 +423,8 @@ "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/socket.io-client": ["@types/socket.io-client@3.0.0", "", { "dependencies": { "socket.io-client": "*" } }, "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], @@ -659,6 +665,10 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1191,6 +1201,10 @@ "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + "socket.io-client": ["socket.io-client@4.8.1", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ=="], + + "socket.io-parser": ["socket.io-parser@4.2.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" } }, "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -1317,8 +1331,12 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1391,6 +1409,8 @@ "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], @@ -1433,6 +1453,10 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + + "socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], diff --git a/concord-client/package.json b/concord-client/package.json index 63a3cbf..0c74498 100644 --- a/concord-client/package.json +++ b/concord-client/package.json @@ -35,6 +35,7 @@ "react-markdown": "^10.1.0", "react-router": "^7.9.3", "react-syntax-highlighter": "^15.6.6", + "socket.io-client": "^4.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", @@ -46,6 +47,7 @@ "@types/react": "^18.2.64", "@types/react-dom": "^18.2.21", "@types/react-syntax-highlighter": "^15.5.13", + "@types/socket.io-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", "@vitejs/plugin-react": "^4.2.1", diff --git a/concord-client/src/App.tsx b/concord-client/src/App.tsx index 8103b75..4c5a7db 100644 --- a/concord-client/src/App.tsx +++ b/concord-client/src/App.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; @@ -10,11 +10,13 @@ import LoginPage from "@/pages/LoginPage"; import ChatPage from "@/pages/ChatPage"; import SettingsPage from "@/pages/SettingsPage"; import NotFoundPage from "@/pages/NotFoundPage"; +import { useVoiceStore } from "@/stores/voiceStore"; import { queryClient } from "@/lib/api-client"; import { useAuthStore } from "@/stores/authStore"; import ErrorBoundary from "@/components/common/ErrorBoundary"; import { Home } from "lucide-react"; +import { Socket } from "socket.io-client"; // Protected Route wrapper const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ @@ -50,7 +52,16 @@ const HomePage: React.FC = () => { ); }; -function App() { +function App(props: { socket: Socket }) { + const initVoiceStore = useVoiceStore((state) => state.init); + + useEffect(() => { + initVoiceStore(props.socket); + return () => { + useVoiceStore.getState().cleanup(); + }; + }, [props.socket, initVoiceStore]); + return ( diff --git a/concord-client/src/components/channel/ChannelItem.tsx b/concord-client/src/components/channel/ChannelItem.tsx new file mode 100644 index 0000000..12ab985 --- /dev/null +++ b/concord-client/src/components/channel/ChannelItem.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { Hash, Volume2 } from "lucide-react"; +import { useNavigate, useParams } from "react-router"; +import { Channel } from "@/lib/api-client"; +import { useVoiceStore } from "@/stores/voiceStore"; +import { useInstanceMembers } from "@/hooks/useServers"; +import { useAuthStore } from "@/stores/authStore"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +interface ChannelItemProps { + channel: Channel; +} + +const ChannelItem: React.FC = ({ channel }) => { + const { instanceId, channelId: activeChannelId } = useParams(); + const navigate = useNavigate(); + + // Voice store hooks + const { + joinChannel, + leaveChannel, + activeVoiceChannelId, + remoteStreams, + localStream, + } = useVoiceStore(); + + // Data hooks + const { data: members } = useInstanceMembers(instanceId); + const { user: currentUser, token } = useAuthStore(); // Get token from auth store + + const isConnectedToThisChannel = activeVoiceChannelId === channel.id; + const isActive = activeChannelId === channel.id; + + const handleChannelClick = () => { + if (channel.type === "text") { + navigate(`/channels/${instanceId}/${channel.id}`); + } else if (channel.type === "voice") { + if (isConnectedToThisChannel) { + leaveChannel(); + } else if (currentUser && token) { + console.log({ + channelId: channel.id, + currentUser: currentUser.id, + token: token, + }); + joinChannel(channel.id, currentUser.id, token); + } + } + }; + + const Icon = channel.type === "voice" ? Volume2 : Hash; + const connectedUserIds = Array.from(remoteStreams.keys()); + + return ( +
+ + + {/* Render connected users for this voice channel */} + {isConnectedToThisChannel && ( +
+ {/* Current User */} + {localStream && currentUser && ( +
+ + + + {currentUser.username.slice(0, 2)} + + + + {currentUser.nickname || currentUser.username} + +
+ )} + + {/* Remote Users */} + {connectedUserIds.map((userId) => { + const member = members?.find((m) => m.id === userId); + if (!member) return null; + return ( +
+ + + {member.username.slice(0, 2)} + + + {member.nickname || member.username} + +
+ ); + })} +
+ )} +
+ ); +}; + +export default ChannelItem; diff --git a/concord-client/src/components/channel/ChannelList.tsx b/concord-client/src/components/channel/ChannelList.tsx index 119a23b..caec7c6 100644 --- a/concord-client/src/components/channel/ChannelList.tsx +++ b/concord-client/src/components/channel/ChannelList.tsx @@ -1,13 +1,6 @@ import React, { useState } from "react"; import { useNavigate, useParams } from "react-router"; -import { - Hash, - Volume2, - ChevronDown, - ChevronRight, - Plus, - Edit, -} from "lucide-react"; +import { ChevronDown, ChevronRight, Plus, Edit } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -18,35 +11,7 @@ import { } from "@/components/ui/dropdown-menu"; import { CategoryWithChannels } from "@/types/api"; import { Channel } from "@/types/database"; - -interface ChannelItemProps { - channel: Channel; - isActive: boolean; - onClick: () => void; -} - -const ChannelItem: React.FC = ({ - channel, - isActive, - onClick, -}) => { - const Icon = channel.type === "voice" ? Volume2 : Hash; - - return ( - - ); -}; +import ChannelItem from "@/components/channel/ChannelItem"; interface CategoryHeaderProps { category: CategoryWithChannels; @@ -110,18 +75,11 @@ interface ChannelListProps { } const ChannelList: React.FC = ({ categories }) => { - const navigate = useNavigate(); - const { instanceId, channelId } = useParams(); - // Track expanded categories const [expandedCategories, setExpandedCategories] = useState>( new Set(categories.map((cat) => cat.id)), // Start with all expanded ); - const handleChannelClick = (channel: Channel) => { - navigate(`/channels/${instanceId}/${channel.id}`); - }; - const toggleCategory = (categoryId: string) => { setExpandedCategories((prev) => { const newSet = new Set(prev); @@ -164,12 +122,7 @@ const ChannelList: React.FC = ({ categories }) => { {category.channels .sort((a, b) => a.position - b.position) .map((channel) => ( - handleChannelClick(channel)} - /> + ))} )} diff --git a/concord-client/src/components/layout/AppLayout.tsx b/concord-client/src/components/layout/AppLayout.tsx index 8d6be9d..453d308 100644 --- a/concord-client/src/components/layout/AppLayout.tsx +++ b/concord-client/src/components/layout/AppLayout.tsx @@ -8,6 +8,7 @@ import ChannelSidebar from "@/components/layout/ChannelSidebar"; import UserPanel from "@/components/layout/UserPanel"; import MemberList from "@/components/layout/MemberList"; import LoadingSpinner from "@/components/common/LoadingSpinner"; +import VoiceConnectionManager from "@/components/voice/VoiceConnectionManager"; const AppLayout: React.FC = () => { const { isLoading } = useAuthStore(); @@ -32,22 +33,15 @@ const AppLayout: React.FC = () => { ); } - // Uncomment if auth is required - // if (!user) { - // return ( - //
- //
Authentication required
- //
- // ); - // } - return (
+ {/* This component handles playing audio from remote users */} + + {/* Server List Sidebar - Always visible on desktop, overlay on mobile */}
- {/* Channel Sidebar - Only shown when in a server context and not collapsed */} {shouldShowChannelSidebar && (
{
)} - {/* Main Content Area */}
{ >
- {/* Member List - Only shown when in a channel and member list is enabled */} {showMemberList && shouldShowChannelSidebar && (
diff --git a/concord-client/src/components/layout/UserPanel.tsx b/concord-client/src/components/layout/UserPanel.tsx index 252b940..bce22c8 100644 --- a/concord-client/src/components/layout/UserPanel.tsx +++ b/concord-client/src/components/layout/UserPanel.tsx @@ -19,6 +19,7 @@ import { import { useAuthStore } from "@/stores/authStore"; import { useUiStore } from "@/stores/uiStore"; import { useLogout } from "@/hooks/useAuth"; +import { useVoiceStore } from "@/stores/voiceStore"; // Status color utility const getStatusColor = (status: string) => { @@ -211,46 +212,18 @@ const UserPanel: React.FC = () => { const { user } = useAuthStore(); const { openUserSettings } = useUiStore(); - const [isMuted, setIsMuted] = useState(false); - const [isDeafened, setIsDeafened] = useState(false); - - // If no authenticated user, show login prompt - if (!user) { - return ( -
-
- -
-
- ); - } + const { isConnected, isMuted, isDeafened, toggleMute, toggleDeafen } = + useVoiceStore(); const handleStatusChange = (newStatus: string) => { console.log("Status change to:", newStatus); - // TODO: Implement API call to update user status - // You can add a useUpdateUserStatus hook here - }; - - const handleMuteToggle = () => setIsMuted(!isMuted); - const handleDeafenToggle = () => { - const newDeafenState = !isDeafened; - setIsDeafened(newDeafenState); - if (newDeafenState) { - setIsMuted(true); // Deafening also mutes - } }; return (
{/* User Info with Dropdown */} {/* Voice Controls */} - + {isConnected && ( + + )}
); }; diff --git a/concord-client/src/components/voice/VoiceConnectionManager.tsx b/concord-client/src/components/voice/VoiceConnectionManager.tsx new file mode 100644 index 0000000..450e7b3 --- /dev/null +++ b/concord-client/src/components/voice/VoiceConnectionManager.tsx @@ -0,0 +1,39 @@ +import React, { useEffect, useRef } from "react"; +import { useVoiceStore } from "@/stores/voiceStore"; + +interface AudioPlayerProps { + stream: MediaStream; + isDeafened: boolean; +} + +const AudioPlayer: React.FC = ({ stream, isDeafened }) => { + const audioRef = useRef(null); + + useEffect(() => { + if (audioRef.current) { + audioRef.current.srcObject = stream; + audioRef.current.volume = isDeafened ? 0 : 1; + } + }, [stream, isDeafened]); + + return