@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
@@ -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 { activeChannel } = useChatClient();
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 parsedText = rawText ? parseSignalMessage(rawText, userMap) : '';
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
- <span className="ermis-message-list__signal-text">
496
- {parsedText || rawText}
497
- </span>
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
 
@@ -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
- return (
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';