@ermis-network/ermis-chat-react 1.0.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/index.cjs +15295 -4209
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +701 -195
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +862 -94
  6. package/dist/index.d.ts +862 -94
  7. package/dist/index.mjs +15246 -4186
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -4
  10. package/src/channelTypeUtils.ts +1 -1
  11. package/src/components/Avatar.tsx +2 -1
  12. package/src/components/Channel.tsx +6 -2
  13. package/src/components/ChannelActions.tsx +61 -2
  14. package/src/components/ChannelHeader.tsx +19 -5
  15. package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
  21. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  23. package/src/components/ChannelInfo/States.tsx +1 -1
  24. package/src/components/ChannelInfo/index.ts +3 -0
  25. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
  26. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  27. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  28. package/src/components/ChannelList.tsx +177 -290
  29. package/src/components/CreateChannelModal.tsx +166 -88
  30. package/src/components/Dropdown.tsx +1 -16
  31. package/src/components/EditPreview.tsx +1 -0
  32. package/src/components/ErmisCallProvider.tsx +72 -17
  33. package/src/components/ErmisCallUI.tsx +43 -20
  34. package/src/components/FlatTopicGroupItem.tsx +232 -0
  35. package/src/components/ForwardMessageModal.tsx +31 -77
  36. package/src/components/MediaLightbox.tsx +62 -40
  37. package/src/components/MentionSuggestions.tsx +47 -35
  38. package/src/components/MessageActionsBox.tsx +4 -1
  39. package/src/components/MessageInput.tsx +137 -16
  40. package/src/components/MessageInputDefaults.tsx +127 -1
  41. package/src/components/MessageItem.tsx +93 -26
  42. package/src/components/MessageQuickReactions.tsx +153 -26
  43. package/src/components/MessageReactions.tsx +2 -1
  44. package/src/components/MessageRenderers.tsx +111 -39
  45. package/src/components/Panel.tsx +1 -14
  46. package/src/components/PinnedMessages.tsx +17 -5
  47. package/src/components/PreviewOverlay.tsx +24 -0
  48. package/src/components/ReadReceipts.tsx +2 -1
  49. package/src/components/TopicList.tsx +221 -0
  50. package/src/components/TopicModal.tsx +4 -1
  51. package/src/components/TypingIndicator.tsx +14 -5
  52. package/src/components/UserPicker.tsx +87 -10
  53. package/src/components/VirtualMessageList.tsx +106 -20
  54. package/src/context/ChatComponentsContext.tsx +14 -0
  55. package/src/context/ChatProvider.tsx +18 -14
  56. package/src/context/ErmisCallContext.tsx +4 -0
  57. package/src/hooks/useChannelCapabilities.ts +7 -4
  58. package/src/hooks/useChannelData.ts +10 -3
  59. package/src/hooks/useChannelListUpdates.ts +72 -20
  60. package/src/hooks/useChannelMessages.ts +72 -10
  61. package/src/hooks/useChannelRowUpdates.ts +24 -5
  62. package/src/hooks/useChatUser.ts +31 -0
  63. package/src/hooks/useContactChannels.ts +45 -0
  64. package/src/hooks/useContactCount.ts +50 -0
  65. package/src/hooks/useDownloadHandler.ts +36 -0
  66. package/src/hooks/useDragAndDrop.ts +79 -0
  67. package/src/hooks/useForwardMessage.ts +112 -0
  68. package/src/hooks/useInviteChannels.ts +88 -0
  69. package/src/hooks/useInviteCount.ts +104 -0
  70. package/src/hooks/useMentions.ts +0 -1
  71. package/src/hooks/useMessageActions.ts +13 -10
  72. package/src/hooks/usePendingState.ts +21 -4
  73. package/src/hooks/usePreviewState.ts +69 -0
  74. package/src/hooks/useStickerPicker.ts +62 -0
  75. package/src/hooks/useTopicGroupUpdates.ts +197 -0
  76. package/src/index.ts +56 -6
  77. package/src/messageTypeUtils.ts +13 -1
  78. package/src/styles/_base.css +0 -1
  79. package/src/styles/_call-ui.css +59 -2
  80. package/src/styles/_channel-info.css +41 -4
  81. package/src/styles/_channel-list.css +97 -57
  82. package/src/styles/_create-channel-modal.css +10 -0
  83. package/src/styles/_forward-modal.css +16 -1
  84. package/src/styles/_media-lightbox.css +32 -0
  85. package/src/styles/_mentions.css +1 -1
  86. package/src/styles/_message-actions.css +3 -4
  87. package/src/styles/_message-bubble.css +286 -107
  88. package/src/styles/_message-input.css +131 -0
  89. package/src/styles/_message-list.css +33 -17
  90. package/src/styles/_message-quick-reactions.css +40 -9
  91. package/src/styles/_message-reactions.css +4 -0
  92. package/src/styles/_modal.css +2 -1
  93. package/src/styles/_preview-overlay.css +38 -0
  94. package/src/styles/_tokens.css +17 -15
  95. package/src/styles/_typing-indicator.css +7 -1
  96. package/src/styles/index.css +1 -0
  97. package/src/types.ts +362 -14
  98. package/src/utils/avatarColors.ts +48 -0
  99. package/src/utils.ts +193 -10
