@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.
Files changed (99) hide show
  1. package/dist/index.cjs +15288 -4203
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +701 -195
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +862 -94
  6. package/dist/index.d.ts +862 -94
  7. package/dist/index.mjs +15238 -4179
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -4
  10. package/src/channelTypeUtils.ts +1 -1
  11. package/src/components/Avatar.tsx +2 -1
  12. package/src/components/Channel.tsx +6 -2
  13. package/src/components/ChannelActions.tsx +61 -2
  14. package/src/components/ChannelHeader.tsx +19 -5
  15. package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
  21. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  23. package/src/components/ChannelInfo/States.tsx +1 -1
  24. package/src/components/ChannelInfo/index.ts +3 -0
  25. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
  26. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  27. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  28. package/src/components/ChannelList.tsx +177 -290
  29. package/src/components/CreateChannelModal.tsx +166 -88
  30. package/src/components/Dropdown.tsx +1 -16
  31. package/src/components/EditPreview.tsx +1 -0
  32. package/src/components/ErmisCallProvider.tsx +72 -17
  33. package/src/components/ErmisCallUI.tsx +43 -20
  34. package/src/components/FlatTopicGroupItem.tsx +232 -0
  35. package/src/components/ForwardMessageModal.tsx +31 -77
  36. package/src/components/MediaLightbox.tsx +62 -40
  37. package/src/components/MentionSuggestions.tsx +47 -35
  38. package/src/components/MessageActionsBox.tsx +4 -1
  39. package/src/components/MessageInput.tsx +126 -7
  40. package/src/components/MessageInputDefaults.tsx +127 -1
  41. package/src/components/MessageItem.tsx +93 -26
  42. package/src/components/MessageQuickReactions.tsx +153 -26
  43. package/src/components/MessageReactions.tsx +2 -1
  44. package/src/components/MessageRenderers.tsx +111 -39
  45. package/src/components/Panel.tsx +1 -14
  46. package/src/components/PinnedMessages.tsx +17 -5
  47. package/src/components/PreviewOverlay.tsx +24 -0
  48. package/src/components/ReadReceipts.tsx +2 -1
  49. package/src/components/TopicList.tsx +221 -0
  50. package/src/components/TopicModal.tsx +4 -1
  51. package/src/components/TypingIndicator.tsx +14 -5
  52. package/src/components/UserPicker.tsx +87 -10
  53. package/src/components/VirtualMessageList.tsx +106 -20
  54. package/src/context/ChatComponentsContext.tsx +14 -0
  55. package/src/context/ChatProvider.tsx +18 -14
  56. package/src/context/ErmisCallContext.tsx +4 -0
  57. package/src/hooks/useChannelCapabilities.ts +7 -4
  58. package/src/hooks/useChannelData.ts +10 -3
  59. package/src/hooks/useChannelListUpdates.ts +72 -20
  60. package/src/hooks/useChannelMessages.ts +72 -10
  61. package/src/hooks/useChannelRowUpdates.ts +24 -5
  62. package/src/hooks/useChatUser.ts +31 -0
  63. package/src/hooks/useContactChannels.ts +45 -0
  64. package/src/hooks/useContactCount.ts +50 -0
  65. package/src/hooks/useDownloadHandler.ts +36 -0
  66. package/src/hooks/useDragAndDrop.ts +79 -0
  67. package/src/hooks/useForwardMessage.ts +112 -0
  68. package/src/hooks/useInviteChannels.ts +88 -0
  69. package/src/hooks/useInviteCount.ts +104 -0
  70. package/src/hooks/useMentions.ts +0 -1
  71. package/src/hooks/useMessageActions.ts +13 -10
  72. package/src/hooks/usePendingState.ts +21 -4
  73. package/src/hooks/usePreviewState.ts +69 -0
  74. package/src/hooks/useStickerPicker.ts +62 -0
  75. package/src/hooks/useTopicGroupUpdates.ts +197 -0
  76. package/src/index.ts +56 -6
  77. package/src/messageTypeUtils.ts +13 -1
  78. package/src/styles/_base.css +0 -1
  79. package/src/styles/_call-ui.css +59 -2
  80. package/src/styles/_channel-info.css +41 -4
  81. package/src/styles/_channel-list.css +97 -57
  82. package/src/styles/_create-channel-modal.css +10 -0
  83. package/src/styles/_forward-modal.css +16 -1
  84. package/src/styles/_media-lightbox.css +32 -0
  85. package/src/styles/_mentions.css +1 -1
  86. package/src/styles/_message-actions.css +3 -4
  87. package/src/styles/_message-bubble.css +286 -107
  88. package/src/styles/_message-input.css +131 -0
  89. package/src/styles/_message-list.css +33 -17
  90. package/src/styles/_message-quick-reactions.css +40 -9
  91. package/src/styles/_message-reactions.css +4 -0
  92. package/src/styles/_modal.css +2 -1
  93. package/src/styles/_preview-overlay.css +38 -0
  94. package/src/styles/_tokens.css +17 -15
  95. package/src/styles/_typing-indicator.css +7 -1
  96. package/src/styles/index.css +1 -0
  97. package/src/types.ts +362 -14
  98. package/src/utils/avatarColors.ts +48 -0
  99. 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
- }> = React.memo(({ message, isOwnMessage, disabled }) => {
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 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
- );
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
- return (
54
- <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
- }}
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
- {EMOJI_MAP[type]}
67
- </button>
68
- );
69
- })}
70
- </div>
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
- const { client } = useChatClient();
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
- if (!url) return;
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.label]));
579
+ const patternToLabel = new Map(replacements.map((r) => [r.pattern, r]));
515
580
 
516
581
  return parts.flatMap((part, i) => {
517
- const label = patternToLabel.get(part);
518
- if (label) {
582
+ const info = patternToLabel.get(part);
583
+ if (info) {
519
584
  // Mention — render as span, do NOT linkify
520
585
  return (
521
- <span key={`mention-${i}`} className="ermis-mention">
522
- {label}
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
  };
@@ -1,19 +1,6 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
2
 
3
- export type PanelProps = {
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 = 'Sticker';
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="Unpin message"
71
- aria-label="Unpin message"
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
- {pinnedMessages.length} pinned message{pinnedMessages.length > 1 ? 's' : ''}
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 ? 'Collapse' : 'See all'}
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="ermis-read-receipts">
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