@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.
Files changed (72) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +5087 -11279
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +632 -152
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +273 -9
  7. package/dist/index.d.ts +273 -9
  8. package/dist/index.mjs +5085 -11295
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/components/Channel.tsx +0 -3
  12. package/src/components/ChannelActions.tsx +6 -1
  13. package/src/components/ChannelHeader.tsx +8 -32
  14. package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
  17. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
  18. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
  19. package/src/components/ChannelList.tsx +72 -13
  20. package/src/components/CreateChannelModal.tsx +131 -12
  21. package/src/components/FilesPreview.tsx +8 -12
  22. package/src/components/FlatTopicGroupItem.tsx +27 -16
  23. package/src/components/ForwardMessageModal.tsx +11 -3
  24. package/src/components/MediaLightbox.tsx +444 -304
  25. package/src/components/MessageActionsBox.tsx +2 -0
  26. package/src/components/MessageInput.tsx +41 -12
  27. package/src/components/MessageItem.tsx +70 -25
  28. package/src/components/MessageQuickReactions.tsx +131 -128
  29. package/src/components/MessageReactions.tsx +47 -2
  30. package/src/components/MessageRenderers.tsx +1030 -433
  31. package/src/components/PinnedMessages.tsx +40 -12
  32. package/src/components/QuotedMessagePreview.tsx +99 -8
  33. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  34. package/src/components/RecoveryPin/index.ts +19 -0
  35. package/src/components/TopicList.tsx +20 -5
  36. package/src/components/TypingIndicator.tsx +3 -3
  37. package/src/components/UserPicker.tsx +26 -25
  38. package/src/components/VirtualMessageList.tsx +345 -125
  39. package/src/context/ChatProvider.tsx +27 -1
  40. package/src/hooks/useChannelListUpdates.ts +22 -1
  41. package/src/hooks/useChannelMessages.ts +338 -51
  42. package/src/hooks/useChannelRowUpdates.ts +18 -6
  43. package/src/hooks/useChatUser.ts +9 -1
  44. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  45. package/src/hooks/useE2eeFileUpload.ts +38 -0
  46. package/src/hooks/useFileUpload.ts +25 -5
  47. package/src/hooks/useForwardMessage.ts +210 -13
  48. package/src/hooks/useLoadMessages.ts +16 -4
  49. package/src/hooks/useMentions.ts +60 -6
  50. package/src/hooks/useMessageActions.ts +14 -8
  51. package/src/hooks/useMessageSend.ts +64 -12
  52. package/src/hooks/usePendingE2eeSends.ts +29 -0
  53. package/src/hooks/useRecoveryPin.ts +287 -0
  54. package/src/hooks/useScrollToMessage.ts +29 -4
  55. package/src/hooks/useTopicGroupUpdates.ts +49 -11
  56. package/src/index.ts +23 -0
  57. package/src/messageTypeUtils.ts +14 -0
  58. package/src/styles/_channel-info.css +9 -0
  59. package/src/styles/_channel-list.css +37 -14
  60. package/src/styles/_media-lightbox.css +36 -3
  61. package/src/styles/_message-bubble.css +381 -41
  62. package/src/styles/_message-input.css +8 -0
  63. package/src/styles/_message-list.css +67 -10
  64. package/src/styles/_message-quick-reactions.css +101 -59
  65. package/src/styles/_message-reactions.css +18 -32
  66. package/src/styles/_recovery-pin.css +97 -0
  67. package/src/styles/_tokens.css +5 -5
  68. package/src/styles/_typing-indicator.css +23 -13
  69. package/src/styles/index.css +1 -0
  70. package/src/types.ts +115 -1
  71. package/src/utils/avatarColors.ts +1 -1
  72. 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
