@ermis-network/ermis-chat-react 1.0.1 → 1.0.2

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/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.2",
4
4
  "description": "React UI components for Ermis Chat",
5
5
  "author": "Ermis",
6
6
  "homepage": "https://ermis.network/",
@@ -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
  };
@@ -35,6 +35,11 @@ function getLastMessagePreview(
35
35
  return { text: parseSignalMessage(rawText, userMap), user: '' };
36
36
  }
37
37
 
38
+ // Display 'Sticker' if message is a sticker
39
+ if (msgType === 'sticker' || (lastMsg as Record<string, unknown>).sticker_url) {
40
+ return { text: 'Sticker', user: lastMsg.user?.name || lastMsg.user_id || '' };
41
+ }
42
+
38
43
  // Regular / other
39
44
  let displayText = rawText;
40
45
  if (!displayText && lastMsg.attachments && lastMsg.attachments.length > 0) {
@@ -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
+ };