@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.
- package/README.md +144 -0
- package/dist/index.cjs +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- 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 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- 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 +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- 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/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -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 +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -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/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- 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
|
|
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-
|
|
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:
|
|
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
|
-
{
|
|
141
|
-
? <AvatarComponent image={userAvatar} name={userName} size={
|
|
142
|
-
: <div style={{ width:
|
|
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
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
|
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
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|