@ermis-network/ermis-chat-react 1.0.9 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. package/src/utils.ts +219 -16
@@ -1,57 +1,69 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
- import { VList, VListHandle } from 'virtua';
3
2
  import { Avatar } from './Avatar';
4
3
  import type { MentionSuggestionsProps } from '../types';
5
4
 
6
5
  export type { MentionSuggestionsProps } from '../types';
7
6
 
8
- // Estimated item height
9
- const ITEM_HEIGHT = 42;
10
-
11
7
  export const MentionSuggestions: React.FC<MentionSuggestionsProps> = React.memo(({
12
8
  members,
13
9
  highlightIndex,
14
10
  onSelect,
15
11
  }) => {
16
- const listRef = useRef<VListHandle>(null);
12
+ const containerRef = useRef<HTMLDivElement>(null);
13
+ const itemsRef = useRef<Map<number, HTMLDivElement>>(new Map());
17
14
 
18
15
  // Auto-scroll highlighted item into view
19
16
  useEffect(() => {
20
- // VList uses scrollToIndex
21
- listRef.current?.scrollToIndex(highlightIndex);
17
+ const el = itemsRef.current.get(highlightIndex);
18
+ if (el && containerRef.current) {
19
+ const container = containerRef.current;
20
+ const elementTop = el.offsetTop;
21
+ const elementBottom = elementTop + el.offsetHeight;
22
+ const containerTop = container.scrollTop;
23
+ const containerBottom = containerTop + container.clientHeight;
24
+
25
+ if (elementTop < containerTop) {
26
+ container.scrollTop = elementTop;
27
+ } else if (elementBottom > containerBottom) {
28
+ container.scrollTop = elementBottom - container.clientHeight;
29
+ }
30
+ }
22
31
  }, [highlightIndex]);
23
32
 
24
33
  if (members.length === 0) return null;
25
34
 
26
- // Calculate dynamic height based on item count, cap at 200px
27
- const listHeight = Math.min(members.length * ITEM_HEIGHT, 200);
28
-
29
35
  return (
30
- <div className="ermis-mention-suggestions" style={{ overflow: 'hidden' }}>
31
- <VList ref={listRef} style={{ height: listHeight }}>
32
- {members.map((member, index) => (
33
- <div
34
- key={member.id}
35
- className={`ermis-mention-suggestions__item${
36
- index === highlightIndex ? ' ermis-mention-suggestions__item--highlighted' : ''
37
- }`}
38
- onMouseDown={(e) => {
39
- // Use mousedown (not click) to fire before blur
40
- e.preventDefault();
41
- onSelect(member);
42
- }}
43
- >
44
- {member.id === '__all__' ? (
45
- <div className="ermis-mention-suggestions__all-icon">@</div>
46
- ) : (
47
- <Avatar image={member.avatar} name={member.name} size={24} />
48
- )}
49
- <span className="ermis-mention-suggestions__name">
50
- {member.id === '__all__' ? 'all' : member.name}
51
- </span>
52
- </div>
53
- ))}
54
- </VList>
36
+ <div
37
+ className="ermis-mention-suggestions"
38
+ ref={containerRef}
39
+ style={{ overflowY: 'auto', maxHeight: '200px' }}
40
+ >
41
+ {members.map((member, index) => (
42
+ <div
43
+ key={member.id}
44
+ ref={(el) => {
45
+ if (el) itemsRef.current.set(index, el);
46
+ else itemsRef.current.delete(index);
47
+ }}
48
+ className={`ermis-mention-suggestions__item${
49
+ index === highlightIndex ? ' ermis-mention-suggestions__item--highlighted' : ''
50
+ }`}
51
+ onMouseDown={(e) => {
52
+ // Use mousedown (not click) to fire before blur
53
+ e.preventDefault();
54
+ onSelect(member);
55
+ }}
56
+ >
57
+ {member.id === '__all__' ? (
58
+ <div className="ermis-mention-suggestions__all-icon">@</div>
59
+ ) : (
60
+ <Avatar image={member.avatar} name={member.name} size={24} />
61
+ )}
62
+ <span className="ermis-mention-suggestions__name">
63
+ {member.id === '__all__' ? 'all' : member.name}
64
+ </span>
65
+ </div>
66
+ ))}
55
67
  </div>
56
68
  );
57
69
  });