@@ -6,16 +6,21 @@ import { usePendingState } from '../hooks/usePendingState';
6
6
  import { useMentions } from '../hooks/useMentions';
7
7
  import { useFileUpload } from '../hooks/useFileUpload';
8
8
  import { useEmojiPicker } from '../hooks/useEmojiPicker';
9
+ import { useStickerPicker } from '../hooks/useStickerPicker';
9
10
  import { useMessageSend } from '../hooks/useMessageSend';
10
- import { DefaultSendButton, DefaultAttachButton, DefaultEmojiButton } from './MessageInputDefaults';
11
+ import { useDragAndDrop } from '../hooks/useDragAndDrop';
12
+ import { DefaultSendButton, DefaultAttachButton, DefaultEmojiButton, DefaultStickerButton, DefaultStickerPicker, DefaultDragAndDropOverlay, DefaultVoiceRecordButton } from './MessageInputDefaults';
11
13
  import { MentionSuggestions } from './MentionSuggestions';
12
14
  import { FilesPreview } from './FilesPreview';
13
15
  import { ReplyPreview } from './ReplyPreview';
14
16
  import { EditPreview } from './EditPreview';
15
- import { buildUserMap, replaceMentionsForPreview, moveCaretToEnd } from '../utils';
17
+ import { PreviewOverlay } from './PreviewOverlay';
18
+ import { usePreviewState } from '../hooks/usePreviewState';
19
+ import { buildUserMap, replaceMentionsForPreview, moveCaretToEnd, countWords } from '../utils';
16
20
  import { getMentionHtml } from '../hooks/useMentions';
17
21
  import { useChannelCapabilities } from '../hooks/useChannelCapabilities';
18
22
  import { CHANNEL_ROLES } from '../channelRoleUtils';
23
+ import { isTopicChannel } from '../channelTypeUtils';
19
24
  import type { MentionMember, MessageInputProps, FilePreviewItem } from '../types';
20
25
 
21
26
  export type { MessageInputProps, SendButtonProps, AttachButtonProps, EmojiPickerProps, EmojiButtonProps } from '../types';
@@ -34,6 +39,11 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
34
39
  onBeforeSend,
35
40
  EmojiPickerComponent,
36
41
  EmojiButtonComponent = DefaultEmojiButton,
42
+ StickerPickerComponent = DefaultStickerPicker,
43
+ StickerButtonComponent = DefaultStickerButton,
44
+ VoiceRecordButtonComponent = DefaultVoiceRecordButton,
45
+ disableStickers = false,
46
+ stickerIframeUrl = 'https://sticker.ermis.network',
37
47
  ReplyPreviewComponent = ReplyPreview,
38
48
  EditPreviewComponent = EditPreview,
39
49
  bannedLabel = 'You have been banned from this channel',
@@ -45,15 +55,25 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
45
55
  <>Slow mode is active. You can send another message in <strong>{cooldown}s</strong>.</>
46
56
  ),
47
57
  closedTopicLabel = 'This topic is closed.',
58
+ PreviewOverlayComponent = PreviewOverlay,
59
+ previewOverlayTitle = 'You are viewing a public channel.',
60
+ joinChannelLabel = 'Join Channel',
61
+ replyingToLabel,
62
+ editingMessageLabel,
63
+ dragAndDropLabel = 'Drop files here to upload',
64
+ DragAndDropOverlayComponent = DefaultDragAndDropOverlay,
65
+ maxCharsLabel = 'Tin nhắn không được vượt quá 5000 ký tự.',
48
66
  }) => {
49
67
  const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage } = useChatClient();
50
68
  const { isBanned } = useBannedState(activeChannel, client.userID);
51
69
  const { isBlocked } = useBlockedState(activeChannel, client.userID);
