@ermis-network/ermis-chat-react 1.0.8 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +15295 -4209
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +701 -195
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +862 -94
- package/dist/index.d.ts +862 -94
- package/dist/index.mjs +15246 -4186
- 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 -2
- package/src/components/ChannelActions.tsx +61 -2
- package/src/components/ChannelHeader.tsx +19 -5
- package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
- package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
- 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 +386 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +177 -290
- package/src/components/CreateChannelModal.tsx +166 -88
- 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/FlatTopicGroupItem.tsx +232 -0
- package/src/components/ForwardMessageModal.tsx +31 -77
- package/src/components/MediaLightbox.tsx +62 -40
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +4 -1
- package/src/components/MessageInput.tsx +137 -16
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +93 -26
- package/src/components/MessageQuickReactions.tsx +153 -26
- package/src/components/MessageReactions.tsx +2 -1
- package/src/components/MessageRenderers.tsx +111 -39
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +17 -5
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/TopicList.tsx +221 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +14 -5
- package/src/components/UserPicker.tsx +87 -10
- package/src/components/VirtualMessageList.tsx +106 -20
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +18 -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 +72 -20
- package/src/hooks/useChannelMessages.ts +72 -10
- package/src/hooks/useChannelRowUpdates.ts +24 -5
- package/src/hooks/useChatUser.ts +31 -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/useForwardMessage.ts +112 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useMentions.ts +0 -1
- package/src/hooks/useMessageActions.ts +13 -10
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +197 -0
- package/src/index.ts +56 -6
- package/src/messageTypeUtils.ts +13 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +41 -4
- package/src/styles/_channel-list.css +97 -57
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +32 -0
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +286 -107
- package/src/styles/_message-input.css +131 -0
- package/src/styles/_message-list.css +33 -17
- package/src/styles/_message-quick-reactions.css +40 -9
- package/src/styles/_message-reactions.css +4 -0
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_tokens.css +17 -15
- package/src/styles/_typing-indicator.css +7 -1
- package/src/styles/index.css +1 -0
- package/src/types.ts +362 -14
- package/src/utils/avatarColors.ts +48 -0
- package/src/utils.ts +193 -10
|
@@ -6,16 +6,21 @@ 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';
|
|
23
|
+
import { isTopicChannel } from '../channelTypeUtils';
|
|
19
24
|
import type { MentionMember, MessageInputProps, FilePreviewItem } from '../types';
|
|
20
25
|
|
|
21
26
|
export type { MessageInputProps, SendButtonProps, AttachButtonProps, EmojiPickerProps, EmojiButtonProps } from '../types';
|
|
@@ -34,6 +39,11 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
34
39
|
onBeforeSend,
|
|
35
40
|
EmojiPickerComponent,
|
|
36
41
|
EmojiButtonComponent = DefaultEmojiButton,
|
|
42
|
+
StickerPickerComponent = DefaultStickerPicker,
|
|
43
|
+
StickerButtonComponent = DefaultStickerButton,
|
|
44
|
+
VoiceRecordButtonComponent = DefaultVoiceRecordButton,
|
|
45
|
+
disableStickers = false,
|
|
46
|
+
stickerIframeUrl = 'https://sticker.ermis.network',
|
|
37
47
|
ReplyPreviewComponent = ReplyPreview,
|
|
38
48
|
EditPreviewComponent = EditPreview,
|
|
39
49
|
bannedLabel = 'You have been banned from this channel',
|
|
@@ -45,15 +55,25 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
45
55
|
<>Slow mode is active. You can send another message in <strong>{cooldown}s</strong>.</>
|
|
46
56
|
),
|
|
47
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ự.',
|
|
48
66
|
}) => {
|
|
49
67
|
const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage } = useChatClient();
|
|
50
68
|
const { isBanned } = useBannedState(activeChannel, client.userID);
|
|
51
69
|
const { isBlocked } = useBlockedState(activeChannel, client.userID);
|
|
52
70
|
const { isPending } = usePendingState(activeChannel, client.userID);
|
|
71
|
+
const { isPreviewMode } = usePreviewState(activeChannel, client.userID);
|
|
53
72
|
const editableRef = React.useRef<HTMLDivElement>(null);
|
|
54
73
|
const [hasContent, setHasContent] = useState(false);
|
|
55
74
|
|
|
56
75
|
const { role, isGroupChannel: isTeamChannel, hasCapability } = useChannelCapabilities();
|
|
76
|
+
const isTopic = isTopicChannel(activeChannel);
|
|
57
77
|
const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
|
|
58
78
|
|
|
59
79
|
// Slow Mode Logic
|
|
@@ -158,6 +178,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
158
178
|
}
|
|
159
179
|
}
|
|
160
180
|
|
|
181
|
+
// Max Characters validation (5000 chars)
|
|
182
|
+
if (text && text.length > 5000) {
|
|
183
|
+
setKeywordError(maxCharsLabel);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
161
187
|
// Custom Keyword validation
|
|
162
188
|
const words = (activeChannel?.data?.filter_words as string[]) || [];
|
|
163
189
|
if (words.length > 0 && text) {
|
|
@@ -198,6 +224,11 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
198
224
|
handleFilesSelected, handleRemoveFile, handleAttachClick, cleanupFiles,
|
|
199
225
|
} = useFileUpload({ activeChannel, editableRef, setHasContent });
|
|
200
226
|
|
|
227
|
+
const { isDragging } = useDragAndDrop(
|
|
228
|
+
handleFilesSelected,
|
|
229
|
+
disableAttachments || !canSendMessage || isSlowModeBlocked || !!editingMessage || !!quotedMessage
|
|
230
|
+
);
|
|
231
|
+
|
|
201
232
|
// Pre-fill text and legacy attachments when editingMessage is set
|
|
202
233
|
useEffect(() => {
|
|
203
234
|
if (editingMessage && editableRef.current) {
|
|
@@ -242,9 +273,15 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
242
273
|
toggleEmojiPicker,
|
|
243
274
|
} = useEmojiPicker({ editableRef, setHasContent });
|
|
244
275
|
|
|
245
|
-
|
|
276
|
+
const {
|
|
277
|
+
stickerPickerOpen,
|
|
278
|
+
toggleStickerPicker,
|
|
279
|
+
closeStickerPicker,
|
|
280
|
+
} = useStickerPicker({ activeChannel, stickerIframeUrl });
|
|
281
|
+
|
|
282
|
+
// Build member list from channel state (only for team channels and topics)
|
|
246
283
|
const members = useMemo<MentionMember[]>(() => {
|
|
247
|
-
if (!isTeamChannel) return [];
|
|
284
|
+
if (!(isTeamChannel || isTopic)) return [];
|
|
248
285
|
const list: MentionMember[] = [];
|
|
249
286
|
const stateMembers = activeChannel?.state?.members as Record<string, unknown> | undefined;
|
|
250
287
|
if (stateMembers && typeof stateMembers === 'object') {
|
|
@@ -258,7 +295,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
258
295
|
}
|
|
259
296
|
}
|
|
260
297
|
return list;
|
|
261
|
-
}, [activeChannel, isTeamChannel]);
|
|
298
|
+
}, [activeChannel, isTeamChannel, isTopic]);
|
|
262
299
|
|
|
263
300
|
const {
|
|
264
301
|
showSuggestions, filteredMembers, highlightIndex,
|
|
@@ -289,7 +326,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
289
326
|
setFiles,
|
|
290
327
|
hasContent,
|
|
291
328
|
setHasContent,
|
|
292
|
-
isTeamChannel,
|
|
329
|
+
isTeamChannel: isTeamChannel || isTopic,
|
|
293
330
|
buildPayload,
|
|
294
331
|
reset,
|
|
295
332
|
syncMessages,
|
|
@@ -324,12 +361,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
324
361
|
const content = el?.textContent?.trim() ?? '';
|
|
325
362
|
setHasContent(content.length > 0 || files.length > 0);
|
|
326
363
|
setKeywordError(null); // clear keyword error if user modifies input
|
|
327
|
-
if (isTeamChannel && !disableMentions) {
|
|
364
|
+
if ((isTeamChannel || isTopic) && !disableMentions) {
|
|
328
365
|
mentionHandleInput();
|
|
329
366
|
}
|
|
330
367
|
// Send typing indicator (SDK throttles to 1 event per 2s)
|
|
331
368
|
activeChannel?.keystroke();
|
|
332
|
-
}, [isTeamChannel, disableMentions, mentionHandleInput, files.length, activeChannel]);
|
|
369
|
+
}, [isTeamChannel, isTopic, disableMentions, mentionHandleInput, files.length, activeChannel]);
|
|
333
370
|
|
|
334
371
|
const handleKeyDown = useCallback(
|
|
335
372
|
(e: React.KeyboardEvent) => {
|
|
@@ -346,25 +383,33 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
346
383
|
return;
|
|
347
384
|
}
|
|
348
385
|
}
|
|
349
|
-
if (isTeamChannel && !disableMentions) {
|
|
386
|
+
if ((isTeamChannel || isTopic) && !disableMentions) {
|
|
350
387
|
const consumed = mentionHandleKeyDown(e);
|
|
351
388
|
if (consumed) return;
|
|
352
389
|
}
|
|
353
390
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
354
391
|
e.preventDefault();
|
|
355
|
-
if (!isSlowModeBlocked) {
|
|
392
|
+
if (!isSlowModeBlocked && !keywordError) {
|
|
356
393
|
handleSend();
|
|
357
394
|
}
|
|
358
395
|
}
|
|
359
396
|
},
|
|
360
|
-
[isTeamChannel, disableMentions, mentionHandleKeyDown, handleSend, editingMessage, quotedMessage, setEditingMessage, setQuotedMessage, reset],
|
|
397
|
+
[isTeamChannel, isTopic, disableMentions, mentionHandleKeyDown, handleSend, editingMessage, quotedMessage, setEditingMessage, setQuotedMessage, reset],
|
|
361
398
|
);
|
|
362
399
|
|
|
363
400
|
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
364
401
|
e.preventDefault();
|
|
402
|
+
if (e.clipboardData.files && e.clipboardData.files.length > 0) {
|
|
403
|
+
if (!disableAttachments && canSendMessage && !isSlowModeBlocked && !editingMessage) {
|
|
404
|
+
handleFilesSelected(e.clipboardData.files);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
365
408
|
const plainText = e.clipboardData.getData('text/plain');
|
|
366
|
-
|
|
367
|
-
|
|
409
|
+
if (plainText) {
|
|
410
|
+
document.execCommand('insertText', false, plainText);
|
|
411
|
+
}
|
|
412
|
+
}, [disableAttachments, canSendMessage, isSlowModeBlocked, editingMessage, handleFilesSelected]);
|
|
368
413
|
|
|
369
414
|
if (!activeChannel) return null;
|
|
370
415
|
|
|
@@ -416,6 +461,52 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
416
461
|
);
|
|
417
462
|
}
|
|
418
463
|
|
|
464
|
+
// Show Preview Overlay for public channels when the user has not joined
|
|
465
|
+
if (isPreviewMode) {
|
|
466
|
+
return (
|
|
467
|
+
<PreviewOverlayComponent
|
|
468
|
+
title={previewOverlayTitle}
|
|
469
|
+
buttonLabel={joinChannelLabel}
|
|
470
|
+
onJoin={async () => {
|
|
471
|
+
if (!activeChannel) return;
|
|
472
|
+
try {
|
|
473
|
+
await activeChannel.acceptInvite('join');
|
|
474
|
+
// Optimistically update local membership
|
|
475
|
+
if (activeChannel.state && client.userID) {
|
|
476
|
+
const updatedMembership = {
|
|
477
|
+
...activeChannel.state.membership,
|
|
478
|
+
channel_role: 'member',
|
|
479
|
+
user_id: client.userID,
|
|
480
|
+
} as Record<string, unknown>;
|
|
481
|
+
activeChannel.state.membership = updatedMembership;
|
|
482
|
+
if (activeChannel.state.members?.[client.userID]) {
|
|
483
|
+
activeChannel.state.members[client.userID] = {
|
|
484
|
+
...activeChannel.state.members[client.userID],
|
|
485
|
+
channel_role: 'member',
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
// Dispatch synthetic event so channel list and other hooks update
|
|
489
|
+
client.dispatchEvent({
|
|
490
|
+
type: 'member.joined',
|
|
491
|
+
cid: activeChannel.cid,
|
|
492
|
+
channel_type: activeChannel.type,
|
|
493
|
+
channel_id: activeChannel.id,
|
|
494
|
+
channel: activeChannel.data,
|
|
495
|
+
member: updatedMembership,
|
|
496
|
+
user: client.user,
|
|
497
|
+
} as any);
|
|
498
|
+
}
|
|
499
|
+
// Re-watch to get full state from server
|
|
500
|
+
activeChannel.watch().catch(() => {});
|
|
501
|
+
} catch (e) {
|
|
502
|
+
console.error('Failed to join public channel', e);
|
|
503
|
+
}
|
|
504
|
+
}}
|
|
505
|
+
className={className}
|
|
506
|
+
/>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
419
510
|
const isStillUploading = files.some((f) => f.status === 'uploading');
|
|
420
511
|
|
|
421
512
|
return (
|
|
@@ -425,6 +516,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
425
516
|
<ReplyPreviewComponent
|
|
426
517
|
message={quotedMessage}
|
|
427
518
|
onDismiss={() => setQuotedMessage(null)}
|
|
519
|
+
replyingToLabel={replyingToLabel}
|
|
428
520
|
/>
|
|
429
521
|
)}
|
|
430
522
|
|
|
@@ -433,6 +525,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
433
525
|
<EditPreviewComponent
|
|
434
526
|
message={editingMessage}
|
|
435
527
|
onDismiss={cancelEdit}
|
|
528
|
+
editingMessageLabel={editingMessageLabel}
|
|
436
529
|
/>
|
|
437
530
|
)}
|
|
438
531
|
|
|
@@ -469,7 +562,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
469
562
|
{/* Text input + send row */}
|
|
470
563
|
<div className={`ermis-message-input__row${(!canSendMessage || isSlowModeBlocked || keywordError) ? ' ermis-message-input__row--banners-active' : ''}`}>
|
|
471
564
|
<div className="ermis-message-input__editable-wrapper">
|
|
472
|
-
{canSendMessage && isTeamChannel && !disableMentions && showSuggestions && (
|
|
565
|
+
{canSendMessage && (isTeamChannel || isTopic) && !disableMentions && showSuggestions && (
|
|
473
566
|
<MentionSuggestionsComponent
|
|
474
567
|
members={filteredMembers}
|
|
475
568
|
highlightIndex={highlightIndex}
|
|
@@ -510,20 +603,48 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
510
603
|
suppressContentEditableWarning
|
|
511
604
|
/>
|
|
512
605
|
|
|
606
|
+
{/* Voice record button */}
|
|
607
|
+
{VoiceRecordButtonComponent && (
|
|
608
|
+
<VoiceRecordButtonComponent
|
|
609
|
+
disabled={sending || !!editingMessage || isSlowModeBlocked || !canSendMessage}
|
|
610
|
+
onRecordComplete={(f) => {
|
|
611
|
+
const dt = new DataTransfer();
|
|
612
|
+
dt.items.add(f);
|
|
613
|
+
handleFilesSelected(dt.files);
|
|
614
|
+
}}
|
|
615
|
+
/>
|
|
616
|
+
)}
|
|
617
|
+
|
|
513
618
|
{/* Emoji button — shown only when EmojiPickerComponent is provided */}
|
|
514
619
|
{EmojiPickerComponent && (
|
|
515
620
|
<EmojiButtonComponent active={emojiPickerOpen} onClick={isSlowModeBlocked ? () => { } : toggleEmojiPicker} />
|
|
516
621
|
)}
|
|
622
|
+
|
|
623
|
+
{/* Sticker button — shown unless disabled */}
|
|
624
|
+
{!disableStickers && StickerButtonComponent && (
|
|
625
|
+
<StickerButtonComponent active={stickerPickerOpen} onClick={isSlowModeBlocked || !!editingMessage || !!quotedMessage ? () => { } : toggleStickerPicker} />
|
|
626
|
+
)}
|
|
517
627
|
</div>
|
|
518
|
-
|
|
628
|
+
|
|
629
|
+
<SendButton disabled={!hasContent || sending || !!editingMessage || isSlowModeBlocked || !canSendMessage || isStillUploading || !!keywordError} onClick={handleSend} />
|
|
519
630
|
</div>
|
|
520
631
|
|
|
521
|
-
{/* Emoji
|
|
632
|
+
{/* Emoji Picker Dropdown */}
|
|
522
633
|
{EmojiPickerComponent && emojiPickerOpen && (
|
|
523
634
|
<div className="ermis-message-input__emoji-picker">
|
|
524
635
|
<EmojiPickerComponent onSelect={handleEmojiSelect} onClose={handleEmojiClose} />
|
|
525
636
|
</div>
|
|
526
637
|
)}
|
|
638
|
+
|
|
639
|
+
{/* Sticker Picker Dropdown */}
|
|
640
|
+
{!disableStickers && StickerPickerComponent && stickerPickerOpen && (
|
|
641
|
+
<StickerPickerComponent stickerIframeUrl={stickerIframeUrl} onClose={closeStickerPicker} />
|
|
642
|
+
)}
|
|
643
|
+
|
|
644
|
+
{/* Drag & Drop Overlay */}
|
|
645
|
+
{isDragging && (
|
|
646
|
+
<DragAndDropOverlayComponent dragAndDropLabel={dragAndDropLabel} />
|
|
647
|
+
)}
|
|
527
648
|
</div>
|
|
528
649
|
);
|
|
529
650
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { MultiRecorder, PCM_WORKLET_URL } from 'react-ts-audio-recorder';
|
|
3
|
+
import type { VoiceRecordButtonProps } from '../types';
|
|
2
4
|
|
|
3
5
|
/* ----------------------------------------------------------
|
|
4
6
|
Default sub-components for MessageInput
|
|
@@ -48,3 +50,127 @@ export const DefaultEmojiButton: React.FC<{ active: boolean; onClick: () => void
|
|
|
48
50
|
</button>
|
|
49
51
|
));
|
|
50
52
|
DefaultEmojiButton.displayName = 'DefaultEmojiButton';
|
|
53
|
+
|
|
54
|
+
export const DefaultStickerButton: React.FC<{ active: boolean; onClick: () => void }> = React.memo(({
|
|
55
|
+
active,
|
|
56
|
+
onClick,
|
|
57
|
+
}) => (
|
|
58
|
+
<button
|
|
59
|
+
className={`ermis-message-input__sticker-btn${active ? ' ermis-message-input__sticker-btn--active' : ''}`}
|
|
60
|
+
onClick={onClick}
|
|
61
|
+
type="button"
|
|
62
|
+
aria-label="Sticker"
|
|
63
|
+
>
|
|
64
|
+
🐱
|
|
65
|
+
</button>
|
|
66
|
+
));
|
|
67
|
+
DefaultStickerButton.displayName = 'DefaultStickerButton';
|
|
68
|
+
|
|
69
|
+
export const DefaultStickerPicker: React.FC<{ stickerIframeUrl: string; onClose: () => void }> = React.memo(({
|
|
70
|
+
stickerIframeUrl,
|
|
71
|
+
}) => (
|
|
72
|
+
<div className="ermis-message-input__sticker-picker-container">
|
|
73
|
+
<iframe
|
|
74
|
+
src={stickerIframeUrl}
|
|
75
|
+
title="Sticker Picker"
|
|
76
|
+
className="ermis-message-input__sticker-iframe"
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
));
|
|
80
|
+
DefaultStickerPicker.displayName = 'DefaultStickerPicker';
|
|
81
|
+
|
|
82
|
+
export const DefaultDragAndDropOverlay: React.FC<{ dragAndDropLabel: string }> = React.memo(({
|
|
83
|
+
dragAndDropLabel,
|
|
84
|
+
}) => (
|
|
85
|
+
<div className="ermis-channel__drop-overlay">
|
|
86
|
+
<div className="ermis-channel__drop-overlay-content">
|
|
87
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
88
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
89
|
+
<polyline points="17 8 12 3 7 8"></polyline>
|
|
90
|
+
<line x1="12" y1="3" x2="12" y2="15"></line>
|
|
91
|
+
</svg>
|
|
92
|
+
<span>{dragAndDropLabel}</span>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
));
|
|
96
|
+
DefaultDragAndDropOverlay.displayName = 'DefaultDragAndDropOverlay';
|
|
97
|
+
|
|
98
|
+
export const DefaultVoiceRecordButton: React.FC<VoiceRecordButtonProps> = React.memo(({ disabled, onRecordComplete }) => {
|
|
99
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
100
|
+
const [recordingTime, setRecordingTime] = useState(0);
|
|
101
|
+
const recorderRef = useRef<MultiRecorder | null>(null);
|
|
102
|
+
const timerRef = useRef<number | null>(null);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
return () => {
|
|
106
|
+
if (timerRef.current) clearInterval(timerRef.current);
|
|
107
|
+
if (recorderRef.current) recorderRef.current.close();
|
|
108
|
+
};
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const toggleRecording = async () => {
|
|
112
|
+
if (isRecording) {
|
|
113
|
+
if (!recorderRef.current) return;
|
|
114
|
+
try {
|
|
115
|
+
const blob = await recorderRef.current.stopRecording();
|
|
116
|
+
const file = new File([blob], `Voice_Message.wav`, { type: 'audio/wav' });
|
|
117
|
+
onRecordComplete(file);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error('Failed to stop recording:', err);
|
|
120
|
+
} finally {
|
|
121
|
+
recorderRef.current.close();
|
|
122
|
+
recorderRef.current = null;
|
|
123
|
+
setIsRecording(false);
|
|
124
|
+
setRecordingTime(0);
|
|
125
|
+
if (timerRef.current) clearInterval(timerRef.current);
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
try {
|
|
129
|
+
const recorder = new MultiRecorder({
|
|
130
|
+
format: 'wav',
|
|
131
|
+
workletURL: PCM_WORKLET_URL,
|
|
132
|
+
});
|
|
133
|
+
await recorder.init();
|
|
134
|
+
await recorder.startRecording();
|
|
135
|
+
recorderRef.current = recorder;
|
|
136
|
+
setIsRecording(true);
|
|
137
|
+
setRecordingTime(0);
|
|
138
|
+
timerRef.current = window.setInterval(() => {
|
|
139
|
+
setRecordingTime(prev => prev + 1);
|
|
140
|
+
}, 1000);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error('Failed to start recording:', err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const formatTime = (seconds: number) => {
|
|
148
|
+
const m = Math.floor(seconds / 60);
|
|
149
|
+
const s = seconds % 60;
|
|
150
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
className={`ermis-message-input__voice-btn ${isRecording ? 'ermis-message-input__voice-btn--recording' : ''}`}
|
|
156
|
+
onClick={toggleRecording}
|
|
157
|
+
disabled={disabled && !isRecording}
|
|
158
|
+
type="button"
|
|
159
|
+
title={isRecording ? 'Stop Recording' : 'Record Voice Message'}
|
|
160
|
+
>
|
|
161
|
+
{isRecording ? (
|
|
162
|
+
<span className="ermis-message-input__voice-recording-indicator">
|
|
163
|
+
<span className="ermis-message-input__voice-dot" />
|
|
164
|
+
{formatTime(recordingTime)}
|
|
165
|
+
</span>
|
|
166
|
+
) : (
|
|
167
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
168
|
+
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
|
169
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
|
170
|
+
<line x1="12" y1="19" x2="12" y2="22" />
|
|
171
|
+
</svg>
|
|
172
|
+
)}
|
|
173
|
+
</button>
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
DefaultVoiceRecordButton.displayName = 'DefaultVoiceRecordButton';
|
|
@@ -7,7 +7,7 @@ import { MessageQuickReactions } from './MessageQuickReactions';
|
|
|
7
7
|
import { useChannelCapabilities } from '../hooks/useChannelCapabilities';
|
|
8
8
|
import { useChatClient } from '../hooks/useChatClient';
|
|
9
9
|
import { formatTime } from '../utils';
|
|
10
|
-
import { isSystemMessage } from '../messageTypeUtils';
|
|
10
|
+
import { isSystemMessage, isDeletedDisplayMessage, isStickerMessage, isSignalMessage } from '../messageTypeUtils';
|
|
11
11
|
|
|
12
12
|
export type { MessageItemProps, SystemMessageItemProps } from '../types';
|
|
13
13
|
|
|
@@ -73,10 +73,16 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
73
73
|
MessageReactionsComponent = MessageReactions,
|
|
74
74
|
forwardedLabel = 'Forwarded',
|
|
75
75
|
editedLabel = 'Edited',
|
|
76
|
+
deletedMessageLabel = 'This message was deleted',
|
|
77
|
+
systemMessageTranslations,
|
|
78
|
+
signalMessageTranslations,
|
|
79
|
+
onMentionClick,
|
|
80
|
+
onUserNameClick,
|
|
81
|
+
onAddReactionClick,
|
|
76
82
|
}) => {
|
|
77
83
|
const { activeChannel, client } = useChatClient();
|
|
78
84
|
const { hasCapability } = useChannelCapabilities();
|
|
79
|
-
|
|
85
|
+
|
|
80
86
|
const canReact = hasCapability('send-reaction');
|
|
81
87
|
|
|
82
88
|
const userName = message.user?.name || message.user_id;
|
|
@@ -87,6 +93,7 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
87
93
|
const oldTexts = (message as any).old_texts;
|
|
88
94
|
const isEdited = oldTexts && oldTexts.length > 0;
|
|
89
95
|
const hasAttachments = message.attachments && message.attachments.length > 0;
|
|
96
|
+
const isDeletedDisplay = isDeletedDisplayMessage(message);
|
|
90
97
|
|
|
91
98
|
const handleReactionToggle = React.useCallback(async (type: string) => {
|
|
92
99
|
if (!activeChannel || !canReact) return;
|
|
@@ -118,20 +125,63 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
118
125
|
return Date.now() - new Date(message.created_at).getTime() < 1000;
|
|
119
126
|
}, [message.created_at]);
|
|
120
127
|
|
|
128
|
+
const isSticker = React.useMemo(() => isStickerMessage(message), [message]);
|
|
129
|
+
|
|
121
130
|
const itemClass = [
|
|
122
131
|
'ermis-message-list__item',
|
|
123
132
|
isOwnMessage ? 'ermis-message-list__item--own' : 'ermis-message-list__item--other',
|
|
124
|
-
isFirstInGroup ? 'ermis-message-list__item--group-
|
|
133
|
+
isFirstInGroup && isLastInGroup ? 'ermis-message-list__item--group-single' : '',
|
|
134
|
+
isFirstInGroup && !isLastInGroup ? 'ermis-message-list__item--group-top' : '',
|
|
135
|
+
!isFirstInGroup && !isLastInGroup ? 'ermis-message-list__item--group-middle' : '',
|
|
136
|
+
!isFirstInGroup && isLastInGroup ? 'ermis-message-list__item--group-bottom' : '',
|
|
125
137
|
isHighlighted ? 'ermis-message-list__item--highlighted' : '',
|
|
126
138
|
isNewMessage ? 'ermis-message-list__item--new' : '',
|
|
139
|
+
isDeletedDisplay ? 'ermis-message-list__item--deleted-display' : '',
|
|
140
|
+
isSticker ? 'ermis-message-list__item--sticker' : '',
|
|
141
|
+
isSignalMessage(message) ? 'ermis-message-list__item--signal' : '',
|
|
127
142
|
statusClass,
|
|
128
143
|
].filter(Boolean).join(' ');
|
|
129
144
|
|
|
130
145
|
const contentClass = [
|
|
131
146
|
'ermis-message-list__item-content',
|
|
132
|
-
hasAttachments ? 'ermis-message-list__item-content--has-attachments' : '',
|
|
147
|
+
hasAttachments && !isDeletedDisplay ? 'ermis-message-list__item-content--has-attachments' : '',
|
|
133
148
|
].filter(Boolean).join(' ');
|
|
134
149
|
|
|
150
|
+
// Deleted display: show icon + label, no actions/reactions/quote/attachments
|
|
151
|
+
if (isDeletedDisplay) {
|
|
152
|
+
return (
|
|
153
|
+
<div className={itemClass} data-message-id={message.id}>
|
|
154
|
+
{!isOwnMessage && (
|
|
155
|
+
<div className="ermis-message-list__item-avatar">
|
|
156
|
+
{isFirstInGroup
|
|
157
|
+
? <AvatarComponent image={userAvatar} name={userName} size={28} />
|
|
158
|
+
: <div style={{ width: 28 }} />
|
|
159
|
+
}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
<div className={contentClass}>
|
|
163
|
+
{!isOwnMessage && isFirstInGroup && (
|
|
164
|
+
<span className="ermis-message-list__item-user">{userName}</span>
|
|
165
|
+
)}
|
|
166
|
+
<div className="ermis-message-list__bubble-wrapper">
|
|
167
|
+
<MessageBubble message={message} isOwnMessage={isOwnMessage}>
|
|
168
|
+
<span className="ermis-message-list__deleted-text">
|
|
169
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
170
|
+
<circle cx="12" cy="12" r="10" />
|
|
171
|
+
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
|
172
|
+
</svg>
|
|
173
|
+
{deletedMessageLabel}
|
|
174
|
+
</span>
|
|
175
|
+
<span className="ermis-message-list__item-time">
|
|
176
|
+
{formatTime(message.created_at)}
|
|
177
|
+
</span>
|
|
178
|
+
</MessageBubble>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
135
185
|
return (
|
|
136
186
|
<div className={itemClass} data-message-id={message.id}>
|
|
137
187
|
{/* Avatar area: show avatar only on first message, otherwise placeholder for alignment */}
|
|
@@ -145,7 +195,10 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
145
195
|
)}
|
|
146
196
|
<div className={contentClass}>
|
|
147
197
|
{!isOwnMessage && isFirstInGroup && (
|
|
148
|
-
<span
|
|
198
|
+
<span
|
|
199
|
+
className={`ermis-message-list__item-user${onUserNameClick ? ' ermis-message-list__item-user--clickable' : ''}`}
|
|
200
|
+
onClick={onUserNameClick ? (e) => { e.stopPropagation(); const uid = message.user?.id || message.user_id; if (uid) onUserNameClick(uid); } : undefined}
|
|
201
|
+
>{userName}</span>
|
|
149
202
|
)}
|
|
150
203
|
{/* Quoted message preview */}
|
|
151
204
|
{quotedMessage && onClickQuote && (
|
|
@@ -156,33 +209,41 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
156
209
|
/>
|
|
157
210
|
)}
|
|
158
211
|
<div className="ermis-message-list__bubble-wrapper">
|
|
159
|
-
<MessageQuickReactions message={message} isOwnMessage={isOwnMessage} disabled={!canReact} />
|
|
212
|
+
{!isSignalMessage(message) && <MessageQuickReactions message={message} isOwnMessage={isOwnMessage} disabled={!canReact} onAddReactionClick={onAddReactionClick} />}
|
|
160
213
|
<MessageBubble message={message} isOwnMessage={isOwnMessage}>
|
|
161
214
|
{isForwarded && (
|
|
162
215
|
<span className="ermis-message-list__forwarded-indicator">{forwardedLabel}</span>
|
|
163
216
|
)}
|
|
164
|
-
<MessageRenderer
|
|
165
|
-
<span className="ermis-message-list__item-time">
|
|
166
|
-
{isEdited && (
|
|
167
|
-
<span
|
|
168
|
-
className="ermis-message-list__edited-indicator"
|
|
169
|
-
// data-tooltip={oldTexts.map((ot: any) => `[${formatTime(ot.created_at)}] ${ot.text}`).join('\n')}
|
|
170
|
-
>
|
|
171
|
-
{editedLabel}
|
|
172
|
-
</span>
|
|
173
|
-
)}
|
|
174
|
-
{formatTime(message.created_at)}
|
|
175
|
-
<InlineStatusIcon status={message.status} isOwnMessage={isOwnMessage} isLastInGroup={isLastInGroup} />
|
|
176
|
-
</span>
|
|
177
|
-
</MessageBubble>
|
|
178
|
-
|
|
179
|
-
{/* Actions: hover buttons + dropdown menu */}
|
|
180
|
-
{!isSystemMessage(message) && (
|
|
181
|
-
<MessageActionsBoxComponent
|
|
217
|
+
<MessageRenderer
|
|
182
218
|
message={message}
|
|
183
219
|
isOwnMessage={isOwnMessage}
|
|
220
|
+
systemMessageTranslations={systemMessageTranslations}
|
|
221
|
+
signalMessageTranslations={signalMessageTranslations}
|
|
222
|
+
onMentionClick={onMentionClick}
|
|
184
223
|
/>
|
|
185
|
-
|
|
224
|
+
{!isSignalMessage(message) && (isLastInGroup || isEdited || message.status === 'error' || message.status === 'failed_offline') && (
|
|
225
|
+
<span className="ermis-message-list__item-time">
|
|
226
|
+
{isEdited && (
|
|
227
|
+
<span
|
|
228
|
+
className="ermis-message-list__edited-indicator"
|
|
229
|
+
// data-tooltip={oldTexts.map((ot: any) => `[${formatTime(ot.created_at)}] ${ot.text}`).join('\n')}
|
|
230
|
+
>
|
|
231
|
+
{editedLabel}
|
|
232
|
+
</span>
|
|
233
|
+
)}
|
|
234
|
+
{isLastInGroup && formatTime(message.created_at)}
|
|
235
|
+
<InlineStatusIcon status={message.status} isOwnMessage={isOwnMessage} isLastInGroup={isLastInGroup} />
|
|
236
|
+
</span>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{/* Actions: hover buttons + dropdown menu */}
|
|
240
|
+
{!isSystemMessage(message) && (
|
|
241
|
+
<MessageActionsBoxComponent
|
|
242
|
+
message={message}
|
|
243
|
+
isOwnMessage={isOwnMessage}
|
|
244
|
+
/>
|
|
245
|
+
)}
|
|
246
|
+
</MessageBubble>
|
|
186
247
|
|
|
187
248
|
{/* Message Reactions */}
|
|
188
249
|
{MessageReactionsComponent && (
|
|
@@ -192,6 +253,7 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
192
253
|
latestReactions={(message as any).latest_reactions}
|
|
193
254
|
onClickReaction={handleReactionToggle}
|
|
194
255
|
disabled={!canReact}
|
|
256
|
+
isOwnMessage={isOwnMessage}
|
|
195
257
|
/>
|
|
196
258
|
)}
|
|
197
259
|
</div>
|
|
@@ -208,9 +270,14 @@ export const SystemMessageItem: React.FC<SystemMessageItemProps> = React.memo(({
|
|
|
208
270
|
message,
|
|
209
271
|
isOwnMessage,
|
|
210
272
|
SystemRenderer,
|
|
273
|
+
systemMessageTranslations,
|
|
211
274
|
}) => (
|
|
212
275
|
<div className="ermis-message-list__system">
|
|
213
|
-
<SystemRenderer
|
|
276
|
+
<SystemRenderer
|
|
277
|
+
message={message}
|
|
278
|
+
isOwnMessage={isOwnMessage}
|
|
279
|
+
systemMessageTranslations={systemMessageTranslations}
|
|
280
|
+
/>
|
|
214
281
|
</div>
|
|
215
282
|
));
|
|
216
283
|
SystemMessageItem.displayName = 'SystemMessageItem';
|