@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
@@ -1,5 +1,8 @@
1
- import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
2
- import { VList, type VListHandle } from 'virtua';
1
+ import React, { useState, useRef, useCallback, useMemo, useEffect, useLayoutEffect } from 'react';
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';
@@ -18,6 +21,7 @@ import {
18
21
  defaultMessageRenderers,
19
22
  type MessageBubbleProps,
20
23
  } from './MessageRenderers';
24
+ import { isStickerMessage } from '../messageTypeUtils';
21
25
  import { getDateKey, formatDateLabel, getMessageUserId, formatReadTimestamp } from '../utils';
22
26
  import { QuotedMessagePreview } from './QuotedMessagePreview';
23
27
  import { PinnedMessages } from './PinnedMessages';
@@ -41,6 +45,20 @@ const DefaultDateSeparator: React.FC<{ label: string }> = React.memo(({ label })
41
45
  ));
42
46
  (DefaultDateSeparator as any).displayName = 'DefaultDateSeparator';
43
47
 
48
+ /** Time gap threshold in ms: messages more than 5 minutes apart get a time separator */
49
+ const TIME_GAP_THRESHOLD_MS = 5 * 60 * 1000;
50
+
51
+ function getTimestamp(date: Date | string | undefined): number {
52
+ if (!date) return 0;
53
+ return date instanceof Date ? date.getTime() : new Date(date).getTime();
54
+ }
55
+
56
+ function formatTimeSeparator(date: Date | string | undefined): string {
57
+ if (!date) return '';
58
+ const d = date instanceof Date ? date : new Date(date);
59
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
60
+ }
61
+
44
62
  const DefaultJumpToLatest = React.memo(({ onClick, label = '↓ Jump to latest' }: any) => (
45
63
  <button className="ermis-message-list__jump-latest" onClick={onClick}>
46
64
  {label}
@@ -110,6 +128,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
110
128
  messageRenderers: customRenderers,
111
129
  loadMoreLimit = 25,
112
130
  DateSeparatorComponent = DefaultDateSeparator,
131
+ dateLocale,
113
132
  MessageItemComponent = MessageItem,
114
133
  SystemMessageItemComponent = SystemMessageItem,
115
134
  JumpToLatestButton = DefaultJumpToLatest,
@@ -144,15 +163,34 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
144
163
  closedTopicReopenLabel = 'Reopen Topic',
145
164
  PendingInviteeNotificationComponent = DefaultPendingInviteeNotification,
146
165
  pendingInviteeLabel,
166
+ pinnedMessagesLabel,
167
+ seeAllLabel,
168
+ collapseLabel,
169
+ unpinLabel,
170
+ stickerLabel,
171
+ attachmentLabel = 'Attachment',
172
+ unavailableMessageLabel = 'Message unavailable',
173
+ encryptedMessageLabel,
174
+ encryptedMessageFailedLabel,
175
+ encryptedMessageDecryptingLabel,
176
+ encryptedMessageUnavailableLabel,
177
+ typingIndicatorLabel,
178
+ deletedMessageLabel = 'This message was deleted',
179
+ systemMessageTranslations,
180
+ signalMessageTranslations,
181
+ includeHiddenMessages = true,
182
+ onMentionClick,
183
+ onUserNameClick,
184
+ onAddReactionClick,
147
185
  }) => {
148
186
  const { client, messages, readState, activeChannel, setActiveChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
149
187
  const { isBanned } = useBannedState(activeChannel, client.userID);
150
188
  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)
189
+ const { isPending, inviteUpdateCount } = usePendingState(activeChannel, client.userID);
190
+
191
+ const isSkipped = client.userID
192
+ ? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
193
+ isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
156
194
  : false;
157
195
 
158
196
  const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
@@ -179,7 +217,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
179
217
  }
180
218
  }
181
219
  return null;
182
- }, [activeChannel, currentUserId, isPending]);
220
+ }, [activeChannel, currentUserId, isPending, inviteUpdateCount]);
183
221
 
184
222
  // Ref to scope DOM queries (safe for multiple instances)
185
223
  const containerRef = useRef<HTMLDivElement>(null);