52
70
  const { isPending } = usePendingState(activeChannel, client.userID);
71
+ const { isPreviewMode } = usePreviewState(activeChannel, client.userID);
53
72
  const editableRef = React.useRef<HTMLDivElement>(null);
54
73
  const [hasContent, setHasContent] = useState(false);
55
74
 
56
75
  const { role, isGroupChannel: isTeamChannel, hasCapability } = useChannelCapabilities();
76
+ const isTopic = isTopicChannel(activeChannel);
57
77
  const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
58
78
 
59
79
  // Slow Mode Logic
@@ -158,6 +178,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
158
178
  }
159
179
  }
160
180
 
181
+ // Max Characters validation (5000 chars)
182
+ if (text && text.length > 5000) {
183
+ setKeywordError(maxCharsLabel);
184
+ return false;
185
+ }
186
+
161
187
  // Custom Keyword validation
162
188
  const words = (activeChannel?.data?.filter_words as string[]) || [];
163
189
  if (words.length > 0 && text) {
@@ -198,6 +224,11 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
198
224
  handleFilesSelected, handleRemoveFile, handleAttachClick, cleanupFiles,
199
225
  } = useFileUpload({ activeChannel, editableRef, setHasContent });
200
226
 
227
+ const { isDragging } = useDragAndDrop(
228
+ handleFilesSelected,
229
+ disableAttachments || !canSendMessage || isSlowModeBlocked || !!editingMessage || !!quotedMessage
230
+ );
231
+
201
232
  // Pre-fill text and legacy attachments when editingMessage is set
202
233
  useEffect(() => {
203
234
  if (editingMessage && editableRef.current) {
@@ -242,9 +273,15 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
242
273
  toggleEmojiPicker,
243
274
  } = useEmojiPicker({ editableRef, setHasContent });
244
275
 
245
- // Build member list from channel state (only for team channels)
276
+ const {
277
+ stickerPickerOpen,
278
+ toggleStickerPicker,
279
+ closeStickerPicker,
280
+ } = useStickerPicker({ activeChannel, stickerIframeUrl });
281
+
282
+ // Build member list from channel state (only for team channels and topics)
246
283
  const members = useMemo<MentionMember[]>(() => {
247
- if (!isTeamChannel) return [];
284
+ if (!(isTeamChannel || isTopic)) return [];
248
285
  const list: MentionMember[] = [];
249
286
  const stateMembers = activeChannel?.state?.members as Record<string, unknown> | undefined;
250
287
  if (stateMembers && typeof stateMembers === 'object') {
@@ -258,7 +295,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
258
295
  }
259
296
  }
260
297
  return list;
261
- }, [activeChannel, isTeamChannel]);
298
+ }, [activeChannel, isTeamChannel, isTopic]);
262
299
 
263
300
  const {
264
301
  showSuggestions, filteredMembers, highlightIndex,
@@ -289,7 +326,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
289
326
  setFiles,
290
327
  hasContent,
291
328
  setHasContent,
292
- isTeamChannel,
329
+ isTeamChannel: isTeamChannel || isTopic,
293
330
  buildPayload,
294
331
  reset,
295
332
  syncMessages,
@@ -324,12 +361,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
324
361
  const content = el?.textContent?.trim() ?? '';
325
362
  setHasContent(content.length > 0 || files.length > 0);
326
363
  setKeywordError(null); // clear keyword error if user modifies input
327
- if (isTeamChannel && !disableMentions) {
364
+ if ((isTeamChannel || isTopic) && !disableMentions) {
328
365
  mentionHandleInput();
329
366
  }
330
367
  // Send typing indicator (SDK throttles to 1 event per 2s)
331
368
  activeChannel?.keystroke();
332
- }, [isTeamChannel, disableMentions, mentionHandleInput, files.length, activeChannel]);
369
+ }, [isTeamChannel, isTopic, disableMentions, mentionHandleInput, files.length, activeChannel]);
333
370
 
334
371
  const handleKeyDown = useCallback(
335
372
  (e: React.KeyboardEvent) => {
@@ -346,25 +383,33 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
346
383
  return;
347
384
  }
348
385
  }
