@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.
- package/dist/index.cjs +6593 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +3375 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +1138 -0
- package/dist/index.d.ts +1138 -0
- package/dist/index.mjs +6500 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +42 -0
- package/src/components/Avatar.tsx +102 -0
- package/src/components/Channel.tsx +77 -0
- package/src/components/ChannelHeader.tsx +85 -0
- package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
- package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
- package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
- package/src/components/ChannelInfo/FileListItem.tsx +49 -0
- package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
- package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
- package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
- package/src/components/ChannelInfo/States.tsx +36 -0
- package/src/components/ChannelInfo/index.ts +10 -0
- package/src/components/ChannelInfo/utils.tsx +49 -0
- package/src/components/ChannelList.tsx +395 -0
- package/src/components/Dropdown.tsx +120 -0
- package/src/components/EditPreview.tsx +102 -0
- package/src/components/FilesPreview.tsx +108 -0
- package/src/components/ForwardMessageModal.tsx +234 -0
- package/src/components/MentionSuggestions.tsx +59 -0
- package/src/components/MessageActionsBox.tsx +186 -0
- package/src/components/MessageInput.tsx +513 -0
- package/src/components/MessageInputDefaults.tsx +50 -0
- package/src/components/MessageItem.tsx +218 -0
- package/src/components/MessageQuickReactions.tsx +73 -0
- package/src/components/MessageReactions.tsx +59 -0
- package/src/components/MessageRenderers.tsx +565 -0
- package/src/components/Modal.tsx +58 -0
- package/src/components/Panel.tsx +64 -0
- package/src/components/PinnedMessages.tsx +165 -0
- package/src/components/QuotedMessagePreview.tsx +55 -0
- package/src/components/ReadReceipts.tsx +80 -0
- package/src/components/ReplyPreview.tsx +98 -0
- package/src/components/TypingIndicator.tsx +57 -0
- package/src/components/VirtualMessageList.tsx +425 -0
- package/src/context/ChatProvider.tsx +73 -0
- package/src/hooks/useBannedState.ts +48 -0
- package/src/hooks/useBlockedState.ts +55 -0
- package/src/hooks/useChannel.ts +18 -0
- package/src/hooks/useChannelCapabilities.ts +42 -0
- package/src/hooks/useChannelData.ts +55 -0
- package/src/hooks/useChannelListUpdates.ts +224 -0
- package/src/hooks/useChannelMessages.ts +159 -0
- package/src/hooks/useChannelRowUpdates.ts +78 -0
- package/src/hooks/useChatClient.ts +11 -0
- package/src/hooks/useEmojiPicker.ts +53 -0
- package/src/hooks/useFileUpload.ts +128 -0
- package/src/hooks/useLoadMessages.ts +178 -0
- package/src/hooks/useMentions.ts +287 -0
- package/src/hooks/useMessageActions.ts +87 -0
- package/src/hooks/useMessageSend.ts +164 -0
- package/src/hooks/usePendingState.ts +63 -0
- package/src/hooks/useScrollToMessage.ts +155 -0
- package/src/hooks/useTypingIndicator.ts +86 -0
- package/src/index.ts +129 -0
- package/src/styles/_add-member-modal.css +122 -0
- package/src/styles/_base.css +32 -0
- package/src/styles/_channel-info.css +941 -0
- package/src/styles/_channel-list.css +217 -0
- package/src/styles/_dropdown.css +69 -0
- package/src/styles/_forward-modal.css +191 -0
- package/src/styles/_mentions.css +102 -0
- package/src/styles/_message-actions.css +61 -0
- package/src/styles/_message-bubble.css +656 -0
- package/src/styles/_message-input.css +389 -0
- package/src/styles/_message-list.css +416 -0
- package/src/styles/_message-quick-reactions.css +62 -0
- package/src/styles/_message-reactions.css +67 -0
- package/src/styles/_modal.css +113 -0
- package/src/styles/_panel.css +69 -0
- package/src/styles/_pinned-messages.css +140 -0
- package/src/styles/_search-panel.css +219 -0
- package/src/styles/_tokens.css +92 -0
- package/src/styles/_typing-indicator.css +59 -0
- package/src/styles/index.css +24 -0
- package/src/types.ts +955 -0
- 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, '&')
|
|
209
|
+
.replace(/</g, '<')
|
|
210
|
+
.replace(/>/g, '>')
|
|
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';
|