@@ -190,13 +228,49 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
190
228
  const handleAcceptInvite = useCallback(async () => {
191
229
  if (!activeChannel) return;
192
230
  try {
193
- const isPublicTeamOrMeeting = isPublicGroupChannel(activeChannel);
194
- const action = isPublicTeamOrMeeting ? 'join' : 'accept';
231
+ let action: 'join' | 'accept' = 'accept';
232
+ if (isPublicGroupChannel(activeChannel)) {
233
+ const isMember = !!(currentUserId && activeChannel.state?.members?.[currentUserId]);
234
+ action = isMember ? 'accept' : 'join';
235
+ }
195
236
  await activeChannel.acceptInvite(action);
237
+
238
+ // Optimistically update local membership so React picks up the change immediately.
239
+ // The async _handleChannelEvent in the SDK races with client listeners,
240
+ // so the WS event alone is not reliable for updating React state in time.
241
+ if (activeChannel.state && currentUserId) {
242
+ const updatedMembership = {
243
+ ...activeChannel.state.membership,
244
+ channel_role: 'member',
245
+ user_id: currentUserId,
246
+ } as Record<string, unknown>;
247
+ activeChannel.state.membership = updatedMembership;
248
+
249
+ if (activeChannel.state.members?.[currentUserId]) {
250
+ activeChannel.state.members[currentUserId] = {
251
+ ...activeChannel.state.members[currentUserId],
252
+ channel_role: 'member',
253
+ };
254
+ }
255
+
256
+ // Dispatch synthetic event so all React listeners update
257
+ const clientObj = activeChannel.getClient();
258
+ const eventType = action === 'join' ? 'member.joined' : 'notification.invite_accepted';
259
+ clientObj.dispatchEvent({
260
+ type: eventType,
261
+ cid: activeChannel.cid,
262
+ channel_type: activeChannel.type,
263
+ channel_id: activeChannel.id,
264
+ channel: activeChannel.data,
265
+ member: updatedMembership,
266
+ user: clientObj.user,
267
+ } as any);
268
+ }
269
+
196
270
  } catch (e: any) {
197
271
  console.error('Error accepting invite', e);
198
272
  }
199
- }, [activeChannel]);
273
+ }, [activeChannel, currentUserId]);
200
274
 
201
275
  const handleRejectInvite = useCallback(async () => {
202
276
  if (!activeChannel) return;
@@ -218,11 +292,13 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
218
292
  }
219
293
  }, [activeChannel, setActiveChannel]);
220
294
 
295
+ const elementsCountRef = useRef(0);
296
+
221
297
  const scrollToBottom = useCallback((smooth = false, attempts = 0) => {
222
298
  const handle = vlistRef.current;
223
299
  if (!handle) return;
224
300
 
225
- const count = messagesRef.current.length;
301
+ const count = elementsCountRef.current;
226
302
  if (count === 0) return;
227
303
 
228
304
  // Ensure virtua has measured the viewport via ResizeObserver.
@@ -232,11 +308,26 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
232
308
  return;
233
309
  }
234
310
 
311
+ if (!smooth && handle.scrollSize > handle.viewportSize) {
312
+ handle.scrollTo(Math.max(0, handle.scrollSize - handle.viewportSize));
313
+ }
235
314
  handle.scrollToIndex(count - 1, { align: 'end', smooth });
236
315
  }, []);
237
316
 
238
317
  // Shared guard: skip scroll-triggered loads during jump transitions
239
318
  const jumpingRef = useRef(false);
319
+ const scrollLoadLockRef = useRef(false);
320
+ const scrollLoadLockTokenRef = useRef(0);
321
+ const holdScrollLoadLock = useCallback((duration = 750) => {
322
+ const token = scrollLoadLockTokenRef.current + 1;
323
+ scrollLoadLockTokenRef.current = token;
324
+ scrollLoadLockRef.current = true;
325
+ setTimeout(() => {
326
+ if (scrollLoadLockTokenRef.current === token) {
327
+ scrollLoadLockRef.current = false;
328
+ }
329
+ }, duration);
330
+ }, []);
240
331
 
241
332
  /* ---------- Hooks ---------- */
242
333
  const {
@@ -250,9 +341,23 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
250
341
  vlistRef,
251
342
  messagesRef,
252
343
  jumpingRef,
344
+ scrollLoadLockRef,
253
345
  loadMoreLimit,
254
346
  });
