@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useState, useMemo } from 'react';
|
|
2
2
|
import { preloadImage, isImagePreloaded } from '../utils';
|
|
3
3
|
import type { FormatMessageResponse, Attachment, MessageLabel } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
-
import { parseSystemMessage, parseSignalMessage } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
+
import { parseSystemMessage, parseSignalMessage, CallType } from '@ermis-network/ermis-chat-sdk';
|
|
5
5
|
import { useChatClient } from '../hooks/useChatClient';
|
|
6
6
|
import { buildUserMap } from '../utils';
|
|
7
7
|
import type { AttachmentProps, MessageRendererProps, MessageBubbleProps } from '../types';
|
|
@@ -482,19 +482,46 @@ export const SystemMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
|
482
482
|
|
|
483
483
|
/** Signal message: call events */
|
|
484
484
|
export const SignalMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
485
|
-
const {
|
|
486
|
-
|
|
487
|
-
const userMap = useMemo<Record<string, string>>(() => {
|
|
488
|
-
return buildUserMap(activeChannel?.state);
|
|
489
|
-
}, [activeChannel?.state]);
|
|
485
|
+
const { client } = useChatClient();
|
|
490
486
|
|
|
491
487
|
const rawText = message.text ?? '';
|
|
492
|
-
const
|
|
488
|
+
const result = rawText ? parseSignalMessage(rawText, client.userID || '') : null;
|
|
489
|
+
|
|
490
|
+
if (!result) {
|
|
491
|
+
return (
|
|
492
|
+
<span className="ermis-message-list__signal-text">
|
|
493
|
+
{rawText}
|
|
494
|
+
</span>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const isSuccess = !!result.duration;
|
|
499
|
+
const colorModifier = isSuccess ? 'success' : 'missed';
|
|
500
|
+
const isAudio = result.callType === CallType.AUDIO;
|
|
493
501
|
|
|
494
502
|
return (
|
|
495
|
-
<
|
|
496
|
-
{
|
|
497
|
-
|
|
503
|
+
<div className="ermis-signal-message">
|
|
504
|
+
<div className={`ermis-signal-message__icon ermis-signal-message__icon--${colorModifier}`}>
|
|
505
|
+
{isAudio ? (
|
|
506
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
507
|
+
<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" />
|
|
508
|
+
</svg>
|
|
509
|
+
) : (
|
|
510
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
511
|
+
<polygon points="23 7 16 12 23 17 23 7" />
|
|
512
|
+
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
|
513
|
+
</svg>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
516
|
+
<div className="ermis-signal-message__body">
|
|
517
|
+
<span className={`ermis-signal-message__text ermis-signal-message__text--${colorModifier}`}>
|
|
518
|
+
{result.text}
|
|
519
|
+
</span>
|
|
520
|
+
{result.duration && (
|
|
521
|
+
<span className="ermis-signal-message__duration">{result.duration}</span>
|
|
522
|
+
)}
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
498
525
|
);
|
|
499
526
|
};
|
|
500
527
|
|
package/src/components/Modal.tsx
CHANGED
|
@@ -9,6 +9,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|
|
9
9
|
footer,
|
|
10
10
|
maxWidth = '480px',
|
|
11
11
|
hideCloseButton = false,
|
|
12
|
+
closeOnOutsideClick = true,
|
|
12
13
|
}) => {
|
|
13
14
|
useEffect(() => {
|
|
14
15
|
const handleKey = (e: KeyboardEvent) => {
|
|
@@ -21,7 +22,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|
|
21
22
|
if (!isOpen) return null;
|
|
22
23
|
|
|
23
24
|
return (
|
|
24
|
-
<div className="ermis-modal-overlay" onClick={onClose}>
|
|
25
|
+
<div className="ermis-modal-overlay" onClick={closeOnOutsideClick ? onClose : undefined}>
|
|
25
26
|
<div
|
|
26
27
|
className="ermis-modal-content"
|
|
27
28
|
style={{ maxWidth }}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo, useCallback, useRef, useTransition } from 'react';
|
|
2
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
3
|
+
import { Avatar } from './Avatar';
|
|
4
|
+
import { VList, type VListHandle } from 'virtua';
|
|
5
|
+
import type {
|
|
6
|
+
UserPickerProps,
|
|
7
|
+
UserPickerItemProps,
|
|
8
|
+
UserPickerSelectedBoxProps,
|
|
9
|
+
UserPickerUser,
|
|
10
|
+
} from '../types';
|
|
11
|
+
|
|
12
|
+
/* ---------- Constants ---------- */
|
|
13
|
+
const DEFAULT_PAGE_SIZE = 30;
|
|
14
|
+
const SEARCH_DEBOUNCE_MS = 500;
|
|
15
|
+
|
|
16
|
+
/* ---------- Static styles ---------- */
|
|
17
|
+
const LIST_STYLE: React.CSSProperties = { height: '100%' };
|
|
18
|
+
|
|
19
|
+
/* ==========================================================
|
|
20
|
+
Default Sub-Components
|
|
21
|
+
========================================================== */
|
|
22
|
+
|
|
23
|
+
/** Check icon for selected state */
|
|
24
|
+
const CheckIcon: React.FC = () => (
|
|
25
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
26
|
+
<polyline points="20 6 9 17 4 12" />
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/** Default user row */
|
|
31
|
+
const DefaultUserItem: React.FC<UserPickerItemProps> = React.memo(({
|
|
32
|
+
user, selected, disabled, mode, onToggle, AvatarComponent,
|
|
33
|
+
}) => {
|
|
34
|
+
const handleClick = useCallback(() => {
|
|
35
|
+
if (!disabled) onToggle(user);
|
|
36
|
+
}, [disabled, onToggle, user]);
|
|
37
|
+
|
|
38
|
+
const inputClass = [
|
|
39
|
+
'ermis-user-picker__input',
|
|
40
|
+
mode === 'radio' ? 'ermis-user-picker__input--radio' : 'ermis-user-picker__input--checkbox',
|
|
41
|
+
selected ? 'ermis-user-picker__input--checked' : '',
|
|
42
|
+
].join(' ');
|
|
43
|
+
|
|
44
|
+
const itemClass = [
|
|
45
|
+
'ermis-user-picker__item',
|
|
46
|
+
selected ? 'ermis-user-picker__item--selected' : '',
|
|
47
|
+
disabled ? 'ermis-user-picker__item--disabled' : '',
|
|
48
|
+
].join(' ');
|
|
49
|
+
|
|
50
|
+
const detail = user.email || user.phone || '';
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={itemClass} onClick={handleClick} role="option" aria-selected={selected}>
|
|
54
|
+
<div className={inputClass}>
|
|
55
|
+
{selected && <CheckIcon />}
|
|
56
|
+
</div>
|
|
57
|
+
<AvatarComponent image={user.avatar} name={user.name || user.id} size={36} />
|
|
58
|
+
<div className="ermis-user-picker__info">
|
|
59
|
+
<span className="ermis-user-picker__name">{user.name || user.id}</span>
|
|
60
|
+
{detail && <span className="ermis-user-picker__detail">{detail}</span>}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
DefaultUserItem.displayName = 'DefaultUserItem';
|
|
66
|
+
|
|
67
|
+
/** Default search input */
|
|
68
|
+
const DefaultSearchInput: React.FC<{ value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; placeholder: string }> = ({ value, onChange, placeholder }) => (
|
|
69
|
+
<div className="ermis-user-picker__search">
|
|
70
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
71
|
+
<circle cx="11" cy="11" r="8" />
|
|
72
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
73
|
+
</svg>
|
|
74
|
+
<input
|
|
75
|
+
type="text"
|
|
76
|
+
placeholder={placeholder}
|
|
77
|
+
value={value}
|
|
78
|
+
onChange={onChange}
|
|
79
|
+
autoFocus
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/** Default selected users chip box */
|
|
85
|
+
const DefaultSelectedBox: React.FC<UserPickerSelectedBoxProps> = React.memo(({
|
|
86
|
+
users, onRemove, AvatarComponent, emptyLabel,
|
|
87
|
+
}) => (
|
|
88
|
+
<div className="ermis-user-picker__selected-box">
|
|
89
|
+
{users.length === 0 && emptyLabel && (
|
|
90
|
+
<span className="ermis-user-picker__selected-empty">{emptyLabel}</span>
|
|
91
|
+
)}
|
|
92
|
+
{users.map(u => (
|
|
93
|
+
<div key={u.id} className="ermis-user-picker__chip">
|
|
94
|
+
<AvatarComponent image={u.avatar} name={u.name || u.id} size={20} />
|
|
95
|
+
<span className="ermis-user-picker__chip-name">{u.name || u.id}</span>
|
|
96
|
+
<button
|
|
97
|
+
className="ermis-user-picker__chip-remove"
|
|
98
|
+
onClick={() => onRemove(u.id)}
|
|
99
|
+
aria-label={`Remove ${u.name || u.id}`}
|
|
100
|
+
>
|
|
101
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
102
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
103
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
104
|
+
</svg>
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
));
|
|
110
|
+
DefaultSelectedBox.displayName = 'DefaultSelectedBox';
|
|
111
|
+
|
|
112
|
+
/* ==========================================================
|
|
113
|
+
UserPicker Component
|
|
114
|
+
========================================================== */
|
|
115
|
+
|
|
116
|
+
export const UserPicker: React.FC<UserPickerProps> = ({
|
|
117
|
+
mode,
|
|
118
|
+
onSelectionChange,
|
|
119
|
+
excludeUserIds,
|
|
120
|
+
initialSelectedUsers,
|
|
121
|
+
pageSize = DEFAULT_PAGE_SIZE,
|
|
122
|
+
AvatarComponent = Avatar,
|
|
123
|
+
UserItemComponent,
|
|
124
|
+
SelectedBoxComponent,
|
|
125
|
+
SearchInputComponent,
|
|
126
|
+
searchPlaceholder = 'Search by name, email or phone...',
|
|
127
|
+
loadingText = 'Loading users...',
|
|
128
|
+
emptyText = 'No users found.',
|
|
129
|
+
loadingMoreText = 'Loading more...',
|
|
130
|
+
selectedEmptyLabel,
|
|
131
|
+
}) => {
|
|
132
|
+
const { client } = useChatClient();
|
|
133
|
+
const currentUserId = client?.userID;
|
|
134
|
+
|
|
135
|
+
/* ---------- State ---------- */
|
|
136
|
+
const [allUsers, setAllUsers] = useState<UserPickerUser[]>([]);
|
|
137
|
+
const [page, setPage] = useState(1);
|
|
138
|
+
const [hasMore, setHasMore] = useState(true);
|
|
139
|
+
const [loading, setLoading] = useState(true);
|
|
140
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
141
|
+
|
|
142
|
+
const [remoteUsers, setRemoteUsers] = useState<UserPickerUser[]>([]);
|
|
143
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
144
|
+
|
|
145
|
+
const [searchInput, setSearchInput] = useState('');
|
|
146
|
+
const [search, setSearch] = useState('');
|
|
147
|
+
const [isPendingFilter, startTransition] = useTransition();
|
|
148
|
+
|
|
149
|
+
const [selectedMap, setSelectedMap] = useState<Map<string, UserPickerUser>>(() => {
|
|
150
|
+
const map = new Map<string, UserPickerUser>();
|
|
151
|
+
initialSelectedUsers?.forEach(u => map.set(u.id, u));
|
|
152
|
+
return map;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const vlistRef = useRef<VListHandle>(null);
|
|
156
|
+
|
|
157
|
+
/* ---------- Resolved sub-components ---------- */
|
|
158
|
+
const UserRow = UserItemComponent || DefaultUserItem;
|
|
159
|
+
const SearchInput = SearchInputComponent || DefaultSearchInput;
|
|
160
|
+
const SelectedBox = SelectedBoxComponent || DefaultSelectedBox;
|
|
161
|
+
|
|
162
|
+
/* ---------- Excluded IDs set ---------- */
|
|
163
|
+
const excludeSet = useMemo(() => new Set(excludeUserIds || []), [excludeUserIds]);
|
|
164
|
+
|
|
165
|
+
/* ---------- Search handler ---------- */
|
|
166
|
+
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
167
|
+
const val = e.target.value;
|
|
168
|
+
setSearchInput(val);
|
|
169
|
+
startTransition(() => {
|
|
170
|
+
setSearch(val);
|
|
171
|
+
});
|
|
172
|
+
}, [startTransition]);
|
|
173
|
+
|
|
174
|
+
/* ---------- 1. Fetch initial page ---------- */
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
let active = true;
|
|
177
|
+
const fetchUsers = async () => {
|
|
178
|
+
if (!client) return;
|
|
179
|
+
try {
|
|
180
|
+
setLoading(true);
|
|
181
|
+
const response = await client.queryUsers(String(pageSize), 1);
|
|
182
|
+
if (active && response.data) {
|
|
183
|
+
setAllUsers(response.data);
|
|
184
|
+
setHasMore(response.data.length >= pageSize);
|
|
185
|
+
setPage(1);
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error('[UserPicker] Error fetching users:', err);
|
|
189
|
+
} finally {
|
|
190
|
+
if (active) setLoading(false);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
fetchUsers();
|
|
194
|
+
return () => { active = false; };
|
|
195
|
+
}, [client, pageSize]);
|
|
196
|
+
|
|
197
|
+
/* ---------- 2. Load more (infinite scroll) ---------- */
|
|
198
|
+
const loadMore = useCallback(async () => {
|
|
199
|
+
if (!client || loadingMore || !hasMore || search.trim()) return;
|
|
200
|
+
const nextPage = page + 1;
|
|
201
|
+
setLoadingMore(true);
|
|
202
|
+
try {
|
|
203
|
+
const response = await client.queryUsers(String(pageSize), nextPage);
|
|
204
|
+
if (response.data) {
|
|
205
|
+
setAllUsers(prev => {
|
|
206
|
+
const existingIds = new Set(prev.map(u => u.id));
|
|
207
|
+
const newUsers = response.data.filter((u: UserPickerUser) => !existingIds.has(u.id));
|
|
208
|
+
return [...prev, ...newUsers];
|
|
209
|
+
});
|
|
210
|
+
setHasMore(response.data.length >= pageSize);
|
|
211
|
+
setPage(nextPage);
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error('[UserPicker] Error loading more users:', err);
|
|
215
|
+
} finally {
|
|
216
|
+
setLoadingMore(false);
|
|
217
|
+
}
|
|
218
|
+
}, [client, loadingMore, hasMore, page, pageSize, search]);
|
|
219
|
+
|
|
220
|
+
/* ---------- 3. Local filter ---------- */
|
|
221
|
+
const localFilteredUsers = useMemo(() => {
|
|
222
|
+
const term = search.toLowerCase().trim();
|
|
223
|
+
if (!term) return allUsers;
|
|
224
|
+
return allUsers.filter(u => {
|
|
225
|
+
const name = (u.name || '').toLowerCase();
|
|
226
|
+
const email = (u.email || '').toLowerCase();
|
|
227
|
+
const phone = (u.phone || '').toLowerCase();
|
|
228
|
+
return name.includes(term) || email.includes(term) || phone.includes(term);
|
|
229
|
+
});
|
|
230
|
+
}, [search, allUsers]);
|
|
231
|
+
|
|
232
|
+
/* ---------- 4. Remote search fallback ---------- */
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (!search.trim() || localFilteredUsers.length > 0) {
|
|
235
|
+
setRemoteUsers([]);
|
|
236
|
+
setIsSearching(false);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let cancelled = false;
|
|
241
|
+
const timer = setTimeout(async () => {
|
|
242
|
+
setIsSearching(true);
|
|
243
|
+
try {
|
|
244
|
+
const response = await client.searchUsers(1, 25, search.trim());
|
|
245
|
+
if (!cancelled && response.data) {
|
|
246
|
+
setRemoteUsers(response.data);
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
console.error('[UserPicker] Error searching remote users:', err);
|
|
250
|
+
} finally {
|
|
251
|
+
if (!cancelled) setIsSearching(false);
|
|
252
|
+
}
|
|
253
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
254
|
+
|
|
255
|
+
return () => {
|
|
256
|
+
cancelled = true;
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
};
|
|
259
|
+
}, [search, localFilteredUsers.length, client, excludeSet]);
|
|
260
|
+
|
|
261
|
+
/* ---------- 5. Derived display list ---------- */
|
|
262
|
+
const usersToDisplay = (search.trim() && localFilteredUsers.length === 0)
|
|
263
|
+
? remoteUsers
|
|
264
|
+
: localFilteredUsers;
|
|
265
|
+
const isListLoading = loading || isSearching || isPendingFilter;
|
|
266
|
+
|
|
267
|
+
/* ---------- 6. Selection handlers ---------- */
|
|
268
|
+
const handleToggle = useCallback((user: UserPickerUser) => {
|
|
269
|
+
// Don't allow toggling disabled users (current user or excluded)
|
|
270
|
+
if (user.id === currentUserId || excludeSet.has(user.id)) return;
|
|
271
|
+
|
|
272
|
+
setSelectedMap(prev => {
|
|
273
|
+
const next = new Map(prev);
|
|
274
|
+
if (mode === 'radio') {
|
|
275
|
+
// Radio: clear all, set this one (or deselect if same)
|
|
276
|
+
if (next.has(user.id)) {
|
|
277
|
+
next.clear();
|
|
278
|
+
} else {
|
|
279
|
+
next.clear();
|
|
280
|
+
next.set(user.id, user);
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
// Checkbox: toggle
|
|
284
|
+
if (next.has(user.id)) {
|
|
285
|
+
next.delete(user.id);
|
|
286
|
+
} else {
|
|
287
|
+
next.set(user.id, user);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return next;
|
|
291
|
+
});
|
|
292
|
+
}, [mode, currentUserId, excludeSet]);
|
|
293
|
+
|
|
294
|
+
// Notify parent of selection changes
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
onSelectionChange?.(Array.from(selectedMap.values()));
|
|
297
|
+
}, [selectedMap, onSelectionChange]);
|
|
298
|
+
|
|
299
|
+
const handleRemoveSelected = useCallback((userId: string) => {
|
|
300
|
+
setSelectedMap(prev => {
|
|
301
|
+
const next = new Map(prev);
|
|
302
|
+
next.delete(userId);
|
|
303
|
+
return next;
|
|
304
|
+
});
|
|
305
|
+
}, []);
|
|
306
|
+
|
|
307
|
+
/* ---------- 7. Scroll handler for infinite scroll ---------- */
|
|
308
|
+
const handleScroll = useCallback((offset: number) => {
|
|
309
|
+
// VList provides scroll offset. We detect near-bottom using the ref
|
|
310
|
+
const el = vlistRef.current;
|
|
311
|
+
if (!el) return;
|
|
312
|
+
// scrollSize = total scroll height, viewportSize = visible height
|
|
313
|
+
const scrollSize = (el as any).scrollSize ?? 0;
|
|
314
|
+
const viewportSize = (el as any).viewportSize ?? 0;
|
|
315
|
+
if (scrollSize > 0 && offset + viewportSize >= scrollSize - 50) {
|
|
316
|
+
loadMore();
|
|
317
|
+
}
|
|
318
|
+
}, [loadMore]);
|
|
319
|
+
|
|
320
|
+
/* ---------- Render ---------- */
|
|
321
|
+
const selectedArr = useMemo(() => Array.from(selectedMap.values()), [selectedMap]);
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<div className="ermis-user-picker" role="listbox" aria-multiselectable={mode === 'checkbox'}>
|
|
325
|
+
{/* Selected Users Box (checkbox mode only) */}
|
|
326
|
+
{mode === 'checkbox' && (
|
|
327
|
+
<SelectedBox
|
|
328
|
+
users={selectedArr}
|
|
329
|
+
onRemove={handleRemoveSelected}
|
|
330
|
+
AvatarComponent={AvatarComponent}
|
|
331
|
+
emptyLabel={selectedEmptyLabel}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
|
|
335
|
+
{/* Search Input */}
|
|
336
|
+
<SearchInput
|
|
337
|
+
value={searchInput}
|
|
338
|
+
onChange={handleSearchChange}
|
|
339
|
+
placeholder={searchPlaceholder}
|
|
340
|
+
/>
|
|
341
|
+
|
|
342
|
+
{/* User List */}
|
|
343
|
+
<div className="ermis-user-picker__list">
|
|
344
|
+
{isListLoading ? (
|
|
345
|
+
<div className="ermis-user-picker__loading">
|
|
346
|
+
<span className="ermis-user-picker__spinner" />
|
|
347
|
+
{loadingText}
|
|
348
|
+
</div>
|
|
349
|
+
) : usersToDisplay.length === 0 ? (
|
|
350
|
+
<div className="ermis-user-picker__empty">{emptyText}</div>
|
|
351
|
+
) : (
|
|
352
|
+
<VList ref={vlistRef} style={LIST_STYLE} onScroll={handleScroll}>
|
|
353
|
+
{usersToDisplay.map(user => (
|
|
354
|
+
<UserRow
|
|
355
|
+
key={user.id}
|
|
356
|
+
user={user}
|
|
357
|
+
selected={selectedMap.has(user.id)}
|
|
358
|
+
disabled={user.id === currentUserId || excludeSet.has(user.id)}
|
|
359
|
+
mode={mode}
|
|
360
|
+
onToggle={handleToggle}
|
|
361
|
+
AvatarComponent={AvatarComponent}
|
|
362
|
+
/>
|
|
363
|
+
))}
|
|
364
|
+
{loadingMore && (
|
|
365
|
+
<div className="ermis-user-picker__load-more">
|
|
366
|
+
<span className="ermis-user-picker__spinner" />
|
|
367
|
+
{loadingMoreText}
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
</VList>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
UserPicker.displayName = 'UserPicker';
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import React, { createContext, useState, useCallback } from 'react';
|
|
2
2
|
import type { Channel, FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
3
3
|
import type { Theme, ChatContextValue, ChatProviderProps, ReadStateEntry } from '../types';
|
|
4
|
+
import { ErmisCallProvider } from '../components/ErmisCallProvider';
|
|
5
|
+
import { ErmisCallUI } from '../components/ErmisCallUI';
|
|
4
6
|
|
|
5
7
|
export type { Theme, ChatContextValue, ChatProviderProps } from '../types';
|
|
6
8
|
|
|
@@ -10,6 +12,19 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
10
12
|
client,
|
|
11
13
|
children,
|
|
12
14
|
initialTheme = 'light',
|
|
15
|
+
enableCall = false,
|
|
16
|
+
callSessionId,
|
|
17
|
+
callWasmPath,
|
|
18
|
+
callRelayUrl,
|
|
19
|
+
CallUIComponent,
|
|
20
|
+
incomingCallAudioPath,
|
|
21
|
+
outgoingCallAudioPath,
|
|
22
|
+
onCallStart,
|
|
23
|
+
onCallEnd,
|
|
24
|
+
onCallError,
|
|
25
|
+
onIncomingCall,
|
|
26
|
+
onCallAccepted,
|
|
27
|
+
onCallRejected,
|
|
13
28
|
}) => {
|
|
14
29
|
const [activeChannelRaw, setActiveChannelRaw] = useState<Channel | null>(null);
|
|
15
30
|
const [theme, setTheme] = useState<Theme>(initialTheme);
|
|
@@ -61,13 +76,46 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
61
76
|
setForwardingMessage,
|
|
62
77
|
jumpToMessageId,
|
|
63
78
|
setJumpToMessageId,
|
|
79
|
+
enableCall,
|
|
64
80
|
};
|
|
65
81
|
|
|
66
|
-
|
|
82
|
+
const CallUIView = CallUIComponent ? <CallUIComponent /> : (
|
|
83
|
+
<ErmisCallUI
|
|
84
|
+
incomingCallAudioPath={incomingCallAudioPath}
|
|
85
|
+
outgoingCallAudioPath={outgoingCallAudioPath}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const content = (
|
|
67
90
|
<ChatContext.Provider value={value}>
|
|
68
91
|
<div className={`ermis-chat ermis-chat--${theme}`}>
|
|
69
92
|
{children}
|
|
93
|
+
{enableCall && CallUIView}
|
|
70
94
|
</div>
|
|
71
95
|
</ChatContext.Provider>
|
|
72
96
|
);
|
|
97
|
+
|
|
98
|
+
if (enableCall) {
|
|
99
|
+
if (!callSessionId) {
|
|
100
|
+
console.warn('ErmisChat React: enableCall is true but callSessionId is missing.');
|
|
101
|
+
}
|
|
102
|
+
return (
|
|
103
|
+
<ErmisCallProvider
|
|
104
|
+
client={client}
|
|
105
|
+
sessionId={callSessionId || ''}
|
|
106
|
+
wasmPath={callWasmPath}
|
|
107
|
+
relayUrl={callRelayUrl}
|
|
108
|
+
onCallStart={onCallStart}
|
|
109
|
+
onCallEnd={onCallEnd}
|
|
110
|
+
onCallError={onCallError}
|
|
111
|
+
onIncomingCall={onIncomingCall}
|
|
112
|
+
onCallAccepted={onCallAccepted}
|
|
113
|
+
onCallRejected={onCallRejected}
|
|
114
|
+
>
|
|
115
|
+
{content}
|
|
116
|
+
</ErmisCallProvider>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return content;
|
|
73
121
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { CallStatus, type ErmisCallNode, type UserCallInfo } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
|
|
4
|
+
export type CallContextValue = {
|
|
5
|
+
callNode: ErmisCallNode | null;
|
|
6
|
+
callStatus: CallStatus | '';
|
|
7
|
+
localStream: MediaStream | null;
|
|
8
|
+
remoteStream: MediaStream | null;
|
|
9
|
+
callType: string;
|
|
10
|
+
callerInfo?: UserCallInfo | undefined;
|
|
11
|
+
receiverInfo?: UserCallInfo | undefined;
|
|
12
|
+
isIncoming: boolean;
|
|
13
|
+
createCall: (type: 'audio' | 'video', cid: string) => Promise<void>;
|
|
14
|
+
acceptCall: () => Promise<void>;
|
|
15
|
+
rejectCall: () => Promise<void>;
|
|
16
|
+
endCall: () => Promise<void>;
|
|
17
|
+
toggleMic: () => void;
|
|
18
|
+
toggleVideo: () => void;
|
|
19
|
+
isMicMuted: boolean;
|
|
20
|
+
isVideoMuted: boolean;
|
|
21
|
+
audioDevices: MediaDeviceInfo[];
|
|
22
|
+
videoDevices: MediaDeviceInfo[];
|
|
23
|
+
selectedAudioDeviceId: string;
|
|
24
|
+
selectedVideoDeviceId: string;
|
|
25
|
+
isScreenSharing: boolean;
|
|
26
|
+
errorMessage: string | null;
|
|
27
|
+
toggleScreenShare: () => Promise<void>;
|
|
28
|
+
switchAudioDevice: (id: string) => Promise<void>;
|
|
29
|
+
switchVideoDevice: (id: string) => Promise<void>;
|
|
30
|
+
clearError: () => void;
|
|
31
|
+
isRemoteMicMuted: boolean;
|
|
32
|
+
isRemoteVideoMuted: boolean;
|
|
33
|
+
upgradeCall: () => Promise<void>;
|
|
34
|
+
callDuration: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const ErmisCallContext = React.createContext<CallContextValue | undefined>(undefined);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { ErmisCallContext } from '../context/ErmisCallContext';
|
|
3
|
+
|
|
4
|
+
export const useCallContext = () => {
|
|
5
|
+
const context = useContext(ErmisCallContext);
|
|
6
|
+
if (context === undefined) {
|
|
7
|
+
throw new Error('useCallContext must be used within an ErmisCallProvider');
|
|
8
|
+
}
|
|
9
|
+
return context;
|
|
10
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -127,3 +127,30 @@ export type {
|
|
|
127
127
|
AddMemberUserItemProps,
|
|
128
128
|
AddMemberButtonProps,
|
|
129
129
|
} from './types';
|
|
130
|
+
|
|
131
|
+
export { UserPicker } from './components/UserPicker';
|
|
132
|
+
export type {
|
|
133
|
+
UserPickerProps,
|
|
134
|
+
UserPickerUser,
|
|
135
|
+
UserPickerItemProps,
|
|
136
|
+
UserPickerSelectedBoxProps,
|
|
137
|
+
} from './types';
|
|
138
|
+
|
|
139
|
+
export { CreateChannelModal } from './components/CreateChannelModal';
|
|
140
|
+
export type { CreateChannelModalProps } from './types';
|
|
141
|
+
|
|
142
|
+
// Call Components
|
|
143
|
+
export { ErmisCallContext } from './context/ErmisCallContext';
|
|
144
|
+
export type { CallContextValue } from './context/ErmisCallContext';
|
|
145
|
+
export { useCallContext } from './hooks/useCallContext';
|
|
146
|
+
export { ErmisCallProvider } from './components/ErmisCallProvider';
|
|
147
|
+
export type { ErmisCallProviderProps } from './components/ErmisCallProvider';
|
|
148
|
+
export { ErmisCallUI } from './components/ErmisCallUI';
|
|
149
|
+
export type {
|
|
150
|
+
ErmisCallUIProps,
|
|
151
|
+
ErmisCallRingingProps,
|
|
152
|
+
ErmisCallConnectedAudioProps,
|
|
153
|
+
ErmisCallConnectedVideoProps,
|
|
154
|
+
ErmisCallErrorProps,
|
|
155
|
+
ErmisCallControlsBarProps,
|
|
156
|
+
} from './types';
|