@ermis-network/ermis-chat-react 1.0.8 → 2.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 (99) hide show
  1. package/dist/index.cjs +15295 -4209
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +701 -195
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +862 -94
  6. package/dist/index.d.ts +862 -94
  7. package/dist/index.mjs +15246 -4186
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -4
  10. package/src/channelTypeUtils.ts +1 -1
  11. package/src/components/Avatar.tsx +2 -1
  12. package/src/components/Channel.tsx +6 -2
  13. package/src/components/ChannelActions.tsx +61 -2
  14. package/src/components/ChannelHeader.tsx +19 -5
  15. package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
  21. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  23. package/src/components/ChannelInfo/States.tsx +1 -1
  24. package/src/components/ChannelInfo/index.ts +3 -0
  25. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
  26. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  27. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  28. package/src/components/ChannelList.tsx +177 -290
  29. package/src/components/CreateChannelModal.tsx +166 -88
  30. package/src/components/Dropdown.tsx +1 -16
  31. package/src/components/EditPreview.tsx +1 -0
  32. package/src/components/ErmisCallProvider.tsx +72 -17
  33. package/src/components/ErmisCallUI.tsx +43 -20
  34. package/src/components/FlatTopicGroupItem.tsx +232 -0
  35. package/src/components/ForwardMessageModal.tsx +31 -77
  36. package/src/components/MediaLightbox.tsx +62 -40
  37. package/src/components/MentionSuggestions.tsx +47 -35
  38. package/src/components/MessageActionsBox.tsx +4 -1
  39. package/src/components/MessageInput.tsx +137 -16
  40. package/src/components/MessageInputDefaults.tsx +127 -1
  41. package/src/components/MessageItem.tsx +93 -26
  42. package/src/components/MessageQuickReactions.tsx +153 -26
  43. package/src/components/MessageReactions.tsx +2 -1
  44. package/src/components/MessageRenderers.tsx +111 -39
  45. package/src/components/Panel.tsx +1 -14
  46. package/src/components/PinnedMessages.tsx +17 -5
  47. package/src/components/PreviewOverlay.tsx +24 -0
  48. package/src/components/ReadReceipts.tsx +2 -1
  49. package/src/components/TopicList.tsx +221 -0
  50. package/src/components/TopicModal.tsx +4 -1
  51. package/src/components/TypingIndicator.tsx +14 -5
  52. package/src/components/UserPicker.tsx +87 -10
  53. package/src/components/VirtualMessageList.tsx +106 -20
  54. package/src/context/ChatComponentsContext.tsx +14 -0
  55. package/src/context/ChatProvider.tsx +18 -14
  56. package/src/context/ErmisCallContext.tsx +4 -0
  57. package/src/hooks/useChannelCapabilities.ts +7 -4
  58. package/src/hooks/useChannelData.ts +10 -3
  59. package/src/hooks/useChannelListUpdates.ts +72 -20
  60. package/src/hooks/useChannelMessages.ts +72 -10
  61. package/src/hooks/useChannelRowUpdates.ts +24 -5
  62. package/src/hooks/useChatUser.ts +31 -0
  63. package/src/hooks/useContactChannels.ts +45 -0
  64. package/src/hooks/useContactCount.ts +50 -0
  65. package/src/hooks/useDownloadHandler.ts +36 -0
  66. package/src/hooks/useDragAndDrop.ts +79 -0
  67. package/src/hooks/useForwardMessage.ts +112 -0
  68. package/src/hooks/useInviteChannels.ts +88 -0
  69. package/src/hooks/useInviteCount.ts +104 -0
  70. package/src/hooks/useMentions.ts +0 -1
  71. package/src/hooks/useMessageActions.ts +13 -10
  72. package/src/hooks/usePendingState.ts +21 -4
  73. package/src/hooks/usePreviewState.ts +69 -0
  74. package/src/hooks/useStickerPicker.ts +62 -0
  75. package/src/hooks/useTopicGroupUpdates.ts +197 -0
  76. package/src/index.ts +56 -6
  77. package/src/messageTypeUtils.ts +13 -1
  78. package/src/styles/_base.css +0 -1
  79. package/src/styles/_call-ui.css +59 -2
  80. package/src/styles/_channel-info.css +41 -4
  81. package/src/styles/_channel-list.css +97 -57
  82. package/src/styles/_create-channel-modal.css +10 -0
  83. package/src/styles/_forward-modal.css +16 -1
  84. package/src/styles/_media-lightbox.css +32 -0
  85. package/src/styles/_mentions.css +1 -1
  86. package/src/styles/_message-actions.css +3 -4
  87. package/src/styles/_message-bubble.css +286 -107
  88. package/src/styles/_message-input.css +131 -0
  89. package/src/styles/_message-list.css +33 -17
  90. package/src/styles/_message-quick-reactions.css +40 -9
  91. package/src/styles/_message-reactions.css +4 -0
  92. package/src/styles/_modal.css +2 -1
  93. package/src/styles/_preview-overlay.css +38 -0
  94. package/src/styles/_tokens.css +17 -15
  95. package/src/styles/_typing-indicator.css +7 -1
  96. package/src/styles/index.css +1 -0
  97. package/src/types.ts +362 -14
  98. package/src/utils/avatarColors.ts +48 -0
  99. package/src/utils.ts +193 -10
