@ermis-network/ermis-chat-react 1.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 (88) hide show
  1. package/dist/index.cjs +6593 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.css +3375 -0
  4. package/dist/index.css.map +1 -0
  5. package/dist/index.d.mts +1138 -0
  6. package/dist/index.d.ts +1138 -0
  7. package/dist/index.mjs +6500 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +42 -0
  10. package/src/components/Avatar.tsx +102 -0
  11. package/src/components/Channel.tsx +77 -0
  12. package/src/components/ChannelHeader.tsx +85 -0
  13. package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
  14. package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
  15. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
  16. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
  17. package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
  18. package/src/components/ChannelInfo/FileListItem.tsx +49 -0
  19. package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
  21. package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
  23. package/src/components/ChannelInfo/States.tsx +36 -0
  24. package/src/components/ChannelInfo/index.ts +10 -0
  25. package/src/components/ChannelInfo/utils.tsx +49 -0
  26. package/src/components/ChannelList.tsx +395 -0
  27. package/src/components/Dropdown.tsx +120 -0
  28. package/src/components/EditPreview.tsx +102 -0
  29. package/src/components/FilesPreview.tsx +108 -0
  30. package/src/components/ForwardMessageModal.tsx +234 -0
  31. package/src/components/MentionSuggestions.tsx +59 -0
  32. package/src/components/MessageActionsBox.tsx +186 -0
  33. package/src/components/MessageInput.tsx +513 -0
  34. package/src/components/MessageInputDefaults.tsx +50 -0
  35. package/src/components/MessageItem.tsx +218 -0
  36. package/src/components/MessageQuickReactions.tsx +73 -0
  37. package/src/components/MessageReactions.tsx +59 -0
  38. package/src/components/MessageRenderers.tsx +565 -0
  39. package/src/components/Modal.tsx +58 -0
  40. package/src/components/Panel.tsx +64 -0
  41. package/src/components/PinnedMessages.tsx +165 -0
  42. package/src/components/QuotedMessagePreview.tsx +55 -0
  43. package/src/components/ReadReceipts.tsx +80 -0
  44. package/src/components/ReplyPreview.tsx +98 -0
  45. package/src/components/TypingIndicator.tsx +57 -0
  46. package/src/components/VirtualMessageList.tsx +425 -0
  47. package/src/context/ChatProvider.tsx +73 -0
  48. package/src/hooks/useBannedState.ts +48 -0
  49. package/src/hooks/useBlockedState.ts +55 -0
  50. package/src/hooks/useChannel.ts +18 -0
  51. package/src/hooks/useChannelCapabilities.ts +42 -0
  52. package/src/hooks/useChannelData.ts +55 -0
  53. package/src/hooks/useChannelListUpdates.ts +224 -0
  54. package/src/hooks/useChannelMessages.ts +159 -0
  55. package/src/hooks/useChannelRowUpdates.ts +78 -0
  56. package/src/hooks/useChatClient.ts +11 -0
  57. package/src/hooks/useEmojiPicker.ts +53 -0
  58. package/src/hooks/useFileUpload.ts +128 -0
  59. package/src/hooks/useLoadMessages.ts +178 -0
  60. package/src/hooks/useMentions.ts +287 -0
  61. package/src/hooks/useMessageActions.ts +87 -0
  62. package/src/hooks/useMessageSend.ts +164 -0
  63. package/src/hooks/usePendingState.ts +63 -0
  64. package/src/hooks/useScrollToMessage.ts +155 -0
  65. package/src/hooks/useTypingIndicator.ts +86 -0
  66. package/src/index.ts +129 -0
  67. package/src/styles/_add-member-modal.css +122 -0
  68. package/src/styles/_base.css +32 -0
  69. package/src/styles/_channel-info.css +941 -0
  70. package/src/styles/_channel-list.css +217 -0
  71. package/src/styles/_dropdown.css +69 -0
  72. package/src/styles/_forward-modal.css +191 -0
  73. package/src/styles/_mentions.css +102 -0
  74. package/src/styles/_message-actions.css +61 -0
  75. package/src/styles/_message-bubble.css +656 -0
  76. package/src/styles/_message-input.css +389 -0
  77. package/src/styles/_message-list.css +416 -0
  78. package/src/styles/_message-quick-reactions.css +62 -0
  79. package/src/styles/_message-reactions.css +67 -0
  80. package/src/styles/_modal.css +113 -0
  81. package/src/styles/_panel.css +69 -0
  82. package/src/styles/_pinned-messages.css +140 -0
  83. package/src/styles/_search-panel.css +219 -0
  84. package/src/styles/_tokens.css +92 -0
  85. package/src/styles/_typing-indicator.css +59 -0
  86. package/src/styles/index.css +24 -0
  87. package/src/types.ts +955 -0
  88. package/src/utils.ts +242 -0
