@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.
- package/dist/index.cjs +15288 -4203
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +701 -195
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +862 -94
- package/dist/index.d.ts +862 -94
- package/dist/index.mjs +15238 -4179
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -2
- package/src/components/ChannelActions.tsx +61 -2
- package/src/components/ChannelHeader.tsx +19 -5
- package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
- package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +177 -290
- package/src/components/CreateChannelModal.tsx +166 -88
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FlatTopicGroupItem.tsx +232 -0
- package/src/components/ForwardMessageModal.tsx +31 -77
- package/src/components/MediaLightbox.tsx +62 -40
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +4 -1
- package/src/components/MessageInput.tsx +126 -7
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +93 -26
- package/src/components/MessageQuickReactions.tsx +153 -26
- package/src/components/MessageReactions.tsx +2 -1
- package/src/components/MessageRenderers.tsx +111 -39
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +17 -5
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/TopicList.tsx +221 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +14 -5
- package/src/components/UserPicker.tsx +87 -10
- package/src/components/VirtualMessageList.tsx +106 -20
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +18 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +72 -20
- package/src/hooks/useChannelMessages.ts +72 -10
- package/src/hooks/useChannelRowUpdates.ts +24 -5
- package/src/hooks/useChatUser.ts +31 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useForwardMessage.ts +112 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useMentions.ts +0 -1
- package/src/hooks/useMessageActions.ts +13 -10
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +197 -0
- package/src/index.ts +56 -6
- package/src/messageTypeUtils.ts +13 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +41 -4
- package/src/styles/_channel-list.css +97 -57
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +32 -0
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +286 -107
- package/src/styles/_message-input.css +131 -0
- package/src/styles/_message-list.css +33 -17
- package/src/styles/_message-quick-reactions.css +40 -9
- package/src/styles/_message-reactions.css +4 -0
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_tokens.css +17 -15
- package/src/styles/_typing-indicator.css +7 -1
- package/src/styles/index.css +1 -0
- package/src/types.ts +362 -14
- package/src/utils/avatarColors.ts +48 -0
- 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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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 (
|
|
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
|
-
|
|
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}>
|
|
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
|
-
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
setTab(
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
<
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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=
|
|
281
|
-
<
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
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)
|
|
163
|
-
|
|
164
|
-
|
|
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)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
345
|
-
<
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
{
|
|
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
|
-
{/*
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
<FinalMicOffIcon
|
|
499
|
-
|
|
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()}
|