voip: initial test run of store for managing socket connections
This commit is contained in:
108
concord-client/src/components/channel/ChannelItem.tsx
Normal file
108
concord-client/src/components/channel/ChannelItem.tsx
Normal 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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user