@ermis-network/ermis-chat-react 2.0.0 → 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 (72) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +5087 -11279
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +632 -152
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +273 -9
  7. package/dist/index.d.ts +273 -9
  8. package/dist/index.mjs +5085 -11295
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/components/Channel.tsx +0 -3
  12. package/src/components/ChannelActions.tsx +6 -1
  13. package/src/components/ChannelHeader.tsx +8 -32
  14. package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
  17. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
  18. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
  19. package/src/components/ChannelList.tsx +72 -13
  20. package/src/components/CreateChannelModal.tsx +131 -12
  21. package/src/components/FilesPreview.tsx +8 -12
  22. package/src/components/FlatTopicGroupItem.tsx +27 -16
  23. package/src/components/ForwardMessageModal.tsx +11 -3
  24. package/src/components/MediaLightbox.tsx +444 -304
  25. package/src/components/MessageActionsBox.tsx +2 -0
  26. package/src/components/MessageInput.tsx +41 -12
  27. package/src/components/MessageItem.tsx +70 -25
  28. package/src/components/MessageQuickReactions.tsx +131 -128
  29. package/src/components/MessageReactions.tsx +47 -2
  30. package/src/components/MessageRenderers.tsx +1030 -433
  31. package/src/components/PinnedMessages.tsx +40 -12
  32. package/src/components/QuotedMessagePreview.tsx +99 -8
  33. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  34. package/src/components/RecoveryPin/index.ts +19 -0
  35. package/src/components/TopicList.tsx +20 -5
  36. package/src/components/TypingIndicator.tsx +3 -3
  37. package/src/components/UserPicker.tsx +26 -25
  38. package/src/components/VirtualMessageList.tsx +345 -125
  39. package/src/context/ChatProvider.tsx +27 -1
  40. package/src/hooks/useChannelListUpdates.ts +22 -1
  41. package/src/hooks/useChannelMessages.ts +338 -51
  42. package/src/hooks/useChannelRowUpdates.ts +18 -6
  43. package/src/hooks/useChatUser.ts +9 -1
  44. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  45. package/src/hooks/useE2eeFileUpload.ts +38 -0
  46. package/src/hooks/useFileUpload.ts +25 -5
  47. package/src/hooks/useForwardMessage.ts +210 -13
  48. package/src/hooks/useLoadMessages.ts +16 -4
  49. package/src/hooks/useMentions.ts +60 -6
  50. package/src/hooks/useMessageActions.ts +14 -8
  51. package/src/hooks/useMessageSend.ts +64 -12
  52. package/src/hooks/usePendingE2eeSends.ts +29 -0
  53. package/src/hooks/useRecoveryPin.ts +287 -0
  54. package/src/hooks/useScrollToMessage.ts +29 -4
  55. package/src/hooks/useTopicGroupUpdates.ts +49 -11
  56. package/src/index.ts +23 -0
  57. package/src/messageTypeUtils.ts +14 -0
  58. package/src/styles/_channel-info.css +9 -0
  59. package/src/styles/_channel-list.css +37 -14
  60. package/src/styles/_media-lightbox.css +36 -3
  61. package/src/styles/_message-bubble.css +381 -41
  62. package/src/styles/_message-input.css +8 -0
  63. package/src/styles/_message-list.css +67 -10
  64. package/src/styles/_message-quick-reactions.css +101 -59
  65. package/src/styles/_message-reactions.css +18 -32
  66. package/src/styles/_recovery-pin.css +97 -0
  67. package/src/styles/_tokens.css +5 -5
  68. package/src/styles/_typing-indicator.css +23 -13
  69. package/src/styles/index.css +1 -0
  70. package/src/types.ts +115 -1
  71. package/src/utils/avatarColors.ts +1 -1
  72. package/src/utils.ts +38 -18
@@ -1,4 +1,4 @@
1
- import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
1
+ import React, { useState, useRef, useCallback, useMemo, useEffect, useLayoutEffect } from 'react';
2
2
  import { VList as _VList, type VListHandle } from 'virtua';
3
3
 
4
4
  // Workaround for React 19 JSX element type mismatch with virtua's VList
@@ -21,6 +21,7 @@ import {
21
21
  defaultMessageRenderers,
22
22
  type MessageBubbleProps,
23
23
  } from './MessageRenderers';
24
+ import { isStickerMessage } from '../messageTypeUtils';
24
25
  import { getDateKey, formatDateLabel, getMessageUserId, formatReadTimestamp } from '../utils';
25
26
  import { QuotedMessagePreview } from './QuotedMessagePreview';
