@ermis-network/ermis-chat-react 1.0.9 → 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 +15288 -4203
- 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 +15238 -4179
- 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 +126 -7
- 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,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,11 +55,20 @@ 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
67
|
const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage } = 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);
|
|
56
74
|
|
|
@@ -160,6 +178,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
160
178
|
}
|
|
161
179
|
}
|
|
162
180
|
|
|
181
|
+
// Max Characters validation (5000 chars)
|
|
182
|
+
if (text && text.length > 5000) {
|
|
183
|
+
setKeywordError(maxCharsLabel);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
163
187
|
// Custom Keyword validation
|
|
164
188
|
const words = (activeChannel?.data?.filter_words as string[]) || [];
|
|
165
189
|
if (words.length > 0 && text) {
|
|
@@ -200,6 +224,11 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
200
224
|
handleFilesSelected, handleRemoveFile, handleAttachClick, cleanupFiles,
|
|
201
225
|
} = useFileUpload({ activeChannel, editableRef, setHasContent });
|
|
202
226
|
|
|
227
|
+
const { isDragging } = useDragAndDrop(
|
|
228
|
+
handleFilesSelected,
|
|
229
|
+
disableAttachments || !canSendMessage || isSlowModeBlocked || !!editingMessage || !!quotedMessage
|
|
230
|
+
);
|
|
231
|
+
|
|
203
232
|
// Pre-fill text and legacy attachments when editingMessage is set
|
|
204
233
|
useEffect(() => {
|
|
205
234
|
if (editingMessage && editableRef.current) {
|
|
@@ -244,6 +273,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
244
273
|
toggleEmojiPicker,
|
|
245
274
|
} = useEmojiPicker({ editableRef, setHasContent });
|
|
246
275
|
|
|
276
|
+
const {
|
|
277
|
+
stickerPickerOpen,
|
|
278
|
+
toggleStickerPicker,
|
|
279
|
+
closeStickerPicker,
|
|
280
|
+
} = useStickerPicker({ activeChannel, stickerIframeUrl });
|
|
281
|
+
|
|
247
282
|
// Build member list from channel state (only for team channels and topics)
|
|
248
283
|
const members = useMemo<MentionMember[]>(() => {
|
|
249
284
|
if (!(isTeamChannel || isTopic)) return [];
|
|
@@ -354,7 +389,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
354
389
|
}
|
|
355
390
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
356
391
|
e.preventDefault();
|
|
357
|
-
if (!isSlowModeBlocked) {
|
|
392
|
+
if (!isSlowModeBlocked && !keywordError) {
|
|
358
393
|
handleSend();
|
|
359
394
|
}
|
|
360
395
|
}
|
|
@@ -364,9 +399,17 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
364
399
|
|
|
365
400
|
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
366
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
|
+
}
|
|
367
408
|
const plainText = e.clipboardData.getData('text/plain');
|
|
368
|
-
|
|
369
|
-
|
|
409
|
+
if (plainText) {
|
|
410
|
+
document.execCommand('insertText', false, plainText);
|
|
411
|
+
}
|
|
412
|
+
}, [disableAttachments, canSendMessage, isSlowModeBlocked, editingMessage, handleFilesSelected]);
|
|
370
413
|
|
|
371
414
|
if (!activeChannel) return null;
|
|
372
415
|
|
|
@@ -418,6 +461,52 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
418
461
|
);
|
|
419
462
|
}
|
|
420
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
|
+
|
|
421
510
|
const isStillUploading = files.some((f) => f.status === 'uploading');
|
|
422
511
|
|
|
423
512
|
return (
|
|
@@ -427,6 +516,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
427
516
|
<ReplyPreviewComponent
|
|
428
517
|
message={quotedMessage}
|
|
429
518
|
onDismiss={() => setQuotedMessage(null)}
|
|
519
|
+
replyingToLabel={replyingToLabel}
|
|
430
520
|
/>
|
|
431
521
|
)}
|
|
432
522
|
|
|
@@ -435,6 +525,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
435
525
|
<EditPreviewComponent
|
|
436
526
|
message={editingMessage}
|
|
437
527
|
onDismiss={cancelEdit}
|
|
528
|
+
editingMessageLabel={editingMessageLabel}
|
|
438
529
|
/>
|
|
439
530
|
)}
|
|
440
531
|
|
|
@@ -512,20 +603,48 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
512
603
|
suppressContentEditableWarning
|
|
513
604
|
/>
|
|
514
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
|
+
|
|
515
618
|
{/* Emoji button — shown only when EmojiPickerComponent is provided */}
|
|
516
619
|
{EmojiPickerComponent && (
|
|
517
620
|
<EmojiButtonComponent active={emojiPickerOpen} onClick={isSlowModeBlocked ? () => { } : toggleEmojiPicker} />
|
|
518
621
|
)}
|
|
622
|
+
|
|
623
|
+
{/* Sticker button — shown unless disabled */}
|
|
624
|
+
{!disableStickers && StickerButtonComponent && (
|
|
625
|
+
<StickerButtonComponent active={stickerPickerOpen} onClick={isSlowModeBlocked || !!editingMessage || !!quotedMessage ? () => { } : toggleStickerPicker} />
|
|
626
|
+
)}
|
|
519
627
|
</div>
|
|
520
|
-
|
|
628
|
+
|
|
629
|
+
<SendButton disabled={!hasContent || sending || !!editingMessage || isSlowModeBlocked || !canSendMessage || isStillUploading || !!keywordError} onClick={handleSend} />
|
|
521
630
|
</div>
|
|
522
631
|
|
|
523
|
-
{/* Emoji
|
|
632
|
+
{/* Emoji Picker Dropdown */}
|
|
524
633
|
{EmojiPickerComponent && emojiPickerOpen && (
|
|
525
634
|
<div className="ermis-message-input__emoji-picker">
|
|
526
635
|
<EmojiPickerComponent onSelect={handleEmojiSelect} onClose={handleEmojiClose} />
|
|
527
636
|
</div>
|
|
528
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
|
+
)}
|
|
529
648
|
</div>
|
|
530
649
|
);
|
|
531
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';
|