Files
enclave/src/components/page/channel/VoiceChannel.tsx
T
2026-08-27 17:53:40 +02:00

123 lines
4.2 KiB
TypeScript

import Enclave from "@/app/app";
import { ChannelPageProps } from "../PageView";
import { useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
export default function VoiceChannel({
appRef,
}: {
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
}) {
const channel = appRef.current?.page?.channel;
if (!channel) return null;
const lastChannelId = useRef<string | null>(null);
useEffect(() => {
const hostname = appRef.current?.server?.hostname;
if (!hostname) return;
if (lastChannelId.current === channel.id) return;
lastChannelId.current = channel.id;
invoke("disconnect_from_vc");
if (channel.kind !== "voice" || !appRef.current?.server) return;
appRef.current.server.voiceJoin = (pin, channelId) => {
invoke("connect_to_vc", { hostname, pin, channelId }).catch(
console.error,
);
};
appRef.current.server.websocket?.send({
method: "JoinVoice",
channel_id: channel.id,
});
}, [channel.id]);
const speakers =
appRef.current?.server?.voiceChatSpeakers &&
Object.keys(appRef.current?.server?.voiceChatSpeakers);
const users = appRef.current?.server?.voiceChatUsers[channel.id];
const usersLength = users?.length || 0;
// Dynamic grid column layout based on user count
const getGridCols = () => {
if (usersLength <= 3) return "grid-cols-1";
if (usersLength <= 6) return "grid-cols-3";
if (usersLength <= 9) return "grid-cols-3";
return "grid-cols-5";
};
// Avatar sizing scales up for fewer participants
const getAvatarSize = () => {
if (usersLength <= 2) return "h-24 w-24 text-3xl";
if (usersLength <= 4) return "h-20 w-20 text-2xl";
return "h-14 w-14 text-lg";
};
return (
<div className="flex flex-col h-screen gap-2">
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border">
<h2>{channel.name}</h2>
</header>
<div
className={cn(
"grid p-4 w-full gap-4 h-full auto-rows-fr bg-background/50 rounded-xl overflow-y-auto",
getGridCols(),
)}
>
{users?.map((pubkey) => {
const user = appRef.current?.server?.users[pubkey];
const isSpeaking = speakers?.includes(pubkey);
const displayName = user?.displayName || "Unknown User";
const fallbackLetter = displayName.charAt(0).toUpperCase();
return (
<div
key={pubkey}
className={cn(
"relative flex flex-col items-center justify-center p-4 rounded-xl bg-card/80 transition-all duration-150 overflow-hidden group h-full",
isSpeaking
? "ring-2 ring-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.25)] bg-card"
: "ring-1 ring-border/60 hover:ring-border",
)}
>
{/* Avatar Container */}
<div className="relative flex items-center justify-center">
<Avatar
className={cn("transition-all duration-200", getAvatarSize())}
>
<AvatarImage
src={user?.avatar}
alt={displayName}
className="object-cover"
/>
<AvatarFallback className="bg-muted font-bold text-muted-foreground">
{fallbackLetter}
</AvatarFallback>
</Avatar>
{/* Mute indicator badge overlay */}
{/*{user?.isMuted && (
<div className="absolute -bottom-1 -right-1 p-1.5 bg-destructive rounded-full text-destructive-foreground shadow-md">
<MicOff className="w-3.5 h-3.5" />
</div>
)}*/}
</div>
<div className="absolute bottom-3 left-3 max-w-[85%] truncate bg-background/80 backdrop-blur-md text-foreground py-1 px-2.5 rounded-md border border-border/50 text-xs font-medium shadow-sm">
{displayName}
</div>
</div>
);
})}
</div>
</div>
);
}