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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +5087 -11279
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +632 -152
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +273 -9
  7. package/dist/index.d.ts +273 -9
  8. package/dist/index.mjs +5085 -11295
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/components/Channel.tsx +0 -3
  12. package/src/components/ChannelActions.tsx +6 -1
  13. package/src/components/ChannelHeader.tsx +8 -32
  14. package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
  17. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
  18. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
  19. package/src/components/ChannelList.tsx +72 -13
  20. package/src/components/CreateChannelModal.tsx +131 -12
  21. package/src/components/FilesPreview.tsx +8 -12
  22. package/src/components/FlatTopicGroupItem.tsx +27 -16
  23. package/src/components/ForwardMessageModal.tsx +11 -3
  24. package/src/components/MediaLightbox.tsx +444 -304
  25. package/src/components/MessageActionsBox.tsx +2 -0
  26. package/src/components/MessageInput.tsx +41 -12
  27. package/src/components/MessageItem.tsx +70 -25
  28. package/src/components/MessageQuickReactions.tsx +131 -128
  29. package/src/components/MessageReactions.tsx +47 -2
  30. package/src/components/MessageRenderers.tsx +1030 -433
  31. package/src/components/PinnedMessages.tsx +40 -12
  32. package/src/components/QuotedMessagePreview.tsx +99 -8
  33. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  34. package/src/components/RecoveryPin/index.ts +19 -0
  35. package/src/components/TopicList.tsx +20 -5
  36. package/src/components/TypingIndicator.tsx +3 -3
  37. package/src/components/UserPicker.tsx +26 -25
  38. package/src/components/VirtualMessageList.tsx +345 -125
  39. package/src/context/ChatProvider.tsx +27 -1
  40. package/src/hooks/useChannelListUpdates.ts +22 -1
  41. package/src/hooks/useChannelMessages.ts +338 -51
  42. package/src/hooks/useChannelRowUpdates.ts +18 -6
  43. package/src/hooks/useChatUser.ts +9 -1
  44. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  45. package/src/hooks/useE2eeFileUpload.ts +38 -0
  46. package/src/hooks/useFileUpload.ts +25 -5
  47. package/src/hooks/useForwardMessage.ts +210 -13
  48. package/src/hooks/useLoadMessages.ts +16 -4
  49. package/src/hooks/useMentions.ts +60 -6
  50. package/src/hooks/useMessageActions.ts +14 -8
  51. package/src/hooks/useMessageSend.ts +64 -12
  52. package/src/hooks/usePendingE2eeSends.ts +29 -0
  53. package/src/hooks/useRecoveryPin.ts +287 -0
  54. package/src/hooks/useScrollToMessage.ts +29 -4
  55. package/src/hooks/useTopicGroupUpdates.ts +49 -11
  56. package/src/index.ts +23 -0
  57. package/src/messageTypeUtils.ts +14 -0
  58. package/src/styles/_channel-info.css +9 -0
  59. package/src/styles/_channel-list.css +37 -14
  60. package/src/styles/_media-lightbox.css +36 -3
  61. package/src/styles/_message-bubble.css +381 -41
  62. package/src/styles/_message-input.css +8 -0
  63. package/src/styles/_message-list.css +67 -10
  64. package/src/styles/_message-quick-reactions.css +101 -59
  65. package/src/styles/_message-reactions.css +18 -32
  66. package/src/styles/_recovery-pin.css +97 -0
  67. package/src/styles/_tokens.css +5 -5
  68. package/src/styles/_typing-indicator.css +23 -13
  69. package/src/styles/index.css +1 -0
  70. package/src/types.ts +115 -1
  71. package/src/utils/avatarColors.ts +1 -1
  72. package/src/utils.ts +38 -18
@@ -1,271 +1,772 @@
1
- import React, { useState, useMemo, useCallback } from 'react';
1
+ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
2
2
  import { preloadImage, isImagePreloaded, formatTime } from '../utils';
3
- import type { FormatMessageResponse, Attachment, MessageLabel } from '@ermis-network/ermis-chat-sdk';
3
+ import type {
4
+ FormatMessageResponse,
5
+ Attachment,
6
+ MessageLabel,
7
+ E2eeAttachmentManifest,
8
+ } from '@ermis-network/ermis-chat-sdk';
4
9
  import { parseSystemMessage, parseSignalMessage, CallType } from '@ermis-network/ermis-chat-sdk';
5
10
  import { useChatClient } from '../hooks/useChatClient';
6
11
  import { useDownloadHandler } from '../hooks/useDownloadHandler';
12
+ import { E2EE_PREVIEW_MAX_CONCURRENT, useE2eeAttachmentRenderer } from '../hooks/useE2eeAttachmentRenderer';
7
13
  import { buildUserMap } from '../utils';
8
14
  import { MediaLightbox } from './MediaLightbox';
9
15
  import { getFileIcon } from './ChannelInfo/utils';
10
16
  import type { AttachmentProps, MessageRendererProps, MessageBubbleProps, MediaLightboxItem } from '../types';
11
17
 
12
18
  export type { AttachmentProps, MessageRendererProps, MessageBubbleProps } from '../types';
13
- import {
14
- isVoiceRecordingAttachment,
15
- isLinkPreviewAttachment,
16
- isImage,
17
- isVideo
18
- } from '../messageTypeUtils';
19
+ import { isVoiceRecordingAttachment, isLinkPreviewAttachment, isImage, isVideo, isAudio } from '../messageTypeUtils';
19
20
 
20
21
  /* ----------------------------------------------------------
21
22
  Attachment renderers
22
23
  ---------------------------------------------------------- */