@@ -3,7 +3,9 @@ import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
3
3
  import { useMessageActions } from '../hooks/useMessageActions';
4
4
  import { useChatClient } from '../hooks/useChatClient';
5
5
  import type { MessageActionsBoxProps } from '../types';
6
- import { Dropdown, closeAllDropdowns } from './Dropdown';
6
+ import { Dropdown as DefaultDropdown, closeAllDropdowns } from './Dropdown';
7
+ import { useChatComponents } from '../context/ChatComponentsContext';
8
+ import { MessageQuickReactions } from './MessageQuickReactions';
7
9
 
8
10
  // Aliased for backward compatibility
9
11
  export const closeAllActionBoxes = closeAllDropdowns;
@@ -26,6 +28,8 @@ export const MessageActionsBox: React.FC<MessageActionsBoxProps> = ({
26
28
  deleteForEveryoneLabel = 'Delete for everyone',
27
29
  }) => {
28
30
  const { setQuotedMessage, setEditingMessage, setForwardingMessage, activeChannel } = useChatClient();
31
+ const { DropdownComponent } = useChatComponents();
32
+ const Dropdown = DropdownComponent || DefaultDropdown;
29
33
  const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
30
34
  const actions = useMessageActions(message, isOwnMessage);
31
35
 
@@ -102,6 +106,7 @@ export const MessageActionsBox: React.FC<MessageActionsBoxProps> = ({
102
106
  </svg>
103
107
  </button>
104
108
  )}
109
+ <MessageQuickReactions message={message} isOwnMessage={isOwnMessage} disabled={!actions.hasCapReact} />
105
110
  {actions.canForward && (
106
111
  <button
107
112
  className="ermis-message-list__actions-trigger"
@@ -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,13 +55,23 @@ 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
- const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage } = useChatClient();
67
+ const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage, setDraft, getDraft } = 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);
74
+ const prevChannelCidRef = useRef<string | null>(null);
56
75
 
57
76
  const { role, isGroupChannel: isTeamChannel, hasCapability } = useChannelCapabilities();
58
77
  const isTopic = isTopicChannel(activeChannel);
@@ -160,6 +179,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
160
179
  }
161
180
  }
162
181
 
182
+ // Max Characters validation (5000 chars)
183
+ if (text && text.length > 5000) {
184
+ setKeywordError(maxCharsLabel);
185
+ return false;
186
+ }
187
+
163
188
  // Custom Keyword validation
164
189
  const words = (activeChannel?.data?.filter_words as string[]) || [];
165
190
  if (words.length > 0 && text) {
@@ -183,8 +208,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
183
208
  lastMsgSentAtRef.current = Date.now();
184
209
  setCooldownEnd(Date.now() + memberMessageCooldown);
185
210
  }
211
+ // Clear draft after successful send
212
+ if (activeChannel?.cid) {
213
+ setDraft(activeChannel.cid, { html: '', files: [] });
214
+ }
186
215
  onSend?.(text);
187
- }, [isSlowModeApplied, memberMessageCooldown, onSend]);
216
+ }, [isSlowModeApplied, memberMessageCooldown, onSend, activeChannel, setDraft]);
188
217
 
189
218
  // Auto-focus when channel changes or when reply/edit is selected