@@ -0,0 +1,221 @@
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
+ systemMessageTranslations,
62
+ signalMessageTranslations,
63
+ }) => {
64
+ const { client, activeChannel, setActiveChannel } = useChatClient();
65
+ const currentUserId = client.userID;
66
+ const { topics } = useTopicGroupUpdates(channel, currentUserId);
67
+
68
+ // Ref for imperative scroll control on the virtualized list
69
+ const vlistRef = useRef<VListHandle>(null);
70
+
71
+ // Auto-scroll to top when the current user sends a message in any topic
72
+ useEffect(() => {
73
+ if (!scrollToTopOnOwnMessage || !currentUserId) return;
74
+
75
+ const subs: { unsubscribe: () => void }[] = [];
76
+
77
+ const handleNewMessage = (event: { user?: { id?: string } }) => {
78
+ if (event.user?.id === currentUserId) {
79
+ setTimeout(() => vlistRef.current?.scrollToIndex(0), 0);
80
+ }
81
+ };
82
+
83
+ // Listen on parent channel
84
+ subs.push(channel.on('message.new', handleNewMessage));
85
+
86
+ // Listen on all sub-topics
87
+ const currentTopics = channel.state?.topics || [];
88
+ currentTopics.forEach((t: Channel) => {
89
+ subs.push(t.on('message.new', handleNewMessage));
90
+ });
91
+
92
+ return () => {
93
+ subs.forEach((s) => s.unsubscribe());
94
+ };
95
+ }, [channel, channel.state?.topics, currentUserId, scrollToTopOnOwnMessage]);
96
+
97
+ // Default edit topic handler: open built-in TopicModal when no custom handler is provided
98
+ const [editingTopic, setEditingTopic] = useState<Channel | null>(null);
99
+
100
+ const handleEditTopic = useCallback((topic: Channel) => {
101
+ if (onEditTopic) {
102
+ onEditTopic(topic);
103
+ } else {
104
+ setEditingTopic(topic);
105
+ }
106
+ }, [onEditTopic]);
107
+
108
+ // General channel proxy — display parent channel as the general topic
109
+ const generalProxy = useMemo(() => {
110
+ return new Proxy(channel, {
111
+ get(target, prop, receiver) {
112
+ if (prop === 'data') {
113
+ return { ...target.data, name: generalTopicLabel, is_pinned: false };
114
+ }
115
+ const value = Reflect.get(target, prop, receiver);
116
+ return typeof value === 'function' ? value.bind(target) : value;
117
+ }
118
+ });
119
+ }, [channel, generalTopicLabel]);
120
+
121
+ const markChannelRead = useCallback((ch: Channel) => {
122
+ const ms = ch.state?.membership as Record<string, unknown> | undefined;
123
+ const chState = ch.state as unknown as Record<string, unknown> | undefined;
124
+ const isBannedInChannel = Boolean(ms?.banned);
125
+ const isPending = isPendingMember(ms?.channel_role as string);
126
+ const isSkipped = isSkippedMember(ms?.channel_role as string);
127
+
128
+ if (!isBannedInChannel && !isPending && !isSkipped && (chState?.unreadCount as number) > 0) {
129
+ ch.markRead().catch(() => { });
130
+ if (chState) chState.unreadCount = 0;
131
+ }
132
+ }, []);
133
+
134
+ const handleSelectGeneral = useCallback(() => {
135
+ if (onSelectTopic) {
136
+ onSelectTopic(channel);
137
+ } else {
138
+ setActiveChannel(channel);
139
+ }
140
+ markChannelRead(channel);
141
+ }, [channel, onSelectTopic, setActiveChannel, markChannelRead]);
142
+
143
+ const handleSelectTopic = useCallback((topic: Channel) => {
144
+ if (onSelectTopic) {
145
+ onSelectTopic(topic);
146
+ } else {
147
+ setActiveChannel(topic);
148
+ }
149
+ markChannelRead(topic);
150
+ }, [onSelectTopic, setActiveChannel, markChannelRead]);
151
+
152
+ /** Null actions component for the general item */
153
+ const NoActions = useCallback(() => null, []);
154
+
155
+ return (
156
+ <>
157
+ <VList ref={vlistRef} style={{ height: '100%' }}>
158
+ {/* General (parent channel) — no actions menu */}
159
+ <ChannelRow
160
+ channel={generalProxy as Channel}
161
+ isActive={activeChannel?.cid === channel.cid}
162
+ handleSelect={handleSelectGeneral}
163
+ ChannelItemComponent={ChannelItemComponent}
164
+ AvatarComponent={GeneralAvatarComponent || DefaultGeneralAvatar as any}
165
+ currentUserId={currentUserId}
166
+ pendingBadgeLabel={pendingBadgeLabel}
167
+ blockedBadgeLabel={blockedBadgeLabel}
168
+ ChannelActionsComponent={NoActions}
169
+ hiddenActions={hiddenActions}
170
+ deletedMessageLabel={deletedMessageLabel}
171
+ stickerMessageLabel={stickerMessageLabel}
172
+ photoMessageLabel={photoMessageLabel}
173
+ videoMessageLabel={videoMessageLabel}
174
+ voiceRecordingMessageLabel={voiceRecordingMessageLabel}
175
+ fileMessageLabel={fileMessageLabel}
176
+ systemMessageTranslations={systemMessageTranslations}
177
+ signalMessageTranslations={signalMessageTranslations}
178
+ />
179
+ {/* Sub-topics — with full data (last msg, unread, timestamp, pin icon) */}
180
+ {topics.map((topic: Channel) => (
181
+ <ChannelRow
182
+ key={topic.cid}
183
+ channel={topic}
184
+ isActive={activeChannel?.cid === topic.cid}
185
+ handleSelect={handleSelectTopic}
186
+ ChannelItemComponent={ChannelItemComponent}
187
+ AvatarComponent={TopicAvatarComponent || DefaultTopicEmojiAvatar as any}
188
+ currentUserId={currentUserId}
189
+ pendingBadgeLabel={pendingBadgeLabel}
190
+ blockedBadgeLabel={blockedBadgeLabel}
191
+ closedTopicIcon={closedTopicIcon}
192
+ PinnedIconComponent={PinnedIconComponent}
193
+ ChannelActionsComponent={ChannelActionsComponent}
194
+ onEditTopic={handleEditTopic}
195
+ onToggleCloseTopic={onToggleCloseTopic}
196
+ onDeleteTopic={onDeleteTopic}
197
+ hiddenActions={hiddenActions}
198
+ actionLabels={actionLabels}
199
+ actionIcons={actionIcons}
200
+ deletedMessageLabel={deletedMessageLabel}
201
+ stickerMessageLabel={stickerMessageLabel}
202
+ photoMessageLabel={photoMessageLabel}
203
+ videoMessageLabel={videoMessageLabel}
204
+ voiceRecordingMessageLabel={voiceRecordingMessageLabel}
205
+ fileMessageLabel={fileMessageLabel}
206
+ systemMessageTranslations={systemMessageTranslations}
207
+ signalMessageTranslations={signalMessageTranslations}
208
+ />
209
+ ))}
210
+ </VList>
211
+ {editingTopic && (
212
+ <TopicModal
213
+ isOpen={true}
214
+ onClose={() => setEditingTopic(null)}
215
+ topic={editingTopic}
216
+ />
217
+ )}
218
+ </>
219
+ );
220
+ });
221
+ 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,14 +12,21 @@ 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
32
  <div className={`ermis-typing-indicator${isActive ? ' ermis-typing-indicator--active' : ''}`}>
