@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ermis-network/ermis-chat-react",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "React UI components for Ermis Chat",
5
5
  "author": "Ermis",
6
6
  "homepage": "https://ermis.network/",
@@ -20,7 +20,7 @@
20
20
  "/src"
21
21
  ],
22
22
  "dependencies": {
23
- "@ermis-network/ermis-chat-sdk": "1.0.0",
23
+ "@ermis-network/ermis-chat-sdk": "1.0.3",
24
24
  "virtua": "^0.48.8"
25
25
  },
26
26
  "peerDependencies": {
@@ -1,10 +1,9 @@
1
- import React, { useMemo, useState, useEffect } from 'react';
1
+ import React, { useMemo, useState, useEffect, useContext } from 'react';
2
2
  import { useChatClient } from '../hooks/useChatClient';
3
3
  import { usePendingState } from '../hooks/usePendingState';
4
- import { useBannedState } from '../hooks/useBannedState';
5
- import { useBlockedState } from '../hooks/useBlockedState';
6
4
  import { Avatar } from './Avatar';
7
5
  import type { ChannelHeaderProps } from '../types';
6
+ import { ErmisCallContext } from '../context/ErmisCallContext';
8
7
 
9
8
  export type { ChannelHeaderProps } from '../types';
10
9
 
@@ -28,9 +27,15 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
28
27
  subtitle,
29
28
  renderRight,
30
29
  renderTitle,
30
+ renderAudioCallButton,
31
+ renderVideoCallButton,
32
+ audioCallTitle = 'Audio Call',
33
+ videoCallTitle = 'Video Call',
34
+ CallBadgeComponent,
31
35
  }) => {
32
- const { activeChannel, client } = useChatClient();
36
+ const { activeChannel, client, enableCall } = useChatClient();
33
37
  const { isPending } = usePendingState(activeChannel, client.userID);
38
+ const callContext = useContext(ErmisCallContext);
34
39
 
35
40
  const actionDisabled = isPending;
36
41
 
@@ -73,11 +78,47 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
73
78
  </div>
74
79
 
75
80
  {/* renderRight exposes actionDisabled for consumers to disable UI features natively */}
76
- {renderRight && (
77
- <div className="ermis-channel-header__actions">
78
- {renderRight(activeChannel, actionDisabled)}
79
- </div>
80
- )}
81
+ <div className="ermis-channel-header__actions">
82
+ {enableCall && callContext && activeChannel?.type === 'messaging' && !isPending && (
83
+ <>
84
+ {renderAudioCallButton ? (
85
+ renderAudioCallButton(() => callContext.createCall('audio', activeChannel.cid || ''), actionDisabled)
86
+ ) : (
87
+ <button
88
+ className="ermis-btn ermis-btn--icon"
89
+ disabled={actionDisabled}
90
+ onClick={() => callContext.createCall('audio', activeChannel.cid || '')}
91
+ title={audioCallTitle}
92
+ >
93
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
94
+ <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>
95
+ </svg>
96
+ </button>
97
+ )}
98
+
99
+ {renderVideoCallButton ? (
100
+ renderVideoCallButton(() => callContext.createCall('video', activeChannel.cid || ''), actionDisabled)
101
+ ) : (
102
+ <button
103
+ className="ermis-btn ermis-btn--icon"
104
+ disabled={actionDisabled}
105
+ onClick={() => callContext.createCall('video', activeChannel.cid || '')}
106
+ title={videoCallTitle}
107
+ >
108
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
109
+ <polygon points="23 7 16 12 23 17 23 7"></polygon>
110
+ <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
111
+ </svg>
112
+ </button>
113
+ )}
114
+ </>
115
+ )}
116
+ {/* C8: Active call badge */}
117
+ {enableCall && callContext && callContext.callStatus && CallBadgeComponent && (
118
+ <CallBadgeComponent callType={callContext.callType} />
119
+ )}
120
+ {renderRight && renderRight(activeChannel, actionDisabled)}
121
+ </div>
81
122
  </div>
82
123
  );