190
219
  useEffect(() => {
@@ -200,6 +229,14 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
200
229
  handleFilesSelected, handleRemoveFile, handleAttachClick, cleanupFiles,
201
230
  } = useFileUpload({ activeChannel, editableRef, setHasContent });
202
231
 
232
+ const filesRef = useRef(files);
233
+ filesRef.current = files;
234
+
235
+ const { isDragging } = useDragAndDrop(
236
+ handleFilesSelected,
237
+ disableAttachments || !canSendMessage || isSlowModeBlocked || !!editingMessage || !!quotedMessage
238
+ );
239
+
203
240
  // Pre-fill text and legacy attachments when editingMessage is set
204
241
  useEffect(() => {
205
242
  if (editingMessage && editableRef.current) {
@@ -244,6 +281,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
244
281
  toggleEmojiPicker,
245
282
  } = useEmojiPicker({ editableRef, setHasContent });
246
283
 
284
+ const {
285
+ stickerPickerOpen,
286
+ toggleStickerPicker,
287
+ closeStickerPicker,
288
+ } = useStickerPicker({ activeChannel, stickerIframeUrl });
289
+
247
290
  // Build member list from channel state (only for team channels and topics)
248
291
  const members = useMemo<MentionMember[]>(() => {
249
292
  if (!(isTeamChannel || isTopic)) return [];
@@ -304,21 +347,44 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
304
347
  });
305
348
 
306
349
  useEffect(() => {
350
+ // Save draft from PREVIOUS channel before switching
351
+ if (prevChannelCidRef.current && editableRef.current) {
352
+ const currentHtml = editableRef.current.innerHTML;
353
+ setDraft(prevChannelCidRef.current, { html: currentHtml, files: filesRef.current });
354
+ }
355
+
307
356
  reset();
308
357
  handleEmojiClose();
309
- setFiles((prev) => {
310
- prev.forEach((f) => {
311
- if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
312
- });
313
- return [];
314
- });
315
- setHasContent(false);
358
+ // Do not revoke Object URLs here since we save files in drafts and need previews when returning
359
+ setFiles([]);
360
+
361
+ // Restore draft for NEW channel
362
+ const newCid = activeChannel?.cid || null;
363
+ prevChannelCidRef.current = newCid;
364
+
365
+ if (newCid && editableRef.current) {
366
+ const draft = getDraft(newCid);
367
+ if (draft) {
368
+ editableRef.current.innerHTML = draft.html;
369
+ setFiles(draft.files || []);
370
+ setHasContent(!!editableRef.current.textContent?.trim() || !!(draft.files && draft.files.length));
371
+ moveCaretToEnd(editableRef.current);
372
+ } else {
373
+ editableRef.current.innerHTML = '';
374
+ setFiles([]);
375
+ setHasContent(false);
376
+ }
377
+ } else {
378
+ if (editableRef.current) editableRef.current.innerHTML = '';
379
+ setFiles([]);
380
+ setHasContent(false);
381
+ }
316
382
 
317
383
  // Stop typing indicator on channel switch / unmount
318
384
  return () => {
319
385
  activeChannel?.stopTyping();
320
386
  };
321
- }, [activeChannel, reset, handleEmojiClose, setFiles]);
387
+ }, [activeChannel, reset, handleEmojiClose, setFiles, setDraft, getDraft]);
322
388
 
323
389
  /* ---------- Input event handlers ---------- */
324
390
  const handleInput = useCallback(() => {
@@ -354,7 +420,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
354
420
  }
355
421
  if (e.key === 'Enter' && !e.shiftKey) {
356
422
  e.preventDefault();
357
- if (!isSlowModeBlocked) {
423
+ if (!isSlowModeBlocked && !keywordError) {
358
424
  handleSend();
359
425
  }
360
426
  }
@@ -364,9 +430,17 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
364
430
 
365
431
  const handlePaste = useCallback((e: React.ClipboardEvent) => {
366
432
  e.preventDefault();
433
+ if (e.clipboardData.files && e.clipboardData.files.length > 0) {
434
+ if (!disableAttachments && canSendMessage && !isSlowModeBlocked && !editingMessage) {
435
+ handleFilesSelected(e.clipboardData.files);
436
+ }
437
+ return;
438
+ }
367
439
  const plainText = e.clipboardData.getData('text/plain');
368
- document.execCommand('insertText', false, plainText);
369
- }, []);
440
+ if (plainText) {
441
+ document.execCommand('insertText', false, plainText);
442
+ }
443
+ }, [disableAttachments, canSendMessage, isSlowModeBlocked, editingMessage, handleFilesSelected]);
370
444
 
371
445
  if (!activeChannel) return null;
372
446
 
@@ -418,6 +492,50 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
418
492
  );
419
493
  }
420
494
 