23
- const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment, onClick }) => {
24
- const src = attachment.image_url || attachment.thumb_url || attachment.url;
25
- const thumbSrc = attachment.thumb_url;
26
- if (!src) return null;
24
+ const ImageAttachment: React.FC<AttachmentProps> = React.memo(
25
+ ({ attachment, onClick }) => {
26
+ const src = attachment.image_url || attachment.thumb_url || attachment.url;
27
+ const thumbSrc = attachment.thumb_url;
28
+ if (!src) return null;
29
+
30
+ const alreadyCached = isImagePreloaded(src);
31
+ const [loaded, setLoaded] = useState(alreadyCached);
32
+ const imgRef = React.useRef<HTMLImageElement>(null);
33
+
34
+ // Trigger background preload (no-op if already cached)
35
+ useMemo(() => {
36
+ preloadImage(src);
37
+ }, [src]);
38
+
39
+ React.useEffect(() => {
40
+ if (!loaded && imgRef.current?.complete) {
41
+ setLoaded(true);
42
+ }
43
+ }, [loaded, src]);
27
44
 
28
- const alreadyCached = isImagePreloaded(src);
29
- const [loaded, setLoaded] = useState(alreadyCached);
30
- const imgRef = React.useRef<HTMLImageElement>(null);
45
+ const clickable = Boolean(onClick);
31
46
 
32
- // Trigger background preload (no-op if already cached)
33
- useMemo(() => { preloadImage(src); }, [src]);
47
+ return (
48
+ <div
49
+ className={`ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3${
50
+ clickable ? ' ermis-attachment--clickable' : ''
51
+ }`}
52
+ onClick={onClick}
53
+ role={clickable ? 'button' : undefined}
54
+ tabIndex={clickable ? 0 : undefined}
55
+ >
56
+ {/* Blur placeholder: use thumb if available, otherwise shimmer */}
57
+ {!loaded &&
58
+ (thumbSrc && thumbSrc !== src ? (
59
+ <img className="ermis-attachment-blur-preview" src={thumbSrc} alt="" aria-hidden />
60
+ ) : (
61
+ <div className="ermis-attachment-shimmer" />
62
+ ))}
63
+ <img
64
+ ref={imgRef}
65
+ className={`ermis-attachment ermis-attachment--image${loaded ? ' ermis-attachment--loaded' : ''}`}
66
+ src={src}
67
+ alt={attachment.file_name || attachment.title || 'image'}
68
+ loading="lazy"
69
+ onLoad={() => setLoaded(true)}
70
+ />
71
+ {clickable && (
72
+ <div className="ermis-attachment__overlay">
73
+ <svg
74
+ width="18"
75
+ height="18"
76
+ viewBox="0 0 24 24"
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth="2"
80
+ strokeLinecap="round"
81
+ strokeLinejoin="round"
82
+ >
83
+ <circle cx="11" cy="11" r="8" />
84
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
85
+ <line x1="11" y1="8" x2="11" y2="14" />
86
+ <line x1="8" y1="11" x2="14" y2="11" />
87
+ </svg>
88
+ </div>
89
+ )}
90
+ <LocalUploadOverlay attachment={attachment} />
91
+ </div>
92
+ );
93
+ },
94
+ (prev, next) => {
95
+ return (
96
+ attachmentRenderKey(prev.attachment) === attachmentRenderKey(next.attachment) && prev.onClick === next.onClick
97
+ );
98
+ },
99
+ );
34
100
 
35
- React.useEffect(() => {
36
- if (!loaded && imgRef.current?.complete) {
37
- setLoaded(true);
38
- }
39
- }, [loaded, src]);
101
+ function isE2eeAttachmentManifest(attachment: unknown): attachment is E2eeAttachmentManifest {
102
+ return Boolean(
103
+ attachment &&
104
+ typeof attachment === 'object' &&
105
+ (attachment as E2eeAttachmentManifest).version === 1 &&
106
+ typeof (attachment as E2eeAttachmentManifest).attachment_id === 'string' &&
107
+ Array.isArray((attachment as E2eeAttachmentManifest).assets),
108
+ );
109
+ }
110
+
111
+ function e2eeDisplayString(display: Record<string, unknown> | undefined, key: string): string | undefined {
112
+ const value = display?.[key];
113
+ return typeof value === 'string' && value.trim() ? value : undefined;
114
+ }
115
+
116
+ function e2eeDisplayNumber(display: Record<string, unknown> | undefined, key: string): number | undefined {
117
+ const value = display?.[key];
118
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
119
+ }
40
120
 
41
- const clickable = Boolean(onClick);
121
+ function formatE2eeProgress(progress?: {
122
+ phase: string;
123
+ loaded: number;
124
+ total: number;
125
+ percentage?: number;
126
+ }): string | undefined {
127
+ if (!progress) return undefined;
128
+ const phaseLabel =
129
+ progress.phase === 'granting'
130
+ ? 'Getting access'
131
+ : progress.phase === 'downloading'
132
+ ? 'Downloading'
133
+ : progress.phase === 'verifying'
134
+ ? 'Verifying'
135
+ : progress.phase === 'decrypting'
136
+ ? 'Decrypting'
137
+ : 'Loading';
138
+ if (typeof progress.percentage === 'number') return `${phaseLabel} ${progress.percentage}%`;
139
+ return phaseLabel;
140
+ }
42
141
 
142
+ function getLocalUploadProgress(attachment: Attachment): number | undefined {
143
+ const value = (attachment as any).upload_progress;
144
+ return typeof value === 'number' && Number.isFinite(value)
145
+ ? Math.max(0, Math.min(100, Math.round(value)))
146
+ : undefined;
147
+ }
148
+
149
+ function attachmentRenderKey(attachment: Attachment | E2eeAttachmentManifest): string {
150
+ const anyAttachment = attachment as any;
151
+ const id = isE2eeAttachmentManifest(attachment)
152
+ ? attachment.attachment_id
153
+ : anyAttachment.id || anyAttachment.asset_url || anyAttachment.url || anyAttachment.file_name || '';
154
+ return [
155
+ id,
156
+ anyAttachment.type || '',
157
+ anyAttachment.upload_status || '',
158
+ typeof anyAttachment.upload_progress === 'number' ? Math.round(anyAttachment.upload_progress) : '',
159
+ anyAttachment.local_object_url || '',
160
+ ].join('|');
161
+ }
162
+
163
+ function LocalUploadOverlay({ attachment }: { attachment: Attachment }) {
164
+ const progress = getLocalUploadProgress(attachment);
165
+ const status = (attachment as any).upload_status;
166
+ if (progress === undefined && !status) return null;
43
167
  return (
44
- <div
45
- className={`ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3${clickable ? ' ermis-attachment--clickable' : ''}`}
46
- onClick={onClick}
47
- role={clickable ? 'button' : undefined}
48
- tabIndex={clickable ? 0 : undefined}
49
- >
50
- {/* Blur placeholder: use thumb if available, otherwise shimmer */}
51
- {!loaded && (
52
- thumbSrc && thumbSrc !== src ? (
53
- <img
54
- className="ermis-attachment-blur-preview"
55
- src={thumbSrc}
56
- alt=""
57
- aria-hidden
58
- />
59
- ) : (
60
- <div className="ermis-attachment-shimmer" />
61
- )
62
- )}
63
- <img
64
- ref={imgRef}
65
- className={`ermis-attachment ermis-attachment--image${loaded ? ' ermis-attachment--loaded' : ''}`}
66
- src={src}
67
- alt={attachment.file_name || attachment.title || 'image'}
68
- loading="lazy"
69
- onLoad={() => setLoaded(true)}
70
- />
71
- {clickable && (
72
- <div className="ermis-attachment__overlay">
73
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
74
- <circle cx="11" cy="11" r="8" />
75
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
76
- <line x1="11" y1="8" x2="11" y2="14" />
77
- <line x1="8" y1="11" x2="14" y2="11" />
78
- </svg>
79
- </div>
80
- )}
81
- </div>
168
+ <span className="ermis-attachment-upload-overlay">
169
+ <span className="ermis-e2ee-attachment-spinner" />
170
+ <span>{progress !== undefined ? `${progress}%` : 'Sending'}</span>
171
+ </span>
82
172
  );
83
- }, (prev, next) => {
84
- const prevSrc = prev.attachment.image_url || prev.attachment.thumb_url || prev.attachment.url;
85
- const nextSrc = next.attachment.image_url || next.attachment.thumb_url || next.attachment.url;
86
- return prevSrc === nextSrc && prev.onClick === next.onClick;
87
- });
88
- (ImageAttachment as any).displayName = 'ImageAttachment';
173
+ }
89
174
 
