@ermis-network/ermis-chat-react 1.0.1 → 1.0.3
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/dist/index.cjs +2501 -1249
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1231 -134
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +306 -2
- package/dist/index.d.ts +306 -2
- package/dist/index.mjs +2427 -1181
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/ChannelHeader.tsx +50 -9
- package/src/components/ChannelInfo/AddMemberModal.tsx +48 -174
- package/src/components/ChannelList.tsx +9 -3
- package/src/components/CreateChannelModal.tsx +274 -0
- package/src/components/ErmisCallProvider.tsx +279 -0
- package/src/components/ErmisCallUI.tsx +634 -0
- package/src/components/MessageRenderers.tsx +37 -10
- package/src/components/Modal.tsx +2 -1
- package/src/components/UserPicker.tsx +377 -0
- package/src/context/ChatProvider.tsx +49 -1
- package/src/context/ErmisCallContext.tsx +37 -0
- package/src/hooks/useCallContext.ts +10 -0
- package/src/index.ts +27 -0
- package/src/styles/_add-member-modal.css +12 -29
- package/src/styles/_call-ui.css +743 -0
- package/src/styles/_channel-info.css +34 -34
- package/src/styles/_channel-list.css +7 -7
- package/src/styles/_create-channel-modal.css +183 -0
- package/src/styles/_message-bubble.css +108 -16
- package/src/styles/_message-input.css +4 -4
- package/src/styles/_message-list.css +11 -11
- package/src/styles/_modal.css +23 -36
- package/src/styles/_panel.css +1 -1
- package/src/styles/_search-panel.css +9 -9
- package/src/styles/_tokens.css +42 -0
- package/src/styles/_typing-indicator.css +15 -2
- package/src/styles/_user-picker.css +268 -0
- package/src/styles/index.css +3 -0
- package/src/types.ts +293 -1
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
|
+
import { useCallContext } from '../hooks/useCallContext';
|
|
3
|
+
import { Modal } from './Modal';
|
|
4
|
+
import { Avatar } from './Avatar';
|
|
5
|
+
import { CallStatus } from '@ermis-network/ermis-chat-sdk';
|
|
6
|
+
import type { ErmisCallUIProps } from '../types';
|
|
7
|
+
|
|
8
|
+
/** Format seconds into MM:SS */
|
|
9
|
+
const formatDuration = (totalSeconds: number): string => {
|
|
10
|
+
const m = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
|
|
11
|
+
const s = (totalSeconds % 60).toString().padStart(2, '0');
|
|
12
|
+
return `${m}:${s}`;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
|
|
16
|
+
className,
|
|
17
|
+
incomingCallTitle = (type: string) => `Incoming ${type} call`,
|
|
18
|
+
outgoingCallTitle = (type: string) => `Outgoing ${type} call`,
|
|
19
|
+
ongoingCallTitle = (type: string) => `Ongoing ${type} Call`,
|
|
20
|
+
isCallingYouLabel = 'is calling you...',
|
|
21
|
+
ringingLabel = 'Ringing...',
|
|
22
|
+
rejectCallLabel = 'Reject',
|
|
23
|
+
acceptCallLabel = 'Accept',
|
|
24
|
+
endCallLabel = 'End Call',
|
|
25
|
+
cancelLabel = 'Cancel',
|
|
26
|
+
toggleMicTitle = 'Toggle Mic',
|
|
27
|
+
toggleVideoTitle = 'Toggle Video',
|
|
28
|
+
shareScreenTitle = 'Share Screen',
|
|
29
|
+
stopScreenShareTitle = 'Stop Sharing',
|
|
30
|
+
connectedLabel = 'Connected',
|
|
31
|
+
audioCallBadgeLabel = 'Audio Call',
|
|
32
|
+
videoCallBadgeLabel = 'Video Call',
|
|
33
|
+
fullscreenTitle = 'Fullscreen',
|
|
34
|
+
exitFullscreenTitle = 'Exit Fullscreen',
|
|
35
|
+
upgradeCallTitle = 'Request Video Upgrade',
|
|
36
|
+
suppressIncomingCalls = false,
|
|
37
|
+
onCallDurationChange,
|
|
38
|
+
AvatarComponent = Avatar,
|
|
39
|
+
MicIcon: PropMicIcon,
|
|
40
|
+
MicOffIcon: PropMicOffIcon,
|
|
41
|
+
VideoIcon: PropVideoIcon,
|
|
42
|
+
VideoOffIcon: PropVideoOffIcon,
|
|
43
|
+
PhoneIcon: PropPhoneIcon,
|
|
44
|
+
ScreenShareIcon: PropScreenShareIcon,
|
|
45
|
+
ScreenShareOffIcon: PropScreenShareOffIcon,
|
|
46
|
+
FullscreenIcon: PropFullscreenIcon,
|
|
47
|
+
ExitFullscreenIcon: PropExitFullscreenIcon,
|
|
48
|
+
UpgradeCallIcon: PropUpgradeCallIcon,
|
|
49
|
+
incomingCallAudioPath = '/call_incoming.mp3',
|
|
50
|
+
outgoingCallAudioPath = '/call_outgoing.mp3',
|
|
51
|
+
RingingComponent: CustomRingingComponent,
|
|
52
|
+
ConnectedAudioComponent: CustomConnectedAudioComponent,
|
|
53
|
+
ConnectedVideoComponent: CustomConnectedVideoComponent,
|
|
54
|
+
ErrorComponent: CustomErrorComponent,
|
|
55
|
+
ControlsBarComponent: CustomControlsBarComponent,
|
|
56
|
+
}) => {
|
|
57
|
+
const {
|
|
58
|
+
callStatus,
|
|
59
|
+
callType,
|
|
60
|
+
callerInfo,
|
|
61
|
+
receiverInfo,
|
|
62
|
+
isIncoming,
|
|
63
|
+
localStream,
|
|
64
|
+
remoteStream,
|
|
65
|
+
acceptCall,
|
|
66
|
+
rejectCall,
|
|
67
|
+
endCall,
|
|
68
|
+
toggleMic,
|
|
69
|
+
toggleVideo,
|
|
70
|
+
isMicMuted,
|
|
71
|
+
isVideoMuted,
|
|
72
|
+
audioDevices,
|
|
73
|
+
videoDevices,
|
|
74
|
+
selectedAudioDeviceId,
|
|
75
|
+
selectedVideoDeviceId,
|
|
76
|
+
isScreenSharing,
|
|
77
|
+
errorMessage,
|
|
78
|
+
toggleScreenShare,
|
|
79
|
+
switchAudioDevice,
|
|
80
|
+
switchVideoDevice,
|
|
81
|
+
clearError,
|
|
82
|
+
isRemoteMicMuted,
|
|
83
|
+
upgradeCall,
|
|
84
|
+
callDuration,
|
|
85
|
+
} = useCallContext();
|
|
86
|
+
|
|
87
|
+
const localVideoRef = useRef<HTMLVideoElement>(null);
|
|
88
|
+
const remoteVideoRef = useRef<HTMLVideoElement>(null);
|
|
89
|
+
const remoteAudioRef = useRef<HTMLAudioElement>(null);
|
|
90
|
+
const ringingAudioRef = useRef<HTMLAudioElement>(null);
|
|
91
|
+
const callContainerRef = useRef<HTMLDivElement>(null);
|
|
92
|
+
|
|
93
|
+
// Fullscreen state
|
|
94
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
95
|
+
|
|
96
|
+
const toggleFullscreen = useCallback(() => {
|
|
97
|
+
if (!callContainerRef.current) return;
|
|
98
|
+
if (!document.fullscreenElement) {
|
|
99
|
+
callContainerRef.current.requestFullscreen?.().catch(() => {});
|
|
100
|
+
} else {
|
|
101
|
+
document.exitFullscreen?.().catch(() => {});
|
|
102
|
+
}
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const handleChange = () => setIsFullscreen(!!document.fullscreenElement);
|
|
107
|
+
document.addEventListener('fullscreenchange', handleChange);
|
|
108
|
+
return () => document.removeEventListener('fullscreenchange', handleChange);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
// C5: Notify consumer of duration changes
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (callDuration > 0) {
|
|
114
|
+
onCallDurationChange?.(callDuration);
|
|
115
|
+
}
|
|
116
|
+
}, [callDuration, onCallDurationChange]);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (localVideoRef.current && localStream) {
|
|
120
|
+
localVideoRef.current.srcObject = localStream;
|
|
121
|
+
}
|
|
122
|
+
}, [localStream, callType, callStatus]);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (remoteStream) {
|
|
126
|
+
if (callType === 'video' && remoteVideoRef.current) {
|
|
127
|
+
remoteVideoRef.current.srcObject = remoteStream;
|
|
128
|
+
}
|
|
129
|
+
if (remoteAudioRef.current) {
|
|
130
|
+
remoteAudioRef.current.srcObject = remoteStream;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}, [remoteStream, callType, callStatus]);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (callStatus === CallStatus.RINGING && ringingAudioRef.current) {
|
|
137
|
+
const playPromise = ringingAudioRef.current.play();
|
|
138
|
+
if (playPromise !== undefined) {
|
|
139
|
+
playPromise.catch((e) => console.log('ErmisChat: Audio play blocked by browser:', e));
|
|
140
|
+
}
|
|
141
|
+
} else if (ringingAudioRef.current) {
|
|
142
|
+
ringingAudioRef.current.pause();
|
|
143
|
+
ringingAudioRef.current.currentTime = 0;
|
|
144
|
+
}
|
|
145
|
+
}, [callStatus]);
|
|
146
|
+
|
|
147
|
+
if (!callStatus && !errorMessage) return null;
|
|
148
|
+
|
|
149
|
+
// C3: Suppress incoming call UI (DND mode)
|
|
150
|
+
if (suppressIncomingCalls && isIncoming && callStatus === CallStatus.RINGING) return null;
|
|
151
|
+
|
|
152
|
+
const isOpen = callStatus === CallStatus.RINGING || callStatus === CallStatus.CONNECTED || !!errorMessage;
|
|
153
|
+
if (!isOpen) return null;
|
|
154
|
+
|
|
155
|
+
const title = errorMessage ? 'Call Error' : (
|
|
156
|
+
callStatus === CallStatus.RINGING
|
|
157
|
+
? (isIncoming ? incomingCallTitle(callType) : outgoingCallTitle(callType))
|
|
158
|
+
: ongoingCallTitle(callType)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const peerInfo = isIncoming ? callerInfo : receiverInfo;
|
|
162
|
+
const modalMaxWidth = callType === 'video' && callStatus === CallStatus.CONNECTED ? '720px' : '480px';
|
|
163
|
+
|
|
164
|
+
// Default icons
|
|
165
|
+
const FinalPhoneIcon = PropPhoneIcon || (() => (
|
|
166
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
167
|
+
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>
|
|
168
|
+
</svg>
|
|
169
|
+
));
|
|
170
|
+
|
|
171
|
+
const FinalVideoIcon = PropVideoIcon || (() => (
|
|
172
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
173
|
+
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
|
174
|
+
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
|
175
|
+
</svg>
|
|
176
|
+
));
|
|
177
|
+
|
|
178
|
+
const FinalVideoOffIcon = PropVideoOffIcon || (() => (
|
|
179
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
180
|
+
<path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10M1 1l22 22"></path>
|
|
181
|
+
</svg>
|
|
182
|
+
));
|
|
183
|
+
|
|
184
|
+
const FinalMicIcon = PropMicIcon || (() => (
|
|
185
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
186
|
+
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
|
187
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
|
188
|
+
<line x1="12" y1="19" x2="12" y2="23"></line>
|
|
189
|
+
<line x1="8" y1="23" x2="16" y2="23"></line>
|
|
190
|
+
</svg>
|
|
191
|
+
));
|
|
192
|
+
|
|
193
|
+
const FinalMicOffIcon = PropMicOffIcon || (() => (
|
|
194
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
195
|
+
<line x1="1" y1="1" x2="23" y2="23"></line>
|
|
196
|
+
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
|
197
|
+
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
|
|
198
|
+
<line x1="12" y1="19" x2="12" y2="23"></line>
|
|
199
|
+
<line x1="8" y1="23" x2="16" y2="23"></line>
|
|
200
|
+
</svg>
|
|
201
|
+
));
|
|
202
|
+
|
|
203
|
+
const FinalScreenShareIcon = PropScreenShareIcon || (() => (
|
|
204
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
205
|
+
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
|
206
|
+
<line x1="8" y1="21" x2="16" y2="21"></line>
|
|
207
|
+
<line x1="12" y1="17" x2="12" y2="21"></line>
|
|
208
|
+
<path d="M16 11l-4 4-4-4"></path>
|
|
209
|
+
<path d="M12 15V7"></path>
|
|
210
|
+
</svg>
|
|
211
|
+
));
|
|
212
|
+
|
|
213
|
+
const FinalScreenShareOffIcon = PropScreenShareOffIcon || (() => (
|
|
214
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
215
|
+
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
|
216
|
+
<line x1="8" y1="21" x2="16" y2="21"></line>
|
|
217
|
+
<line x1="12" y1="17" x2="12" y2="21"></line>
|
|
218
|
+
<line x1="12" y1="10" x2="12" y2="10"></line>
|
|
219
|
+
</svg>
|
|
220
|
+
));
|
|
221
|
+
|
|
222
|
+
const FinalFullscreenIcon = PropFullscreenIcon || (() => (
|
|
223
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
224
|
+
<path d="M8 3H5a2 2 0 0 0-2 2v3"></path>
|
|
225
|
+
<path d="M21 8V5a2 2 0 0 0-2-2h-3"></path>
|
|
226
|
+
<path d="M3 16v3a2 2 0 0 0 2 2h3"></path>
|
|
227
|
+
<path d="M16 21h3a2 2 0 0 0 2-2v-3"></path>
|
|
228
|
+
</svg>
|
|
229
|
+
));
|
|
230
|
+
|
|
231
|
+
const FinalExitFullscreenIcon = PropExitFullscreenIcon || (() => (
|
|
232
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
233
|
+
<path d="M4 14h6v6"></path>
|
|
234
|
+
<path d="M20 10h-6V4"></path>
|
|
235
|
+
<path d="M14 10l7-7"></path>
|
|
236
|
+
<path d="M3 21l7-7"></path>
|
|
237
|
+
</svg>
|
|
238
|
+
));
|
|
239
|
+
|
|
240
|
+
// C4: Upgrade call icon (defaults to FinalVideoIcon)
|
|
241
|
+
const FinalUpgradeCallIcon = PropUpgradeCallIcon || FinalVideoIcon;
|
|
242
|
+
|
|
243
|
+
/* ================================================================
|
|
244
|
+
Shared Controls Bar — used by both audio and video active states
|
|
245
|
+
================================================================ */
|
|
246
|
+
const renderControls = () => {
|
|
247
|
+
// C6: Allow consumer to replace controls bar entirely
|
|
248
|
+
if (CustomControlsBarComponent) {
|
|
249
|
+
return (
|
|
250
|
+
<CustomControlsBarComponent
|
|
251
|
+
callType={callType}
|
|
252
|
+
toggleMic={toggleMic}
|
|
253
|
+
toggleVideo={toggleVideo}
|
|
254
|
+
toggleScreenShare={toggleScreenShare}
|
|
255
|
+
toggleFullscreen={toggleFullscreen}
|
|
256
|
+
upgradeCall={upgradeCall}
|
|
257
|
+
endCall={endCall}
|
|
258
|
+
isMicMuted={isMicMuted}
|
|
259
|
+
isVideoMuted={isVideoMuted}
|
|
260
|
+
isScreenSharing={isScreenSharing}
|
|
261
|
+
isFullscreen={isFullscreen}
|
|
262
|
+
audioDevices={audioDevices}
|
|
263
|
+
videoDevices={videoDevices}
|
|
264
|
+
selectedAudioDeviceId={selectedAudioDeviceId}
|
|
265
|
+
selectedVideoDeviceId={selectedVideoDeviceId}
|
|
266
|
+
switchAudioDevice={switchAudioDevice}
|
|
267
|
+
switchVideoDevice={switchVideoDevice}
|
|
268
|
+
/>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<div className="ermis-call-ui__controls">
|
|
274
|
+
{/* Mic */}
|
|
275
|
+
<div className="ermis-call-ui__action-group">
|
|
276
|
+
<button
|
|
277
|
+
onClick={toggleMic}
|
|
278
|
+
className={`ermis-call-ui__control-btn ${isMicMuted ? 'ermis-call-ui__control-btn--muted' : ''}`}
|
|
279
|
+
data-tooltip={toggleMicTitle}
|
|
280
|
+
>
|
|
281
|
+
{isMicMuted ? <FinalMicOffIcon /> : <FinalMicIcon />}
|
|
282
|
+
</button>
|
|
283
|
+
{audioDevices.length > 0 && (
|
|
284
|
+
<select
|
|
285
|
+
className="ermis-call-ui__device-select"
|
|
286
|
+
value={selectedAudioDeviceId}
|
|
287
|
+
onChange={(e) => switchAudioDevice(e.target.value)}
|
|
288
|
+
>
|
|
289
|
+
{audioDevices.map(d => (
|
|
290
|
+
<option key={d.deviceId} value={d.deviceId}>{d.label || 'Microphone'}</option>
|
|
291
|
+
))}
|
|
292
|
+
</select>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
{/* Video controls */}
|
|
297
|
+
{callType === 'video' ? (
|
|
298
|
+
<div className="ermis-call-ui__action-group">
|
|
299
|
+
<button
|
|
300
|
+
onClick={toggleVideo}
|
|
301
|
+
className={`ermis-call-ui__control-btn ${isVideoMuted ? 'ermis-call-ui__control-btn--muted' : ''}`}
|
|
302
|
+
data-tooltip={toggleVideoTitle}
|
|
303
|
+
>
|
|
304
|
+
{isVideoMuted ? <FinalVideoOffIcon /> : <FinalVideoIcon />}
|
|
305
|
+
</button>
|
|
306
|
+
{videoDevices.length > 0 && (
|
|
307
|
+
<select
|
|
308
|
+
className="ermis-call-ui__device-select"
|
|
309
|
+
value={selectedVideoDeviceId}
|
|
310
|
+
onChange={(e) => switchVideoDevice(e.target.value)}
|
|
311
|
+
>
|
|
312
|
+
{videoDevices.map(d => (
|
|
313
|
+
<option key={d.deviceId} value={d.deviceId}>{d.label || 'Camera'}</option>
|
|
314
|
+
))}
|
|
315
|
+
</select>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
) : (
|
|
319
|
+
<div className="ermis-call-ui__action-group">
|
|
320
|
+
<button
|
|
321
|
+
onClick={upgradeCall}
|
|
322
|
+
className="ermis-call-ui__control-btn"
|
|
323
|
+
data-tooltip={upgradeCallTitle}
|
|
324
|
+
>
|
|
325
|
+
<FinalUpgradeCallIcon />
|
|
326
|
+
</button>
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
{/* Screen Share */}
|
|
331
|
+
{callType === 'video' && typeof navigator.mediaDevices?.getDisplayMedia === 'function' && (
|
|
332
|
+
<div className="ermis-call-ui__action-group">
|
|
333
|
+
<button
|
|
334
|
+
onClick={toggleScreenShare}
|
|
335
|
+
className={`ermis-call-ui__control-btn ${isScreenSharing ? 'ermis-call-ui__control-btn--active' : ''}`}
|
|
336
|
+
data-tooltip={isScreenSharing ? stopScreenShareTitle : shareScreenTitle}
|
|
337
|
+
>
|
|
338
|
+
{isScreenSharing ? <FinalScreenShareIcon /> : <FinalScreenShareOffIcon />}
|
|
339
|
+
</button>
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
|
|
343
|
+
{/* Fullscreen */}
|
|
344
|
+
<div className="ermis-call-ui__action-group">
|
|
345
|
+
<button
|
|
346
|
+
onClick={toggleFullscreen}
|
|
347
|
+
className="ermis-call-ui__control-btn"
|
|
348
|
+
data-tooltip={isFullscreen ? exitFullscreenTitle : fullscreenTitle}
|
|
349
|
+
>
|
|
350
|
+
{isFullscreen ? <FinalExitFullscreenIcon /> : <FinalFullscreenIcon />}
|
|
351
|
+
</button>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
{/* Separator before end call */}
|
|
355
|
+
<div className="ermis-call-ui__controls-separator" />
|
|
356
|
+
|
|
357
|
+
{/* End Call */}
|
|
358
|
+
<button
|
|
359
|
+
onClick={endCall}
|
|
360
|
+
className="ermis-call-ui__control-btn ermis-call-ui__control-btn--danger"
|
|
361
|
+
data-tooltip={endCallLabel}
|
|
362
|
+
>
|
|
363
|
+
<FinalPhoneIcon />
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/* ================================================================
|
|
370
|
+
Render ringing state
|
|
371
|
+
================================================================ */
|
|
372
|
+
const renderRinging = () => {
|
|
373
|
+
// C6: Allow consumer to replace ringing view entirely
|
|
374
|
+
if (CustomRingingComponent) {
|
|
375
|
+
return (
|
|
376
|
+
<CustomRingingComponent
|
|
377
|
+
peerInfo={peerInfo}
|
|
378
|
+
callType={callType}
|
|
379
|
+
isIncoming={isIncoming}
|
|
380
|
+
acceptCall={acceptCall}
|
|
381
|
+
rejectCall={rejectCall}
|
|
382
|
+
endCall={endCall}
|
|
383
|
+
AvatarComponent={AvatarComponent}
|
|
384
|
+
isCallingYouLabel={isCallingYouLabel}
|
|
385
|
+
ringingLabel={ringingLabel}
|
|
386
|
+
rejectCallLabel={rejectCallLabel}
|
|
387
|
+
acceptCallLabel={acceptCallLabel}
|
|
388
|
+
endCallLabel={endCallLabel}
|
|
389
|
+
audioCallBadgeLabel={audioCallBadgeLabel}
|
|
390
|
+
videoCallBadgeLabel={videoCallBadgeLabel}
|
|
391
|
+
/>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<div className="ermis-call-ui__ringing">
|
|
397
|
+
{/* Avatar with pulse rings */}
|
|
398
|
+
<div className="ermis-call-ui__ringing-avatar">
|
|
399
|
+
<div className="ermis-call-ui__ringing-avatar-inner">
|
|
400
|
+
<AvatarComponent
|
|
401
|
+
image={peerInfo?.avatar}
|
|
402
|
+
name={peerInfo?.name}
|
|
403
|
+
size={88}
|
|
404
|
+
/>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
<h3 className="ermis-call-ui__ringing-name">
|
|
409
|
+
{peerInfo?.name}
|
|
410
|
+
</h3>
|
|
411
|
+
<p className="ermis-call-ui__ringing-status">
|
|
412
|
+
{isIncoming ? isCallingYouLabel : ringingLabel}
|
|
413
|
+
</p>
|
|
414
|
+
|
|
415
|
+
{/* Call type badge */}
|
|
416
|
+
<div className="ermis-call-ui__type-badge">
|
|
417
|
+
{callType === 'video' ? <FinalVideoIcon /> : <FinalPhoneIcon />}
|
|
418
|
+
{callType === 'video' ? videoCallBadgeLabel : audioCallBadgeLabel}
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
{/* Action buttons */}
|
|
422
|
+
<div className="ermis-call-ui__ringing-actions">
|
|
423
|
+
{isIncoming ? (
|
|
424
|
+
<>
|
|
425
|
+
<div className="ermis-call-ui__ringing-action">
|
|
426
|
+
<button
|
|
427
|
+
onClick={rejectCall}
|
|
428
|
+
className="ermis-call-ui__action-circle ermis-call-ui__action-circle--reject"
|
|
429
|
+
>
|
|
430
|
+
<FinalPhoneIcon />
|
|
431
|
+
</button>
|
|
432
|
+
<span className="ermis-call-ui__action-label">{rejectCallLabel}</span>
|
|
433
|
+
</div>
|
|
434
|
+
<div className="ermis-call-ui__ringing-action">
|
|
435
|
+
<button
|
|
436
|
+
onClick={acceptCall}
|
|
437
|
+
className="ermis-call-ui__action-circle ermis-call-ui__action-circle--accept"
|
|
438
|
+
>
|
|
439
|
+
{callType === 'video' ? <FinalVideoIcon /> : <FinalPhoneIcon />}
|
|
440
|
+
</button>
|
|
441
|
+
<span className="ermis-call-ui__action-label">{acceptCallLabel}</span>
|
|
442
|
+
</div>
|
|
443
|
+
</>
|
|
444
|
+
) : (
|
|
445
|
+
<div className="ermis-call-ui__ringing-action">
|
|
446
|
+
<button
|
|
447
|
+
onClick={endCall}
|
|
448
|
+
className="ermis-call-ui__action-circle ermis-call-ui__action-circle--reject"
|
|
449
|
+
>
|
|
450
|
+
<FinalPhoneIcon />
|
|
451
|
+
</button>
|
|
452
|
+
<span className="ermis-call-ui__action-label">{endCallLabel}</span>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
/* ================================================================
|
|
461
|
+
Render connected state
|
|
462
|
+
================================================================ */
|
|
463
|
+
const renderConnected = () => {
|
|
464
|
+
if (callType === 'video') {
|
|
465
|
+
// C6: Allow consumer to replace connected video view
|
|
466
|
+
if (CustomConnectedVideoComponent) {
|
|
467
|
+
return (
|
|
468
|
+
<CustomConnectedVideoComponent
|
|
469
|
+
localVideoRef={localVideoRef}
|
|
470
|
+
remoteVideoRef={remoteVideoRef}
|
|
471
|
+
isRemoteMicMuted={isRemoteMicMuted}
|
|
472
|
+
renderControls={renderControls}
|
|
473
|
+
/>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return (
|
|
478
|
+
<div className="ermis-call-ui__active">
|
|
479
|
+
<div className="ermis-call-ui__video-container">
|
|
480
|
+
<video
|
|
481
|
+
ref={remoteVideoRef}
|
|
482
|
+
autoPlay
|
|
483
|
+
playsInline
|
|
484
|
+
className="ermis-call-ui__video-remote"
|
|
485
|
+
/>
|
|
486
|
+
<div className="ermis-call-ui__video-local">
|
|
487
|
+
<video
|
|
488
|
+
ref={localVideoRef}
|
|
489
|
+
autoPlay
|
|
490
|
+
playsInline
|
|
491
|
+
muted
|
|
492
|
+
className="ermis-call-ui__video-local-stream"
|
|
493
|
+
/>
|
|
494
|
+
</div>
|
|
495
|
+
{/* Remote mic muted indicator */}
|
|
496
|
+
{isRemoteMicMuted && (
|
|
497
|
+
<div className="ermis-call-ui__remote-muted-badge">
|
|
498
|
+
<FinalMicOffIcon />
|
|
499
|
+
</div>
|
|
500
|
+
)}
|
|
501
|
+
{/* Glassmorphism controls overlay */}
|
|
502
|
+
<div className="ermis-call-ui__video-controls-overlay">
|
|
503
|
+
{renderControls()}
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Audio call
|
|
511
|
+
// C6: Allow consumer to replace connected audio view
|
|
512
|
+
if (CustomConnectedAudioComponent) {
|
|
513
|
+
return (
|
|
514
|
+
<CustomConnectedAudioComponent
|
|
515
|
+
peerInfo={peerInfo}
|
|
516
|
+
callDuration={callDuration}
|
|
517
|
+
isRemoteMicMuted={isRemoteMicMuted}
|
|
518
|
+
AvatarComponent={AvatarComponent}
|
|
519
|
+
connectedLabel={connectedLabel}
|
|
520
|
+
renderControls={renderControls}
|
|
521
|
+
/>
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return (
|
|
526
|
+
<div className="ermis-call-ui__active">
|
|
527
|
+
<div className="ermis-call-ui__audio-container">
|
|
528
|
+
<div className="ermis-call-ui__audio-avatar-wrapper">
|
|
529
|
+
<AvatarComponent
|
|
530
|
+
image={peerInfo?.avatar}
|
|
531
|
+
name={peerInfo?.name}
|
|
532
|
+
size={100}
|
|
533
|
+
/>
|
|
534
|
+
{/* Remote mic muted indicator */}
|
|
535
|
+
{isRemoteMicMuted && (
|
|
536
|
+
<div className="ermis-call-ui__remote-muted-badge ermis-call-ui__remote-muted-badge--audio">
|
|
537
|
+
<FinalMicOffIcon />
|
|
538
|
+
</div>
|
|
539
|
+
)}
|
|
540
|
+
</div>
|
|
541
|
+
<h3 className="ermis-call-ui__active-name">
|
|
542
|
+
{peerInfo?.name}
|
|
543
|
+
</h3>
|
|
544
|
+
|
|
545
|
+
{/* Status + Timer */}
|
|
546
|
+
<div className="ermis-call-ui__active-status">
|
|
547
|
+
<span className="ermis-call-ui__active-status-dot" />
|
|
548
|
+
<span>{connectedLabel}</span>
|
|
549
|
+
<span className="ermis-call-ui__timer">
|
|
550
|
+
{formatDuration(callDuration)}
|
|
551
|
+
</span>
|
|
552
|
+
</div>
|
|
553
|
+
|
|
554
|
+
{/* Audio wave visualizer */}
|
|
555
|
+
<div className="ermis-call-ui__audio-waves">
|
|
556
|
+
{Array.from({ length: 9 }).map((_, i) => (
|
|
557
|
+
<span key={i} className="ermis-call-ui__audio-wave-bar" />
|
|
558
|
+
))}
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
<audio ref={remoteAudioRef} autoPlay className="ermis-call-ui__audio--hidden" />
|
|
562
|
+
|
|
563
|
+
{/* Controls bar */}
|
|
564
|
+
{renderControls()}
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
);
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
/* ================================================================
|
|
571
|
+
Render error state
|
|
572
|
+
================================================================ */
|
|
573
|
+
const renderError = () => {
|
|
574
|
+
if (!errorMessage) return null;
|
|
575
|
+
|
|
576
|
+
// C6: Allow consumer to replace error view
|
|
577
|
+
if (CustomErrorComponent) {
|
|
578
|
+
return (
|
|
579
|
+
<CustomErrorComponent
|
|
580
|
+
errorMessage={errorMessage}
|
|
581
|
+
clearError={clearError}
|
|
582
|
+
cancelLabel={cancelLabel}
|
|
583
|
+
PhoneIcon={FinalPhoneIcon}
|
|
584
|
+
/>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return (
|
|
589
|
+
<div className="ermis-call-ui__error">
|
|
590
|
+
<div className="ermis-call-ui__error-icon">
|
|
591
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="var(--ermis-color-danger)" strokeWidth="2" width="56" height="56">
|
|
592
|
+
<circle cx="12" cy="12" r="10" />
|
|
593
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
594
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
595
|
+
</svg>
|
|
596
|
+
</div>
|
|
597
|
+
<p className="ermis-call-ui__error-text">{errorMessage}</p>
|
|
598
|
+
<button
|
|
599
|
+
className="ermis-call-ui__error-dismiss"
|
|
600
|
+
onClick={clearError}
|
|
601
|
+
>
|
|
602
|
+
<FinalPhoneIcon /> {cancelLabel}
|
|
603
|
+
</button>
|
|
604
|
+
</div>
|
|
605
|
+
);
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
return (
|
|
609
|
+
<Modal isOpen={isOpen} onClose={endCall} title={title} hideCloseButton closeOnOutsideClick={false} maxWidth={modalMaxWidth}>
|
|
610
|
+
<div className={`ermis-call-ui ${isFullscreen ? 'ermis-call-ui--fullscreen' : ''}${className ? ` ${className}` : ''}`} ref={callContainerRef}>
|
|
611
|
+
{/* Ringing audio */}
|
|
612
|
+
{(incomingCallAudioPath || outgoingCallAudioPath) && (
|
|
613
|
+
<audio
|
|
614
|
+
ref={ringingAudioRef}
|
|
615
|
+
src={isIncoming ? incomingCallAudioPath : outgoingCallAudioPath}
|
|
616
|
+
loop
|
|
617
|
+
className="ermis-call-ui__audio--hidden"
|
|
618
|
+
/>
|
|
619
|
+
)}
|
|
620
|
+
|
|
621
|
+
{/* ============ ERROR STATE ============ */}
|
|
622
|
+
{errorMessage && renderError()}
|
|
623
|
+
|
|
624
|
+
{/* ============ RINGING STATE ============ */}
|
|
625
|
+
{!errorMessage && callStatus === CallStatus.RINGING && renderRinging()}
|
|
626
|
+
|
|
627
|
+
{/* ============ CONNECTED STATE ============ */}
|
|
628
|
+
{!errorMessage && callStatus === CallStatus.CONNECTED && renderConnected()}
|
|
629
|
+
</div>
|
|
630
|
+
</Modal>
|
|
631
|
+
);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
ErmisCallUI.displayName = 'ErmisCallUI';
|