495
+ // Show Preview Overlay for public channels when the user has not joined
496
+ if (isPreviewMode) {
497
+ return (
498
+ <PreviewOverlayComponent
499
+ title={previewOverlayTitle}
500
+ buttonLabel={joinChannelLabel}
501
+ onJoin={async () => {
502
+ if (!activeChannel) return;
503
+ try {
504
+ await activeChannel.acceptInvite('join');
505
+ // Optimistically update local membership
506
+ if (activeChannel.state && client.userID) {
507
+ const updatedMembership = {
508
+ ...activeChannel.state.membership,
509
+ channel_role: 'member',
510
+ user_id: client.userID,
511
+ } as Record<string, unknown>;
512
+ activeChannel.state.membership = updatedMembership;
513
+ if (activeChannel.state.members?.[client.userID]) {
514
+ activeChannel.state.members[client.userID] = {
515
+ ...activeChannel.state.members[client.userID],
516
+ channel_role: 'member',
517
+ };
518
+ }
519
+ // Dispatch synthetic event so channel list and other hooks update
520
+ client.dispatchEvent({
521
+ type: 'member.joined',
522
+ cid: activeChannel.cid,
523
+ channel_type: activeChannel.type,
524
+ channel_id: activeChannel.id,
525
+ channel: activeChannel.data,
526
+ member: updatedMembership,
527
+ user: client.user,
528
+ } as any);
529
+ }
530
+ } catch (e) {
531
+ console.error('Failed to join public channel', e);
532
+ }
533
+ }}
534
+ className={className}
535
+ />
536
+ );
537
+ }
538
+
421
539
  const isStillUploading = files.some((f) => f.status === 'uploading');
422
540
 
423
541
  return (
@@ -427,6 +545,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
427
545
  <ReplyPreviewComponent
428
546
  message={quotedMessage}
429
547
  onDismiss={() => setQuotedMessage(null)}
548
+ replyingToLabel={replyingToLabel}
430
549
  />
431
550
  )}
432
551
 
@@ -435,6 +554,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
435
554
  <EditPreviewComponent
436
555
  message={editingMessage}
437
556
  onDismiss={cancelEdit}
557
+ editingMessageLabel={editingMessageLabel}
438
558
  />
439
559
  )}
440
560
 
@@ -512,20 +632,48 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
512
632
  suppressContentEditableWarning
513
633
  />
514
634
 
635
+ {/* Voice record button */}
636
+ {VoiceRecordButtonComponent && (
637
+ <VoiceRecordButtonComponent
638
+ disabled={sending || !!editingMessage || isSlowModeBlocked || !canSendMessage}
639
+ onRecordComplete={(f) => {
640
+ const dt = new DataTransfer();
641
+ dt.items.add(f);
642
+ handleFilesSelected(dt.files);
643
+ }}
644
+ />
645
+ )}
646
+
515
647
  {/* Emoji button — shown only when EmojiPickerComponent is provided */}
516
648
  {EmojiPickerComponent && (
517
649
  <EmojiButtonComponent active={emojiPickerOpen} onClick={isSlowModeBlocked ? () => { } : toggleEmojiPicker} />
518
650
  )}
651
+
652
+ {/* Sticker button — shown unless disabled */}
653
+ {!disableStickers && StickerButtonComponent && (
654
+ <StickerButtonComponent active={stickerPickerOpen} onClick={isSlowModeBlocked || !!editingMessage || !!quotedMessage ? () => { } : toggleStickerPicker} />
655
+ )}
519
656
  </div>
520
- <SendButton disabled={!hasContent || sending || isStillUploading || isSlowModeBlocked} onClick={handleSend} />
657
+
658
+ <SendButton disabled={!hasContent || sending || !!editingMessage || isSlowModeBlocked || !canSendMessage || isStillUploading || !!keywordError} onClick={handleSend} />
521
659
  </div>
522
660
 
523
- {/* Emoji picker positioned above input */}
661
+ {/* Emoji Picker Dropdown */}
524
662
  {EmojiPickerComponent && emojiPickerOpen && (
525
663
  <div className="ermis-message-input__emoji-picker">
526
664
  <EmojiPickerComponent onSelect={handleEmojiSelect} onClose={handleEmojiClose} />
527
665
  </div>
528
666
  )}
667
+
668
+ {/* Sticker Picker Dropdown */}
669
+ {!disableStickers && StickerPickerComponent && stickerPickerOpen && (
670
+ <StickerPickerComponent stickerIframeUrl={stickerIframeUrl} onClose={closeStickerPicker} />
671
+ )}
672
+
673
+ {/* Drag & Drop Overlay */}
674
+ {isDragging && (
675
+ <DragAndDropOverlayComponent dragAndDropLabel={dragAndDropLabel} />
676
+ )}
529
677
  </div>
530
678
  );
531
679
  });
@@ -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';