@enfin/chat 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -0
- package/dist/assets/music/i_phone_message.mp3 +0 -0
- package/dist/call/WebRTCManager.d.ts +54 -0
- package/dist/call/WebRTCManager.js +274 -0
- package/dist/call/signaling.d.ts +3 -0
- package/dist/call/signaling.js +24 -0
- package/dist/call/types.d.ts +25 -0
- package/dist/call/types.js +13 -0
- package/dist/components/Chat.d.ts +14 -0
- package/dist/components/Chat.js +452 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.js +2 -0
- package/dist/context/ChatContext.d.ts +59 -0
- package/dist/context/ChatContext.js +647 -0
- package/dist/esm/call/WebRTCManager.d.ts +54 -0
- package/dist/esm/call/WebRTCManager.js +274 -0
- package/dist/esm/call/signaling.d.ts +3 -0
- package/dist/esm/call/signaling.js +24 -0
- package/dist/esm/call/types.d.ts +25 -0
- package/dist/esm/call/types.js +13 -0
- package/dist/esm/components/Chat.d.ts +14 -0
- package/dist/esm/components/Chat.js +452 -0
- package/dist/esm/components/index.d.ts +3 -0
- package/dist/esm/components/index.js +2 -0
- package/dist/esm/context/ChatContext.d.ts +59 -0
- package/dist/esm/context/ChatContext.js +647 -0
- package/dist/esm/hooks/index.d.ts +6 -0
- package/dist/esm/hooks/index.js +6 -0
- package/dist/esm/hooks/useCall.d.ts +15 -0
- package/dist/esm/hooks/useCall.js +38 -0
- package/dist/esm/hooks/useChat.d.ts +23 -0
- package/dist/esm/hooks/useChat.js +40 -0
- package/dist/esm/hooks/useMessages.d.ts +17 -0
- package/dist/esm/hooks/useMessages.js +38 -0
- package/dist/esm/hooks/usePresence.d.ts +4 -0
- package/dist/esm/hooks/usePresence.js +12 -0
- package/dist/esm/hooks/useRooms.d.ts +9 -0
- package/dist/esm/hooks/useRooms.js +19 -0
- package/dist/esm/hooks/useSocket.d.ts +7 -0
- package/dist/esm/hooks/useSocket.js +92 -0
- package/dist/esm/index.d.ts +11 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/utils/index.d.ts +2 -0
- package/dist/esm/utils/index.js +2 -0
- package/dist/esm/utils/ringtone.d.ts +2 -0
- package/dist/esm/utils/ringtone.js +39 -0
- package/dist/esm/utils/testUtils.d.ts +102 -0
- package/dist/esm/utils/testUtils.js +153 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +6 -0
- package/dist/hooks/useCall.d.ts +15 -0
- package/dist/hooks/useCall.js +38 -0
- package/dist/hooks/useChat.d.ts +23 -0
- package/dist/hooks/useChat.js +40 -0
- package/dist/hooks/useMessages.d.ts +17 -0
- package/dist/hooks/useMessages.js +38 -0
- package/dist/hooks/usePresence.d.ts +4 -0
- package/dist/hooks/usePresence.js +12 -0
- package/dist/hooks/useRooms.d.ts +9 -0
- package/dist/hooks/useRooms.js +19 -0
- package/dist/hooks/useSocket.d.ts +7 -0
- package/dist/hooks/useSocket.js +92 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +15 -0
- package/dist/public/music/i_phone_message.mp3 +0 -0
- package/dist/style.css +1226 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/ringtone.d.ts +2 -0
- package/dist/utils/ringtone.js +39 -0
- package/dist/utils/testUtils.d.ts +102 -0
- package/dist/utils/testUtils.js +153 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# @enfin/chat
|
|
2
|
+
|
|
3
|
+
React frontend SDK for chat platform — components, hooks, and context provider.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @enfin/chat react react-dom socket.io-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### 1. Wrap Your App with ChatProvider
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { ChatProvider } from '@enfin/chat';
|
|
17
|
+
import Chat from '@enfin/chat';
|
|
18
|
+
|
|
19
|
+
function App() {
|
|
20
|
+
return (
|
|
21
|
+
<ChatProvider
|
|
22
|
+
config={{ apiKey: 'chat_xxx' }}
|
|
23
|
+
userId="user_123"
|
|
24
|
+
userName="Alice"
|
|
25
|
+
serverUrl="http://localhost:3002"
|
|
26
|
+
>
|
|
27
|
+
<Chat />
|
|
28
|
+
</ChatProvider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Pass `serverUrl` as your chat-server URL. It connects to `${serverUrl}/chat` namespace and calls REST at `${serverUrl}/api/*`.
|
|
34
|
+
|
|
35
|
+
## ChatProvider Props
|
|
36
|
+
|
|
37
|
+
| Prop | Type | Required | Description |
|
|
38
|
+
|------|------|----------|-------------|
|
|
39
|
+
| `config` | `ChatConfig` | Yes | `{ apiKey: string }` |
|
|
40
|
+
| `userId` | string | Yes | Unique user ID |
|
|
41
|
+
| `userName` | string | Yes | Display name |
|
|
42
|
+
| `serverUrl` | string | Yes | Your chat-server URL |
|
|
43
|
+
|
|
44
|
+
### ChatConfig
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
interface ChatConfig {
|
|
48
|
+
apiKey: string;
|
|
49
|
+
validationUrl?: string; // optional custom validation endpoint
|
|
50
|
+
ringtoneUrl?: string; // optional override for the incoming-call ringtone
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
#### `ringtoneUrl` — custom incoming-call ringtone
|
|
55
|
+
|
|
56
|
+
By default the SDK plays a bundled `i_phone_message.mp3` whenever an audio call comes in. Pass `ringtoneUrl` to override it with your own file. Works with absolute URLs or any path the consumer's bundler/dev server can resolve.
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
<ChatProvider
|
|
60
|
+
config={{
|
|
61
|
+
apiKey: 'chat_xxx',
|
|
62
|
+
ringtoneUrl: '/sounds/my-ring.mp3', // served by your app
|
|
63
|
+
}}
|
|
64
|
+
userId="user_123"
|
|
65
|
+
userName="Alice"
|
|
66
|
+
serverUrl="http://localhost:3002"
|
|
67
|
+
>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- Anything that HTML5 `<audio>` accepts works: `/sounds/ring.mp3`, `https://cdn.example.com/ring.ogg`, or a Vite/Webpack `import` resolved URL.
|
|
71
|
+
- If the override URL fails to load, the browser console logs `[RINGTONE] play() rejected: ...` and the call UI keeps working.
|
|
72
|
+
- The default mp3 is bundled at `dist/public/music/i_phone_message.mp3` and shipped with the package — no extra setup needed.
|
|
73
|
+
|
|
74
|
+
## Default Chat Component
|
|
75
|
+
|
|
76
|
+
The `<Chat />` component provides full UI:
|
|
77
|
+
|
|
78
|
+
- User registration/login
|
|
79
|
+
- Room list (direct and group)
|
|
80
|
+
- Real-time messaging
|
|
81
|
+
- File uploads with drag-and-drop
|
|
82
|
+
- Typing indicators
|
|
83
|
+
- Online presence (green dot)
|
|
84
|
+
- Audio calls (WebRTC)
|
|
85
|
+
|
|
86
|
+
### Customize Appearance
|
|
87
|
+
|
|
88
|
+
Styles are in `styles.css`. Override CSS variables:
|
|
89
|
+
|
|
90
|
+
```css
|
|
91
|
+
:root {
|
|
92
|
+
--chat-primary: #007bff;
|
|
93
|
+
--chat-bg: #ffffff;
|
|
94
|
+
--chat-text: #333333;
|
|
95
|
+
--chat-border: #e0e0e0;
|
|
96
|
+
--chat-radius: 8px;
|
|
97
|
+
--chat-font: system-ui, sans-serif;
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Build Custom UI
|
|
102
|
+
|
|
103
|
+
Use hooks instead of `<Chat />`:
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
import { useChatContext, useRooms, useMessages, useCall, useSocket } from '@enfin/chat';
|
|
107
|
+
import { ChatProvider } from '@enfin/chat';
|
|
108
|
+
|
|
109
|
+
function CustomChat() {
|
|
110
|
+
const { userId, messages, rooms, currentRoom, sendMessage, selectRoom } = useChatContext();
|
|
111
|
+
const { activeCall, startCall, endCall, acceptCall } = useCall();
|
|
112
|
+
|
|
113
|
+
// Send message
|
|
114
|
+
await sendMessage(currentRoom._id, 'Hello!');
|
|
115
|
+
|
|
116
|
+
// Start call
|
|
117
|
+
await startCall(currentRoom._id, 'participant_456');
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### useChatContext
|
|
122
|
+
|
|
123
|
+
Returns everything:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
interface ChatContextValue {
|
|
127
|
+
userId: string;
|
|
128
|
+
userName: string;
|
|
129
|
+
messages: Message[];
|
|
130
|
+
rooms: Room[];
|
|
131
|
+
users: ChatUser[];
|
|
132
|
+
currentRoom?: Room;
|
|
133
|
+
activeCall?: AudioCall;
|
|
134
|
+
typingUsers: PresenceStatus[];
|
|
135
|
+
connected: boolean;
|
|
136
|
+
loading: boolean;
|
|
137
|
+
error?: string;
|
|
138
|
+
sendMessage: (roomId, content, file?) => Promise<void>;
|
|
139
|
+
selectRoom: (roomId) => void;
|
|
140
|
+
createRoom: (name, type, members) => Promise<Room>;
|
|
141
|
+
startCall: (roomId, participantId) => Promise<void>;
|
|
142
|
+
endCall: () => Promise<void>;
|
|
143
|
+
acceptCall: () => Promise<void>;
|
|
144
|
+
mute: () => void;
|
|
145
|
+
unmute: () => void;
|
|
146
|
+
isMuted: boolean;
|
|
147
|
+
socket?: Socket;
|
|
148
|
+
refreshUsers: () => Promise<void>;
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### useRooms
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
const { rooms, loading, error, createRoom, openDirectRoom } = useRooms();
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### useMessages
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const { messages, loading, sendMessage, markRead } = useMessages(roomId);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### useCall
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const { activeCall, startCall, endCall, acceptCall, mute, unmute, isMuted } = useCall();
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### useSocket
|
|
171
|
+
|
|
172
|
+
Raw Socket.IO access:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const socket = useSocket();
|
|
176
|
+
// socket.emit('message', { roomId, content });
|
|
177
|
+
// socket.on('message', (msg) => { ... });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Types
|
|
181
|
+
|
|
182
|
+
All types are exported:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { Message, Room, User, ChatConfig } from '@enfin/chat';
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Environment Variables
|
|
189
|
+
|
|
190
|
+
### Vite
|
|
191
|
+
|
|
192
|
+
```env
|
|
193
|
+
VITE_CHAT_API_KEY=chat_xxx
|
|
194
|
+
VITE_SERVER_URL=http://localhost:3002
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### CRA
|
|
198
|
+
|
|
199
|
+
```env
|
|
200
|
+
REACT_APP_CHAT_API_KEY=chat_xxx
|
|
201
|
+
REACT_APP_SERVER_URL=http://localhost:3002
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Next.js
|
|
205
|
+
|
|
206
|
+
In `.env.local`:
|
|
207
|
+
|
|
208
|
+
```env
|
|
209
|
+
NEXT_PUBLIC_CHAT_API_KEY=chat_xxx
|
|
210
|
+
NEXT_PUBLIC_SERVER_URL=http://localhost:3002
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Then access via `process.env.NEXT_PUBLIC_*`.
|
|
214
|
+
|
|
215
|
+
## Server Requirement
|
|
216
|
+
|
|
217
|
+
Your backend must run `@enfin/chat-server`. The frontend SDK communicates with it via:
|
|
218
|
+
|
|
219
|
+
- Socket.IO: `${serverUrl}/chat`
|
|
220
|
+
- REST: `${serverUrl}/api/*`
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
For backend server, see `@enfin/chat-server`.
|
|
Binary file
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { WebRTCConfig, SignalPayload } from './types';
|
|
2
|
+
export type WebRTCManagerState = 'idle' | 'requesting-media' | 'connecting' | 'connected' | 'failed' | 'closed';
|
|
3
|
+
export interface WebRTCManagerCallbacks {
|
|
4
|
+
sendSignal: (payload: SignalPayload) => void;
|
|
5
|
+
onStateChange?: (state: WebRTCManagerState) => void;
|
|
6
|
+
onRemoteStream?: (stream: MediaStream) => void;
|
|
7
|
+
onLocalStream?: (stream: MediaStream) => void;
|
|
8
|
+
onError?: (err: Error) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare class WebRTCManager {
|
|
11
|
+
private pc;
|
|
12
|
+
private localStream;
|
|
13
|
+
private remoteStream;
|
|
14
|
+
private state;
|
|
15
|
+
private config;
|
|
16
|
+
private callbacks;
|
|
17
|
+
private isInitiator;
|
|
18
|
+
private currentRoomId;
|
|
19
|
+
private hasReceivedRemote;
|
|
20
|
+
private pendingOffer;
|
|
21
|
+
private bufferedOffer;
|
|
22
|
+
private pendingIceCandidates;
|
|
23
|
+
private isCallee;
|
|
24
|
+
private isAccepted;
|
|
25
|
+
constructor(config: WebRTCConfig, callbacks: WebRTCManagerCallbacks);
|
|
26
|
+
setSignalEmitter(sendSignal: (payload: import('./types').SignalPayload) => void): void;
|
|
27
|
+
getState(): WebRTCManagerState;
|
|
28
|
+
getRemoteStream(): MediaStream | null;
|
|
29
|
+
getLocalStream(): MediaStream | null;
|
|
30
|
+
private setState;
|
|
31
|
+
private buildPeerConnection;
|
|
32
|
+
startAsCaller(roomId: string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Sends the buffered offer + flushes any pending ICE candidates.
|
|
35
|
+
* Call this AFTER the server has confirmed the call record exists,
|
|
36
|
+
* so the callee is ready to receive `call:signal` events.
|
|
37
|
+
*/
|
|
38
|
+
sendOffer(): Promise<void>;
|
|
39
|
+
handleOffer(offerSdp: string, roomId: string): Promise<void>;
|
|
40
|
+
private flushPendingIce;
|
|
41
|
+
handleAnswer(answerSdp: string): Promise<void>;
|
|
42
|
+
handleIceCandidate(candidate: string, sdpMid: string | null, sdpMLineIndex: number): Promise<void>;
|
|
43
|
+
acquireLocalStream(): Promise<MediaStream>;
|
|
44
|
+
setMuted(muted: boolean): void;
|
|
45
|
+
processPendingOffer(): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Mark this manager as a callee (it received the offer via call:incoming,
|
|
48
|
+
* not via startCall). While not yet accepted, incoming offers are buffered
|
|
49
|
+
* so we don't request mic / open PC before the user clicks Accept.
|
|
50
|
+
*/
|
|
51
|
+
markAsCallee(): void;
|
|
52
|
+
markAsAccepted(): void;
|
|
53
|
+
close(): void;
|
|
54
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { DEFAULT_WEBRTC_CONFIG } from './types';
|
|
2
|
+
export class WebRTCManager {
|
|
3
|
+
constructor(config, callbacks) {
|
|
4
|
+
this.pc = null;
|
|
5
|
+
this.localStream = null;
|
|
6
|
+
this.remoteStream = null;
|
|
7
|
+
this.state = 'idle';
|
|
8
|
+
this.isInitiator = false;
|
|
9
|
+
this.currentRoomId = null;
|
|
10
|
+
this.hasReceivedRemote = false;
|
|
11
|
+
this.pendingOffer = null;
|
|
12
|
+
this.bufferedOffer = null;
|
|
13
|
+
this.pendingIceCandidates = [];
|
|
14
|
+
this.isCallee = false;
|
|
15
|
+
this.isAccepted = false;
|
|
16
|
+
this.config = { ...DEFAULT_WEBRTC_CONFIG, ...config };
|
|
17
|
+
this.callbacks = callbacks;
|
|
18
|
+
}
|
|
19
|
+
setSignalEmitter(sendSignal) {
|
|
20
|
+
this.callbacks = { ...this.callbacks, sendSignal };
|
|
21
|
+
}
|
|
22
|
+
getState() {
|
|
23
|
+
return this.state;
|
|
24
|
+
}
|
|
25
|
+
getRemoteStream() {
|
|
26
|
+
return this.remoteStream;
|
|
27
|
+
}
|
|
28
|
+
getLocalStream() {
|
|
29
|
+
return this.localStream;
|
|
30
|
+
}
|
|
31
|
+
setState(next) {
|
|
32
|
+
this.state = next;
|
|
33
|
+
this.callbacks.onStateChange?.(next);
|
|
34
|
+
}
|
|
35
|
+
buildPeerConnection() {
|
|
36
|
+
const pc = new RTCPeerConnection({
|
|
37
|
+
iceServers: this.config.iceServers,
|
|
38
|
+
iceTransportPolicy: this.config.iceTransportPolicy
|
|
39
|
+
});
|
|
40
|
+
pc.onicecandidate = (event) => {
|
|
41
|
+
if (event.candidate && this.currentRoomId) {
|
|
42
|
+
this.callbacks.sendSignal({
|
|
43
|
+
type: 'ice-candidate',
|
|
44
|
+
candidate: event.candidate.candidate,
|
|
45
|
+
sdpMid: event.candidate.sdpMid,
|
|
46
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
47
|
+
roomId: this.currentRoomId
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
pc.ontrack = (event) => {
|
|
52
|
+
// Chrome fires ontrack for locally added tracks too (the transceiver
|
|
53
|
+
// emits a track event for the sender's stream). Filter those out so
|
|
54
|
+
// the audio element only ever plays the REMOTE peer's stream.
|
|
55
|
+
if (event.transceiver && event.transceiver.direction === 'sendonly') {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Also reject the event if every stream is actually our own local stream.
|
|
59
|
+
const remoteStream = event.streams.find(s => s !== this.localStream) || event.streams[0];
|
|
60
|
+
if (remoteStream && remoteStream !== this.localStream) {
|
|
61
|
+
this.remoteStream = remoteStream;
|
|
62
|
+
this.hasReceivedRemote = true;
|
|
63
|
+
this.callbacks.onRemoteStream?.(remoteStream);
|
|
64
|
+
this.setState('connected');
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
pc.onconnectionstatechange = () => {
|
|
68
|
+
const s = pc.connectionState;
|
|
69
|
+
if (s === 'connected')
|
|
70
|
+
this.setState('connected');
|
|
71
|
+
else if (s === 'failed')
|
|
72
|
+
this.setState('failed');
|
|
73
|
+
else if (s === 'closed')
|
|
74
|
+
this.setState('closed');
|
|
75
|
+
else if (s === 'connecting')
|
|
76
|
+
this.setState('connecting');
|
|
77
|
+
};
|
|
78
|
+
pc.oniceconnectionstatechange = () => {
|
|
79
|
+
const s = pc.iceConnectionState;
|
|
80
|
+
if (s === 'connected' || s === 'completed')
|
|
81
|
+
this.setState('connected');
|
|
82
|
+
else if (s === 'failed')
|
|
83
|
+
this.setState('failed');
|
|
84
|
+
};
|
|
85
|
+
return pc;
|
|
86
|
+
}
|
|
87
|
+
async startAsCaller(roomId) {
|
|
88
|
+
if (this.state !== 'idle' && this.state !== 'closed') {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.isInitiator = true;
|
|
92
|
+
this.currentRoomId = roomId;
|
|
93
|
+
this.hasReceivedRemote = false;
|
|
94
|
+
this.setState('requesting-media');
|
|
95
|
+
try {
|
|
96
|
+
await this.acquireLocalStream();
|
|
97
|
+
const pc = this.buildPeerConnection();
|
|
98
|
+
this.localStream.getTracks().forEach(track => {
|
|
99
|
+
pc.addTrack(track, this.localStream);
|
|
100
|
+
});
|
|
101
|
+
this.pc = pc;
|
|
102
|
+
const offer = await pc.createOffer();
|
|
103
|
+
await pc.setLocalDescription(offer);
|
|
104
|
+
// Buffer the offer — don't send yet. The caller (startCall) will
|
|
105
|
+
// invoke sendOffer() after the server confirms the call exists.
|
|
106
|
+
this.bufferedOffer = { sdp: offer.sdp, roomId };
|
|
107
|
+
this.setState('connecting');
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
this.setState('failed');
|
|
111
|
+
this.callbacks.onError?.(err);
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Sends the buffered offer + flushes any pending ICE candidates.
|
|
117
|
+
* Call this AFTER the server has confirmed the call record exists,
|
|
118
|
+
* so the callee is ready to receive `call:signal` events.
|
|
119
|
+
*/
|
|
120
|
+
async sendOffer() {
|
|
121
|
+
if (!this.bufferedOffer || !this.pc)
|
|
122
|
+
return;
|
|
123
|
+
const { sdp, roomId } = this.bufferedOffer;
|
|
124
|
+
this.callbacks.sendSignal({
|
|
125
|
+
type: 'offer',
|
|
126
|
+
sdp,
|
|
127
|
+
roomId
|
|
128
|
+
});
|
|
129
|
+
this.bufferedOffer = null;
|
|
130
|
+
// Flush any ICE candidates that arrived before the offer was sent
|
|
131
|
+
await this.flushPendingIce();
|
|
132
|
+
}
|
|
133
|
+
async handleOffer(offerSdp, roomId) {
|
|
134
|
+
// Callee: always buffer the offer until the user accepts the call.
|
|
135
|
+
// We don't want to request mic / open PC before the user clicks Accept.
|
|
136
|
+
if (this.isCallee && !this.isAccepted) {
|
|
137
|
+
this.pendingOffer = { sdp: offerSdp, roomId };
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (this.state !== 'idle' && this.state !== 'closed') {
|
|
141
|
+
// Already in a call or call in progress - buffer the offer
|
|
142
|
+
if (this.isInitiator)
|
|
143
|
+
return; // initiator ignores incoming offers
|
|
144
|
+
this.pendingOffer = { sdp: offerSdp, roomId };
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.isInitiator = false;
|
|
148
|
+
this.currentRoomId = roomId;
|
|
149
|
+
this.hasReceivedRemote = false;
|
|
150
|
+
this.setState('requesting-media');
|
|
151
|
+
try {
|
|
152
|
+
await this.acquireLocalStream();
|
|
153
|
+
const pc = this.buildPeerConnection();
|
|
154
|
+
this.localStream.getTracks().forEach(track => {
|
|
155
|
+
pc.addTrack(track, this.localStream);
|
|
156
|
+
});
|
|
157
|
+
this.pc = pc;
|
|
158
|
+
await pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: offerSdp }));
|
|
159
|
+
const answer = await pc.createAnswer();
|
|
160
|
+
await pc.setLocalDescription(answer);
|
|
161
|
+
this.callbacks.sendSignal({
|
|
162
|
+
type: 'answer',
|
|
163
|
+
sdp: answer.sdp,
|
|
164
|
+
roomId
|
|
165
|
+
});
|
|
166
|
+
this.setState('connecting');
|
|
167
|
+
// Flush buffered ICE candidates
|
|
168
|
+
await this.flushPendingIce();
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
this.setState('failed');
|
|
172
|
+
this.callbacks.onError?.(err);
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async flushPendingIce() {
|
|
177
|
+
if (!this.pc)
|
|
178
|
+
return;
|
|
179
|
+
for (const c of this.pendingIceCandidates) {
|
|
180
|
+
try {
|
|
181
|
+
await this.pc.addIceCandidate(new RTCIceCandidate(c));
|
|
182
|
+
}
|
|
183
|
+
catch { /* benign */ }
|
|
184
|
+
}
|
|
185
|
+
this.pendingIceCandidates = [];
|
|
186
|
+
}
|
|
187
|
+
async handleAnswer(answerSdp) {
|
|
188
|
+
if (!this.pc)
|
|
189
|
+
return;
|
|
190
|
+
if (this.pc.signalingState !== 'have-local-offer') {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await this.pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: answerSdp }));
|
|
194
|
+
}
|
|
195
|
+
async handleIceCandidate(candidate, sdpMid, sdpMLineIndex) {
|
|
196
|
+
if (!this.pc) {
|
|
197
|
+
// Buffer until PC exists
|
|
198
|
+
this.pendingIceCandidates.push({ candidate, sdpMid, sdpMLineIndex });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await this.pc.addIceCandidate(new RTCIceCandidate({
|
|
203
|
+
candidate,
|
|
204
|
+
sdpMid,
|
|
205
|
+
sdpMLineIndex
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
// Common benign failure: candidate arrives after PC closed
|
|
210
|
+
if (this.pc.connectionState !== 'closed') {
|
|
211
|
+
this.callbacks.onError?.(err);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async acquireLocalStream() {
|
|
216
|
+
if (this.localStream)
|
|
217
|
+
return this.localStream;
|
|
218
|
+
this.localStream = await navigator.mediaDevices.getUserMedia({
|
|
219
|
+
audio: this.config.audioConstraints
|
|
220
|
+
});
|
|
221
|
+
this.callbacks.onLocalStream?.(this.localStream);
|
|
222
|
+
return this.localStream;
|
|
223
|
+
}
|
|
224
|
+
setMuted(muted) {
|
|
225
|
+
if (!this.localStream)
|
|
226
|
+
return;
|
|
227
|
+
this.localStream.getAudioTracks().forEach(track => {
|
|
228
|
+
track.enabled = !muted;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async processPendingOffer() {
|
|
232
|
+
if (!this.pendingOffer)
|
|
233
|
+
return;
|
|
234
|
+
const { sdp, roomId } = this.pendingOffer;
|
|
235
|
+
this.pendingOffer = null;
|
|
236
|
+
this.isAccepted = true;
|
|
237
|
+
await this.handleOffer(sdp, roomId);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Mark this manager as a callee (it received the offer via call:incoming,
|
|
241
|
+
* not via startCall). While not yet accepted, incoming offers are buffered
|
|
242
|
+
* so we don't request mic / open PC before the user clicks Accept.
|
|
243
|
+
*/
|
|
244
|
+
markAsCallee() {
|
|
245
|
+
this.isCallee = true;
|
|
246
|
+
this.isAccepted = false;
|
|
247
|
+
}
|
|
248
|
+
markAsAccepted() {
|
|
249
|
+
this.isAccepted = true;
|
|
250
|
+
}
|
|
251
|
+
close() {
|
|
252
|
+
if (this.localStream && this.localStream.getTracks) {
|
|
253
|
+
this.localStream.getTracks().forEach(track => track.stop());
|
|
254
|
+
this.localStream = null;
|
|
255
|
+
}
|
|
256
|
+
if (this.pc) {
|
|
257
|
+
try {
|
|
258
|
+
this.pc.close();
|
|
259
|
+
}
|
|
260
|
+
catch { /* noop */ }
|
|
261
|
+
this.pc = null;
|
|
262
|
+
}
|
|
263
|
+
this.remoteStream = null;
|
|
264
|
+
this.isInitiator = false;
|
|
265
|
+
this.currentRoomId = null;
|
|
266
|
+
this.hasReceivedRemote = false;
|
|
267
|
+
this.pendingOffer = null;
|
|
268
|
+
this.bufferedOffer = null;
|
|
269
|
+
this.pendingIceCandidates = [];
|
|
270
|
+
this.isCallee = false;
|
|
271
|
+
this.isAccepted = false;
|
|
272
|
+
this.setState('closed');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function attachSignaling(socket, manager) {
|
|
2
|
+
const onSignal = (payload) => {
|
|
3
|
+
if (!payload || !payload.type || !payload.roomId)
|
|
4
|
+
return;
|
|
5
|
+
switch (payload.type) {
|
|
6
|
+
case 'offer':
|
|
7
|
+
manager.handleOffer(payload.sdp, payload.roomId).catch(() => { });
|
|
8
|
+
break;
|
|
9
|
+
case 'answer':
|
|
10
|
+
manager.handleAnswer(payload.sdp).catch(() => { });
|
|
11
|
+
break;
|
|
12
|
+
case 'ice-candidate':
|
|
13
|
+
manager.handleIceCandidate(payload.candidate, payload.sdpMid, payload.sdpMLineIndex).catch(() => { });
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
manager.setSignalEmitter((payload) => {
|
|
18
|
+
socket.emit('call:signal', payload);
|
|
19
|
+
});
|
|
20
|
+
socket.on('call:signal', onSignal);
|
|
21
|
+
return () => {
|
|
22
|
+
socket.off('call:signal', onSignal);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type SignalType = 'offer' | 'answer' | 'ice-candidate';
|
|
2
|
+
export interface SignalOffer {
|
|
3
|
+
type: 'offer';
|
|
4
|
+
sdp: string;
|
|
5
|
+
roomId: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SignalAnswer {
|
|
8
|
+
type: 'answer';
|
|
9
|
+
sdp: string;
|
|
10
|
+
roomId: string;
|
|
11
|
+
}
|
|
12
|
+
export interface SignalIceCandidate {
|
|
13
|
+
type: 'ice-candidate';
|
|
14
|
+
candidate: string;
|
|
15
|
+
sdpMid: string | null;
|
|
16
|
+
sdpMLineIndex: number;
|
|
17
|
+
roomId: string;
|
|
18
|
+
}
|
|
19
|
+
export type SignalPayload = SignalOffer | SignalAnswer | SignalIceCandidate;
|
|
20
|
+
export interface WebRTCConfig {
|
|
21
|
+
iceServers?: RTCIceServer[];
|
|
22
|
+
iceTransportPolicy?: RTCIceTransportPolicy;
|
|
23
|
+
audioConstraints?: MediaTrackConstraints;
|
|
24
|
+
}
|
|
25
|
+
export declare const DEFAULT_WEBRTC_CONFIG: Required<WebRTCConfig>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// WebRTC Signaling Types for audio calls
|
|
2
|
+
export const DEFAULT_WEBRTC_CONFIG = {
|
|
3
|
+
iceServers: [
|
|
4
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
5
|
+
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
6
|
+
],
|
|
7
|
+
iceTransportPolicy: 'all',
|
|
8
|
+
audioConstraints: {
|
|
9
|
+
echoCancellation: true,
|
|
10
|
+
noiseSuppression: true,
|
|
11
|
+
autoGainControl: true
|
|
12
|
+
}
|
|
13
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface ChatProps {
|
|
3
|
+
theme?: 'light' | 'dark';
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Default Chat Component
|
|
7
|
+
*
|
|
8
|
+
* Two-pane layout:
|
|
9
|
+
* - Top: app header with current user (avatar, name, switch button)
|
|
10
|
+
* - Left: list of registered users (click to select a peer)
|
|
11
|
+
* - Right: messages + input area for the selected peer
|
|
12
|
+
*/
|
|
13
|
+
export declare function Chat({ theme }: ChatProps): React.JSX.Element;
|
|
14
|
+
export default Chat;
|