voip: initial test run of store for managing socket connections

This commit is contained in:
2025-09-28 10:05:11 -04:00
parent a2fb529911
commit bdc448c193
15 changed files with 581 additions and 212 deletions

View File

@@ -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 (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>

View File

@@ -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<ChannelItemProps> = ({ 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 (
<div>
<button
onClick={handleChannelClick}
className={`w-full flex items-center p-1.5 rounded-md text-left transition-colors ${
isActive || isConnectedToThisChannel
? "bg-concord-secondary text-concord-primary"
: "text-concord-secondary hover:bg-concord-secondary/50 hover:text-concord-primary"
}`}
>
<Icon className="w-5 h-5 mr-2 flex-shrink-0" />
<span className="truncate flex-1">{channel.name}</span>
</button>
{/* Render connected users for this voice channel */}
{isConnectedToThisChannel && (
<div className="pl-4 mt-1 space-y-1">
{/* Current User */}
{localStream && currentUser && (
<div className="flex items-center p-1">
<Avatar className="h-6 w-6">
<AvatarImage src={currentUser.picture || ""} />
<AvatarFallback>
{currentUser.username.slice(0, 2)}
</AvatarFallback>
</Avatar>
<span className="ml-2 text-sm text-concord-primary">
{currentUser.nickname || currentUser.username}
</span>
</div>
)}
{/* Remote Users */}
{connectedUserIds.map((userId) => {
const member = members?.find((m) => m.id === userId);
if (!member) return null;
return (
<div key={userId} className="flex items-center p-1">
<Avatar className="h-6 w-6">
<AvatarImage src={member.picture || ""} />
<AvatarFallback>{member.username.slice(0, 2)}</AvatarFallback>
</Avatar>
<span className="ml-2 text-sm text-concord-primary">
{member.nickname || member.username}
</span>
</div>
);
})}
</div>
)}
</div>
);
};
export default ChannelItem;

View File

@@ -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<ChannelItemProps> = ({
channel,
isActive,
onClick,
}) => {
const Icon = channel.type === "voice" ? Volume2 : Hash;
return (
<Button
variant="ghost"
className={`w-full justify-start px-2 py-2 h-8 text-left font-medium p-3 rounded-lg transition-all ${
isActive
? "border-primary bg-primary/10 border-2 "
: "hover:border-primary/50"
}`}
onClick={onClick}
>
<Icon size={16} className="mr-2 flex-shrink-0" />
<span className="truncate">{channel.name}</span>
</Button>
);
};
import ChannelItem from "@/components/channel/ChannelItem";
interface CategoryHeaderProps {
category: CategoryWithChannels;
@@ -110,18 +75,11 @@ interface ChannelListProps {
}
const ChannelList: React.FC<ChannelListProps> = ({ categories }) => {
const navigate = useNavigate();
const { instanceId, channelId } = useParams();
// Track expanded categories
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
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<ChannelListProps> = ({ categories }) => {
{category.channels
.sort((a, b) => a.position - b.position)
.map((channel) => (
<ChannelItem
key={channel.id}
channel={channel}
isActive={channelId === channel.id}
onClick={() => handleChannelClick(channel)}
/>
<ChannelItem key={channel.id} channel={channel} />
))}
</div>
)}

View File

@@ -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 (
// <div className="h-screen w-screen flex items-center justify-center bg-concord-primary">
// <div className="text-red-400">Authentication required</div>
// </div>
// );
// }
return (
<div className="flex h-screen overflow-hidden bg-concord-primary text-concord-primary">
{/* This component handles playing audio from remote users */}
<VoiceConnectionManager />
{/* Server List Sidebar - Always visible on desktop, overlay on mobile */}
<div className="relative w-[72px] sidebar-primary flex-shrink-0">
<ServerSidebar />
</div>
{/* Channel Sidebar - Only shown when in a server context and not collapsed */}
{shouldShowChannelSidebar && (
<div
@@ -64,7 +58,6 @@ const AppLayout: React.FC = () => {
</div>
</div>
)}
{/* Main Content Area */}
<div
className={`flex-1 flex flex-col min-w-0 ${
@@ -73,7 +66,6 @@ const AppLayout: React.FC = () => {
>
<Outlet />
</div>
{/* Member List - Only shown when in a channel and member list is enabled */}
{showMemberList && shouldShowChannelSidebar && (
<div className="flex-0 sidebar-secondary order-l border-sidebar">

View File

@@ -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 (
<div className="flex-shrink-0 p-2 bg-concord-tertiary border-t border-sidebar">
<div className="text-center text-concord-secondary text-sm">
<Button
variant="outline"
size="sm"
onClick={() => (window.location.href = "/login")}
>
Login Required
</Button>
</div>
</div>
);
}
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 (
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
{/* User Info with Dropdown */}
<UserStatusDropdown
currentStatus={user.status}
currentStatus={user?.status as string}
onStatusChange={handleStatusChange}
>
<Button
@@ -260,23 +233,25 @@ const UserPanel: React.FC = () => {
<UserAvatar user={user} size="md" />
<div className="ml-2 flex-1 min-w-0 text-left">
<div className="text-sm font-medium text-concord-primary truncate">
{user.nickname || user.username}
{user?.nickname || user?.username}
</div>
<div className="text-xs text-concord-secondary truncate capitalize">
{user.status}
{user?.status}
</div>
</div>
</Button>
</UserStatusDropdown>
{/* Voice Controls */}
<VoiceControls
isMuted={isMuted}
isDeafened={isDeafened}
onMuteToggle={handleMuteToggle}
onDeafenToggle={handleDeafenToggle}
onSettingsClick={openUserSettings}
/>
{isConnected && (
<VoiceControls
isMuted={isMuted}
isDeafened={isDeafened}
onMuteToggle={toggleMute}
onDeafenToggle={toggleDeafen}
onSettingsClick={openUserSettings}
/>
)}
</div>
);
};

View File

@@ -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<AudioPlayerProps> = ({ stream, isDeafened }) => {
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (audioRef.current) {
audioRef.current.srcObject = stream;
audioRef.current.volume = isDeafened ? 0 : 1;
}
}, [stream, isDeafened]);
return <audio ref={audioRef} autoPlay playsInline />;
};
const VoiceConnectionManager: React.FC = () => {
const remoteStreams = useVoiceStore((state) => state.remoteStreams);
const isDeafened = useVoiceStore((state) => state.isDeafened);
if (remoteStreams.size === 0) {
return null;
}
return (
<div style={{ display: "none" }}>
{Array.from(remoteStreams.entries()).map(([userId, stream]) => (
<AudioPlayer key={userId} stream={stream} isDeafened={isDeafened} />
))}
</div>
);
};
export default VoiceConnectionManager;

View File

@@ -1,4 +1,3 @@
// src/hooks/useAuth.ts - Fixed with proper types
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/authStore";
import {

View File

@@ -1,4 +1,3 @@
// src/hooks/useServers.ts - Fixed with proper types
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
apiClient,

View File

@@ -1,15 +1,33 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { io } from "socket.io-client";
import "./index.css";
function printPayload(data: unknown) {
console.log(data);
}
const socket = io("http://localhost:3000");
socket.on("connect", () => {
console.log("connected!");
socket.emit("ping", "world");
});
socket.on("pong", () => {
console.log("pong");
});
socket.on("joined-voicechannel", printPayload);
socket.on("user-joined-voicechannel", printPayload);
socket.on("error-voicechannel", printPayload);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App socket={socket} />
</React.StrictMode>,
);
// Use contextBridge
window.ipcRenderer.on('main-process-message', (_event, message) => {
console.log(message)
})
window.ipcRenderer.on("main-process-message", (_event, message) => {
console.log(message);
});

View File

@@ -1,5 +0,0 @@
import { io } from "socket.io-client";
const URL = import.meta.env.PROD === true ? undefined : "http://localhost:5173";
export const socket = io(URL);

View File

@@ -0,0 +1,342 @@
import { create } from "zustand";
import { Socket } from "socket.io-client";
import { BackendUser, User } from "@/types";
// --- TYPE DEFINITIONS ---
interface IceServerConfig {
urls: string | string[];
username?: string;
credential?: string;
}
// The state managed by the store
interface VoiceState {
socket: Socket | null;
localStream: MediaStream | null;
remoteStreams: Map<string, MediaStream>;
peerConnections: Map<string, RTCPeerConnection>;
iceServers: IceServerConfig[];
isConnected: boolean;
isConnecting: boolean;
activeVoiceChannelId: string | null;
isDeafened: boolean;
isMuted: boolean;
}
// Actions that can be performed on the store
interface VoiceActions {
init: (socket: Socket) => void;
joinChannel: (
channelId: string,
userId: string,
token: string,
) => Promise<void>;
leaveChannel: () => void;
cleanup: () => void;
toggleMute: () => void;
toggleDeafen: () => void;
}
// --- ZUSTAND STORE IMPLEMENTATION ---
export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
// --- INTERNAL HELPERS (not exposed in the store's public interface) ---
/**
* Safely closes and removes a single peer connection.
* @param userId The ID of the user whose connection to clean up.
*/
const cleanupPeerConnection = (userId: string) => {
const { peerConnections } = get();
const peerConnection = peerConnections.get(userId);
if (peerConnection) {
peerConnection.close();
peerConnections.delete(userId);
}
set((state) => {
const newStreams = new Map(state.remoteStreams);
newStreams.delete(userId);
return {
remoteStreams: newStreams,
peerConnections: new Map(peerConnections),
};
});
};
/**
* Creates a new RTCPeerConnection for a target user and configures it.
* @param targetUserId The user to connect to.
* @returns The configured RTCPeerConnection instance.
*/
const createPeerConnection = (targetUserId: string): RTCPeerConnection => {
console.log(`Creating peer connection for: ${targetUserId}`);
const { iceServers, localStream, socket, peerConnections } = get();
const peerConnection = new RTCPeerConnection({ iceServers });
// Add local stream tracks to the new connection
if (localStream) {
localStream
.getTracks()
.forEach((track) => peerConnection.addTrack(track, localStream));
}
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate && socket) {
socket.emit("webrtc-ice-candidate", {
targetUserId,
candidate: event.candidate,
});
}
};
// Handle incoming remote tracks
peerConnection.ontrack = (event) => {
console.log(`Received remote track from: ${targetUserId}`);
set((state) => {
const newStreams = new Map(state.remoteStreams);
newStreams.set(targetUserId, event.streams[0]);
return { remoteStreams: newStreams };
});
};
// For debugging connection state
peerConnection.onconnectionstatechange = () => {
console.log(
`Connection state change for ${targetUserId}: ${peerConnection.connectionState}`,
);
if (
peerConnection.connectionState === "disconnected" ||
peerConnection.connectionState === "failed"
) {
cleanupPeerConnection(targetUserId);
}
};
peerConnections.set(targetUserId, peerConnection);
set({ peerConnections: new Map(peerConnections) });
return peerConnection;
};
// --- SOCKET EVENT HANDLERS ---
// These are defined once and can be reused by the join/leave actions.
const onJoinedVoiceChannel = async (data: {
connectedUserIds: string[];
iceServers: IceServerConfig[];
}) => {
console.log(
"Successfully joined voice channel. Users:",
data.connectedUserIds,
);
set({
iceServers: data.iceServers,
isConnecting: false,
isConnected: true,
});
for (const userId of data.connectedUserIds) {
const peerConnection = createPeerConnection(userId);
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
get().socket?.emit("webrtc-offer", {
targetUserId: userId,
sdp: peerConnection.localDescription,
});
}
};
const onUserLeft = (data: { userId: string }) => {
console.log(`User ${data.userId} left the channel.`);
cleanupPeerConnection(data.userId);
};
const onWebRTCOffer = async (data: {
senderUserId: string;
sdp: RTCSessionDescriptionInit;
}) => {
console.log("Received WebRTC offer from:", data.senderUserId);
const peerConnection = createPeerConnection(data.senderUserId);
await peerConnection.setRemoteDescription(
new RTCSessionDescription(data.sdp),
);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
get().socket?.emit("webrtc-answer", {
targetUserId: data.senderUserId,
sdp: peerConnection.localDescription,
});
};
const onWebRTCAnswer = async (data: {
senderUserId: string;
sdp: RTCSessionDescriptionInit;
}) => {
console.log("Received WebRTC answer from:", data.senderUserId);
const peerConnection = get().peerConnections.get(data.senderUserId);
if (peerConnection) {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(data.sdp),
);
}
};
const onICECandidate = async (data: {
senderUserId: string;
candidate: RTCIceCandidateInit;
}) => {
const peerConnection = get().peerConnections.get(data.senderUserId);
if (peerConnection && data.candidate) {
try {
await peerConnection.addIceCandidate(
new RTCIceCandidate(data.candidate),
);
} catch (e) {
console.error("Error adding received ICE candidate", e);
}
}
};
const onError = (error: { message: string }) => {
console.error("Voice channel error:", error.message);
get().leaveChannel(); // Disconnect on error
};
// --- STORE DEFINITION (STATE & ACTIONS) ---
return {
// Initial State
socket: null,
localStream: null,
remoteStreams: new Map(),
peerConnections: new Map(),
iceServers: [],
isConnected: false,
isConnecting: false,
activeVoiceChannelId: null,
isMuted: false,
isDeafened: false,
// Actions
init: (socketInstance) => {
set({ socket: socketInstance });
},
joinChannel: async (channelId: string, userId: string, token: string) => {
const { socket, activeVoiceChannelId, leaveChannel, isConnecting } =
get();
if (!socket || isConnecting || activeVoiceChannelId === channelId) return;
if (!userId || !token) {
console.error("Join channel requires user and token.");
return;
}
if (activeVoiceChannelId) {
leaveChannel();
}
set({ isConnecting: true, activeVoiceChannelId: channelId });
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
set({ localStream: stream });
} catch (error) {
console.error("Could not get user media:", error);
set({ isConnecting: false, activeVoiceChannelId: null });
return;
}
// Attach all necessary listeners for a voice session
socket.on("joined-voicechannel", onJoinedVoiceChannel);
socket.on("user-left-voicechannel", onUserLeft);
socket.on("webrtc-offer", onWebRTCOffer);
socket.on("webrtc-answer", onWebRTCAnswer);
socket.on("webrtc-ice-candidate", onICECandidate);
socket.on("error-voicechannel", onError);
// *** THE FIX: Send user credentials with the join request ***
socket.emit("join-voicechannel", {
channelId,
userId,
token,
});
},
leaveChannel: () => {
const { socket, peerConnections, localStream, activeVoiceChannelId } =
get();
if (!socket || !activeVoiceChannelId) return;
console.log(`Leaving voice channel: ${activeVoiceChannelId}`);
socket.emit("leave-voicechannel", { channelId: activeVoiceChannelId });
// Clean up all event listeners
socket.off("joined-voicechannel");
socket.off("user-left-voicechannel");
socket.off("webrtc-offer");
socket.off("webrtc-answer");
socket.off("webrtc-ice-candidate");
socket.off("error-voicechannel");
// Close all peer connections
peerConnections.forEach((pc) => pc.close());
// Stop local media tracks
localStream?.getTracks().forEach((track) => track.stop());
// Reset state to initial values
set({
localStream: null,
remoteStreams: new Map(),
peerConnections: new Map(),
isConnected: false,
isConnecting: false,
activeVoiceChannelId: null,
iceServers: [],
});
},
toggleMute: () => {
set((state) => {
const newMutedState = !state.isMuted;
if (state.localStream) {
state.localStream.getAudioTracks().forEach((track) => {
track.enabled = !newMutedState;
});
}
// Cannot be deafened and unmuted
if (state.isDeafened && !newMutedState) {
return { isMuted: newMutedState, isDeafened: false };
}
return { isMuted: newMutedState };
});
},
toggleDeafen: () => {
set((state) => {
const newDeafenedState = !state.isDeafened;
// When deafening, you are also muted
if (newDeafenedState && !state.isMuted) {
// Manually mute logic without toggling deafen state again
if (state.localStream) {
state.localStream.getAudioTracks().forEach((track) => {
track.enabled = false;
});
}
return { isDeafened: newDeafenedState, isMuted: true };
}
return { isDeafened: newDeafenedState };
});
},
cleanup: () => {
get().leaveChannel();
set({ socket: null });
},
};
});