- setFiles((prev) => {
345
- prev.forEach((f) => {
346
- if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
347
- });
348
- return [];
349
- });
350
- setHasContent(false);
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 quotedMessage = (message as any).quoted_message;
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
- {isFirstInGroup
157
- ? <AvatarComponent image={userAvatar} name={userName} size={28} />
158
- : <div style={{ width: 28 }} />
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: show avatar only on first message, otherwise placeholder for alignment */}
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
- {isFirstInGroup
191
- ? <AvatarComponent image={userAvatar} name={userName} size={28} />
192
- : <div style={{ width: 28 }} />
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 QUICK_REACTIONS = ['like', 'love', 'haha', 'sad', 'fire'];
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
- onAddReactionClick?: (e: React.MouseEvent, messageId: string) => void;
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 (isExpanded && containerRef.current && !containerRef.current.contains(e.target as Node)) {
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
- <motion.div
71
- ref={containerRef}
72
- layout
73
- transition={{ type: "spring", stiffness: 350, damping: 30 }}
74
- className={`ermis-message-quick-reactions ${isOwnMessage ? 'ermis-message-quick-reactions--own' : ''} ${disabled ? 'ermis-message-quick-reactions--disabled' : ''} ${isExpanded ? 'ermis-message-quick-reactions--expanded' : ''}`}
75
- style={{
76
- overflow: 'hidden',
77
- padding: isExpanded ? 0 : undefined,
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
- <AnimatePresence mode="popLayout">
88
- {!isExpanded ? (
89
- <motion.div
90
- key="quick-reactions"
91
- initial={{ opacity: 0 }}
92
- animate={{ opacity: 1 }}
93
- exit={{ opacity: 0, scale: 0.95 }}
94
- transition={{ duration: 0.15 }}
95
- style={{ display: 'flex', alignItems: 'center', gap: '2px' }}
96
- >
97
- {QUICK_REACTIONS.map((type) => {
98
- const isOwn =
99
- (message as any).own_reactions?.some((r: any) => r.type === type) ||
100
- (message as any).latest_reactions?.some(
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
- return (
105
- <button
106
- key={type}
107
- className={`ermis-message-quick-reactions__btn ${
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
- className="ermis-message-quick-reactions__btn ermis-message-quick-reactions__btn--more"
124
- title="More reactions"
125
- onClick={(e) => {
126
- e.preventDefault();
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
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-zinc-500">
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
- </motion.div>
137
- ) : (
138
- <motion.div
139
- key="full-picker"
140
- initial={{ opacity: 0, scale: 0.95 }}
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
- <EmojiPickerRoot
148
- className="isolate flex h-full w-full flex-col bg-white dark:bg-[#1a1828]"
149
- locale="vi"
150
- onEmojiSelect={(emoji: any) => {
151
- handleReactionToggle(emoji.emoji);
152
- setIsExpanded(false);
153
- }}
154
- >
155
- <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" />
156
- <EmojiPickerViewport className="relative flex-1 outline-hidden mt-2">
157
- <EmojiPickerLoading className="absolute inset-0 flex items-center justify-center text-zinc-400 text-sm dark:text-zinc-500">
158
- Đang tải…
159
- </EmojiPickerLoading>
160
- <EmojiPickerEmpty className="absolute inset-0 flex items-center justify-center text-zinc-400 text-sm dark:text-zinc-500">
161
- Không tìm thấy emoji.
162
- </EmojiPickerEmpty>
163
- <EmojiPickerList
164
- className="select-none pb-1.5"
165
- components={{
166
- CategoryHeader: ({ category, ...props }: any) => (
167
- <div
168
- 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"
169
- {...props}
170
- >
171
- {category.label}
172
- </div>
173
- ),
174
- Row: ({ children, ...props }: any) => (
175
- <div className="scroll-my-1.5 px-2 flex justify-between" {...props}>
176
- {children as React.ReactNode}
177
- </div>
178
- ),
179
- Emoji: ({ emoji, ...props }: any) => {
180
- const { formAction, ...safeProps } = props;
181
- return (
182
- <button
183
- 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"
184
- {...safeProps}
185
- >
186
- {emoji.emoji}
187
- </button>
188
- );
189
- },
190
- }}
191
- />
192
- </EmojiPickerViewport>
193
- </EmojiPickerRoot>
194
- </motion.div>
195
- )}
196
- </AnimatePresence>
197
- </motion.div>
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