83
124
  });
@@ -1,58 +1,14 @@
1
- import React, { useState, useEffect, useMemo, useCallback, useTransition } from 'react';
2
- import { useChatClient } from '../../hooks/useChatClient';
1
+ import React, { useState, useMemo, useCallback } from 'react';
3
2
  import { Modal } from '../Modal';
4
- import { VList } from 'virtua';
5
- import type { AddMemberModalProps, AddMemberUserItemProps } from '../../types';
6
-
7
- /* ---------- Static styles hoisted outside render ---------- */
8
- const LIST_CONTAINER_STYLE: React.CSSProperties = { overflow: 'hidden', height: '400px' };
9
- const VLIST_STYLE: React.CSSProperties = { height: '100%' };
10
- const DISABLED_BTN_STYLE: React.CSSProperties = { opacity: 0.5, cursor: 'not-allowed' };
11
-
12
- /* ---------- Default user row ---------- */
13
- const DefaultUserItem: React.FC<AddMemberUserItemProps> = React.memo(({
14
- user, isExisting, isAdding, onAdd, AvatarComponent,
15
- addedLabel = 'Added', addingLabel = 'Adding...', addLabel = 'Add',
16
- }) => (
17
- <div className="ermis-modal-user-item">
18
- <AvatarComponent image={user.avatar} name={user.name || user.id} size={36} />
19
- <div className="ermis-modal-user-info">
20
- <span className="ermis-modal-user-name">{user.name || user.id}</span>
21
- </div>
22
- <button
23
- className={`ermis-modal-add-btn ${isExisting ? 'ermis-modal-add-btn--disabled' : ''}`}
24
- onClick={() => onAdd(user.id)}
25
- disabled={isAdding || isExisting}
26
- style={isExisting ? DISABLED_BTN_STYLE : undefined}
27
- >
28
- {isExisting ? addedLabel : (isAdding ? addingLabel : addLabel)}
29
- </button>
30
- </div>
31
- ));
32
- DefaultUserItem.displayName = 'DefaultUserItem';
33
-
34
- /* ---------- Default search input ---------- */
35
- const DefaultSearchInput: React.FC<{ value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; placeholder: string }> = ({ value, onChange, placeholder }) => (
36
- <div className="ermis-modal-search">
37
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
38
- <circle cx="11" cy="11" r="8"></circle>
39
- <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
40
- </svg>
41
- <input
42
- type="text"
43
- placeholder={placeholder}
44
- value={value}
45
- onChange={onChange}
46
- autoFocus
47
- />
48
- </div>
49
- );
3
+ import { UserPicker } from '../UserPicker';
4
+ import { Avatar } from '../Avatar';
5
+ import type { AddMemberModalProps, UserPickerUser } from '../../types';
50
6
 
