"use client"; import { Mic, MicOff, MonitorUp, PhoneOff, Search, UserPlus, Video as VideoIcon, VideoOff, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSpeaking } from "@/components/meetings/use-audio-level"; import { useWebRtcMesh } from "@/components/meetings/use-webrtc-mesh"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Dialog, DialogDescription, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipPopup, TooltipTrigger, } from "@/components/ui/tooltip"; import { authClient } from "@/lib/auth-client"; import { listClinicMembers, type Participant } from "@/lib/messages"; import { getSocket } from "@/lib/socket"; import { cn } from "@/lib/utils"; function initials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase(); } // One participant tile: video when the camera is on, an avatar otherwise, with a // green speaking ring driven by the stream's audio level. function VideoTile({ stream, label, caption, muted, showVideo, }: { stream: MediaStream | null; label: string; // The corner caption (e.g. "You"); falls back to `label` when omitted. Initials // are always derived from `label` (the real name), never the caption. caption?: string; muted?: boolean; showVideo: boolean; }) { const ref = useRef(null); // Analyse the stream's audio for the speaking ring (muting only affects // playback, so the local tile still gets a ring when you talk). const speaking = useSpeaking(stream); useEffect(() => { if (ref.current && ref.current.srcObject !== stream) { ref.current.srcObject = stream; } }, [stream]); return (
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
); } // A round control-bar button with a tooltip label. function ControlButton({ label, onClick, active, variant = "secondary", children, }: { label: string; onClick: () => void; active?: boolean; variant?: "secondary" | "outline" | "default" | "destructive"; children: React.ReactNode; }) { return ( } > {children} {label} ); } export function MeetingRoom({ roomId, roomName, selfName, onLeave, }: { roomId: string; roomName: string; selfName: string; onLeave: () => void; }) { const { t } = useTranslation(); const { data: session } = authClient.useSession(); const myId = session?.user?.id ?? ""; const { localStream, peers, joinState, micOn, camOn, screenOn, toggleMic, toggleCam, toggleScreen, maxPeers, } = useWebRtcMesh(roomId); // Invite picker. const [inviteOpen, setInviteOpen] = useState(false); const [members, setMembers] = useState([]); const [memberQuery, setMemberQuery] = useState(""); const [invited, setInvited] = useState>(new Set()); const openInvite = () => { setInviteOpen(true); setMemberQuery(""); listClinicMembers() .then(setMembers) .catch(() => setMembers([])); }; const invite = (userId: string) => { getSocket().emit("call:invite", { roomId, toUserId: userId }); setInvited((prev) => new Set(prev).add(userId)); }; const visibleMembers = members.filter( (m) => m.id !== myId && m.name.toLowerCase().includes(memberQuery.trim().toLowerCase()), ); return (
{roomName} {joinState === "joined" ? t("meetings.inCall", { count: peers.length + 1 }) : joinState === "joining" ? t("meetings.connecting") : joinState === "full" ? t("meetings.roomFull", { max: maxPeers }) : joinState === "error" ? t("meetings.callError") : ""}
{joinState === "full" || joinState === "error" ? (
{joinState === "full" ? t("meetings.roomFull", { max: maxPeers }) : t("meetings.callError")}
) : (
{peers.map((p) => ( ))}
)}
{/* Discord-style control bar — a compact pill that hugs its buttons */}
{micOn ? : } {camOn ? ( ) : ( )} void toggleScreen()} variant={screenOn ? "default" : "secondary"} >
{t("meetings.invite.title")} {t("meetings.invite.description", { room: roomName })}
setMemberQuery(e.target.value)} placeholder={t("meetings.invite.search")} size="sm" value={memberQuery} />
{visibleMembers.length === 0 ? (

{t("meetings.invite.noMembers")}

) : ( visibleMembers.map((m) => (
{initials(m.name)} {m.name}
)) )}
); }