@@ -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,6 +183,55 @@ export const UserPicker: React.FC<UserPickerProps> = ({
176
183
  let active = true;
177
184
  const fetchUsers = async () => {
178
185
  if (!client) return;
186
+
187
+ const cacheKey = friendsOnly
188
+ ? `${client.userID || 'anon'}-friends`
189
+ : `${client.userID || 'anon'}-${pageSize}`;
190
+
191
+ if (globalUsersCache[cacheKey] && globalUsersCache[cacheKey].users.length > 0) {
192
+ const cached = globalUsersCache[cacheKey];
193
+ setAllUsers(cached.users);
194
+ setHasMore(cached.hasMore);
195
+ setPage(cached.page);
196
+ setLoading(false);
197
+ return;
198
+ }
199
+
200
+ if (friendsOnly) {
201
+ const friends: UserPickerUser[] = [];
202
+ const seenIds = new Set<string>();
203
+
204
+ for (const channel of Object.values(client.activeChannels)) {
205
+ const members = channel.state?.members;
206
+ if (!members) continue;
207
+
208
+ for (const [memberId, member] of Object.entries(members)) {
209
+ if (memberId === client.userID) continue;
210
+
211
+ if (isFriendChannel(channel, memberId, client.userID as string) && !seenIds.has(memberId)) {
212
+ if (member.user) {
213
+ friends.push(member.user as UserPickerUser);
214
+ seenIds.add(memberId);
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ if (active) {
221
+ setAllUsers(friends);
222
+ setHasMore(false);
223
+ setPage(1);
224
+ setLoading(false);
225
+
226
+ globalUsersCache[cacheKey] = {
227
+ users: friends,
228
+ page: 1,
229
+ hasMore: false,
230
+ };
231
+ }
232
+ return;
233
+ }
234
+
179
235
  try {
180
236
  setLoading(true);
181
237
  const response = await client.queryUsers(String(pageSize), 1);
@@ -183,6 +239,12 @@ export const UserPicker: React.FC<UserPickerProps> = ({
183
239
  setAllUsers(response.data);
184
240
  setHasMore(response.data.length >= pageSize);
185
241
  setPage(1);
242
+
243
+ globalUsersCache[cacheKey] = {
244
+ users: response.data,
245
+ page: 1,
246
+ hasMore: response.data.length >= pageSize
247
+ };
186
248
  }
187
249
  } catch (err) {
188
250
  console.error('[UserPicker] Error fetching users:', err);
@@ -205,7 +267,18 @@ export const UserPicker: React.FC<UserPickerProps> = ({
205
267
  setAllUsers(prev => {
206
268
  const existingIds = new Set(prev.map(u => u.id));
207
269
  const newUsers = response.data.filter((u: UserPickerUser) => !existingIds.has(u.id));
208
- return [...prev, ...newUsers];
270
+ const combined = [...prev, ...newUsers];
271
+
272
+ if (client) {
273
+ const cacheKey = `${client.userID || 'anon'}-${pageSize}`;
274
+ globalUsersCache[cacheKey] = {
275
+ users: combined,
276
+ page: nextPage,
277
+ hasMore: response.data.length >= pageSize
278
+ };
279
+ }
280
+
281
+ return combined;
209
282
  });
210
283
  setHasMore(response.data.length >= pageSize);
211
284
  setPage(nextPage);
@@ -219,19 +292,19 @@ export const UserPicker: React.FC<UserPickerProps> = ({
219
292
 
220
293
  /* ---------- 3. Local filter ---------- */
221
294
  const localFilteredUsers = useMemo(() => {
222
- const term = search.toLowerCase().trim();
295
+ const term = removeAccents(search.toLowerCase().trim());
223
296
  if (!term) return allUsers;
224
297
  return allUsers.filter(u => {
225
- const name = (u.name || '').toLowerCase();
226
- const email = (u.email || '').toLowerCase();
227
- const phone = (u.phone || '').toLowerCase();
298
+ const name = removeAccents((u.name || '').toLowerCase());
299
+ const email = removeAccents((u.email || '').toLowerCase());
300
+ const phone = removeAccents((u.phone || '').toLowerCase());
228
301
  return name.includes(term) || email.includes(term) || phone.includes(term);
229
302
  });
230
303
  }, [search, allUsers]);
231
304
 
232
305
  /* ---------- 4. Remote search fallback ---------- */
233
306
  useEffect(() => {
234
- if (!search.trim() || localFilteredUsers.length > 0) {
307
+ if (!search.trim() || localFilteredUsers.length > 0 || friendsOnly) {
235
308
  setRemoteUsers([]);
236
309
  setIsSearching(false);
237
310
  return;
@@ -259,9 +332,13 @@ export const UserPicker: React.FC<UserPickerProps> = ({
259
332
  }, [search, localFilteredUsers.length, client]);
260
333
 
261
334
  /* ---------- 5. Derived display list ---------- */
262
- const usersToDisplay = (search.trim() && localFilteredUsers.length === 0)
263
- ? remoteUsers
264
- : localFilteredUsers;
335
+ const usersToDisplay = useMemo(() => {
336
+ const list = (search.trim() && localFilteredUsers.length === 0)
337
+ ? remoteUsers
338
+ : localFilteredUsers;
339
+ return list.filter(u => !excludeSet.has(u.id));
340
+ }, [search, localFilteredUsers, remoteUsers, excludeSet]);
341
+
265
342
  const isListLoading = loading || isSearching || isPendingFilter;
266
343
 
267
344
  /* ---------- 6. Selection handlers ---------- */
@@ -1,5 +1,8 @@
1
1
  import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
2
- import { VList, type VListHandle } from 'virtua';
2
+ import { VList as _VList, type VListHandle } from 'virtua';
3
+
4
+ // Workaround for React 19 JSX element type mismatch with virtua's VList
5
+ const VList = _VList as any;
3
6
  import type { MessageLabel } from '@ermis-network/ermis-chat-sdk';
4
7
  import { useChatClient } from '../hooks/useChatClient';
5
8
  import { useBannedState } from '../hooks/useBannedState';
@@ -110,6 +113,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
110
113
  messageRenderers: customRenderers,
111
114
  loadMoreLimit = 25,
112
115
  DateSeparatorComponent = DefaultDateSeparator,
116
+ dateLocale,
113
117
  MessageItemComponent = MessageItem,
114
118
  SystemMessageItemComponent = SystemMessageItem,
115
119
  JumpToLatestButton = DefaultJumpToLatest,
@@ -144,15 +148,28 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
144
148
  closedTopicReopenLabel = 'Reopen Topic',
145
149
  PendingInviteeNotificationComponent = DefaultPendingInviteeNotification,
146
150
  pendingInviteeLabel,
151
+ pinnedMessagesLabel,
152
+ seeAllLabel,
153
+ collapseLabel,
154
+ unpinLabel,
155
+ stickerLabel,
156
+ typingIndicatorLabel,
157
+ deletedMessageLabel = 'This message was deleted',
158
+ systemMessageTranslations,
159
+ signalMessageTranslations,
160
+ includeHiddenMessages = true,
161
+ onMentionClick,
162
+ onUserNameClick,
163
+ onAddReactionClick,
147
164
  }) => {
148
165
  const { client, messages, readState, activeChannel, setActiveChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
149
166
  const { isBanned } = useBannedState(activeChannel, client.userID);
150
167
  const { isBlocked } = useBlockedState(activeChannel, client.userID);
151
- const { isPending } = usePendingState(activeChannel, client.userID);
152
-
153
- const isSkipped = client.userID
154
- ? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
155
- isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
168
+ const { isPending, inviteUpdateCount } = usePendingState(activeChannel, client.userID);
169
+
170
+ const isSkipped = client.userID
171
+ ? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
172
+ isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
156
173
  : false;
157
174
 
158
175
  const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
@@ -179,7 +196,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
179
196
  }
180
197
  }
181
198
  return null;
182
- }, [activeChannel, currentUserId, isPending]);
199
+ }, [activeChannel, currentUserId, isPending, inviteUpdateCount]);
183
200
 
184
201
  // Ref to scope DOM queries (safe for multiple instances)
185
202
  const containerRef = useRef<HTMLDivElement>(null);
@@ -190,13 +207,51 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
190
207
  const handleAcceptInvite = useCallback(async () => {
191
208
  if (!activeChannel) return;
192
209
  try {
193
- const isPublicTeamOrMeeting = isPublicGroupChannel(activeChannel);
194
- const action = isPublicTeamOrMeeting ? 'join' : 'accept';
210
+ let action: 'join' | 'accept' = 'accept';
211
+ if (isPublicGroupChannel(activeChannel)) {
212
+ const isMember = !!(currentUserId && activeChannel.state?.members?.[currentUserId]);
213
+ action = isMember ? 'accept' : 'join';
214
+ }
195
215
  await activeChannel.acceptInvite(action);
216
+
217
+ // Optimistically update local membership so React picks up the change immediately.
218
+ // The async _handleChannelEvent in the SDK races with client listeners,
219
+ // so the WS event alone is not reliable for updating React state in time.
220
+ if (activeChannel.state && currentUserId) {
221
+ const updatedMembership = {
222
+ ...activeChannel.state.membership,
223
+ channel_role: 'member',
224
+ user_id: currentUserId,
225
+ } as Record<string, unknown>;
226
+ activeChannel.state.membership = updatedMembership;
227
+
228
+ if (activeChannel.state.members?.[currentUserId]) {
229
+ activeChannel.state.members[currentUserId] = {
230
+ ...activeChannel.state.members[currentUserId],
231
+ channel_role: 'member',
232
+ };
233
+ }
234
+
235
+ // Dispatch synthetic event so all React listeners update
236
+ const clientObj = activeChannel.getClient();
237
+ const eventType = action === 'join' ? 'member.joined' : 'notification.invite_accepted';
238
+ clientObj.dispatchEvent({
239
+ type: eventType,
240
+ cid: activeChannel.cid,
241
+ channel_type: activeChannel.type,
242
+ channel_id: activeChannel.id,
243
+ channel: activeChannel.data,
244
+ member: updatedMembership,
245
+ user: clientObj.user,
246
+ } as any);
247
+ }
248
+
249
+ // Re-watch to get full fresh state from server
250
+ activeChannel.watch().catch(() => {});
196
251
  } catch (e: any) {
197
252
  console.error('Error accepting invite', e);
198
253
  }
199
- }, [activeChannel]);
254
+ }, [activeChannel, currentUserId]);
200
255
 
201
256
  const handleRejectInvite = useCallback(async () => {
202
257
  if (!activeChannel) return;
@@ -281,6 +336,8 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
281
336
  loadingMoreRef.current = false;
282
337
  loadingNewerRef.current = false;
283
338
  }, [setHasMore, setHasNewer]),
339
+ includeHiddenMessages,
340
+ containerRef,
284
341
  });
285
342
 
286
343
  const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked || isSkipped);
@@ -336,7 +393,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
336
393
  const showDateSeparator =
337
394
  !prevMsg || getDateKey(message.created_at) !== getDateKey(prevMsg.created_at);
338
395
  const dateSeparator = showDateSeparator ? (
339
- <DateSeparatorComponent label={formatDateLabel(message.created_at)} />
396
+ <DateSeparatorComponent label={formatDateLabel(message.created_at, dateLocale)} />
340
397
  ) : null;
341
398
 
342
399
  if (renderMessage) {
@@ -356,6 +413,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
356
413
  message={message}
357
414
  isOwnMessage={isOwnMessage}
358
415
  SystemRenderer={renderers.system}
416
+ systemMessageTranslations={systemMessageTranslations}
359
417
  />
360
418
  </div>
361
419
  );
@@ -363,12 +421,16 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
363
421
 
364
422
  // Message grouping
365
423
  const prevType = (prevMsg?.type || 'regular') as MessageLabel;
424
+ const prevValidReaders = prevMsg?.id && readByMap[prevMsg.id] ? readByMap[prevMsg.id].filter(r => r.id !== getMessageUserId(prevMsg)) : [];
425
+ const prevHasReaders = showReadReceipts && prevValidReaders.length > 0;
426
+
366
427
  const isFirstInGroup =
367
428
  showDateSeparator ||
368
429
  !prevMsg ||
369
430
  prevType === 'system' ||
370
431
  prevType === 'signal' ||
371
- getMessageUserId(prevMsg) !== getMessageUserId(message);
432
+ getMessageUserId(prevMsg) !== getMessageUserId(message) ||
433
+ prevHasReaders;
372
434
 
373
435
  const nextMsg = index < messages.length - 1 ? messages[index + 1] : null;
374
436
  const nextType = (nextMsg?.type || 'regular') as MessageLabel;
@@ -376,12 +438,16 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
376
438
  ? getDateKey(nextMsg.created_at) !== getDateKey(message.created_at)
377
439
  : false;
378
440
 
441
+ const validReaders = message.id && readByMap[message.id] ? readByMap[message.id].filter(r => r.id !== getMessageUserId(message)) : [];
442
+ const hasReaders = showReadReceipts && validReaders.length > 0;
443
+
379
444
  const isLastInGroup =
380
445
  !nextMsg ||
381
446
  nextShowDateSeparator ||
382
447
  nextType === 'system' ||
383
448
  nextType === 'signal' ||
384
- getMessageUserId(nextMsg) !== getMessageUserId(message);
449
+ getMessageUserId(nextMsg) !== getMessageUserId(message) ||
450
+ hasReaders;
385
451
 
386
452
  const MessageRenderer = renderers[messageType] || renderers.regular;
387
453
 
@@ -401,11 +467,17 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
401
467
  QuotedMessagePreviewComponent={QuotedMessagePreviewComponent}
402
468
  MessageActionsBoxComponent={MessageActionsBoxComponent}
403
469
  MessageReactionsComponent={MessageReactionsComponent}
470
+ deletedMessageLabel={deletedMessageLabel}
471
+ systemMessageTranslations={systemMessageTranslations}
472
+ signalMessageTranslations={signalMessageTranslations}
473
+ onMentionClick={onMentionClick}
474
+ onUserNameClick={onUserNameClick}
475
+ onAddReactionClick={onAddReactionClick}
404
476
  />
405
477
  {/* Read receipts — full width, right-aligned */}
406
- {showReadReceipts && (
478
+ {showReadReceipts && validReaders.length > 0 && (
407
479
  <ReadReceiptsComponent
408
- readers={readByMap[message.id!] || []}
480
+ readers={validReaders}
409
481
  maxAvatars={readReceiptsMaxAvatars}
410
482
  AvatarComponent={AvatarComponent}
411
483
  TooltipComponent={ReadReceiptsTooltipComponent}
@@ -437,6 +509,10 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
437
509
  ReadReceiptsComponent,
438
510
  ReadReceiptsTooltipComponent,
439
511
  readReceiptsMaxAvatars,
512
+ dateLocale,
513
+ onMentionClick,
514
+ onUserNameClick,
515
+ onAddReactionClick,
440
516
  ]);
441
517
 
442
518
  if (isBanned || isBlocked) {
@@ -499,7 +575,17 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
499
575
 
500
576
  return (
501
577
  <div ref={containerRef} className={`ermis-message-list${className ? ` ${className}` : ''}`}>
502
- {showPinnedMessages && <PinnedMessagesComponent onClickMessage={scrollToMessage} AvatarComponent={AvatarComponent} />}
578
+ {showPinnedMessages && (
579
+ <PinnedMessagesComponent
580
+ onClickMessage={scrollToMessage}
581
+ AvatarComponent={AvatarComponent}
582
+ pinnedMessagesLabel={pinnedMessagesLabel}
583
+ seeAllLabel={seeAllLabel}
584
+ collapseLabel={collapseLabel}
585
+ unpinLabel={unpinLabel}
586
+ stickerLabel={stickerLabel}
587
+ />
588
+ )}
503
589
 
504
590
  {messages.length === 0 && (
505
591
  EmptyStateIndicator === DefaultEmpty
@@ -508,9 +594,9 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
508
594
  )}
509
595
 
510
596
  {pendingInviteeName && (
511
- <PendingInviteeNotificationComponent
512
- inviteeName={pendingInviteeName}
513
- label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
597
+ <PendingInviteeNotificationComponent
598
+ inviteeName={pendingInviteeName}
599
+ label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
514
600
  />
515
601
  )}
516
602
 
@@ -525,7 +611,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
525
611
  </VList>
526
612
 
527
613
  {/* Typing indicator */}
528
- {showTypingIndicator && <TypingIndicatorComponent />}
614
+ {showTypingIndicator && <TypingIndicatorComponent typingIndicatorLabel={typingIndicatorLabel} />}
529
615
 
530
616
  {/* Jump to latest button */}
531
617
  {hasNewer && (