255
347
 
348
+ const isNearBottom = useCallback(() => {
349
+ const handle = vlistRef.current;
350
+ if (!handle) return isAtBottomRef.current;
351
+
352
+ const { scrollOffset, scrollSize, viewportSize } = handle;
353
+ if (!Number.isFinite(scrollOffset) || !Number.isFinite(scrollSize) || !Number.isFinite(viewportSize)) {
354
+ return isAtBottomRef.current;
355
+ }
356
+ if (scrollSize <= viewportSize || viewportSize <= 0) return true;
357
+
358
+ return scrollSize - (scrollOffset + viewportSize) <= 160;
359
+ }, [isAtBottomRef]);
360
+
256
361
  const { highlightedId, scrollToMessage, jumpToLatest } = useScrollToMessage({
257
362
  vlistRef,
258
363
  messagesRef,
@@ -273,6 +378,8 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
273
378
 
274
379
  useChannelMessages({
275
380
  scrollToBottom,
381
+ isNearBottom,
382
+ holdScrollLoadLock,
276
383
  jumpingRef,
277
384
  isAtBottomRef,
278
385
  onChannelSwitch: useCallback(() => {
@@ -281,8 +388,38 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
281
388
  loadingMoreRef.current = false;
282
389
  loadingNewerRef.current = false;
283
390
  }, [setHasMore, setHasNewer]),
391
+ includeHiddenMessages,
392
+ containerRef,
284
393
  });
285
394
 
395
+ const lastAutoScrollKeyRef = useRef<string | null>(null);
396
+
397
+ useLayoutEffect(() => {
398
+ const lastMessage = messages[messages.length - 1];
399
+ if (!lastMessage?.id || !currentUserId) return;
400
+
401
+ const key = `${activeChannel?.cid || ''}:${lastMessage.id}`;
402
+ if (lastAutoScrollKeyRef.current === key) return;
403
+
404
+ const isOwnLastMessage =
405
+ lastMessage.user_id === currentUserId || lastMessage.user?.id === currentUserId;
406
+ if (!isOwnLastMessage && !isAtBottomRef.current && !isNearBottom()) return;
407
+ if (!isOwnLastMessage && jumpingRef.current) return;
408
+ if (loadingMoreRef.current || loadingNewerRef.current) return;
409
+
410
+ lastAutoScrollKeyRef.current = key;
411
+ isAtBottomRef.current = true;
412
+ holdScrollLoadLock(750);
413
+
414
+ scrollToBottom(false);
415
+ requestAnimationFrame(() => {
416
+ requestAnimationFrame(() => scrollToBottom(false));
417
+ });
418
+ setTimeout(() => scrollToBottom(false), 80);
419
+ setTimeout(() => scrollToBottom(false), 180);
420
+ setTimeout(() => scrollToBottom(false), 360);
421
+ }, [activeChannel?.cid, currentUserId, messages, scrollToBottom, isNearBottom, holdScrollLoadLock]);
422
+
286
423
  const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked || isSkipped);
287
424
  const prevOverlayRef = useRef(hasOverlay);
288
425
 
@@ -326,97 +463,245 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
326
463
 
327
464
  /* ---------- Memoized message elements ---------- */
328
465
  const messageElements = useMemo(() => {
329
- return messages.map((message, index) => {
466
+ const elements: React.ReactNode[] = [];
467
+
468
+ // Pre-compute per-message data
469
+ type MsgEntry = {
470
+ message: typeof messages[0];
471
+ index: number;
472
+ isOwnMessage: boolean;
473
+ messageType: MessageLabel;
474
+ showDateSeparator: boolean;
475
+ isFirstInGroup: boolean;
476
+ isLastInGroup: boolean;
477
+ validReaders: Array<{ id: string; name?: string; avatar?: string; last_read?: Date | string }>;
478
+ hasReaders: boolean;
479
+ };
480
+ const entries: MsgEntry[] = messages.map((message, index) => {
330
481
  const isOwnMessage =
331
482
  message.user_id === currentUserId || message.user?.id === currentUserId;
332
- const messageType = (message.type || 'regular') as MessageLabel;
483
+ const messageType = (
484
+ isStickerMessage(message) ? 'sticker' : (message.type || 'regular')
485
+ ) as MessageLabel;
333
486
 
334
487
  // Date separator
335
488
  const prevMsg = index > 0 ? messages[index - 1] : null;
336
489
  const showDateSeparator =
337
490
  !prevMsg || getDateKey(message.created_at) !== getDateKey(prevMsg.created_at);
338
- const dateSeparator = showDateSeparator ? (
339
- <DateSeparatorComponent label={formatDateLabel(message.created_at)} />
340
- ) : null;
341
-
342
- if (renderMessage) {
343
- return (
344
- <div key={message.id || `msg-${index}`}>
345
- {dateSeparator}
346
- <div>{renderMessage(message, isOwnMessage)}</div>
347
- </div>
348
- );
349
- }
350
-
351
- if (messageType === 'system') {
352
- return (
353
- <div key={message.id || `msg-${index}`}>
354
- {dateSeparator}
355
- <SystemMessageItemComponent
356
- message={message}
357
- isOwnMessage={isOwnMessage}
358
- SystemRenderer={renderers.system}
359
- />
360
- </div>
361
- );
362
- }
363
-
364
- // Message grouping
365
491
  const prevType = (prevMsg?.type || 'regular') as MessageLabel;
492
+ const prevTimeGap = prevMsg
493
+ ? Math.abs(getTimestamp(message.created_at) - getTimestamp(prevMsg.created_at)) > TIME_GAP_THRESHOLD_MS
494
+ : false;
366
495
  const isFirstInGroup =
367
496
  showDateSeparator ||
368
497
  !prevMsg ||
369
498
  prevType === 'system' ||
370
499
  prevType === 'signal' ||
371
- getMessageUserId(prevMsg) !== getMessageUserId(message);
372
-
500
+ getMessageUserId(prevMsg) !== getMessageUserId(message) ||
501
+ prevTimeGap;
373
502
  const nextMsg = index < messages.length - 1 ? messages[index + 1] : null;
374
503
  const nextType = (nextMsg?.type || 'regular') as MessageLabel;
375
504
  const nextShowDateSeparator = nextMsg
376
505
  ? getDateKey(nextMsg.created_at) !== getDateKey(message.created_at)
377
506
  : false;
378
-
507
+ const validReaders = message.id && readByMap[message.id] ? readByMap[message.id].filter(r => r.id !== getMessageUserId(message)) : [];
508
+ const hasReaders = showReadReceipts && validReaders.length > 0;
509
+ const nextTimeGap = nextMsg
510
+ ? Math.abs(getTimestamp(nextMsg.created_at) - getTimestamp(message.created_at)) > TIME_GAP_THRESHOLD_MS
511
+ : false;
379
512
  const isLastInGroup =
380
513
  !nextMsg ||
381
514
  nextShowDateSeparator ||
382
515
  nextType === 'system' ||
383
516
  nextType === 'signal' ||
384
- getMessageUserId(nextMsg) !== getMessageUserId(message);
385
-
386
- const MessageRenderer = renderers[messageType] || renderers.regular;
387
-
388
- return (
389
- <div key={message.id || `msg-${index}`}>
390
- {dateSeparator}
391
- <MessageItemComponent
392
- message={message}
393
- isOwnMessage={isOwnMessage}
394
- isFirstInGroup={isFirstInGroup}
395
- isLastInGroup={isLastInGroup}
396
- isHighlighted={highlightedId === message.id}
397
- AvatarComponent={AvatarComponent}
398
- MessageBubble={MessageBubble}
399
- MessageRenderer={MessageRenderer}
400
- onClickQuote={scrollToMessage}
401
- QuotedMessagePreviewComponent={QuotedMessagePreviewComponent}
402
- MessageActionsBoxComponent={MessageActionsBoxComponent}
403
- MessageReactionsComponent={MessageReactionsComponent}
404
- />
405
- {/* Read receipts — full width, right-aligned */}
406
- {showReadReceipts && (
407
- <ReadReceiptsComponent
408
- readers={readByMap[message.id!] || []}
409
- maxAvatars={readReceiptsMaxAvatars}
410
- AvatarComponent={AvatarComponent}
411
- TooltipComponent={ReadReceiptsTooltipComponent}
412
- isOwnMessage={isOwnMessage}
413
- isLastInGroup={isLastInGroup}
414
- status={message.status}
517
+ getMessageUserId(nextMsg) !== getMessageUserId(message) ||
518
+ nextTimeGap;
519
+ return { message, index, isOwnMessage, messageType, showDateSeparator, isFirstInGroup, isLastInGroup, validReaders, hasReaders };
520
+ });
521
+
522
+ // Build groups: consecutive regular messages from same user
523
+ let i = 0;
524
+ while (i < entries.length) {
525
+ const entry = entries[i];
526
+
527
+ // Date separator before any message
528
+ if (entry.showDateSeparator) {
529
+ elements.push(
530
+ <div key={`date-${getDateKey(entry.message.created_at)}`}>
531
+ <DateSeparatorComponent label={formatDateLabel(entry.message.created_at, dateLocale)} />
532
+ </div>
533
+ );
534
+ }
535
+
536
+ // Custom renderMessage
537
+ if (renderMessage) {
538
+ elements.push(
539
+ <div key={entry.message.id || `msg-${entry.index}`}>
540
+ <div>{renderMessage(entry.message, entry.isOwnMessage)}</div>
541
+ </div>
542
+ );
543
+ i++;
544
+ continue;
545
+ }
546
+
547
+ // System messages — standalone
548
+ if (entry.messageType === 'system') {
549
+ elements.push(
550
+ <div key={entry.message.id || `msg-${entry.index}`}>
551
+ <SystemMessageItemComponent
552
+ message={entry.message}
553
+ isOwnMessage={entry.isOwnMessage}
554
+ SystemRenderer={renderers.system}
555
+ systemMessageTranslations={systemMessageTranslations}
415
556
  />
416
- )}
557
+ </div>
558
+ );
559
+ i++;
560
+ continue;
561
+ }
562
+
563
+ // Collect consecutive regular/signal messages from the same user into a group
564
+ // Break group on: different user, system message, date separator, or time gap > 5min
565
+ const groupEntries: MsgEntry[] = [entry];
566
+ let j = i + 1;
567
+ while (j < entries.length) {
568
+ const nextEntry = entries[j];
569
+ const prevEntry = entries[j - 1];
570
+ const timeGap = Math.abs(
571
+ getTimestamp(nextEntry.message.created_at) - getTimestamp(prevEntry.message.created_at)
572
+ );
573
+ // Break group if: different user, system message, date separator, or time gap
574
+ if (
575
+ nextEntry.showDateSeparator ||
576
+ nextEntry.messageType === 'system' ||
577
+ getMessageUserId(nextEntry.message) !== getMessageUserId(entry.message) ||
578
+ timeGap > TIME_GAP_THRESHOLD_MS
579
+ ) {
580
+ break;
581
+ }
582
+ groupEntries.push(nextEntry);
583
+ j++;
584
+ }
585
+
586
+ const isOwn = entry.isOwnMessage;
587
+ const userName = entry.message.user?.name || entry.message.user_id;
588
+ const userAvatar = entry.message.user?.avatar;
589
+ const groupKey = `group-${entry.message.id || `g-${entry.index}`}`;
590
+
591
+ // Check if we need a time separator BEFORE this group
592
+ // (when previous group was from same user but time gap split them)
593
+ if (i > 0) {
594
+ const prevEntry = entries[i - 1];
595
+ const timeGap = Math.abs(
596
+ getTimestamp(entry.message.created_at) - getTimestamp(prevEntry.message.created_at)
597
+ );
598
+ if (
599
+ !entry.showDateSeparator &&
600
+ prevEntry.messageType !== 'system' &&
601
+ getMessageUserId(prevEntry.message) === getMessageUserId(entry.message) &&
602
+ timeGap > TIME_GAP_THRESHOLD_MS
603
+ ) {
604
+ elements.push(
605
+ <div key={`timesep-${entry.message.id}`}>
606
+ <div className="ermis-message-list__time-separator">
607
+ <span className="ermis-message-list__time-separator-label">
608
+ {formatTimeSeparator(entry.message.created_at)}
609
+ </span>
610
+ </div>
611
+ </div>
612
+ );
613
+ }
614
+ }
615
+
616
+ // Render group wrapper with sticky avatar
617
+ elements.push(
618
+ <div key={groupKey}>
619
+ <div className={`ermis-message-group ${isOwn ? 'ermis-message-group--own' : 'ermis-message-group--other'}`}>
620
+ {/* Avatar column — sticky for scroll tracking */}
621
+ {!isOwn && (
622
+ <div className="ermis-message-group__avatar-col">
623
+ <AvatarComponent image={userAvatar} name={userName} size={36} />
624
+ </div>
625
+ )}
626
+ {/* Messages column */}
627
+ <div className="ermis-message-group__messages-col">
628
+ {groupEntries.map((ge) => {
629
+ const MessageRenderer = renderers[ge.messageType] || renderers.regular;
630
+ return (
631
+ <React.Fragment key={ge.message.id || `msg-${ge.index}`}>
632
+ {/* Date separators within group (if needed for mid-group entries) */}
633
+ {ge !== entry && ge.showDateSeparator && (
634
+ <DateSeparatorComponent label={formatDateLabel(ge.message.created_at, dateLocale)} />
635
+ )}
636
+ <MessageItemComponent
637
+ message={ge.message}
638
+ isOwnMessage={ge.isOwnMessage}
639
+ isFirstInGroup={ge.isFirstInGroup}
640
+ isLastInGroup={ge.isLastInGroup}
641
+ isHighlighted={highlightedId === ge.message.id}
642
+ AvatarComponent={AvatarComponent}
643
+ MessageBubble={MessageBubble}
644
+ MessageRenderer={MessageRenderer}
645
+ onClickQuote={scrollToMessage}
646
+ QuotedMessagePreviewComponent={QuotedMessagePreviewComponent}
647
+ MessageActionsBoxComponent={MessageActionsBoxComponent}
648
+ MessageReactionsComponent={MessageReactionsComponent}
649
+ deletedMessageLabel={deletedMessageLabel}
650
+ attachmentLabel={attachmentLabel}
651
+ unavailableMessageLabel={unavailableMessageLabel}
652
+ stickerLabel={stickerLabel}
653
+ encryptedMessageLabel={encryptedMessageLabel}
654
+ encryptedMessageFailedLabel={encryptedMessageFailedLabel}
655
+ encryptedMessageDecryptingLabel={encryptedMessageDecryptingLabel}
656
+ systemMessageTranslations={systemMessageTranslations}
657
+ signalMessageTranslations={signalMessageTranslations}
658
+ onMentionClick={onMentionClick}
659
+ onUserNameClick={onUserNameClick}
660
+ onAddReactionClick={onAddReactionClick}
661
+ hideAvatar
662
+ />
663
+ </React.Fragment>
664
+ );
665
+ })}
666
+ </div>
667
+ </div>
668
+ {/* Read receipts — consolidated: merge all readers in this group into one row */}
669
+ {(() => {
670
+ if (!showReadReceipts) return null;
671
+ const allReaders: Array<{ id: string; name?: string; avatar?: string; last_read?: Date | string }> = [];
672
+ const seen = new Set<string>();
673
+ for (const ge of groupEntries) {
674
+ for (const r of ge.validReaders) {
675
+ if (!seen.has(r.id)) {
676
+ seen.add(r.id);
677
+ allReaders.push(r);
678
+ }
679
+ }
680
+ }
681
+ if (allReaders.length === 0) return null;
682
+ const lastEntry = groupEntries[groupEntries.length - 1];
683
+ return (
684
+ <ReadReceiptsComponent
685
+ key={`receipt-${lastEntry.message.id}`}
686
+ readers={allReaders}
687
+ maxAvatars={readReceiptsMaxAvatars}
688
+ AvatarComponent={AvatarComponent}
689
+ TooltipComponent={ReadReceiptsTooltipComponent}
690
+ isOwnMessage={lastEntry.isOwnMessage}
691
+ isLastInGroup={lastEntry.isLastInGroup}
692
+ status={lastEntry.message.status}
693
+ />
694
+ );
695
+ })()}
417
696
  </div>
418
697
  );
419
- });
698
+
699
+ i = j;
700
+ }
701
+
702
+
703
+ elementsCountRef.current = elements.length;
704
+ return elements;
420
705
  }, [
421
706
  messages,
422
707
  currentUserId,
@@ -437,6 +722,13 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
437
722
  ReadReceiptsComponent,
438
723
  ReadReceiptsTooltipComponent,
439
724
  readReceiptsMaxAvatars,
725
+ dateLocale,
726
+ onMentionClick,
727
+ onUserNameClick,
728
+ onAddReactionClick,
729
+ encryptedMessageLabel,
730
+ encryptedMessageFailedLabel,
731
+ encryptedMessageDecryptingLabel,
440
732
  ]);
441
733
 
442
734
  if (isBanned || isBlocked) {
@@ -498,42 +790,56 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
498
790
  }
499
791
 
500
792
  return (
501
- <div ref={containerRef} className={`ermis-message-list${className ? ` ${className}` : ''}`}>
502
- {showPinnedMessages && <PinnedMessagesComponent onClickMessage={scrollToMessage} AvatarComponent={AvatarComponent} />}
503
-
504
- {messages.length === 0 && (
505
- EmptyStateIndicator === DefaultEmpty
506
- ? <DefaultEmpty title={emptyTitle} subtitle={emptySubtitle} />
507
- : <EmptyStateIndicator />
508
- )}
509
-
510
- {pendingInviteeName && (
511
- <PendingInviteeNotificationComponent
512
- inviteeName={pendingInviteeName}
513
- label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
514
- />
515
- )}
516
-
517
- <VList
518
- key={activeChannel?.cid || 'empty'}
519
- ref={vlistRef}
520
- shift={shiftMode}
521
- onScroll={handleScroll}
522
- className="ermis-message-list__vlist"
523
- >
524
- {messageElements}
525
- </VList>
526
-
527
- {/* Typing indicator */}
528
- {showTypingIndicator && <TypingIndicatorComponent />}
529
-
530
- {/* Jump to latest button */}
531
- {hasNewer && (
532
- JumpToLatestButton === DefaultJumpToLatest
533
- ? <DefaultJumpToLatest onClick={jumpToLatest} label={jumpToLatestLabel} />
534
- : <JumpToLatestButton onClick={jumpToLatest} />
535
- )}
536
- </div>
793
+ <>
794
+ <div ref={containerRef} className={`ermis-message-list${className ? ` ${className}` : ''}`}>
795
+ {showPinnedMessages && (
796
+ <PinnedMessagesComponent
797
+ onClickMessage={scrollToMessage}
798
+ AvatarComponent={AvatarComponent}
799
+ pinnedMessagesLabel={pinnedMessagesLabel}
800
+ seeAllLabel={seeAllLabel}
801
+ collapseLabel={collapseLabel}
802
+ unpinLabel={unpinLabel}
803
+ stickerLabel={stickerLabel}
804
+ attachmentLabel={attachmentLabel}
805
+ unavailableMessageLabel={unavailableMessageLabel}
806
+ />
807
+ )}
808
+
809
+ {messages.length === 0 && (
810
+ EmptyStateIndicator === DefaultEmpty
811
+ ? <DefaultEmpty title={emptyTitle} subtitle={emptySubtitle} />
812
+ : <EmptyStateIndicator />
813
+ )}
814
+
815
+ {pendingInviteeName && (
816
+ <PendingInviteeNotificationComponent
817
+ inviteeName={pendingInviteeName}
818
+ label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
819
+ />
820
+ )}
821
+
822
+ <VList
823
+ key={activeChannel?.cid || 'empty'}
824
+ ref={vlistRef}
825
+ shift={shiftMode}
826
+ onScroll={handleScroll}
827
+ className="ermis-message-list__vlist"
828
+ >
829
+ {messageElements}
830
+ </VList>
831
+
832
+ {/* Jump to latest button */}
833
+ {hasNewer && (
834
+ JumpToLatestButton === DefaultJumpToLatest
835
+ ? <DefaultJumpToLatest onClick={jumpToLatest} label={jumpToLatestLabel} />
836
+ : <JumpToLatestButton onClick={jumpToLatest} />
837
+ )}
838
+ </div>
839
+
840
+ {/* Typing indicator — outside message list, flows between messages and input */}
841
+ {showTypingIndicator && <TypingIndicatorComponent typingIndicatorLabel={typingIndicatorLabel} />}
842
+ </>
537
843
  );
538
844
  });
539
845