349
- if (isTeamChannel && !disableMentions) {
386
+ if ((isTeamChannel || isTopic) && !disableMentions) {
350
387
  const consumed = mentionHandleKeyDown(e);
351
388
  if (consumed) return;
352
389
  }
353
390
  if (e.key === 'Enter' && !e.shiftKey) {
354
391
  e.preventDefault();
355
- if (!isSlowModeBlocked) {
392
+ if (!isSlowModeBlocked && !keywordError) {
356
393
  handleSend();
357
394
  }
358
395
  }
359
396
  },
360
- [isTeamChannel, disableMentions, mentionHandleKeyDown, handleSend, editingMessage, quotedMessage, setEditingMessage, setQuotedMessage, reset],
397
+ [isTeamChannel, isTopic, disableMentions, mentionHandleKeyDown, handleSend, editingMessage, quotedMessage, setEditingMessage, setQuotedMessage, reset],
361
398
  );
362
399
 
363
400
  const handlePaste = useCallback((e: React.ClipboardEvent) => {
364
401
  e.preventDefault();
402
+ if (e.clipboardData.files && e.clipboardData.files.length > 0) {
403
+ if (!disableAttachments && canSendMessage && !isSlowModeBlocked && !editingMessage) {
404
+ handleFilesSelected(e.clipboardData.files);
405
+ }
406
+ return;
407
+ }
365
408
  const plainText = e.clipboardData.getData('text/plain');
366
- document.execCommand('insertText', false, plainText);
367
- }, []);
409
+ if (plainText) {
410
+ document.execCommand('insertText', false, plainText);
411
+ }
412
+ }, [disableAttachments, canSendMessage, isSlowModeBlocked, editingMessage, handleFilesSelected]);
368
413
 
369
414
  if (!activeChannel) return null;
370
415
 
@@ -416,6 +461,52 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
416
461
  );
417
462
  }
418
463
 
464
+ // Show Preview Overlay for public channels when the user has not joined
465
+ if (isPreviewMode) {
466
+ return (
467
+ <PreviewOverlayComponent
468
+ title={previewOverlayTitle}
469
+ buttonLabel={joinChannelLabel}
470
+ onJoin={async () => {
471
+ if (!activeChannel) return;
472
+ try {
473
+ await activeChannel.acceptInvite('join');
474
+ // Optimistically update local membership
475
+ if (activeChannel.state && client.userID) {
476
+ const updatedMembership = {
477
+ ...activeChannel.state.membership,
478
+ channel_role: 'member',
479
+ user_id: client.userID,
480
+ } as Record<string, unknown>;
481
+ activeChannel.state.membership = updatedMembership;
482
+ if (activeChannel.state.members?.[client.userID]) {
483
+ activeChannel.state.members[client.userID] = {
484
+ ...activeChannel.state.members[client.userID],
485
+ channel_role: 'member',
486
+ };
487
+ }
488
+ // Dispatch synthetic event so channel list and other hooks update
489
+ client.dispatchEvent({
490
+ type: 'member.joined',
491
+ cid: activeChannel.cid,
492
+ channel_type: activeChannel.type,
493
+ channel_id: activeChannel.id,
494
+ channel: activeChannel.data,
495
+ member: updatedMembership,
496
+ user: client.user,
497
+ } as any);
498
+ }
499
+ // Re-watch to get full state from server
500
+ activeChannel.watch().catch(() => {});
501
+ } catch (e) {
502
+ console.error('Failed to join public channel', e);
503
+ }
504
+ }}
505
+ className={className}
506
+ />
507
+ );
508
+ }
509
+
419
510
  const isStillUploading = files.some((f) => f.status === 'uploading');
420
511
 
421
512
  return (
@@ -425,6 +516,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
425
516
  <ReplyPreviewComponent
426
517
  message={quotedMessage}
427
518
  onDismiss={() => setQuotedMessage(null)}
519
+ replyingToLabel={replyingToLabel}
428
520
  />
429
521
  )}
430
522
 
@@ -433,6 +525,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
433
525
  <EditPreviewComponent
434
526
  message={editingMessage}
435
527
  onDismiss={cancelEdit}
528
+ editingMessageLabel={editingMessageLabel}
436
529
  />
437
530
  )}
438
531
 
@@ -469,7 +562,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
469
562
  {/* Text input + send row */}
470
563
  <div className={`ermis-message-input__row${(!canSendMessage || isSlowModeBlocked || keywordError) ? ' ermis-message-input__row--banners-active' : ''}`}>
471
564
  <div className="ermis-message-input__editable-wrapper">
