@ermis-network/ermis-chat-react 1.0.9 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. package/src/utils.ts +219 -16
@@ -3,11 +3,10 @@ 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';
10
- import { isSystemMessage } from '../messageTypeUtils';
9
+ import { isSystemMessage, isDeletedDisplayMessage, isStickerMessage, isSignalMessage } from '../messageTypeUtils';
11
10
 
12
11
  export type { MessageItemProps, SystemMessageItemProps } from '../types';
13
12
 
@@ -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,
@@ -73,20 +95,39 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
73
95
  MessageReactionsComponent = MessageReactions,
74
96
  forwardedLabel = 'Forwarded',
75
97
  editedLabel = 'Edited',
98
+ deletedMessageLabel = 'This message was deleted',
99
+ attachmentLabel = 'Attachment',
100
+ unavailableMessageLabel = 'Message unavailable',
101
+ stickerLabel = 'Sticker',
102
+ encryptedMessageLabel,
103
+ encryptedMessageFailedLabel,
104
+ encryptedMessageDecryptingLabel,
105
+ systemMessageTranslations,
106
+ signalMessageTranslations,
107
+ onMentionClick,
108
+ onUserNameClick,
109
+ onAddReactionClick,
110
+ hideAvatar,
76
111
  }) => {
77
112
  const { activeChannel, client } = useChatClient();
78
113
  const { hasCapability } = useChannelCapabilities();
79
-
114
+
80
115
  const canReact = hasCapability('send-reaction');
81
116
 
82
117
  const userName = message.user?.name || message.user_id;
83
118
  const userAvatar = message.user?.avatar;
84
119
 
85
- 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;
86
126
  const isForwarded = !!(message as any).forward_cid;
87
127
  const oldTexts = (message as any).old_texts;
88
128
  const isEdited = oldTexts && oldTexts.length > 0;
89
129
  const hasAttachments = message.attachments && message.attachments.length > 0;
130
+ const isDeletedDisplay = isDeletedDisplayMessage(message);
90
131
 
91
132
  const handleReactionToggle = React.useCallback(async (type: string) => {
92
133
  if (!activeChannel || !canReact) return;
@@ -118,34 +159,80 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
118
159
  return Date.now() - new Date(message.created_at).getTime() < 1000;
119
160
  }, [message.created_at]);
120
161
 
162
+ const isSticker = React.useMemo(() => isStickerMessage(message), [message]);
163
+
121
164
  const itemClass = [
122
165
  'ermis-message-list__item',
123
166
  isOwnMessage ? 'ermis-message-list__item--own' : 'ermis-message-list__item--other',
124
- isFirstInGroup ? 'ermis-message-list__item--group-start' : 'ermis-message-list__item--group-cont',
167
+ isFirstInGroup && isLastInGroup ? 'ermis-message-list__item--group-single' : '',
168
+ isFirstInGroup && !isLastInGroup ? 'ermis-message-list__item--group-top' : '',
169
+ !isFirstInGroup && !isLastInGroup ? 'ermis-message-list__item--group-middle' : '',
170
+ !isFirstInGroup && isLastInGroup ? 'ermis-message-list__item--group-bottom' : '',
125
171
  isHighlighted ? 'ermis-message-list__item--highlighted' : '',
126
172
  isNewMessage ? 'ermis-message-list__item--new' : '',
173
+ isDeletedDisplay ? 'ermis-message-list__item--deleted-display' : '',
174
+ isSticker ? 'ermis-message-list__item--sticker' : '',
175
+ isSignalMessage(message) ? 'ermis-message-list__item--signal' : '',
127
176
  statusClass,
128
177
  ].filter(Boolean).join(' ');
129
178
 
130
179
  const contentClass = [
131
180
  'ermis-message-list__item-content',
132
- hasAttachments ? 'ermis-message-list__item-content--has-attachments' : '',
181
+ hasAttachments && !isDeletedDisplay ? 'ermis-message-list__item-content--has-attachments' : '',
133
182
  ].filter(Boolean).join(' ');
134
183
 
184
+ // Deleted display: show icon + label, no actions/reactions/quote/attachments
185
+ if (isDeletedDisplay) {
186
+ return (
187
+ <div className={itemClass} data-message-id={message.id}>
188
+ {!hideAvatar && !isOwnMessage && (
189
+ <div className="ermis-message-list__item-avatar">
190
+ {isLastInGroup
191
+ ? <AvatarComponent image={userAvatar} name={userName} size={36} />
192
+ : <div style={{ width: 36 }} />
193
+ }
194
+ </div>
195
+ )}
196
+ <div className={contentClass}>
197
+ {!isOwnMessage && isFirstInGroup && (
198
+ <span className="ermis-message-list__item-user">{userName}</span>
199
+ )}
200
+ <div className="ermis-message-list__bubble-wrapper">
201
+ <MessageBubble message={message} isOwnMessage={isOwnMessage}>
202
+ <span className="ermis-message-list__deleted-text">
203
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
204
+ <circle cx="12" cy="12" r="10" />
205
+ <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
206
+ </svg>
207
+ {deletedMessageLabel}
208
+ </span>
209
+ <span className="ermis-message-list__item-time">
210
+ {formatTime(message.created_at)}
211
+ </span>
212
+ </MessageBubble>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ );
217
+ }
218
+
135
219
  return (
136
220
  <div className={itemClass} data-message-id={message.id}>
137
- {/* Avatar area: show avatar only on first message, otherwise placeholder for alignment */}
138
- {!isOwnMessage && (
221
+ {/* Avatar area: only render when not hidden by group wrapper */}
222
+ {!hideAvatar && !isOwnMessage && (
139
223
  <div className="ermis-message-list__item-avatar">
140
- {isFirstInGroup
141
- ? <AvatarComponent image={userAvatar} name={userName} size={28} />
142
- : <div style={{ width: 28 }} />
224
+ {isLastInGroup
225
+ ? <AvatarComponent image={userAvatar} name={userName} size={36} />
226
+ : <div style={{ width: 36 }} />
143
227
  }
144
228
  </div>
145
229
  )}
146
230
  <div className={contentClass}>
147
231
  {!isOwnMessage && isFirstInGroup && (
148
- <span className="ermis-message-list__item-user">{userName}</span>
232
+ <span
233
+ className={`ermis-message-list__item-user${onUserNameClick ? ' ermis-message-list__item-user--clickable' : ''}`}
234
+ onClick={onUserNameClick ? (e) => { e.stopPropagation(); const uid = message.user?.id || message.user_id; if (uid) onUserNameClick(uid); } : undefined}
235
+ >{userName}</span>
149
236
  )}
150
237
  {/* Quoted message preview */}
151
238
  {quotedMessage && onClickQuote && (
@@ -153,47 +240,67 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
153
240
  quotedMessage={quotedMessage}
154
241
  isOwnMessage={isOwnMessage}
155
242
  onClick={onClickQuote}
243
+ attachmentLabel={attachmentLabel}
244
+ unavailableMessageLabel={unavailableMessageLabel}
245
+ stickerLabel={stickerLabel}
246
+ deletedMessageLabel={typeof deletedMessageLabel === 'string' ? deletedMessageLabel : 'This message was deleted'}
156
247
  />
157
248
  )}
158
249
  <div className="ermis-message-list__bubble-wrapper">
159
- <MessageQuickReactions message={message} isOwnMessage={isOwnMessage} disabled={!canReact} />
160
250
  <MessageBubble message={message} isOwnMessage={isOwnMessage}>
161
251
  {isForwarded && (
162
252
  <span className="ermis-message-list__forwarded-indicator">{forwardedLabel}</span>
163
253
  )}
164
- <MessageRenderer message={message} isOwnMessage={isOwnMessage} />
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
254
+ <MessageRenderer
182
255
  message={message}
183
256
  isOwnMessage={isOwnMessage}
257
+ systemMessageTranslations={systemMessageTranslations}
258
+ signalMessageTranslations={signalMessageTranslations}
259
+ onMentionClick={onMentionClick}
260
+ encryptedMessageLabel={encryptedMessageLabel}
261
+ encryptedMessageFailedLabel={encryptedMessageFailedLabel}
262
+ encryptedMessageDecryptingLabel={encryptedMessageDecryptingLabel}
184
263
  />
185
- )}
186
264
 
187
- {/* Message Reactions */}
188
- {MessageReactionsComponent && (
189
- <MessageReactionsComponent
190
- reactionCounts={(message as any).reaction_counts}
191
- ownReactions={(message as any).own_reactions}
192
- latestReactions={(message as any).latest_reactions}
193
- onClickReaction={handleReactionToggle}
194
- disabled={!canReact}
195
- />
196
- )}
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 */}
281
+ {!isSignalMessage(message) && (isLastInGroup || isEdited || message.status === 'error' || message.status === 'failed_offline') && (
282
+ <span className="ermis-message-list__item-time">
283
+ {isEdited && (
284
+ <span
285
+ className="ermis-message-list__edited-indicator"
286
+ // data-tooltip={oldTexts.map((ot: any) => `[${formatTime(ot.created_at)}] ${ot.text}`).join('\n')}
287
+ >
288
+ {editedLabel}
289
+ </span>
290
+ )}
291
+ {isLastInGroup && formatTime(message.created_at)}
292
+ <InlineStatusIcon status={message.status} isOwnMessage={isOwnMessage} isLastInGroup={isLastInGroup} />
293
+ </span>
294
+ )}
295
+
296
+ {/* Actions: hover buttons + dropdown menu */}
297
+ {!isSystemMessage(message) && (
298
+ <MessageActionsBoxComponent
299
+ message={message}
300
+ isOwnMessage={isOwnMessage}
301
+ />
302
+ )}
303
+ </MessageBubble>
197
304
  </div>
198
305
  </div>
199
306
  </div>
@@ -208,9 +315,14 @@ export const SystemMessageItem: React.FC<SystemMessageItemProps> = React.memo(({
208
315
  message,
209
316
  isOwnMessage,
210
317
  SystemRenderer,
318
+ systemMessageTranslations,
211
319
  }) => (
212
320
  <div className="ermis-message-list__system">
213
- <SystemRenderer message={message} isOwnMessage={isOwnMessage} />
321
+ <SystemRenderer
322
+ message={message}
323
+ isOwnMessage={isOwnMessage}
324
+ systemMessageTranslations={systemMessageTranslations}
325
+ />
214
326
  </div>
215
327
  ));
216
328
  SystemMessageItem.displayName = 'SystemMessageItem';
@@ -1,8 +1,17 @@
1
- import React, { useCallback } from 'react';
1
+ import React, { useCallback, useState, useRef, useEffect } from 'react';
2
+ import { EmojiPicker } from 'frimousse';
2
3
  import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
3
4
  import { useChatClient } from '../hooks/useChatClient';
4
5
 
5
- const QUICK_REACTIONS = ['like', 'love', 'haha', 'sad', 'fire'];
6
+ const EmojiPickerRoot = EmojiPicker.Root as any;
7
+ const EmojiPickerSearch = EmojiPicker.Search as any;
8
+ const EmojiPickerViewport = EmojiPicker.Viewport as any;
9
+ const EmojiPickerLoading = EmojiPicker.Loading as any;
10
+ const EmojiPickerEmpty = EmojiPicker.Empty as any;
11
+ const EmojiPickerList = EmojiPicker.List as any;
12
+
13
+ const REACTIONS = ['like', 'love', 'haha', 'sad', 'fire'];
14
+
6
15
  const EMOJI_MAP: Record<string, string> = {
7
16
  like: '👍',
8
17
  love: '❤️',
@@ -18,6 +27,24 @@ export const MessageQuickReactions: React.FC<{
18
27
  }> = React.memo(({ message, isOwnMessage, disabled }) => {
19
28
  const { activeChannel, client } = useChatClient();
20
29
  const currentUserId = client?.userID;
30
+ const [isExpanded, setIsExpanded] = useState(false);
31
+ const [showPicker, setShowPicker] = useState(false);
32
+ const [pickerPosition, setPickerPosition] = useState<'top' | 'bottom'>('top');
33
+ const containerRef = useRef<HTMLDivElement>(null);
34
+
35
+ // Close when clicking outside
36
+ useEffect(() => {
37
+ const handleClickOutside = (e: MouseEvent) => {
38
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
39
+ setIsExpanded(false);
40
+ setShowPicker(false);
41
+ }
42
+ };
43
+ if (isExpanded || showPicker) {
44
+ document.addEventListener('mousedown', handleClickOutside);
45
+ }
46
+ return () => document.removeEventListener('mousedown', handleClickOutside);
47
+ }, [isExpanded, showPicker]);
21
48
 
22
49
  const handleReactionToggle = useCallback(
23
50
  async (type: string) => {
@@ -41,32 +68,135 @@ export const MessageQuickReactions: React.FC<{
41
68
  [activeChannel, message, currentUserId]
42
69
  );
43
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
+
44
97
  return (
45
- <div className={`ermis-message-quick-reactions ${isOwnMessage ? 'ermis-message-quick-reactions--own' : ''} ${disabled ? 'ermis-message-quick-reactions--disabled' : ''}`}>
46
- {QUICK_REACTIONS.map((type) => {
47
- const isOwn =
48
- (message as any).own_reactions?.some((r: any) => r.type === type) ||
49
- (message as any).latest_reactions?.some(
50
- (r: any) => r.type === type && (r.user?.id === currentUserId || (r as any).user_id === currentUserId)
51
- );
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
+ }
106
+ }}
107
+ >
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>
52
122
 
53
- return (
123
+ {/* Horizontal Strip */}
124
+ {isExpanded && (
125
+ <div className="ermis-qr__strip ermis-qr__strip--horizontal" onClick={(e) => e.stopPropagation()}>
126
+ {REACTIONS.map((type) => (
127
+ <button
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); }}
132
+ >
133
+ {EMOJI_MAP[type]}
134
+ </button>
135
+ ))}
54
136
  <button
55
- key={type}
56
- className={`ermis-message-quick-reactions__btn ${
57
- isOwn ? 'ermis-message-quick-reactions__btn--active' : ''
58
- }`}
59
- title={type}
60
- onClick={(e) => {
61
- e.preventDefault();
62
- e.stopPropagation();
63
- handleReactionToggle(type);
64
- }}
137
+ className="ermis-qr__emoji ermis-qr__emoji--more"
138
+ title="More reactions"
139
+ onClick={handleMoreClick}
65
140
  >
66
- {EMOJI_MAP[type]}
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>
67
146
  </button>
68
- );
69
- })}
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
+ )}
70
200
  </div>
71
201
  );
