@ermis-network/ermis-chat-react 2.0.0 → 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 +5087 -11279
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +632 -152
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +273 -9
- package/dist/index.d.ts +273 -9
- package/dist/index.mjs +5085 -11295
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/Channel.tsx +0 -3
- package/src/components/ChannelActions.tsx +6 -1
- package/src/components/ChannelHeader.tsx +8 -32
- package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
- package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
- package/src/components/ChannelList.tsx +72 -13
- package/src/components/CreateChannelModal.tsx +131 -12
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +27 -16
- package/src/components/ForwardMessageModal.tsx +11 -3
- package/src/components/MediaLightbox.tsx +444 -304
- package/src/components/MessageActionsBox.tsx +2 -0
- package/src/components/MessageInput.tsx +41 -12
- package/src/components/MessageItem.tsx +70 -25
- package/src/components/MessageQuickReactions.tsx +131 -128
- package/src/components/MessageReactions.tsx +47 -2
- package/src/components/MessageRenderers.tsx +1030 -433
- package/src/components/PinnedMessages.tsx +40 -12
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +20 -5
- package/src/components/TypingIndicator.tsx +3 -3
- package/src/components/UserPicker.tsx +26 -25
- package/src/components/VirtualMessageList.tsx +345 -125
- package/src/context/ChatProvider.tsx +27 -1
- package/src/hooks/useChannelListUpdates.ts +22 -1
- package/src/hooks/useChannelMessages.ts +338 -51
- package/src/hooks/useChannelRowUpdates.ts +18 -6
- package/src/hooks/useChatUser.ts +9 -1
- 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 +210 -13
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -6
- package/src/hooks/useMessageActions.ts +14 -8
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useTopicGroupUpdates.ts +49 -11
- package/src/index.ts +23 -0
- package/src/messageTypeUtils.ts +14 -0
- package/src/styles/_channel-info.css +9 -0
- package/src/styles/_channel-list.css +37 -14
- package/src/styles/_media-lightbox.css +36 -3
- package/src/styles/_message-bubble.css +381 -41
- package/src/styles/_message-input.css +8 -0
- package/src/styles/_message-list.css +67 -10
- package/src/styles/_message-quick-reactions.css +101 -59
- package/src/styles/_message-reactions.css +18 -32
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +5 -5
- package/src/styles/_typing-indicator.css +23 -13
- package/src/styles/index.css +1 -0
- package/src/types.ts +115 -1
- package/src/utils/avatarColors.ts +1 -1
- package/src/utils.ts +38 -18
|
@@ -5,6 +5,7 @@ import { useChatClient } from '../hooks/useChatClient';
|
|
|
5
5
|
import type { MessageActionsBoxProps } from '../types';
|
|
6
6
|
import { Dropdown as DefaultDropdown, closeAllDropdowns } from './Dropdown';
|
|
7
7
|
import { useChatComponents } from '../context/ChatComponentsContext';
|
|
8
|
+
import { MessageQuickReactions } from './MessageQuickReactions';
|
|
8
9
|
|
|
9
10
|
// Aliased for backward compatibility
|
|
10
11
|
export const closeAllActionBoxes = closeAllDropdowns;
|
|
@@ -105,6 +106,7 @@ export const MessageActionsBox: React.FC<MessageActionsBoxProps> = ({
|
|
|
105
106
|
</svg>
|
|
106
107
|
</button>
|
|
107
108
|
)}
|
|
109
|
+
<MessageQuickReactions message={message} isOwnMessage={isOwnMessage} disabled={!actions.hasCapReact} />
|
|
108
110
|
{actions.canForward && (
|
|
109
111
|
<button
|
|
110
112
|
className="ermis-message-list__actions-trigger"
|
|
@@ -64,13 +64,14 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
64
64
|
DragAndDropOverlayComponent = DefaultDragAndDropOverlay,
|
|
65
65
|
maxCharsLabel = 'Tin nhắn không được vượt quá 5000 ký tự.',
|
|
66
66
|
}) => {
|
|
67
|
-
const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage } = useChatClient();
|
|
67
|
+
const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage, setDraft, getDraft } = useChatClient();
|
|
68
68
|
const { isBanned } = useBannedState(activeChannel, client.userID);
|
|
69
69
|
const { isBlocked } = useBlockedState(activeChannel, client.userID);
|
|
70
70
|
const { isPending } = usePendingState(activeChannel, client.userID);
|
|
71
71
|
const { isPreviewMode } = usePreviewState(activeChannel, client.userID);
|
|
72
72
|
const editableRef = React.useRef<HTMLDivElement>(null);
|
|
73
73
|
const [hasContent, setHasContent] = useState(false);
|
|
74
|
+
const prevChannelCidRef = useRef<string | null>(null);
|
|
74
75
|
|
|
75
76
|
const { role, isGroupChannel: isTeamChannel, hasCapability } = useChannelCapabilities();
|
|
76
77
|
const isTopic = isTopicChannel(activeChannel);
|
|
@@ -207,8 +208,12 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
207
208
|
lastMsgSentAtRef.current = Date.now();
|
|
208
209
|
setCooldownEnd(Date.now() + memberMessageCooldown);
|
|
209
210
|
}
|
|
211
|
+
// Clear draft after successful send
|
|
212
|
+
if (activeChannel?.cid) {
|
|
213
|
+
setDraft(activeChannel.cid, { html: '', files: [] });
|
|
214
|
+
}
|
|
210
215
|
onSend?.(text);
|
|
211
|
-
}, [isSlowModeApplied, memberMessageCooldown, onSend]);
|
|
216
|
+
}, [isSlowModeApplied, memberMessageCooldown, onSend, activeChannel, setDraft]);
|
|
212
217
|
|
|
213
218
|
// Auto-focus when channel changes or when reply/edit is selected
|
|
214
219
|
useEffect(() => {
|
|
@@ -224,6 +229,9 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
224
229
|
handleFilesSelected, handleRemoveFile, handleAttachClick, cleanupFiles,
|
|
225
230
|
} = useFileUpload({ activeChannel, editableRef, setHasContent });
|
|
226
231
|
|
|
232
|
+
const filesRef = useRef(files);
|
|
233
|
+
filesRef.current = files;
|
|
234
|
+
|
|
227
235
|
const { isDragging } = useDragAndDrop(
|
|
228
236
|
handleFilesSelected,
|
|
229
237
|
disableAttachments || !canSendMessage || isSlowModeBlocked || !!editingMessage || !!quotedMessage
|
|
@@ -339,21 +347,44 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
339
347
|
});
|
|
340
348
|
|
|
341
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
|
+
|
|
342
356
|
reset();
|
|
343
357
|
handleEmojiClose();
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
}
|
|
351
382
|
|
|
352
383
|
// Stop typing indicator on channel switch / unmount
|
|
353
384
|
return () => {
|
|
354
385
|
activeChannel?.stopTyping();
|
|
355
386
|
};
|
|
356
|
-
}, [activeChannel, reset, handleEmojiClose, setFiles]);
|
|
387
|
+
}, [activeChannel, reset, handleEmojiClose, setFiles, setDraft, getDraft]);
|
|
357
388
|
|
|
358
389
|
/* ---------- Input event handlers ---------- */
|
|
359
390
|
const handleInput = useCallback(() => {
|
|
@@ -496,8 +527,6 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
496
527
|
user: client.user,
|
|
497
528
|
} as any);
|
|
498
529
|
}
|
|
499
|
-
// Re-watch to get full state from server
|
|
500
|
-
activeChannel.watch().catch(() => {});
|
|
501
530
|
} catch (e) {
|
|
502
531
|
console.error('Failed to join public channel', e);
|
|
503
532
|
}
|
|
@@ -3,7 +3,6 @@ import type { MessageItemProps, SystemMessageItemProps } from '../types';
|
|
|
3
3
|
import { QuotedMessagePreview } from './QuotedMessagePreview';
|
|
4
4
|
import { MessageActionsBox } from './MessageActionsBox';
|
|
5
5
|
import { MessageReactions } from './MessageReactions';
|
|
6
|
-
import { MessageQuickReactions } from './MessageQuickReactions';
|
|
7
6
|
import { useChannelCapabilities } from '../hooks/useChannelCapabilities';
|
|
8
7
|
import { useChatClient } from '../hooks/useChatClient';
|
|
9
8
|
import { formatTime } from '../utils';
|
|
@@ -58,6 +57,29 @@ const InlineStatusIcon: React.FC<{ status?: string; isOwnMessage: boolean; isLas
|
|
|
58
57
|
});
|
|
59
58
|
InlineStatusIcon.displayName = 'InlineStatusIcon';
|
|
60
59
|
|
|
60
|
+
function findQuotedMessageInChannelState(channel: any, quotedMessageId?: string) {
|
|
61
|
+
if (!channel || !quotedMessageId) return undefined;
|
|
62
|
+
|
|
63
|
+
const messageSets = Array.isArray(channel.state?.messageSets) ? channel.state.messageSets : [];
|
|
64
|
+
for (const set of messageSets) {
|
|
65
|
+
const messages = Array.isArray(set?.messages) ? set.messages : [];
|
|
66
|
+
const found = messages.find((item: any) => item?.id === quotedMessageId);
|
|
67
|
+
if (found) return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const pinnedMessages = Array.isArray(channel.state?.pinnedMessages) ? channel.state.pinnedMessages : [];
|
|
71
|
+
return pinnedMessages.find((item: any) => item?.id === quotedMessageId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hasRenderableQuotedMessageContent(quotedMessage: any) {
|
|
75
|
+
if (!quotedMessage) return false;
|
|
76
|
+
if (typeof quotedMessage.text === 'string' && quotedMessage.text.trim()) return true;
|
|
77
|
+
if (Array.isArray(quotedMessage.attachments) && quotedMessage.attachments.length > 0) return true;
|
|
78
|
+
if (typeof quotedMessage.sticker_url === 'string' && quotedMessage.sticker_url) return true;
|
|
79
|
+
if (isStickerMessage(quotedMessage)) return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
62
84
|
message,
|
|
63
85
|
isOwnMessage,
|
|
@@ -74,11 +96,18 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
74
96
|
forwardedLabel = 'Forwarded',
|
|
75
97
|
editedLabel = 'Edited',
|
|
76
98
|
deletedMessageLabel = 'This message was deleted',
|
|
99
|
+
attachmentLabel = 'Attachment',
|
|
100
|
+
unavailableMessageLabel = 'Message unavailable',
|
|
101
|
+
stickerLabel = 'Sticker',
|
|
102
|
+
encryptedMessageLabel,
|
|
103
|
+
encryptedMessageFailedLabel,
|
|
104
|
+
encryptedMessageDecryptingLabel,
|
|
77
105
|
systemMessageTranslations,
|
|
78
106
|
signalMessageTranslations,
|
|
79
107
|
onMentionClick,
|
|
80
108
|
onUserNameClick,
|
|
81
109
|
onAddReactionClick,
|
|
110
|
+
hideAvatar,
|
|
82
111
|
}) => {
|
|
83
112
|
const { activeChannel, client } = useChatClient();
|
|
84
113
|
const { hasCapability } = useChannelCapabilities();
|
|
@@ -88,7 +117,12 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
88
117
|
const userName = message.user?.name || message.user_id;
|
|
89
118
|
const userAvatar = message.user?.avatar;
|
|
90
119
|
|
|
91
|
-
const
|
|
120
|
+
const directQuotedMessage = (message as any).quoted_message;
|
|
121
|
+
const stateQuotedMessage = findQuotedMessageInChannelState(activeChannel, (message as any).quoted_message_id);
|
|
122
|
+
const quotedMessage =
|
|
123
|
+
(hasRenderableQuotedMessageContent(directQuotedMessage) ? directQuotedMessage : undefined) ||
|
|
124
|
+
(hasRenderableQuotedMessageContent(stateQuotedMessage) ? stateQuotedMessage : undefined) ||
|
|
125
|
+
directQuotedMessage;
|
|
92
126
|
const isForwarded = !!(message as any).forward_cid;
|
|
93
127
|
const oldTexts = (message as any).old_texts;
|
|
94
128
|
const isEdited = oldTexts && oldTexts.length > 0;
|
|
@@ -151,11 +185,11 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
151
185
|
if (isDeletedDisplay) {
|
|
152
186
|
return (
|
|
153
187
|
<div className={itemClass} data-message-id={message.id}>
|
|
154
|
-
{!isOwnMessage && (
|
|
188
|
+
{!hideAvatar && !isOwnMessage && (
|
|
155
189
|
<div className="ermis-message-list__item-avatar">
|
|
156
|
-
{
|
|
157
|
-
? <AvatarComponent image={userAvatar} name={userName} size={
|
|
158
|
-
: <div style={{ width:
|
|
190
|
+
{isLastInGroup
|
|
191
|
+
? <AvatarComponent image={userAvatar} name={userName} size={36} />
|
|
192
|
+
: <div style={{ width: 36 }} />
|
|
159
193
|
}
|
|
160
194
|
</div>
|
|
161
195
|
)}
|
|
@@ -184,18 +218,18 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
184
218
|
|
|
185
219
|
return (
|
|
186
220
|
<div className={itemClass} data-message-id={message.id}>
|
|
187
|
-
{/* Avatar area:
|
|
188
|
-
{!isOwnMessage && (
|
|
221
|
+
{/* Avatar area: only render when not hidden by group wrapper */}
|
|
222
|
+
{!hideAvatar && !isOwnMessage && (
|
|
189
223
|
<div className="ermis-message-list__item-avatar">
|
|
190
|
-
{
|
|
191
|
-
? <AvatarComponent image={userAvatar} name={userName} size={
|
|
192
|
-
: <div style={{ width:
|
|
224
|
+
{isLastInGroup
|
|
225
|
+
? <AvatarComponent image={userAvatar} name={userName} size={36} />
|
|
226
|
+
: <div style={{ width: 36 }} />
|
|
193
227
|
}
|
|
194
228
|
</div>
|
|
195
229
|
)}
|
|
196
230
|
<div className={contentClass}>
|
|
197
231
|
{!isOwnMessage && isFirstInGroup && (
|
|
198
|
-
<span
|
|
232
|
+
<span
|
|
199
233
|
className={`ermis-message-list__item-user${onUserNameClick ? ' ermis-message-list__item-user--clickable' : ''}`}
|
|
200
234
|
onClick={onUserNameClick ? (e) => { e.stopPropagation(); const uid = message.user?.id || message.user_id; if (uid) onUserNameClick(uid); } : undefined}
|
|
201
235
|
>{userName}</span>
|
|
@@ -206,10 +240,13 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
206
240
|
quotedMessage={quotedMessage}
|
|
207
241
|
isOwnMessage={isOwnMessage}
|
|
208
242
|
onClick={onClickQuote}
|
|
243
|
+
attachmentLabel={attachmentLabel}
|
|
244
|
+
unavailableMessageLabel={unavailableMessageLabel}
|
|
245
|
+
stickerLabel={stickerLabel}
|
|
246
|
+
deletedMessageLabel={typeof deletedMessageLabel === 'string' ? deletedMessageLabel : 'This message was deleted'}
|
|
209
247
|
/>
|
|
210
248
|
)}
|
|
211
249
|
<div className="ermis-message-list__bubble-wrapper">
|
|
212
|
-
{!isSignalMessage(message) && <MessageQuickReactions message={message} isOwnMessage={isOwnMessage} disabled={!canReact} onAddReactionClick={onAddReactionClick} />}
|
|
213
250
|
<MessageBubble message={message} isOwnMessage={isOwnMessage}>
|
|
214
251
|
{isForwarded && (
|
|
215
252
|
<span className="ermis-message-list__forwarded-indicator">{forwardedLabel}</span>
|
|
@@ -220,7 +257,27 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
220
257
|
systemMessageTranslations={systemMessageTranslations}
|
|
221
258
|
signalMessageTranslations={signalMessageTranslations}
|
|
222
259
|
onMentionClick={onMentionClick}
|
|
260
|
+
encryptedMessageLabel={encryptedMessageLabel}
|
|
261
|
+
encryptedMessageFailedLabel={encryptedMessageFailedLabel}
|
|
262
|
+
encryptedMessageDecryptingLabel={encryptedMessageDecryptingLabel}
|
|
223
263
|
/>
|
|
264
|
+
|
|
265
|
+
{/* Message Reactions — inside bubble */}
|
|
266
|
+
{MessageReactionsComponent && (
|
|
267
|
+
<>
|
|
268
|
+
<div className="ermis-message-reactions-break" style={{ width: '100%', display: 'block' }}></div>
|
|
269
|
+
<MessageReactionsComponent
|
|
270
|
+
reactionCounts={(message as any).reaction_counts}
|
|
271
|
+
ownReactions={(message as any).own_reactions}
|
|
272
|
+
latestReactions={(message as any).latest_reactions}
|
|
273
|
+
onClickReaction={handleReactionToggle}
|
|
274
|
+
disabled={!canReact}
|
|
275
|
+
isOwnMessage={isOwnMessage}
|
|
276
|
+
/>
|
|
277
|
+
</>
|
|
278
|
+
)}
|
|
279
|
+
|
|
280
|
+
{/* Time rendered AFTER text/reactions for bottom-right alignment */}
|
|
224
281
|
{!isSignalMessage(message) && (isLastInGroup || isEdited || message.status === 'error' || message.status === 'failed_offline') && (
|
|
225
282
|
<span className="ermis-message-list__item-time">
|
|
226
283
|
{isEdited && (
|
|
@@ -244,18 +301,6 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
244
301
|
/>
|
|
245
302
|
)}
|
|
246
303
|
</MessageBubble>
|
|
247
|
-
|
|
248
|
-
{/* Message Reactions */}
|
|
249
|
-
{MessageReactionsComponent && (
|
|
250
|
-
<MessageReactionsComponent
|
|
251
|
-
reactionCounts={(message as any).reaction_counts}
|
|
252
|
-
ownReactions={(message as any).own_reactions}
|
|
253
|
-
latestReactions={(message as any).latest_reactions}
|
|
254
|
-
onClickReaction={handleReactionToggle}
|
|
255
|
-
disabled={!canReact}
|
|
256
|
-
isOwnMessage={isOwnMessage}
|
|
257
|
-
/>
|
|
258
|
-
)}
|
|
259
304
|
</div>
|
|
260
305
|
</div>
|
|
261
306
|
</div>
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
|
2
|
-
import { motion, AnimatePresence } from 'motion/react';
|
|
3
2
|
import { EmojiPicker } from 'frimousse';
|
|
4
3
|
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
5
4
|
import { useChatClient } from '../hooks/useChatClient';
|
|
@@ -11,7 +10,8 @@ const EmojiPickerLoading = EmojiPicker.Loading as any;
|
|
|
11
10
|
const EmojiPickerEmpty = EmojiPicker.Empty as any;
|
|
12
11
|
const EmojiPickerList = EmojiPicker.List as any;
|
|
13
12
|
|
|
14
|
-
const
|
|
13
|
+
const REACTIONS = ['like', 'love', 'haha', 'sad', 'fire'];
|
|
14
|
+
|
|
15
15
|
const EMOJI_MAP: Record<string, string> = {
|
|
16
16
|
like: '👍',
|
|
17
17
|
love: '❤️',
|
|
@@ -24,25 +24,27 @@ export const MessageQuickReactions: React.FC<{
|
|
|
24
24
|
message: FormatMessageResponse;
|
|
25
25
|
isOwnMessage: boolean;
|
|
26
26
|
disabled?: boolean;
|
|
27
|
-
|
|
28
|
-
}> = React.memo(({ message, isOwnMessage, disabled, onAddReactionClick }) => {
|
|
27
|
+
}> = React.memo(({ message, isOwnMessage, disabled }) => {
|
|
29
28
|
const { activeChannel, client } = useChatClient();
|
|
30
29
|
const currentUserId = client?.userID;
|
|
31
30
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
31
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
32
|
+
const [pickerPosition, setPickerPosition] = useState<'top' | 'bottom'>('top');
|
|
32
33
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
33
34
|
|
|
34
35
|
// Close when clicking outside
|
|
35
36
|
useEffect(() => {
|
|
36
37
|
const handleClickOutside = (e: MouseEvent) => {
|
|
37
|
-
if (
|
|
38
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
38
39
|
setIsExpanded(false);
|
|
40
|
+
setShowPicker(false);
|
|
39
41
|
}
|
|
40
42
|
};
|
|
41
|
-
if (isExpanded) {
|
|
43
|
+
if (isExpanded || showPicker) {
|
|
42
44
|
document.addEventListener('mousedown', handleClickOutside);
|
|
43
45
|
}
|
|
44
46
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
45
|
-
}, [isExpanded]);
|
|
47
|
+
}, [isExpanded, showPicker]);
|
|
46
48
|
|
|
47
49
|
const handleReactionToggle = useCallback(
|
|
48
50
|
async (type: string) => {
|
|
@@ -66,135 +68,136 @@ export const MessageQuickReactions: React.FC<{
|
|
|
66
68
|
[activeChannel, message, currentUserId]
|
|
67
69
|
);
|
|
68
70
|
|
|
71
|
+
const isOwnReaction = useCallback(
|
|
72
|
+
(type: string) => {
|
|
73
|
+
return (
|
|
74
|
+
(message as any).own_reactions?.some((r: any) => r.type === type) ||
|
|
75
|
+
(message as any).latest_reactions?.some(
|
|
76
|
+
(r: any) => r.type === type && (r.user?.id === currentUserId || (r as any).user_id === currentUserId)
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
[message, currentUserId]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const handleMoreClick = useCallback(
|
|
84
|
+
(e: React.MouseEvent) => {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
e.stopPropagation();
|
|
87
|
+
if (containerRef.current) {
|
|
88
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
89
|
+
setPickerPosition(rect.top < 388 ? 'bottom' : 'top');
|
|
90
|
+
}
|
|
91
|
+
setShowPicker((prev) => !prev);
|
|
92
|
+
setIsExpanded(false);
|
|
93
|
+
},
|
|
94
|
+
[]
|
|
95
|
+
);
|
|
96
|
+
|
|
69
97
|
return (
|
|
70
|
-
<
|
|
71
|
-
ref={containerRef}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
width: isExpanded ? 350 : undefined,
|
|
79
|
-
height: isExpanded ? 368 : undefined,
|
|
80
|
-
borderRadius: isExpanded ? 16 : 20,
|
|
81
|
-
backgroundColor: isExpanded ? 'var(--ermis-bg-primary)' : undefined,
|
|
82
|
-
border: isExpanded ? '1px solid var(--ermis-border)' : undefined,
|
|
83
|
-
boxShadow: isExpanded ? '0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)' : undefined,
|
|
84
|
-
zIndex: isExpanded ? 101 : 20,
|
|
98
|
+
<div
|
|
99
|
+
ref={containerRef}
|
|
100
|
+
className="ermis-qr-wrapper"
|
|
101
|
+
style={{ position: 'relative', display: 'inline-flex' }}
|
|
102
|
+
onMouseLeave={() => {
|
|
103
|
+
if (!showPicker) {
|
|
104
|
+
setIsExpanded(false);
|
|
105
|
+
}
|
|
85
106
|
}}
|
|
86
107
|
>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
(r: any) => r.type === type && (r.user?.id === currentUserId || (r as any).user_id === currentUserId)
|
|
102
|
-
);
|
|
108
|
+
{/* Trigger button (looks like other action buttons) */}
|
|
109
|
+
<button
|
|
110
|
+
className={`ermis-message-list__actions-trigger ${isExpanded || showPicker ? 'ermis-message-list__actions-trigger--active' : ''}`}
|
|
111
|
+
onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (!disabled) { setIsExpanded(!isExpanded); setShowPicker(false); } }}
|
|
112
|
+
title="Add reaction"
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
>
|
|
115
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
116
|
+
<circle cx="12" cy="12" r="10" />
|
|
117
|
+
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
|
118
|
+
<line x1="9" y1="9" x2="9.01" y2="9" />
|
|
119
|
+
<line x1="15" y1="9" x2="15.01" y2="9" />
|
|
120
|
+
</svg>
|
|
121
|
+
</button>
|
|
103
122
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
isOwn ? 'ermis-message-quick-reactions__btn--active' : ''
|
|
109
|
-
}`}
|
|
110
|
-
title={type}
|
|
111
|
-
onClick={(e) => {
|
|
112
|
-
e.preventDefault();
|
|
113
|
-
e.stopPropagation();
|
|
114
|
-
handleReactionToggle(type);
|
|
115
|
-
}}
|
|
116
|
-
>
|
|
117
|
-
{EMOJI_MAP[type]}
|
|
118
|
-
</button>
|
|
119
|
-
);
|
|
120
|
-
})}
|
|
121
|
-
|
|
123
|
+
{/* Horizontal Strip */}
|
|
124
|
+
{isExpanded && (
|
|
125
|
+
<div className="ermis-qr__strip ermis-qr__strip--horizontal" onClick={(e) => e.stopPropagation()}>
|
|
126
|
+
{REACTIONS.map((type) => (
|
|
122
127
|
<button
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
e.stopPropagation();
|
|
128
|
-
setIsExpanded(true);
|
|
129
|
-
}}
|
|
128
|
+
key={type}
|
|
129
|
+
className={`ermis-qr__emoji ${isOwnReaction(type) ? 'ermis-qr__emoji--active' : ''}`}
|
|
130
|
+
title={type}
|
|
131
|
+
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleReactionToggle(type); setIsExpanded(false); }}
|
|
130
132
|
>
|
|
131
|
-
|
|
132
|
-
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
133
|
-
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
134
|
-
</svg>
|
|
133
|
+
{EMOJI_MAP[type]}
|
|
135
134
|
</button>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
animate={{ opacity: 1, scale: 1 }}
|
|
142
|
-
exit={{ opacity: 0, scale: 0.95 }}
|
|
143
|
-
transition={{ duration: 0.2 }}
|
|
144
|
-
style={{ width: '100%', height: '100%' }}
|
|
145
|
-
onClick={(e) => e.stopPropagation()}
|
|
135
|
+
))}
|
|
136
|
+
<button
|
|
137
|
+
className="ermis-qr__emoji ermis-qr__emoji--more"
|
|
138
|
+
title="More reactions"
|
|
139
|
+
onClick={handleMoreClick}
|
|
146
140
|
>
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
141
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none">
|
|
142
|
+
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
|
|
143
|
+
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
|
144
|
+
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
|
|
145
|
+
</svg>
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* Full emoji picker */}
|
|
151
|
+
{showPicker && (
|
|
152
|
+
<div
|
|
153
|
+
className={`ermis-qr__picker ermis-qr__picker--${pickerPosition} ${isOwnMessage ? 'ermis-qr__picker--own' : ''}`}
|
|
154
|
+
onClick={(e) => e.stopPropagation()}
|
|
155
|
+
>
|
|
156
|
+
<EmojiPickerRoot
|
|
157
|
+
className="isolate flex h-full w-full flex-col bg-white dark:bg-[#1a1828]"
|
|
158
|
+
locale="vi"
|
|
159
|
+
onEmojiSelect={(emoji: any) => {
|
|
160
|
+
handleReactionToggle(emoji.emoji);
|
|
161
|
+
setShowPicker(false);
|
|
162
|
+
setIsExpanded(false);
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<EmojiPickerSearch className="z-10 mx-3 mt-3 appearance-none rounded-xl bg-zinc-100 px-3 py-2 text-sm dark:bg-zinc-800 dark:text-zinc-100 outline-none focus:ring-2 focus:ring-primary/50" />
|
|
166
|
+
<EmojiPickerViewport className="relative flex-1 outline-hidden mt-2">
|
|
167
|
+
<EmojiPickerLoading className="absolute inset-0 flex items-center justify-center text-zinc-400 text-sm dark:text-zinc-500">
|
|
168
|
+
Đang tải…
|
|
169
|
+
</EmojiPickerLoading>
|
|
170
|
+
<EmojiPickerEmpty className="absolute inset-0 flex items-center justify-center text-zinc-400 text-sm dark:text-zinc-500">
|
|
171
|
+
Không tìm thấy emoji.
|
|
172
|
+
</EmojiPickerEmpty>
|
|
173
|
+
<EmojiPickerList
|
|
174
|
+
className="select-none pb-1.5"
|
|
175
|
+
components={{
|
|
176
|
+
CategoryHeader: ({ category, ...props }: any) => (
|
|
177
|
+
<div className="bg-white/90 px-3 pt-3 pb-1.5 font-semibold text-zinc-500 text-xs dark:bg-[#1a1828]/90 dark:text-zinc-400 backdrop-blur-md" {...props}>
|
|
178
|
+
{category.label}
|
|
179
|
+
</div>
|
|
180
|
+
),
|
|
181
|
+
Row: ({ children, ...props }: any) => (
|
|
182
|
+
<div className="scroll-my-1.5 px-2 flex justify-between" {...props}>
|
|
183
|
+
{children as React.ReactNode}
|
|
184
|
+
</div>
|
|
185
|
+
),
|
|
186
|
+
Emoji: ({ emoji, ...props }: any) => {
|
|
187
|
+
const { formAction, ...safeProps } = props;
|
|
188
|
+
return (
|
|
189
|
+
<button className="flex size-9 items-center justify-center rounded-lg text-xl hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors data-[active]:bg-zinc-100 dark:data-[active]:bg-zinc-800" {...safeProps}>
|
|
190
|
+
{emoji.emoji}
|
|
191
|
+
</button>
|
|
192
|
+
);
|
|
193
|
+
},
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
</EmojiPickerViewport>
|
|
197
|
+
</EmojiPickerRoot>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
198
201
|
);
|
|
199
202
|
});
|
|
200
203
|
|