import React, { useEffect, useRef, useState, useMemo, Suspense } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; interface ErrorBoundaryProps { children: React.ReactNode; fallback: React.ReactNode; } interface ErrorBoundaryState { hasError: boolean; } class ModelErrorBoundary extends React.Component { constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { console.error("🚨 [ModelErrorBoundary] Falha de renderização/rede no modelo:", error, errorInfo); } render() { if (this.state.hasError) { return this.props.fallback; } return this.props.children; } } import { Canvas, useFrame } from '@react-three/fiber'; import { OrbitControls, Text, useGLTF, Billboard, Html } from '@react-three/drei'; import * as THREE from 'three'; import { supabase } from '@/integrations/supabase/client'; import { useModelStore, type SceneModel } from '@/stores/useModelStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { toast } from 'sonner'; import { Users, MessageSquare, Mic, MicOff, Home, Send, ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut, RotateCw, Award, ShieldAlert, Maximize2, Minimize2, Box, Settings, Check } from 'lucide-react'; // Interfaces tipadas para evitar any interface AvatarPreset { id: number; name: string; desc: string; color: string; url: string; } interface PresentationState { rotation: number[]; scale: number; sectionEnabled: boolean; sectionAxis: 'x' | 'y' | 'z'; sectionLevel: number; sectionInvert: boolean; modelUrl?: string; modelFileName?: string; modelFileSize?: number; } interface PeerData { username: string; avatarId: number; chairId: string; role: string; cargo: string; empresa: string; presence_ref: string; allowedAvatars?: number[]; } // Detector nativo de suporte ao WebGL function isWebGLAvailable() { try { const canvas = document.createElement('canvas'); return !!( window.WebGLRenderingContext && (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')) ); } catch (e) { return false; } } // Hook de verificação de suporte a WebGL no cliente function useWebGLSupport() { const [supported, setSupported] = useState(true); useEffect(() => { setSupported(isWebGLAvailable()); }, []); return supported; } // --- Configurações da Mesa de Reunião --- const CHAIRS = [ { id: '1', name: 'Assento 1', pos: [-1.2, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] }, { id: '2', name: 'Assento 2', pos: [-0.4, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] }, { id: '3', name: 'Assento 3', pos: [0.4, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] }, { id: '4', name: 'Assento 4', pos: [1.2, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] }, { id: '5', name: 'Assento 5', pos: [1.2, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] }, { id: '6', name: 'Assento 6', pos: [0.4, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] }, { id: '7', name: 'Assento 7', pos: [-0.4, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] }, { id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] }, ]; const AVATAR_PRESETS: AvatarPreset[] = [ // Masculinos (1 a 10) { id: 1, name: 'Leo', desc: 'Negro, cabelo curto preto', url: '/avatars/avatar_1.png', color: '#ef4444' }, { id: 2, name: 'Marcus', desc: 'Loiro, cabelo curto', url: '/avatars/avatar_2.png', color: '#b45309' }, { id: 3, name: 'Arthur', desc: 'Ruivo com barba', url: '/avatars/avatar_3.png', color: '#eab308' }, { id: 4, name: 'Jin', desc: 'AsiÔtico, cabelo preto liso', url: '/avatars/avatar_4.png', color: '#3b82f6' }, { id: 5, name: 'Mateo', desc: 'Castanho, cabelo cacheado', url: '/avatars/avatar_5.png', color: '#f97316' }, { id: 6, name: 'Malik', desc: 'Negro, careca com barba', url: '/avatars/avatar_6.png', color: '#84cc16' }, { id: 7, name: 'Oliver', desc: 'Cabelo grisalho com óculos', url: '/avatars/avatar_7.png', color: '#06b6d4' }, { id: 8, name: 'Carlos', desc: 'Latino com bigode', url: '/avatars/avatar_8.png', color: '#6366f1' }, { id: 9, name: 'Yuki', desc: 'Cabelo espetado azul escuro', url: '/avatars/avatar_9.png', color: '#a855f7' }, { id: 10, name: 'Elijah', desc: 'Negro com dreadlocks', url: '/avatars/avatar_10.png', color: '#ec4899' }, // Femininos (11 a 20) { id: 11, name: 'Sophia', desc: 'Morena, cabelo longo castanho', url: '/avatars/avatar_11.png', color: '#10b981' }, { id: 12, name: 'Aisha', desc: 'Negra com turbante/lenço', url: '/avatars/avatar_12.png', color: '#78350f' }, { id: 13, name: 'Isabella', desc: 'Loira, cabelo longo ondulado', url: '/avatars/avatar_13.png', color: '#1e293b' }, { id: 14, name: 'Chloe', desc: 'Ruiva, corte canal/bob', url: '/avatars/avatar_14.png', color: '#ca8a04' }, { id: 15, name: 'Mei', desc: 'AsiÔtica, cabelo com franja', url: '/avatars/avatar_15.png', color: '#f43f5e' }, { id: 16, name: 'Elena', desc: 'Castanha, cabelo afro volumoso', url: '/avatars/avatar_16.png', color: '#14b8a6' }, { id: 17, name: 'Zara', desc: 'Cabelo roxo moderno', url: '/avatars/avatar_17.png', color: '#0ea5e9' }, { id: 18, name: 'Naomi', desc: 'Negra de óculos e rabo de cavalo', url: '/avatars/avatar_18.png', color: '#4f46e5' }, { id: 19, name: 'Harper', desc: 'Corte pixie rosa', url: '/avatars/avatar_19.png', color: '#d946ef' }, { id: 20, name: 'Amara', desc: 'Negra com tranças', url: '/avatars/avatar_20.png', color: '#f59e0b' } ]; const SKIN_COLORS = ['#fed7aa', '#fdba74', '#f59e0b', '#d97706', '#9a3412', '#ffedd5']; // Classes Tailwind estÔticas para evitar inline styles no list view dos avatares const getAvatarBgClass = (avatarId: number): string => { const classes = [ 'bg-red-500/20 text-red-400', 'bg-amber-800/20 text-amber-500', 'bg-yellow-500/20 text-yellow-400', 'bg-blue-500/20 text-blue-400', 'bg-orange-500/20 text-orange-400', 'bg-lime-500/20 text-lime-400', 'bg-cyan-500/20 text-cyan-400', 'bg-indigo-500/20 text-indigo-400', 'bg-purple-500/20 text-purple-400', 'bg-pink-500/20 text-pink-400', 'bg-emerald-500/20 text-emerald-400', 'bg-amber-950/40 text-amber-600', 'bg-slate-700/20 text-slate-300', 'bg-yellow-600/20 text-yellow-500', 'bg-rose-500/20 text-rose-400', 'bg-teal-500/20 text-teal-400', 'bg-sky-500/20 text-sky-400', 'bg-indigo-600/20 text-indigo-500', 'bg-fuchsia-500/20 text-fuchsia-400', 'bg-amber-500/20 text-amber-400' ]; return classes[(avatarId - 1) % classes.length] || classes[0]; }; // --- Subcomponentes 3D --- // ========================================== // BACKUP DOS AVATARES PROCEDURAIS ORIGINAIS // ========================================== function MiiAvatarBackup({ avatarId, username, isTalking, scale = 1, isLastChatSender = false, ...props }: { avatarId: number; username: string; isTalking?: boolean; scale?: number; isLastChatSender?: boolean; } & Record) { const groupRef = useRef(null); const ringRef = useRef(null); const headRef = useRef(null); const lastSenderRef = useRef(isLastChatSender); const pulseEndTimeRef = useRef(0); useEffect(() => { if (isLastChatSender && !lastSenderRef.current) { pulseEndTimeRef.current = Date.now() + 1500; } lastSenderRef.current = isLastChatSender; }, [isLastChatSender]); const avatarUrl = useMemo(() => { const preset = AVATAR_PRESETS.find((p) => p.id === avatarId); return preset ? preset.url : `/avatars/avatar_${avatarId}.png`; }, [avatarId]); useFrame((state) => { if (groupRef.current && props.position && Array.isArray(props.position) && props.position.length >= 2) { groupRef.current.position.y = (props.position[1] as number) + Math.sin(state.clock.getElapsedTime() * 2 + avatarId) * 0.012; } if (headRef.current) { if (isTalking) { headRef.current.position.y = 0.16 + Math.abs(Math.sin(state.clock.getElapsedTime() * 12)) * 0.025; headRef.current.rotation.z = Math.sin(state.clock.getElapsedTime() * 12) * 0.06; } else { headRef.current.position.y = THREE.MathUtils.lerp(headRef.current.position.y, 0.16, 0.15); headRef.current.rotation.z = THREE.MathUtils.lerp(headRef.current.rotation.z, 0, 0.15); } } if (ringRef.current) { const now = Date.now(); const material = ringRef.current.material as THREE.MeshBasicMaterial; if (isTalking) { material.color.set('#3b82f6'); material.opacity = 0.6 + Math.sin(state.clock.getElapsedTime() * 10) * 0.3; ringRef.current.scale.set(1, 1, 1); } else if (isLastChatSender) { material.color.set('#eab308'); const isPulsing = now < pulseEndTimeRef.current; if (isPulsing) { material.opacity = 0.5 + Math.sin(state.clock.getElapsedTime() * 25) * 0.4; const s = 1.0 + Math.sin(state.clock.getElapsedTime() * 25) * 0.15; ringRef.current.scale.set(s, s, 1); } else { material.opacity = 0.75; ringRef.current.scale.set(1, 1, 1); } } } }); const positionVector = useMemo(() => { if (props.position && Array.isArray(props.position)) { return new THREE.Vector3(props.position[0] as number, props.position[1] as number, props.position[2] as number); } return new THREE.Vector3(0, 0, 0); }, [props.position]); const rotationEuler = useMemo(() => { if (props.rotation && Array.isArray(props.rotation)) { return new THREE.Euler(props.rotation[0] as number, props.rotation[1] as number, props.rotation[2] as number); } return new THREE.Euler(0, 0, 0); }, [props.rotation]); return ( {username} {(isTalking || isLastChatSender) && ( )} {/* Cabeça do Avatar: Toon Head do Dicebear em HTML Transformado em 3D Plano (olhando para a mesa) */}
{username} { (e.currentTarget as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(username || 'U')}&background=475569&color=fff&size=128&rounded=true`; }} />
{/* Base cÓnica para dar uma sustentação 3D estilizada */}
); } // ========================================== // CLASSES E COMPONENTES DE RESILIÊNCIA // ========================================== class AvatarErrorBoundary extends React.Component<{ fallback: React.ReactNode; children: React.ReactNode }, { hasError: boolean }> { constructor(props: { fallback: React.ReactNode; children: React.ReactNode }) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: Error) { console.warn("🚨 [AvatarErrorBoundary] Falha ao carregar o modelo GLB do avatar. Utilizando o avatar de fallback procedural.", error); } render() { if (this.state.hasError) { return this.props.fallback; } return this.props.children; } } // O componente principal MiiAvatar agora Ć© um wrapper resiliente com Suspense e ErrorBoundary function MiiAvatar(props: { avatarId: number; username: string; isTalking?: boolean; scale?: number; isLastChatSender?: boolean; } & Record) { const preset = AVATAR_PRESETS.find((p) => p.id === props.avatarId); const isPng = preset?.url.endsWith('.png'); if (isPng) { return ; } return ( }> }> ); } // ========================================== // NOVOS AVATARES REALISTAS (READY PLAYER ME) // ========================================== function RealMiiAvatar({ avatarId, username, isTalking, scale = 1, isLastChatSender = false, ...props }: { avatarId: number; username: string; isTalking?: boolean; scale?: number; isLastChatSender?: boolean; } & Record) { const groupRef = useRef(null); const ringRef = useRef(null); const lastSenderRef = useRef(isLastChatSender); const pulseEndTimeRef = useRef(0); // Monitora quando este avatar se torna o Ćŗltimo a escrever no chat useEffect(() => { if (isLastChatSender && !lastSenderRef.current) { pulseEndTimeRef.current = Date.now() + 1500; } lastSenderRef.current = isLastChatSender; }, [isLastChatSender]); // ObtĆ©m o preset correto para pegar a URL do avatar GLB const preset = useMemo(() => { return AVATAR_PRESETS.find((p) => p.id === avatarId) || AVATAR_PRESETS[0]; }, [avatarId]); // Carrega o modelo 3D GLB de forma assĆ­ncrona const { scene } = useGLTF(preset.url); const clone = useMemo(() => scene.clone(), [scene]); // Configuração inicial do clone para sombra e materiais useEffect(() => { if (clone) { clone.traverse((child) => { if (child instanceof THREE.Mesh) { child.castShadow = true; child.receiveShadow = true; if (child.material) { child.material.roughness = 0.8; } } }); } }, [clone]); useFrame((state) => { // Flutuação sutil de respiração/repouso if (groupRef.current && props.position && Array.isArray(props.position) && props.position.length >= 2) { groupRef.current.position.y = (props.position[1] as number) + Math.sin(state.clock.getElapsedTime() * 1.5 + avatarId) * 0.008 - 0.22; } // Animação de sincronização labial (lip-sync) nativa baseada em Morph Targets if (clone) { clone.traverse((child) => { if (child instanceof THREE.SkinnedMesh && child.morphTargetInfluences) { const dict = child.morphTargetDictionary; if (dict) { const jawOpenIdx = dict['jawOpen'] ?? dict['viseme_sil'] ?? dict['mouthOpen']; const mouthSmileIdx = dict['mouthSmile'] ?? dict['mouthSmileLeft']; if (jawOpenIdx !== undefined) { if (isTalking) { // Abre e fecha a boca no ritmo do clock child.morphTargetInfluences[jawOpenIdx] = 0.15 + Math.abs(Math.sin(state.clock.getElapsedTime() * 18)) * 0.6; } else { child.morphTargetInfluences[jawOpenIdx] = 0; } } if (mouthSmileIdx !== undefined) { child.morphTargetInfluences[mouthSmileIdx] = 0.25; } } } }); } // Animação do anel de fala / chat if (ringRef.current) { const now = Date.now(); const material = ringRef.current.material as THREE.MeshBasicMaterial; if (isTalking) { material.color.set('#3b82f6'); material.opacity = 0.6 + Math.sin(state.clock.getElapsedTime() * 10) * 0.3; ringRef.current.scale.set(1, 1, 1); } else if (isLastChatSender) { material.color.set('#eab308'); const isPulsing = now < pulseEndTimeRef.current; if (isPulsing) { material.opacity = 0.5 + Math.sin(state.clock.getElapsedTime() * 25) * 0.4; const s = 1.0 + Math.sin(state.clock.getElapsedTime() * 25) * 0.15; ringRef.current.scale.set(s, s, 1); } else { material.opacity = 0.75; ringRef.current.scale.set(1, 1, 1); } } } }); const positionVector = useMemo(() => { if (props.position && Array.isArray(props.position)) { return new THREE.Vector3(props.position[0] as number, (props.position[1] as number) - 0.22, props.position[2] as number); } return new THREE.Vector3(0, -0.22, 0); }, [props.position]); const rotationEuler = useMemo(() => { if (props.rotation && Array.isArray(props.rotation)) { return new THREE.Euler(props.rotation[0] as number, props.rotation[1] as number, props.rotation[2] as number); } return new THREE.Euler(0, 0, 0); }, [props.rotation]); return ( {/* BalĆ£o de Nome acima da cabeƧa */} {username} {/* Modelo GLB Realista Clonado */} {/* Indicador de Fala & Chat */} {(isTalking || isLastChatSender) && ( )} ); } // Carregador e Escalador Inteligente da Maquete HologrĆ”fica // Carregador e Escalador Inteligente da Maquete HologrĆ”fica function HologramModel({ model, presentationState }: { model: SceneModel; presentationState: PresentationState }) { const { scene } = useGLTF(model.url); const clone = useMemo(() => scene.clone(), [scene]); const presentationGroupRef = useRef(null); const baseScaleRef = useRef(1); const centerRef = useRef(new THREE.Vector3()); // Calcula escala de aproximadamente 1 metro para caber na mesa (apenas UMA vez no carregamento) useEffect(() => { if (!clone) return; // Reseta rotação e escala para obter o bounding box original limpo const oldRot = clone.rotation.clone(); const oldScale = clone.scale.clone(); clone.rotation.set(0, 0, 0); clone.scale.setScalar(1); const box = new THREE.Box3().setFromObject(clone); const size = new THREE.Vector3(); box.getSize(size); const maxDim = Math.max(size.x, size.y, size.z); if (maxDim > 0) { const targetSize = 0.95; const scaleFactor = targetSize / maxDim; baseScaleRef.current = scaleFactor; const center = new THREE.Vector3(); box.getCenter(center); centerRef.current.copy(center); } // Restaura clone.rotation.copy(oldRot); clone.scale.copy(oldScale); }, [clone]); // Aplica cor do modelo nos materiais do clone useEffect(() => { if (!clone || !model.color) return; clone.traverse((child) => { if (child instanceof THREE.Mesh) { // Clone do material para evitar afetar cache global do R3F if (Array.isArray(child.material)) { child.material = child.material.map(m => m.clone()); } else { child.material = child.material.clone(); } const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((mat: THREE.Material) => { if (mat instanceof THREE.MeshStandardMaterial) { mat.color.set(model.color); mat.needsUpdate = true; } }); } }); }, [clone, model.color]); // Aplica transformaƧƵes de Rotação e Escala compartilhadas no grupo externo de apresentação useFrame(() => { if (presentationGroupRef.current && presentationState) { const { rotation, scale } = presentationState; if (rotation) { presentationGroupRef.current.rotation.x = rotation[0]; presentationGroupRef.current.rotation.y = rotation[1]; presentationGroupRef.current.rotation.z = rotation[2]; } if (scale) { presentationGroupRef.current.scale.setScalar(baseScaleRef.current * scale); } } }); // Aplica Planos de Corte (clippingPlanes) nos materiais useEffect(() => { if (!clone || !presentationState) return; const { sectionEnabled, sectionAxis, sectionLevel, sectionInvert } = presentationState; if (!sectionEnabled) { clone.traverse((obj) => { if (obj instanceof THREE.Mesh) { const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; mats.forEach((mat) => { if (mat) { mat.clippingPlanes = []; mat.needsUpdate = true; } }); } }); return; } const normal = new THREE.Vector3(); if (sectionAxis === 'x') normal.set(sectionInvert ? -1 : 1, 0, 0); else if (sectionAxis === 'y') normal.set(0, sectionInvert ? -1 : 1, 0); else if (sectionAxis === 'z') normal.set(0, 0, sectionInvert ? -1 : 1); // Ajusta a distĆ¢ncia baseado no bounding box original e no nĆ­vel do corte const box = new THREE.Box3().setFromObject(clone); const size = new THREE.Vector3(); box.getSize(size); const minVal = sectionAxis === 'x' ? box.min.x : sectionAxis === 'y' ? box.min.y : box.min.z; const maxVal = sectionAxis === 'x' ? box.max.x : sectionAxis === 'y' ? box.max.y : box.max.z; const distance = minVal + (maxVal - minVal) * sectionLevel; const plane = new THREE.Plane(normal, -distance); clone.traverse((obj) => { if (obj instanceof THREE.Mesh) { const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; mats.forEach((mat) => { if (mat) { mat.clippingPlanes = [plane]; mat.clipShadows = true; mat.needsUpdate = true; } }); } }); }, [clone, presentationState]); // TranslaƧƵes, RotaƧƵes finas e Escala fina nativas da maquete vindas do Viewer const fineTuning = model.fineTuning || { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 }; const rotXRad = (fineTuning.rotX * Math.PI) / 180; const rotYRad = (fineTuning.rotY * Math.PI) / 180; const rotZRad = (fineTuning.rotZ * Math.PI) / 180; const s = fineTuning.scale ?? 1; // Quaternion de calibração nativo da maquete const calQuatArr = model.calibrationQuat; const calQuat = useMemo(() => { if (!calQuatArr) return new THREE.Quaternion(); return new THREE.Quaternion(calQuatArr[0], calQuatArr[1], calQuatArr[2], calQuatArr[3]); }, [calQuatArr]); return ( // Grupo Externo de Apresentação Compartilhada {/* Grupo de Translação Fina */} {/* Grupo de Rotação Fina e Escala Fina */} {/* Grupo de Calibração */} {/* Grupo de Centralização do Bounding Box (para girar em torno do seu centro geomĆ©trico) */} ); } // CenĆ”rio da Sala de ReuniĆ£o Clara function ThreeMeetingRoomScene({ currentActiveModel, presentationState, isFocusMode }: { currentActiveModel: SceneModel | null; presentationState: PresentationState; isFocusMode: boolean }) { const tableGeom = useMemo(() => { // Geometria da mesa em U / Retangular de cantos arredondados const shape = new THREE.Shape(); shape.moveTo(-1.6, -1.1); shape.lineTo(1.6, -1.1); shape.quadraticCurveTo(1.9, -1.1, 1.9, -0.8); shape.lineTo(1.9, 0.8); shape.quadraticCurveTo(1.9, 1.1, 1.6, 1.1); shape.lineTo(-1.6, 1.1); shape.quadraticCurveTo(-1.9, 1.1, -1.9, 0.8); shape.lineTo(-1.9, -0.8); shape.quadraticCurveTo(-1.9, -1.1, -1.6, -1.1); const hole = new THREE.Path(); hole.absellipse(0, 0, 0.9, 0.45, 0, Math.PI * 2, true); shape.holes.push(hole); return new THREE.ExtrudeGeometry(shape, { depth: 0.06, bevelEnabled: true, bevelThickness: 0.01, bevelSize: 0.01, bevelSegments: 3 }); }, []); return ( {/* Luzes da Sala / Foco */} {isFocusMode && ( )} {!isFocusMode && ( <> {/* Piso Clara */} {/* Tapete Central */} {/* Paredes Claras */} {/* Janelas PanorĆ¢micas */} {/* Mesa ElĆ­ptica/Retangular com furo central (Madeira Mel Carvalho Clara) */} {/* PĆ©s da mesa (Cromados) */} {[-1.3, 1.3].map((x) => [-0.7, 0.7].map((z) => ( )) )} {/* Projetor de Holograma / CĆ­rculo centralizado desenhado na mesa */} )} {/* Modelo 3D da Maquete HologrĆ”fica */} }> }> {currentActiveModel ? ( ) : ( // Holograma PadrĆ£o GeomĆ©trico se nĆ£o houver maquete carregada )} {/* Renderizar as Cadeiras FĆ­sicas da mesa - Ocultadas no Modo Foco */} {!isFocusMode && CHAIRS.map((chair) => ( {/* Base giratória metĆ”lica */} {/* PĆ©s base */} {/* Assento */} {/* Encosto */} ))} {/* Iluminação Clara da Sala - Ocultada no Modo Foco */} {!isFocusMode && ( <> )} ); } // --- Componente Principal da Tela --- export default function MeetingRoom() { const isWebGLSupported = useWebGLSupport(); const { roomId } = useParams<{ roomId?: string }>(); const navigate = useNavigate(); const models = useModelStore((s) => s.models); const activeModelId = useModelStore((s) => s.activeModelId); const activeModel = useMemo(() => { return models.find((m) => m.id === activeModelId) || null; }, [models, activeModelId]); // Estados do Lobby const [inRoom, setInRoom] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isFocusMode, setIsFocusMode] = useState(false); const [name, setName] = useState(() => localStorage.getItem('tsxr_meeting_user_name') || ''); const [cargo, setCargo] = useState(() => localStorage.getItem('tsxr_meeting_user_cargo') || ''); const [empresa, setEmpresa] = useState(() => localStorage.getItem('tsxr_meeting_user_empresa') || ''); const [isPresenter, setIsPresenter] = useState(() => { const saved = localStorage.getItem('tsxr_meeting_user_is_presenter'); if (saved !== null) return saved === 'true'; return !roomId; }); const [selectedAvatar, setSelectedAvatar] = useState(() => Number(localStorage.getItem('tsxr_meeting_user_avatar')) || 1); const [selectedChair, setSelectedChair] = useState(() => localStorage.getItem('tsxr_meeting_user_chair') || '1'); const [inputRoomId, setInputRoomId] = useState(roomId || ''); // Estados da Sala (ConexĆ£o Supabase) const [peers, setPeers] = useState>({}); // Lista de avatares permitidos (IDs de 1 a 20). Por padrĆ£o, todos estĆ£o ativos. const [allowedAvatars, setAllowedAvatars] = useState(() => { return Array.from({ length: 20 }, (_, i) => i + 1); }); const [isAvatarConfigOpen, setIsAvatarConfigOpen] = useState(false); const updateAllowedAvatars = async (newAllowed: number[]) => { setAllowedAvatars(newAllowed); if (isPresenter && channelRef.current) { console.log("āš™ļø [MeetingRoom] Atualizando metadados de Presence com avatares permitidos:", newAllowed); await channelRef.current.track({ username: name.substring(0, 10), avatarId: selectedAvatar, chairId: selectedChair, role: isPresenter ? 'presenter' : 'guest', cargo: cargo.trim() || 'Colaborador', empresa: empresa.trim() || 'SteelXR Corp', joinedAt: new Date().toISOString(), allowedAvatars: newAllowed }); } }; const mergedPeers = useMemo(() => { if (!inRoom) return peers; // Se o usuĆ”rio local jĆ” estiver no peers (via sync do Supabase), usamos o peers original if (peers[selectedChair]) { return peers; } // Caso contrĆ”rio, injetamos o usuĆ”rio local no dicionĆ”rio temporĆ”rio return { ...peers, [selectedChair]: { presence_ref: 'local_user', username: name, avatarId: selectedAvatar, chairId: selectedChair, role: isPresenter ? 'presenter' : 'guest', cargo: cargo || 'Apresentador', empresa: empresa || 'SteelXR Corp', joinedAt: new Date().toISOString() } as unknown as PeerData }; }, [peers, inRoom, selectedChair, name, selectedAvatar, isPresenter, cargo, empresa]); const activeUsersCount = useMemo(() => Object.keys(mergedPeers).length, [mergedPeers]); const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]); const [newMessage, setNewMessage] = useState(''); const [lastChatSender, setLastChatSender] = useState(null); const [isMuted, setIsMuted] = useState(false); const [isTalking, setIsTalking] = useState(false); const [roomCode, setRoomCode] = useState(roomId || ''); const [activeTab, setActiveTab] = useState<'chat' | 'participants'>('participants'); // Estado compartilhado da Apresentação da maquete const [presentationState, setPresentationState] = useState({ rotation: [0, 0, 0], scale: 1, sectionEnabled: false, sectionAxis: 'y', sectionLevel: 0.0, sectionInvert: false }); // Carrega modificaƧƵes da peƧa vindas do Viewer se houver useEffect(() => { const savedState = localStorage.getItem('tsxr_initial_meeting_state'); if (savedState) { try { const parsed = JSON.parse(savedState) as PresentationState; setPresentationState(parsed); console.log('šŸ”„ [MeetingRoom] Estado inicial carregado do Viewer:', parsed); } catch (err) { console.error('Falha ao carregar estado inicial do Viewer', err); } localStorage.removeItem('tsxr_initial_meeting_state'); } }, []); const channelRef = useRef | null>(null); const chatScrollRef = useRef(null); // Se o roomId mudar na url, atualiza o checkbox isPresenter e o inputRoomId useEffect(() => { setIsPresenter(!roomId); setInputRoomId(roomId || ''); }, [roomId]); // Simulação de detecção de Ć”udio useEffect(() => { if (!inRoom || isMuted) { setIsTalking(false); return; } const interval = setInterval(() => { setIsTalking(Math.random() > 0.7); }, 1500); return () => clearInterval(interval); }, [inRoom, isMuted]); useEffect(() => { if (chatScrollRef.current) { chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight; } }, [chatMessages]); // --- ESCUTA DO LOBBY EM TEMPO REAL --- // Conecta ao canal antes do lobby finalizar para ler assentos e avatares ocupados na sala digitada useEffect(() => { if (!inputRoomId || inputRoomId.trim().length < 3 || inRoom || isConnecting) { setPeers({}); return; } const roomIdUpper = inputRoomId.trim().toUpperCase(); console.log(`šŸ”Œ [Lobby Watcher] Escutando sala: ${roomIdUpper}`); const tempChannel = supabase.channel(`meeting_room_${roomIdUpper}`, { config: { presence: { key: 'lobby_watcher_' + Math.random().toString(36).substring(3, 8) } } }); tempChannel .on('presence', { event: 'sync' }, () => { const state = tempChannel.presenceState(); console.log("šŸ‘„ [Lobby Watcher Sync] Estado de presenƧa bruto:", state); const formattedPeers: Record = {}; let remoteAllowed: number[] | null = null; Object.keys(state).forEach((key) => { if (key.startsWith('lobby_watcher_')) return; const presences = state[key] as unknown as PeerData[]; if (presences && presences.length > 0) { presences.forEach((presence) => { if (presence && presence.chairId && presence.username) { formattedPeers[presence.chairId] = { ...presence, avatarId: Number(presence.avatarId) || 1, cargo: presence.cargo || 'Colaborador', empresa: presence.empresa || 'SteelXR Corp', role: presence.role || 'guest' }; if (presence.role === 'presenter' && Array.isArray(presence.allowedAvatars)) { remoteAllowed = presence.allowedAvatars; } } }); } }); console.log("šŸ‘„ [Lobby Watcher Sync] Peers formatados:", formattedPeers); setPeers(formattedPeers); if (remoteAllowed) { console.log("āš™ļø [Lobby Watcher Sync] Sincronizados avatares permitidos remotos:", remoteAllowed); setAllowedAvatars(remoteAllowed); } }) .subscribe(); return () => { console.log(`šŸ”Œ [Lobby Watcher] Desinscrevendo escuta passiva da sala: ${roomIdUpper}`); tempChannel.unsubscribe(); supabase.removeChannel(tempChannel); }; }, [inputRoomId, inRoom, isConnecting]); // Envia as atualizaƧƵes de apresentação via Broadcast para a sala inteira const handlePresentationChange = (updatedFields: Partial) => { if (!isPresenter || !channelRef.current) return; const store = useModelStore.getState(); const currentModel = store.models.find(m => m.id === store.activeModelId) || null; const newState = { ...presentationState, ...updatedFields, modelUrl: currentModel?.url, modelFileName: currentModel?.fileName, modelFileSize: currentModel?.fileSize } as PresentationState; setPresentationState(newState); channelRef.current.send({ type: 'broadcast', event: 'presentation_update', payload: newState }); }; // Inicializa o Supabase Presence & Broadcast para conexƵes oficiais const connectToRoom = async (targetRoomId: string) => { if (!name.trim()) { toast.error('Digite seu nome primeiro'); return; } // Salva no localStorage para auto-preenchimento conveniente localStorage.setItem('tsxr_meeting_user_name', name); localStorage.setItem('tsxr_meeting_user_cargo', cargo); localStorage.setItem('tsxr_meeting_user_empresa', empresa); localStorage.setItem('tsxr_meeting_user_avatar', String(selectedAvatar)); localStorage.setItem('tsxr_meeting_user_chair', selectedChair); localStorage.setItem('tsxr_meeting_user_is_presenter', String(isPresenter)); // Validar se o avatar jĆ” estĆ” em uso por outra pessoa const avatarInUse = Object.values(peers).find((p) => p.avatarId === selectedAvatar); if (avatarInUse) { toast.error(`O avatar "${AVATAR_PRESETS[selectedAvatar - 1].name}" jĆ” estĆ” em uso por ${avatarInUse.username}. Escolha outro!`); return; } // Validar se a cadeira estĆ” ocupada if (Object.keys(peers).includes(selectedChair)) { toast.error('O assento selecionado jĆ” estĆ” ocupado por outro participante. Por favor, selecione outro assento.'); return; } // Ativa o estado de conexĆ£o e aguarda 120ms para a escuta do lobby se desmontar totalmente setIsConnecting(true); await new Promise((resolve) => setTimeout(resolve, 120)); const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase(); setRoomCode(finalRoomId); // Evita o erro "cannot add presence callbacks ... after subscribe()" removendo canais antigos com o mesmo tópico const oldChannels = supabase.getChannels().filter(c => c.topic === `realtime:meeting_room_${finalRoomId}`); for (const old of oldChannels) { console.log(`šŸ”Œ [MeetingRoom] Removendo canal antigo ativo para evitar conflitos: ${old.topic}`); await supabase.removeChannel(old); } console.log(`šŸ”Œ [MeetingRoom] Conectando Ć  sala: ${finalRoomId} como ${isPresenter ? 'Apresentador' : 'Convidado'}`); const channel = supabase.channel(`meeting_room_${finalRoomId}`, { config: { broadcast: { self: true } }, }); channelRef.current = channel; // Sincroniza participantes online com tolerĆ¢ncia a mĆŗltiplos formatos e falhas de conexĆ£o channel .on('presence', { event: 'sync' }, () => { const state = channel.presenceState(); console.log("šŸ‘„ [Presence Sync] Estado de presenƧa bruto da reuniĆ£o:", state); const formattedPeers: Record = {}; let remoteAllowed: number[] | null = null; Object.keys(state).forEach((key) => { if (key.startsWith('lobby_watcher_')) return; const presences = state[key] as unknown as PeerData[]; if (presences && presences.length > 0) { presences.forEach((presence) => { if (presence && presence.chairId && presence.username) { formattedPeers[presence.chairId] = { ...presence, avatarId: Number(presence.avatarId) || 1, cargo: presence.cargo || 'Colaborador', empresa: presence.empresa || 'SteelXR Corp', role: presence.role || 'guest' }; if (presence.role === 'presenter' && Array.isArray(presence.allowedAvatars)) { remoteAllowed = presence.allowedAvatars; } } }); } }); console.log("šŸ‘„ [Presence Sync] Peers formatados:", formattedPeers); setPeers(formattedPeers); if (!isPresenter && remoteAllowed) { console.log("āš™ļø [Presence Sync] Sincronizando avatares permitidos remotos na sala conectada:", remoteAllowed); setAllowedAvatars(remoteAllowed); } }) .on('presence', { event: 'join' }, ({ key, newPresences }) => { if (key.startsWith('lobby_watcher_')) return; toast.info(`"${(newPresences[0] as unknown as PeerData)?.username}" sentou-se no assento ${key}`); }) .on('presence', { event: 'leave' }, ({ key, leftPresences }) => { if (key.startsWith('lobby_watcher_')) return; toast.info(`"${(leftPresences[0] as unknown as PeerData)?.username}" liberou o assento ${key}`); }); // Escuta mensagens do Chat via Broadcast channel.on('broadcast', { event: 'chat_msg' }, (payload) => { setChatMessages((prev) => [ ...prev, { id: Math.random().toString(), sender: payload.payload.sender, text: payload.payload.text, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), }, ]); setLastChatSender(payload.payload.sender); }); // Escuta atualizaƧƵes de Apresentação da Maquete via Broadcast (apenas para convidados) channel.on('broadcast', { event: 'presentation_update' }, (payload) => { if (!isPresenter) { const data = payload.payload as PresentationState; setPresentationState(data); // Se o apresentador propagar um modelo remoto que o convidado nĆ£o tem ativo, carrega if (data.modelUrl && data.modelFileName) { const store = useModelStore.getState(); const hasModel = store.models.some(m => m.fileName === data.modelFileName); if (!hasModel) { console.log(`šŸ“„ [MeetingRoom] Carregando modelo remoto do apresentador: ${data.modelFileName}`); useModelStore.getState().addModel({ fileName: data.modelFileName, fileSize: data.modelFileSize || 0, url: data.modelUrl }); } else { const existing = store.models.find(m => m.fileName === data.modelFileName); if (existing && store.activeModelId !== existing.id) { useModelStore.getState().setActiveModel(existing.id); } } } } }); // Inscrever no WebSocket channel.subscribe(async (status) => { if (status === 'SUBSCRIBED') { // Envia as informaƧƵes completas para o Presence do Supabase await channel.track({ username: name.substring(0, 10), avatarId: selectedAvatar, chairId: selectedChair, role: isPresenter ? 'presenter' : 'guest', cargo: cargo.trim() || 'Colaborador', empresa: empresa.trim() || 'SteelXR Corp', joinedAt: new Date().toISOString(), allowedAvatars: allowedAvatars }); // Se for o apresentador, propaga o estado inicial de apresentação (vinda do Viewer) para todos if (isPresenter) { const store = useModelStore.getState(); const currentModel = store.models.find(m => m.id === store.activeModelId) || null; channel.send({ type: 'broadcast', event: 'presentation_update', payload: { ...presentationState, modelUrl: currentModel?.url, modelFileName: currentModel?.fileName, modelFileSize: currentModel?.fileSize } }); } setInRoom(true); setIsConnecting(false); toast.success(`Entrou na sala de reuniĆ£o: ${finalRoomId}`); navigate(`/meeting/${finalRoomId}`, { replace: true }); } else if (status === 'CHANNEL_ERROR') { setIsConnecting(false); toast.error('Erro ao conectar ao canal de reuniƵes.'); } }); }; // Enviar Mensagem de Chat const handleSendMessage = () => { if (!newMessage.trim() || !channelRef.current) return; const senderName = name.substring(0, 10); channelRef.current.send({ type: 'broadcast', event: 'chat_msg', payload: { sender: senderName, text: newMessage.trim(), }, }); setChatMessages((prev) => [ ...prev, { id: Math.random().toString(), sender: senderName, text: newMessage.trim(), time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), }, ]); setLastChatSender(senderName); setNewMessage(''); }; // Sair da Sala de ReuniĆ£o const handleLeaveRoom = async () => { if (channelRef.current) { await supabase.removeChannel(channelRef.current); } setInRoom(false); setIsConnecting(false); setPeers({}); setChatMessages([]); navigate('/'); }; const occupiedChairs = useMemo(() => { return Object.keys(peers).filter(k => !k.startsWith('lobby_watcher_')); }, [peers]); // Checar se o avatar selecionado no lobby estĆ” em uso const isAvatarInUse = useMemo(() => { return Object.values(peers).find((p) => p.avatarId === selectedAvatar); }, [peers, selectedAvatar]); // Lista de avatares disponĆ­veis para este usuĆ”rio escolher const availableAvatars = useMemo(() => { if (isPresenter) { return AVATAR_PRESETS.map((p) => p.id); } // Para convidados, filtra apenas os IDs permitidos pelo apresentador return AVATAR_PRESETS.map((p) => p.id).filter((id) => allowedAvatars.includes(id)); }, [isPresenter, allowedAvatars]); // Se o selectedAvatar atual nĆ£o estiver na lista de permitidos, selecionamos o primeiro disponĆ­vel useEffect(() => { if (availableAvatars.length > 0 && !availableAvatars.includes(selectedAvatar)) { setSelectedAvatar(availableAvatars[0]); } }, [availableAvatars, selectedAvatar]); const nextAvatar = () => { if (availableAvatars.length === 0) return; const currentIndex = availableAvatars.indexOf(selectedAvatar); const nextIndex = (currentIndex + 1) % availableAvatars.length; setSelectedAvatar(availableAvatars[nextIndex]); }; const prevAvatar = () => { if (availableAvatars.length === 0) return; const currentIndex = availableAvatars.indexOf(selectedAvatar); const prevIndex = (currentIndex - 1 + availableAvatars.length) % availableAvatars.length; setSelectedAvatar(availableAvatars[prevIndex]); }; // --- Renderização do Lobby --- if (!inRoom) { const currentAvatar = AVATAR_PRESETS[selectedAvatar - 1]; const canJoin = name.trim().length > 0 && !isAvatarInUse && !occupiedChairs.includes(selectedChair); return (
TrackSteelXR

Lobby de Reunião Virtual

Configure suas credenciais e escolha seu avatar

{/* Seletor de Avatar (Lado Esquerdo - 5 colunas) */}
Visual Avatar {/* Enquadramento centralizado perfeito (Camera apontando e fov adequado) */}
{isWebGLSupported ? ( ) : (
WebGL Desativado Aceleração por hardware inativa. O avatar 3D não pÓde ser pré-visualizado.
)} {/* Aviso se o avatar estiver em uso na sala */} {isAvatarInUse && (
Avatar em Uso Este visual jĆ” estĆ” ocupado por {isAvatarInUse.username}
)}
{currentAvatar.name}

{currentAvatar.desc}

{isPresenter && ( )}
{/* FormulÔrio de Identificação: Nome, Cargo e Empresa agrupados sequencialmente na esquerda */}
setName(e.target.value)} className="font-semibold text-center uppercase font-mono tracking-wider text-xs h-9" />
setCargo(e.target.value)} className="text-xs h-9 text-center" />
setEmpresa(e.target.value)} className="text-xs h-9 text-center" />
{/* Seleção do Assento, Host e Conexão (Lado Direito - 7 colunas) */}
{/* Seleção do Assento (Exibe quem estÔ ocupando em tempo real) */}
Selecione seu Assento na Mesa
{CHAIRS.map((chair) => { const occupier = Object.values(peers).find((p) => p.chairId === chair.id); const isOccupied = !!occupier; const isSelected = selectedChair === chair.id; return ( ); })}
{/* Papel e Código de Conexão */}
setIsPresenter(!!checked)} />
setInputRoomId(e.target.value.toUpperCase())} className="font-mono tracking-widest font-bold text-center text-xs h-9" />
{isAvatarInUse && (

* Escolha outro avatar para liberar o acesso.

)}
{/* Modal de Configuração de Avatares Permitidos (Apenas para o Apresentador) */} {isAvatarConfigOpen && (

Avatares da Reunião

Selecione entre 4 e 10 avatares que estarão disponíveis para a reunião

{allowedAvatars.length} Selecionados
{/* Grid dos Avatares para Seleção */}
{AVATAR_PRESETS.map((preset) => { const isSelected = allowedAvatars.includes(preset.id); const isMale = preset.id <= 10; return (
{ if (isSelected) { // Se desmarcar, verifica se fica com menos de 4 if (allowedAvatars.length <= 4) { toast.warning('A reunião deve conter pelo menos 4 avatares disponíveis.'); return; } updateAllowedAvatars(allowedAvatars.filter(id => id !== preset.id)); } else { // Se marcar, verifica se excede 10 if (allowedAvatars.length >= 10) { toast.warning('Você pode selecionar no mÔximo 10 avatares.'); return; } updateAllowedAvatars([...allowedAvatars, preset.id]); } }} className={`relative flex flex-col items-center justify-center p-3 rounded-lg border-2 cursor-pointer transition-all duration-200 hover:scale-105 select-none ${ isSelected ? 'border-primary bg-primary/10 shadow-[0_0_10px_rgba(59,130,246,0.3)]' : 'border-slate-800 bg-slate-950/40 hover:border-slate-700 hover:bg-slate-950/70' }`} > {/* Badge Sexo */} {isMale ? 'M' : 'F'} {/* Miniatura da Imagem Local */}
{preset.name}
{preset.name} {preset.desc} {/* Check Icon */} {isSelected && (
)}
); })}
{/* Botão de Fechar / Salvar */}
)}
); } // --- Renderização da Sala de Reunião Ativa --- return (
{/* 3D Canvas - Sala Clara */}
{isWebGLSupported ? ( {/* Renderizar avatares de TODOS na sala - Ocultados se modo foco */} {!isFocusMode && Object.keys(mergedPeers).map((chairKey) => { const peer = mergedPeers[chairKey]; const chairConf = CHAIRS.find((c) => c.id === peer.chairId); if (!chairConf) return null; const isLocalUser = peer.chairId === selectedChair; return ( 0.5)} isLastChatSender={peer.username === lastChatSender} scale={4.8} /> ); })} ) : (

WebGL Requerido

Aceleração por hardware (WebGL) estÔ desativada ou não é suportada por seu navegador/dispositivo atual.

Como resolver no Chrome:

1. VĆ” em chrome://settings/system

2. Ative "Usar aceleração de hardware quando disponível"

3. Reinicie o navegador e recarregue esta pƔgina.

* Nota: O chat lateral, lista de participantes e chamadas continuam funcionando normalmente.
)} {/* Painel do Apresentador (Controles de Manipulação Sincronizados) */} {isPresenter && (
Painel do Apresentador Sincronizado
{/* Rotação Y */}
Giro da Peça (Horizontal) {Math.round((presentationState.rotation[1] * 180) / Math.PI)}°
handlePresentationChange({ rotation: [0, parseFloat(e.target.value), 0] })} className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary" />
{/* Escala */}
Escala (Zoom da PeƧa) {presentationState.scale.toFixed(1)}x
handlePresentationChange({ scale: parseFloat(e.target.value) })} className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary" />
{/* Plano de Corte */}
Corte de Seção (Clipping) handlePresentationChange({ sectionEnabled: e.target.checked })} className="rounded border-slate-800 bg-slate-950 accent-primary cursor-pointer h-3.5 w-3.5" />
{presentationState.sectionEnabled && (
{/* Seletor Eixo */}
{(['x', 'y', 'z'] as const).map((ax) => ( ))}
{/* Slider de NĆ­vel */}
Posição do Corte {(presentationState.sectionLevel * 100).toFixed(0)}%
handlePresentationChange({ sectionLevel: parseFloat(e.target.value) })} className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary" />
{/* Inverter Corte */}
handlePresentationChange({ sectionInvert: e.target.checked })} className="rounded border-slate-800 bg-slate-950 accent-primary cursor-pointer h-3 w-3" />
)}
{/* Botão de Reset */} {/* Botão de Apresentação no Viewer */}
)} {/* HUD Superior */}
Reunião Conectada Online: {activeUsersCount}/8
{/* Código da sala */}
CƓDIGO: {roomCode}
{/* Sair */}
{/* HUD Inferior - Controles de Ɓudio & Modo Foco */}
{isPresenter ? 'Apresentação Ativa' : 'Observador'} {isPresenter ? 'Você estÔ transmitindo os cortes e rotações da maquete.' : 'Aguarde as rotações e planos de corte efetuados pelo Host.'}
{/* Painel Lateral Direito (Abas Chat / Participantes) */}
{/* Seletor de Abas */}
{/* ABA PARTICIPANTES */} {activeTab === 'participants' && (
Quem estĆ” na sala
{Object.keys(mergedPeers).map((chairKey) => { const peer = mergedPeers[chairKey]; const isHost = peer.role === 'presenter'; const isMe = peer.chairId === selectedChair; return (
{/* Indicador da cor do avatar do Mii via classe estƔtica Tailwind */}
{peer.username.substring(0, 2).toUpperCase()}
{/* Detalhes do UsuƔrio */}
{peer.username} {isMe && '(VocĆŖ)'} {isHost && ( Host )}
{/* Cargo e Empresa */} šŸ’¼ {peer.cargo} šŸ¢ {peer.empresa}
Chair {peer.chairId}
); })}
)} {/* ABA CHAT */} {activeTab === 'chat' && ( <>
{chatMessages.length === 0 ? (

Nenhuma mensagem enviada. Seja o primeiro a digitar no chat da reunião!

) : ( chatMessages.map((msg) => (
{msg.sender} {msg.time}

{msg.text}

)) )}
{/* Input do Chat */}
setNewMessage(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSendMessage(); }} className="flex-1 text-xs border-slate-800 bg-slate-950/65" />
)}
); }