72
202
  });
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import type { MessageReactionsProps } from '../types';
3
3
 
4
4
  import { useChatClient } from '../hooks/useChatClient';
5
+ import { createPortal } from 'react-dom';
5
6
 
6
7
  const defaultReactionEmojiMap: Record<string, string> = {
7
8
  like: '👍',
@@ -11,20 +12,61 @@ const defaultReactionEmojiMap: Record<string, string> = {
11
12
  fire: '🔥',
12
13
  };
13
14
 
15
+ const ReactionTooltip = ({ text, rect }: { text: string; rect: DOMRect }) => {
16
+ if (!text || !rect) return null;
17
+ return createPortal(
18
+ <div style={{
19
+ position: 'fixed',
20
+ top: rect.top - 6,
21
+ left: rect.left + rect.width / 2,
22
+ transform: 'translate(-50%, -100%)',
23
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
24
+ color: '#fff',
25
+ padding: '4px 8px',
26
+ borderRadius: '6px',
27
+ fontSize: '11px',
28
+ whiteSpace: 'pre-wrap',
29
+ wordBreak: 'break-word',
30
+ width: 'max-content',
31
+ maxWidth: '200px',
32
+ textAlign: 'center',
33
+ zIndex: 999999,
34
+ pointerEvents: 'none'
35
+ }}>
36
+ {text}
37
+ </div>,
38
+ document.body
39
+ );
40
+ };
41
+
14
42
  export const MessageReactions: React.FC<MessageReactionsProps> = React.memo(({
15
43
  reactionCounts,
16
44
  ownReactions,
17
45
  latestReactions,
18
46
  onClickReaction,
19
47
  disabled,
48
+ isOwnMessage,
20
49
  }) => {
21
50
  const { client } = useChatClient();
22
51
  const currentUserId = client?.userID;
52
+ const [hoveredTooltip, setHoveredTooltip] = React.useState<{text: string, rect: DOMRect} | null>(null);
53
+
54
+ React.useEffect(() => {
55
+ if (hoveredTooltip) {
56
+ const handleHide = () => setHoveredTooltip(null);
57
+ window.addEventListener('scroll', handleHide, true);
58
+ window.addEventListener('resize', handleHide);
59
+ return () => {
60
+ window.removeEventListener('scroll', handleHide, true);
61
+ window.removeEventListener('resize', handleHide);
62
+ };
63
+ }
64
+ }, [hoveredTooltip]);
23
65
 
24
66
  if (!reactionCounts || Object.keys(reactionCounts).length === 0) return null;
25
67
 
26
68
  return (
27
- <div className={`ermis-message-reactions${disabled ? ' ermis-message-reactions--disabled' : ''}`}>
69
+ <div className={`ermis-message-reactions${disabled ? ' ermis-message-reactions--disabled' : ''}${isOwnMessage ? ' ermis-message-reactions--own' : ''}`}>
28
70
  {Object.entries(reactionCounts).map(([type, count]) => {
29
71
  const isOwn =
30
72
  ownReactions?.some((r) => r.type === type) ||
@@ -48,15 +90,19 @@ export const MessageReactions: React.FC<MessageReactionsProps> = React.memo(({
48
90
  className={`ermis-message-reactions__item ${
49
91
  isOwn ? 'ermis-message-reactions__item--active' : ''
50
92
  }`}
51
- data-tooltip={tooltip}
93
+ onMouseEnter={(e) => {
94
+ setHoveredTooltip({ text: tooltip, rect: e.currentTarget.getBoundingClientRect() });
95
+ }}
96
+ onMouseLeave={() => setHoveredTooltip(null)}
52
97
  onClick={() => onClickReaction?.(type)}
53
98
  type="button"
54
99
  >
55
100
  <span className="ermis-message-reactions__emoji">{emoji}</span>
56
- <span className="ermis-message-reactions__count">{count}</span>
101
+ {count > 1 && <span className="ermis-message-reactions__count">{count}</span>}
57
102
  </button>
58
103
  );
59
104
  })}
105
+ {hoveredTooltip && <ReactionTooltip text={hoveredTooltip.text} rect={hoveredTooltip.rect} />}
60
106
  </div>
61
107
  );
62
108
  });