472
- {canSendMessage && isTeamChannel && !disableMentions && showSuggestions && (
565
+ {canSendMessage && (isTeamChannel || isTopic) && !disableMentions && showSuggestions && (
473
566
  <MentionSuggestionsComponent
474
567
  members={filteredMembers}
475
568
  highlightIndex={highlightIndex}
@@ -510,20 +603,48 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
510
603
  suppressContentEditableWarning
511
604
  />
512
605
 
606
+ {/* Voice record button */}
607
+ {VoiceRecordButtonComponent && (
608
+ <VoiceRecordButtonComponent
609
+ disabled={sending || !!editingMessage || isSlowModeBlocked || !canSendMessage}
610
+ onRecordComplete={(f) => {
611
+ const dt = new DataTransfer();
612
+ dt.items.add(f);
613
+ handleFilesSelected(dt.files);
614
+ }}
615
+ />
616
+ )}
617
+
513
618
  {/* Emoji button — shown only when EmojiPickerComponent is provided */}
514
619
  {EmojiPickerComponent && (
515
620
  <EmojiButtonComponent active={emojiPickerOpen} onClick={isSlowModeBlocked ? () => { } : toggleEmojiPicker} />
516
621
  )}
622
+
623
+ {/* Sticker button — shown unless disabled */}
624
+ {!disableStickers && StickerButtonComponent && (
625
+ <StickerButtonComponent active={stickerPickerOpen} onClick={isSlowModeBlocked || !!editingMessage || !!quotedMessage ? () => { } : toggleStickerPicker} />
626
+ )}
517
627
  </div>
518
- <SendButton disabled={!hasContent || sending || isStillUploading || isSlowModeBlocked} onClick={handleSend} />
628
+
629
+ <SendButton disabled={!hasContent || sending || !!editingMessage || isSlowModeBlocked || !canSendMessage || isStillUploading || !!keywordError} onClick={handleSend} />
519
630
  </div>
520
631
 
521
- {/* Emoji picker positioned above input */}
632
+ {/* Emoji Picker Dropdown */}
522
633
  {EmojiPickerComponent && emojiPickerOpen && (
523
634
  <div className="ermis-message-input__emoji-picker">
524
635
  <EmojiPickerComponent onSelect={handleEmojiSelect} onClose={handleEmojiClose} />
525
636
  </div>
526
637
  )}
638
+
639
+ {/* Sticker Picker Dropdown */}
640
+ {!disableStickers && StickerPickerComponent && stickerPickerOpen && (
641
+ <StickerPickerComponent stickerIframeUrl={stickerIframeUrl} onClose={closeStickerPicker} />
642
+ )}
643
+
644
+ {/* Drag & Drop Overlay */}
645
+ {isDragging && (
646
+ <DragAndDropOverlayComponent dragAndDropLabel={dragAndDropLabel} />
647
+ )}
527
648
  </div>
528
649
  );
529
650
  });
@@ -1,4 +1,6 @@
1
- import React from 'react';
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { MultiRecorder, PCM_WORKLET_URL } from 'react-ts-audio-recorder';
3
+ import type { VoiceRecordButtonProps } from '../types';
2
4
 
3
5
  /* ----------------------------------------------------------
4
6
  Default sub-components for MessageInput
@@ -48,3 +50,127 @@ export const DefaultEmojiButton: React.FC<{ active: boolean; onClick: () => void
48
50
  </button>
49
51
  ));
50
52
  DefaultEmojiButton.displayName = 'DefaultEmojiButton';
53
+
54
+ export const DefaultStickerButton: React.FC<{ active: boolean; onClick: () => void }> = React.memo(({
55
+ active,
56
+ onClick,
57
+ }) => (
58
+ <button
59
+ className={`ermis-message-input__sticker-btn${active ? ' ermis-message-input__sticker-btn--active' : ''}`}
60
+ onClick={onClick}
61
+ type="button"
62
+ aria-label="Sticker"
63
+ >
64
+ 🐱
65
+ </button>
66
+ ));
67
+ DefaultStickerButton.displayName = 'DefaultStickerButton';
68
+
69
+ export const DefaultStickerPicker: React.FC<{ stickerIframeUrl: string; onClose: () => void }> = React.memo(({
70
+ stickerIframeUrl,
71
+ }) => (
72
+ <div className="ermis-message-input__sticker-picker-container">
73
+ <iframe
74
+ src={stickerIframeUrl}
75
+ title="Sticker Picker"
76
+ className="ermis-message-input__sticker-iframe"
77
+ />
78
+ </div>
79
+ ));
80
+ DefaultStickerPicker.displayName = 'DefaultStickerPicker';
81
+
82
+ export const DefaultDragAndDropOverlay: React.FC<{ dragAndDropLabel: string }> = React.memo(({
83
+ dragAndDropLabel,
84
+ }) => (
85
+ <div className="ermis-channel__drop-overlay">
86
+ <div className="ermis-channel__drop-overlay-content">
87
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
88
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
89
+ <polyline points="17 8 12 3 7 8"></polyline>
90
+ <line x1="12" y1="3" x2="12" y2="15"></line>
91
+ </svg>
92
+ <span>{dragAndDropLabel}</span>
93
+ </div>
94
+ </div>
95
+ ));
96
+ DefaultDragAndDropOverlay.displayName = 'DefaultDragAndDropOverlay';
97
+
98
+ export const DefaultVoiceRecordButton: React.FC<VoiceRecordButtonProps> = React.memo(({ disabled, onRecordComplete }) => {
99
+ const [isRecording, setIsRecording] = useState(false);
100
+ const [recordingTime, setRecordingTime] = useState(0);
101
+ const recorderRef = useRef<MultiRecorder | null>(null);
102
+ const timerRef = useRef<number | null>(null);
103
+
104
+ useEffect(() => {
105
+ return () => {
106
+ if (timerRef.current) clearInterval(timerRef.current);
107
+ if (recorderRef.current) recorderRef.current.close();
108
+ };
109
+ }, []);
110
+
111
+ const toggleRecording = async () => {
112
+ if (isRecording) {
113
+ if (!recorderRef.current) return;
114
+ try {
115
+ const blob = await recorderRef.current.stopRecording();
116
+ const file = new File([blob], `Voice_Message.wav`, { type: 'audio/wav' });
117
+ onRecordComplete(file);
118
+ } catch (err) {
119
+ console.error('Failed to stop recording:', err);
120
+ } finally {
121
+ recorderRef.current.close();
122
+ recorderRef.current = null;
123
+ setIsRecording(false);
124
+ setRecordingTime(0);
125
+ if (timerRef.current) clearInterval(timerRef.current);
126
+ }
127
+ } else {
128
+ try {
129
+ const recorder = new MultiRecorder({
130
+ format: 'wav',
131
+ workletURL: PCM_WORKLET_URL,
132
+ });
133
+ await recorder.init();
134
+ await recorder.startRecording();
135
+ recorderRef.current = recorder;
136
+ setIsRecording(true);
137
+ setRecordingTime(0);
138
+ timerRef.current = window.setInterval(() => {
139
+ setRecordingTime(prev => prev + 1);
140
+ }, 1000);
141
+ } catch (err) {
142
+ console.error('Failed to start recording:', err);
143
+ }
144
+ }
145
+ };
146
+
147
+ const formatTime = (seconds: number) => {
148
+ const m = Math.floor(seconds / 60);
149
+ const s = seconds % 60;
150
+ return `${m}:${s.toString().padStart(2, '0')}`;
151
+ };
152
+
153
+ return (
154
+ <button
155
+ className={`ermis-message-input__voice-btn ${isRecording ? 'ermis-message-input__voice-btn--recording' : ''}`}
156
+ onClick={toggleRecording}
157
+ disabled={disabled && !isRecording}
158
+ type="button"
159
+ title={isRecording ? 'Stop Recording' : 'Record Voice Message'}
160
+ >
161
+ {isRecording ? (
162
+ <span className="ermis-message-input__voice-recording-indicator">
163
+ <span className="ermis-message-input__voice-dot" />
164
+ {formatTime(recordingTime)}
165
+ </span>
166
+ ) : (
167
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
168
+ <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
169
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
170
+ <line x1="12" y1="19" x2="12" y2="22" />
171
+ </svg>
172
+ )}
173
+ </button>
174
+ );
175
+ });
176
+ DefaultVoiceRecordButton.displayName = 'DefaultVoiceRecordButton';
@@ -7,7 +7,7 @@ import { MessageQuickReactions } from './MessageQuickReactions';
7
7
  import { useChannelCapabilities } from '../hooks/useChannelCapabilities';
8
8
  import { useChatClient } from '../hooks/useChatClient';
9
9
  import { formatTime } from '../utils';
10
- import { isSystemMessage } from '../messageTypeUtils';
10
+ import { isSystemMessage, isDeletedDisplayMessage, isStickerMessage, isSignalMessage } from '../messageTypeUtils';
11
11
 
12
12
  export type { MessageItemProps, SystemMessageItemProps } from '../types';
13
13
 
@@ -73,10 +73,16 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
73
73
  MessageReactionsComponent = MessageReactions,
74
74
  forwardedLabel = 'Forwarded',
75
75
  editedLabel = 'Edited',
76
+ deletedMessageLabel = 'This message was deleted',
77
+ systemMessageTranslations,
78
+ signalMessageTranslations,
79
+ onMentionClick,
80
+ onUserNameClick,
81
+ onAddReactionClick,
76
82
  }) => {
77
83
  const { activeChannel, client } = useChatClient();
78
84
  const { hasCapability } = useChannelCapabilities();
79
-
85
+
80
86
  const canReact = hasCapability('send-reaction');
81
87
 
82
88
  const userName = message.user?.name || message.user_id;
@@ -87,6 +93,7 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
87
93
  const oldTexts = (message as any).old_texts;
88
94
  const isEdited = oldTexts && oldTexts.length > 0;
89
95
  const hasAttachments = message.attachments && message.attachments.length > 0;
96
+ const isDeletedDisplay = isDeletedDisplayMessage(message);
90
97
 
91
98
  const handleReactionToggle = React.useCallback(async (type: string) => {
92
99
  if (!activeChannel || !canReact) return;
@@ -118,20 +125,63 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
118
125
  return Date.now() - new Date(message.created_at).getTime() < 1000;
119
126
  }, [message.created_at]);
120
127
 
128
+ const isSticker = React.useMemo(() => isStickerMessage(message), [message]);
129
+
121
130
  const itemClass = [
122
131
  'ermis-message-list__item',
123
132
  isOwnMessage ? 'ermis-message-list__item--own' : 'ermis-message-list__item--other',
124
- isFirstInGroup ? 'ermis-message-list__item--group-start' : 'ermis-message-list__item--group-cont',
133
+ isFirstInGroup && isLastInGroup ? 'ermis-message-list__item--group-single' : '',
134
+ isFirstInGroup && !isLastInGroup ? 'ermis-message-list__item--group-top' : '',
135
+ !isFirstInGroup && !isLastInGroup ? 'ermis-message-list__item--group-middle' : '',
136
+ !isFirstInGroup && isLastInGroup ? 'ermis-message-list__item--group-bottom' : '',
125
137
  isHighlighted ? 'ermis-message-list__item--highlighted' : '',
126
138
  isNewMessage ? 'ermis-message-list__item--new' : '',
139
+ isDeletedDisplay ? 'ermis-message-list__item--deleted-display' : '',
140
+ isSticker ? 'ermis-message-list__item--sticker' : '',
141
+ isSignalMessage(message) ? 'ermis-message-list__item--signal' : '',
127
142
  statusClass,
128
143
  ].filter(Boolean).join(' ');
129
144
 
130
145
  const contentClass = [
131
146
  'ermis-message-list__item-content',
132
- hasAttachments ? 'ermis-message-list__item-content--has-attachments' : '',
147
+ hasAttachments && !isDeletedDisplay ? 'ermis-message-list__item-content--has-attachments' : '',
133
148
  ].filter(Boolean).join(' ');
134
149
 
150
+ // Deleted display: show icon + label, no actions/reactions/quote/attachments
151
+ if (isDeletedDisplay) {
152
+ return (
153
+ <div className={itemClass} data-message-id={message.id}>
154
+ {!isOwnMessage && (
155
+ <div className="ermis-message-list__item-avatar">
156
+ {isFirstInGroup
157
+ ? <AvatarComponent image={userAvatar} name={userName} size={28} />
158
+ : <div style={{ width: 28 }} />
159
+ }
160
+ </div>
161
+ )}
162
+ <div className={contentClass}>
163
+ {!isOwnMessage && isFirstInGroup && (
164
+ <span className="ermis-message-list__item-user">{userName}</span>
165
+ )}
166
+ <div className="ermis-message-list__bubble-wrapper">
167
+ <MessageBubble message={message} isOwnMessage={isOwnMessage}>
168
+ <span className="ermis-message-list__deleted-text">
169
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
170
+ <circle cx="12" cy="12" r="10" />
171
+ <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
172
+ </svg>
173
+ {deletedMessageLabel}
174
+ </span>
175
+ <span className="ermis-message-list__item-time">
176
+ {formatTime(message.created_at)}
177
+ </span>
178
+ </MessageBubble>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ );
183
+ }
184
+
135
185
  return (
136
186
  <div className={itemClass} data-message-id={message.id}>
137
187
  {/* Avatar area: show avatar only on first message, otherwise placeholder for alignment */}
