From b517258ef58b4e2a559f52c8ee5ec2faced4e0df Mon Sep 17 00:00:00 2001 From: senke Date: Sun, 22 Feb 2026 03:46:10 +0100 Subject: [PATCH] feat(chat): add call signaling types --- CHANGELOG.md | 15 +- .../chat/components/ActiveCallBar.stories.tsx | 27 ++ .../chat/components/ActiveCallBar.tsx | 48 +++ .../chat/components/CallButton.stories.tsx | 27 ++ .../features/chat/components/CallButton.tsx | 29 ++ .../src/features/chat/components/ChatRoom.tsx | 69 ++++- .../components/IncomingCallModal.stories.tsx | 27 ++ .../chat/components/IncomingCallModal.tsx | 57 ++++ apps/web/src/features/chat/hooks/useChat.ts | 67 +++++ apps/web/src/features/chat/hooks/useWebRTC.ts | 280 ++++++++++++++++++ apps/web/src/features/chat/store/chatStore.ts | 87 ++++++ apps/web/src/features/chat/types/index.ts | 23 +- apps/web/src/hooks/types.ts | 1 + apps/web/src/mocks/test-helpers.ts | 1 + docs/FEATURE_STATUS.md | 4 +- docs/PROJECT_STATE.md | 21 +- veza-chat-server/src/websocket/handler.rs | 3 + veza-chat-server/src/websocket/mod.rs | 1 + 18 files changed, 768 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/features/chat/components/ActiveCallBar.stories.tsx create mode 100644 apps/web/src/features/chat/components/ActiveCallBar.tsx create mode 100644 apps/web/src/features/chat/components/CallButton.stories.tsx create mode 100644 apps/web/src/features/chat/components/CallButton.tsx create mode 100644 apps/web/src/features/chat/components/IncomingCallModal.stories.tsx create mode 100644 apps/web/src/features/chat/components/IncomingCallModal.tsx create mode 100644 apps/web/src/features/chat/hooks/useWebRTC.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e920f61f0..f6c92d07e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog - Veza +## [v0.303] - 2026-02-22 + +### Added + +- **Lot C2 — Chat appels WebRTC 1-to-1** + - Chat Server : signalisation CallOffer, CallAnswer, ICECandidate, CallHangup, CallReject + - WebSocketManager.send_to_user pour livraison 1-to-1 + - RateLimitAction::CallSignaling (60 req/min) + - Frontend : useWebRTC hook, CallButton, IncomingCallModal, ActiveCallBar + - Appels audio 1-to-1 dans conversations DM + +--- + ## [v0.302] - 2026-02-21 ### Added @@ -31,7 +44,7 @@ ### Deferred (v0.303) -- **Lot C2** : Chat appels WebRTC (signalisation, CallButton, IncomingCallModal, ActiveCallBar) +- **Lot C2** : Livré en v0.303 --- diff --git a/apps/web/src/features/chat/components/ActiveCallBar.stories.tsx b/apps/web/src/features/chat/components/ActiveCallBar.stories.tsx new file mode 100644 index 000000000..d829377de --- /dev/null +++ b/apps/web/src/features/chat/components/ActiveCallBar.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ActiveCallBar } from './ActiveCallBar'; + +const meta: Meta = { + component: ActiveCallBar, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + remoteUserName: 'Bob', + isMuted: false, + onToggleMute: () => {}, + onHangup: () => {}, + }, +}; + +export const Muted: Story = { + args: { + ...Default.args, + isMuted: true, + }, +}; diff --git a/apps/web/src/features/chat/components/ActiveCallBar.tsx b/apps/web/src/features/chat/components/ActiveCallBar.tsx new file mode 100644 index 000000000..b4f0403e0 --- /dev/null +++ b/apps/web/src/features/chat/components/ActiveCallBar.tsx @@ -0,0 +1,48 @@ +import { Button } from '@/components/ui/button'; +import { Mic, MicOff, PhoneOff } from 'lucide-react'; + +interface ActiveCallBarProps { + remoteUserName: string; + isMuted: boolean; + onToggleMute: () => void; + onHangup: () => void; +} + +export function ActiveCallBar({ + remoteUserName, + isMuted, + onToggleMute, + onHangup, +}: ActiveCallBarProps) { + return ( +
+ + En appel avec {remoteUserName} + +
+ + +
+
+ ); +} diff --git a/apps/web/src/features/chat/components/CallButton.stories.tsx b/apps/web/src/features/chat/components/CallButton.stories.tsx new file mode 100644 index 000000000..e45785e21 --- /dev/null +++ b/apps/web/src/features/chat/components/CallButton.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { CallButton } from './CallButton'; + +const meta: Meta = { + component: CallButton, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + conversationId: 'conv-123', + targetUserId: 'user-456', + onCall: () => {}, + disabled: false, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/apps/web/src/features/chat/components/CallButton.tsx b/apps/web/src/features/chat/components/CallButton.tsx new file mode 100644 index 000000000..4b648ca17 --- /dev/null +++ b/apps/web/src/features/chat/components/CallButton.tsx @@ -0,0 +1,29 @@ +import { Button } from '@/components/ui/button'; +import { Phone } from 'lucide-react'; + +interface CallButtonProps { + conversationId: string; + targetUserId: string; + onCall: () => void; + disabled?: boolean; +} + +export function CallButton({ + conversationId, + targetUserId, + onCall, + disabled = false, +}: CallButtonProps) { + return ( + + ); +} diff --git a/apps/web/src/features/chat/components/ChatRoom.tsx b/apps/web/src/features/chat/components/ChatRoom.tsx index f5c140ec6..0f51df513 100644 --- a/apps/web/src/features/chat/components/ChatRoom.tsx +++ b/apps/web/src/features/chat/components/ChatRoom.tsx @@ -2,8 +2,12 @@ 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 @@ -17,9 +21,11 @@ interface ChatRoomProps { } export const ChatRoom: React.FC = ({ conversationId }) => { - const { messages } = useChatStore(); - const { fetchHistory } = useChat(); - const { data: _user } = useUser(); + const { messages, conversations, userId, incomingCall, activeCall } = + useChatStore(); + const { fetchHistory, sendRawMessage, wsStatus } = useChat(); + const { data: user } = useUser(); + const webrtc = useWebRTC({ sendMessage: sendRawMessage }); const messagesEndRef = useRef(null); const [showSearch, setShowSearch] = useState(false); const [highlightedMessageId, setHighlightedMessageId] = useState< @@ -70,6 +76,18 @@ export const ChatRoom: React.FC = ({ conversationId }) => { } }; + const conversation = conversations.find((c) => c.id === conversationId); + const isDM = + conversation?.type === 'direct' && conversation.participants.length === 2; + 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 (
@@ -89,7 +107,38 @@ export const ChatRoom: React.FC = ({ conversationId }) => { } return ( -
+
+ + incomingCall && + webrtc.acceptCall( + incomingCall.conversationId, + incomingCall.callerUserId, + incomingCall.sdp, + ) + } + onReject={() => + incomingCall && + webrtc.rejectCall( + incomingCall.conversationId, + incomingCall.callerUserId, + ) + } + /> + {activeCall && ( +
+ + webrtc.hangup(activeCall.conversationId, activeCall.remoteUserId) + } + /> +
+ )} {/* Search Header Overlay */}
= ({ conversationId }) => {
) : ( -
+
+ {isDM && targetUserId && ( + + webrtc.startCall(conversationId, targetUserId, 'audio') + } + disabled={wsStatus !== 'connected'} + /> + )} + +
+
+ + + ); +} diff --git a/apps/web/src/features/chat/hooks/useChat.ts b/apps/web/src/features/chat/hooks/useChat.ts index 5dc9ed2de..9b8c91a58 100644 --- a/apps/web/src/features/chat/hooks/useChat.ts +++ b/apps/web/src/features/chat/hooks/useChat.ts @@ -28,6 +28,12 @@ export const useChat = (): UseChatReturn => { setUserTyping, setMessageDelivered, setMessageRead, + setIncomingCall, + setActiveCall, + setCallState, + clearCall, + setPendingCallAnswer, + addPendingICECandidate, } = useChatStore(); const ws = useRef(null); @@ -167,6 +173,60 @@ export const useChat = (): UseChatReturn => { delivered.delivered_at, ); } + } else if (data.type === 'CallOffer') { + const call: IncomingMessage = data; + if ( + call.conversation_id && + call.caller_user_id && + call.sdp && + call.call_type + ) { + setIncomingCall({ + conversationId: call.conversation_id, + callerUserId: call.caller_user_id, + sdp: call.sdp, + callType: call.call_type, + }); + setCallState('ringing'); + } + } else if (data.type === 'CallAnswer') { + const call: IncomingMessage = data; + if ( + call.conversation_id && + (call.from_user_id || call.target_user_id) && + call.sdp + ) { + setPendingCallAnswer({ + conversationId: call.conversation_id, + sdp: call.sdp, + fromUserId: call.from_user_id || call.target_user_id || '', + }); + } + } else if (data.type === 'ICECandidate') { + const ice: IncomingMessage = data; + if ( + ice.conversation_id && + ice.from_user_id && + ice.candidate + ) { + addPendingICECandidate({ + conversationId: ice.conversation_id, + fromUserId: ice.from_user_id, + candidate: ice.candidate, + }); + } + } else if (data.type === 'CallHangup') { + const hangup: IncomingMessage = data; + if (hangup.conversation_id && hangup.user_id) { + setCallState('ended'); + clearCall(); + } + } else if (data.type === 'CallRejected') { + const rejected: IncomingMessage = data; + if (rejected.conversation_id && rejected.user_id) { + setCallState('ended'); + clearCall(); + } } // Handle other incoming message types (ActionConfirmed, Error, Pong) }; @@ -321,6 +381,12 @@ export const useChat = (): UseChatReturn => { [currentConversationId, userId], ); + const sendRawMessage = useCallback((msg: OutgoingMessage) => { + if (ws.current?.readyState === WebSocket.OPEN) { + ws.current.send(JSON.stringify(msg)); + } + }, []); + const markAsDelivered = useCallback( (messageId: string) => { if ( @@ -437,6 +503,7 @@ export const useChat = (): UseChatReturn => { connect, disconnect, sendMessage, + sendRawMessage, fetchHistory, addReaction: addReactionFunc, removeReaction: removeReactionFunc, diff --git a/apps/web/src/features/chat/hooks/useWebRTC.ts b/apps/web/src/features/chat/hooks/useWebRTC.ts new file mode 100644 index 000000000..ff42d335b --- /dev/null +++ b/apps/web/src/features/chat/hooks/useWebRTC.ts @@ -0,0 +1,280 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useChatStore } from '../store/chatStore'; +import type { OutgoingMessage } from '../types'; + +const ICE_SERVERS: RTCConfiguration['iceServers'] = [ + { urls: 'stun:stun.l.google.com:19302' }, +]; + +export interface UseWebRTCOptions { + sendMessage: (msg: OutgoingMessage) => void; +} + +export function useWebRTC({ sendMessage }: UseWebRTCOptions) { + const { + userId, + incomingCall, + callState, + setCallState, + setIncomingCall, + setActiveCall, + clearCall, + pendingCallAnswer, + setPendingCallAnswer, + pendingICECandidates, + clearPendingICECandidates, + } = useChatStore(); + + const pcRef = useRef(null); + const localStreamRef = useRef(null); + const [isMuted, setIsMuted] = useState(false); + + const cleanup = useCallback(() => { + if (pcRef.current) { + pcRef.current.close(); + pcRef.current = null; + } + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach((t) => t.stop()); + localStreamRef.current = null; + } + clearCall(); + }, [clearCall]); + + const startCall = useCallback( + async (conversationId: string, targetUserId: string, callType = 'audio') => { + if (!userId) return; + try { + setCallState('calling'); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: callType === 'video', + }); + localStreamRef.current = stream; + + const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); + pcRef.current = pc; + + stream.getTracks().forEach((track) => pc.addTrack(track, stream)); + + pc.onicecandidate = (e) => { + if (e.candidate) { + sendMessage({ + type: 'ICECandidate', + conversation_id: conversationId, + target_user_id: targetUserId, + candidate: JSON.stringify(e.candidate), + }); + } + }; + + pc.onconnectionstatechange = () => { + if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { + setCallState('ended'); + cleanup(); + } else if (pc.connectionState === 'connected') { + setCallState('connected'); + setActiveCall({ conversationId, remoteUserId: targetUserId }); + } + }; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + sendMessage({ + type: 'CallOffer', + conversation_id: conversationId, + target_user_id: targetUserId, + sdp: JSON.stringify(offer), + call_type: callType, + }); + } catch (err) { + console.error('startCall error:', err); + setCallState('error'); + cleanup(); + } + }, + [userId, sendMessage, setCallState, setActiveCall, cleanup], + ); + + const acceptCall = useCallback( + async (conversationId: string, callerUserId: string, sdpStr: string) => { + if (!userId || !incomingCall) return; + try { + setCallState('ringing'); + setIncomingCall(null); + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: incomingCall.callType === 'video', + }); + localStreamRef.current = stream; + + const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); + pcRef.current = pc; + + stream.getTracks().forEach((track) => pc.addTrack(track, stream)); + + pc.onicecandidate = (e) => { + if (e.candidate) { + sendMessage({ + type: 'ICECandidate', + conversation_id: conversationId, + target_user_id: callerUserId, + candidate: JSON.stringify(e.candidate), + }); + } + }; + + pc.onconnectionstatechange = () => { + if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { + setCallState('ended'); + cleanup(); + } else if (pc.connectionState === 'connected') { + setCallState('connected'); + setActiveCall({ conversationId, remoteUserId: callerUserId }); + } + }; + + const offer = JSON.parse(sdpStr) as RTCSessionDescriptionInit; + await pc.setRemoteDescription(new RTCSessionDescription(offer)); + + const candidates = useChatStore.getState().pendingICECandidates.filter( + (c) => c.conversationId === conversationId && c.fromUserId === callerUserId, + ); + for (const { candidate } of candidates) { + try { + await pc.addIceCandidate(new RTCIceCandidate(JSON.parse(candidate))); + } catch { + // Ignore + } + } + clearPendingICECandidates(); + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + sendMessage({ + type: 'CallAnswer', + conversation_id: conversationId, + caller_user_id: callerUserId, + sdp: JSON.stringify(answer), + }); + } catch (err) { + console.error('acceptCall error:', err); + setCallState('error'); + cleanup(); + } + }, + [ + userId, + incomingCall, + sendMessage, + setCallState, + setIncomingCall, + setActiveCall, + clearPendingICECandidates, + cleanup, + ], + ); + + const rejectCall = useCallback( + (conversationId: string, callerUserId: string) => { + sendMessage({ + type: 'CallReject', + conversation_id: conversationId, + caller_user_id: callerUserId, + }); + setIncomingCall(null); + setCallState('idle'); + }, + [sendMessage, setIncomingCall, setCallState], + ); + + const hangup = useCallback( + (conversationId: string, targetUserId: string) => { + sendMessage({ + type: 'CallHangup', + conversation_id: conversationId, + target_user_id: targetUserId, + }); + cleanup(); + }, + [sendMessage, cleanup], + ); + + const toggleMute = useCallback(() => { + if (localStreamRef.current) { + const audioTracks = localStreamRef.current.getAudioTracks(); + audioTracks.forEach((track) => { + track.enabled = !track.enabled; + }); + setIsMuted((m) => !m); + } + }, []); + + // Process incoming ICECandidates when we have remote description + useEffect(() => { + const pc = pcRef.current; + if (!pc || !pc.remoteDescription || pendingICECandidates.length === 0) return; + + const toProcess = [...pendingICECandidates]; + clearPendingICECandidates(); + + toProcess.forEach(({ candidate }) => { + try { + pc.addIceCandidate(new RTCIceCandidate(JSON.parse(candidate))).catch(() => { + // Ignore + }); + } catch { + // Ignore + } + }); + }, [pendingICECandidates, clearPendingICECandidates]); + + // Process pending CallAnswer (caller receives answer from callee) + useEffect(() => { + if (!pendingCallAnswer || !pcRef.current) return; + const { conversationId, sdp, fromUserId } = pendingCallAnswer; + setPendingCallAnswer(null); + + const pc = pcRef.current; + const answer = JSON.parse(sdp) as RTCSessionDescriptionInit; + pc.setRemoteDescription(new RTCSessionDescription(answer)) + .then(async () => { + const candidates = useChatStore.getState().pendingICECandidates.filter( + (c) => c.conversationId === conversationId && c.fromUserId === fromUserId, + ); + for (const { candidate } of candidates) { + try { + await pc.addIceCandidate(new RTCIceCandidate(JSON.parse(candidate))); + } catch { + // Ignore + } + } + clearPendingICECandidates(); + }) + .catch((err) => { + console.error('setRemoteDescription error:', err); + setCallState('error'); + cleanup(); + }); + }, [ + pendingCallAnswer, + setPendingCallAnswer, + clearPendingICECandidates, + setCallState, + cleanup, + ]); + + return { + startCall, + acceptCall, + rejectCall, + hangup, + toggleMute, + callState, + isMuted, + cleanup, + }; +} diff --git a/apps/web/src/features/chat/store/chatStore.ts b/apps/web/src/features/chat/store/chatStore.ts index 4be13d9ee..f07c641a9 100644 --- a/apps/web/src/features/chat/store/chatStore.ts +++ b/apps/web/src/features/chat/store/chatStore.ts @@ -26,6 +26,26 @@ export interface Conversation { unread_count: number; } +export type CallState = + | 'idle' + | 'calling' + | 'ringing' + | 'connected' + | 'ended' + | 'error'; + +export interface IncomingCallInfo { + conversationId: string; + callerUserId: string; + sdp: string; + callType: string; +} + +export interface ActiveCallInfo { + conversationId: string; + remoteUserId: string; +} + export interface ChatState { userId: string | null; // Current authenticated user ID username: string | null; @@ -37,6 +57,21 @@ export interface ChatState { wsUrl: string | null; wsStatus: 'disconnected' | 'connecting' | 'connected' | 'error'; + // Call state (C2 WebRTC) + incomingCall: IncomingCallInfo | null; + activeCall: ActiveCallInfo | null; + callState: CallState; + pendingCallAnswer: { + conversationId: string; + sdp: string; + fromUserId: string; + } | null; + pendingICECandidates: Array<{ + conversationId: string; + fromUserId: string; + candidate: string; + }>; + // Actions setUserId: (userId: string | null, username: string | null) => void; setWsToken: (token: string, wsUrl: string) => void; @@ -73,6 +108,21 @@ export interface ChatState { messageId: string, readAt?: string, ) => void; + setIncomingCall: (call: IncomingCallInfo | null) => void; + setActiveCall: (call: ActiveCallInfo | null) => void; + setCallState: (state: CallState) => void; + clearCall: () => void; + setPendingCallAnswer: (data: { + conversationId: string; + sdp: string; + fromUserId: string; + } | null) => void; + addPendingICECandidate: (data: { + conversationId: string; + fromUserId: string; + candidate: string; + }) => void; + clearPendingICECandidates: () => void; } export const useChatStore = create()( @@ -87,6 +137,11 @@ export const useChatStore = create()( wsToken: null, wsUrl: null, wsStatus: 'disconnected', + incomingCall: null, + activeCall: null, + callState: 'idle', + pendingCallAnswer: null, + pendingICECandidates: [], setUserId: (userId, username) => set((state) => { @@ -223,6 +278,38 @@ export const useChatStore = create()( } } }), + setIncomingCall: (call) => + set((state) => { + state.incomingCall = call; + }), + setActiveCall: (call) => + set((state) => { + state.activeCall = call; + }), + setCallState: (callState) => + set((state) => { + state.callState = callState; + }), + clearCall: () => + set((state) => { + state.incomingCall = null; + state.activeCall = null; + state.callState = 'idle'; + state.pendingCallAnswer = null; + state.pendingICECandidates = []; + }), + setPendingCallAnswer: (data) => + set((state) => { + state.pendingCallAnswer = data; + }), + addPendingICECandidate: (data) => + set((state) => { + state.pendingICECandidates.push(data); + }), + clearPendingICECandidates: () => + set((state) => { + state.pendingICECandidates = []; + }), })), { name: 'ChatStore', diff --git a/apps/web/src/features/chat/types/index.ts b/apps/web/src/features/chat/types/index.ts index e25c20acb..f6fa728cd 100644 --- a/apps/web/src/features/chat/types/index.ts +++ b/apps/web/src/features/chat/types/index.ts @@ -16,6 +16,11 @@ export interface OutgoingMessage { | 'Typing' | 'AddReaction' | 'RemoveReaction' + | 'CallOffer' + | 'CallAnswer' + | 'ICECandidate' + | 'CallHangup' + | 'CallReject' | 'Ping'; conversation_id?: string; content?: string; @@ -24,6 +29,11 @@ export interface OutgoingMessage { is_typing?: boolean; emoji?: string; attachments?: MessageAttachment[]; + target_user_id?: string; + caller_user_id?: string; + sdp?: string; + candidate?: string; + call_type?: string; } export interface IncomingMessage { @@ -37,7 +47,12 @@ export interface IncomingMessage { | 'ReactionRemoved' | 'MessageRead' | 'MessageDelivered' - | 'HistoryChunk'; + | 'HistoryChunk' + | 'CallOffer' + | 'CallAnswer' + | 'ICECandidate' + | 'CallHangup' + | 'CallRejected'; conversation_id: string; message_id?: string; sender_id?: string; @@ -56,6 +71,12 @@ export interface IncomingMessage { messages?: HistoryMessage[]; // For HistoryChunk has_more_before?: boolean; has_more_after?: boolean; + caller_user_id?: string; + target_user_id?: string; + from_user_id?: string; // For CallAnswer: who sent the answer + sdp?: string; + candidate?: string; + call_type?: string; } export interface HistoryMessage { diff --git a/apps/web/src/hooks/types.ts b/apps/web/src/hooks/types.ts index 4d524a765..93acdba6a 100644 --- a/apps/web/src/hooks/types.ts +++ b/apps/web/src/hooks/types.ts @@ -57,6 +57,7 @@ export interface UseChatReturn { content: string, attachments?: import('@/features/chat/types').MessageAttachment[], ) => void; + sendRawMessage: (msg: import('@/features/chat/types').OutgoingMessage) => void; fetchHistory: (conversationId: string) => Promise; addReaction: (messageId: string, emoji: string) => void; removeReaction: (messageId: string) => void; diff --git a/apps/web/src/mocks/test-helpers.ts b/apps/web/src/mocks/test-helpers.ts index 26c66c3c3..92705e87e 100644 --- a/apps/web/src/mocks/test-helpers.ts +++ b/apps/web/src/mocks/test-helpers.ts @@ -47,6 +47,7 @@ export function useChat() { return { messages: [] as Message[], sendMessage: () => {}, + sendRawMessage: () => {}, conversations: [] as Conversation[], currentConversation: null, setCurrentConversation: () => {}, diff --git a/docs/FEATURE_STATUS.md b/docs/FEATURE_STATUS.md index 6499da8f9..f0c6f9faf 100644 --- a/docs/FEATURE_STATUS.md +++ b/docs/FEATURE_STATUS.md @@ -92,11 +92,11 @@ Voir [V0_301_RELEASE_SCOPE.md](V0_301_RELEASE_SCOPE.md) pour le détail. | N1 | Notifications push : Web Push subscription, envoi sur follow/like/comment/message, préférences, badge titre | | P2 | Présence enrichie : rich presence (track en cours), mode invisible, PUT /users/me/presence | -## À livrer en v0.303 +## Livré en v0.303 | Lot | Feature | |-----|---------| -| C2 | Chat appels : WebRTC audio/vidéo 1-to-1, signalisation via WebSocket (CallOffer, CallAnswer, ICECandidate, CallHangup, CallReject) | +| C2 | Chat appels : WebRTC audio 1-to-1, signalisation via WebSocket (CallOffer, CallAnswer, ICECandidate, CallHangup, CallReject), CallButton, IncomingCallModal, ActiveCallBar | Voir [V0_303_RELEASE_SCOPE.md](V0_303_RELEASE_SCOPE.md) pour le détail. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index f1d8e91bd..dce570906 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -9,9 +9,9 @@ | Élément | Valeur | |---------|--------| | **Dernier tag** | v0.302 | -| **Branche courante** | `main` (v0.302 mergée) | -| **Phase** | Phase 3 Social — Lots S2, N1, P2 livrés (C2 reporté v0.303) | -| **Prochaine version** | v0.303 | +| **Branche courante** | `main` | +| **Phase** | Phase 3 Social — Lots S2, N1, P2, C2 livrés | +| **Prochaine version** | v0.304 | --- @@ -48,20 +48,21 @@ - Lot S2 : Groupes avancés (request join, invite, rôles, feed groupes, mes groupes) - Lot N1 : Notifications push Web (subscription, envoi sur événement, préférences, badge) - Lot P2 : Présence enrichie (rich presence track, mode invisible, PUT /users/me/presence) -- Lot C2 : Reporté en v0.303 + +### v0.303 (Phase 3 Social — Lot C2) +- Lot C2 : Chat appels WebRTC 1-to-1 (signalisation, CallButton, IncomingCallModal, ActiveCallBar) --- ## 3. Prochaines étapes -### Immédiat (post v0.302) -1. Tag : v0.302 +### Immédiat (post v0.303) +1. Tag : v0.303 2. Merge dans main -3. V0_303_RELEASE_SCOPE.md créé (scope complet Lot C2) +3. Créer V0_304_RELEASE_SCOPE.md (placeholder) -### Prochaine version (v0.303) -- **Lot C2** : Chat appels WebRTC 1-to-1 (signalisation CallOffer/Answer/ICE, CallButton, IncomingCallModal, ActiveCallBar) -- Voir [V0_303_RELEASE_SCOPE.md](V0_303_RELEASE_SCOPE.md) et [PLAN_V0_303_IMPLEMENTATION.md](PLAN_V0_303_IMPLEMENTATION.md) +### Prochaine version (v0.304) +- À définir (appels groupe, E2E, FCM, forum groupes, etc.) --- diff --git a/veza-chat-server/src/websocket/handler.rs b/veza-chat-server/src/websocket/handler.rs index b162cdfd4..c3f723c34 100644 --- a/veza-chat-server/src/websocket/handler.rs +++ b/veza-chat-server/src/websocket/handler.rs @@ -1145,9 +1145,12 @@ async fn handle_incoming_message( caller_user_id, sdp, } => { + let callee_uuid = Uuid::parse_str(&claims.user_id) + .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; let msg = OutgoingMessage::CallAnswer { conversation_id, target_user_id: caller_user_id, + from_user_id: callee_uuid, sdp: sdp.clone(), }; state diff --git a/veza-chat-server/src/websocket/mod.rs b/veza-chat-server/src/websocket/mod.rs index 0554703cd..75bd6ccbb 100644 --- a/veza-chat-server/src/websocket/mod.rs +++ b/veza-chat-server/src/websocket/mod.rs @@ -227,6 +227,7 @@ pub enum OutgoingMessage { CallAnswer { conversation_id: Uuid, target_user_id: Uuid, + from_user_id: Uuid, sdp: String, }, /// Appel WebRTC — candidat ICE