26
27
  import { PinnedMessages } from './PinnedMessages';
@@ -44,6 +45,20 @@ const DefaultDateSeparator: React.FC<{ label: string }> = React.memo(({ label })
44
45
  ));
45
46
  (DefaultDateSeparator as any).displayName = 'DefaultDateSeparator';
46
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
+
47
62
  const DefaultJumpToLatest = React.memo(({ onClick, label = '↓ Jump to latest' }: any) => (
48
63
  <button className="ermis-message-list__jump-latest" onClick={onClick}>
49
64
  {label}
@@ -153,6 +168,12 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
153
168
  collapseLabel,
154
169
  unpinLabel,
155
170
  stickerLabel,
171
+ attachmentLabel = 'Attachment',
172
+ unavailableMessageLabel = 'Message unavailable',
173
+ encryptedMessageLabel,
174
+ encryptedMessageFailedLabel,
175
+ encryptedMessageDecryptingLabel,
176
+ encryptedMessageUnavailableLabel,
156
177
  typingIndicatorLabel,
157
178
  deletedMessageLabel = 'This message was deleted',
158
179
  systemMessageTranslations,
@@ -246,8 +267,6 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
246
267
  } as any);
247
268
  }
248
269
 
249
- // Re-watch to get full fresh state from server
250
- activeChannel.watch().catch(() => {});
251
270
  } catch (e: any) {
252
271
  console.error('Error accepting invite', e);
253
272
  }
@@ -273,11 +292,13 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
273
292
  }
274
293
  }, [activeChannel, setActiveChannel]);
275
294
 
295
+ const elementsCountRef = useRef(0);
296
+
276
297
  const scrollToBottom = useCallback((smooth = false, attempts = 0) => {
277
298
  const handle = vlistRef.current;
278
299
  if (!handle) return;
279
300
 
280
- const count = messagesRef.current.length;
301
+ const count = elementsCountRef.current;
281
302
  if (count === 0) return;
282
303
 
283
304
  // Ensure virtua has measured the viewport via ResizeObserver.
@@ -287,11 +308,26 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
287
308
  return;
288
309
  }
289
310
 
311
+ if (!smooth && handle.scrollSize > handle.viewportSize) {
312
+ handle.scrollTo(Math.max(0, handle.scrollSize - handle.viewportSize));
313
+ }
290
314
  handle.scrollToIndex(count - 1, { align: 'end', smooth });
291
315
  }, []);
292
316
 
293
317
  // Shared guard: skip scroll-triggered loads during jump transitions
294
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
+ }, []);
295
331
 
296
332
  /* ---------- Hooks ---------- */
297
333
  const {
@@ -305,9 +341,23 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
305
341
  vlistRef,
306
342
  messagesRef,
307
343
  jumpingRef,
344
+ scrollLoadLockRef,
308
345
  loadMoreLimit,
309
346
  });
