@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,309 @@
1
+ import { useState, useMemo, useCallback } from 'react';
2
+ import type {
3
+ Channel,
4
+ E2eeAttachmentManifest,
5
+ E2eeAttachmentManifestAsset,
6
+ FormatMessageResponse,
7
+ } from '@ermis-network/ermis-chat-sdk';
8
+ import { createForwardMessagePayload } from '@ermis-network/ermis-chat-sdk';
9
+ import { useChatClient } from './useChatClient';
10
+ import { removeAccents, buildUserMap } from '../utils';
11
+ import { isPendingMember, isSkippedMember } from '../channelRoleUtils';
12
+
13
+ function isE2eeAttachmentManifest(attachment: unknown): attachment is E2eeAttachmentManifest {
14
+ return Boolean(
15
+ attachment &&
16
+ typeof attachment === 'object' &&
17
+ (attachment as E2eeAttachmentManifest).version === 1 &&
18
+ typeof (attachment as E2eeAttachmentManifest).attachment_id === 'string' &&
19
+ Array.isArray((attachment as E2eeAttachmentManifest).assets),
20
+ );
21
+ }
22
+
23
+ function isEffectiveE2ee(channel: Channel | null | undefined): boolean {
24
+ if (!channel) return false;
25
+ return typeof (channel as any)._isEffectiveE2ee === 'function'
26
+ ? (channel as any)._isEffectiveE2ee()
27
+ : channel.data?.mls_enabled === true;
28
+ }
29
+
30
+ function attachmentUrl(attachment: any): string | undefined {
31
+ return attachment?.asset_url || attachment?.image_url || attachment?.url || attachment?.thumb_url;
32
+ }
33
+
34
+ function originalAsset(attachment: E2eeAttachmentManifest): E2eeAttachmentManifestAsset | undefined {
35
+ return attachment.assets.find((asset) => asset.kind === 'original') || attachment.assets[0];
36
+ }
37
+
38
+ function displayString(display: Record<string, unknown> | undefined, key: string): string | undefined {
39
+ const value = display?.[key];
40
+ return typeof value === 'string' && value.trim() ? value : undefined;
41
+ }
42
+
43
+ function displayNumber(display: Record<string, unknown> | undefined, key: string): number | undefined {
44
+ const value = display?.[key];
45
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
46
+ }
47
+
48
+ function extensionForMimeType(mimeType?: string): string {
49
+ if (!mimeType) return '';
50
+ if (mimeType === 'image/jpeg') return '.jpg';
51
+ if (mimeType === 'image/png') return '.png';
52
+ if (mimeType === 'image/webp') return '.webp';
53
+ if (mimeType === 'image/gif') return '.gif';
54
+ if (mimeType === 'video/mp4') return '.mp4';
55
+ if (mimeType === 'video/quicktime') return '.mov';
56
+ if (mimeType === 'video/webm') return '.webm';
57
+ if (mimeType === 'audio/mpeg') return '.mp3';
58
+ if (mimeType === 'audio/webm') return '.webm';
59
+ return '';
60
+ }
61
+
62
+ function inferAttachmentType(attachment: any, mimeType?: string): string | undefined {
63
+ const explicitType = attachment?.attachment_type || attachment?.type;
64
+ if (explicitType === 'voiceRecording') return 'voiceRecording';
65
+ if (explicitType === 'image' || explicitType === 'video' || explicitType === 'file') return explicitType;
66
+ if (mimeType?.startsWith('image/')) return 'image';
67
+ if (mimeType?.startsWith('video/')) return 'video';
68
+ if (mimeType?.startsWith('audio/')) return 'file';
69
+ return undefined;
70
+ }
71
+
72
+ function sourceAttachmentMetadata(
73
+ attachment: any,
74
+ blob: Blob,
75
+ index: number,
76
+ ): { file: File; displayOverride: Record<string, unknown> } {
77
+ const manifestDisplay = isE2eeAttachmentManifest(attachment) ? originalAsset(attachment)?.display : undefined;
78
+ const sourceName =
79
+ displayString(manifestDisplay, 'name') ||
80
+ attachment?.file_name ||
81
+ attachment?.title ||
82
+ attachment?.name ||
83
+ `forwarded-attachment-${index + 1}`;
84
+ const sourceMime =
85
+ displayString(manifestDisplay, 'mime_type') ||
86
+ attachment?.mime_type ||
87
+ attachment?.content_type ||
88
+ blob.type ||
89
+ 'application/octet-stream';
90
+ const hasExtension = /\.[A-Za-z0-9]{1,8}$/.test(sourceName);
91
+ const name = hasExtension ? sourceName : `${sourceName}${extensionForMimeType(sourceMime)}`;
92
+ const normalizedBlob = new File([blob], name, { type: sourceMime || blob.type || 'application/octet-stream' });
93
+ const attachmentType = inferAttachmentType(attachment, sourceMime);
94
+ const width = displayNumber(manifestDisplay, 'width') || attachment?.original_width || attachment?.width;
95
+ const height = displayNumber(manifestDisplay, 'height') || attachment?.original_height || attachment?.height;
96
+ const duration = displayNumber(manifestDisplay, 'duration') || attachment?.duration;
97
+ const displayOverride: Record<string, unknown> = {
98
+ name,
99
+ mime_type: sourceMime,
100
+ size: blob.size,
101
+ ...(attachmentType ? { attachment_type: attachmentType } : {}),
102
+ ...(typeof width === 'number' && Number.isFinite(width) ? { width } : {}),
103
+ ...(typeof height === 'number' && Number.isFinite(height) ? { height } : {}),
104
+ ...(typeof duration === 'number' && Number.isFinite(duration) ? { duration } : {}),
105
+ };
106
+ return { file: normalizedBlob, displayOverride };
107
+ }
108
+
109
+ async function materializeForwardSourceAttachments(
110
+ manager: any,
111
+ sourceChannel: Channel,
112
+ sourceAttachments: any[],
113
+ ): Promise<{ files: File[]; displayOverrides: Map<number, Record<string, unknown>> }> {
114
+ if (!manager?.initialized) throw new Error('E2EE forward requires an initialized encryption manager');
115
+ const files: File[] = [];
116
+ const displayOverrides = new Map<number, Record<string, unknown>>();
117
+
118
+ for (const [index, attachment] of sourceAttachments.entries()) {
119
+ let sourceBlob: Blob;
120
+ if (isE2eeAttachmentManifest(attachment)) {
121
+ sourceBlob = await manager.downloadE2eeAttachmentAsset(
122
+ sourceChannel.type,
123
+ sourceChannel.id!,
124
+ attachment,
125
+ 'original',
126
+ );
127
+ } else {
128
+ const url = attachmentUrl(attachment);
129
+ if (!url) throw new Error('Forward source attachment has no downloadable URL');
130
+ const response = await fetch(url);
131
+ if (!response.ok) throw new Error(`Forward source attachment download failed: HTTP ${response.status}`);
132
+ sourceBlob = await response.blob();
133
+ }
134
+ const { file, displayOverride } = sourceAttachmentMetadata(attachment, sourceBlob, index);
135
+ files.push(file);
136
+ displayOverrides.set(index, displayOverride);
137
+ }
138
+
139
+ return { files, displayOverrides };
140
+ }
141
+
142
+ export function useForwardMessage(message: FormatMessageResponse, onDismiss: () => void) {
143
+ const { client, activeChannel } = useChatClient();
144
+ const [selectedChannels, setSelectedChannels] = useState<Set<string>>(new Set());
145
+ const [search, setSearch] = useState('');
146
+ const [sending, setSending] = useState(false);
147
+ const [results, setResults] = useState<{ success: string[]; failed: string[] } | null>(null);
148
+
149
+ /* ---------- Get channels from client state (include topics) ---------- */
150
+ const channels = useMemo(() => {
151
+ return (Object.values(client.activeChannels) as Channel[]).filter((ch) => {
152
+ const role = ch.state?.membership?.channel_role as string;
153
+ return !isPendingMember(role) && !isSkippedMember(role);
154
+ });
155
+ }, [client.activeChannels]);
156
+
157
+ /* ---------- Filter by search ---------- */
158
+ const filteredChannels = useMemo(() => {
159
+ if (!search.trim()) return channels;
160
+ const q = search.toLowerCase();
161
+ const cleanQ = removeAccents(q);
162
+ const isStrict = q !== cleanQ;
163
+
164
+ const result: Channel[] = [];
165
+ for (const ch of channels) {
166
+ const name = (ch.data?.name || ch.cid) as string;
167
+ const t = name.toLowerCase();
168
+ const cleanT = removeAccents(t);
169
+
170
+ const parentCid = ch.data?.parent_cid as string | undefined;
171
+ const parent = parentCid ? client.activeChannels[parentCid] : null;
172
+ const parentName = parent?.data?.name || '';
173
+ const pt = parentName.toLowerCase();
174
+ const cleanPT = removeAccents(pt);
175
+
176
+ let matched = false;
177
+ if (isStrict) {
178
+ // Strict match when query has accents
179
+ matched = t.startsWith(q) || pt.startsWith(q);
180
+ } else {
181
+ // Broad match when query is accent-less
182
+ matched = cleanT.startsWith(cleanQ) || cleanPT.startsWith(cleanQ);
183
+ }
184
+
185
+ if (matched) {
186
+ result.push(ch);
187
+ if (result.length >= 50) break;
188
+ }
189
+ }
190
+ return result;
191
+ }, [channels, search, client.activeChannels]);
192
+
193
+ /* ---------- Toggle selection ---------- */
194
+ const toggleChannel = useCallback((channel: Channel) => {
195
+ setSelectedChannels((prev) => {
196
+ const next = new Set(prev);
197
+ if (next.has(channel.cid)) {
198
+ next.delete(channel.cid);
199
+ } else {
200
+ next.add(channel.cid);
201
+ }
202
+ return next;
203
+ });
204
+ }, []);
205
+
206
+ /* ---------- Send forward ---------- */
207
+ const handleSend = useCallback(async () => {
208
+ if (!activeChannel || selectedChannels.size === 0 || sending) return;
209
+ setSending(true);
210
+ const success: string[] = [];
211
+ const failed: string[] = [];
212
+ let queuedBackgroundForward = false;
213
+
214
+ // Format message text to replace mention IDs with names
215
+ let formattedMessage = { ...message };
216
+ if (formattedMessage.text && formattedMessage.mentioned_users && formattedMessage.mentioned_users.length > 0) {
217
+ let newText = formattedMessage.text;
218
+ const userMap = buildUserMap(activeChannel.state);
219
+
220
+ formattedMessage.mentioned_users.forEach((userId) => {
221
+ const name = userMap[userId] || client.state.users[userId]?.name || userId;
222
+ newText = newText.replace(new RegExp(`@${userId}`, 'g'), `@${name}`);
223
+ });
224
+ formattedMessage.text = newText;
225
+ }
226
+
227
+ for (const cid of selectedChannels) {
228
+ const targetChannel = channels.find((c) => c.cid === cid);
229
+ if (!targetChannel) continue;
230
+ try {
231
+ if (!['messaging', 'team', 'topic'].includes(activeChannel.type)) {
232
+ throw new Error('Forward source channel type is not allowed');
233
+ }
234
+ if (formattedMessage.quoted_message_id || formattedMessage.parent_id) {
235
+ throw new Error('Reply/thread messages cannot be forwarded');
236
+ }
237
+ if (formattedMessage.mentioned_all || (formattedMessage.mentioned_users?.length || 0) > 0) {
238
+ throw new Error('Mention messages cannot be forwarded');
239
+ }
240
+ const targetIsE2ee = isEffectiveE2ee(targetChannel);
241
+ const sourceIsE2ee = isEffectiveE2ee(activeChannel);
242
+ if (sourceIsE2ee && !targetIsE2ee) {
243
+ const accepted =
244
+ typeof window === 'undefined' ||
245
+ window.confirm('Forwarding this encrypted message to a standard channel will remove E2EE protection.');
246
+ if (!accepted) throw new Error('Privacy downgrade canceled');
247
+ }
248
+ const forwardPayload = createForwardMessagePayload(
249
+ formattedMessage,
250
+ targetChannel.cid as string,
251
+ activeChannel.cid as string,
252
+ );
253
+
254
+ const sourceAttachments = (formattedMessage.attachments as any[] | undefined) || [];
255
+ if (targetIsE2ee && sourceAttachments.length > 0) {
256
+ const { files, displayOverrides } = await materializeForwardSourceAttachments(
257
+ client.encryptionManager,
258
+ activeChannel,
259
+ sourceAttachments,
260
+ );
261
+ await (targetChannel as any).enqueueE2eeAttachmentMessage(forwardPayload, files, { displayOverrides });
262
+ queuedBackgroundForward = true;
263
+ } else {
264
+ const standardForwardPayload = { ...forwardPayload };
265
+ if (!targetIsE2ee && sourceIsE2ee && sourceAttachments.length > 0) {
266
+ const { files } = await materializeForwardSourceAttachments(
267
+ client.encryptionManager,
268
+ activeChannel,
269
+ sourceAttachments,
270
+ );
271
+ const { attachments, failedFiles } = await targetChannel.uploadAndPrepareAttachments(files);
272
+ if (failedFiles.length > 0) {
273
+ throw new Error(`Forward standard attachment upload failed for ${failedFiles.length} file(s)`);
274
+ }
275
+ standardForwardPayload.attachments = attachments as any;
276
+ }
277
+ await targetChannel.forwardMessage(standardForwardPayload, {
278
+ type: targetChannel.type,
279
+ channelID: targetChannel.id!,
280
+ });
281
+ }
282
+ success.push((targetChannel.data?.name || targetChannel.cid) as string);
283
+ } catch (err) {
284
+ console.error(`Failed to forward to ${cid}`, err);
285
+ failed.push((targetChannel.data?.name || targetChannel.cid) as string);
286
+ }
287
+ }
288
+
289
+ setResults({ success, failed });
290
+ setSending(false);
291
+
292
+ // Auto-close after success (short delay)
293
+ if (failed.length === 0) {
294
+ setTimeout(() => onDismiss(), queuedBackgroundForward ? 0 : 1200);
295
+ }
296
+ }, [client, activeChannel, selectedChannels, channels, message, sending, onDismiss]);
297
+
298
+ return {
299
+ search,
300
+ setSearch,
301
+ selectedChannels,
302
+ toggleChannel,
303
+ sending,
304
+ results,
305
+ setResults,
306
+ filteredChannels,
307
+ handleSend,
308
+ };
309
+ }
@@ -0,0 +1,88 @@
1
+ import { useEffect, useState, useMemo } from 'react';
2
+ import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
+ import { useChatClient } from './useChatClient';
4
+ import { isPendingMember } from '../channelRoleUtils';
5
+ import { isTopicChannel } from '../channelTypeUtils';
6
+
7
+ /**
8
+ * A hook that retrieves all pending invite channels from the SDK's local cache
9
+ * without triggering an extra API network query.
10
+ *
11
+ * Re-renders automatically when related events (e.g., invites, accepts, deletes) arrive.
12
+ */
13
+ export function useInviteChannels(): Channel[] {
14
+ const { client } = useChatClient();
15
+ const [updateCount, setUpdateCount] = useState(0);
16
+
17
+ useEffect(() => {
18
+ if (!client) return;
19
+
20
+ const forceUpdate = () => setUpdateCount((c) => c + 1);
21
+
22
+ const handleEvent = (event: any) => {
23
+ // If a new channel is created or we are added to it, wait for SDK initialization
24
+ const isNewChannelEvent =
25
+ event.type === 'member.added' ||
26
+ event.type === 'notification.added_to_channel' ||
27
+ event.type === 'channel.created';
28
+
29
+ if (isNewChannelEvent) {
30
+ const cid =
31
+ event.channel?.cid ||
32
+ event.cid ||
33
+ (event.channel_type && event.channel_id ? `${event.channel_type}:${event.channel_id}` : null);
34
+
35
+ if (cid) {
36
+ console.log('[useInviteChannels] Received new channel event:', event.type, cid);
37
+ let attempts = 0;
38
+ const checkInitialized = setInterval(() => {
39
+ attempts++;
40
+ const channel = client.activeChannels[cid];
41
+ if ((channel && channel.initialized) || attempts > 30) {
42
+ console.log(
43
+ '[useInviteChannels] Channel initialized or timeout:',
44
+ cid,
45
+ 'initialized:',
46
+ channel?.initialized,
47
+ 'attempts:',
48
+ attempts,
49
+ );
50
+ clearInterval(checkInitialized);
51
+ forceUpdate();
52
+ }
53
+ }, 100);
54
+ return;
55
+ }
56
+ }
57
+ setTimeout(forceUpdate, 0);
58
+ };
59
+
60
+ const listeners = [
61
+ client.on('channels.queried', handleEvent),
62
+ client.on('notification.invite_accepted', handleEvent),
63
+ client.on('notification.invite_rejected', handleEvent),
64
+ client.on('notification.invite_messaging_skipped', handleEvent),
65
+ client.on('channel.created', handleEvent),
66
+ client.on('channel.deleted', handleEvent),
67
+ client.on('notification.channel_deleted', handleEvent),
68
+ client.on('member.added', handleEvent),
69
+ client.on('member.removed', handleEvent),
70
+ client.on('notification.added_to_channel' as any, handleEvent),
71
+ client.on('notification.invited' as any, handleEvent),
72
+ ];
73
+
74
+ return () => listeners.forEach((l) => l.unsubscribe());
75
+ }, [client]);
76
+
77
+ return useMemo(() => {
78
+ if (!client) return [];
79
+
80
+ return Object.values(client.activeChannels).filter((ch) => {
81
+ // Exclude topic channels from the invites list
82
+ if (isTopicChannel(ch)) return false;
83
+
84
+ const ms = ch.state?.membership as Record<string, unknown> | undefined;
85
+ return isPendingMember(ms?.channel_role as string);
86
+ });
87
+ }, [client, updateCount]);
88
+ }
@@ -0,0 +1,104 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useChatClient } from './useChatClient';
3
+ import { isPendingMember } from '../channelRoleUtils';
4
+ import { isTopicChannel } from '../channelTypeUtils';
5
+
6
+ export const useInviteCount = () => {
7
+ const { client } = useChatClient();
8
+ const [inviteCount, setInviteCount] = useState(0);
9
+
10
+ useEffect(() => {
11
+ if (!client || !client.user) return;
12
+
13
+ const countInvites = () => {
14
+ let count = 0;
15
+ const channels = Object.values(client.activeChannels);
16
+ const userId = client.user?.id;
17
+ if (!userId) return 0;
18
+ for (const channel of channels) {
19
+ // Exclude topic channels from the count
20
+ if (isTopicChannel(channel)) continue;
21
+
22
+ const membership = channel.state?.membership || channel.state?.members?.[userId];
23
+ if (isPendingMember(membership?.channel_role as string)) {
24
+ count++;
25
+ }
26
+ }
27
+ return count;
28
+ };
29
+
30
+ // Calculate initial count
31
+ setInviteCount(countInvites());
32
+
33
+ const handleEvent = (event: any) => {
34
+ if (
35
+ event.type === 'channel.created' &&
36
+ (event.user?.id === client.user?.id || event.user_id === client.user?.id)
37
+ ) {
38
+ return;
39
+ }
40
+
41
+ // If a new channel is created or we are added to it, wait for SDK initialization
42
+ const isNewChannelEvent =
43
+ event.type === 'member.added' ||
44
+ event.type === 'notification.added_to_channel' ||
45
+ event.type === 'channel.created';
46
+
47
+ if (isNewChannelEvent) {
48
+ const cid =
49
+ event.channel?.cid ||
50
+ event.cid ||
51
+ (event.channel_type && event.channel_id ? `${event.channel_type}:${event.channel_id}` : null);
52
+
53
+ if (cid) {
54
+ console.log('[useInviteCount] Received new channel event:', event.type, cid);
55
+ let attempts = 0;
56
+ const checkInitialized = setInterval(() => {
57
+ attempts++;
58
+ const channel = client.activeChannels[cid];
59
+ if ((channel && channel.initialized) || attempts > 30) {
60
+ console.log(
61
+ '[useInviteCount] Channel initialized or timeout:',
62
+ cid,
63
+ 'initialized:',
64
+ channel?.initialized,
65
+ 'attempts:',
66
+ attempts,
67
+ );
68
+ clearInterval(checkInitialized);
69
+ const newCount = countInvites();
70
+ console.log('[useInviteCount] New invite count:', newCount);
71
+ setInviteCount(newCount);
72
+ }
73
+ }, 100);
74
+ return;
75
+ }
76
+ }
77
+
78
+ // Delay slightly to ensure client.activeChannels is updated by SDK internal handlers first
79
+ setTimeout(() => {
80
+ setInviteCount(countInvites());
81
+ }, 0);
82
+ };
83
+
84
+ const listeners = [
85
+ client.on('channels.queried', handleEvent),
86
+ client.on('notification.invite_accepted', handleEvent),
87
+ client.on('notification.invite_rejected', handleEvent),
88
+ client.on('notification.invite_messaging_skipped', handleEvent),
89
+ client.on('channel.created', handleEvent),
90
+ client.on('channel.deleted', handleEvent),
91
+ client.on('notification.channel_deleted', handleEvent),
92
+ client.on('member.added', handleEvent),
93
+ client.on('member.removed', handleEvent),
94
+ client.on('notification.added_to_channel' as any, handleEvent),
95
+ client.on('notification.invited' as any, handleEvent),
96
+ ];
97
+
98
+ return () => {
99
+ listeners.forEach((l) => l.unsubscribe());
100
+ };
101
+ }, [client]);
102
+
103
+ return { inviteCount };
104
+ };
@@ -21,6 +21,8 @@ export type UseLoadMessagesOptions = {
21
21
  messagesRef: React.MutableRefObject<FormatMessageResponse[]>;
22
22
  /** Shared guard ref — skip scroll-triggered loads during jump transitions */
23
23
  jumpingRef: React.MutableRefObject<boolean>;
24
+ /** Blocks scroll-triggered pagination while auto-following appended messages. */
25
+ scrollLoadLockRef?: React.MutableRefObject<boolean>;
24
26
  loadMoreLimit?: number;
25
27
  };
