@ermis-network/ermis-chat-react 1.0.9 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +15288 -4203
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +701 -195
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +862 -94
- package/dist/index.d.ts +862 -94
- package/dist/index.mjs +15238 -4179
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -2
- package/src/components/ChannelActions.tsx +61 -2
- package/src/components/ChannelHeader.tsx +19 -5
- package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
- package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +177 -290
- package/src/components/CreateChannelModal.tsx +166 -88
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FlatTopicGroupItem.tsx +232 -0
- package/src/components/ForwardMessageModal.tsx +31 -77
- package/src/components/MediaLightbox.tsx +62 -40
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +4 -1
- package/src/components/MessageInput.tsx +126 -7
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +93 -26
- package/src/components/MessageQuickReactions.tsx +153 -26
- package/src/components/MessageReactions.tsx +2 -1
- package/src/components/MessageRenderers.tsx +111 -39
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +17 -5
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/TopicList.tsx +221 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +14 -5
- package/src/components/UserPicker.tsx +87 -10
- package/src/components/VirtualMessageList.tsx +106 -20
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +18 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +72 -20
- package/src/hooks/useChannelMessages.ts +72 -10
- package/src/hooks/useChannelRowUpdates.ts +24 -5
- package/src/hooks/useChatUser.ts +31 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useForwardMessage.ts +112 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useMentions.ts +0 -1
- package/src/hooks/useMessageActions.ts +13 -10
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +197 -0
- package/src/index.ts +56 -6
- package/src/messageTypeUtils.ts +13 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +41 -4
- package/src/styles/_channel-list.css +97 -57
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +32 -0
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +286 -107
- package/src/styles/_message-input.css +131 -0
- package/src/styles/_message-list.css +33 -17
- package/src/styles/_message-quick-reactions.css +40 -9
- package/src/styles/_message-reactions.css +4 -0
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_tokens.css +17 -15
- package/src/styles/_typing-indicator.css +7 -1
- package/src/styles/index.css +1 -0
- package/src/types.ts +362 -14
- package/src/utils/avatarColors.ts +48 -0
- package/src/utils.ts +193 -10
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import React, { useCallback } from 'react';
|
|
1
|
+
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'motion/react';
|
|
3
|
+
import { EmojiPicker } from 'frimousse';
|
|
2
4
|
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
3
5
|
import { useChatClient } from '../hooks/useChatClient';
|
|
4
6
|
|
|
7
|
+
const EmojiPickerRoot = EmojiPicker.Root as any;
|
|
8
|
+
const EmojiPickerSearch = EmojiPicker.Search as any;
|
|
9
|
+
const EmojiPickerViewport = EmojiPicker.Viewport as any;
|
|
10
|
+
const EmojiPickerLoading = EmojiPicker.Loading as any;
|
|
11
|
+
const EmojiPickerEmpty = EmojiPicker.Empty as any;
|
|
12
|
+
const EmojiPickerList = EmojiPicker.List as any;
|
|
13
|
+
|
|
5
14
|
const QUICK_REACTIONS = ['like', 'love', 'haha', 'sad', 'fire'];
|
|
6
15
|
const EMOJI_MAP: Record<string, string> = {
|
|
7
16
|
like: '👍',
|
|
@@ -15,9 +24,25 @@ export const MessageQuickReactions: React.FC<{
|
|
|
15
24
|
message: FormatMessageResponse;
|
|
16
25
|
isOwnMessage: boolean;
|
|
17
26
|
disabled?: boolean;
|
|
18
|
-
|
|
27
|
+
onAddReactionClick?: (e: React.MouseEvent, messageId: string) => void;
|
|
28
|
+
}> = React.memo(({ message, isOwnMessage, disabled, onAddReactionClick }) => {
|
|
19
29
|
const { activeChannel, client } = useChatClient();
|
|
20
30
|
const currentUserId = client?.userID;
|
|
31
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
32
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
|
|
34
|
+
// Close when clicking outside
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
37
|
+
if (isExpanded && containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
38
|
+
setIsExpanded(false);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
if (isExpanded) {
|
|
42
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
43
|
+
}
|
|
44
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
45
|
+
}, [isExpanded]);
|
|
21
46
|
|
|
22
47
|
const handleReactionToggle = useCallback(
|
|
23
48
|
async (type: string) => {
|
|
@@ -42,32 +67,134 @@ export const MessageQuickReactions: React.FC<{
|
|
|
42
67
|
);
|
|
43
68
|
|
|
44
69
|
return (
|
|
45
|
-
<div
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
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
|
+
);
|
|
52
103
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
|
|
122
|
+
<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
|
+
}}
|
|
130
|
+
>
|
|
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>
|
|
135
|
+
</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()}
|
|
65
146
|
>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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>
|
|
71
198
|
);
|
|
72
199
|
});
|
|
73
200
|
|
|
@@ -17,6 +17,7 @@ export const MessageReactions: React.FC<MessageReactionsProps> = React.memo(({
|
|
|
17
17
|
latestReactions,
|
|
18
18
|
onClickReaction,
|
|
19
19
|
disabled,
|
|
20
|
+
isOwnMessage,
|
|
20
21
|
}) => {
|
|
21
22
|
const { client } = useChatClient();
|
|
22
23
|
const currentUserId = client?.userID;
|
|
@@ -24,7 +25,7 @@ export const MessageReactions: React.FC<MessageReactionsProps> = React.memo(({
|
|
|
24
25
|
if (!reactionCounts || Object.keys(reactionCounts).length === 0) return null;
|
|
25
26
|
|
|
26
27
|
return (
|
|
27
|
-
<div className={`ermis-message-reactions${disabled ? ' ermis-message-reactions--disabled' : ''}`}>
|
|
28
|
+
<div className={`ermis-message-reactions${disabled ? ' ermis-message-reactions--disabled' : ''}${isOwnMessage ? ' ermis-message-reactions--own' : ''}`}>
|
|
28
29
|
{Object.entries(reactionCounts).map(([type, count]) => {
|
|
29
30
|
const isOwn =
|
|
30
31
|
ownReactions?.some((r) => r.type === type) ||
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React, { useState, useMemo, useCallback } from 'react';
|
|
2
|
-
import { preloadImage, isImagePreloaded } from '../utils';
|
|
2
|
+
import { preloadImage, isImagePreloaded, formatTime } from '../utils';
|
|
3
3
|
import type { FormatMessageResponse, Attachment, MessageLabel } from '@ermis-network/ermis-chat-sdk';
|
|
4
4
|
import { parseSystemMessage, parseSignalMessage, CallType } from '@ermis-network/ermis-chat-sdk';
|
|
5
5
|
import { useChatClient } from '../hooks/useChatClient';
|
|
6
|
+
import { useDownloadHandler } from '../hooks/useDownloadHandler';
|
|
6
7
|
import { buildUserMap } from '../utils';
|
|
7
8
|
import { MediaLightbox } from './MediaLightbox';
|
|
8
9
|
import { getFileIcon } from './ChannelInfo/utils';
|
|
@@ -198,27 +199,14 @@ const FileAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =>
|
|
|
198
199
|
const size = attachment.file_size;
|
|
199
200
|
const mimeType = attachment.mime_type || attachment.type || '';
|
|
200
201
|
const ext = name.split('.').pop()?.toUpperCase() || 'FILE';
|
|
201
|
-
|
|
202
|
+
|
|
203
|
+
const { downloadFile } = useDownloadHandler();
|
|
202
204
|
|
|
203
205
|
const handleDownload = useCallback(async (e: React.MouseEvent) => {
|
|
204
206
|
e.preventDefault();
|
|
205
207
|
e.stopPropagation();
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
try {
|
|
209
|
-
const blob = await client.downloadMedia(url);
|
|
210
|
-
const urlBlob = window.URL.createObjectURL(blob);
|
|
211
|
-
const a = document.createElement('a');
|
|
212
|
-
a.href = urlBlob;
|
|
213
|
-
a.download = name;
|
|
214
|
-
document.body.appendChild(a);
|
|
215
|
-
a.click();
|
|
216
|
-
a.remove();
|
|
217
|
-
window.URL.revokeObjectURL(urlBlob);
|
|
218
|
-
} catch {
|
|
219
|
-
window.open(url, '_blank', 'noopener,noreferrer');
|
|
220
|
-
}
|
|
221
|
-
}, [client, url, name]);
|
|
208
|
+
await downloadFile(url, name);
|
|
209
|
+
}, [downloadFile, url, name]);
|
|
222
210
|
|
|
223
211
|
return (
|
|
224
212
|
<div className="ermis-attachment ermis-attachment--file">
|
|
@@ -254,6 +242,87 @@ const FileAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =>
|
|
|
254
242
|
});
|
|
255
243
|
(FileAttachment as any).displayName = 'FileAttachment';
|
|
256
244
|
|
|
245
|
+
const PlayIcon = () => (
|
|
246
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
247
|
+
<path d="M8 5v14l11-7z" />
|
|
248
|
+
</svg>
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const PauseIcon = () => (
|
|
252
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
253
|
+
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
254
|
+
</svg>
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const MicIcon = () => (
|
|
258
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
259
|
+
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
|
260
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
|
261
|
+
<line x1="12" x2="12" y1="19" y2="22" />
|
|
262
|
+
</svg>
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const CustomAudioPlayer: React.FC<{ src: string; durationLabel: string }> = ({ src, durationLabel }) => {
|
|
266
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
267
|
+
const [progress, setProgress] = useState(0);
|
|
268
|
+
const audioRef = React.useRef<HTMLAudioElement>(null);
|
|
269
|
+
|
|
270
|
+
React.useEffect(() => {
|
|
271
|
+
const audio = audioRef.current;
|
|
272
|
+
if (!audio) return;
|
|
273
|
+
const updateProgress = () => {
|
|
274
|
+
setProgress((audio.currentTime / audio.duration) * 100 || 0);
|
|
275
|
+
};
|
|
276
|
+
const onEnded = () => {
|
|
277
|
+
setIsPlaying(false);
|
|
278
|
+
setProgress(0);
|
|
279
|
+
};
|
|
280
|
+
audio.addEventListener('timeupdate', updateProgress);
|
|
281
|
+
audio.addEventListener('ended', onEnded);
|
|
282
|
+
return () => {
|
|
283
|
+
audio.removeEventListener('timeupdate', updateProgress);
|
|
284
|
+
audio.removeEventListener('ended', onEnded);
|
|
285
|
+
};
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
288
|
+
const togglePlay = () => {
|
|
289
|
+
if (audioRef.current) {
|
|
290
|
+
if (isPlaying) {
|
|
291
|
+
audioRef.current.pause();
|
|
292
|
+
} else {
|
|
293
|
+
audioRef.current.play().catch(e => console.error(e));
|
|
294
|
+
}
|
|
295
|
+
setIsPlaying(!isPlaying);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
300
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
301
|
+
const x = e.clientX - rect.left;
|
|
302
|
+
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
|
303
|
+
if (audioRef.current && audioRef.current.duration) {
|
|
304
|
+
audioRef.current.currentTime = percentage * audioRef.current.duration;
|
|
305
|
+
setProgress(percentage * 100);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<div className="ermis-custom-audio-player">
|
|
311
|
+
<button className="ermis-custom-audio-play-btn" onClick={togglePlay} aria-label={isPlaying ? "Pause" : "Play"}>
|
|
312
|
+
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
|
313
|
+
</button>
|
|
314
|
+
<div className="ermis-custom-audio-progress-container">
|
|
315
|
+
<div className="ermis-custom-audio-progress-bg" onClick={handleSeek}>
|
|
316
|
+
<div className="ermis-custom-audio-progress-fill" style={{ width: `${progress}%` }} />
|
|
317
|
+
<div className="ermis-custom-audio-progress-thumb" style={{ left: `${progress}%` }} />
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
<span className="ermis-custom-audio-duration">{durationLabel}</span>
|
|
321
|
+
<audio ref={audioRef} src={src} preload="metadata" className="ermis-custom-audio-hidden" />
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
};
|
|
325
|
+
|
|
257
326
|
const VoiceRecordingAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
|
|
258
327
|
const src = attachment.asset_url || attachment.url;
|
|
259
328
|
if (!src) return null;
|
|
@@ -263,13 +332,7 @@ const VoiceRecordingAttachment: React.FC<AttachmentProps> = React.memo(({ attach
|
|
|
263
332
|
const secs = Math.round(durationSec % 60);
|
|
264
333
|
const durationLabel = `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
265
334
|
|
|
266
|
-
return
|
|
267
|
-
<div className="ermis-attachment ermis-attachment--voice">
|
|
268
|
-
<span className="ermis-attachment__voice-icon">🎙️</span>
|
|
269
|
-
<audio src={src} controls preload="metadata" className="ermis-attachment__voice-player" />
|
|
270
|
-
<span className="ermis-attachment__voice-duration">{durationLabel}</span>
|
|
271
|
-
</div>
|
|
272
|
-
);
|
|
335
|
+
return <CustomAudioPlayer src={src} durationLabel={durationLabel} />;
|
|
273
336
|
}, (prev, next) => {
|
|
274
337
|
return (prev.attachment.asset_url || prev.attachment.url) ===
|
|
275
338
|
(next.attachment.asset_url || next.attachment.url);
|
|
@@ -479,6 +542,7 @@ function renderTextWithMentions(
|
|
|
479
542
|
text: string,
|
|
480
543
|
message: FormatMessageResponse,
|
|
481
544
|
userMap: Record<string, string>,
|
|
545
|
+
onMentionClick?: (userId: string) => void,
|
|
482
546
|
): React.ReactNode {
|
|
483
547
|
const mentionedUsers: string[] = (message as any).mentioned_users ?? [];
|
|
484
548
|
const mentionedAll: boolean = (message as any).mentioned_all ?? false;
|
|
@@ -489,17 +553,18 @@ function renderTextWithMentions(
|
|
|
489
553
|
}
|
|
490
554
|
|
|
491
555
|
// Build a list of patterns to replace: @userId → @userName
|
|
492
|
-
const replacements: { pattern: string; label: string }[] = [];
|
|
556
|
+
const replacements: { pattern: string; label: string; id: string }[] = [];
|
|
493
557
|
|
|
494
558
|
for (const userId of mentionedUsers) {
|
|
495
559
|
replacements.push({
|
|
496
560
|
pattern: `@${userId}`,
|
|
497
561
|
label: `@${userMap[userId] ?? userId}`,
|
|
562
|
+
id: userId,
|
|
498
563
|
});
|
|
499
564
|
}
|
|
500
565
|
|
|
501
566
|
if (mentionedAll) {
|
|
502
|
-
replacements.push({ pattern: '@all', label: '@all' });
|
|
567
|
+
replacements.push({ pattern: '@all', label: '@all', id: 'all' });
|
|
503
568
|
}
|
|
504
569
|
|
|
505
570
|
// Build a regex that matches any of the mention patterns
|
|
@@ -511,15 +576,19 @@ function renderTextWithMentions(
|
|
|
511
576
|
const parts = text.split(regex);
|
|
512
577
|
|
|
513
578
|
// Map from pattern → label for quick lookup
|
|
514
|
-
const patternToLabel = new Map(replacements.map((r) => [r.pattern, r
|
|
579
|
+
const patternToLabel = new Map(replacements.map((r) => [r.pattern, r]));
|
|
515
580
|
|
|
516
581
|
return parts.flatMap((part, i) => {
|
|
517
|
-
const
|
|
518
|
-
if (
|
|
582
|
+
const info = patternToLabel.get(part);
|
|
583
|
+
if (info) {
|
|
519
584
|
// Mention — render as span, do NOT linkify
|
|
520
585
|
return (
|
|
521
|
-
<span
|
|
522
|
-
{
|
|
586
|
+
<span
|
|
587
|
+
key={`mention-${i}`}
|
|
588
|
+
className={`ermis-mention${onMentionClick && info.id !== 'all' ? ' ermis-mention--clickable' : ''}`}
|
|
589
|
+
onClick={onMentionClick && info.id !== 'all' ? (e) => { e.stopPropagation(); onMentionClick(info.id); } : undefined}
|
|
590
|
+
>
|
|
591
|
+
{info.label}
|
|
523
592
|
</span>
|
|
524
593
|
);
|
|
525
594
|
}
|
|
@@ -529,7 +598,7 @@ function renderTextWithMentions(
|
|
|
529
598
|
}
|
|
530
599
|
|
|
531
600
|
/** Regular message: text with @mentions + attachments */
|
|
532
|
-
export const RegularMessage: React.FC<MessageRendererProps> = React.memo(({ message }) => {
|
|
601
|
+
export const RegularMessage: React.FC<MessageRendererProps> = React.memo(({ message, onMentionClick }) => {
|
|
533
602
|
const { activeChannel } = useChatClient();
|
|
534
603
|
|
|
535
604
|
const userMap = useMemo<Record<string, string>>(() => {
|
|
@@ -537,7 +606,7 @@ export const RegularMessage: React.FC<MessageRendererProps> = React.memo(({ mess
|
|
|
537
606
|
}, [activeChannel?.state]);
|
|
538
607
|
|
|
539
608
|
const textContent = message.text
|
|
540
|
-
? renderTextWithMentions(message.text, message, userMap)
|
|
609
|
+
? renderTextWithMentions(message.text, message, userMap, onMentionClick)
|
|
541
610
|
: null;
|
|
542
611
|
|
|
543
612
|
const attachmentsToRender = useMemo(() => {
|
|
@@ -582,7 +651,7 @@ export const RegularMessage: React.FC<MessageRendererProps> = React.memo(({ mess
|
|
|
582
651
|
RegularMessage.displayName = 'RegularMessage';
|
|
583
652
|
|
|
584
653
|
/** System message: centered info text, parsed from raw format */
|
|
585
|
-
export const SystemMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
654
|
+
export const SystemMessage: React.FC<MessageRendererProps> = ({ message, systemMessageTranslations }) => {
|
|
586
655
|
const { activeChannel } = useChatClient();
|
|
587
656
|
|
|
588
657
|
const userMap = useMemo<Record<string, string>>(() => {
|
|
@@ -590,8 +659,8 @@ export const SystemMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
|
590
659
|
}, [activeChannel?.state]);
|
|
591
660
|
|
|
592
661
|
const parsedText = useMemo(
|
|
593
|
-
() => (message.text ? parseSystemMessage(message.text, userMap) : ''),
|
|
594
|
-
[message.text, userMap],
|
|
662
|
+
() => (message.text ? parseSystemMessage(message.text, userMap, systemMessageTranslations) : ''),
|
|
663
|
+
[message.text, userMap, systemMessageTranslations],
|
|
595
664
|
);
|
|
596
665
|
|
|
597
666
|
return (
|
|
@@ -602,11 +671,11 @@ export const SystemMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
|
602
671
|
};
|
|
603
672
|
|
|
604
673
|
/** Signal message: call events */
|
|
605
|
-
export const SignalMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
674
|
+
export const SignalMessage: React.FC<MessageRendererProps> = ({ message, signalMessageTranslations }) => {
|
|
606
675
|
const { client } = useChatClient();
|
|
607
676
|
|
|
608
677
|
const rawText = message.text ?? '';
|
|
609
|
-
const result = rawText ? parseSignalMessage(rawText, client.userID || '') : null;
|
|
678
|
+
const result = rawText ? parseSignalMessage(rawText, client.userID || '', signalMessageTranslations) : null;
|
|
610
679
|
|
|
611
680
|
if (!result) {
|
|
612
681
|
return (
|
|
@@ -642,6 +711,9 @@ export const SignalMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
|
642
711
|
<span className="ermis-signal-message__duration">{result.duration}</span>
|
|
643
712
|
)}
|
|
644
713
|
</div>
|
|
714
|
+
<span className="ermis-signal-message__time">
|
|
715
|
+
{formatTime(message.created_at)}
|
|
716
|
+
</span>
|
|
645
717
|
</div>
|
|
646
718
|
);
|
|
647
719
|
};
|
package/src/components/Panel.tsx
CHANGED
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useRef } from 'react';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
/** Whether the panel is visible */
|
|
5
|
-
isOpen: boolean;
|
|
6
|
-
/** Called when user clicks the back button */
|
|
7
|
-
onClose: () => void;
|
|
8
|
-
/** Panel title shown in the header */
|
|
9
|
-
title?: string;
|
|
10
|
-
/** Panel body content */
|
|
11
|
-
children: React.ReactNode;
|
|
12
|
-
/** Optional header content (replaces default title + back button) */
|
|
13
|
-
headerContent?: React.ReactNode;
|
|
14
|
-
/** Additional CSS class name */
|
|
15
|
-
className?: string;
|
|
16
|
-
};
|
|
3
|
+
import type { PanelProps } from '../types';
|
|
17
4
|
|
|
18
5
|
/**
|
|
19
6
|
* Reusable sliding panel component.
|
|
@@ -15,6 +15,8 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
|
|
|
15
15
|
onClickMessage,
|
|
16
16
|
onUnpin,
|
|
17
17
|
AvatarComponent,
|
|
18
|
+
unpinLabel = 'Unpin message',
|
|
19
|
+
stickerLabel = 'Sticker',
|
|
18
20
|
}) => {
|
|
19
21
|
const { activeChannel } = useChatClient();
|
|
20
22
|
const userName = message.user?.name || message.user_id || 'Unknown';
|
|
@@ -32,7 +34,7 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
|
|
|
32
34
|
const firstAttach = message.attachments![0];
|
|
33
35
|
previewText = firstAttach.title || `${firstAttach.type || 'file'}`;
|
|
34
36
|
} else if (isSticker) {
|
|
35
|
-
previewText =
|
|
37
|
+
previewText = stickerLabel;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
// Convert @userId → @UserName in preview text
|
|
@@ -67,8 +69,8 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
|
|
|
67
69
|
<button
|
|
68
70
|
className="ermis-pinned-messages__unpin-btn"
|
|
69
71
|
onClick={(e) => { e.stopPropagation(); onUnpin?.(message.id); }}
|
|
70
|
-
title=
|
|
71
|
-
aria-label=
|
|
72
|
+
title={unpinLabel}
|
|
73
|
+
aria-label={unpinLabel}
|
|
72
74
|
>
|
|
73
75
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
74
76
|
<line x1="2" y1="2" x2="22" y2="22" />
|
|
@@ -89,6 +91,11 @@ export const PinnedMessages: React.FC<PinnedMessagesProps> = React.memo(({
|
|
|
89
91
|
PinnedMessageItemComponent = DefaultPinnedMessageItem,
|
|
90
92
|
onClickMessage,
|
|
91
93
|
maxCollapsed = 1,
|
|
94
|
+
pinnedMessagesLabel,
|
|
95
|
+
seeAllLabel = 'See all',
|
|
96
|
+
collapseLabel = 'Collapse',
|
|
97
|
+
unpinLabel = 'Unpin message',
|
|
98
|
+
stickerLabel = 'Sticker',
|
|
92
99
|
}) => {
|
|
93
100
|
const { activeChannel, client, messages } = useChatClient();
|
|
94
101
|
const [expanded, setExpanded] = useState(false);
|
|
@@ -134,14 +141,17 @@ export const PinnedMessages: React.FC<PinnedMessagesProps> = React.memo(({
|
|
|
134
141
|
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
|
|
135
142
|
</svg>
|
|
136
143
|
<span className="ermis-pinned-messages__label">
|
|
137
|
-
{
|
|
144
|
+
{typeof pinnedMessagesLabel === 'function'
|
|
145
|
+
? pinnedMessagesLabel(pinnedMessages.length)
|
|
146
|
+
: pinnedMessagesLabel || `${pinnedMessages.length} pinned message${pinnedMessages.length > 1 ? 's' : ''}`
|
|
147
|
+
}
|
|
138
148
|
</span>
|
|
139
149
|
{hasMore && (
|
|
140
150
|
<button
|
|
141
151
|
className="ermis-pinned-messages__toggle"
|
|
142
152
|
onClick={(e) => { e.stopPropagation(); toggleExpanded(); }}
|
|
143
153
|
>
|
|
144
|
-
{expanded ?
|
|
154
|
+
{expanded ? collapseLabel : seeAllLabel}
|
|
145
155
|
</button>
|
|
146
156
|
)}
|
|
147
157
|
</div>
|
|
@@ -156,6 +166,8 @@ export const PinnedMessages: React.FC<PinnedMessagesProps> = React.memo(({
|
|
|
156
166
|
onClickMessage={onClickMessage}
|
|
157
167
|
onUnpin={handleUnpin}
|
|
158
168
|
AvatarComponent={AvatarComponent}
|
|
169
|
+
unpinLabel={unpinLabel}
|
|
170
|
+
stickerLabel={stickerLabel}
|
|
159
171
|
/>
|
|
160
172
|
))}
|
|
161
173
|
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { PreviewOverlayProps } from '../types';
|
|
3
|
+
|
|
4
|
+
export const PreviewOverlay: React.FC<PreviewOverlayProps> = ({
|
|
5
|
+
title = 'You are viewing a public channel.',
|
|
6
|
+
buttonLabel = 'Join Channel',
|
|
7
|
+
onJoin,
|
|
8
|
+
className = '',
|
|
9
|
+
}) => {
|
|
10
|
+
return (
|
|
11
|
+
<div className={`ermis-preview-overlay ${className}`}>
|
|
12
|
+
<span className="ermis-preview-overlay__text">{title}</span>
|
|
13
|
+
<button
|
|
14
|
+
className="ermis-preview-overlay__button"
|
|
15
|
+
onClick={onJoin}
|
|
16
|
+
type="button"
|
|
17
|
+
>
|
|
18
|
+
{buttonLabel}
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
PreviewOverlay.displayName = 'PreviewOverlay';
|
|
@@ -41,6 +41,7 @@ export const ReadReceipts: React.FC<ReadReceiptsProps> = React.memo(({
|
|
|
41
41
|
AvatarComponent = Avatar,
|
|
42
42
|
TooltipComponent = DefaultReadReceiptsTooltip,
|
|
43
43
|
showTooltip = true,
|
|
44
|
+
isOwnMessage,
|
|
44
45
|
}) => {
|
|
45
46
|
// Only render when there are actual readers (avatar-based display)
|
|
46
47
|
// Sent/Sending/Error status icons are now rendered inline inside the message bubble
|
|
@@ -52,7 +53,7 @@ export const ReadReceipts: React.FC<ReadReceiptsProps> = React.memo(({
|
|
|
52
53
|
const overflow = readers.length - maxAvatars;
|
|
53
54
|
|
|
54
55
|
return (
|
|
55
|
-
<div className=
|
|
56
|
+
<div className={`ermis-read-receipts${isOwnMessage ? ' ermis-read-receipts--own' : ' ermis-read-receipts--other'}`}>
|
|
56
57
|
<div className="ermis-read-receipts__avatars">
|
|
57
58
|
{visible.map((reader) => (
|
|
58
59
|
<AvatarComponent
|