90
- const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment, onClick }) => {
91
- const src = attachment.asset_url || attachment.url;
92
- const posterSrc = attachment.image_url || attachment.thumb_url;
93
- const blurThumb = attachment.thumb_url;
94
- if (!src) return null;
175
+ function e2eeAspectStyle(width?: number, height?: number): React.CSSProperties {
176
+ const ratio = width && height && width > 0 && height > 0 ? width / height : 4 / 3;
177
+ const maxWidth = 340;
178
+ const maxHeight = 420;
179
+ const targetWidth = Math.max(160, Math.min(maxWidth, Math.round(maxHeight * ratio)));
180
+ return {
181
+ aspectRatio: `${width && height ? width : 4} / ${width && height ? height : 3}`,
182
+ width: `min(100%, ${targetWidth}px)`,
183
+ maxWidth: `${maxWidth}px`,
184
+ maxHeight: `${maxHeight}px`,
185
+ };
186
+ }
95
187
 
96
- const alreadyCached = posterSrc ? isImagePreloaded(posterSrc) : true;
97
- const [loaded, setLoaded] = useState(alreadyCached);
98
- const imgRef = React.useRef<HTMLImageElement>(null);
188
+ function formatFileSize(size?: number): string | undefined {
189
+ if (!size || size <= 0) return undefined;
190
+ if (size < 1024) return `${size} B`;
191
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
192
+ return `${(size / 1024 / 1024).toFixed(1)} MB`;
193
+ }
99
194
 
100
- useMemo(() => {
101
- if (posterSrc) preloadImage(posterSrc);
102
- }, [posterSrc]);
195
+ function extensionForName(name: string): string {
196
+ const ext = name.split('.').pop();
197
+ return ext && ext !== name ? ext.toUpperCase() : 'E2EE';
198
+ }
103
199
 
104
- React.useEffect(() => {
105
- if (!loaded && imgRef.current?.complete) {
106
- setLoaded(true);
200
+ function isLikelyImage(name: string, mimeType?: string): boolean {
201
+ return Boolean(mimeType?.startsWith('image/') || /\.(apng|avif|gif|jpe?g|png|webp)$/i.test(name));
202
+ }
203
+
204
+ function isLikelyVideo(name: string, mimeType?: string): boolean {
205
+ return Boolean(mimeType?.startsWith('video/') || /\.(mov|m4v|mp4|mpeg|mpg|ogv|webm)$/i.test(name));
206
+ }
207
+
208
+ function isLikelyAudio(name: string, mimeType?: string, attachmentType?: string): boolean {
209
+ return Boolean(
210
+ attachmentType === 'voiceRecording' ||
211
+ mimeType?.startsWith('audio/') ||
212
+ /\.(aac|flac|m4a|mp3|oga|ogg|opus|wav|webm)$/i.test(name),
213
+ );
214
+ }
215
+
216
+ function E2eePlayIcon() {
217
+ return (
218
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
219
+ <path d="M8 5v14l11-7z" />
220
+ </svg>
221
+ );
222
+ }
223
+
224
+ let activeE2eePreviewLoads = 0;
225
+ const queuedE2eePreviewLoads: Array<() => void> = [];
226
+
227
+ function scheduleE2eePreviewLoad(load: () => Promise<unknown>): void {
228
+ const run = () => {
229
+ activeE2eePreviewLoads += 1;
230
+ void load().finally(() => {
231
+ activeE2eePreviewLoads = Math.max(0, activeE2eePreviewLoads - 1);
232
+ const next = queuedE2eePreviewLoads.shift();
233
+ if (next) next();
234
+ });
235
+ };
236
+ if (activeE2eePreviewLoads < E2EE_PREVIEW_MAX_CONCURRENT) run();
237
+ else queuedE2eePreviewLoads.push(run);
238
+ }
239
+
240
+ const E2eeAttachment: React.FC<{ attachment: E2eeAttachmentManifest; grantReady?: boolean }> = React.memo(
241
+ ({ attachment, grantReady = true }) => {
242
+ const { activeChannel } = useChatClient();
243
+ const original = useE2eeAttachmentRenderer(activeChannel, attachment, 'original');
244
+ const preview = useE2eeAttachmentRenderer(activeChannel, attachment, 'preview');
245
+ const [mediaError, setMediaError] = useState(false);
246
+ const [lightboxOpen, setLightboxOpen] = useState(false);
247
+ const [naturalPreviewSize, setNaturalPreviewSize] = useState<{ width: number; height: number } | undefined>();
248
+ const previewRef = useRef<HTMLDivElement | null>(null);
249
+ const asset = attachment.assets.find((item) => item.kind === 'original') || attachment.assets[0];
250
+ const previewAsset = attachment.assets.find((item) => item.kind === 'preview');
251
+ const hasPreview = Boolean(previewAsset);
252
+ const display = asset?.display;
253
+ const previewDisplay = previewAsset?.display;
254
+ const title = e2eeDisplayString(display, 'name') || 'Encrypted attachment';
255
+ const mimeType = e2eeDisplayString(display, 'mime_type');
256
+ const attachmentType = e2eeDisplayString(display, 'attachment_type');
257
+ const size = e2eeDisplayNumber(display, 'size') || asset?.plaintext_size || asset?.cipher_size;
258
+ const ext = extensionForName(title);
259
+ const sizeLabel = formatFileSize(size);
260
+ const isImageAsset = isLikelyImage(title, mimeType);
261
+ const isVideoAsset = isLikelyVideo(title, mimeType);
262
+ const isAudioAsset = isLikelyAudio(title, mimeType, attachmentType);
263
+ const loadedUrl = preview.url || original.url;
264
+ const loading = original.loading || preview.loading;
265
+ const error = original.error || preview.error;
266
+ const width =
267
+ e2eeDisplayNumber(display, 'width') || e2eeDisplayNumber(previewDisplay, 'width') || naturalPreviewSize?.width;
268
+ const height =
269
+ e2eeDisplayNumber(display, 'height') || e2eeDisplayNumber(previewDisplay, 'height') || naturalPreviewSize?.height;
270
+ const aspectStyle = e2eeAspectStyle(width, height);
271
+ const progressLabel = formatE2eeProgress(original.progress || preview.progress);
272
+ const statusLabel = mediaError
273
+ ? 'Preview unavailable, download file'
274
+ : !grantReady
275
+ ? 'Sending'
276
+ : progressLabel
277
+ ? progressLabel
278
+ : error
279
+ ? 'Unavailable'
280
+ : original.url
281
+ ? 'Ready'
282
+ : preview.url
283
+ ? 'Preview ready'
284
+ : 'Encrypted';
285
+
286
+ useEffect(() => {
287
+ if (!grantReady) return;
288
+ if (!hasPreview || !(isImageAsset || isVideoAsset) || preview.url || preview.loading || preview.error) return;
289
+ const element = previewRef.current;
290
+ if (!element || typeof IntersectionObserver === 'undefined') {
291
+ scheduleE2eePreviewLoad(preview.load);
292
+ return;
293
+ }
294
+ let scheduled = false;
295
+ const observer = new IntersectionObserver(
296
+ (entries) => {
297
+ if (scheduled) return;
298
+ if (entries.some((entry) => entry.isIntersecting)) {
299
+ scheduled = true;
300
+ observer.disconnect();
301
+ scheduleE2eePreviewLoad(preview.load);
302
+ }
303
+ },
304
+ { rootMargin: '160px' },
305
+ );
306
+ observer.observe(element);
307
+ return () => observer.disconnect();
308
+ }, [grantReady, hasPreview, isImageAsset, isVideoAsset, preview.error, preview.load, preview.loading, preview.url]);
309
+
310
+ const ensureOriginal = useCallback(() => {
311
+ if (!grantReady) return;
312
+ setMediaError(false);
313
+ if (isVideoAsset && !original.streamUrl && !original.streamLoading) {
314
+ void original.loadStream().then((streamUrl) => {
315
+ if (!streamUrl && !original.url && !original.loading) void original.load();
316
+ });
317
+ return;
318
+ }
319
+ if (!original.url && !original.loading && !original.streamUrl) void original.load();
320
+ }, [grantReady, isVideoAsset, original]);
321
+
322
+ const openViewer = useCallback(
323
+ (event?: React.MouseEvent) => {
324
+ event?.preventDefault();
325
+ event?.stopPropagation();
326
+ setLightboxOpen(true);
327
+ ensureOriginal();
328
+ },
329
+ [ensureOriginal],
330
+ );
331
+
332
+ const handleLoad = useCallback(
333
+ (event?: React.MouseEvent) => {
334
+ event?.preventDefault();
335
+ event?.stopPropagation();
336
+ ensureOriginal();
337
+ },
338
+ [ensureOriginal],
339
+ );
340
+
341
+ const handleDownload = useCallback(
342
+ (event?: React.MouseEvent) => {
343
+ event?.preventDefault();
344
+ event?.stopPropagation();
345
+ if (!grantReady) return;
346
+ void original.download(title);
347
+ },
348
+ [grantReady, original, title],
349
+ );
350
+
351
+ const lightboxItems = useMemo<MediaLightboxItem[]>(
352
+ () => [
353
+ {
354
+ type: isVideoAsset ? 'video' : 'image',
355
+ src: original.streamUrl || original.url,
356
+ posterSrc: preview.url,
357
+ alt: title,
358
+ loading: original.loading || (lightboxOpen && !original.streamUrl && !original.url && !original.error),
359
+ progressLabel: formatE2eeProgress(original.progress),
360
+ download: async () => {
361
+ await original.download(title);
362
+ },
363
+ onPlaybackError: async () => {
364
+ await original.disposeStream();
365
+ if (!original.url && !original.loading) await original.load();
366
+ },
367
+ onDispose: original.disposeStream,
368
+ },
369
+ ],
370
+ [isVideoAsset, lightboxOpen, original, preview.url, title],
371
+ );
372
+
373
+ if (isAudioAsset) {
374
+ const durationSec = e2eeDisplayNumber(display, 'duration') || 0;
375
+ const mins = Math.floor(durationSec / 60);
376
+ const secs = Math.round(durationSec % 60);
377
+ const durationLabel = `${mins}:${secs.toString().padStart(2, '0')}`;
378
+
379
+ return (
380
+ <div className="ermis-e2ee-voice-attachment">
381
+ {original.url ? (
382
+ <CustomAudioPlayer src={original.url} durationLabel={durationLabel} fileName={title} />
383
+ ) : (
384
+ <button
385
+ type="button"
386
+ className="ermis-custom-audio-player ermis-custom-audio-player--placeholder"
387
+ onClick={handleLoad}
388
+ disabled={loading || !grantReady}
389
+ >
390
+ <span className="ermis-custom-audio-play-btn" aria-hidden>
391
+ {loading ? <span className="ermis-e2ee-attachment-spinner" /> : <PlayIcon />}
392
+ </span>
393
+ <span className="ermis-custom-audio-progress-container">
394
+ <span className="ermis-custom-audio-progress-bg">
395
+ <span
396
+ className="ermis-custom-audio-progress-fill"
397
+ style={{ width: `${original.progress?.percentage || 0}%` }}
398
+ />
399
+ </span>
400
+ </span>
401
+ <span className="ermis-custom-audio-duration">{progressLabel || durationLabel}</span>
402
+ <span className="ermis-custom-audio-download-btn" aria-hidden>
403
+ <DownloadIcon />
404
+ </span>
405
+ </button>
406
+ )}
407
+ </div>
408
+ );
409
+ }
410
+
411
+ if ((isImageAsset || isVideoAsset) && loadedUrl && !mediaError) {
412
+ return (
413
+ <div className="ermis-e2ee-attachment-media" ref={previewRef}>
414
+ <button
415
+ className="ermis-e2ee-attachment-placeholder ermis-attachment-aspect-box ermis-attachment-aspect-box--e2ee"
416
+ style={aspectStyle}
417
+ type="button"
418
+ onClick={openViewer}
419
+ disabled={!grantReady}
420
+ >
421
+ <img
422
+ className="ermis-attachment ermis-attachment--image ermis-attachment--loaded"
423
+ src={loadedUrl}
424
+ alt={title}
425
+ loading="lazy"
426
+ onLoad={(event) => {
427
+ const img = event.currentTarget;
428
+ if (img.naturalWidth && img.naturalHeight) {
429
+ setNaturalPreviewSize({ width: img.naturalWidth, height: img.naturalHeight });
430
+ }
431
+ }}
432
+ onError={() => setMediaError(true)}
433
+ />
434
+ {(isVideoAsset || original.loading) && (
435
+ <span className="ermis-e2ee-attachment-placeholder__icon">
436
+ {original.loading ? <span className="ermis-e2ee-attachment-spinner" /> : <E2eePlayIcon />}
437
+ </span>
438
+ )}
439
+ {original.loading && (
440
+ <span className="ermis-e2ee-attachment-progress">
441
+ {formatE2eeProgress(original.progress) || 'Loading'}
442
+ </span>
443
+ )}
444
+ </button>
445
+ <div className="ermis-e2ee-attachment-actions">
446
+ <span className="ermis-e2ee-attachment-actions__label">{title}</span>
447
+ <button
448
+ className="ermis-attachment__file-download"
449
+ onClick={handleDownload}
450
+ title="Download decrypted file"
451
+ type="button"
452
+ disabled={!grantReady}
453
+ >
454
+ <DownloadIcon />
455
+ </button>
456
+ </div>
457
+ {lightboxOpen && (
458
+ <MediaLightbox items={lightboxItems} isOpen={lightboxOpen} onClose={() => setLightboxOpen(false)} />
459
+ )}
460
+ </div>
461
+ );
107
462
  }
108
- }, [loaded, posterSrc]);
109
463
 
110
- const clickable = Boolean(onClick);
464
+ if (isImageAsset || isVideoAsset) {
465
+ return (
466
+ <div className="ermis-e2ee-attachment-media" ref={previewRef}>
467
+ <button
468
+ type="button"
469
+ className="ermis-e2ee-attachment-placeholder ermis-attachment-aspect-box ermis-attachment-aspect-box--e2ee"
470
+ style={aspectStyle}
471
+ onClick={handleLoad}
472
+ disabled={loading || !grantReady}
473
+ >
474
+ <span className="ermis-attachment-shimmer" />
475
+ <span className="ermis-e2ee-attachment-placeholder__center">
476
+ <span className="ermis-e2ee-attachment-placeholder__icon">
477
+ {loading ? (
478
+ <span className="ermis-e2ee-attachment-spinner" />
479
+ ) : isVideoAsset ? (
480
+ <E2eePlayIcon />
481
+ ) : (
482
+ getFileIcon(mimeType || 'image/*', title)
483
+ )}
484
+ </span>
485
+ <span className="ermis-e2ee-attachment-placeholder__title">{title}</span>
486
+ <span className="ermis-e2ee-attachment-placeholder__meta">{statusLabel}</span>
487
+ </span>
488
+ </button>
489
+ <div className="ermis-e2ee-attachment-actions">
490
+ <span className="ermis-e2ee-attachment-actions__label">{sizeLabel || 'Encrypted media'}</span>
491
+ <button
492
+ className="ermis-attachment__file-download"
493
+ onClick={handleDownload}
494
+ title="Download decrypted file"
495
+ type="button"
496
+ disabled={loading || !grantReady}
497
+ >
498
+ <DownloadIcon />
499
+ </button>
500
+ </div>
501
+ </div>
502
+ );
503
+ }
111
504
 
112
- // When clickable (lightbox mode): show poster thumbnail + play icon overlay
113
- if (clickable) {
114
505
  return (
115
- <div
116
- className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3 ermis-attachment--clickable"
117
- onClick={onClick}
118
- role="button"
119
- tabIndex={0}
120
- >
121
- {!loaded && (
122
- blurThumb && blurThumb !== posterSrc ? (
506
+ <div className="ermis-attachment ermis-attachment--file ermis-attachment--e2ee">
507
+ <span className="ermis-attachment__file-icon">
508
+ {getFileIcon(mimeType || '', title)}
509
+ <span className="ermis-attachment__file-ext">{ext}</span>
510
+ </span>
511
+ <button
512
+ type="button"
513
+ className="ermis-attachment__file-info ermis-e2ee-attachment__open"
514
+ onClick={handleLoad}
515
+ disabled={loading || !grantReady}
516
+ >
517
+ <span className="ermis-attachment__file-name">{title}</span>
518
+ <span className="ermis-attachment__file-size">
519
+ {sizeLabel ? `${sizeLabel} · ${statusLabel}` : statusLabel}
520
+ </span>
521
+ </button>
522
+ <button
523
+ className="ermis-attachment__file-download"
524
+ onClick={handleDownload}
525
+ title="Download decrypted file"
526
+ type="button"
527
+ disabled={loading || !grantReady}
528
+ >
529
+ <DownloadIcon />
530
+ </button>
531
+ </div>
532
+ );
533
+ },
534
+ (prev, next) => prev.attachment === next.attachment && prev.grantReady === next.grantReady,
535
+ );
536
+ (E2eeAttachment as any).displayName = 'E2eeAttachment';
537
+ (ImageAttachment as any).displayName = 'ImageAttachment';
538
+
539
+ const VideoAttachment: React.FC<AttachmentProps> = React.memo(
540
+ ({ attachment, onClick }) => {
541
+ const src = attachment.asset_url || attachment.url;
542
+ const posterSrc = attachment.image_url || attachment.thumb_url;
543
+ const blurThumb = attachment.thumb_url;
544
+ if (!src) return null;
545
+
546
+ const alreadyCached = posterSrc ? isImagePreloaded(posterSrc) : true;
547
+ const [loaded, setLoaded] = useState(alreadyCached);
548
+ const imgRef = React.useRef<HTMLImageElement>(null);
549
+
550
+ useMemo(() => {
551
+ if (posterSrc) preloadImage(posterSrc);
552
+ }, [posterSrc]);
553
+
554
+ React.useEffect(() => {
555
+ if (!loaded && imgRef.current?.complete) {
556
+ setLoaded(true);
557
+ }
558
+ }, [loaded, posterSrc]);
559
+
560
+ const clickable = Boolean(onClick);
561
+
562
+ // When clickable (lightbox mode): show poster thumbnail + play icon overlay
563
+ if (clickable) {
564
+ return (
565
+ <div
566
+ className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3 ermis-attachment--clickable"
567
+ onClick={onClick}
568
+ role="button"
569
+ tabIndex={0}
570
+ >
571
+ {!loaded &&
572
+ (blurThumb && blurThumb !== posterSrc ? (
573
+ <img className="ermis-attachment-blur-preview" src={blurThumb} alt="" aria-hidden />
574
+ ) : (
575
+ <div className="ermis-attachment-shimmer" />
576
+ ))}
577
+ {posterSrc ? (
578
+ <img
579
+ ref={imgRef}
580
+ className={`ermis-attachment ermis-attachment--video-poster${loaded ? ' ermis-attachment--loaded' : ''}`}
581
+ src={posterSrc}
582
+ alt={attachment.file_name || 'video'}
583
+ loading="lazy"
584
+ onLoad={() => setLoaded(true)}
585
+ />
586
+ ) : (
587
+ <video
588
+ className={`ermis-attachment ermis-attachment--video${loaded ? ' ermis-attachment--loaded' : ''}`}
589
+ src={src}
590
+ preload="metadata"
591
+ onLoadedData={() => setLoaded(true)}
592
+ />
593
+ )}
594
+ <div className="ermis-attachment__overlay">
595
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
596
+ <polygon points="5 3 19 12 5 21 5 3" />
597
+ </svg>
598
+ </div>
599
+ <LocalUploadOverlay attachment={attachment} />
600
+ </div>
601
+ );
602
+ }
603
+
604
+ // Default inline video player (no lightbox)
605
+ return (
606
+ <div className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3">
607
+ {!loaded &&
608
+ (blurThumb && blurThumb !== posterSrc ? (
123
609
  <img className="ermis-attachment-blur-preview" src={blurThumb} alt="" aria-hidden />
124
610
  ) : (
125
611
  <div className="ermis-attachment-shimmer" />
126
- )
127
- )}
128
- {posterSrc ? (
612
+ ))}
613
+ {posterSrc && !loaded && (
129
614
  <img
130
615
  ref={imgRef}
131
- className={`ermis-attachment ermis-attachment--video-poster${loaded ? ' ermis-attachment--loaded' : ''}`}
132
616
  src={posterSrc}
133
- alt={attachment.file_name || 'video'}
134
- loading="lazy"
617
+ className="ermis-attachment--hidden-loader"
135
618
  onLoad={() => setLoaded(true)}
136
- />
137
- ) : (
138
- <video
139
- className={`ermis-attachment ermis-attachment--video${loaded ? ' ermis-attachment--loaded' : ''}`}
140
- src={src}
141
- preload="metadata"
142
- onLoadedData={() => setLoaded(true)}
619
+ alt="poster-loader"
143
620
  />
144
621
  )}
145
- <div className="ermis-attachment__overlay">
146
- <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
147
- <polygon points="5 3 19 12 5 21 5 3" />
148
- </svg>
149
- </div>
622
+ <video
623
+ className={`ermis-attachment ermis-attachment--video${
624
+ loaded || !posterSrc ? ' ermis-attachment--loaded' : ''
625
+ }`}
626
+ src={src}
627
+ poster={posterSrc}
628
+ controls
629
+ preload="metadata"
630
+ onLoadedData={() => {
631
+ if (!posterSrc) setLoaded(true);
632
+ }}
633
+ />
634
+ <LocalUploadOverlay attachment={attachment} />
150
635
  </div>
151
636
  );
152
- }
153
-
154
- // Default inline video player (no lightbox)
155
- return (
156
- <div className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3">
157
- {!loaded && (
158
- blurThumb && blurThumb !== posterSrc ? (
159
- <img
160
- className="ermis-attachment-blur-preview"
161
- src={blurThumb}
162
- alt=""
163
- aria-hidden
164
- />
165
- ) : (
166
- <div className="ermis-attachment-shimmer" />
167
- )
168
- )}
169
- {posterSrc && !loaded && (
170
- <img
171
- ref={imgRef}
172
- src={posterSrc}
173
- className="ermis-attachment--hidden-loader"
174
- onLoad={() => setLoaded(true)}
175
- alt="poster-loader"
176
- />
177
- )}
178
- <video
179
- className={`ermis-attachment ermis-attachment--video${loaded || !posterSrc ? ' ermis-attachment--loaded' : ''}`}
180
- src={src}
181
- poster={posterSrc}
182
- controls
183
- preload="metadata"
184
- onLoadedData={() => {
185
- if (!posterSrc) setLoaded(true);
186
- }}
187
- />
188
- </div>
189
- );
190
- }, (prev, next) => {
191
- return (prev.attachment.asset_url || prev.attachment.url) ===
192
- (next.attachment.asset_url || next.attachment.url) && prev.onClick === next.onClick;
193
- });
637
+ },
638
+ (prev, next) => {
639
+ return (
640
+ attachmentRenderKey(prev.attachment) === attachmentRenderKey(next.attachment) && prev.onClick === next.onClick
641
+ );
642
+ },
643
+ );
194
644
  (VideoAttachment as any).displayName = 'VideoAttachment';
195
645
 
196
- const FileAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
197
- const url = attachment.url || attachment.asset_url;
198
- const name = attachment.file_name || attachment.title || 'File';
199
- const size = attachment.file_size;
200
- const mimeType = attachment.mime_type || attachment.type || '';
201
- const ext = name.split('.').pop()?.toUpperCase() || 'FILE';
202
-
203
- const { downloadFile } = useDownloadHandler();
204
-
205
- const handleDownload = useCallback(async (e: React.MouseEvent) => {
206
- e.preventDefault();
207
- e.stopPropagation();
208
- await downloadFile(url, name);
209
- }, [downloadFile, url, name]);
646
+ const FileAttachment: React.FC<AttachmentProps> = React.memo(
647
+ ({ attachment }) => {
648
+ const url = attachment.url || attachment.asset_url;
649
+ const name = attachment.file_name || attachment.title || 'File';
650
+ const size = attachment.file_size;
651
+ const mimeType = attachment.mime_type || attachment.type || '';
652
+ const ext = name.split('.').pop()?.toUpperCase() || 'FILE';
653
+
654
+ const { downloadFile } = useDownloadHandler();
655
+
656
+ const handleDownload = useCallback(
657
+ async (e: React.MouseEvent) => {
658
+ e.preventDefault();
659
+ e.stopPropagation();
660
+ await downloadFile(url, name);
661
+ },
662
+ [downloadFile, url, name],
663
+ );
210
664
 
211
- return (
212
- <div className="ermis-attachment ermis-attachment--file">
213
- <span className="ermis-attachment__file-icon">
214
- {getFileIcon(mimeType, name)}
215
- <span className="ermis-attachment__file-ext">{ext}</span>
216
- </span>
217
- <span className="ermis-attachment__file-info">
218
- <span className="ermis-attachment__file-name">{name}</span>
219
- {size && (
220
- <span className="ermis-attachment__file-size">
221
- {typeof size === 'number' ? `${(size / 1024).toFixed(1)} KB` : size}
222
- </span>
223
- )}
224
- </span>
225
- <button
226
- className="ermis-attachment__file-download"
227
- onClick={handleDownload}
228
- title="Download"
229
- type="button"
230
- >
231
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
232
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
233
- <polyline points="7 10 12 15 17 10" />
234
- <line x1="12" y1="15" x2="12" y2="3" />
235
- </svg>
236
- </button>
237
- </div>
238
- );
239
- }, (prev, next) => {
240
- return (prev.attachment.url || prev.attachment.asset_url) ===
241
- (next.attachment.url || next.attachment.asset_url);
242
- });
665
+ return (
666
+ <div className="ermis-attachment ermis-attachment--file">
667
+ <span className="ermis-attachment__file-icon">
668
+ {getFileIcon(mimeType, name)}
669
+ <span className="ermis-attachment__file-ext">{ext}</span>
670
+ </span>
671
+ <span className="ermis-attachment__file-info">
672
+ <span className="ermis-attachment__file-name">{name}</span>
673
+ {size && (
674
+ <span className="ermis-attachment__file-size">
675
+ {typeof size === 'number' ? `${(size / 1024).toFixed(1)} KB` : size}
676
+ {getLocalUploadProgress(attachment) !== undefined ? ` · ${getLocalUploadProgress(attachment)}%` : ''}
677
+ </span>
678
+ )}
679
+ </span>
680
+ <button className="ermis-attachment__file-download" onClick={handleDownload} title="Download" type="button">
681
+ <svg
682
+ width="18"
683
+ height="18"
684
+ viewBox="0 0 24 24"
685
+ fill="none"
686
+ stroke="currentColor"
687
+ strokeWidth="2"
688
+ strokeLinecap="round"
689
+ strokeLinejoin="round"
690
+ >
691
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
692
+ <polyline points="7 10 12 15 17 10" />
693
+ <line x1="12" y1="15" x2="12" y2="3" />
694
+ </svg>
695
+ </button>
696
+ </div>
697
+ );
698
+ },
699
+ (prev, next) => {
700
+ return attachmentRenderKey(prev.attachment) === attachmentRenderKey(next.attachment);
701
+ },
702
+ );
243
703
  (FileAttachment as any).displayName = 'FileAttachment';