@@ -0,0 +1,513 @@
1
+ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
2
+ import { useChatClient } from '../hooks/useChatClient';
3
+ import { useBannedState } from '../hooks/useBannedState';
4
+ import { useBlockedState } from '../hooks/useBlockedState';
5
+ import { usePendingState } from '../hooks/usePendingState';
6
+ import { useMentions } from '../hooks/useMentions';
7
+ import { useFileUpload } from '../hooks/useFileUpload';
8
+ import { useEmojiPicker } from '../hooks/useEmojiPicker';
9
+ import { useMessageSend } from '../hooks/useMessageSend';
10
+ import { DefaultSendButton, DefaultAttachButton, DefaultEmojiButton } from './MessageInputDefaults';
11
+ import { MentionSuggestions } from './MentionSuggestions';
12
+ import { FilesPreview } from './FilesPreview';
13
+ import { ReplyPreview } from './ReplyPreview';
14
+ import { EditPreview } from './EditPreview';
15
+ import { buildUserMap, replaceMentionsForPreview, moveCaretToEnd } from '../utils';
16
+ import { getMentionHtml } from '../hooks/useMentions';
17
+ import { useChannelCapabilities } from '../hooks/useChannelCapabilities';
18
+ import type { MentionMember, MessageInputProps, FilePreviewItem } from '../types';
19
+
20
+ export type { MessageInputProps, SendButtonProps, AttachButtonProps, EmojiPickerProps, EmojiButtonProps } from '../types';
21
+
22
+ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
23
+ placeholder = 'Type a message...',
24
+ onSend,
25
+ className,
26
+ SendButton = DefaultSendButton,
27
+ AttachButton = DefaultAttachButton,
28
+ FilesPreviewComponent = FilesPreview,
29
+ MentionSuggestionsComponent = MentionSuggestions,
30
+ disableAttachments = false,
31
+ disableMentions = false,
32
+ renderAbove,
33
+ onBeforeSend,
34
+ EmojiPickerComponent,
35
+ EmojiButtonComponent = DefaultEmojiButton,
36
+ ReplyPreviewComponent = ReplyPreview,
37
+ EditPreviewComponent = EditPreview,
38
+ bannedLabel = 'You have been blocked from this channel',
39
+ blockedLabel = 'You have blocked this user. Unblock to send messages.',
40
+ linksDisabledLabel = 'Message blocked: Sending links is disabled for members.',
41
+ keywordBlockedLabel = (match: string) => `Message blocked: Contains restricted word "${match}".`,
42
+ sendDisabledLabel = 'Sending messages is disabled in this channel.',
43
+ slowModeLabel = (cooldown: number) => (
44
+ <>Slow mode is active. You can send another message in <strong>{cooldown}s</strong>.</>
45
+ ),
46
+ }) => {
47
+ const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage } = useChatClient();
48
+ const { isBanned } = useBannedState(activeChannel, client.userID);
49
+ const { isBlocked } = useBlockedState(activeChannel, client.userID);
50
+ const { isPending } = usePendingState(activeChannel, client.userID);
51
+ const editableRef = React.useRef<HTMLDivElement>(null);
52
+ const [hasContent, setHasContent] = useState(false);
53
+
54
+ const { role, isTeamChannel, hasCapability } = useChannelCapabilities();
55
+
56
+ // Slow Mode Logic
57
+ const [memberMessageCooldown, setMemberMessageCooldown] = useState(Number(activeChannel?.data?.member_message_cooldown) || 0);
58
+
59
+ useEffect(() => {
60
+ if (!activeChannel) return;
61
+ setMemberMessageCooldown(Number(activeChannel.data?.member_message_cooldown) || 0);
62
+ const handleUpdate = (event: Record<string, unknown>) => {
63
+ const channelData = (event?.channel as Record<string, unknown>) || activeChannel.data;
64
+ setMemberMessageCooldown(Number(channelData?.member_message_cooldown) || 0);
65
+ };
66
+ activeChannel.on('channel.updated', handleUpdate);
67
+ return () => {
68
+ activeChannel.off('channel.updated', handleUpdate);
69
+ };
70
+ }, [activeChannel]);
71
+
72
+ const isSlowModeApplied = isTeamChannel && role === 'member' && memberMessageCooldown > 0;
73
+
74
+ const [cooldownEnd, setCooldownEnd] = useState<number | null>(null);
75
+ const [cooldown, setCooldown] = useState(0);
76
+ const lastMsgSentAtRef = useRef<number>(0);
77
+
78
+ // Initialize cooldown state periodically or on change
79
+ useEffect(() => {
80
+ if (!isSlowModeApplied) {
81
+ setCooldownEnd(null);
82
+ setCooldown(0);
83
+ return;
84
+ }
85
+
86
+ let lastMsgSentAt = lastMsgSentAtRef.current || 0;
87
+ const messages = activeChannel?.state?.messages || [];
88
+
89
+ // Iterate from newest to oldest to find actual highest timestamp
90
+ for (let i = messages.length - 1; i >= 0; i--) {
91
+ if (messages[i].user?.id === client.userID) {
92
+ const msgTime = new Date(messages[i].created_at).getTime();
93
+ if (msgTime && !isNaN(msgTime) && msgTime > lastMsgSentAt) {
94
+ lastMsgSentAt = msgTime;
95
+ }
96
+ break;
97
+ }
98
+ }
99
+
100
+ if (lastMsgSentAt) {
101
+ const cdEnd = lastMsgSentAt + memberMessageCooldown;
102
+ if (cdEnd > Date.now()) {
103
+ setCooldownEnd(cdEnd);
104
+ } else {
105
+ setCooldownEnd(null);
106
+ setCooldown(0);
107
+ }
108
+ } else {
109
+ setCooldownEnd(null);
110
+ setCooldown(0);
111
+ }
112
+ }, [isSlowModeApplied, activeChannel, memberMessageCooldown, client.userID]);
113
+
114
+ // Tick the countdown visualization
115
+ useEffect(() => {
116
+ if (!cooldownEnd || cooldownEnd <= Date.now()) {
117
+ setCooldown(0);
118
+ return;
119
+ }
120
+ const updateCd = () => {
121
+ const remaining = cooldownEnd - Date.now();
122
+ if (remaining <= 0) {
123
+ setCooldown(0);
124
+ } else {
125
+ setCooldown(Math.ceil(remaining / 1000));
126
+ }
127
+ };
128
+ updateCd();
129
+ const timer = setInterval(updateCd, 1000);
130
+ return () => clearInterval(timer);
131
+ }, [cooldownEnd]);
132
+
133
+ const isSlowModeBlocked = isSlowModeApplied && cooldown > 0 && !editingMessage;
134
+
135
+ const canSendMessage = hasCapability('send-message');
136
+ const canSendLinks = hasCapability('send-links');
137
+
138
+ const [keywordError, setKeywordError] = useState<string | null>(null);
139
+
140
+ // Auto-clear link restriction banner if admin suddenly restores the capability
141
+ useEffect(() => {
142
+ if (keywordError?.includes('links') && canSendLinks) {
143
+ setKeywordError(null);
144
+ }
145
+ }, [canSendLinks, keywordError]);
146
+
147
+ const localOnBeforeSend = useCallback(async (text: string, attachments: FilePreviewItem[]) => {
148
+ // Permission validation: Send Links
149
+ if (!canSendLinks && text) {
150
+ // Basic URL matching config
151
+ const urlRegex = /(https?:\/\/[^\s]+)|(www\.[^\s]+)|([a-zA-Z0-9-]+\.[a-zA-Z]{2,}(\/[^\s]*)?)/i;
152
+ if (urlRegex.test(text)) {
153
+ setKeywordError(linksDisabledLabel);
154
+ return false;
155
+ }
156
+ }
157
+
158
+ // Custom Keyword validation
159
+ const words = (activeChannel?.data?.filter_words as string[]) || [];
160
+ if (words.length > 0 && text) {
161
+ const lowerText = text.toLowerCase();
162
+ const match = words.find(w => lowerText.includes(w.toLowerCase()));
163
+ if (match) {
164
+ setKeywordError(keywordBlockedLabel(match));
165
+ // We could also visually shake the input box here
166
+ return false;
167
+ }
168
+ }
169
+ setKeywordError(null);
170
+ if (onBeforeSend) {
171
+ return await onBeforeSend(text, attachments);
172
+ }
173
+ return true;
174
+ }, [activeChannel, onBeforeSend, canSendLinks]);
175
+
176
+ const handleMessageSent = useCallback((text: string) => {
177
+ if (isSlowModeApplied) {
178
+ lastMsgSentAtRef.current = Date.now();
179
+ setCooldownEnd(Date.now() + memberMessageCooldown);
180
+ }
181
+ onSend?.(text);
182
+ }, [isSlowModeApplied, memberMessageCooldown, onSend]);
183
+
184
+ // Auto-focus when channel changes or when reply/edit is selected
185
+ useEffect(() => {
186
+ if (activeChannel && editableRef.current) {
187
+ editableRef.current.focus();
188
+ }
189
+ }, [activeChannel, quotedMessage, editingMessage]);
190
+
191
+
192
+ /* ---------- Hooks ---------- */
193
+ const {
194
+ files, setFiles, fileInputRef,
195
+ handleFilesSelected, handleRemoveFile, handleAttachClick, cleanupFiles,
196
+ } = useFileUpload({ activeChannel, editableRef, setHasContent });
197
+
198
+ // Pre-fill text and legacy attachments when editingMessage is set
199
+ useEffect(() => {
200
+ if (editingMessage && editableRef.current) {
201
+ // 1. Prefill text content
202
+ const rawText = editingMessage.text || '';
203
+
204
+ // Extract user map locally since we have `activeChannel.state.members`
205
+ const userMap = buildUserMap(activeChannel?.state);
206
+
207
+ const htmlText = rawText
208
+ .replace(/&/g, '&amp;')
209
+ .replace(/</g, '&lt;')
210
+ .replace(/>/g, '&gt;')
211
+ .replace(/\n/g, '<br>');
212
+
213
+ editableRef.current.innerHTML = replaceMentionsForPreview(
214
+ htmlText,
215
+ editingMessage,
216
+ userMap,
217
+ getMentionHtml
218
+ );
219
+
220
+ // Move cursor to the end
221
+ moveCaretToEnd(editableRef.current);
222
+
223
+ // The API does not support attachment modifications during edits.
224
+ // Flush any active files and only allow text/mention modifications.
225
+ setFiles([]);
226
+ setHasContent(!!editingMessage.text);
227
+ }
228
+ }, [editingMessage, setFiles]);
229
+
230
+ // Cleanup blob URLs on unmount
231
+ useEffect(() => {
232
+ return () => cleanupFiles();
233
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
234
+
235
+ const {
236
+ emojiPickerOpen,
237
+ handleEmojiSelect,
238
+ handleEmojiClose,
239
+ toggleEmojiPicker,
240
+ } = useEmojiPicker({ editableRef, setHasContent });
241
+
242
+ // Build member list from channel state (only for team channels)
243
+ const members = useMemo<MentionMember[]>(() => {
244
+ if (!isTeamChannel) return [];
245
+ const list: MentionMember[] = [];
246
+ const stateMembers = activeChannel?.state?.members as Record<string, unknown> | undefined;
247
+ if (stateMembers && typeof stateMembers === 'object') {
248
+ for (const [id, memberVal] of Object.entries(stateMembers)) {
249
+ const member = memberVal as Record<string, any>;
250
+ list.push({
251
+ id,
252
+ name: member?.user?.name || member?.user_id || id,
253
+ avatar: member?.user?.avatar,
254
+ });
255
+ }
256
+ }
257
+ return list;
258
+ }, [activeChannel, isTeamChannel]);
259
+
260
+ const {
261
+ showSuggestions, filteredMembers, highlightIndex,
262
+ handleInput: mentionHandleInput,
263
+ handleKeyDown: mentionHandleKeyDown,
264
+ selectMention, buildPayload, reset,
265
+ } = useMentions({
266
+ members,
267
+ currentUserId: client.userID,
268
+ editableRef,
269
+ });
270
+
271
+ const cancelEdit = useCallback(() => {
272
+ setEditingMessage(null);
273
+ cleanupFiles();
274
+ setFiles([]);
275
+ setHasContent(false);
276
+ reset();
277
+ if (editableRef.current) {
278
+ editableRef.current.innerHTML = '';
279
+ }
280
+ }, [setEditingMessage, cleanupFiles, setFiles, setHasContent, reset]);
281
+
282
+ const { sending, handleSend } = useMessageSend({
283
+ activeChannel,
284
+ editableRef,
285
+ files,
286
+ setFiles,
287
+ hasContent,
288
+ setHasContent,
289
+ isTeamChannel,
290
+ buildPayload,
291
+ reset,
292
+ syncMessages,
293
+ onSend: handleMessageSent,
294
+ onBeforeSend: localOnBeforeSend,
295
+ quotedMessage,
296
+ clearQuotedMessage: () => setQuotedMessage(null),
297
+ editingMessage,
298
+ clearEditingMessage: () => setEditingMessage(null),
299
+ });
300
+
301
+ useEffect(() => {
302
+ reset();
303
+ handleEmojiClose();
304
+ setFiles((prev) => {
305
+ prev.forEach((f) => {
306
+ if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
307
+ });
308
+ return [];
309
+ });
310
+ setHasContent(false);
311
+
312
+ // Stop typing indicator on channel switch / unmount
313
+ return () => {
314
+ activeChannel?.stopTyping();
315
+ };
316
+ }, [activeChannel, reset, handleEmojiClose, setFiles]);
317
+
318
+ /* ---------- Input event handlers ---------- */
319
+ const handleInput = useCallback(() => {
320
+ const el = editableRef.current;
321
+ const content = el?.textContent?.trim() ?? '';
322
+ setHasContent(content.length > 0 || files.length > 0);
323
+ setKeywordError(null); // clear keyword error if user modifies input
324
+ if (isTeamChannel && !disableMentions) {
325
+ mentionHandleInput();
326
+ }
327
+ // Send typing indicator (SDK throttles to 1 event per 2s)
328
+ activeChannel?.keystroke();
329
+ }, [isTeamChannel, disableMentions, mentionHandleInput, files.length, activeChannel]);
330
+
331
+ const handleKeyDown = useCallback(
332
+ (e: React.KeyboardEvent) => {
333
+ // Prevent reacting to "Enter" when constructing characters with an IME (e.g. Vietnamese telex)
334
+ if (e.nativeEvent.isComposing) return;
335
+
336
+ if (e.key === 'Escape') {
337
+ if (editingMessage) {
338
+ cancelEdit();
339
+ return;
340
+ }
341
+ if (quotedMessage) {
342
+ setQuotedMessage(null);
343
+ return;
344
+ }
345
+ }
346
+ if (isTeamChannel && !disableMentions) {
347
+ const consumed = mentionHandleKeyDown(e);
348
+ if (consumed) return;
349
+ }
350
+ if (e.key === 'Enter' && !e.shiftKey) {
351
+ e.preventDefault();
352
+ if (!isSlowModeBlocked) {
353
+ handleSend();
354
+ }
355
+ }
356
+ },
357
+ [isTeamChannel, disableMentions, mentionHandleKeyDown, handleSend, editingMessage, quotedMessage, setEditingMessage, setQuotedMessage, reset],
358
+ );
359
+
360
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
361
+ e.preventDefault();
362
+ const plainText = e.clipboardData.getData('text/plain');
363
+ document.execCommand('insertText', false, plainText);
364
+ }, []);
365
+
366
+ if (!activeChannel) return null;
367
+
368
+ // Don't show input for pending invitations at all
369
+ if (isPending) return null;
370
+
371
+ // Show banned banner instead of input
372
+ if (isBanned) {
373
+ return (
374
+ <div className={`ermis-message-input ermis-message-input--banned${className ? ` ${className}` : ''}`}>
375
+ <div className="ermis-message-input__banned-banner">
376
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
377
+ <circle cx="12" cy="12" r="10" />
378
+ <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
379
+ </svg>
380
+ <span>{bannedLabel}</span>
381
+ </div>
382
+ </div>
383
+ );
384
+ }
385
+
386
+ // Show blocked banner instead of input (messaging channels only)
387
+ if (isBlocked) {
388
+ return (
389
+ <div className={`ermis-message-input ermis-message-input--blocked${className ? ` ${className}` : ''}`}>
390
+ <div className="ermis-message-input__blocked-banner">
391
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
392
+ <circle cx="12" cy="12" r="10" />
393
+ <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
394
+ </svg>
395
+ <span>{blockedLabel}</span>
396
+ </div>
397
+ </div>
398
+ );
399
+ }
400
+
401
+ const isStillUploading = files.some((f) => f.status === 'uploading');
402
+
403
+ return (
404
+ <div className={`ermis-message-input${className ? ` ${className}` : ''}`}>
405
+ {/* Reply preview */}
406
+ {quotedMessage && !editingMessage && (
407
+ <ReplyPreviewComponent
408
+ message={quotedMessage}
409
+ onDismiss={() => setQuotedMessage(null)}
410
+ />
411
+ )}
412
+
413
+ {/* Edit preview */}
414
+ {editingMessage && (
415
+ <EditPreviewComponent
416
+ message={editingMessage}
417
+ onDismiss={cancelEdit}
418
+ />
419
+ )}
420
+
421
+ {/* Custom content above input */}
422
+ {renderAbove?.()}
423
+
424
+ {/* File previews */}
425
+ {!disableAttachments && <FilesPreviewComponent files={files} onRemove={handleRemoveFile} />}
426
+
427
+ {/* Keyword Error Banner */}
428
+ {keywordError && (
429
+ <div className="ermis-message-input__keyword-banner">
430
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
431
+ {keywordError}
432
+ </div>
433
+ )}
434
+
435
+ {/* Permission Disabled Banner */}
436
+ {!canSendMessage && !editingMessage && (
437
+ <div className="ermis-message-input__permission-banner">
438
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
439
+ {sendDisabledLabel}
440
+ </div>
441
+ )}
442
+
443
+ {/* Slow Mode Cooldown Banner */}
444
+ {canSendMessage && isSlowModeBlocked && !keywordError && (
445
+ <div className="ermis-message-input__slow-mode-banner">
446
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
447
+ {typeof slowModeLabel === 'function' ? slowModeLabel(cooldown) : slowModeLabel}
448
+ </div>
449
+ )}
450
+
451
+ {/* Text input + send row */}
452
+ <div className={`ermis-message-input__row${(!canSendMessage || isSlowModeBlocked || keywordError) ? ' ermis-message-input__row--banners-active' : ''}`}>
453
+ <div className="ermis-message-input__editable-wrapper">
454
+ {canSendMessage && isTeamChannel && !disableMentions && showSuggestions && (
455
+ <MentionSuggestionsComponent
456
+ members={filteredMembers}
457
+ highlightIndex={highlightIndex}
458
+ onSelect={selectMention}
459
+ />
460
+ )}
461
+
462
+ {/* Attach button */}
463
+ {!disableAttachments && (
464
+ <AttachButton disabled={sending || !!editingMessage || isSlowModeBlocked || !canSendMessage} onClick={handleAttachClick} />
465
+ )}
466
+
467
+ {/* Hidden file input */}
468
+ {!disableAttachments && (
469
+ <input
470
+ ref={fileInputRef}
471
+ type="file"
472
+ multiple
473
+ className="ermis-message-input__file-input"
474
+ onChange={(e) => {
475
+ handleFilesSelected(e.target.files);
476
+ e.target.value = '';
477
+ }}
478
+ disabled={!!editingMessage || isSlowModeBlocked || !canSendMessage}
479
+ />
480
+ )}
481
+
482
+ <div
483
+ ref={editableRef}
484
+ className="ermis-message-input__editable"
485
+ contentEditable={!sending && !isSlowModeBlocked && canSendMessage}
486
+ role="textbox"
487
+ aria-placeholder={placeholder}
488
+ data-placeholder={placeholder}
489
+ onInput={handleInput}
490
+ onKeyDown={handleKeyDown}
491
+ onPaste={handlePaste}
492
+ suppressContentEditableWarning
493
+ />
494
+
495
+ {/* Emoji button — shown only when EmojiPickerComponent is provided */}
496
+ {EmojiPickerComponent && (
497
+ <EmojiButtonComponent active={emojiPickerOpen} onClick={isSlowModeBlocked ? () => { } : toggleEmojiPicker} />
498
+ )}
499
+ </div>
500
+ <SendButton disabled={!hasContent || sending || isStillUploading || isSlowModeBlocked} onClick={handleSend} />
501
+ </div>
502
+
503
+ {/* Emoji picker — positioned above input */}
504
+ {EmojiPickerComponent && emojiPickerOpen && (
505
+ <div className="ermis-message-input__emoji-picker">
506
+ <EmojiPickerComponent onSelect={handleEmojiSelect} onClose={handleEmojiClose} />
507
+ </div>
508
+ )}
509
+ </div>
510
+ );
511
+ });
512
+
513
+ MessageInput.displayName = 'MessageInput';
@@ -0,0 +1,50 @@
1
+ import React from 'react';
2
+
3
+ /* ----------------------------------------------------------
4
+ Default sub-components for MessageInput
5
+ ---------------------------------------------------------- */
6
+
7
+ export const DefaultSendButton: React.FC<{ disabled: boolean; onClick: () => void }> = React.memo(({
8
+ disabled,
9
+ onClick,
10
+ }) => (
11
+ <button
12
+ className="ermis-message-input__send-btn"
13
+ onClick={onClick}
14
+ disabled={disabled}
15
+ >
16
+ Send
17
+ </button>
18
+ ));
19
+ DefaultSendButton.displayName = 'DefaultSendButton';
20
+
21
+ export const DefaultAttachButton: React.FC<{ disabled: boolean; onClick: () => void }> = React.memo(({
22
+ disabled,
23
+ onClick,
24
+ }) => (
25
+ <button
26
+ className="ermis-message-input__attach-btn"
27
+ onClick={onClick}
28
+ type="button"
29
+ aria-label="Attach files"
30
+ disabled={disabled}
31
+ >
32
+ 📎
33
+ </button>
34
+ ));
35
+ DefaultAttachButton.displayName = 'DefaultAttachButton';
36
+
37
+ export const DefaultEmojiButton: React.FC<{ active: boolean; onClick: () => void }> = React.memo(({
38
+ active,
39
+ onClick,
40
+ }) => (
41
+ <button
42
+ className={`ermis-message-input__emoji-btn${active ? ' ermis-message-input__emoji-btn--active' : ''}`}
43
+ onClick={onClick}
44
+ type="button"
45
+ aria-label="Emoji"
46
+ >
47
+ 😀
48
+ </button>
49
+ ));
50
+ DefaultEmojiButton.displayName = 'DefaultEmojiButton';