@ermis-network/ermis-chat-react 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/index.cjs +6593 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.css +3375 -0
  4. package/dist/index.css.map +1 -0
  5. package/dist/index.d.mts +1138 -0
  6. package/dist/index.d.ts +1138 -0
  7. package/dist/index.mjs +6500 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +42 -0
  10. package/src/components/Avatar.tsx +102 -0
  11. package/src/components/Channel.tsx +77 -0
  12. package/src/components/ChannelHeader.tsx +85 -0
  13. package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
  14. package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
  15. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
  16. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
  17. package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
  18. package/src/components/ChannelInfo/FileListItem.tsx +49 -0
  19. package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
  21. package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
  23. package/src/components/ChannelInfo/States.tsx +36 -0
  24. package/src/components/ChannelInfo/index.ts +10 -0
  25. package/src/components/ChannelInfo/utils.tsx +49 -0
  26. package/src/components/ChannelList.tsx +395 -0
  27. package/src/components/Dropdown.tsx +120 -0
  28. package/src/components/EditPreview.tsx +102 -0
  29. package/src/components/FilesPreview.tsx +108 -0
  30. package/src/components/ForwardMessageModal.tsx +234 -0
  31. package/src/components/MentionSuggestions.tsx +59 -0
  32. package/src/components/MessageActionsBox.tsx +186 -0
  33. package/src/components/MessageInput.tsx +513 -0
  34. package/src/components/MessageInputDefaults.tsx +50 -0
  35. package/src/components/MessageItem.tsx +218 -0
  36. package/src/components/MessageQuickReactions.tsx +73 -0
  37. package/src/components/MessageReactions.tsx +59 -0
  38. package/src/components/MessageRenderers.tsx +565 -0
  39. package/src/components/Modal.tsx +58 -0
  40. package/src/components/Panel.tsx +64 -0
  41. package/src/components/PinnedMessages.tsx +165 -0
  42. package/src/components/QuotedMessagePreview.tsx +55 -0
  43. package/src/components/ReadReceipts.tsx +80 -0
  44. package/src/components/ReplyPreview.tsx +98 -0
  45. package/src/components/TypingIndicator.tsx +57 -0
  46. package/src/components/VirtualMessageList.tsx +425 -0
  47. package/src/context/ChatProvider.tsx +73 -0
  48. package/src/hooks/useBannedState.ts +48 -0
  49. package/src/hooks/useBlockedState.ts +55 -0
  50. package/src/hooks/useChannel.ts +18 -0
  51. package/src/hooks/useChannelCapabilities.ts +42 -0
  52. package/src/hooks/useChannelData.ts +55 -0
  53. package/src/hooks/useChannelListUpdates.ts +224 -0
  54. package/src/hooks/useChannelMessages.ts +159 -0
  55. package/src/hooks/useChannelRowUpdates.ts +78 -0
  56. package/src/hooks/useChatClient.ts +11 -0
  57. package/src/hooks/useEmojiPicker.ts +53 -0
  58. package/src/hooks/useFileUpload.ts +128 -0
  59. package/src/hooks/useLoadMessages.ts +178 -0
  60. package/src/hooks/useMentions.ts +287 -0
  61. package/src/hooks/useMessageActions.ts +87 -0
  62. package/src/hooks/useMessageSend.ts +164 -0
  63. package/src/hooks/usePendingState.ts +63 -0
  64. package/src/hooks/useScrollToMessage.ts +155 -0
  65. package/src/hooks/useTypingIndicator.ts +86 -0
  66. package/src/index.ts +129 -0
  67. package/src/styles/_add-member-modal.css +122 -0
  68. package/src/styles/_base.css +32 -0
  69. package/src/styles/_channel-info.css +941 -0
  70. package/src/styles/_channel-list.css +217 -0
  71. package/src/styles/_dropdown.css +69 -0
  72. package/src/styles/_forward-modal.css +191 -0
  73. package/src/styles/_mentions.css +102 -0
  74. package/src/styles/_message-actions.css +61 -0
  75. package/src/styles/_message-bubble.css +656 -0
  76. package/src/styles/_message-input.css +389 -0
  77. package/src/styles/_message-list.css +416 -0
  78. package/src/styles/_message-quick-reactions.css +62 -0
  79. package/src/styles/_message-reactions.css +67 -0
  80. package/src/styles/_modal.css +113 -0
  81. package/src/styles/_panel.css +69 -0
  82. package/src/styles/_pinned-messages.css +140 -0
  83. package/src/styles/_search-panel.css +219 -0
  84. package/src/styles/_tokens.css +92 -0
  85. package/src/styles/_typing-indicator.css +59 -0
  86. package/src/styles/index.css +24 -0
  87. package/src/types.ts +955 -0
  88. package/src/utils.ts +242 -0
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@ermis-network/ermis-chat-react",
3
+ "version": "1.0.0",
4
+ "description": "React UI components for Ermis Chat",
5
+ "author": "Ermis",
6
+ "homepage": "https://ermis.network/",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.mjs",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.mjs",
14
+ "require": "./dist/index.cjs"
15
+ },
16
+ "./dist/index.css": "./dist/index.css"
17
+ },
18
+ "files": [
19
+ "/dist",
20
+ "/src"
21
+ ],
22
+ "dependencies": {
23
+ "@ermis-network/ermis-chat-sdk": "1.0.0",
24
+ "virtua": "^0.48.8"
25
+ },
26
+ "peerDependencies": {
27
+ "react": ">=18.0.0",
28
+ "react-dom": ">=18.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/react": "^18.2.0",
32
+ "@types/react-dom": "^18.2.0",
33
+ "react": "^18.2.0",
34
+ "react-dom": "^18.2.0",
35
+ "tsup": "^8.0.0",
36
+ "typescript": "^5.9.3"
37
+ },
38
+ "scripts": {
39
+ "build": "rm -rf dist && tsup",
40
+ "dev": "tsup --watch"
41
+ }
42
+ }
@@ -0,0 +1,102 @@
1
+ import React, { useMemo } from 'react';
2
+ import type { AvatarProps } from '../types';
3
+
4
+ export type { AvatarProps } from '../types';
5
+
6
+ /**
7
+ * Extracts 1–2 initials from a name.
8
+ */
9
+ function getInitials(name?: string): string {
10
+ if (!name) return '?';
11
+ if (name.startsWith('0x')) return '0x';
12
+ const parts = name.trim().split(/\s+/);
13
+ if (parts.length >= 2) {
14
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
15
+ }
16
+ return parts[0][0].toUpperCase();
17
+ }
18
+
19
+ /**
20
+ * Avatar component with image or initial fallback.
21
+ */
22
+ export const Avatar: React.FC<AvatarProps> = React.memo(({
23
+ image,
24
+ name,
25
+ size = 36,
26
+ className,
27
+ }) => {
28
+ const [isLoaded, setIsLoaded] = React.useState(false);
29
+ const [hasError, setHasError] = React.useState(false);
30
+ const imgRef = React.useRef<HTMLImageElement>(null);
31
+
32
+ // Reset state if image URL changes
33
+ React.useEffect(() => {
34
+ if (image) {
35
+ setHasError(false);
36
+ if (imgRef.current?.complete) {
37
+ setIsLoaded(true);
38
+ } else {
39
+ setIsLoaded(false);
40
+ }
41
+ }
42
+ }, [image]);
43
+
44
+ const initials = useMemo(() => getInitials(name), [name]);
45
+
46
+ const wrapperStyle = useMemo<React.CSSProperties>(() => ({
47
+ width: size,
48
+ height: size,
49
+ minWidth: size,
50
+ position: 'relative',
51
+ borderRadius: '100%', /* Or var(--ermis-radius-full) */
52
+ overflow: 'hidden',
53
+ flexShrink: 0,
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ justifyContent: 'center',
57
+ }), [size]);
58
+
59
+ const contentStyle = useMemo<React.CSSProperties>(() => ({
60
+ width: '100%',
61
+ height: '100%',
62
+ fontSize: size * 0.4,
63
+ lineHeight: 1,
64
+ }), [size]);
65
+
66
+ return (
67
+ <div className={`ermis-avatar-wrapper${className ? ` ${className}` : ''}`} style={wrapperStyle}>
68
+ {/* 1. Underlying Fallback (Placeholder) */}
69
+ <div
70
+ className="ermis-avatar ermis-avatar--fallback"
71
+ style={contentStyle}
72
+ title={name}
73
+ >
74
+ {initials}
75
+ </div>
76
+
77
+ {/* 2. Actual Image (Lazy, Fades in natively using CSS opacity) */}
78
+ {image && !hasError && (
79
+ <img
80
+ ref={imgRef}
81
+ className="ermis-avatar__img"
82
+ src={image}
83
+ alt={name || 'Avatar'}
84
+ loading="lazy"
85
+ onLoad={() => setIsLoaded(true)}
86
+ onError={() => setHasError(true)}
87
+ style={{
88
+ ...contentStyle,
89
+ position: 'absolute',
90
+ top: 0,
91
+ left: 0,
92
+ opacity: isLoaded ? 1 : 0,
93
+ transition: 'opacity 0.3s ease-in-out',
94
+ objectFit: 'cover',
95
+ }}
96
+ />
97
+ )}
98
+ </div>
99
+ );
100
+ });
101
+
102
+ Avatar.displayName = 'Avatar';
@@ -0,0 +1,77 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import { useChatClient } from '../hooks/useChatClient';
3
+ import { useBannedState } from '../hooks/useBannedState';
4
+ import { useBlockedState } from '../hooks/useBlockedState';
5
+ import { ForwardMessageModal } from './ForwardMessageModal';
6
+ import type { ChannelProps } from '../types';
7
+
8
+ export type { ChannelProps } from '../types';
9
+
10
+ const DefaultEmpty = React.memo(() => (
11
+ <div className="ermis-channel__empty">Select a channel to start chatting</div>
12
+ ));
13
+ DefaultEmpty.displayName = 'DefaultEmpty';
14
+
15
+ /**
16
+ * Channel wrapper component.
17
+ *
18
+ * Customization:
19
+ * - `HeaderComponent` — replace default ChannelHeader with a fully custom component.
20
+ * Receives `{ channel, name, image }` as props.
21
+ * - `EmptyStateIndicator` — custom component when no channel is selected.
22
+ */
23
+ export const Channel: React.FC<ChannelProps> = React.memo(({
24
+ children,
25
+ className,
26
+ EmptyStateIndicator = DefaultEmpty,
27
+ HeaderComponent,
28
+ ForwardMessageModalComponent = ForwardMessageModal,
29
+ }) => {
30
+ const { activeChannel, client, forwardingMessage, setForwardingMessage } = useChatClient();
31
+ const { isBanned } = useBannedState(activeChannel, client.userID);
32
+ const { isBlocked } = useBlockedState(activeChannel, client.userID);
33
+
34
+ // Force re-render when channel info is updated via WS
35
+ const [channelUpdateCount, setChannelUpdateCount] = useState(0);
36
+ useEffect(() => {
37
+
38
+ console.log('---activeChannel--', activeChannel)
39
+
40
+ if (!activeChannel) return;
41
+ const sub = activeChannel.on('channel.updated', () => setChannelUpdateCount((c) => c + 1));
42
+ return () => sub.unsubscribe();
43
+ }, [activeChannel]);
44
+
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ const headerData = useMemo(() => {
47
+ if (!activeChannel || !HeaderComponent) return null;
48
+ return {
49
+ channel: activeChannel,
50
+ name: (activeChannel.data?.name || activeChannel.cid || '') as string,
51
+ image: activeChannel.data?.image as string | undefined,
52
+ };
53
+ }, [activeChannel, HeaderComponent, channelUpdateCount]);
54
+
55
+ if (!activeChannel) {
56
+ return <EmptyStateIndicator />;
57
+ }
58
+
59
+ const bannedClass = isBanned ? ' ermis-channel--banned' : '';
60
+ const blockedClass = isBlocked ? ' ermis-channel--blocked' : '';
61
+
62
+ return (
63
+ <div className={`ermis-channel${bannedClass}${blockedClass}${className ? ` ${className}` : ''}`}>
64
+ {HeaderComponent && headerData && <HeaderComponent {...headerData} />}
65
+ {children}
66
+ {forwardingMessage && (
67
+ <ForwardMessageModalComponent
68
+ message={forwardingMessage}
69
+ onDismiss={() => setForwardingMessage(null)}
70
+ />
71
+ )}
72
+ </div>
73
+ );
74
+ });
75
+
76
+ Channel.displayName = 'Channel';
77
+
@@ -0,0 +1,85 @@
1
+ import React, { useMemo, useState, useEffect } from 'react';
2
+ import { useChatClient } from '../hooks/useChatClient';
3
+ import { usePendingState } from '../hooks/usePendingState';
4
+ import { useBannedState } from '../hooks/useBannedState';
5
+ import { useBlockedState } from '../hooks/useBlockedState';
6
+ import { Avatar } from './Avatar';
7
+ import type { ChannelHeaderProps } from '../types';
8
+
9
+ export type { ChannelHeaderProps } from '../types';
10
+
11
+ /**
12
+ * ChannelHeader displays the active channel's avatar and name.
13
+ *
14
+ * Customization:
15
+ * - `title` / `image` — override the channel name and avatar
16
+ * - `subtitle` — add a subtitle line (e.g. member count)
17
+ * - `AvatarComponent` — replace the avatar
18
+ * - `renderTitle(channel)` — fully custom title rendering
19
+ * - `renderRight(channel)` — render content on the right side
20
+ *
21
+ * For a fully custom header, use `Channel`'s `HeaderComponent` prop instead.
22
+ */
23
+ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
24
+ className,
25
+ AvatarComponent = Avatar,
26
+ title,
27
+ image,
28
+ subtitle,
29
+ renderRight,
30
+ renderTitle,
31
+ }) => {
32
+ const { activeChannel, client } = useChatClient();
33
+ const { isPending } = usePendingState(activeChannel, client.userID);
34
+
35
+ const actionDisabled = isPending;
36
+
37
+ // Force re-render when channel.updated WS event fires
38
+ const [channelUpdateCount, setChannelUpdateCount] = useState(0);
39
+
40
+ useEffect(() => {
41
+ if (!activeChannel) return;
42
+ const sub = activeChannel.on('channel.updated', () => setChannelUpdateCount(c => c + 1));
43
+ return () => sub.unsubscribe();
44
+ }, [activeChannel]);
45
+
46
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
+ const channelName = useMemo(() =>
48
+ title || activeChannel?.data?.name || activeChannel?.cid || '',
49
+ [title, activeChannel?.data?.name, activeChannel?.cid, channelUpdateCount],
50
+ );
51
+
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ const channelImage = useMemo(() =>
54
+ image || (activeChannel?.data?.image as string | undefined),
55
+ [image, activeChannel?.data?.image, channelUpdateCount],
56
+ );
57
+
58
+ if (!activeChannel) return null;
59
+
60
+ return (
61
+ <div className={`ermis-channel-header${className ? ` ${className}` : ''}`}>
62
+ <AvatarComponent image={channelImage} name={channelName} size={32} />
63
+
64
+ <div className="ermis-channel-header__info">
65
+ {renderTitle ? (
66
+ renderTitle(activeChannel)
67
+ ) : (
68
+ <div className="ermis-channel-header__name">{channelName}</div>
69
+ )}
70
+ {subtitle && (
71
+ <div className="ermis-channel-header__subtitle">{subtitle}</div>
72
+ )}
73
+ </div>
74
+
75
+ {/* 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>
82
+ );
83
+ });
84
+
85
+ ChannelHeader.displayName = 'ChannelHeader';
@@ -0,0 +1,204 @@
1
+ import React, { useState, useEffect, useMemo, useCallback, useTransition } from 'react';
2
+ import { useChatClient } from '../../hooks/useChatClient';
3
+ 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
+ );
50
+
51
+ export const AddMemberModal: React.FC<AddMemberModalProps> = ({
52
+ channel,
53
+ currentMembers,
54
+ onClose,
55
+ AvatarComponent,
56
+ title = 'Add Member',
57
+ searchPlaceholder = 'Search by name, email or phone...',
58
+ loadingText = 'Loading users...',
59
+ emptyText = 'No users found.',
60
+ addLabel = 'Add',
61
+ addingLabel = 'Adding...',
62
+ addedLabel = 'Added',
63
+ UserItemComponent,
64
+ SearchInputComponent,
65
+ }) => {
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]);
121
+
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]);
150
+
151
+ // 4. Derived state
152
+ const usersToDisplay = (search.trim() && localFilteredUsers.length === 0) ? remoteUsers : localFilteredUsers;
153
+ const isListLoading = loading || isSearching || isPendingFilter;
154
+
155
+ const existingMemberIds = useMemo(() => {
156
+ return new Set(currentMembers.map((m: any) => m.user_id));
157
+ }, [currentMembers]);
158
+
159
+ const handleAdd = useCallback(async (userId: string) => {
160
+ try {
161
+ setAddingUser(userId);
162
+ await channel.addMembers([userId]);
163
+ setJustAdded(prev => new Set(prev).add(userId));
164
+ } catch (err) {
165
+ console.error('Failed to add member:', err);
166
+ } finally {
167
+ setAddingUser(null);
168
+ }
169
+ }, [channel]);
170
+
171
+ return (
172
+ <Modal isOpen onClose={onClose} title={title} maxWidth="480px">
173
+ <SearchInput
174
+ value={searchInput}
175
+ onChange={handleSearchChange}
176
+ placeholder={searchPlaceholder}
177
+ />
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
+ </Modal>
203
+ );
204
+ };