244
704
 
245
705
  const PlayIcon = () => (
246
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
706
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
247
707
  <path d="M8 5v14l11-7z" />
248
708
  </svg>
249
709
  );
250
710
 
251
711
  const PauseIcon = () => (
252
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
712
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
253
713
  <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
254
714
  </svg>
255
715
  );
256
716
 
257
717
  const MicIcon = () => (
258
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
718
+ <svg
719
+ width="18"
720
+ height="18"
721
+ viewBox="0 0 24 24"
722
+ fill="none"
723
+ stroke="currentColor"
724
+ strokeWidth="2"
725
+ strokeLinecap="round"
726
+ strokeLinejoin="round"
727
+ >
259
728
  <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
260
729
  <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
261
730
  <line x1="12" x2="12" y1="19" y2="22" />
262
731
  </svg>
263
732
  );
264
733
 
265
- const CustomAudioPlayer: React.FC<{ src: string; durationLabel: string }> = ({ src, durationLabel }) => {
734
+ const DownloadIcon = () => (
735
+ <svg
736
+ width="18"
737
+ height="18"
738
+ viewBox="0 0 24 24"
739
+ fill="none"
740
+ stroke="currentColor"
741
+ strokeWidth="2"
742
+ strokeLinecap="round"
743
+ strokeLinejoin="round"
744
+ >
745
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
746
+ <polyline points="7 10 12 15 17 10" />
747
+ <line x1="12" y1="15" x2="12" y2="3" />
748
+ </svg>
749
+ );
750
+
751
+ const CustomAudioPlayer: React.FC<{ src: string; durationLabel: string; fileName?: string }> = ({
752
+ src,
753
+ durationLabel,
754
+ fileName,
755
+ }) => {
266
756
  const [isPlaying, setIsPlaying] = useState(false);
267
757
  const [progress, setProgress] = useState(0);
758
+ const [dynamicDuration, setDynamicDuration] = useState(durationLabel);
268
759
  const audioRef = React.useRef<HTMLAudioElement>(null);
760
+ const { downloadFile } = useDownloadHandler();
761
+
762
+ const handleDownload = useCallback(
763
+ async (e: React.MouseEvent) => {
764
+ e.preventDefault();
765
+ e.stopPropagation();
766
+ await downloadFile(src, fileName || 'audio.mp3');
767
+ },
768
+ [downloadFile, src, fileName],
769
+ );
269
770
 
270
771
  React.useEffect(() => {
271
772
  const audio = audioRef.current;
@@ -277,20 +778,29 @@ const CustomAudioPlayer: React.FC<{ src: string; durationLabel: string }> = ({ s
277
778
  setIsPlaying(false);
278
779
  setProgress(0);
279
780
  };
781
+ const onLoadedMetadata = () => {
782
+ if (audio.duration && audio.duration !== Infinity && durationLabel === '0:00') {
783
+ const mins = Math.floor(audio.duration / 60);
784
+ const secs = Math.floor(audio.duration % 60);
785
+ setDynamicDuration(`${mins}:${secs.toString().padStart(2, '0')}`);
786
+ }
787
+ };
280
788
  audio.addEventListener('timeupdate', updateProgress);
281
789
  audio.addEventListener('ended', onEnded);
790
+ audio.addEventListener('loadedmetadata', onLoadedMetadata);
282
791
  return () => {
283
792
  audio.removeEventListener('timeupdate', updateProgress);
284
793
  audio.removeEventListener('ended', onEnded);
794
+ audio.removeEventListener('loadedmetadata', onLoadedMetadata);
285
795
  };
286
- }, []);
796
+ }, [durationLabel]);
287
797
 
288
798
  const togglePlay = () => {
289
799
  if (audioRef.current) {
290
800
  if (isPlaying) {
291
801
  audioRef.current.pause();
292
802
  } else {
293
- audioRef.current.play().catch(e => console.error(e));
803
+ audioRef.current.play().catch((e) => console.error(e));
294
804
  }
295
805
  setIsPlaying(!isPlaying);
296
806
  }
@@ -308,7 +818,7 @@ const CustomAudioPlayer: React.FC<{ src: string; durationLabel: string }> = ({ s
308
818
 
309
819
  return (
310
820
  <div className="ermis-custom-audio-player">
311
- <button className="ermis-custom-audio-play-btn" onClick={togglePlay} aria-label={isPlaying ? "Pause" : "Play"}>
821
+ <button className="ermis-custom-audio-play-btn" onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}>
312
822
  {isPlaying ? <PauseIcon /> : <PlayIcon />}
313
823
  </button>
314
824
  <div className="ermis-custom-audio-progress-container">
@@ -317,183 +827,219 @@ const CustomAudioPlayer: React.FC<{ src: string; durationLabel: string }> = ({ s
317
827
  <div className="ermis-custom-audio-progress-thumb" style={{ left: `${progress}%` }} />
318
828
  </div>
319
829
  </div>
320
- <span className="ermis-custom-audio-duration">{durationLabel}</span>
830
+ <span className="ermis-custom-audio-duration">{dynamicDuration}</span>
831
+ <button className="ermis-custom-audio-download-btn" onClick={handleDownload} title="Download" type="button">
832
+ <DownloadIcon />
833
+ </button>
321
834
  <audio ref={audioRef} src={src} preload="metadata" className="ermis-custom-audio-hidden" />
322
835
  </div>
323
836
  );
324
837
  };
325
838
 
326
- const VoiceRecordingAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
327
- const src = attachment.asset_url || attachment.url;
328
- if (!src) return null;
839
+ const VoiceRecordingAttachment: React.FC<AttachmentProps> = React.memo(
840
+ ({ attachment }) => {
841
+ const src = attachment.asset_url || attachment.url;
842
+ if (!src) return null;
329
843
 
330
- const durationSec = attachment.duration ?? 0;
331
- const mins = Math.floor(durationSec / 60);
332
- const secs = Math.round(durationSec % 60);
333
- const durationLabel = `${mins}:${secs.toString().padStart(2, '0')}`;
844
+ const durationSec = attachment.duration ?? 0;
845
+ const mins = Math.floor(durationSec / 60);
846
+ const secs = Math.round(durationSec % 60);
847
+ const durationLabel = `${mins}:${secs.toString().padStart(2, '0')}`;
848
+ const fileName = attachment.file_name || attachment.title || 'audio.mp3';
849
+ const uploadProgress = getLocalUploadProgress(attachment);
334
850
 
335
- return <CustomAudioPlayer src={src} durationLabel={durationLabel} />;
336
- }, (prev, next) => {
337
- return (prev.attachment.asset_url || prev.attachment.url) ===
338
- (next.attachment.asset_url || next.attachment.url);
339
- });
851
+ return (
852
+ <div className="ermis-voice-upload-wrap">
853
+ <CustomAudioPlayer src={src} durationLabel={durationLabel} fileName={fileName} />
854
+ {uploadProgress !== undefined && <span className="ermis-voice-upload-progress">{uploadProgress}%</span>}
855
+ </div>
856
+ );
857
+ },
858
+ (prev, next) => {
859
+ return (
860
+ (prev.attachment.asset_url || prev.attachment.url) === (next.attachment.asset_url || next.attachment.url) &&
861
+ getLocalUploadProgress(prev.attachment) === getLocalUploadProgress(next.attachment)
862
+ );
863
+ },
864
+ );
340
865
  (VoiceRecordingAttachment as any).displayName = 'VoiceRecordingAttachment';
341
866
 
342
- const LinkPreviewAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
343
- const url = attachment.link_url || attachment.og_scrape_url || attachment.title_link || attachment.url;
344
- const title = attachment.title;
345
- const description = attachment.text;
346
- const image = attachment.image_url;
867
+ const LinkPreviewAttachment: React.FC<AttachmentProps> = React.memo(
868
+ ({ attachment }) => {
869
+ const url = attachment.link_url || attachment.og_scrape_url || attachment.title_link || attachment.url;
870
+ const title = attachment.title;
871
+ const description = attachment.text;
872
+ const image = attachment.image_url;
347
873
 
348
- const alreadyCached = image ? isImagePreloaded(image) : false;
349
- const [loaded, setLoaded] = useState(alreadyCached);
350
- const imgRef = React.useRef<HTMLImageElement>(null);
874
+ const alreadyCached = image ? isImagePreloaded(image) : false;
875
+ const [loaded, setLoaded] = useState(alreadyCached);
876
+ const imgRef = React.useRef<HTMLImageElement>(null);
351
877
 
352
- useMemo(() => {
353
- if (image) preloadImage(image);
354
- }, [image]);
878
+ useMemo(() => {
879
+ if (image) preloadImage(image);
880
+ }, [image]);
355
881
 
356
- React.useEffect(() => {
357
- if (!loaded && imgRef.current?.complete) {
358
- setLoaded(true);
359
- }
360
- }, [loaded, image]);
882
+ React.useEffect(() => {
883
+ if (!loaded && imgRef.current?.complete) {
884
+ setLoaded(true);
885
+ }
886
+ }, [loaded, image]);
361
887
 
362
- if (!title) return null;
888
+ if (!title) return null;
363
889
 
364
- return (
365
- <a
366
- className="ermis-attachment ermis-attachment--link-preview"
367
- href={url}
368
- target="_blank"
369
- rel="noopener noreferrer"
370
- >
371
- {image && (
372
- <div className="ermis-attachment__link-image-wrapper">
373
- {!loaded && <div className="ermis-attachment-shimmer" />}
374
- <img
375
- ref={imgRef}
376
- className={`ermis-attachment__link-image${loaded ? ' ermis-attachment--loaded' : ''}`}
377
- src={image}
378
- alt={title || 'preview'}
379
- loading="lazy"
380
- onLoad={() => setLoaded(true)}
381
- />
382
- </div>
383
- )}
384
- <div className="ermis-attachment__link-info">
385
- {title && <span className="ermis-attachment__link-title">{title}</span>}
386
- {description && <span className="ermis-attachment__link-description">{description}</span>}
387
- {url && (
388
- <span className="ermis-attachment__link-url">
389
- {new URL(url).hostname}
390
- </span>
890
+ return (
891
+ <a
892
+ className="ermis-attachment ermis-attachment--link-preview"
893
+ href={url}
894
+ target="_blank"
895
+ rel="noopener noreferrer"
896
+ >
897
+ {image && (
898
+ <div className="ermis-attachment__link-image-wrapper">
899
+ {!loaded && <div className="ermis-attachment-shimmer" />}
900
+ <img
901
+ ref={imgRef}
902
+ className={`ermis-attachment__link-image${loaded ? ' ermis-attachment--loaded' : ''}`}
903
+ src={image}
904
+ alt={title || 'preview'}
905
+ loading="lazy"
906
+ onLoad={() => setLoaded(true)}
907
+ />
908
+ </div>
391
909
  )}
392
- </div>
393
- </a>
394
- );
395
- }, (prev, next) => {
396
- return (prev.attachment.link_url || prev.attachment.og_scrape_url || prev.attachment.url) ===
397
- (next.attachment.link_url || next.attachment.og_scrape_url || next.attachment.url);
398
- });
910
+ <div className="ermis-attachment__link-info">
911
+ {title && <span className="ermis-attachment__link-title">{title}</span>}
912
+ {description && <span className="ermis-attachment__link-description">{description}</span>}
913
+ {url && <span className="ermis-attachment__link-url">{new URL(url).hostname}</span>}
914
+ </div>
915
+ </a>
916
+ );
917
+ },
918
+ (prev, next) => {
919
+ return (
920
+ (prev.attachment.link_url || prev.attachment.og_scrape_url || prev.attachment.url) ===
921
+ (next.attachment.link_url || next.attachment.og_scrape_url || next.attachment.url)
922
+ );
923
+ },
924
+ );
399
925
  (LinkPreviewAttachment as any).displayName = 'LinkPreviewAttachment';
400
926
 
401
927
  export const MessageAttachment: React.FC<AttachmentProps> = ({ attachment }) => {
402
928
  if (isImage(attachment)) return <ImageAttachment attachment={attachment} />;
403
929
  if (isVideo(attachment)) return <VideoAttachment attachment={attachment} />;
404
- if (isVoiceRecordingAttachment(attachment)) return <VoiceRecordingAttachment attachment={attachment} />;
930
+ if (isAudio(attachment)) return <VoiceRecordingAttachment attachment={attachment} />;
405
931
  if (isLinkPreviewAttachment(attachment)) return <LinkPreviewAttachment attachment={attachment} />;
406
932
  return <FileAttachment attachment={attachment} />;
407
933
  };
408
934
 
409
- export const AttachmentList: React.FC<{ attachments?: Attachment[] }> = React.memo(({ attachments }) => {
410
- if (!attachments || attachments.length === 0) return null;
411
-
412
- // Group by type
413
- const media = attachments.filter((a) => isImage(a) || isVideo(a));
414
- const files = attachments.filter((a) => !isImage(a) && !isVideo(a) && !isVoiceRecordingAttachment(a) && !isLinkPreviewAttachment(a));
415
- const voices = attachments.filter(isVoiceRecordingAttachment);
416
- const links = attachments.filter(isLinkPreviewAttachment);
417
-
418
- // Lightbox state
419
- const [lightboxOpen, setLightboxOpen] = useState(false);
420
- const [lightboxIndex, setLightboxIndex] = useState(0);
421
-
422
- // Build lightbox items from media attachments
423
- const lightboxItems = useMemo<MediaLightboxItem[]>(() => {
424
- return media.map(att => {
425
- if (isImage(att)) {
935
+ export const AttachmentList: React.FC<{
936
+ attachments?: Array<Attachment | E2eeAttachmentManifest>;
937
+ e2eeGrantReady?: boolean;
938
+ }> = React.memo(
939
+ ({ attachments, e2eeGrantReady = true }) => {
940
+ if (!attachments || attachments.length === 0) return null;
941
+
942
+ // Group by type
943
+ const e2eeAttachments = attachments.filter(isE2eeAttachmentManifest);
944
+ const standardAttachments = attachments.filter((a): a is Attachment => !isE2eeAttachmentManifest(a));
945
+ const media = standardAttachments.filter((a) => isImage(a) || isVideo(a));
946
+ const files = standardAttachments.filter(
947
+ (a) => !isImage(a) && !isVideo(a) && !isVoiceRecordingAttachment(a) && !isLinkPreviewAttachment(a),
948
+ );
949
+ const voices = standardAttachments.filter(isVoiceRecordingAttachment);
950
+ const links = standardAttachments.filter(isLinkPreviewAttachment);
951
+
952
+ // Lightbox state
953
+ const [lightboxOpen, setLightboxOpen] = useState(false);
954
+ const [lightboxIndex, setLightboxIndex] = useState(0);
955
+
956
+ // Build lightbox items from media attachments
957
+ const lightboxItems = useMemo<MediaLightboxItem[]>(() => {
958
+ return media.map((att) => {
959
+ if (isImage(att)) {
960
+ return {
961
+ type: 'image' as const,
962
+ src: att.image_url || att.thumb_url || att.url || '',
963
+ alt: att.file_name || att.title,
964
+ };
965
+ }
426
966
  return {
427
- type: 'image' as const,
428
- src: att.image_url || att.thumb_url || att.url || '',
967
+ type: 'video' as const,
968
+ src: att.asset_url || att.url || '',
429
969
  alt: att.file_name || att.title,
970
+ posterSrc: att.image_url || att.thumb_url,
430
971
  };
431
- }
432
- return {
433
- type: 'video' as const,
434
- src: att.asset_url || att.url || '',
435
- alt: att.file_name || att.title,
436
- posterSrc: att.image_url || att.thumb_url,
437
- };
438
- });
439
- }, [media]);
972
+ });
973
+ }, [media]);
440
974
 
441
- const openLightbox = useCallback((index: number) => {
442
- setLightboxIndex(index);
443
- setLightboxOpen(true);
444
- }, []);
975
+ const openLightbox = useCallback((index: number) => {
976
+ setLightboxIndex(index);
977
+ setLightboxOpen(true);
978
+ }, []);
445
979
 
446
- const closeLightbox = useCallback(() => {
447
- setLightboxOpen(false);
448
- }, []);
980
+ const closeLightbox = useCallback(() => {
981
+ setLightboxOpen(false);
982
+ }, []);
449
983
 
450
- const mediaGridClass = media.length === 1
451
- ? 'ermis-attachment-grid ermis-attachment-grid--single'
452
- : 'ermis-attachment-grid ermis-attachment-grid--multi';
984
+ const mediaGridClass =
985
+ media.length === 1
986
+ ? 'ermis-attachment-grid ermis-attachment-grid--single'
987
+ : 'ermis-attachment-grid ermis-attachment-grid--multi';
453
988
 
454
- return (
455
- <div className="ermis-attachment-list">
456
- {/* Media group: images + videos in grid */}
457
- {media.length > 0 && (
458
- <div className={mediaGridClass}>
459
- {media.map((att, i) => (
460
- isImage(att)
461
- ? <ImageAttachment key={att.id || `img-${i}`} attachment={att} onClick={() => openLightbox(i)} />
462
- : <VideoAttachment key={att.id || `vid-${i}`} attachment={att} onClick={() => openLightbox(i)} />
463
- ))}
464
- </div>
465
- )}
466
- {/* File group */}
467
- {files.map((att, i) => (
468
- <FileAttachment key={att.id || `file-${i}`} attachment={att} />
469
- ))}
470
- {/* Voice recording group */}
471
- {voices.map((att, i) => (
472
- <VoiceRecordingAttachment key={att.id || `voice-${i}`} attachment={att} />
473
- ))}
474
- {/* Link preview group */}
475
- {links.map((att, i) => (
476
- <LinkPreviewAttachment key={att.id || `link-${i}`} attachment={att} />
477
- ))}
478
-
479
- {/* Media Lightbox */}
480
- {lightboxItems.length > 0 && (
481
- <MediaLightbox
482
- items={lightboxItems}
483
- initialIndex={lightboxIndex}
484
- isOpen={lightboxOpen}
485
- onClose={closeLightbox}
486
- />
487
- )}
488
- </div>
489
- );
490
- }, (prev, next) => {
491
- // Skip re-render if same attachment array reference
492
- if (prev.attachments === next.attachments) return true;
493
- if (!prev.attachments || !next.attachments) return false;
494
- if (prev.attachments.length !== next.attachments.length) return false;
495
- return prev.attachments.every((a, i) => a.id === next.attachments![i].id);
496
- });
989
+ return (
990
+ <div className="ermis-attachment-list">
991
+ {/* Media group: images + videos in grid */}
992
+ {media.length > 0 && (
993
+ <div className={mediaGridClass}>
994
+ {media.map((att, i) =>
995
+ isImage(att) ? (
996
+ <ImageAttachment key={att.id || `img-${i}`} attachment={att} onClick={() => openLightbox(i)} />
997
+ ) : (
998
+ <VideoAttachment key={att.id || `vid-${i}`} attachment={att} onClick={() => openLightbox(i)} />
999
+ ),
1000
+ )}
1001
+ </div>
1002
+ )}
1003
+ {/* File group */}
1004
+ {e2eeAttachments.map((att) => (
1005
+ <E2eeAttachment key={att.attachment_id} attachment={att} grantReady={e2eeGrantReady} />
1006
+ ))}
1007
+ {files.map((att, i) => (
1008
+ <FileAttachment key={att.id || `file-${i}`} attachment={att} />
1009
+ ))}
1010
+ {/* Voice recording group */}
1011
+ {voices.map((att, i) => (
1012
+ <VoiceRecordingAttachment key={att.id || `voice-${i}`} attachment={att} />
1013
+ ))}
1014
+ {/* Link preview group */}
1015
+ {links.map((att, i) => (
1016
+ <LinkPreviewAttachment key={att.id || `link-${i}`} attachment={att} />
1017
+ ))}
1018
+
1019
+ {/* Media Lightbox */}
1020
+ {lightboxItems.length > 0 && (
1021
+ <MediaLightbox
1022
+ items={lightboxItems}
1023
+ initialIndex={lightboxIndex}
1024
+ isOpen={lightboxOpen}
1025
+ onClose={closeLightbox}
1026
+ />
1027
+ )}
1028
+ </div>
1029
+ );
1030
+ },
1031
+ (prev, next) => {
1032
+ // Skip re-render if same attachment array reference
1033
+ if (prev.attachments === next.attachments && prev.e2eeGrantReady === next.e2eeGrantReady) return true;
1034
+ if (prev.e2eeGrantReady !== next.e2eeGrantReady) return false;
1035
+ if (!prev.attachments || !next.attachments) return false;
1036
+ if (prev.attachments.length !== next.attachments.length) return false;
1037
+ return prev.attachments.every((a, i) => {
1038
+ const b = next.attachments![i];
1039
+ return attachmentRenderKey(a) === attachmentRenderKey(b);
1040
+ });
1041
+ },
1042
+ );
497
1043
  (AttachmentList as any).displayName = 'AttachmentList';
498
1044
 
499
1045
  /* ----------------------------------------------------------
@@ -515,7 +1061,7 @@ function linkifyText(text: string, keyPrefix: string): React.ReactNode[] {
515
1061
  // Reset lastIndex since we reuse the regex
516
1062
  URL_REGEX.lastIndex = 0;
517
1063
  const isEmail = part.includes('@') && !part.startsWith('http');
518
- const href = isEmail ? `mailto:${part}` : (part.startsWith('http') ? part : `https://${part}`);
1064
+ const href = isEmail ? `mailto:${part}` : part.startsWith('http') ? part : `https://${part}`;
519
1065
  return (
520
1066
  <a
521
1067
  key={`${keyPrefix}-link-${i}`}
@@ -544,7 +1090,7 @@ function renderTextWithMentions(
544
1090
  userMap: Record<string, string>,
545
1091
  onMentionClick?: (userId: string) => void,
546
1092
  ): React.ReactNode {
547
- const mentionedUsers: string[] = (message as any).mentioned_users ?? [];
1093
+ const mentionedUsers: any[] = (message as any).mentioned_users ?? [];
548
1094
  const mentionedAll: boolean = (message as any).mentioned_all ?? false;
549
1095
 
550
1096
  // If no mentions, just linkify the text
@@ -555,10 +1101,17 @@ function renderTextWithMentions(
555
1101
  // Build a list of patterns to replace: @userId → @userName
556
1102
  const replacements: { pattern: string; label: string; id: string }[] = [];
557
1103
 
558
- for (const userId of mentionedUsers) {
1104
+ for (const userItem of mentionedUsers) {
1105
+ if (!userItem) continue;
1106
+ const userId = typeof userItem === 'string' ? userItem : userItem.id;
1107
+ if (!userId) continue;
1108
+
1109
+ const itemObjName = typeof userItem === 'object' ? userItem.name : undefined;
1110
+ const name = userMap[userId] ?? itemObjName ?? userId;
1111
+
559
1112
  replacements.push({
560
1113
  pattern: `@${userId}`,
561
- label: `@${userMap[userId] ?? userId}`,
1114
+ label: `@${name}`,
562
1115
  id: userId,
563
1116
  });
564
1117
  }
@@ -568,9 +1121,7 @@ function renderTextWithMentions(
568
1121
  }
569
1122
 
570
1123
  // Build a regex that matches any of the mention patterns
571
- const escaped = replacements.map((r) =>
572
- r.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
573
- );
1124
+ const escaped = replacements.map((r) => r.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
574
1125
  const regex = new RegExp(`(${escaped.join('|')})`, 'g');
575
1126
 
576
1127
  const parts = text.split(regex);
@@ -583,10 +1134,17 @@ function renderTextWithMentions(
583
1134
  if (info) {
584
1135
  // Mention — render as span, do NOT linkify
585
1136
  return (
586
- <span
587
- key={`mention-${i}`}
1137
+ <span
1138
+ key={`mention-${i}`}
588
1139
  className={`ermis-mention${onMentionClick && info.id !== 'all' ? ' ermis-mention--clickable' : ''}`}
589
- onClick={onMentionClick && info.id !== 'all' ? (e) => { e.stopPropagation(); onMentionClick(info.id); } : undefined}
1140
+ onClick={
1141
+ onMentionClick && info.id !== 'all'
1142
+ ? (e) => {
1143
+ e.stopPropagation();
1144
+ onMentionClick(info.id);
1145
+ }
1146
+ : undefined
1147
+ }
590
1148
  >
591
1149
  {info.label}
592
1150
  </span>
@@ -598,56 +1156,94 @@ function renderTextWithMentions(
598
1156
  }
599
1157
 
600
1158
  /** Regular message: text with @mentions + attachments */
601
- export const RegularMessage: React.FC<MessageRendererProps> = React.memo(({ message, onMentionClick }) => {
602
- const { activeChannel } = useChatClient();
603
-
604
- const userMap = useMemo<Record<string, string>>(() => {
605
- return buildUserMap(activeChannel?.state);
606
- }, [activeChannel?.state]);
607
-
608
- const textContent = message.text
609
- ? renderTextWithMentions(message.text, message, userMap, onMentionClick)
610
- : null;
611
-
612
- const attachmentsToRender = useMemo(() => {
613
- if (!message.attachments || message.attachments.length === 0) return [];
614
-
615
- const text = (message.text || '').trim();
616
- const URL_REGEX_STRICT = /^(https?:\/\/[^\s<>]+|www\.[^\s<>]+)$/;
617
- const isOnlyUrl = URL_REGEX_STRICT.test(text);
618
-
619
- return message.attachments.filter(att => {
620
- if (isLinkPreviewAttachment(att)) return isOnlyUrl;
621
- return true;
622
- });
623
- }, [message.attachments, message.text]);
1159
+ export const RegularMessage: React.FC<MessageRendererProps> = React.memo(
1160
+ ({
1161
+ message,
1162
+ onMentionClick,
1163
+ encryptedMessageLabel = 'Encrypted message',
1164
+ encryptedMessageFailedLabel = 'Encrypted message could not be decrypted',
1165
+ encryptedMessageDecryptingLabel = 'Decrypting encrypted message...',
1166
+ }) => {
1167
+ const { activeChannel } = useChatClient();
1168
+
1169
+ const isEncrypted = message.content_type === 'mls' || Boolean((message as any).mls_ciphertext);
1170
+ const hasRawAttachments = Boolean(message.attachments?.length);
1171
+ const rawText = message.text || '';
1172
+ const isEncryptedSentinelText =
1173
+ hasRawAttachments &&
1174
+ (rawText === 'Encrypted message' ||
1175
+ rawText === 'Encrypted message unavailable' ||
1176
+ rawText === encryptedMessageLabel);
1177
+
1178
+ const userMap = useMemo<Record<string, string>>(() => {
1179
+ return buildUserMap(activeChannel?.state);
1180
+ }, [activeChannel?.state]);
1181
+
1182
+ const textContent =
1183
+ rawText && !isEncryptedSentinelText ? renderTextWithMentions(rawText, message, userMap, onMentionClick) : null;
1184
+
1185
+ const attachmentsToRender = useMemo(() => {
1186
+ if (!message.attachments || message.attachments.length === 0) return [];
1187
+
1188
+ const text = (message.text || '').trim();
1189
+ const URL_REGEX_STRICT = /^(https?:\/\/[^\s<>]+|www\.[^\s<>]+)$/;
1190
+ const isOnlyUrl = URL_REGEX_STRICT.test(text);
1191
+
1192
+ return message.attachments.filter((att) => {
1193
+ if (isLinkPreviewAttachment(att)) return isOnlyUrl;
1194
+ return true;
1195
+ });
1196
+ }, [message.attachments, message.text]);
1197
+
1198
+ const hasAttachments = attachmentsToRender.length > 0;
1199
+ const hasE2eeAttachments = attachmentsToRender.some(isE2eeAttachmentManifest);
1200
+ const messageStatus = (message as any).status;
1201
+ const e2eeGrantReady = !['sending', 'error', 'failed_offline'].includes(messageStatus);
1202
+ const encryptedPlaceholder =
1203
+ isEncrypted && !message.text && !hasAttachments ? (
1204
+ <span className="ermis-message-list__item-text ermis-message-list__item-text--encrypted">
1205
+ {(message as any).e2ee_status === 'failed'
1206
+ ? encryptedMessageFailedLabel
1207
+ : (message as any).e2ee_status === 'decrypting'
1208
+ ? encryptedMessageDecryptingLabel
1209
+ : encryptedMessageLabel}
1210
+ </span>
1211
+ ) : null;
624
1212
 
625
- const hasAttachments = attachmentsToRender.length > 0;
1213
+ if (hasAttachments) {
1214
+ return (
1215
+ <div
1216
+ className={`ermis-message-content--with-attachments${
1217
+ hasE2eeAttachments ? ' ermis-message-content--with-e2ee-attachments' : ''
1218
+ }`}
1219
+ >
1220
+ {textContent && <span className="ermis-message-list__item-text">{textContent}</span>}
1221
+ {encryptedPlaceholder}
1222
+ <AttachmentList attachments={attachmentsToRender} e2eeGrantReady={e2eeGrantReady} />
1223
+ </div>
1224
+ );
1225
+ }
626
1226
 
627
- if (hasAttachments) {
628
1227
  return (
629
- <div className="ermis-message-content--with-attachments">
630
- {textContent && (
631
- <span className="ermis-message-list__item-text">{textContent}</span>
632
- )}
633
- <AttachmentList attachments={attachmentsToRender} />
634
- </div>
1228
+ <>
1229
+ {textContent && <span className="ermis-message-list__item-text">{textContent}</span>}
1230
+ {encryptedPlaceholder}
1231
+ </>
635
1232
  );
636
- }
637
-
638
- return (
639
- <>
640
- {textContent && (
641
- <span className="ermis-message-list__item-text">{textContent}</span>
642
- )}
643
- </>
644
- );
645
- }, (prev, next) => {
646
- return prev.message.id === next.message.id &&
647
- prev.message.updated_at === next.message.updated_at &&
648
- prev.message.text === next.message.text &&
649
- prev.isOwnMessage === next.isOwnMessage;
650
- });
1233
+ },
1234
+ (prev, next) => {
1235
+ return (
1236
+ prev.message.id === next.message.id &&
1237
+ prev.message.updated_at === next.message.updated_at &&
1238
+ prev.message.text === next.message.text &&
1239
+ prev.message.content_type === next.message.content_type &&
1240
+ (prev.message as any).status === (next.message as any).status &&
1241
+ (prev.message as any).e2ee_status === (next.message as any).e2ee_status &&
1242
+ prev.message.attachments === next.message.attachments &&
1243
+ prev.isOwnMessage === next.isOwnMessage
1244
+ );
1245
+ },
1246
+ );
651
1247
  RegularMessage.displayName = 'RegularMessage';
652
1248
 
653
1249
  /** System message: centered info text, parsed from raw format */
@@ -663,11 +1259,7 @@ export const SystemMessage: React.FC<MessageRendererProps> = ({ message, systemM
663
1259
  [message.text, userMap, systemMessageTranslations],
664
1260
  );
665
1261
 
666
- return (
667
- <span className="ermis-message-list__system-text">
668
- {parsedText || message.text}
669
- </span>
670
- );
1262
+ return <span className="ermis-message-list__system-text">{parsedText || message.text}</span>;
671
1263
  };
672
1264
 
673
1265
  /** Signal message: call events */
@@ -678,11 +1270,7 @@ export const SignalMessage: React.FC<MessageRendererProps> = ({ message, signalM
678
1270
  const result = rawText ? parseSignalMessage(rawText, client.userID || '', signalMessageTranslations) : null;
679
1271
 
680
1272
  if (!result) {
681
- return (
682
- <span className="ermis-message-list__signal-text">
683
- {rawText}
684
- </span>
685
- );
1273
+ return <span className="ermis-message-list__signal-text">{rawText}</span>;
686
1274
  }
687
1275
 
688
1276
  const isSuccess = !!result.duration;
@@ -693,27 +1281,39 @@ export const SignalMessage: React.FC<MessageRendererProps> = ({ message, signalM
693
1281
  <div className="ermis-signal-message">
694
1282
  <div className={`ermis-signal-message__icon ermis-signal-message__icon--${colorModifier}`}>
695
1283
  {isAudio ? (
696
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1284
+ <svg
1285
+ width="18"
1286
+ height="18"
1287
+ viewBox="0 0 24 24"
1288
+ fill="none"
1289
+ stroke="currentColor"
1290
+ strokeWidth="2"
1291
+ strokeLinecap="round"
1292
+ strokeLinejoin="round"
1293
+ >
697
1294
  <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
698
1295
  </svg>
699
1296
  ) : (
700
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1297
+ <svg
1298
+ width="18"
1299
+ height="18"
1300
+ viewBox="0 0 24 24"
1301
+ fill="none"
1302
+ stroke="currentColor"
1303
+ strokeWidth="2"
1304
+ strokeLinecap="round"
1305
+ strokeLinejoin="round"
1306
+ >
701
1307
  <polygon points="23 7 16 12 23 17 23 7" />
702
1308
  <rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
703
1309
  </svg>
704
1310
  )}
705
1311
  </div>
706
1312
  <div className="ermis-signal-message__body">
707
- <span className={`ermis-signal-message__text ermis-signal-message__text--${colorModifier}`}>
708
- {result.text}
709
- </span>
710
- {result.duration && (
711
- <span className="ermis-signal-message__duration">{result.duration}</span>
712
- )}
1313
+ <span className={`ermis-signal-message__text ermis-signal-message__text--${colorModifier}`}>{result.text}</span>
1314
+ {result.duration && <span className="ermis-signal-message__duration">{result.duration}</span>}
713
1315
  </div>
714
- <span className="ermis-signal-message__time">
715
- {formatTime(message.created_at)}
716
- </span>
1316
+ <span className="ermis-signal-message__time">{formatTime(message.created_at)}</span>
717
1317
  </div>
718
1318
  );
719
1319
  };
@@ -771,10 +1371,7 @@ export const ErrorMessage: React.FC<MessageRendererProps> = ({ message }) => (
771
1371
  * Map from MessageLabel → component.
772
1372
  * Consumer can override individual renderers via the `messageRenderers` prop.
773
1373
  */
774
- export const defaultMessageRenderers: Record<
775
- MessageLabel,
776
- React.ComponentType<MessageRendererProps>
777
- > = {
1374
+ export const defaultMessageRenderers: Record<MessageLabel, React.ComponentType<MessageRendererProps>> = {
778
1375
  regular: RegularMessage,
779
1376
  system: SystemMessage,
780
1377
  signal: SignalMessage,