@@ -145,7 +195,10 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
145
195
  )}
146
196
  <div className={contentClass}>
147
197
  {!isOwnMessage && isFirstInGroup && (
148
- <span className="ermis-message-list__item-user">{userName}</span>
198
+ <span
199
+ className={`ermis-message-list__item-user${onUserNameClick ? ' ermis-message-list__item-user--clickable' : ''}`}
200
+ onClick={onUserNameClick ? (e) => { e.stopPropagation(); const uid = message.user?.id || message.user_id; if (uid) onUserNameClick(uid); } : undefined}
201
+ >{userName}</span>
149
202
  )}
150
203
  {/* Quoted message preview */}
151
204
  {quotedMessage && onClickQuote && (
@@ -156,33 +209,41 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
156
209
  />
157
210
  )}
158
211
  <div className="ermis-message-list__bubble-wrapper">
159
- <MessageQuickReactions message={message} isOwnMessage={isOwnMessage} disabled={!canReact} />
212
+ {!isSignalMessage(message) && <MessageQuickReactions message={message} isOwnMessage={isOwnMessage} disabled={!canReact} onAddReactionClick={onAddReactionClick} />}
160
213
  <MessageBubble message={message} isOwnMessage={isOwnMessage}>
161
214
  {isForwarded && (
162
215
  <span className="ermis-message-list__forwarded-indicator">{forwardedLabel}</span>
163
216
  )}
164
- <MessageRenderer message={message} isOwnMessage={isOwnMessage} />
165
- <span className="ermis-message-list__item-time">
166
- {isEdited && (
167
- <span
168
- className="ermis-message-list__edited-indicator"
169
- // data-tooltip={oldTexts.map((ot: any) => `[${formatTime(ot.created_at)}] ${ot.text}`).join('\n')}
170
- >
171
- {editedLabel}
172
- </span>
173
- )}
174
- {formatTime(message.created_at)}
175
- <InlineStatusIcon status={message.status} isOwnMessage={isOwnMessage} isLastInGroup={isLastInGroup} />
176
- </span>
177
- </MessageBubble>
178
-
179
- {/* Actions: hover buttons + dropdown menu */}
180
- {!isSystemMessage(message) && (
181
- <MessageActionsBoxComponent
217
+ <MessageRenderer
182
218
  message={message}
183
219
  isOwnMessage={isOwnMessage}
220
+ systemMessageTranslations={systemMessageTranslations}
221
+ signalMessageTranslations={signalMessageTranslations}
222
+ onMentionClick={onMentionClick}
184
223
  />
185
- )}
224
+ {!isSignalMessage(message) && (isLastInGroup || isEdited || message.status === 'error' || message.status === 'failed_offline') && (
225
+ <span className="ermis-message-list__item-time">
226
+ {isEdited && (
227
+ <span
228
+ className="ermis-message-list__edited-indicator"
229
+ // data-tooltip={oldTexts.map((ot: any) => `[${formatTime(ot.created_at)}] ${ot.text}`).join('\n')}
230
+ >
231
+ {editedLabel}
232
+ </span>
233
+ )}
234
+ {isLastInGroup && formatTime(message.created_at)}
235
+ <InlineStatusIcon status={message.status} isOwnMessage={isOwnMessage} isLastInGroup={isLastInGroup} />
236
+ </span>
237
+ )}
238
+
239
+ {/* Actions: hover buttons + dropdown menu */}
240
+ {!isSystemMessage(message) && (
241
+ <MessageActionsBoxComponent
242
+ message={message}
243
+ isOwnMessage={isOwnMessage}
244
+ />
245
+ )}
246
+ </MessageBubble>
186
247
 
187
248
  {/* Message Reactions */}
188
249
  {MessageReactionsComponent && (
@@ -192,6 +253,7 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
192
253
  latestReactions={(message as any).latest_reactions}
193
254
  onClickReaction={handleReactionToggle}
194
255
  disabled={!canReact}
256
+ isOwnMessage={isOwnMessage}
195
257
  />
196
258
  )}
197
259
  </div>
@@ -208,9 +270,14 @@ export const SystemMessageItem: React.FC<SystemMessageItemProps> = React.memo(({
208
270
  message,
209
271
  isOwnMessage,
210
272
  SystemRenderer,
273
+ systemMessageTranslations,
211
274
  }) => (
212
275
  <div className="ermis-message-list__system">
213
- <SystemRenderer message={message} isOwnMessage={isOwnMessage} />
276
+ <SystemRenderer
277
+ message={message}
278
+ isOwnMessage={isOwnMessage}
279
+ systemMessageTranslations={systemMessageTranslations}
280
+ />
214
281
  </div>
215
282
  ));
216
283
  SystemMessageItem.displayName = 'SystemMessageItem';