26
28
 
@@ -45,6 +47,7 @@ export function useLoadMessages({
45
47
  vlistRef,
46
48
  messagesRef,
47
49
  jumpingRef,
50
+ scrollLoadLockRef,
48
51
  loadMoreLimit = 25,
49
52
  }: UseLoadMessagesOptions): UseLoadMessagesReturn {
50
53
  const { activeChannel, setMessages } = useChatClient();
@@ -52,10 +55,19 @@ export function useLoadMessages({
52
55
  const [hasNewer, setHasNewer] = useState(false);
53
56
  const [shiftMode, setShiftMode] = useState(false);
54
57
 
55
- // Auto-reset shiftMode after each prepend render
58
+ // Reset shiftMode on channel switch so initial load isn't treated as a prepend
59
+ useEffect(() => {
60
+ setShiftMode(false);
61
+ }, [activeChannel?.cid]);
62
+
63
+ // Reset shiftMode to false after the prepend render is committed.
64
+ // shift should only be true for the single render cycle when older messages
65
+ // are prepended; leaving it on causes virtua to mis-compensate scroll
66
+ // positions when new messages are appended at the bottom, which leads
67
+ // to intermittent message overlapping.
56
68
  useEffect(() => {
57
69
  if (shiftMode) {
58
- requestAnimationFrame(() => setShiftMode(false));
70
+ setShiftMode(false);
59
71
  }
60
72
  }, [shiftMode]);
61
73
 
@@ -135,7 +147,7 @@ export function useLoadMessages({
135
147
 
136
148
  const handleScroll = useCallback(
137
149
  (offset: number) => {
138
- if (jumpingRef.current) return;
150
+ if (jumpingRef.current || scrollLoadLockRef?.current) return;
139
151
  const handle = vlistRef.current;
140
152
  if (!handle) return;
141
153
  const { scrollSize, viewportSize } = handle;
@@ -157,7 +169,7 @@ export function useLoadMessages({
157
169
  loadNewer();
158
170
  }
159
171
  },
160
- [loadMore, loadNewer],
172
+ [loadMore, loadNewer, scrollLoadLockRef],
161
173
  );
162
174
 
163
175
  return {
@@ -119,6 +119,31 @@ function getActiveMentionIds(editableEl: HTMLElement): Set<string> {
119
119
  return ids;
120
120
  }
121
121
 
122
+ /**
123
+ * Binary search to find the first index where the name starts with the given prefix.
124
+ */
125
+ function findFirstMatchIndex(arr: MentionMember[], prefix: string): number {
126
+ let low = 0;
127
+ let high = arr.length - 1;
128
+ let result = -1;
129
+
130
+ while (low <= high) {
131
+ const mid = Math.floor((low + high) / 2);
132
+ const name = (arr[mid].name || '').toLowerCase();
133
+
134
+ if (name.startsWith(prefix)) {
135
+ result = mid; // Found match, keep searching left for the first one
136
+ high = mid - 1;
137
+ } else if (name < prefix) {
138
+ low = mid + 1;
139
+ } else {
140
+ high = mid - 1;
141
+ }
142
+ }
143
+
144
+ return result;
145
+ }
146
+
122
147
  export function useMentions({
123
148
  members,
124
149
  currentUserId,
@@ -137,6 +162,17 @@ export function useMentions({
137
162
  [],
138
163
  );
139
164
 
165
+ // Pre-sort members for binary range search
166
+ const sortedMembers = useMemo(() => {
167
+ return [...members].sort((a, b) => {
168
+ const nameA = (a.name || '').toLowerCase();
169
+ const nameB = (b.name || '').toLowerCase();
170
+ if (nameA < nameB) return -1;
171
+ if (nameA > nameB) return 1;
172
+ return 0;
173
+ });
174
+ }, [members]);
175
+
140
176
  // Filter members based on deferred query, exclude self and already-mentioned
141
177
  const filteredMembers = useMemo(() => {
142
178
  const q = deferredQuery.toLowerCase();
@@ -144,20 +180,37 @@ export function useMentions({
144
180
  // Start with @all if not already selected
145
181
  const result: MentionMember[] = [];
146
182
  if (!activeMentionIds.has('__all__')) {
147
- if (!q || 'all'.includes(q)) {
183
+ if (!q || 'all'.startsWith(q)) {
148
184
  result.push(allItem);
149
185
  }
150
186
  }
151
187
 
152
- for (const m of members) {
153
- if (m.id === currentUserId) continue; // skip self
154
- if (activeMentionIds.has(m.id)) continue; // skip already mentioned
155
- if (q && !m.name.toLowerCase().includes(q)) continue; // filter by query
156
- result.push(m);
188
+ if (!q) {
189
+ for (const m of sortedMembers) {
190
+ if (m.id === currentUserId) continue; // skip self
191
+ result.push(m);
192
+ if (result.length >= 50) break;
193
+ }
194
+ return result;
195
+ }
196
+
197
+ // Range search using binary search
198
+ const startIndex = findFirstMatchIndex(sortedMembers, q);
199
+ if (startIndex !== -1) {
200
+ for (let i = startIndex; i < sortedMembers.length; i++) {
201
+ const m = sortedMembers[i];
202
+ const name = (m.name || '').toLowerCase();
203
+
204
+ if (!name.startsWith(q)) break; // End of range
205
+
206
+ if (m.id === currentUserId) continue; // skip self
207
+ result.push(m);
208
+ if (result.length >= 50) break;
209
+ }
157
210
  }
158
211
 
159
212
  return result;
160
- }, [members, deferredQuery, activeMentionIds, currentUserId, allItem]);
213
+ }, [sortedMembers, deferredQuery, activeMentionIds, currentUserId, allItem]);
161
214
 
162
215
  // Detect @ trigger from cursor position
163
216
  const detectTrigger = useCallback((): { triggered: boolean; query: string } => {