310
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
+
311
361
  const { highlightedId, scrollToMessage, jumpToLatest } = useScrollToMessage({
312
362
  vlistRef,
313
363
  messagesRef,
@@ -328,6 +378,8 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
328
378
 
329
379
  useChannelMessages({
330
380
  scrollToBottom,
381
+ isNearBottom,
382
+ holdScrollLoadLock,
331
383
  jumpingRef,
332
384
  isAtBottomRef,
333
385
  onChannelSwitch: useCallback(() => {
@@ -340,6 +392,34 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
340
392
  containerRef,
341
393
  });
342
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
+
343
423
  const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked || isSkipped);
344
424
  const prevOverlayRef = useRef(hasOverlay);
345
425
 
@@ -383,112 +463,245 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
383
463
 
384
464
  /* ---------- Memoized message elements ---------- */
385
465
  const messageElements = useMemo(() => {
386
- 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) => {
387
481
  const isOwnMessage =
388
482
  message.user_id === currentUserId || message.user?.id === currentUserId;
389
- const messageType = (message.type || 'regular') as MessageLabel;
483
+ const messageType = (
484
+ isStickerMessage(message) ? 'sticker' : (message.type || 'regular')
485
+ ) as MessageLabel;
390
486
 
391
487
  // Date separator
392
488
  const prevMsg = index > 0 ? messages[index - 1] : null;
393
489
  const showDateSeparator =
394
490
  !prevMsg || getDateKey(message.created_at) !== getDateKey(prevMsg.created_at);
395
- const dateSeparator = showDateSeparator ? (
396
- <DateSeparatorComponent label={formatDateLabel(message.created_at, dateLocale)} />
397
- ) : null;
398
-
399
- if (renderMessage) {
400
- return (
401
- <div key={message.id || `msg-${index}`}>
402
- {dateSeparator}
403
- <div>{renderMessage(message, isOwnMessage)}</div>
404
- </div>
405
- );
406
- }
407
-
408
- if (messageType === 'system') {
409
- return (
410
- <div key={message.id || `msg-${index}`}>
411
- {dateSeparator}
412
- <SystemMessageItemComponent
413
- message={message}
414
- isOwnMessage={isOwnMessage}
415
- SystemRenderer={renderers.system}
416
- systemMessageTranslations={systemMessageTranslations}
417
- />
418
- </div>
419
- );
420
- }
421
-
422
- // Message grouping
423
491
  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
-
492
+ const prevTimeGap = prevMsg
493
+ ? Math.abs(getTimestamp(message.created_at) - getTimestamp(prevMsg.created_at)) > TIME_GAP_THRESHOLD_MS
494
+ : false;
427
495
  const isFirstInGroup =
428
496
  showDateSeparator ||
429
497
  !prevMsg ||
430
498
  prevType === 'system' ||
431
499
  prevType === 'signal' ||
432
500
  getMessageUserId(prevMsg) !== getMessageUserId(message) ||
433
- prevHasReaders;
434
-
501
+ prevTimeGap;
435
502
  const nextMsg = index < messages.length - 1 ? messages[index + 1] : null;
436
503
  const nextType = (nextMsg?.type || 'regular') as MessageLabel;
437
504
  const nextShowDateSeparator = nextMsg
438
505
  ? getDateKey(nextMsg.created_at) !== getDateKey(message.created_at)
439
506
  : false;
440
-
441
507
  const validReaders = message.id && readByMap[message.id] ? readByMap[message.id].filter(r => r.id !== getMessageUserId(message)) : [];
442
508
  const hasReaders = showReadReceipts && validReaders.length > 0;
443
-
509
+ const nextTimeGap = nextMsg
510
+ ? Math.abs(getTimestamp(nextMsg.created_at) - getTimestamp(message.created_at)) > TIME_GAP_THRESHOLD_MS
511
+ : false;
444
512
  const isLastInGroup =
445
513
  !nextMsg ||
446
514
  nextShowDateSeparator ||
447
515
  nextType === 'system' ||
448
516
  nextType === 'signal' ||
449
517
  getMessageUserId(nextMsg) !== getMessageUserId(message) ||
450
- hasReaders;
451
-
452
- const MessageRenderer = renderers[messageType] || renderers.regular;
453
-
454
- return (
455
- <div key={message.id || `msg-${index}`}>
456
- {dateSeparator}
457
- <MessageItemComponent
458
- message={message}
459
- isOwnMessage={isOwnMessage}
460
- isFirstInGroup={isFirstInGroup}
461
- isLastInGroup={isLastInGroup}
462
- isHighlighted={highlightedId === message.id}
463
- AvatarComponent={AvatarComponent}
464
- MessageBubble={MessageBubble}
465
- MessageRenderer={MessageRenderer}
466
- onClickQuote={scrollToMessage}
467
- QuotedMessagePreviewComponent={QuotedMessagePreviewComponent}
468
- MessageActionsBoxComponent={MessageActionsBoxComponent}
469
- MessageReactionsComponent={MessageReactionsComponent}
470
- deletedMessageLabel={deletedMessageLabel}
471
- systemMessageTranslations={systemMessageTranslations}
472
- signalMessageTranslations={signalMessageTranslations}
473
- onMentionClick={onMentionClick}
474
- onUserNameClick={onUserNameClick}
475
- onAddReactionClick={onAddReactionClick}
476
- />
477
- {/* Read receipts — full width, right-aligned */}
478
- {showReadReceipts && validReaders.length > 0 && (
479
- <ReadReceiptsComponent
480
- readers={validReaders}
481
- maxAvatars={readReceiptsMaxAvatars}
482
- AvatarComponent={AvatarComponent}
483
- TooltipComponent={ReadReceiptsTooltipComponent}
484
- isOwnMessage={isOwnMessage}
485
- isLastInGroup={isLastInGroup}
486
- status={message.status}
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}
487
556
  />
488
- )}
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
+ })()}
489
696
  </div>
490
697
  );
491
- });
698
+
699
+ i = j;
700
+ }
701
+
702
+
703
+ elementsCountRef.current = elements.length;
704
+ return elements;
492
705
  }, [
493
706
  messages,
494
707
  currentUserId,
@@ -513,6 +726,9 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
513
726
  onMentionClick,
514
727
  onUserNameClick,
515
728
  onAddReactionClick,
729
+ encryptedMessageLabel,
730
+ encryptedMessageFailedLabel,
731
+ encryptedMessageDecryptingLabel,
516
732
  ]);
