feat(chat): add call signaling types

This commit is contained in:
senke 2026-02-22 03:46:10 +01:00
parent 2c0614fa3a
commit f48a910d5d
18 changed files with 768 additions and 19 deletions

View file

@ -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
---

View file

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ActiveCallBar } from './ActiveCallBar';
const meta: Meta<typeof ActiveCallBar> = {
component: ActiveCallBar,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof ActiveCallBar>;
export const Default: Story = {
args: {
remoteUserName: 'Bob',
isMuted: false,
onToggleMute: () => {},
onHangup: () => {},
},
};
export const Muted: Story = {
args: {
...Default.args,
isMuted: true,
},
};

View file

@ -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 (
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-t border-border">
<span className="text-sm text-muted-foreground">
En appel avec {remoteUserName}
</span>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={onToggleMute}
className="h-8 w-8 p-0"
aria-label={isMuted ? 'Activer le micro' : 'Couper le micro'}
>
{isMuted ? (
<MicOff className="h-4 w-4 text-destructive" />
) : (
<Mic className="h-4 w-4" />
)}
</Button>
<Button
variant="destructive"
size="sm"
onClick={onHangup}
className="h-8 w-8 p-0"
aria-label="Raccrocher"
>
<PhoneOff className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CallButton } from './CallButton';
const meta: Meta<typeof CallButton> = {
component: CallButton,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof CallButton>;
export const Default: Story = {
args: {
conversationId: 'conv-123',
targetUserId: 'user-456',
onCall: () => {},
disabled: false,
},
};
export const Disabled: Story = {
args: {
...Default.args,
disabled: true,
},
};

View file

@ -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 (
<Button
variant="ghost"
size="sm"
onClick={onCall}
disabled={disabled}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Appeler"
>
<Phone className="h-4 w-4" />
</Button>
);
}

View file

@ -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<ChatRoomProps> = ({ 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<HTMLDivElement>(null);
const [showSearch, setShowSearch] = useState(false);
const [highlightedMessageId, setHighlightedMessageId] = useState<
@ -70,6 +76,18 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ 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 (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground space-y-4 animate-empty-state-in">
@ -89,7 +107,38 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
}
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
<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(
@ -117,7 +166,17 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
</Button>
</div>
) : (
<div className="flex justify-end pointer-events-auto">
<div className="flex justify-end items-center gap-2 pointer-events-auto">
{isDM && targetUserId && (
<CallButton
conversationId={conversationId}
targetUserId={targetUserId}
onCall={() =>
webrtc.startCall(conversationId, targetUserId, 'audio')
}
disabled={wsStatus !== 'connected'}
/>
)}
<Button
variant="ghost"
size="sm"

View file

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IncomingCallModal } from './IncomingCallModal';
const meta: Meta<typeof IncomingCallModal> = {
component: IncomingCallModal,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof IncomingCallModal>;
export const Default: Story = {
args: {
open: true,
callerName: 'Alice',
onAccept: () => {},
onReject: () => {},
},
};
export const Closed: Story = {
args: {
...Default.args,
open: false,
},
};

View file

@ -0,0 +1,57 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Phone, PhoneOff } from 'lucide-react';
interface IncomingCallModalProps {
open: boolean;
callerName: string;
onAccept: () => void;
onReject: () => void;
}
export function IncomingCallModal({
open,
callerName,
onAccept,
onReject,
}: IncomingCallModalProps) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onReject()}>
<DialogContent className="sm:max-w-md" onPointerDownOutside={onReject}>
<DialogHeader>
<DialogTitle>Appel entrant</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-6 py-4">
<p className="text-muted-foreground">
{callerName} vous appelle
</p>
<div className="flex gap-4">
<Button
variant="default"
size="lg"
onClick={onAccept}
className="rounded-full h-14 w-14 p-0 bg-success hover:bg-success/90"
aria-label="Accepter"
>
<Phone className="h-6 w-6" />
</Button>
<Button
variant="destructive"
size="lg"
onClick={onReject}
className="rounded-full h-14 w-14 p-0"
aria-label="Refuser"
>
<PhoneOff className="h-6 w-6" />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -28,6 +28,12 @@ export const useChat = (): UseChatReturn => {
setUserTyping,
setMessageDelivered,
setMessageRead,
setIncomingCall,
setActiveCall,
setCallState,
clearCall,
setPendingCallAnswer,
addPendingICECandidate,
} = useChatStore();
const ws = useRef<WebSocket | null>(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,

View file

@ -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<RTCPeerConnection | null>(null);
const localStreamRef = useRef<MediaStream | null>(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,
};
}

View file

@ -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<ChatState>()(
@ -87,6 +137,11 @@ export const useChatStore = create<ChatState>()(
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<ChatState>()(
}
}
}),
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',

View file

@ -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 {

View file

@ -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<void>;
addReaction: (messageId: string, emoji: string) => void;
removeReaction: (messageId: string) => void;

View file

@ -47,6 +47,7 @@ export function useChat() {
return {
messages: [] as Message[],
sendMessage: () => {},
sendRawMessage: () => {},
conversations: [] as Conversation[],
currentConversation: null,
setCurrentConversation: () => {},

View file

@ -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.

View file

@ -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.)
---

View file

@ -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

View file

@ -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