@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.
- package/README.md +144 -0
- package/dist/index.cjs +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- 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
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
657
|
+
|
|
658
|
+
<SendButton disabled={!hasContent || sending || !!editingMessage || isSlowModeBlocked || !canSendMessage || isStillUploading || !!keywordError} onClick={handleSend} />
|
|
521
659
|
</div>
|
|
522
660
|
|
|
523
|
-
{/* Emoji
|
|
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';
|