517
733
 
518
734
  if (isBanned || isBlocked) {
@@ -574,52 +790,56 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
574
790
  }
575
791
 
576
792
  return (
577
- <div ref={containerRef} className={`ermis-message-list${className ? ` ${className}` : ''}`}>
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
- )}
589
-
590
- {messages.length === 0 && (
591
- EmptyStateIndicator === DefaultEmpty
592
- ? <DefaultEmpty title={emptyTitle} subtitle={emptySubtitle} />
593
- : <EmptyStateIndicator />
594
- )}
595
-
596
- {pendingInviteeName && (
597
- <PendingInviteeNotificationComponent
598
- inviteeName={pendingInviteeName}
599
- label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
600
- />
601
- )}
602
-
603
- <VList
604
- key={activeChannel?.cid || 'empty'}
605
- ref={vlistRef}
606
- shift={shiftMode}
607
- onScroll={handleScroll}
608
- className="ermis-message-list__vlist"
609
- >
610
- {messageElements}
611
- </VList>
612
-
613
- {/* Typing indicator */}
614
- {showTypingIndicator && <TypingIndicatorComponent typingIndicatorLabel={typingIndicatorLabel} />}
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>
615
839
 
616
- {/* Jump to latest button */}
617
- {hasNewer && (
618
- JumpToLatestButton === DefaultJumpToLatest
619
- ? <DefaultJumpToLatest onClick={jumpToLatest} label={jumpToLatestLabel} />
620
- : <JumpToLatestButton onClick={jumpToLatest} />
621
- )}
622
- </div>
840
+ {/* Typing indicator outside message list, flows between messages and input */}
841
+ {showTypingIndicator && <TypingIndicatorComponent typingIndicatorLabel={typingIndicatorLabel} />}
842
+ </>
623
843
  );
624
844
  });
625
845
 
@@ -1,4 +1,4 @@
1
- import React, { createContext, useState, useCallback, useRef } from 'react';
1
+ import React, { createContext, useState, useCallback, useRef, useMemo } from 'react';
2
2
  import type { Channel, FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
3
3
  import type { Theme, ChatContextValue, ChatProviderProps, ReadStateEntry } from '../types';
4
4
  import { ErmisCallProvider } from '../components/ErmisCallProvider';
@@ -40,6 +40,10 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
40
40
  const activeChannel = activeChannelRaw;
41
41
  const activeChannelCidRef = useRef<string | null>(null);
42
42
 
43
+ // In-memory draft storage — Map<cid, { html: string; files: any[] }>
44
+ // O(1) lookup/insert/delete, bounded by number of visited channels per session
45
+ const draftsRef = useRef<Map<string, { html: string; files: any[] }>>(new Map());
46
+
43
47
  const setActiveChannel = useCallback((channel: Channel | null) => {
44
48
  const newCid = channel?.cid || null;
45
49
  if (activeChannelCidRef.current === newCid) return;
@@ -59,6 +63,25 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
59
63
  }
60
64
  }, [activeChannel]);
61
65
 
66
+ /** Save a draft message (innerHTML and files) for a specific channel */
67
+ const setDraft = useCallback((cid: string, draft: { html: string; files: any[] }) => {
68
+ if ((draft.html && draft.html.trim()) || (draft.files && draft.files.length > 0)) {
69
+ draftsRef.current.set(cid, draft);
70
+ } else {
71
+ draftsRef.current.delete(cid);
72
+ }
73
+ }, []);
74
+
75
+ /** Retrieve the saved draft for a specific channel */
76
+ const getDraft = useCallback((cid: string): { html: string; files: any[] } | undefined => {
77
+ return draftsRef.current.get(cid);
78
+ }, []);
79
+
80
+ /** Clear all saved drafts (e.g. on logout) */
81
+ const clearAllDrafts = useCallback(() => {
82
+ draftsRef.current.clear();
83
+ }, []);
84
+
62
85
  const value: ChatContextValue = {
63
86
  client,
64
87
  activeChannel,
@@ -79,6 +102,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
79
102
  jumpToMessageId,
80
103
  setJumpToMessageId,
81
104
  enableCall,
105
+ setDraft,
106
+ getDraft,
107
+ clearAllDrafts,
82
108
  };
83
109
 
84
110
  const CallUIView = CallUIComponent ? <CallUIComponent /> : (