@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.
Files changed (38) hide show
  1. package/dist/index.cjs +2501 -1249
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1231 -134
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +306 -2
  6. package/dist/index.d.ts +306 -2
  7. package/dist/index.mjs +2427 -1181
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +2 -2
  10. package/src/components/ChannelHeader.tsx +50 -9
  11. package/src/components/ChannelInfo/AddMemberModal.tsx +48 -174
  12. package/src/components/ChannelList.tsx +9 -3
  13. package/src/components/CreateChannelModal.tsx +274 -0
  14. package/src/components/ErmisCallProvider.tsx +279 -0
  15. package/src/components/ErmisCallUI.tsx +634 -0
  16. package/src/components/MessageRenderers.tsx +37 -10
  17. package/src/components/Modal.tsx +2 -1
  18. package/src/components/UserPicker.tsx +377 -0
  19. package/src/context/ChatProvider.tsx +49 -1
  20. package/src/context/ErmisCallContext.tsx +37 -0
  21. package/src/hooks/useCallContext.ts +10 -0
  22. package/src/index.ts +27 -0
  23. package/src/styles/_add-member-modal.css +12 -29
  24. package/src/styles/_call-ui.css +743 -0
  25. package/src/styles/_channel-info.css +34 -34
  26. package/src/styles/_channel-list.css +7 -7
  27. package/src/styles/_create-channel-modal.css +183 -0
  28. package/src/styles/_message-bubble.css +108 -16
  29. package/src/styles/_message-input.css +4 -4
  30. package/src/styles/_message-list.css +11 -11
  31. package/src/styles/_modal.css +23 -36
  32. package/src/styles/_panel.css +1 -1
  33. package/src/styles/_search-panel.css +9 -9
  34. package/src/styles/_tokens.css +42 -0
  35. package/src/styles/_typing-indicator.css +15 -2
  36. package/src/styles/_user-picker.css +268 -0
  37. package/src/styles/index.css +3 -0
  38. 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';