feat(chat): add call signaling types
This commit is contained in:
parent
2c0614fa3a
commit
f48a910d5d
18 changed files with 768 additions and 19 deletions
15
CHANGELOG.md
15
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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
48
apps/web/src/features/chat/components/ActiveCallBar.tsx
Normal file
48
apps/web/src/features/chat/components/ActiveCallBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/web/src/features/chat/components/CallButton.stories.tsx
Normal file
27
apps/web/src/features/chat/components/CallButton.stories.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
29
apps/web/src/features/chat/components/CallButton.tsx
Normal file
29
apps/web/src/features/chat/components/CallButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
57
apps/web/src/features/chat/components/IncomingCallModal.tsx
Normal file
57
apps/web/src/features/chat/components/IncomingCallModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
280
apps/web/src/features/chat/hooks/useWebRTC.ts
Normal file
280
apps/web/src/features/chat/hooks/useWebRTC.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export function useChat() {
|
|||
return {
|
||||
messages: [] as Message[],
|
||||
sendMessage: () => {},
|
||||
sendRawMessage: () => {},
|
||||
conversations: [] as Conversation[],
|
||||
currentConversation: null,
|
||||
setCurrentConversation: () => {},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue