Closes FUNCTIONAL_AUDIT.md §4 #1: WebRTC 1:1 calls had working signaling but no NAT traversal, so calls between two peers behind symmetric NAT (corporate firewalls, mobile carrier CGNAT, Incus container default networking) failed silently after the SDP exchange. Backend: - GET /api/v1/config/webrtc (public) returns {iceServers: [...]} built from WEBRTC_STUN_URLS / WEBRTC_TURN_URLS / *_USERNAME / *_CREDENTIAL env vars. Half-config (URLs without creds, or vice versa) deliberately omits the TURN block — a half-configured TURN surfaces auth errors at call time instead of falling back cleanly to STUN-only. - 4 handler tests cover the matrix. Frontend: - services/api/webrtcConfig.ts caches the config for the page lifetime and falls back to the historical hardcoded Google STUN if the fetch fails. - useWebRTC fetches at mount, hands iceServers synchronously to every RTCPeerConnection, exposes a {hasTurn, loaded} hint. - CallButton tooltip warns up-front when TURN isn't configured instead of letting calls time out silently. Ops: - infra/coturn/turnserver.conf — annotated template with the SSRF- safe denied-peer-ip ranges, prometheus exporter, TLS for TURNS, static lt-cred-mech (REST-secret rotation deferred to v1.1). - infra/coturn/README.md — Incus deploy walkthrough, smoke test via turnutils_uclient, capacity rules of thumb. - docs/ENV_VARIABLES.md gains a 13bis. WebRTC ICE servers section. Coturn deployment itself is a separate ops action — this commit lands the plumbing so the deploy can light up the path with zero code changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { useChatStore } from '../store/chatStore';
|
|
import { ChatMessageComponent } from './ChatMessage';
|
|
import { useChat } from '../hooks/useChat';
|
|
import { useWebRTC } from '../hooks/useWebRTC';
|
|
import { MessageSearch } from './MessageSearch';
|
|
import { TypingIndicator } from './TypingIndicator';
|
|
import { CallButton } from './CallButton';
|
|
import { IncomingCallModal } from './IncomingCallModal';
|
|
import { ActiveCallBar } from './ActiveCallBar';
|
|
import {
|
|
Search,
|
|
X,
|
|
MessageSquare,
|
|
UserPlus,
|
|
Users,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
import { InviteRoomModal } from './InviteRoomModal';
|
|
import { RoomMembersModal } from './RoomMembersModal';
|
|
import { useUser } from '@/features/auth/hooks/useUser';
|
|
import { adminService } from '@/services/adminService';
|
|
|
|
interface ChatRoomProps {
|
|
conversationId: string;
|
|
}
|
|
|
|
export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
|
|
const { messages, conversations, userId, incomingCall, activeCall } =
|
|
useChatStore();
|
|
const { fetchHistory, sendRawMessage, wsStatus } = useChat();
|
|
const { data: _user } = useUser();
|
|
const webrtc = useWebRTC({ sendMessage: sendRawMessage });
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const [showSearch, setShowSearch] = useState(false);
|
|
const [showInviteModal, setShowInviteModal] = useState(false);
|
|
const [showMembersModal, setShowMembersModal] = useState(false);
|
|
const [webrtcEnabled, setWebrtcEnabled] = useState(true);
|
|
const [highlightedMessageId, setHighlightedMessageId] = useState<
|
|
string | null
|
|
>(null);
|
|
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
adminService.getClientFeatureFlags().then((flags) => {
|
|
if (cancelled) return;
|
|
const webrtc = flags.find((f) => f.name === 'WEBRTC_CALLS');
|
|
setWebrtcEnabled(webrtc?.enabled ?? true);
|
|
}).catch(() => { if (!cancelled) setWebrtcEnabled(true); });
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
// Cleanup highlight timeout on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (highlightTimeoutRef.current) {
|
|
clearTimeout(highlightTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const currentMessages = messages[conversationId] || [];
|
|
const fetchingRef = useRef<{ [key: string]: boolean }>({});
|
|
|
|
useEffect(() => {
|
|
if (
|
|
conversationId &&
|
|
!messages[conversationId] &&
|
|
!fetchingRef.current[conversationId]
|
|
) {
|
|
fetchingRef.current[conversationId] = true;
|
|
fetchHistory(conversationId).finally(() => {
|
|
// Fetch complete
|
|
});
|
|
}
|
|
}, [conversationId, messages[conversationId], fetchHistory]);
|
|
|
|
useEffect(() => {
|
|
if (messagesEndRef.current) {
|
|
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}, [currentMessages.length, conversationId]); // Scroll on new messages or channel switch
|
|
|
|
const handleMessageSelect = (messageId: string) => {
|
|
setHighlightedMessageId(messageId);
|
|
const messageElement = document.getElementById(`message-${messageId}`);
|
|
if (messageElement) {
|
|
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
if (highlightTimeoutRef.current) {
|
|
clearTimeout(highlightTimeoutRef.current);
|
|
}
|
|
highlightTimeoutRef.current = setTimeout(() => setHighlightedMessageId(null), 3000);
|
|
}
|
|
};
|
|
|
|
const conversation = conversations.find((c) => c.id === conversationId);
|
|
const isDM =
|
|
conversation?.type === 'direct' && conversation.participants.length === 2;
|
|
const isGroupRoom = conversation && conversation.type !== 'direct';
|
|
const targetUserId =
|
|
isDM && userId
|
|
? conversation.participants.find((p) => p !== userId) ?? null
|
|
: null;
|
|
const remoteUserName =
|
|
conversation?.name && conversation.name !== 'direct'
|
|
? conversation.name
|
|
: 'Utilisateur';
|
|
|
|
if (!conversationId) {
|
|
return (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground space-y-4 animate-empty-state-in">
|
|
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center">
|
|
<MessageSquare className="w-10 h-10 text-muted-foreground" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium text-foreground mb-1">
|
|
No conversation selected
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Pick a channel from the sidebar to start chatting.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
|
<IncomingCallModal
|
|
open={!!incomingCall}
|
|
callerName={remoteUserName}
|
|
onAccept={() =>
|
|
incomingCall &&
|
|
webrtc.acceptCall(
|
|
incomingCall.conversationId,
|
|
incomingCall.callerUserId,
|
|
incomingCall.sdp,
|
|
)
|
|
}
|
|
onReject={() =>
|
|
incomingCall &&
|
|
webrtc.rejectCall(
|
|
incomingCall.conversationId,
|
|
incomingCall.callerUserId,
|
|
)
|
|
}
|
|
/>
|
|
{activeCall && (
|
|
<div className="absolute bottom-0 left-0 right-0 z-30">
|
|
<ActiveCallBar
|
|
remoteUserName={remoteUserName}
|
|
isMuted={webrtc.isMuted}
|
|
onToggleMute={webrtc.toggleMute}
|
|
onHangup={() =>
|
|
webrtc.hangup(activeCall.conversationId, activeCall.remoteUserId)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* Search Header Overlay */}
|
|
<div
|
|
className={cn(
|
|
'absolute top-0 left-0 right-0 z-20 px-4 py-2 transition-all duration-[var(--sumi-duration-normal)]',
|
|
showSearch
|
|
? 'bg-card/90 backdrop-blur-md border-b border-border'
|
|
: 'bg-transparent pointer-events-none',
|
|
)}
|
|
>
|
|
{showSearch ? (
|
|
<div className="flex items-center gap-2 max-w-2xl mx-auto">
|
|
<div className="flex-1">
|
|
<MessageSearch
|
|
conversationId={conversationId}
|
|
onMessageSelect={handleMessageSelect}
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowSearch(false)}
|
|
className="hover:bg-muted/50"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex justify-end items-center gap-2 pointer-events-auto">
|
|
{isGroupRoom && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowInviteModal(true)}
|
|
className="text-muted-foreground/50 hover:text-foreground hover:bg-muted/50 bg-muted/30 backdrop-blur-sm rounded-full h-8 px-4 shadow-[0_0_8px_rgba(26,26,30,0.05)]"
|
|
>
|
|
<UserPlus className="h-3 w-3 mr-2" />
|
|
<span className="text-xs font-mono uppercase">Invite</span>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowMembersModal(true)}
|
|
className="text-muted-foreground/50 hover:text-foreground hover:bg-muted/50 bg-muted/30 backdrop-blur-sm rounded-full h-8 px-4 shadow-[0_0_8px_rgba(26,26,30,0.05)]"
|
|
>
|
|
<Users className="h-3 w-3 mr-2" />
|
|
<span className="text-xs font-mono uppercase">Members</span>
|
|
</Button>
|
|
</>
|
|
)}
|
|
{isDM && targetUserId && webrtcEnabled && (
|
|
<CallButton
|
|
conversationId={conversationId}
|
|
targetUserId={targetUserId}
|
|
onCall={() =>
|
|
webrtc.startCall(conversationId, targetUserId, 'audio')
|
|
}
|
|
disabled={wsStatus !== 'connected'}
|
|
hasTurn={webrtc.nat.loaded ? webrtc.nat.hasTurn : undefined}
|
|
/>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowSearch(true)}
|
|
className="text-muted-foreground/50 hover:text-foreground hover:bg-muted/50 bg-muted/30 backdrop-blur-sm rounded-full h-8 px-4 shadow-[0_0_8px_rgba(26,26,30,0.05)]"
|
|
>
|
|
<Search className="h-3 w-3 mr-2" />
|
|
<span className="text-xs font-mono uppercase">Search Log</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 scroll-smooth">
|
|
{/* Welcome Message for Empty Room */}
|
|
{currentMessages.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center h-layout-lyrics-sm text-center space-y-4 animate-empty-state-in">
|
|
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
|
|
<MessageSquare className="w-7 h-7 text-muted-foreground" />
|
|
</div>
|
|
<div>
|
|
<p className="text-foreground font-medium">No messages yet</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Send the first message to start the conversation.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Message Stream */}
|
|
{currentMessages.map((msg) => {
|
|
return (
|
|
<div
|
|
key={msg.id}
|
|
id={`message-${msg.id}`}
|
|
className={cn(
|
|
'transition-all duration-[var(--sumi-duration-slow)] animate-slideUp',
|
|
highlightedMessageId === msg.id &&
|
|
'bg-muted/10 rounded-xl -mx-4 px-4 py-2 ring-1 ring-border/30',
|
|
)}
|
|
>
|
|
<ChatMessageComponent message={msg} />
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<TypingIndicator conversationId={conversationId} />
|
|
<div ref={messagesEndRef} className="h-4" />
|
|
</div>
|
|
|
|
{isGroupRoom && (
|
|
<>
|
|
<InviteRoomModal
|
|
open={showInviteModal}
|
|
onOpenChange={setShowInviteModal}
|
|
conversationId={conversationId}
|
|
/>
|
|
<RoomMembersModal
|
|
open={showMembersModal}
|
|
onOpenChange={setShowMembersModal}
|
|
conversationId={conversationId}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|