51
7
  export const AddMemberModal: React.FC<AddMemberModalProps> = ({
52
8
  channel,
53
9
  currentMembers,
54
10
  onClose,
55
- AvatarComponent,
11
+ AvatarComponent = Avatar,
56
12
  title = 'Add Member',
57
13
  searchPlaceholder = 'Search by name, email or phone...',
58
14
  loadingText = 'Loading users...',
@@ -63,142 +19,60 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({
63
19
  UserItemComponent,
64
20
  SearchInputComponent,
65
21
  }) => {
66
- const { client } = useChatClient();
67
- const [initialUsers, setInitialUsers] = useState<any[]>([]);
68
- const [remoteUsers, setRemoteUsers] = useState<any[]>([]);
69
- const [searchInput, setSearchInput] = useState('');
70
- const [search, setSearch] = useState('');
71
- const [isPendingFilter, startTransition] = useTransition();
72
-
73
- const [loading, setLoading] = useState(true);
74
- const [isSearching, setIsSearching] = useState(false);
75
- const [addingUser, setAddingUser] = useState<string | null>(null);
76
- const [justAdded, setJustAdded] = useState<Set<string>>(new Set());
77
-
78
- const UserRow = UserItemComponent || DefaultUserItem;
79
- const SearchInput = SearchInputComponent || DefaultSearchInput;
80
-
81
- // Handle immediate input + deferred filter via transition
82
- const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
83
- const val = e.target.value;
84
- setSearchInput(val);
85
- startTransition(() => {
86
- setSearch(val);
87
- });
88
- }, [startTransition]);
89
-
90
- // 1. Fetch initial 100 users
91
- useEffect(() => {
92
- let active = true;
93
- const fetchUsers = async () => {
94
- if (!client) return;
95
- try {
96
- const response = await client.queryUsers('100', 1);
97
- if (active && response.data) {
98
- setInitialUsers(response.data);
99
- }
100
- } catch (err) {
101
- console.error('Error fetching users:', err);
102
- } finally {
103
- if (active) setLoading(false);
104
- }
105
- };
106
- fetchUsers();
107
- return () => { active = false; };
108
- }, [client]);
109
-
110
- // 2. Local filter by name/email/phone
111
- const localFilteredUsers = useMemo(() => {
112
- if (!search.trim()) return initialUsers;
113
- const term = search.toLowerCase().trim();
114
- return initialUsers.filter(u => {
115
- const email = (u.email || '').toLowerCase();
116
- const phone = (u.phone || '').toLowerCase();
117
- const name = (u.name || '').toLowerCase();
118
- return email.includes(term) || phone.includes(term) || name.includes(term);
119
- });
120
- }, [search, initialUsers]);
22
+ const [selectedUsers, setSelectedUsers] = useState<UserPickerUser[]>([]);
23
+ const [isAdding, setIsAdding] = useState(false);
121
24
 
122
- // 3. Remote search fallback (with race-condition guard)
123
- useEffect(() => {
124
- if (!search.trim() || localFilteredUsers.length > 0) {
125
- setRemoteUsers([]);
126
- setIsSearching(false);
127
- return;
128
- }
129
-
130
- let cancelled = false;
131
- const timer = setTimeout(async () => {
132
- setIsSearching(true);
133
- try {
134
- const response = await client.searchUsers(1, 25, search.trim());
135
- if (!cancelled && response.data) {
136
- setRemoteUsers(response.data);
137
- }
138
- } catch (err) {
139
- console.error('Error searching remote users:', err);
140
- } finally {
141
- if (!cancelled) setIsSearching(false);
142
- }
143
- }, 500);
144
-
145
- return () => {
146
- cancelled = true;
147
- clearTimeout(timer);
148
- };
149
- }, [search, localFilteredUsers.length, client]);
25
+ // Exclude existing members from the picker
26
+ const excludeUserIds = useMemo(
27
+ () => currentMembers.map((m: any) => m.user_id),
28
+ [currentMembers],
29
+ );
150
30
 
151
- // 4. Derived state
152
- const usersToDisplay = (search.trim() && localFilteredUsers.length === 0) ? remoteUsers : localFilteredUsers;
153
- const isListLoading = loading || isSearching || isPendingFilter;
154
31
 
155
- const existingMemberIds = useMemo(() => {
156
- return new Set(currentMembers.map((m: any) => m.user_id));
157
- }, [currentMembers]);
32
+ const handleSelectionChange = useCallback((users: UserPickerUser[]) => {
33
+ setSelectedUsers(users);
34
+ }, []);
158
35
 
159
- const handleAdd = useCallback(async (userId: string) => {
36
+ const handleAdd = useCallback(async () => {
37
+ if (selectedUsers.length === 0 || isAdding) return;
160
38
  try {
161
- setAddingUser(userId);
162
- await channel.addMembers([userId]);
163
- setJustAdded(prev => new Set(prev).add(userId));
39
+ setIsAdding(true);
40
+ await channel.addMembers(selectedUsers.map(u => u.id));
41
+ onClose();
164
42
  } catch (err) {
165
- console.error('Failed to add member:', err);
43
+ console.error('Failed to add members:', err);
166
44
  } finally {
167
- setAddingUser(null);
45
+ setIsAdding(false);
168
46
  }
169
- }, [channel]);
47
+ }, [selectedUsers, isAdding, channel, onClose]);
48
+
49
+ const footer = (
50
+ <button
51
+ className="ermis-modal-add-btn"
52
+ onClick={handleAdd}
53
+ disabled={selectedUsers.length === 0 || isAdding}
54
+ >
55
+ {isAdding
56
+ ? addingLabel
57
+ : `${addLabel} ${selectedUsers.length > 0 ? `(${selectedUsers.length})` : ''}`}
58
+ </button>
59
+ );
170
60
 
171
61
  return (
172
- <Modal isOpen onClose={onClose} title={title} maxWidth="480px">
173
- <SearchInput
174
- value={searchInput}
175
- onChange={handleSearchChange}
176
- placeholder={searchPlaceholder}
62
+ <Modal isOpen onClose={onClose} title={title} maxWidth="480px" footer={footer}>
63
+ <UserPicker
64
+ mode="checkbox"
65
+ onSelectionChange={handleSelectionChange}
66
+ excludeUserIds={excludeUserIds}
67
+ pageSize={30}
68
+ AvatarComponent={AvatarComponent}
69
+ UserItemComponent={UserItemComponent as any}
70
+ SearchInputComponent={SearchInputComponent}
71
+ searchPlaceholder={searchPlaceholder}
72
+ loadingText={loadingText}
73
+ emptyText={emptyText}
74
+ selectedEmptyLabel="Select users to add..."
177
75
  />
178
-
179
- <div className="ermis-modal-user-list" style={LIST_CONTAINER_STYLE}>
180
- {isListLoading ? (
181
- <div className="ermis-modal-loading">{loadingText}</div>
182
- ) : usersToDisplay.length === 0 ? (
183
- <div className="ermis-modal-empty">{emptyText}</div>
184
- ) : (
185
- <VList style={VLIST_STYLE}>
186
- {usersToDisplay.map(user => (
187
- <UserRow
188
- key={user.id}
189
- user={user}
190
- isExisting={existingMemberIds.has(user.id) || justAdded.has(user.id)}
191
- isAdding={addingUser === user.id}
192
- onAdd={handleAdd}
193
- AvatarComponent={AvatarComponent}
194
- addedLabel={addedLabel}
195
- addingLabel={addingLabel}
196
- addLabel={addLabel}
197
- />
198
- ))}
199
- </VList>
200
- )}
201
- </div>
202
76
  </Modal>
203
77
  );
204
78
  };
