@ermis-network/ermis-chat-react 1.0.9 → 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 +15288 -4203
  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 +15238 -4179
  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 +126 -7
  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,13 +6,17 @@ 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';
@@ -35,6 +39,11 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
35
39
  onBeforeSend,
36
40
  EmojiPickerComponent,
37
41
  EmojiButtonComponent = DefaultEmojiButton,
42
+ StickerPickerComponent = DefaultStickerPicker,
43
+ StickerButtonComponent = DefaultStickerButton,
44
+ VoiceRecordButtonComponent = DefaultVoiceRecordButton,
45
+ disableStickers = false,
46
+ stickerIframeUrl = 'https://sticker.ermis.network',
38
47
  ReplyPreviewComponent = ReplyPreview,
39
48
  EditPreviewComponent = EditPreview,
40
49
  bannedLabel = 'You have been banned from this channel',
@@ -46,11 +55,20 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
46
55
  <>Slow mode is active. You can send another message in <strong>{cooldown}s</strong>.</>
47
56
  ),
48
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ự.',
49
66
  }) => {
50
67
  const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage } = useChatClient();
51
68
  const { isBanned } = useBannedState(activeChannel, client.userID);
52
69
  const { isBlocked } = useBlockedState(activeChannel, client.userID);
53
70
  const { isPending } = usePendingState(activeChannel, client.userID);
71
+ const { isPreviewMode } = usePreviewState(activeChannel, client.userID);
54
72
  const editableRef = React.useRef<HTMLDivElement>(null);
55
73
  const [hasContent, setHasContent] = useState(false);
56
74
 
@@ -160,6 +178,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
160
178
  }
161
179
  }
162
180
 
181
+ // Max Characters validation (5000 chars)
182
+ if (text && text.length > 5000) {
183
+ setKeywordError(maxCharsLabel);
184
+ return false;
185
+ }
186
+
163
187
  // Custom Keyword validation
164
188
  const words = (activeChannel?.data?.filter_words as string[]) || [];
165
189
  if (words.length > 0 && text) {
@@ -200,6 +224,11 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
200
224
  handleFilesSelected, handleRemoveFile, handleAttachClick, cleanupFiles,
201
225
  } = useFileUpload({ activeChannel, editableRef, setHasContent });
202
226
 
227
+ const { isDragging } = useDragAndDrop(
228
+ handleFilesSelected,
229
+ disableAttachments || !canSendMessage || isSlowModeBlocked || !!editingMessage || !!quotedMessage
230
+ );
231
+
203
232
  // Pre-fill text and legacy attachments when editingMessage is set
204
233
  useEffect(() => {
205
234
  if (editingMessage && editableRef.current) {
@@ -244,6 +273,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
244
273
  toggleEmojiPicker,
245
274
  } = useEmojiPicker({ editableRef, setHasContent });
246
275
 
276
+ const {
277
+ stickerPickerOpen,
278
+ toggleStickerPicker,
279
+ closeStickerPicker,
280
+ } = useStickerPicker({ activeChannel, stickerIframeUrl });
281
+
247
282
  // Build member list from channel state (only for team channels and topics)
248
283
  const members = useMemo<MentionMember[]>(() => {
249
284
  if (!(isTeamChannel || isTopic)) return [];
@@ -354,7 +389,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
354
389
  }
355
390
  if (e.key === 'Enter' && !e.shiftKey) {
356
391
  e.preventDefault();
357
- if (!isSlowModeBlocked) {
392
+ if (!isSlowModeBlocked && !keywordError) {
358
393
  handleSend();
359
394
  }
360
395
  }
@@ -364,9 +399,17 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
364
399
 
365
400
  const handlePaste = useCallback((e: React.ClipboardEvent) => {
366
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
+ }
367
408
  const plainText = e.clipboardData.getData('text/plain');
368
- document.execCommand('insertText', false, plainText);
369
- }, []);
409
+ if (plainText) {
410
+ document.execCommand('insertText', false, plainText);
411
+ }
412
+ }, [disableAttachments, canSendMessage, isSlowModeBlocked, editingMessage, handleFilesSelected]);
370
413
 
371
414
  if (!activeChannel) return null;
372
415
 
@@ -418,6 +461,52 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
418
461
  );
419
462
  }
420
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
+
421
510
  const isStillUploading = files.some((f) => f.status === 'uploading');
422
511
 
423
512
  return (
@@ -427,6 +516,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
427
516
  <ReplyPreviewComponent
428
517
  message={quotedMessage}
429
518
  onDismiss={() => setQuotedMessage(null)}
519
+ replyingToLabel={replyingToLabel}
430
520
  />
431
521
  )}
432
522
 
@@ -435,6 +525,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
435
525
  <EditPreviewComponent
436
526
  message={editingMessage}
437
527
  onDismiss={cancelEdit}
528
+ editingMessageLabel={editingMessageLabel}
438
529
  />
439
530
  )}
440
531
 
@@ -512,20 +603,48 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
512
603
  suppressContentEditableWarning
513
604
  />
514
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
+
515
618
  {/* Emoji button — shown only when EmojiPickerComponent is provided */}
516
619
  {EmojiPickerComponent && (
517
620
  <EmojiButtonComponent active={emojiPickerOpen} onClick={isSlowModeBlocked ? () => { } : toggleEmojiPicker} />
518
621
  )}
622
+
623
+ {/* Sticker button — shown unless disabled */}
624
+ {!disableStickers && StickerButtonComponent && (
625
+ <StickerButtonComponent active={stickerPickerOpen} onClick={isSlowModeBlocked || !!editingMessage || !!quotedMessage ? () => { } : toggleStickerPicker} />
626
+ )}
519
627
  </div>
520
- <SendButton disabled={!hasContent || sending || isStillUploading || isSlowModeBlocked} onClick={handleSend} />
628
+
629
+ <SendButton disabled={!hasContent || sending || !!editingMessage || isSlowModeBlocked || !canSendMessage || isStillUploading || !!keywordError} onClick={handleSend} />
521
630
  </div>
522
631
 
523
- {/* Emoji picker positioned above input */}
632
+ {/* Emoji Picker Dropdown */}
524
633
  {EmojiPickerComponent && emojiPickerOpen && (
525
634
  <div className="ermis-message-input__emoji-picker">
526
635
  <EmojiPickerComponent onSelect={handleEmojiSelect} onClose={handleEmojiClose} />
527
636
  </div>
528
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
+ )}
529
648
  </div>
530
649
  );
531
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';