@ermis-network/ermis-chat-react 1.0.9 → 2.0.1

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 (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. package/src/utils.ts +219 -16
@@ -0,0 +1,236 @@
1
+ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
2
+ import { VList as _VList, type VListHandle } from 'virtua';
3
+ const VList = _VList as any;
4
+ import type { Channel } from '@ermis-network/ermis-chat-sdk';
5
+ import { useChatClient } from '../hooks/useChatClient';
6
+ import { useTopicGroupUpdates } from '../hooks/useTopicGroupUpdates';
7
+ import { ChannelRow } from './ChannelList';
8
+ import { ChannelItem } from './ChannelList';
9
+ import { Avatar } from './Avatar';
10
+ import { DefaultPinnedIcon } from './ChannelList';
11
+ import { TopicModal } from './TopicModal';
12
+ import { isPendingMember, isSkippedMember } from '../channelRoleUtils';
13
+ import type { TopicListProps } from '../types';
14
+
15
+ /* ----------------------------------------------------------
16
+ Default avatars for general and topic items
17
+ ---------------------------------------------------------- */
18
+ const DefaultGeneralAvatar = React.memo(() => (
19
+ <div className="ermis-channel-list__topic-hashtag">#</div>
20
+ ));
21
+ DefaultGeneralAvatar.displayName = 'DefaultGeneralAvatar';
22
+
23
+ const DefaultTopicEmojiAvatar = React.memo(({ image }: { image?: string | null }) => {
24
+ let emoji = '💬';
25
+ if (image && typeof image === 'string' && image.startsWith('emoji://')) {
26
+ emoji = image.replace('emoji://', '');
27
+ }
28
+ return <div className="ermis-channel-list__topic-hashtag">{emoji}</div>;
29
+ });
30
+ DefaultTopicEmojiAvatar.displayName = 'DefaultTopicEmojiAvatar';
31
+
32
+ /* ----------------------------------------------------------
33
+ TopicList – headless virtualized list of topics
34
+ ---------------------------------------------------------- */
35
+ export const TopicList: React.FC<TopicListProps> = React.memo(({
36
+ channel,
37
+ ChannelItemComponent = ChannelItem,
38
+ AvatarComponent = Avatar,
39
+ GeneralAvatarComponent,
40
+ TopicAvatarComponent,
41
+ generalTopicLabel = 'general',
42
+ PinnedIconComponent = DefaultPinnedIcon,
43
+ ChannelActionsComponent,
44
+ onSelectTopic,
45
+ onEditTopic,
46
+ onToggleCloseTopic,
47
+ onDeleteTopic,
48
+ hiddenActions,
49
+ actionLabels,
50
+ actionIcons,
51
+ closedTopicIcon,
52
+ pendingBadgeLabel,
53
+ blockedBadgeLabel,
54
+ scrollToTopOnOwnMessage = true,
55
+ deletedMessageLabel,
56
+ stickerMessageLabel,
57
+ photoMessageLabel,
58
+ videoMessageLabel,
59
+ voiceRecordingMessageLabel,
60
+ fileMessageLabel,
61
+ encryptedMessageLabel,
62
+ encryptedMessageUnavailableLabel,
63
+ systemMessageTranslations,
64
+ signalMessageTranslations,
65
+ }) => {
66
+ const { client, activeChannel, setActiveChannel } = useChatClient();
67
+ const currentUserId = client.userID;
68
+ const { topics } = useTopicGroupUpdates(channel, currentUserId);
69
+
70
+ // Ref for imperative scroll control on the virtualized list
71
+ const vlistRef = useRef<VListHandle>(null);
72
+
73
+ // Auto-scroll to top when the current user sends a message in any topic
74
+ useEffect(() => {
75
+ if (!scrollToTopOnOwnMessage || !currentUserId) return;
76
+
77
+ const subs: { unsubscribe: () => void }[] = [];
78
+
79
+ const handleNewMessage = (event: { user?: { id?: string } }) => {
80
+ if (event.user?.id === currentUserId) {
81
+ setTimeout(() => vlistRef.current?.scrollToIndex(0), 0);
82
+ }
83
+ };
84
+
85
+ // Listen on parent channel
86
+ subs.push(channel.on('message.new', handleNewMessage));
87
+
88
+ // Listen on all sub-topics
89
+ const currentTopics = channel.state?.topics || [];
90
+ currentTopics.forEach((t: Channel) => {
91
+ subs.push(t.on('message.new', handleNewMessage));
92
+ });
93
+
94
+ return () => {
95
+ subs.forEach((s) => s.unsubscribe());
96
+ };
97
+ }, [channel, channel.state?.topics, currentUserId, scrollToTopOnOwnMessage]);
98
+
99
+ // Default edit topic handler: open built-in TopicModal when no custom handler is provided
100
+ const [editingTopic, setEditingTopic] = useState<Channel | null>(null);
101
+
102
+ const handleEditTopic = useCallback((topic: Channel) => {
103
+ if (onEditTopic) {
104
+ onEditTopic(topic);
105
+ } else {
106
+ setEditingTopic(topic);
107
+ }
108
+ }, [onEditTopic]);
109
+
110
+ // General channel proxy — display parent channel as the general topic
111
+ const generalProxy = useMemo(() => {
112
+ return new Proxy(channel, {
113
+ get(target, prop, receiver) {
114
+ if (prop === 'data') {
115
+ return { ...target.data, name: generalTopicLabel, is_pinned: false };
116
+ }
117
+ const value = Reflect.get(target, prop, receiver);
118
+ return typeof value === 'function' ? value.bind(target) : value;
119
+ }
120
+ });
121
+ }, [channel, generalTopicLabel]);
122
+
123
+ const markChannelRead = useCallback((ch: Channel) => {
124
+ const client = ch.getClient();
125
+ const activeCh = client.activeChannels[ch.cid] || ch;
126
+ const ms = activeCh.state?.membership as Record<string, unknown> | undefined;
127
+ const chState = activeCh.state as unknown as Record<string, unknown> | undefined;
128
+ const isBannedInChannel = Boolean(ms?.banned);
129
+ const isPending = isPendingMember(ms?.channel_role as string);
130
+ const isSkipped = isSkippedMember(ms?.channel_role as string);
131
+
132
+ if (!isBannedInChannel && !isPending && !isSkipped) {
133
+ if ((chState?.unreadCount as number) > 0) {
134
+ activeCh.markRead().catch(() => { });
135
+ if (chState) chState.unreadCount = 0;
136
+ }
137
+
138
+ // Always clear the stale channel just in case to fix UI ghost badges
139
+ if (ch.state && (ch.state as any).unreadCount > 0) {
140
+ (ch.state as any).unreadCount = 0;
141
+ }
142
+ }
143
+ }, []);
144
+
145
+ const handleSelectGeneral = useCallback(() => {
146
+ if (onSelectTopic) {
147
+ onSelectTopic(channel);
148
+ } else {
149
+ setActiveChannel(channel);
150
+ }
151
+ markChannelRead(channel);
152
+ }, [channel, onSelectTopic, setActiveChannel, markChannelRead]);
153
+
154
+ const handleSelectTopic = useCallback((topic: Channel) => {
155
+ if (onSelectTopic) {
156
+ onSelectTopic(topic);
157
+ } else {
158
+ setActiveChannel(topic);
159
+ }
160
+ markChannelRead(topic);
161
+ }, [onSelectTopic, setActiveChannel, markChannelRead]);
162
+
163
+ /** Null actions component for the general item */
164
+ const NoActions = useCallback(() => null, []);
165
+
166
+ return (
167
+ <>
168
+ <VList ref={vlistRef} style={{ height: '100%' }}>
169
+ {/* General (parent channel) — no actions menu */}
170
+ <ChannelRow
171
+ channel={generalProxy as Channel}
172
+ isActive={activeChannel?.cid === channel.cid}
173
+ handleSelect={handleSelectGeneral}
174
+ ChannelItemComponent={ChannelItemComponent}
175
+ AvatarComponent={GeneralAvatarComponent || DefaultGeneralAvatar as any}
176
+ currentUserId={currentUserId}
177
+ pendingBadgeLabel={pendingBadgeLabel}
178
+ blockedBadgeLabel={blockedBadgeLabel}
179
+ ChannelActionsComponent={NoActions}
180
+ hiddenActions={hiddenActions}
181
+ deletedMessageLabel={deletedMessageLabel}
182
+ stickerMessageLabel={stickerMessageLabel}
183
+ photoMessageLabel={photoMessageLabel}
184
+ videoMessageLabel={videoMessageLabel}
185
+ voiceRecordingMessageLabel={voiceRecordingMessageLabel}
186
+ fileMessageLabel={fileMessageLabel}
187
+ encryptedMessageLabel={encryptedMessageLabel}
188
+ encryptedMessageUnavailableLabel={encryptedMessageUnavailableLabel}
189
+ systemMessageTranslations={systemMessageTranslations}
190
+ signalMessageTranslations={signalMessageTranslations}
191
+ />
192
+ {/* Sub-topics — with full data (last msg, unread, timestamp, pin icon) */}
193
+ {topics.map((topic: Channel) => (
194
+ <ChannelRow
195
+ key={topic.cid}
196
+ channel={topic}
197
+ isActive={activeChannel?.cid === topic.cid}
198
+ handleSelect={handleSelectTopic}
199
+ ChannelItemComponent={ChannelItemComponent}
200
+ AvatarComponent={TopicAvatarComponent || DefaultTopicEmojiAvatar as any}
201
+ currentUserId={currentUserId}
202
+ pendingBadgeLabel={pendingBadgeLabel}
203
+ blockedBadgeLabel={blockedBadgeLabel}
204
+ closedTopicIcon={closedTopicIcon}
205
+ PinnedIconComponent={PinnedIconComponent}
206
+ ChannelActionsComponent={ChannelActionsComponent}
207
+ onEditTopic={handleEditTopic}
208
+ onToggleCloseTopic={onToggleCloseTopic}
209
+ onDeleteTopic={onDeleteTopic}
210
+ hiddenActions={hiddenActions}
211
+ actionLabels={actionLabels}
212
+ actionIcons={actionIcons}
213
+ deletedMessageLabel={deletedMessageLabel}
214
+ stickerMessageLabel={stickerMessageLabel}
215
+ photoMessageLabel={photoMessageLabel}
216
+ videoMessageLabel={videoMessageLabel}
217
+ voiceRecordingMessageLabel={voiceRecordingMessageLabel}
218
+ fileMessageLabel={fileMessageLabel}
219
+ encryptedMessageLabel={encryptedMessageLabel}
220
+ encryptedMessageUnavailableLabel={encryptedMessageUnavailableLabel}
221
+ systemMessageTranslations={systemMessageTranslations}
222
+ signalMessageTranslations={signalMessageTranslations}
223
+ />
224
+ ))}
225
+ </VList>
226
+ {editingTopic && (
227
+ <TopicModal
228
+ isOpen={true}
229
+ onClose={() => setEditingTopic(null)}
230
+ topic={editingTopic}
231
+ />
232
+ )}
233
+ </>
234
+ );
235
+ });
236
+ TopicList.displayName = 'TopicList';
@@ -1,7 +1,8 @@
1
1
  import React, { useState, useCallback } from 'react';
2
2
  import type { CreateTopicData, EditTopicData } from '@ermis-network/ermis-chat-sdk';
3
- import { Modal } from './Modal';
3
+ import { Modal as DefaultModal } from './Modal';
4
4
  import { useChatClient } from '../hooks/useChatClient';
5
+ import { useChatComponents } from '../context/ChatComponentsContext';
5
6
  import type { TopicModalProps } from '../types';
6
7
 
7
8
  const DEFAULT_TOPIC_ICONS = ['💬', '🔥', '🚀', '⭐', '💡', '🎉', '📌', '📁', '🎨', '💻', '📈', '🤝'];
@@ -24,6 +25,8 @@ export const TopicModal: React.FC<TopicModalProps> = React.memo(({
24
25
  savingButtonLabel = topic ? 'Saving...' : 'Creating...',
25
26
  }) => {
26
27
  const { activeChannel, client } = useChatClient();
28
+ const { ModalComponent } = useChatComponents();
29
+ const Modal = ModalComponent || DefaultModal;
27
30
 
28
31
  const originalName = (topic?.data?.name as string) || '';
29
32
  const originalImage = (topic?.data?.image as string) || '';
@@ -2,7 +2,9 @@ import React from 'react';
2
2
  import { useTypingIndicator, type TypingUser } from '../hooks/useTypingIndicator';
3
3
 
4
4
  export type TypingIndicatorProps = {
5
- /** Custom render function for the typing text */
5
+ /** Custom render function for the typing text (I18n) */
6
+ typingIndicatorLabel?: (users: TypingUser[]) => string;
7
+ /** Custom render function for the typing text (JSX) */
6
8
  renderText?: (users: TypingUser[]) => React.ReactNode;
7
9
  };
8
10
 
@@ -10,26 +12,33 @@ export type TypingIndicatorProps = {
10
12
  * Displays a "X is typing..." indicator below the message list.
11
13
  * Automatically subscribes to typing events via the useTypingIndicator hook.
12
14
  */
13
- export const TypingIndicator: React.FC<TypingIndicatorProps> = React.memo(({ renderText }) => {
15
+ export const TypingIndicator: React.FC<TypingIndicatorProps> = React.memo(({ typingIndicatorLabel, renderText }) => {
14
16
  const { typingUsers } = useTypingIndicator();
15
17
 
16
18
  const isActive = typingUsers.length > 0;
17
19
 
18
- const text = isActive
19
- ? (renderText ? renderText(typingUsers) : formatTypingText(typingUsers))
20
- : null;
20
+ let text: React.ReactNode = null;
21
+ if (isActive) {
22
+ if (renderText) {
23
+ text = renderText(typingUsers);
24
+ } else if (typingIndicatorLabel) {
25
+ text = typingIndicatorLabel(typingUsers);
26
+ } else {
27
+ text = formatTypingText(typingUsers);
28
+ }
29
+ }
21
30
 
22
31
  return (
23
- <div className={`ermis-typing-indicator${isActive ? ' ermis-typing-indicator--active' : ''}`}>
32
+ <div className={`ermis-typing-indicator-wrapper`}>
24
33
  {isActive && (
25
- <>
34
+ <div className="ermis-typing-indicator ermis-typing-indicator--active">
26
35
  <div className="ermis-typing-indicator__dots">
27
36
  <span className="ermis-typing-indicator__dot" />
28
37
  <span className="ermis-typing-indicator__dot" />
29
38
  <span className="ermis-typing-indicator__dot" />
30
39
  </div>
31
40
  <span className="ermis-typing-indicator__text">{text}</span>
32
- </>
41
+ </div>
33
42
  )}
34
43
  </div>
35
44
  );
@@ -1,13 +1,16 @@
1
1
  import React, { useState, useEffect, useMemo, useCallback, useRef, useTransition } from 'react';
2
2
  import { useChatClient } from '../hooks/useChatClient';
3
3
  import { Avatar } from './Avatar';
4
- import { VList, type VListHandle } from 'virtua';
4
+ import { VList as _VList, type VListHandle } from 'virtua';
5
+ const VList = _VList as any;
5
6
  import type {
6
7
  UserPickerProps,
7
8
  UserPickerItemProps,
8
9
  UserPickerSelectedBoxProps,
9
10
  UserPickerUser,
10
11
  } from '../types';
12
+ import { isFriendChannel } from '../channelRoleUtils';
13
+ import { removeAccents } from '../utils';
11
14
 
12
15
  /* ---------- Constants ---------- */
13
16
  const DEFAULT_PAGE_SIZE = 30;
@@ -113,6 +116,9 @@ DefaultSelectedBox.displayName = 'DefaultSelectedBox';
113
116
  UserPicker Component
114
117
  ========================================================== */
115
118
 
119
+ // Global cache to persist users across UserPicker unmounts/remounts (e.g. during tab switch)
120
+ const globalUsersCache: Record<string, { users: UserPickerUser[], page: number, hasMore: boolean }> = {};
121
+
116
122
  export const UserPicker: React.FC<UserPickerProps> = ({
117
123
  mode,
118
124
  onSelectionChange,
@@ -128,6 +134,7 @@ export const UserPicker: React.FC<UserPickerProps> = ({
128
134
  emptyText = 'No users found.',
129
135
  loadingMoreText = 'Loading more...',
130
136
  selectedEmptyLabel,
137
+ friendsOnly,
131
138
  }) => {
132
139
  const { client } = useChatClient();
133
140
  const currentUserId = client?.userID;
@@ -176,13 +183,64 @@ export const UserPicker: React.FC<UserPickerProps> = ({
176
183
  let active = true;
177
184
  const fetchUsers = async () => {
178
185
  if (!client) return;
186
+
187
+ // For friendsOnly mode, always read fresh data from activeChannels.
188
+ // Do NOT use globalUsersCache here — the friend list can change at any
189
+ // time (e.g. a new friend request was accepted) and caching would
190
+ // return stale results, hiding the newly added friend.
191
+ if (friendsOnly) {
192
+ const friends: UserPickerUser[] = [];
193
+ const seenIds = new Set<string>();
194
+
195
+ for (const channel of Object.values(client.activeChannels)) {
196
+ const members = channel.state?.members;
197
+ if (!members) continue;
198
+
199
+ for (const [memberId, member] of Object.entries(members)) {
200
+ if (memberId === client.userID) continue;
201
+
202
+ if (isFriendChannel(channel, memberId, client.userID as string) && !seenIds.has(memberId)) {
203
+ if (member.user) {
204
+ friends.push(member.user as UserPickerUser);
205
+ seenIds.add(memberId);
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ if (active) {
212
+ setAllUsers(friends);
213
+ setHasMore(false);
214
+ setPage(1);
215
+ setLoading(false);
216
+ }
217
+ return;
218
+ }
219
+
220
+ const cacheKey = `${client.userID || 'anon'}-${pageSize}`;
221
+
222
+ if (globalUsersCache[cacheKey] && globalUsersCache[cacheKey].users.length > 0) {
223
+ const cached = globalUsersCache[cacheKey];
224
+ setAllUsers(cached.users);
225
+ setHasMore(cached.hasMore);
226
+ setPage(cached.page);
227
+ setLoading(false);
228
+ return;
229
+ }
230
+
179
231
  try {
180
232
  setLoading(true);
181
- const response = await client.queryUsers(String(pageSize), 1);
233
+ const response = await client.queryUsers(pageSize, 1);
182
234
  if (active && response.data) {
183
235
  setAllUsers(response.data);
184
236
  setHasMore(response.data.length >= pageSize);
185
237
  setPage(1);
238
+
239
+ globalUsersCache[cacheKey] = {
240
+ users: response.data,
241
+ page: 1,
242
+ hasMore: response.data.length >= pageSize
243
+ };
186
244
  }
187
245
  } catch (err) {
188
246
  console.error('[UserPicker] Error fetching users:', err);
@@ -192,7 +250,7 @@ export const UserPicker: React.FC<UserPickerProps> = ({
192
250
  };
193
251
  fetchUsers();
194
252
  return () => { active = false; };
195
- }, [client, pageSize]);
253
+ }, [client, pageSize, friendsOnly]);
196
254
 
197
255
  /* ---------- 2. Load more (infinite scroll) ---------- */
198
256
  const loadMore = useCallback(async () => {
@@ -200,12 +258,23 @@ export const UserPicker: React.FC<UserPickerProps> = ({
200
258
  const nextPage = page + 1;
201
259
  setLoadingMore(true);
202
260
  try {
203
- const response = await client.queryUsers(String(pageSize), nextPage);
261
+ const response = await client.queryUsers(pageSize, nextPage);
204
262
  if (response.data) {
205
263
  setAllUsers(prev => {
206
264
  const existingIds = new Set(prev.map(u => u.id));
207
265
  const newUsers = response.data.filter((u: UserPickerUser) => !existingIds.has(u.id));
208
- return [...prev, ...newUsers];
266
+ const combined = [...prev, ...newUsers];
267
+
268
+ if (client) {
269
+ const cacheKey = `${client.userID || 'anon'}-${pageSize}`;
270
+ globalUsersCache[cacheKey] = {
271
+ users: combined,
272
+ page: nextPage,
273
+ hasMore: response.data.length >= pageSize
274
+ };
275
+ }
276
+
277
+ return combined;
209
278
  });
210
279
  setHasMore(response.data.length >= pageSize);
211
280
  setPage(nextPage);
@@ -219,19 +288,24 @@ export const UserPicker: React.FC<UserPickerProps> = ({
219
288
 
220
289
  /* ---------- 3. Local filter ---------- */
221
290
  const localFilteredUsers = useMemo(() => {
222
- const term = search.toLowerCase().trim();
291
+ const term = removeAccents(search.toLowerCase().trim());
223
292
  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
- });
293
+ const result: UserPickerUser[] = [];
294
+ for (const u of allUsers) {
295
+ const name = removeAccents((u.name || '').toLowerCase());
296
+ const email = removeAccents((u.email || '').toLowerCase());
297
+ const phone = removeAccents((u.phone || '').toLowerCase());
298
+ if (name.startsWith(term) || email.startsWith(term) || phone.startsWith(term)) {
299
+ result.push(u);
300
+ if (result.length >= 100) break; // optimize for large room
301
+ }
302
+ }
303
+ return result;
230
304
  }, [search, allUsers]);
231
305
 
232
306
  /* ---------- 4. Remote search fallback ---------- */
233
307
  useEffect(() => {
234
- if (!search.trim() || localFilteredUsers.length > 0) {
308
+ if (!search.trim() || localFilteredUsers.length > 0 || friendsOnly) {
235
309
  setRemoteUsers([]);
236
310
  setIsSearching(false);
237
311
  return;
@@ -259,9 +333,13 @@ export const UserPicker: React.FC<UserPickerProps> = ({
259
333
  }, [search, localFilteredUsers.length, client]);
260
334
 
261
335
  /* ---------- 5. Derived display list ---------- */
262
- const usersToDisplay = (search.trim() && localFilteredUsers.length === 0)
263
- ? remoteUsers
264
- : localFilteredUsers;
336
+ const usersToDisplay = useMemo(() => {
337
+ const list = (search.trim() && localFilteredUsers.length === 0)
338
+ ? remoteUsers
339
+ : localFilteredUsers;
340
+ return list.filter(u => !excludeSet.has(u.id));
341
+ }, [search, localFilteredUsers, remoteUsers, excludeSet]);
342
+
265
343
  const isListLoading = loading || isSearching || isPendingFilter;
266
344
 
267
345
  /* ---------- 6. Selection handlers ---------- */