@@ -18,6 +18,7 @@ export type { ChannelListProps, ChannelItemProps } from '../types';
18
18
  */
19
19
  function getLastMessagePreview(
20
20
  channel: Channel,
21
+ myUserId?: string,
21
22
  ): { text: string; user: string } {
22
23
  const lastMsg = channel.state?.latestMessages?.slice(-1)[0];
23
24
  if (!lastMsg) return { text: '', user: '' };
@@ -31,8 +32,13 @@ function getLastMessagePreview(
31
32
  }
32
33
 
33
34
  if (msgType === 'signal') {
34
- const userMap = buildUserMap(channel.state);
35
- return { text: parseSignalMessage(rawText, userMap), user: '' };
35
+ const result = parseSignalMessage(rawText, myUserId || '');
36
+ return { text: result?.text || rawText, user: '' };
37
+ }
38
+
39
+ // Display 'Sticker' if message is a sticker
40
+ if (msgType === 'sticker' || (lastMsg as Record<string, unknown>).sticker_url) {
41
+ return { text: 'Sticker', user: lastMsg.user?.name || lastMsg.user_id || '' };
36
42
  }
37
43
 
38
44
  // Regular / other
@@ -201,7 +207,7 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
201
207
  // Derive last message preview computation is deferred here,
202
208
  // so it only executes when VList actually mounts this visible item
203
209
  const { text: rawLastMessageText, user: rawLastMessageUser } = useMemo(
204
- () => getLastMessagePreview(channel),
210
+ () => getLastMessagePreview(channel, currentUserId),
205
211
  // Recompute if latestMessage changes or we get a force update
206
212
  // eslint-disable-next-line react-hooks/exhaustive-deps
207
213
  [channel, channel.state?.latestMessages, updateCount]
@@ -0,0 +1,274 @@
1
+ import React, { useState, useMemo, useCallback } from 'react';
2
+ import { Modal } from './Modal';
3
+ import { UserPicker } from './UserPicker';
4
+ import { Avatar } from './Avatar';
5
+ import { useChatClient } from '../hooks/useChatClient';
6
+ import type { CreateChannelModalProps, UserPickerUser } from '../types';
7
+
8
+
9
+ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
10
+ isOpen,
11
+ onClose,
12
+ onSuccess,
13
+ AvatarComponent = Avatar,
14
+ UserItemComponent,
15
+ title = 'New Message',
16
+ directTabLabel = 'Direct',
17
+ groupTabLabel = 'Group',
18
+ groupNameLabel = 'Channel Name',
19
+ groupNamePlaceholder = 'Enter channel name (required)',
20
+ groupDescriptionLabel = 'Description',
21
+ groupDescriptionPlaceholder = 'Optional description',
22
+ groupPublicLabel = 'Public Channel',
23
+ userSearchPlaceholder = 'Search users...',
24
+ cancelButtonLabel = 'Cancel',
25
+ createButtonLabel = 'Create',
26
+ creatingButtonLabel = 'Creating...',
27
+ }) => {
28
+ const { client } = useChatClient();
29
+ const currentUserId = client?.userID;
30
+
31
+ /* ---------- State ---------- */
32
+ const [tab, setTab] = useState<'messaging' | 'team'>('messaging');
33
+ const [step, setStep] = useState<1 | 2>(1); // Only for team channel
34
+
35
+ // Group specific
36
+ const [name, setName] = useState('');
37
+ const [description, setDescription] = useState('');
38
+ const [isPublic, setIsPublic] = useState(false);
39
+
40
+ // Users
41
+ const [selectedUsers, setSelectedUsers] = useState<UserPickerUser[]>([]);
42
+
43
+ // Progress/Error
44
+ const [isCreating, setIsCreating] = useState(false);
45
+ const [error, setError] = useState<string | null>(null);
46
+
47
+ /* ---------- Exclude IDs for Direct ---------- */
48
+ const existingDirectUserIds = useMemo(() => {
49
+ if (!client || !currentUserId || tab !== 'messaging') return [];
50
+
51
+ const ids = new Set<string>();
52
+ Object.values(client.activeChannels).forEach((channel: any) => {
53
+ if (channel.type === 'messaging' && channel.state?.members) {
54
+ Object.keys(channel.state.members).forEach(uid => {
55
+ if (uid !== currentUserId) ids.add(uid);
56
+ });
57
+ }
58
+ });
59
+ return Array.from(ids);
60
+ }, [client, currentUserId, tab]);
61
+
62
+ /* ---------- Handlers ---------- */
63
+ const handleCreate = useCallback(async () => {
64
+ if (!client || !currentUserId || isCreating) return;
65
+
66
+ // Validations
67
+ if (selectedUsers.length === 0) {
68
+ setError('Please select at least one user.');
69
+ return;
70
+ }
71
+
72
+ if (tab === 'team' && !name.trim()) {
73
+ setError('Group name is required.');
74
+ return;
75
+ }
76
+
77
+ setIsCreating(true);
78
+ setError(null);
79
+
80
+ try {
81
+ let createdChannel;
82
+
83
+ if (tab === 'messaging') {
84
+ const targetUserId = selectedUsers[0].id;
85
+ createdChannel = client.channel('messaging', {
86
+ members: [currentUserId, targetUserId],
87
+ } as any);
88
+ await createdChannel.create();
89
+ } else {
90
+ // Group Channel
91
+ const memberIds = selectedUsers.map(member => member.id);
92
+ // Ensure current user is in the group members
93
+ if (!memberIds.includes(currentUserId)) {
94
+ memberIds.push(currentUserId);
95
+ }
96
+
97
+ const payload: any = {
98
+ name: name.trim(),
99
+ members: memberIds,
100
+ public: isPublic,
101
+ };
102
+
103
+ if (description.trim()) {
104
+ payload.description = description.trim();
105
+ }
106
+
107
+ createdChannel = client.channel('team', payload);
108
+ await createdChannel.create();
109
+ }
110
+
111
+ // Cleanup and execute callback
112
+ if (onSuccess) {
113
+ onSuccess(createdChannel);
114
+ } else {
115
+ onClose();
116
+ }
117
+
118
+ } catch (err: any) {
119
+ setError(err?.message || 'Failed to create channel');
120
+ } finally {
121
+ setIsCreating(false);
122
+ }
123
+ }, [client, currentUserId, isCreating, selectedUsers, tab, name, isPublic, description, onSuccess, onClose]);
124
+
125
+
126
+ const isValid = useMemo(() => {
127
+ if (tab === 'messaging' && selectedUsers.length === 0) return false;
128
+ if (tab === 'team' && step === 1 && !name.trim()) return false;
129
+ if (tab === 'team' && step === 2 && selectedUsers.length === 0) return false;
130
+ return true;
131
+ }, [selectedUsers, tab, name, step]);
132
+
133
+ let footer;
134
+ if (tab === 'messaging') {
135
+ footer = (
136
+ <div className="ermis-create-channel__footer">
137
+ <button className="ermis-create-channel__btn ermis-create-channel__btn--cancel" onClick={onClose} disabled={isCreating}>{cancelButtonLabel}</button>
138
+ <button className="ermis-create-channel__btn ermis-create-channel__btn--create" onClick={handleCreate} disabled={isCreating || !isValid}>
139
+ {isCreating ? creatingButtonLabel : createButtonLabel}
140
+ </button>
141
+ </div>
142
+ );
143
+ } else if (tab === 'team' && step === 1) {
144
+ footer = (
145
+ <div className="ermis-create-channel__footer">
146
+ <button className="ermis-create-channel__btn ermis-create-channel__btn--cancel" onClick={onClose} disabled={isCreating}>{cancelButtonLabel}</button>
147
+ <button className="ermis-create-channel__btn ermis-create-channel__btn--create" onClick={() => { setError(null); setStep(2); }} disabled={isCreating || !isValid}>
148
+ Next
149
+ </button>
150
+ </div>
151
+ );
152
+ } else if (tab === 'team' && step === 2) {
153
+ footer = (
154
+ <div className="ermis-create-channel__footer">
155
+ <button className="ermis-create-channel__btn ermis-create-channel__btn--cancel" onClick={() => { setError(null); setStep(1); }} disabled={isCreating}>Back</button>
156
+ <button className="ermis-create-channel__btn ermis-create-channel__btn--create" onClick={handleCreate} disabled={isCreating || !isValid}>
157
+ {isCreating ? creatingButtonLabel : createButtonLabel}
158
+ </button>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ return (
164
+ <Modal isOpen={isOpen} onClose={isCreating ? () => { } : onClose} title={title} maxWidth="480px" footer={footer}>
165
+ <div className="ermis-create-channel__body">
166
+
167
+ {/* Type Toggle */}
168
+ <div className="ermis-create-channel__tabs">
169
+ <button
170
+ className={`ermis-create-channel__tab ${tab === 'messaging' ? 'ermis-create-channel__tab--active' : ''}`}
171
+ onClick={() => {
172
+ setTab('messaging');
173
+ setStep(1);
174
+ setSelectedUsers([]);
175
+ setError(null);
176
+ }}
177
+ disabled={isCreating}
178
+ >
179
+ {directTabLabel}
180
+ </button>
181
+ <button
182
+ className={`ermis-create-channel__tab ${tab === 'team' ? 'ermis-create-channel__tab--active' : ''}`}
183
+ onClick={() => {
184
+ setTab('team');
185
+ setStep(1);
186
+ setSelectedUsers([]);
187
+ setError(null);
188
+ }}
189
+ disabled={isCreating}
190
+ >
191
+ {groupTabLabel}
192
+ </button>
193
+ </div>
194
+
195
+ {/* Group Specific Fields - Step 1 */}
196
+ {tab === 'team' && step === 1 && (
197
+ <>
198
+
199
+ <div className="ermis-create-channel__field">
200
+ <label className="ermis-create-channel__label">{groupNameLabel} <span style={{ color: 'var(--ermis-error)' }}>*</span></label>
201
+ <input
202
+ className="ermis-create-channel__input"
203
+ value={name}
204
+ onChange={(e) => setName(e.target.value)}
205
+ placeholder={groupNamePlaceholder}
206
+ disabled={isCreating}
207
+ maxLength={100}
208
+ />
209
+ </div>
210
+
211
+ <div className="ermis-create-channel__field">
212
+ <label className="ermis-create-channel__label">{groupDescriptionLabel}</label>
213
+ <textarea
214
+ className="ermis-create-channel__textarea"
215
+ value={description}
216
+ onChange={(e) => setDescription(e.target.value)}
217
+ placeholder={groupDescriptionPlaceholder}
218
+ disabled={isCreating}
219
+ maxLength={500}
220
+ rows={2}
221
+ />
222
+ </div>
223
+
224
+ <div className="ermis-create-channel__field ermis-create-channel__field--toggle">
225
+ <label className="ermis-create-channel__label">{groupPublicLabel}</label>
226
+ <button
227
+ type="button"
228
+ role="switch"
229
+ aria-checked={isPublic}
230
+ className={`ermis-create-channel__toggle ${isPublic ? 'ermis-create-channel__toggle--on' : ''}`}
231
+ onClick={() => setIsPublic(v => !v)}
232
+ disabled={isCreating}
233
+ >
234
+ <span className="ermis-create-channel__toggle-thumb" />
235
+ </button>
236
+ </div>
237
+ </>
238
+ )}
239
+
240
+ {/* User Selection - Step 2 (Group) or Step 1 (Messaging) */}
241
+ {((tab === 'team' && step === 2) || tab === 'messaging') && (
242
+ <div className="ermis-create-channel__users">
243
+ <div className="ermis-create-channel__users-title">
244
+ Members <span style={{ color: 'var(--ermis-error)' }}>*</span>
245
+ </div>
246
+ <div style={{ height: tab === 'team' ? '280px' : '400px', display: 'flex', flexDirection: 'column' }}>
247
+ <UserPicker
248
+ mode={tab === 'messaging' ? 'radio' : 'checkbox'}
249
+ onSelectionChange={setSelectedUsers}
250
+ excludeUserIds={tab === 'messaging' ? existingDirectUserIds : []}
251
+ initialSelectedUsers={selectedUsers}
252
+ AvatarComponent={AvatarComponent}
253
+ UserItemComponent={UserItemComponent as any}
254
+ searchPlaceholder={userSearchPlaceholder}
255
+ />
256
+ </div>
257
+ </div>
258
+ )}
259
+
260
+ {error && (
261
+ <div className="ermis-create-channel__error">
262
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
263
+ <circle cx="12" cy="12" r="10" />
264
+ <line x1="12" y1="8" x2="12" y2="12" />
265
+ <line x1="12" y1="16" x2="12.01" y2="16" />
266
+ </svg>
267
+ {error}
268
+ </div>
269
+ )}
270
+
271
+ </div>
272
+ </Modal>
273
+ );
274
+ };