feat(chat): add call signaling types
This commit is contained in:
parent
08bc158ae0
commit
b517258ef5
18 changed files with 768 additions and 19 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -1,5 +1,18 @@
|
||||||
# Changelog - Veza
|
# 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
|
## [v0.302] - 2026-02-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -31,7 +44,7 @@
|
||||||
|
|
||||||
### Deferred (v0.303)
|
### 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 { useChatStore } from '../store/chatStore';
|
||||||
import { ChatMessageComponent } from './ChatMessage';
|
import { ChatMessageComponent } from './ChatMessage';
|
||||||
import { useChat } from '../hooks/useChat';
|
import { useChat } from '../hooks/useChat';
|
||||||
|
import { useWebRTC } from '../hooks/useWebRTC';
|
||||||
import { MessageSearch } from './MessageSearch';
|
import { MessageSearch } from './MessageSearch';
|
||||||
import { TypingIndicator } from './TypingIndicator';
|
import { TypingIndicator } from './TypingIndicator';
|
||||||
|
import { CallButton } from './CallButton';
|
||||||
|
import { IncomingCallModal } from './IncomingCallModal';
|
||||||
|
import { ActiveCallBar } from './ActiveCallBar';
|
||||||
import {
|
import {
|
||||||
Search, X,
|
Search, X,
|
||||||
MessageSquare
|
MessageSquare
|
||||||
|
|
@ -17,9 +21,11 @@ interface ChatRoomProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
|
export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
|
||||||
const { messages } = useChatStore();
|
const { messages, conversations, userId, incomingCall, activeCall } =
|
||||||
const { fetchHistory } = useChat();
|
useChatStore();
|
||||||
const { data: _user } = useUser();
|
const { fetchHistory, sendRawMessage, wsStatus } = useChat();
|
||||||
|
const { data: user } = useUser();
|
||||||
|
const webrtc = useWebRTC({ sendMessage: sendRawMessage });
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [highlightedMessageId, setHighlightedMessageId] = useState<
|
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) {
|
if (!conversationId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground space-y-4 animate-empty-state-in">
|
<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 (
|
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 */}
|
{/* Search Header Overlay */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -117,7 +166,17 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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,
|
setUserTyping,
|
||||||
setMessageDelivered,
|
setMessageDelivered,
|
||||||
setMessageRead,
|
setMessageRead,
|
||||||
|
setIncomingCall,
|
||||||
|
setActiveCall,
|
||||||
|
setCallState,
|
||||||
|
clearCall,
|
||||||
|
setPendingCallAnswer,
|
||||||
|
addPendingICECandidate,
|
||||||
} = useChatStore();
|
} = useChatStore();
|
||||||
|
|
||||||
const ws = useRef<WebSocket | null>(null);
|
const ws = useRef<WebSocket | null>(null);
|
||||||
|
|
@ -167,6 +173,60 @@ export const useChat = (): UseChatReturn => {
|
||||||
delivered.delivered_at,
|
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)
|
// Handle other incoming message types (ActionConfirmed, Error, Pong)
|
||||||
};
|
};
|
||||||
|
|
@ -321,6 +381,12 @@ export const useChat = (): UseChatReturn => {
|
||||||
[currentConversationId, userId],
|
[currentConversationId, userId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sendRawMessage = useCallback((msg: OutgoingMessage) => {
|
||||||
|
if (ws.current?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.current.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const markAsDelivered = useCallback(
|
const markAsDelivered = useCallback(
|
||||||
(messageId: string) => {
|
(messageId: string) => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -437,6 +503,7 @@ export const useChat = (): UseChatReturn => {
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
sendRawMessage,
|
||||||
fetchHistory,
|
fetchHistory,
|
||||||
addReaction: addReactionFunc,
|
addReaction: addReactionFunc,
|
||||||
removeReaction: removeReactionFunc,
|
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;
|
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 {
|
export interface ChatState {
|
||||||
userId: string | null; // Current authenticated user ID
|
userId: string | null; // Current authenticated user ID
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
|
@ -37,6 +57,21 @@ export interface ChatState {
|
||||||
wsUrl: string | null;
|
wsUrl: string | null;
|
||||||
wsStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
|
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
|
// Actions
|
||||||
setUserId: (userId: string | null, username: string | null) => void;
|
setUserId: (userId: string | null, username: string | null) => void;
|
||||||
setWsToken: (token: string, wsUrl: string) => void;
|
setWsToken: (token: string, wsUrl: string) => void;
|
||||||
|
|
@ -73,6 +108,21 @@ export interface ChatState {
|
||||||
messageId: string,
|
messageId: string,
|
||||||
readAt?: string,
|
readAt?: string,
|
||||||
) => void;
|
) => 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>()(
|
export const useChatStore = create<ChatState>()(
|
||||||
|
|
@ -87,6 +137,11 @@ export const useChatStore = create<ChatState>()(
|
||||||
wsToken: null,
|
wsToken: null,
|
||||||
wsUrl: null,
|
wsUrl: null,
|
||||||
wsStatus: 'disconnected',
|
wsStatus: 'disconnected',
|
||||||
|
incomingCall: null,
|
||||||
|
activeCall: null,
|
||||||
|
callState: 'idle',
|
||||||
|
pendingCallAnswer: null,
|
||||||
|
pendingICECandidates: [],
|
||||||
|
|
||||||
setUserId: (userId, username) =>
|
setUserId: (userId, username) =>
|
||||||
set((state) => {
|
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',
|
name: 'ChatStore',
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ export interface OutgoingMessage {
|
||||||
| 'Typing'
|
| 'Typing'
|
||||||
| 'AddReaction'
|
| 'AddReaction'
|
||||||
| 'RemoveReaction'
|
| 'RemoveReaction'
|
||||||
|
| 'CallOffer'
|
||||||
|
| 'CallAnswer'
|
||||||
|
| 'ICECandidate'
|
||||||
|
| 'CallHangup'
|
||||||
|
| 'CallReject'
|
||||||
| 'Ping';
|
| 'Ping';
|
||||||
conversation_id?: string;
|
conversation_id?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|
@ -24,6 +29,11 @@ export interface OutgoingMessage {
|
||||||
is_typing?: boolean;
|
is_typing?: boolean;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
attachments?: MessageAttachment[];
|
attachments?: MessageAttachment[];
|
||||||
|
target_user_id?: string;
|
||||||
|
caller_user_id?: string;
|
||||||
|
sdp?: string;
|
||||||
|
candidate?: string;
|
||||||
|
call_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IncomingMessage {
|
export interface IncomingMessage {
|
||||||
|
|
@ -37,7 +47,12 @@ export interface IncomingMessage {
|
||||||
| 'ReactionRemoved'
|
| 'ReactionRemoved'
|
||||||
| 'MessageRead'
|
| 'MessageRead'
|
||||||
| 'MessageDelivered'
|
| 'MessageDelivered'
|
||||||
| 'HistoryChunk';
|
| 'HistoryChunk'
|
||||||
|
| 'CallOffer'
|
||||||
|
| 'CallAnswer'
|
||||||
|
| 'ICECandidate'
|
||||||
|
| 'CallHangup'
|
||||||
|
| 'CallRejected';
|
||||||
conversation_id: string;
|
conversation_id: string;
|
||||||
message_id?: string;
|
message_id?: string;
|
||||||
sender_id?: string;
|
sender_id?: string;
|
||||||
|
|
@ -56,6 +71,12 @@ export interface IncomingMessage {
|
||||||
messages?: HistoryMessage[]; // For HistoryChunk
|
messages?: HistoryMessage[]; // For HistoryChunk
|
||||||
has_more_before?: boolean;
|
has_more_before?: boolean;
|
||||||
has_more_after?: 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 {
|
export interface HistoryMessage {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ export interface UseChatReturn {
|
||||||
content: string,
|
content: string,
|
||||||
attachments?: import('@/features/chat/types').MessageAttachment[],
|
attachments?: import('@/features/chat/types').MessageAttachment[],
|
||||||
) => void;
|
) => void;
|
||||||
|
sendRawMessage: (msg: import('@/features/chat/types').OutgoingMessage) => void;
|
||||||
fetchHistory: (conversationId: string) => Promise<void>;
|
fetchHistory: (conversationId: string) => Promise<void>;
|
||||||
addReaction: (messageId: string, emoji: string) => void;
|
addReaction: (messageId: string, emoji: string) => void;
|
||||||
removeReaction: (messageId: string) => void;
|
removeReaction: (messageId: string) => void;
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export function useChat() {
|
||||||
return {
|
return {
|
||||||
messages: [] as Message[],
|
messages: [] as Message[],
|
||||||
sendMessage: () => {},
|
sendMessage: () => {},
|
||||||
|
sendRawMessage: () => {},
|
||||||
conversations: [] as Conversation[],
|
conversations: [] as Conversation[],
|
||||||
currentConversation: null,
|
currentConversation: null,
|
||||||
setCurrentConversation: () => {},
|
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 |
|
| 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 |
|
| 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 |
|
| 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.
|
Voir [V0_303_RELEASE_SCOPE.md](V0_303_RELEASE_SCOPE.md) pour le détail.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
| Élément | Valeur |
|
| Élément | Valeur |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| **Dernier tag** | v0.302 |
|
| **Dernier tag** | v0.302 |
|
||||||
| **Branche courante** | `main` (v0.302 mergée) |
|
| **Branche courante** | `main` |
|
||||||
| **Phase** | Phase 3 Social — Lots S2, N1, P2 livrés (C2 reporté v0.303) |
|
| **Phase** | Phase 3 Social — Lots S2, N1, P2, C2 livrés |
|
||||||
| **Prochaine version** | v0.303 |
|
| **Prochaine version** | v0.304 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -48,20 +48,21 @@
|
||||||
- Lot S2 : Groupes avancés (request join, invite, rôles, feed groupes, mes groupes)
|
- 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 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 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
|
## 3. Prochaines étapes
|
||||||
|
|
||||||
### Immédiat (post v0.302)
|
### Immédiat (post v0.303)
|
||||||
1. Tag : v0.302
|
1. Tag : v0.303
|
||||||
2. Merge dans main
|
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)
|
### Prochaine version (v0.304)
|
||||||
- **Lot C2** : Chat appels WebRTC 1-to-1 (signalisation CallOffer/Answer/ICE, CallButton, IncomingCallModal, ActiveCallBar)
|
- À définir (appels groupe, E2E, FCM, forum groupes, etc.)
|
||||||
- Voir [V0_303_RELEASE_SCOPE.md](V0_303_RELEASE_SCOPE.md) et [PLAN_V0_303_IMPLEMENTATION.md](PLAN_V0_303_IMPLEMENTATION.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1145,9 +1145,12 @@ async fn handle_incoming_message(
|
||||||
caller_user_id,
|
caller_user_id,
|
||||||
sdp,
|
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 {
|
let msg = OutgoingMessage::CallAnswer {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
target_user_id: caller_user_id,
|
target_user_id: caller_user_id,
|
||||||
|
from_user_id: callee_uuid,
|
||||||
sdp: sdp.clone(),
|
sdp: sdp.clone(),
|
||||||
};
|
};
|
||||||
state
|
state
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,7 @@ pub enum OutgoingMessage {
|
||||||
CallAnswer {
|
CallAnswer {
|
||||||
conversation_id: Uuid,
|
conversation_id: Uuid,
|
||||||
target_user_id: Uuid,
|
target_user_id: Uuid,
|
||||||
|
from_user_id: Uuid,
|
||||||
sdp: String,
|
sdp: String,
|
||||||
},
|
},
|
||||||
/// Appel WebRTC — candidat ICE
|
/// Appel WebRTC — candidat ICE
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue