@ermis-network/ermis-chat-react 1.0.9 → 2.0.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.
Files changed (99) hide show
  1. package/dist/index.cjs +15288 -4203
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +701 -195
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +862 -94
  6. package/dist/index.d.ts +862 -94
  7. package/dist/index.mjs +15238 -4179
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -4
  10. package/src/channelTypeUtils.ts +1 -1
  11. package/src/components/Avatar.tsx +2 -1
  12. package/src/components/Channel.tsx +6 -2
  13. package/src/components/ChannelActions.tsx +61 -2
  14. package/src/components/ChannelHeader.tsx +19 -5
  15. package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
  21. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  23. package/src/components/ChannelInfo/States.tsx +1 -1
  24. package/src/components/ChannelInfo/index.ts +3 -0
  25. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
  26. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  27. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  28. package/src/components/ChannelList.tsx +177 -290
  29. package/src/components/CreateChannelModal.tsx +166 -88
  30. package/src/components/Dropdown.tsx +1 -16
  31. package/src/components/EditPreview.tsx +1 -0
  32. package/src/components/ErmisCallProvider.tsx +72 -17
  33. package/src/components/ErmisCallUI.tsx +43 -20
  34. package/src/components/FlatTopicGroupItem.tsx +232 -0
  35. package/src/components/ForwardMessageModal.tsx +31 -77
  36. package/src/components/MediaLightbox.tsx +62 -40
  37. package/src/components/MentionSuggestions.tsx +47 -35
  38. package/src/components/MessageActionsBox.tsx +4 -1
  39. package/src/components/MessageInput.tsx +126 -7
  40. package/src/components/MessageInputDefaults.tsx +127 -1
  41. package/src/components/MessageItem.tsx +93 -26
  42. package/src/components/MessageQuickReactions.tsx +153 -26
  43. package/src/components/MessageReactions.tsx +2 -1
  44. package/src/components/MessageRenderers.tsx +111 -39
  45. package/src/components/Panel.tsx +1 -14
  46. package/src/components/PinnedMessages.tsx +17 -5
  47. package/src/components/PreviewOverlay.tsx +24 -0
  48. package/src/components/ReadReceipts.tsx +2 -1
  49. package/src/components/TopicList.tsx +221 -0
  50. package/src/components/TopicModal.tsx +4 -1
  51. package/src/components/TypingIndicator.tsx +14 -5
  52. package/src/components/UserPicker.tsx +87 -10
  53. package/src/components/VirtualMessageList.tsx +106 -20
  54. package/src/context/ChatComponentsContext.tsx +14 -0
  55. package/src/context/ChatProvider.tsx +18 -14
  56. package/src/context/ErmisCallContext.tsx +4 -0
  57. package/src/hooks/useChannelCapabilities.ts +7 -4
  58. package/src/hooks/useChannelData.ts +10 -3
  59. package/src/hooks/useChannelListUpdates.ts +72 -20
  60. package/src/hooks/useChannelMessages.ts +72 -10
  61. package/src/hooks/useChannelRowUpdates.ts +24 -5
  62. package/src/hooks/useChatUser.ts +31 -0
  63. package/src/hooks/useContactChannels.ts +45 -0
  64. package/src/hooks/useContactCount.ts +50 -0
  65. package/src/hooks/useDownloadHandler.ts +36 -0
  66. package/src/hooks/useDragAndDrop.ts +79 -0
  67. package/src/hooks/useForwardMessage.ts +112 -0
  68. package/src/hooks/useInviteChannels.ts +88 -0
  69. package/src/hooks/useInviteCount.ts +104 -0
  70. package/src/hooks/useMentions.ts +0 -1
  71. package/src/hooks/useMessageActions.ts +13 -10
  72. package/src/hooks/usePendingState.ts +21 -4
  73. package/src/hooks/usePreviewState.ts +69 -0
  74. package/src/hooks/useStickerPicker.ts +62 -0
  75. package/src/hooks/useTopicGroupUpdates.ts +197 -0
  76. package/src/index.ts +56 -6
  77. package/src/messageTypeUtils.ts +13 -1
  78. package/src/styles/_base.css +0 -1
  79. package/src/styles/_call-ui.css +59 -2
  80. package/src/styles/_channel-info.css +41 -4
  81. package/src/styles/_channel-list.css +97 -57
  82. package/src/styles/_create-channel-modal.css +10 -0
  83. package/src/styles/_forward-modal.css +16 -1
  84. package/src/styles/_media-lightbox.css +32 -0
  85. package/src/styles/_mentions.css +1 -1
  86. package/src/styles/_message-actions.css +3 -4
  87. package/src/styles/_message-bubble.css +286 -107
  88. package/src/styles/_message-input.css +131 -0
  89. package/src/styles/_message-list.css +33 -17
  90. package/src/styles/_message-quick-reactions.css +40 -9
  91. package/src/styles/_message-reactions.css +4 -0
  92. package/src/styles/_modal.css +2 -1
  93. package/src/styles/_preview-overlay.css +38 -0
  94. package/src/styles/_tokens.css +17 -15
  95. package/src/styles/_typing-indicator.css +7 -1
  96. package/src/styles/index.css +1 -0
  97. package/src/types.ts +362 -14
  98. package/src/utils/avatarColors.ts +48 -0
  99. package/src/utils.ts +193 -10
@@ -1,8 +1,10 @@
1
1
  import React, { useState, useMemo, useCallback } from 'react';
2
- import { Modal } from './Modal';
2
+ import { Modal as DefaultModal } from './Modal';
3
3
  import { UserPicker } from './UserPicker';
4
4
  import { Avatar } from './Avatar';
5
5
  import { useChatClient } from '../hooks/useChatClient';
6
+ import { useChatComponents } from '../context/ChatComponentsContext';
7
+ import { markChannelAsFullyQueried } from '../hooks/useChannelMessages';
6
8
  import type { CreateChannelModalProps, UserPickerUser } from '../types';
7
9
  import { isDirectChannel } from '../channelTypeUtils';
8
10
 
@@ -26,8 +28,18 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
26
28
  createButtonLabel = 'Create',
27
29
  creatingButtonLabel = 'Creating...',
28
30
  messageButtonLabel = 'Message',
31
+ nextButtonLabel = 'Next',
32
+ backButtonLabel = 'Back',
33
+ emptyStateLabel = 'No users found',
34
+ TabsComponent,
35
+ FooterComponent,
36
+ GroupFieldsComponent,
37
+ SearchInputComponent,
38
+ SelectedBoxComponent,
29
39
  }) => {
30
40
  const { client, setActiveChannel } = useChatClient();
41
+ const { ModalComponent } = useChatComponents();
42
+ const Modal = ModalComponent || DefaultModal;
31
43
  const currentUserId = client?.userID;
32
44
 
33
45
  /* ---------- State ---------- */
@@ -54,9 +66,9 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
54
66
  return Object.values(client.activeChannels).some((ch: any) => {
55
67
  if (isDirectChannel(ch) && ch.state?.members) {
56
68
  const membersList = Object.keys(ch.state.members);
57
- return membersList.length === 2 &&
58
- membersList.includes(currentUserId) &&
59
- membersList.includes(targetUserId);
69
+ return membersList.length === 2 &&
70
+ membersList.includes(currentUserId) &&
71
+ membersList.includes(targetUserId);
60
72
  }
61
73
  return false;
62
74
  });
@@ -90,9 +102,9 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
90
102
  const existingChannel = Object.values(client.activeChannels).find((ch: any) => {
91
103
  if (isDirectChannel(ch) && ch.state?.members) {
92
104
  const membersList = Object.keys(ch.state.members);
93
- return membersList.length === 2 &&
94
- membersList.includes(currentUserId) &&
95
- membersList.includes(targetUserId);
105
+ return membersList.length === 2 &&
106
+ membersList.includes(currentUserId) &&
107
+ membersList.includes(targetUserId);
96
108
  }
97
109
  return false;
98
110
  });
@@ -114,7 +126,8 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
114
126
  const response = (await createdChannel.create()) as any;
115
127
  if (response?.channel?.id) {
116
128
  createdChannel = client.channel('messaging', response.channel.id);
117
- await createdChannel.watch();
129
+ await createdChannel.watch({ messages: { limit: 25, include_hidden_messages: true } });
130
+ markChannelAsFullyQueried(createdChannel.cid);
118
131
  }
119
132
  } else {
120
133
  // Group Channel
@@ -138,7 +151,8 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
138
151
  const response = (await createdChannel.create()) as any;
139
152
  if (response?.channel?.id) {
140
153
  createdChannel = client.channel('team', response.channel.id);
141
- await createdChannel.watch();
154
+ await createdChannel.watch({ messages: { limit: 25, include_hidden_messages: true } });
155
+ markChannelAsFullyQueried(createdChannel.cid);
142
156
  }
143
157
  }
144
158
 
@@ -169,7 +183,40 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
169
183
  }, [selectedUsers, tab, name, step]);
170
184
 
171
185
  let footer;
172
- if (tab === 'messaging') {
186
+ if (FooterComponent) {
187
+ footer = (
188
+ <FooterComponent
189
+ tab={tab}
190
+ step={step}
191
+ onCancel={() => {
192
+ if (tab === 'team' && step === 2) {
193
+ setError(null);
194
+ setStep(1);
195
+ } else {
196
+ onClose();
197
+ }
198
+ }}
199
+ onNext={() => {
200
+ setError(null);
201
+ setStep(2);
202
+ }}
203
+ onBack={() => {
204
+ setError(null);
205
+ setStep(1);
206
+ }}
207
+ onCreate={handleCreate}
208
+ isCreating={isCreating}
209
+ isValid={isValid}
210
+ hasExistingDirectChannel={hasExistingDirectChannel}
211
+ cancelButtonLabel={cancelButtonLabel}
212
+ createButtonLabel={createButtonLabel}
213
+ creatingButtonLabel={creatingButtonLabel}
214
+ messageButtonLabel={messageButtonLabel}
215
+ nextButtonLabel={nextButtonLabel}
216
+ backButtonLabel={backButtonLabel}
217
+ />
218
+ );
219
+ } else if (tab === 'messaging') {
173
220
  footer = (
174
221
  <div className="ermis-create-channel__footer">
175
222
  <button className="ermis-create-channel__btn ermis-create-channel__btn--cancel" onClick={onClose} disabled={isCreating}>{cancelButtonLabel}</button>
@@ -183,14 +230,14 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
183
230
  <div className="ermis-create-channel__footer">
184
231
  <button className="ermis-create-channel__btn ermis-create-channel__btn--cancel" onClick={onClose} disabled={isCreating}>{cancelButtonLabel}</button>
185
232
  <button className="ermis-create-channel__btn ermis-create-channel__btn--create" onClick={() => { setError(null); setStep(2); }} disabled={isCreating || !isValid}>
186
- Next
233
+ {nextButtonLabel}
187
234
  </button>
188
235
  </div>
189
236
  );
190
237
  } else if (tab === 'team' && step === 2) {
191
238
  footer = (
192
239
  <div className="ermis-create-channel__footer">
193
- <button className="ermis-create-channel__btn ermis-create-channel__btn--cancel" onClick={() => { setError(null); setStep(1); }} disabled={isCreating}>Back</button>
240
+ <button className="ermis-create-channel__btn ermis-create-channel__btn--cancel" onClick={() => { setError(null); setStep(1); }} disabled={isCreating}>{backButtonLabel}</button>
194
241
  <button className="ermis-create-channel__btn ermis-create-channel__btn--create" onClick={handleCreate} disabled={isCreating || !isValid}>
195
242
  {isCreating ? creatingButtonLabel : createButtonLabel}
196
243
  </button>
@@ -203,94 +250,125 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
203
250
  <div className="ermis-create-channel__body">
204
251
 
205
252
  {/* Type Toggle */}
206
- <div className="ermis-create-channel__tabs">
207
- <button
208
- className={`ermis-create-channel__tab ${tab === 'messaging' ? 'ermis-create-channel__tab--active' : ''}`}
209
- onClick={() => {
210
- setTab('messaging');
211
- setStep(1);
212
- setSelectedUsers([]);
213
- setError(null);
214
- }}
215
- disabled={isCreating}
216
- >
217
- {directTabLabel}
218
- </button>
219
- <button
220
- className={`ermis-create-channel__tab ${tab === 'team' ? 'ermis-create-channel__tab--active' : ''}`}
221
- onClick={() => {
222
- setTab('team');
253
+ {TabsComponent ? (
254
+ <TabsComponent
255
+ activeTab={tab}
256
+ onTabChange={(t) => {
257
+ setTab(t);
223
258
  setStep(1);
224
259
  setSelectedUsers([]);
225
260
  setError(null);
226
261
  }}
227
262
  disabled={isCreating}
228
- >
229
- {groupTabLabel}
230
- </button>
231
- </div>
263
+ directTabLabel={directTabLabel}
264
+ groupTabLabel={groupTabLabel}
265
+ />
266
+ ) : (
267
+ <div className="ermis-create-channel__tabs">
268
+ <button
269
+ className={`ermis-create-channel__tab ${tab === 'messaging' ? 'ermis-create-channel__tab--active' : ''}`}
270
+ onClick={() => {
271
+ setTab('messaging');
272
+ setStep(1);
273
+ setSelectedUsers([]);
274
+ setError(null);
275
+ }}
276
+ disabled={isCreating}
277
+ >
278
+ {directTabLabel}
279
+ </button>
280
+ <button
281
+ className={`ermis-create-channel__tab ${tab === 'team' ? 'ermis-create-channel__tab--active' : ''}`}
282
+ onClick={() => {
283
+ setTab('team');
284
+ setStep(1);
285
+ setSelectedUsers([]);
286
+ setError(null);
287
+ }}
288
+ disabled={isCreating}
289
+ >
290
+ {groupTabLabel}
291
+ </button>
292
+ </div>
293
+ )}
232
294
 
233
295
  {/* Group Specific Fields - Step 1 */}
234
296
  {tab === 'team' && step === 1 && (
235
- <>
236
-
237
- <div className="ermis-create-channel__field">
238
- <label className="ermis-create-channel__label">{groupNameLabel} <span style={{ color: 'var(--ermis-error)' }}>*</span></label>
239
- <input
240
- className="ermis-create-channel__input"
241
- value={name}
242
- onChange={(e) => setName(e.target.value)}
243
- placeholder={groupNamePlaceholder}
244
- disabled={isCreating}
245
- maxLength={100}
246
- />
247
- </div>
248
-
249
- <div className="ermis-create-channel__field">
250
- <label className="ermis-create-channel__label">{groupDescriptionLabel}</label>
251
- <textarea
252
- className="ermis-create-channel__textarea"
253
- value={description}
254
- onChange={(e) => setDescription(e.target.value)}
255
- placeholder={groupDescriptionPlaceholder}
256
- disabled={isCreating}
257
- maxLength={500}
258
- rows={2}
259
- />
260
- </div>
261
-
262
- <div className="ermis-create-channel__field ermis-create-channel__field--toggle">
263
- <label className="ermis-create-channel__label">{groupPublicLabel}</label>
264
- <button
265
- type="button"
266
- role="switch"
267
- aria-checked={isPublic}
268
- className={`ermis-create-channel__toggle ${isPublic ? 'ermis-create-channel__toggle--on' : ''}`}
269
- onClick={() => setIsPublic(v => !v)}
270
- disabled={isCreating}
271
- >
272
- <span className="ermis-create-channel__toggle-thumb" />
273
- </button>
274
- </div>
275
- </>
297
+ GroupFieldsComponent ? (
298
+ <GroupFieldsComponent
299
+ name={name}
300
+ onNameChange={setName}
301
+ description={description}
302
+ onDescriptionChange={setDescription}
303
+ isPublic={isPublic}
304
+ onPublicChange={setIsPublic}
305
+ disabled={isCreating}
306
+ groupNameLabel={groupNameLabel}
307
+ groupNamePlaceholder={groupNamePlaceholder}
308
+ groupDescriptionLabel={groupDescriptionLabel}
309
+ groupDescriptionPlaceholder={groupDescriptionPlaceholder}
310
+ groupPublicLabel={groupPublicLabel}
311
+ />
312
+ ) : (
313
+ <>
314
+ <div className="ermis-create-channel__field">
315
+ <label className="ermis-create-channel__label">{groupNameLabel} <span style={{ color: 'var(--ermis-error)' }}>*</span></label>
316
+ <input
317
+ className="ermis-create-channel__input"
318
+ value={name}
319
+ onChange={(e) => setName(e.target.value)}
320
+ placeholder={groupNamePlaceholder}
321
+ disabled={isCreating}
322
+ maxLength={100}
323
+ />
324
+ </div>
325
+
326
+ <div className="ermis-create-channel__field">
327
+ <label className="ermis-create-channel__label">{groupDescriptionLabel}</label>
328
+ <textarea
329
+ className="ermis-create-channel__textarea"
330
+ value={description}
331
+ onChange={(e) => setDescription(e.target.value)}
332
+ placeholder={groupDescriptionPlaceholder}
333
+ disabled={isCreating}
334
+ maxLength={500}
335
+ rows={2}
336
+ />
337
+ </div>
338
+
339
+ <div className="ermis-create-channel__field ermis-create-channel__field--toggle">
340
+ <label className="ermis-create-channel__label">{groupPublicLabel}</label>
341
+ <button
342
+ type="button"
343
+ role="switch"
344
+ aria-checked={isPublic}
345
+ className={`ermis-create-channel__toggle ${isPublic ? 'ermis-create-channel__toggle--on' : ''}`}
346
+ onClick={() => setIsPublic(v => !v)}
347
+ disabled={isCreating}
348
+ >
349
+ <span className="ermis-create-channel__toggle-thumb" />
350
+ </button>
351
+ </div>
352
+ </>
353
+ )
276
354
  )}
277
355
 
278
356
  {/* User Selection - Step 2 (Group) or Step 1 (Messaging) */}
279
357
  {((tab === 'team' && step === 2) || tab === 'messaging') && (
280
- <div className="ermis-create-channel__users">
281
- <div className="ermis-create-channel__users-title">
282
- Members <span style={{ color: 'var(--ermis-error)' }}>*</span>
283
- </div>
284
- <div style={{ height: tab === 'team' ? '280px' : '400px', display: 'flex', flexDirection: 'column' }}>
285
- <UserPicker
286
- mode={tab === 'messaging' ? 'radio' : 'checkbox'}
287
- onSelectionChange={setSelectedUsers}
288
- initialSelectedUsers={selectedUsers}
289
- AvatarComponent={AvatarComponent}
290
- UserItemComponent={UserItemComponent as any}
291
- searchPlaceholder={userSearchPlaceholder}
292
- />
293
- </div>
358
+ <div className={`ermis-create-channel__users ermis-create-channel__users--${tab}`}>
359
+ <UserPicker
360
+ key={tab}
361
+ mode={tab === 'messaging' ? 'radio' : 'checkbox'}
362
+ friendsOnly={tab === 'team'}
363
+ onSelectionChange={setSelectedUsers}
364
+ initialSelectedUsers={selectedUsers}
365
+ emptyText={emptyStateLabel}
366
+ AvatarComponent={AvatarComponent}
367
+ UserItemComponent={UserItemComponent as any}
368
+ SearchInputComponent={SearchInputComponent as any}
369
+ SelectedBoxComponent={SelectedBoxComponent as any}
370
+ searchPlaceholder={userSearchPlaceholder}
371
+ />
294
372
  </div>
295
373
  )}
296
374
 
@@ -9,22 +9,7 @@ export const closeAllDropdowns = () => {
9
9
  document.dispatchEvent(new CustomEvent(CLOSE_ALL_EVENT));
10
10
  };
11
11
 
12
- export interface DropdownProps {
13
- /** Whether the dropdown is open */
14
- isOpen: boolean;
15
- /** Rect from getBoundingClientRect() of the anchor element */
16
- anchorRect: DOMRect | null;
17
- /** Callback when dropdown requests to close (e.g., click outside, scroll, Escape) */
18
- onClose: () => void;
19
- /** Dropdown menu content */
20
- children: React.ReactNode;
21
- /** Horizontal alignment relative to the anchor. Default: 'left' */
22
- align?: 'left' | 'right';
23
- /** Optional custom CSS class for the container */
24
- className?: string;
25
- /** Optional custom CSS style for the container */
26
- style?: React.CSSProperties;
27
- }
12
+ import type { DropdownProps } from '../types';
28
13
 
29
14
  export const Dropdown: React.FC<DropdownProps> = ({
30
15
  isOpen,
@@ -40,6 +40,7 @@ function getAttachmentSummary(attachments: any[]): string {
40
40
  export const EditPreview: React.FC<{
41
41
  message: FormatMessageResponse;
42
42
  onDismiss: () => void;
43
+ editingMessageLabel?: string;
43
44
  }> = React.memo(({
44
45
  message,
45
46
  onDismiss,
@@ -36,6 +36,9 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
36
36
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
37
37
  const [isRemoteMicMuted, setIsRemoteMicMuted] = useState(false);
38
38
  const [isRemoteVideoMuted, setIsRemoteVideoMuted] = useState(false);
39
+ const [isAccepting, setIsAccepting] = useState(false);
40
+ const [isRejecting, setIsRejecting] = useState(false);
41
+ const [isEnding, setIsEnding] = useState(false);
39
42
 
40
43
  // Call duration timer (C7 — exposed via context)
41
44
  const [callDuration, setCallDuration] = useState(0);
@@ -59,9 +62,15 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
59
62
  useEffect(() => {
60
63
  if (callStatus === CallStatus.CONNECTED) {
61
64
  startTimer();
65
+ setIsAccepting(false);
62
66
  } else {
63
67
  stopTimer();
64
68
  }
69
+ if (!callStatus) {
70
+ setIsAccepting(false);
71
+ setIsRejecting(false);
72
+ setIsEnding(false);
73
+ }
65
74
  return () => stopTimer();
66
75
  }, [callStatus, startTimer, stopTimer]);
67
76
 
@@ -92,12 +101,25 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
92
101
  node.onDeviceChange = (audio, video) => {
93
102
  setAudioDevices(audio);
94
103
  setVideoDevices(video);
104
+ const selected = node.getSelectedDevices();
105
+ if (selected.audioDevice) setSelectedAudioDeviceId(selected.audioDevice.deviceId);
106
+ else if (audio.length > 0) setSelectedAudioDeviceId(audio[0].deviceId);
107
+
108
+ if (selected.videoDevice) setSelectedVideoDeviceId(selected.videoDevice.deviceId);
109
+ else if (video.length > 0) setSelectedVideoDeviceId(video[0].deviceId);
95
110
  };
111
+
96
112
  node.onScreenShareChange = (isSharing: boolean) => setIsScreenSharing(isSharing);
97
113
 
98
114
  node.getDevices().then(({ audioDevices: a, videoDevices: v }) => {
99
115
  setAudioDevices(a);
100
116
  setVideoDevices(v);
117
+ const selected = node.getSelectedDevices();
118
+ if (selected.audioDevice) setSelectedAudioDeviceId(selected.audioDevice.deviceId);
119
+ else if (a.length > 0) setSelectedAudioDeviceId(a[0].deviceId);
120
+
121
+ if (selected.videoDevice) setSelectedVideoDeviceId(selected.videoDevice.deviceId);
122
+ else if (v.length > 0) setSelectedVideoDeviceId(v[0].deviceId);
101
123
  });
102
124
 
103
125
  node.onCallStatus = (status: string | null) => {
@@ -152,35 +174,56 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
152
174
  if (!callNode) return;
153
175
  setCallType(type);
154
176
  setIsIncoming(false);
155
- setCallStatus(CallStatus.RINGING);
177
+
178
+ // Tận dụng Local Cache: Phân giải thông tin đồng bộ ngay trước khi bật modal
179
+ callNode.prefillUserInfo(cid);
180
+ setCallerInfo(callNode.callerInfo);
181
+ setReceiverInfo(callNode.receiverInfo);
182
+
183
+ setCallStatus(CallStatus.PREPARING);
156
184
  await callNode.createCall(type, cid);
157
185
  // C1: Lifecycle callback — call started
158
186
  onCallStart?.(type, cid);
159
187
  }, [callNode, onCallStart]);
160
188
 
161
189
  const acceptCall = useCallback(async () => {
162
- if (callNode) await callNode.acceptCall();
163
- // C1: Lifecycle callback — call accepted
164
- onCallAccepted?.();
190
+ if (!callNode) return;
191
+ setIsAccepting(true);
192
+ try {
193
+ await callNode.acceptCall();
194
+ onCallAccepted?.();
195
+ } catch (e) {
196
+ setIsAccepting(false);
197
+ }
165
198
  }, [callNode, onCallAccepted]);
166
199
 
167
200
  const rejectCall = useCallback(async () => {
168
- if (callNode) await callNode.rejectCall();
169
- setCallStatus('');
170
- setIsIncoming(false);
171
- // C1: Lifecycle callback — call rejected
172
- onCallRejected?.();
201
+ if (!callNode) return;
202
+ setIsRejecting(true);
203
+ try {
204
+ await callNode.rejectCall();
205
+ setCallStatus('');
206
+ setIsIncoming(false);
207
+ onCallRejected?.();
208
+ } finally {
209
+ setIsRejecting(false);
210
+ }
173
211
  }, [callNode, onCallRejected]);
174
212
 
175
213
  const endCall = useCallback(async () => {
176
- if (callNode) await callNode.endCall();
177
- // C1: Lifecycle callback — call ended (capture duration before reset)
178
- const duration = callDuration;
179
- setCallStatus('');
180
- setIsIncoming(false);
181
- setLocalStream(null);
182
- setRemoteStream(null);
183
- onCallEnd?.(duration);
214
+ if (!callNode) return;
215
+ setIsEnding(true);
216
+ try {
217
+ await callNode.endCall();
218
+ const duration = callDuration;
219
+ setCallStatus('');
220
+ setIsIncoming(false);
221
+ setLocalStream(null);
222
+ setRemoteStream(null);
223
+ onCallEnd?.(duration);
224
+ } finally {
225
+ setIsEnding(false);
226
+ }
184
227
  }, [callNode, callDuration, onCallEnd]);
185
228
 
186
229
  const toggleScreenShare = useCallback(async () => {
@@ -205,6 +248,14 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
205
248
  }, [callNode]);
206
249
 
207
250
  const clearError = useCallback(() => setErrorMessage(null), []);
251
+ const resetCall = useCallback(() => {
252
+ if (callNode) callNode.destroy();
253
+ setCallStatus('');
254
+ setErrorMessage(null);
255
+ setIsIncoming(false);
256
+ setCallDuration(0);
257
+ setCallType('audio');
258
+ }, [callNode]);
208
259
 
209
260
  const upgradeCall = useCallback(async () => {
210
261
  if (!callNode) return;
@@ -267,6 +318,10 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
267
318
  isRemoteVideoMuted,
268
319
  upgradeCall,
269
320
  callDuration,
321
+ isAccepting,
322
+ isRejecting,
323
+ isEnding,
324
+ resetCall,
270
325
  };
271
326
 
272
327
  return (
@@ -1,7 +1,8 @@
1
1
  import React, { useEffect, useRef, useState, useCallback } from 'react';
2
2
  import { useCallContext } from '../hooks/useCallContext';
3
- import { Modal } from './Modal';
3
+ import { Modal as DefaultModal } from './Modal';
4
4
  import { Avatar } from './Avatar';
5
+ import { useChatComponents } from '../context/ChatComponentsContext';
5
6
  import { CallStatus } from '@ermis-network/ermis-chat-sdk';
6
7
  import type { ErmisCallUIProps } from '../types';
7
8
 
@@ -82,8 +83,14 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
82
83
  isRemoteMicMuted,
83
84
  upgradeCall,
84
85
  callDuration,
86
+ isAccepting,
87
+ isRejecting,
88
+ isEnding,
85
89
  } = useCallContext();
86
90
 
91
+ const { ModalComponent } = useChatComponents();
92
+ const Modal = ModalComponent || DefaultModal;
93
+
87
94
  const localVideoRef = useRef<HTMLVideoElement>(null);
88
95
  const remoteVideoRef = useRef<HTMLVideoElement>(null);
89
96
  const remoteAudioRef = useRef<HTMLAudioElement>(null);
@@ -259,6 +266,7 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
259
266
  isVideoMuted={isVideoMuted}
260
267
  isScreenSharing={isScreenSharing}
261
268
  isFullscreen={isFullscreen}
269
+ isEnding={isEnding}
262
270
  audioDevices={audioDevices}
263
271
  videoDevices={videoDevices}
264
272
  selectedAudioDeviceId={selectedAudioDeviceId}
@@ -341,15 +349,17 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
341
349
  )}
342
350
 
343
351
  {/* 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>
352
+ {callType === 'video' && (
353
+ <div className="ermis-call-ui__action-group">
354
+ <button
355
+ onClick={toggleFullscreen}
356
+ className="ermis-call-ui__control-btn"
357
+ data-tooltip={isFullscreen ? exitFullscreenTitle : fullscreenTitle}
358
+ >
359
+ {isFullscreen ? <FinalExitFullscreenIcon /> : <FinalFullscreenIcon />}
360
+ </button>
361
+ </div>
362
+ )}
353
363
 
354
364
  {/* Separator before end call */}
355
365
  <div className="ermis-call-ui__controls-separator" />
@@ -357,10 +367,11 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
357
367
  {/* End Call */}
358
368
  <button
359
369
  onClick={endCall}
370
+ disabled={isEnding}
360
371
  className="ermis-call-ui__control-btn ermis-call-ui__control-btn--danger"
361
372
  data-tooltip={endCallLabel}
362
373
  >
363
- <FinalPhoneIcon />
374
+ {isEnding ? <div className="ermis-call-ui__spinner" /> : <FinalPhoneIcon />}
364
375
  </button>
365
376
  </div>
366
377
  );
@@ -388,6 +399,9 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
388
399
  endCallLabel={endCallLabel}
389
400
  audioCallBadgeLabel={audioCallBadgeLabel}
390
401
  videoCallBadgeLabel={videoCallBadgeLabel}
402
+ isAccepting={isAccepting}
403
+ isRejecting={isRejecting}
404
+ isEnding={isEnding}
391
405
  />
392
406
  );
393
407
  }
@@ -425,18 +439,24 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
425
439
  <div className="ermis-call-ui__ringing-action">
426
440
  <button
427
441
  onClick={rejectCall}
442
+ disabled={isRejecting}
428
443
  className="ermis-call-ui__action-circle ermis-call-ui__action-circle--reject"
429
444
  >
430
- <FinalPhoneIcon />
445
+ {isRejecting ? <div className="ermis-call-ui__spinner" /> : <FinalPhoneIcon />}
431
446
  </button>
432
447
  <span className="ermis-call-ui__action-label">{rejectCallLabel}</span>
433
448
  </div>
434
449
  <div className="ermis-call-ui__ringing-action">
435
450
  <button
436
451
  onClick={acceptCall}
452
+ disabled={isAccepting}
437
453
  className="ermis-call-ui__action-circle ermis-call-ui__action-circle--accept"
438
454
  >
439
- {callType === 'video' ? <FinalVideoIcon /> : <FinalPhoneIcon />}
455
+ {isAccepting ? (
456
+ <div className="ermis-call-ui__spinner" />
457
+ ) : (
458
+ callType === 'video' ? <FinalVideoIcon /> : <FinalPhoneIcon />
459
+ )}
440
460
  </button>
441
461
  <span className="ermis-call-ui__action-label">{acceptCallLabel}</span>
442
462
  </div>
@@ -445,9 +465,10 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
445
465
  <div className="ermis-call-ui__ringing-action">
446
466
  <button
447
467
  onClick={endCall}
468
+ disabled={isEnding}
448
469
  className="ermis-call-ui__action-circle ermis-call-ui__action-circle--reject"
449
470
  >
450
- <FinalPhoneIcon />
471
+ {isEnding ? <div className="ermis-call-ui__spinner" /> : <FinalPhoneIcon />}
451
472
  </button>
452
473
  <span className="ermis-call-ui__action-label">{endCallLabel}</span>
453
474
  </div>
@@ -492,12 +513,14 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
492
513
  className="ermis-call-ui__video-local-stream"
493
514
  />
494
515
  </div>
495
- {/* Remote mic muted indicator */}
496
- {isRemoteMicMuted && (
497
- <div className="ermis-call-ui__remote-muted-badge">
498
- <FinalMicOffIcon />
499
- </div>
500
- )}
516
+ {/* Call status bar: mic-muted indicator + duration timer */}
517
+ <div className="ermis-call-ui__video-timer">
518
+ {isRemoteMicMuted && (
519
+ <span className="ermis-call-ui__video-timer-mic"><FinalMicOffIcon /></span>
520
+ )}
521
+ <span className="ermis-call-ui__active-status-dot" />
522
+ <span>{formatDuration(callDuration)}</span>
523
+ </div>
501
524
  {/* Glassmorphism controls overlay */}
502
525
  <div className="ermis-call-ui__video-controls-overlay">
503
526
  {renderControls()}