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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. package/src/utils.ts +219 -16
@@ -1,436 +1,1045 @@
1
- import React, { useState, useMemo, useCallback } from 'react';
2
- import { preloadImage, isImagePreloaded } from '../utils';
3
- import type { FormatMessageResponse, Attachment, MessageLabel } from '@ermis-network/ermis-chat-sdk';
1
+ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
2
+ import { preloadImage, isImagePreloaded, formatTime } from '../utils';
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';
11
+ import { useDownloadHandler } from '../hooks/useDownloadHandler';
12
+ import { E2EE_PREVIEW_MAX_CONCURRENT, useE2eeAttachmentRenderer } from '../hooks/useE2eeAttachmentRenderer';
6
13
  import { buildUserMap } from '../utils';
7
14
  import { MediaLightbox } from './MediaLightbox';
8
15
  import { getFileIcon } from './ChannelInfo/utils';
9
16
  import type { AttachmentProps, MessageRendererProps, MessageBubbleProps, MediaLightboxItem } from '../types';
10
17
 
11
18
  export type { AttachmentProps, MessageRendererProps, MessageBubbleProps } from '../types';
12
- import {
13
- isVoiceRecordingAttachment,
14
- isLinkPreviewAttachment,
15
- isImage,
16
- isVideo
17
- } from '../messageTypeUtils';
19
+ import { isVoiceRecordingAttachment, isLinkPreviewAttachment, isImage, isVideo, isAudio } from '../messageTypeUtils';
18
20
 
19
21
  /* ----------------------------------------------------------
20
22
  Attachment renderers
21
23
  ---------------------------------------------------------- */
22
- const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment, onClick }) => {
23
- const src = attachment.image_url || attachment.thumb_url || attachment.url;
24
- const thumbSrc = attachment.thumb_url;
25
- 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]);
26
44
 
27
- const alreadyCached = isImagePreloaded(src);
28
- const [loaded, setLoaded] = useState(alreadyCached);
29
- const imgRef = React.useRef<HTMLImageElement>(null);
45
+ const clickable = Boolean(onClick);
46
+
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
+ );
30
100
 
31
- // Trigger background preload (no-op if already cached)
32
- useMemo(() => { preloadImage(src); }, [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
+ }
33
110
 
34
- React.useEffect(() => {
35
- if (!loaded && imgRef.current?.complete) {
36
- setLoaded(true);
37
- }
38
- }, [loaded, src]);
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
+ }
39
115
 
40
- const clickable = Boolean(onClick);
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
+ }
120
+
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
+ }
41
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;
42
167
  return (
43
- <div
44
- className={`ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3${clickable ? ' ermis-attachment--clickable' : ''}`}
45
- onClick={onClick}
46
- role={clickable ? 'button' : undefined}
47
- tabIndex={clickable ? 0 : undefined}
48
- >
49
- {/* Blur placeholder: use thumb if available, otherwise shimmer */}
50
- {!loaded && (
51
- thumbSrc && thumbSrc !== src ? (
52
- <img
53
- className="ermis-attachment-blur-preview"
54
- src={thumbSrc}
55
- alt=""
56
- aria-hidden
57
- />
58
- ) : (
59
- <div className="ermis-attachment-shimmer" />
60
- )
61
- )}
62
- <img
63
- ref={imgRef}
64
- className={`ermis-attachment ermis-attachment--image${loaded ? ' ermis-attachment--loaded' : ''}`}
65
- src={src}
66
- alt={attachment.file_name || attachment.title || 'image'}
67
- loading="lazy"
68
- onLoad={() => setLoaded(true)}
69
- />
70
- {clickable && (
71
- <div className="ermis-attachment__overlay">
72
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
73
- <circle cx="11" cy="11" r="8" />
74
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
75
- <line x1="11" y1="8" x2="11" y2="14" />
76
- <line x1="8" y1="11" x2="14" y2="11" />
77
- </svg>
78
- </div>
79
- )}
80
- </div>
168
+ <span className="ermis-attachment-upload-overlay">
169
+ <span className="ermis-e2ee-attachment-spinner" />
170
+ <span>{progress !== undefined ? `${progress}%` : 'Sending'}</span>
171
+ </span>
81
172
  );
82
- }, (prev, next) => {
83
- const prevSrc = prev.attachment.image_url || prev.attachment.thumb_url || prev.attachment.url;
84
- const nextSrc = next.attachment.image_url || next.attachment.thumb_url || next.attachment.url;
85
- return prevSrc === nextSrc && prev.onClick === next.onClick;
86
- });
87
- (ImageAttachment as any).displayName = 'ImageAttachment';
173
+ }
88
174
 
89
- const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment, onClick }) => {
90
- const src = attachment.asset_url || attachment.url;
91
- const posterSrc = attachment.image_url || attachment.thumb_url;
92
- const blurThumb = attachment.thumb_url;
93
- 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
+ }
94
187
 
95
- const alreadyCached = posterSrc ? isImagePreloaded(posterSrc) : true;
96
- const [loaded, setLoaded] = useState(alreadyCached);
97
- 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
+ }
98
194
 
99
- useMemo(() => {
100
- if (posterSrc) preloadImage(posterSrc);
101
- }, [posterSrc]);
195
+ function extensionForName(name: string): string {
196
+ const ext = name.split('.').pop();
197
+ return ext && ext !== name ? ext.toUpperCase() : 'E2EE';
198
+ }
102
199
 
103
- React.useEffect(() => {
104
- if (!loaded && imgRef.current?.complete) {
105
- 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
+ );
106
462
  }
107
- }, [loaded, posterSrc]);
108
463
 
109
- 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
+ }
110
504
 
111
- // When clickable (lightbox mode): show poster thumbnail + play icon overlay
112
- if (clickable) {
113
505
  return (
114
- <div
115
- className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3 ermis-attachment--clickable"
116
- onClick={onClick}
117
- role="button"
118
- tabIndex={0}
119
- >
120
- {!loaded && (
121
- 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 ? (
122
609
  <img className="ermis-attachment-blur-preview" src={blurThumb} alt="" aria-hidden />
123
610
  ) : (
124
611
  <div className="ermis-attachment-shimmer" />
125
- )
126
- )}
127
- {posterSrc ? (
612
+ ))}
613
+ {posterSrc && !loaded && (
128
614
  <img
129
615
  ref={imgRef}
130
- className={`ermis-attachment ermis-attachment--video-poster${loaded ? ' ermis-attachment--loaded' : ''}`}
131
616
  src={posterSrc}
132
- alt={attachment.file_name || 'video'}
133
- loading="lazy"
617
+ className="ermis-attachment--hidden-loader"
134
618
  onLoad={() => setLoaded(true)}
135
- />
136
- ) : (
137
- <video
138
- className={`ermis-attachment ermis-attachment--video${loaded ? ' ermis-attachment--loaded' : ''}`}
139
- src={src}
140
- preload="metadata"
141
- onLoadedData={() => setLoaded(true)}
619
+ alt="poster-loader"
142
620
  />
143
621
  )}
144
- <div className="ermis-attachment__overlay">
145
- <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
146
- <polygon points="5 3 19 12 5 21 5 3" />
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} />
635
+ </div>
636
+ );
637
+ },
638
+ (prev, next) => {
639
+ return (
640
+ attachmentRenderKey(prev.attachment) === attachmentRenderKey(next.attachment) && prev.onClick === next.onClick
641
+ );
642
+ },
643
+ );
644
+ (VideoAttachment as any).displayName = 'VideoAttachment';
645
+
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
+ );
664
+
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" />
147
694
  </svg>
148
- </div>
695
+ </button>
149
696
  </div>
150
697
  );
151
- }
698
+ },
699
+ (prev, next) => {
700
+ return attachmentRenderKey(prev.attachment) === attachmentRenderKey(next.attachment);
701
+ },
702
+ );
703
+ (FileAttachment as any).displayName = 'FileAttachment';
152
704
 
153
- // Default inline video player (no lightbox)
154
- return (
155
- <div className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3">
156
- {!loaded && (
157
- blurThumb && blurThumb !== posterSrc ? (
158
- <img
159
- className="ermis-attachment-blur-preview"
160
- src={blurThumb}
161
- alt=""
162
- aria-hidden
163
- />
164
- ) : (
165
- <div className="ermis-attachment-shimmer" />
166
- )
167
- )}
168
- {posterSrc && !loaded && (
169
- <img
170
- ref={imgRef}
171
- src={posterSrc}
172
- className="ermis-attachment--hidden-loader"
173
- onLoad={() => setLoaded(true)}
174
- alt="poster-loader"
175
- />
176
- )}
177
- <video
178
- className={`ermis-attachment ermis-attachment--video${loaded || !posterSrc ? ' ermis-attachment--loaded' : ''}`}
179
- src={src}
180
- poster={posterSrc}
181
- controls
182
- preload="metadata"
183
- onLoadedData={() => {
184
- if (!posterSrc) setLoaded(true);
185
- }}
186
- />
187
- </div>
188
- );
189
- }, (prev, next) => {
190
- return (prev.attachment.asset_url || prev.attachment.url) ===
191
- (next.attachment.asset_url || next.attachment.url) && prev.onClick === next.onClick;
192
- });
193
- (VideoAttachment as any).displayName = 'VideoAttachment';
705
+ const PlayIcon = () => (
706
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
707
+ <path d="M8 5v14l11-7z" />
708
+ </svg>
709
+ );
194
710
 
195
- const FileAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
196
- const url = attachment.url || attachment.asset_url;
197
- const name = attachment.file_name || attachment.title || 'File';
198
- const size = attachment.file_size;
199
- const mimeType = attachment.mime_type || attachment.type || '';
200
- const ext = name.split('.').pop()?.toUpperCase() || 'FILE';
201
- const { client } = useChatClient();
711
+ const PauseIcon = () => (
712
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
713
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
714
+ </svg>
715
+ );
716
+
717
+ const MicIcon = () => (
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
+ >
728
+ <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
729
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
730
+ <line x1="12" x2="12" y1="19" y2="22" />
731
+ </svg>
732
+ );
202
733
 
203
- const handleDownload = useCallback(async (e: React.MouseEvent) => {
204
- e.preventDefault();
205
- 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');
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
+ }) => {
756
+ const [isPlaying, setIsPlaying] = useState(false);
757
+ const [progress, setProgress] = useState(0);
758
+ const [dynamicDuration, setDynamicDuration] = useState(durationLabel);
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
+ );
770
+
771
+ React.useEffect(() => {
772
+ const audio = audioRef.current;
773
+ if (!audio) return;
774
+ const updateProgress = () => {
775
+ setProgress((audio.currentTime / audio.duration) * 100 || 0);
776
+ };
777
+ const onEnded = () => {
778
+ setIsPlaying(false);
779
+ setProgress(0);
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
+ };
788
+ audio.addEventListener('timeupdate', updateProgress);
789
+ audio.addEventListener('ended', onEnded);
790
+ audio.addEventListener('loadedmetadata', onLoadedMetadata);
791
+ return () => {
792
+ audio.removeEventListener('timeupdate', updateProgress);
793
+ audio.removeEventListener('ended', onEnded);
794
+ audio.removeEventListener('loadedmetadata', onLoadedMetadata);
795
+ };
796
+ }, [durationLabel]);
797
+
798
+ const togglePlay = () => {
799
+ if (audioRef.current) {
800
+ if (isPlaying) {
801
+ audioRef.current.pause();
802
+ } else {
803
+ audioRef.current.play().catch((e) => console.error(e));
804
+ }
805
+ setIsPlaying(!isPlaying);
806
+ }
807
+ };
808
+
809
+ const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
810
+ const rect = e.currentTarget.getBoundingClientRect();
811
+ const x = e.clientX - rect.left;
812
+ const percentage = Math.max(0, Math.min(1, x / rect.width));
813
+ if (audioRef.current && audioRef.current.duration) {
814
+ audioRef.current.currentTime = percentage * audioRef.current.duration;
815
+ setProgress(percentage * 100);
220
816
  }
221
- }, [client, url, name]);
817
+ };
222
818
 
223
819
  return (
224
- <div className="ermis-attachment ermis-attachment--file">
225
- <span className="ermis-attachment__file-icon">
226
- {getFileIcon(mimeType, name)}
227
- <span className="ermis-attachment__file-ext">{ext}</span>
228
- </span>
229
- <span className="ermis-attachment__file-info">
230
- <span className="ermis-attachment__file-name">{name}</span>
231
- {size && (
232
- <span className="ermis-attachment__file-size">
233
- {typeof size === 'number' ? `${(size / 1024).toFixed(1)} KB` : size}
234
- </span>
235
- )}
236
- </span>
237
- <button
238
- className="ermis-attachment__file-download"
239
- onClick={handleDownload}
240
- title="Download"
241
- type="button"
242
- >
243
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
244
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
245
- <polyline points="7 10 12 15 17 10" />
246
- <line x1="12" y1="15" x2="12" y2="3" />
247
- </svg>
820
+ <div className="ermis-custom-audio-player">
821
+ <button className="ermis-custom-audio-play-btn" onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}>
822
+ {isPlaying ? <PauseIcon /> : <PlayIcon />}
248
823
  </button>
824
+ <div className="ermis-custom-audio-progress-container">
825
+ <div className="ermis-custom-audio-progress-bg" onClick={handleSeek}>
826
+ <div className="ermis-custom-audio-progress-fill" style={{ width: `${progress}%` }} />
827
+ <div className="ermis-custom-audio-progress-thumb" style={{ left: `${progress}%` }} />
828
+ </div>
829
+ </div>
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>
834
+ <audio ref={audioRef} src={src} preload="metadata" className="ermis-custom-audio-hidden" />
249
835
  </div>
250
836
  );
251
- }, (prev, next) => {
252
- return (prev.attachment.url || prev.attachment.asset_url) ===
253
- (next.attachment.url || next.attachment.asset_url);
254
- });
255
- (FileAttachment as any).displayName = 'FileAttachment';
837
+ };
256
838
 
257
- const VoiceRecordingAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
258
- const src = attachment.asset_url || attachment.url;
259
- 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;
260
843
 
261
- const durationSec = attachment.duration ?? 0;
262
- const mins = Math.floor(durationSec / 60);
263
- const secs = Math.round(durationSec % 60);
264
- 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);
265
850
 
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
- );
273
- }, (prev, next) => {
274
- return (prev.attachment.asset_url || prev.attachment.url) ===
275
- (next.attachment.asset_url || next.attachment.url);
276
- });
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
+ );
277
865
  (VoiceRecordingAttachment as any).displayName = 'VoiceRecordingAttachment';
278
866
 
279
- const LinkPreviewAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
280
- const url = attachment.link_url || attachment.og_scrape_url || attachment.title_link || attachment.url;
281
- const title = attachment.title;
282
- const description = attachment.text;
283
- 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;
284
873
 
285
- const alreadyCached = image ? isImagePreloaded(image) : false;
286
- const [loaded, setLoaded] = useState(alreadyCached);
287
- 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);
288
877
 
289
- useMemo(() => {
290
- if (image) preloadImage(image);
291
- }, [image]);
878
+ useMemo(() => {
879
+ if (image) preloadImage(image);
880
+ }, [image]);
292
881
 
293
- React.useEffect(() => {
294
- if (!loaded && imgRef.current?.complete) {
295
- setLoaded(true);
296
- }
297
- }, [loaded, image]);
882
+ React.useEffect(() => {
883
+ if (!loaded && imgRef.current?.complete) {
884
+ setLoaded(true);
885
+ }
886
+ }, [loaded, image]);
298
887
 
299
- if (!title) return null;
888
+ if (!title) return null;
300
889
 
301
- return (
302
- <a
303
- className="ermis-attachment ermis-attachment--link-preview"
304
- href={url}
305
- target="_blank"
306
- rel="noopener noreferrer"
307
- >
308
- {image && (
309
- <div className="ermis-attachment__link-image-wrapper">
310
- {!loaded && <div className="ermis-attachment-shimmer" />}
311
- <img
312
- ref={imgRef}
313
- className={`ermis-attachment__link-image${loaded ? ' ermis-attachment--loaded' : ''}`}
314
- src={image}
315
- alt={title || 'preview'}
316
- loading="lazy"
317
- onLoad={() => setLoaded(true)}
318
- />
319
- </div>
320
- )}
321
- <div className="ermis-attachment__link-info">
322
- {title && <span className="ermis-attachment__link-title">{title}</span>}
323
- {description && <span className="ermis-attachment__link-description">{description}</span>}
324
- {url && (
325
- <span className="ermis-attachment__link-url">
326
- {new URL(url).hostname}
327
- </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>
328
909
  )}
329
- </div>
330
- </a>
331
- );
332
- }, (prev, next) => {
333
- return (prev.attachment.link_url || prev.attachment.og_scrape_url || prev.attachment.url) ===
334
- (next.attachment.link_url || next.attachment.og_scrape_url || next.attachment.url);
335
- });
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
+ );
336
925
  (LinkPreviewAttachment as any).displayName = 'LinkPreviewAttachment';
337
926
 
338
927
  export const MessageAttachment: React.FC<AttachmentProps> = ({ attachment }) => {
339
928
  if (isImage(attachment)) return <ImageAttachment attachment={attachment} />;
340
929
  if (isVideo(attachment)) return <VideoAttachment attachment={attachment} />;
341
- if (isVoiceRecordingAttachment(attachment)) return <VoiceRecordingAttachment attachment={attachment} />;
930
+ if (isAudio(attachment)) return <VoiceRecordingAttachment attachment={attachment} />;
342
931
  if (isLinkPreviewAttachment(attachment)) return <LinkPreviewAttachment attachment={attachment} />;
343
932
  return <FileAttachment attachment={attachment} />;
344
933
  };
345
934
 
346
- export const AttachmentList: React.FC<{ attachments?: Attachment[] }> = React.memo(({ attachments }) => {
347
- if (!attachments || attachments.length === 0) return null;
348
-
349
- // Group by type
350
- const media = attachments.filter((a) => isImage(a) || isVideo(a));
351
- const files = attachments.filter((a) => !isImage(a) && !isVideo(a) && !isVoiceRecordingAttachment(a) && !isLinkPreviewAttachment(a));
352
- const voices = attachments.filter(isVoiceRecordingAttachment);
353
- const links = attachments.filter(isLinkPreviewAttachment);
354
-
355
- // Lightbox state
356
- const [lightboxOpen, setLightboxOpen] = useState(false);
357
- const [lightboxIndex, setLightboxIndex] = useState(0);
358
-
359
- // Build lightbox items from media attachments
360
- const lightboxItems = useMemo<MediaLightboxItem[]>(() => {
361
- return media.map(att => {
362
- 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
+ }
363
966
  return {
364
- type: 'image' as const,
365
- src: att.image_url || att.thumb_url || att.url || '',
967
+ type: 'video' as const,
968
+ src: att.asset_url || att.url || '',
366
969
  alt: att.file_name || att.title,
970
+ posterSrc: att.image_url || att.thumb_url,
367
971
  };
368
- }
369
- return {
370
- type: 'video' as const,
371
- src: att.asset_url || att.url || '',
372
- alt: att.file_name || att.title,
373
- posterSrc: att.image_url || att.thumb_url,
374
- };
375
- });
376
- }, [media]);
972
+ });
973
+ }, [media]);
377
974
 
378
- const openLightbox = useCallback((index: number) => {
379
- setLightboxIndex(index);
380
- setLightboxOpen(true);
381
- }, []);
975
+ const openLightbox = useCallback((index: number) => {
976
+ setLightboxIndex(index);
977
+ setLightboxOpen(true);
978
+ }, []);
382
979
 
383
- const closeLightbox = useCallback(() => {
384
- setLightboxOpen(false);
385
- }, []);
980
+ const closeLightbox = useCallback(() => {
981
+ setLightboxOpen(false);
982
+ }, []);
386
983
 
387
- const mediaGridClass = media.length === 1
388
- ? 'ermis-attachment-grid ermis-attachment-grid--single'
389
- : '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';
390
988
 
391
- return (
392
- <div className="ermis-attachment-list">
393
- {/* Media group: images + videos in grid */}
394
- {media.length > 0 && (
395
- <div className={mediaGridClass}>
396
- {media.map((att, i) => (
397
- isImage(att)
398
- ? <ImageAttachment key={att.id || `img-${i}`} attachment={att} onClick={() => openLightbox(i)} />
399
- : <VideoAttachment key={att.id || `vid-${i}`} attachment={att} onClick={() => openLightbox(i)} />
400
- ))}
401
- </div>
402
- )}
403
- {/* File group */}
404
- {files.map((att, i) => (
405
- <FileAttachment key={att.id || `file-${i}`} attachment={att} />
406
- ))}
407
- {/* Voice recording group */}
408
- {voices.map((att, i) => (
409
- <VoiceRecordingAttachment key={att.id || `voice-${i}`} attachment={att} />
410
- ))}
411
- {/* Link preview group */}
412
- {links.map((att, i) => (
413
- <LinkPreviewAttachment key={att.id || `link-${i}`} attachment={att} />
414
- ))}
415
-
416
- {/* Media Lightbox */}
417
- {lightboxItems.length > 0 && (
418
- <MediaLightbox
419
- items={lightboxItems}
420
- initialIndex={lightboxIndex}
421
- isOpen={lightboxOpen}
422
- onClose={closeLightbox}
423
- />
424
- )}
425
- </div>
426
- );
427
- }, (prev, next) => {
428
- // Skip re-render if same attachment array reference
429
- if (prev.attachments === next.attachments) return true;
430
- if (!prev.attachments || !next.attachments) return false;
431
- if (prev.attachments.length !== next.attachments.length) return false;
432
- return prev.attachments.every((a, i) => a.id === next.attachments![i].id);
433
- });
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
+ );
434
1043
  (AttachmentList as any).displayName = 'AttachmentList';
435
1044
 
436
1045
  /* ----------------------------------------------------------
@@ -452,7 +1061,7 @@ function linkifyText(text: string, keyPrefix: string): React.ReactNode[] {
452
1061
  // Reset lastIndex since we reuse the regex
453
1062
  URL_REGEX.lastIndex = 0;
454
1063
  const isEmail = part.includes('@') && !part.startsWith('http');
455
- const href = isEmail ? `mailto:${part}` : (part.startsWith('http') ? part : `https://${part}`);
1064
+ const href = isEmail ? `mailto:${part}` : part.startsWith('http') ? part : `https://${part}`;
456
1065
  return (
457
1066
  <a
458
1067
  key={`${keyPrefix}-link-${i}`}
@@ -479,8 +1088,9 @@ function renderTextWithMentions(
479
1088
  text: string,
480
1089
  message: FormatMessageResponse,
481
1090
  userMap: Record<string, string>,
1091
+ onMentionClick?: (userId: string) => void,
482
1092
  ): React.ReactNode {
483
- const mentionedUsers: string[] = (message as any).mentioned_users ?? [];
1093
+ const mentionedUsers: any[] = (message as any).mentioned_users ?? [];
484
1094
  const mentionedAll: boolean = (message as any).mentioned_all ?? false;
485
1095
 
486
1096
  // If no mentions, just linkify the text
@@ -489,37 +1099,54 @@ function renderTextWithMentions(
489
1099
  }
490
1100
 
491
1101
  // Build a list of patterns to replace: @userId → @userName
492
- const replacements: { pattern: string; label: string }[] = [];
1102
+ const replacements: { pattern: string; label: string; id: string }[] = [];
1103
+
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;
493
1111
 
494
- for (const userId of mentionedUsers) {
495
1112
  replacements.push({
496
1113
  pattern: `@${userId}`,
497
- label: `@${userMap[userId] ?? userId}`,
1114
+ label: `@${name}`,
1115
+ id: userId,
498
1116
  });
499
1117
  }
500
1118
 
501
1119
  if (mentionedAll) {
502
- replacements.push({ pattern: '@all', label: '@all' });
1120
+ replacements.push({ pattern: '@all', label: '@all', id: 'all' });
503
1121
  }
504
1122
 
505
1123
  // Build a regex that matches any of the mention patterns
506
- const escaped = replacements.map((r) =>
507
- r.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
508
- );
1124
+ const escaped = replacements.map((r) => r.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
509
1125
  const regex = new RegExp(`(${escaped.join('|')})`, 'g');
510
1126
 
511
1127
  const parts = text.split(regex);
512
1128
 
513
1129
  // Map from pattern → label for quick lookup
514
- const patternToLabel = new Map(replacements.map((r) => [r.pattern, r.label]));
1130
+ const patternToLabel = new Map(replacements.map((r) => [r.pattern, r]));
515
1131
 
516
1132
  return parts.flatMap((part, i) => {
517
- const label = patternToLabel.get(part);
518
- if (label) {
1133
+ const info = patternToLabel.get(part);
1134
+ if (info) {
519
1135
  // Mention — render as span, do NOT linkify
520
1136
  return (
521
- <span key={`mention-${i}`} className="ermis-mention">
522
- {label}
1137
+ <span
1138
+ key={`mention-${i}`}
1139
+ className={`ermis-mention${onMentionClick && info.id !== 'all' ? ' ermis-mention--clickable' : ''}`}
1140
+ onClick={
1141
+ onMentionClick && info.id !== 'all'
1142
+ ? (e) => {
1143
+ e.stopPropagation();
1144
+ onMentionClick(info.id);
1145
+ }
1146
+ : undefined
1147
+ }
1148
+ >
1149
+ {info.label}
523
1150
  </span>
524
1151
  );
525
1152
  }
@@ -529,60 +1156,98 @@ function renderTextWithMentions(
529
1156
  }
530
1157
 
531
1158
  /** Regular message: text with @mentions + attachments */
532
- export const RegularMessage: React.FC<MessageRendererProps> = React.memo(({ message }) => {
533
- const { activeChannel } = useChatClient();
534
-
535
- const userMap = useMemo<Record<string, string>>(() => {
536
- return buildUserMap(activeChannel?.state);
537
- }, [activeChannel?.state]);
538
-
539
- const textContent = message.text
540
- ? renderTextWithMentions(message.text, message, userMap)
541
- : null;
542
-
543
- const attachmentsToRender = useMemo(() => {
544
- if (!message.attachments || message.attachments.length === 0) return [];
545
-
546
- const text = (message.text || '').trim();
547
- const URL_REGEX_STRICT = /^(https?:\/\/[^\s<>]+|www\.[^\s<>]+)$/;
548
- const isOnlyUrl = URL_REGEX_STRICT.test(text);
549
-
550
- return message.attachments.filter(att => {
551
- if (isLinkPreviewAttachment(att)) return isOnlyUrl;
552
- return true;
553
- });
554
- }, [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;
555
1212
 
556
- 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
+ }
557
1226
 
558
- if (hasAttachments) {
559
1227
  return (
560
- <div className="ermis-message-content--with-attachments">
561
- {textContent && (
562
- <span className="ermis-message-list__item-text">{textContent}</span>
563
- )}
564
- <AttachmentList attachments={attachmentsToRender} />
565
- </div>
1228
+ <>
1229
+ {textContent && <span className="ermis-message-list__item-text">{textContent}</span>}
1230
+ {encryptedPlaceholder}
1231
+ </>
566
1232
  );
567
- }
568
-
569
- return (
570
- <>
571
- {textContent && (
572
- <span className="ermis-message-list__item-text">{textContent}</span>
573
- )}
574
- </>
575
- );
576
- }, (prev, next) => {
577
- return prev.message.id === next.message.id &&
578
- prev.message.updated_at === next.message.updated_at &&
579
- prev.message.text === next.message.text &&
580
- prev.isOwnMessage === next.isOwnMessage;
581
- });
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
+ );
582
1247
  RegularMessage.displayName = 'RegularMessage';
583
1248
 
584
1249
  /** System message: centered info text, parsed from raw format */
585
- export const SystemMessage: React.FC<MessageRendererProps> = ({ message }) => {
1250
+ export const SystemMessage: React.FC<MessageRendererProps> = ({ message, systemMessageTranslations }) => {
586
1251
  const { activeChannel } = useChatClient();
587
1252
 
588
1253
  const userMap = useMemo<Record<string, string>>(() => {
@@ -590,30 +1255,22 @@ export const SystemMessage: React.FC<MessageRendererProps> = ({ message }) => {
590
1255
  }, [activeChannel?.state]);
591
1256
 
592
1257
  const parsedText = useMemo(
593
- () => (message.text ? parseSystemMessage(message.text, userMap) : ''),
594
- [message.text, userMap],
1258
+ () => (message.text ? parseSystemMessage(message.text, userMap, systemMessageTranslations) : ''),
1259
+ [message.text, userMap, systemMessageTranslations],
595
1260
  );
596
1261
 
597
- return (
598
- <span className="ermis-message-list__system-text">
599
- {parsedText || message.text}
600
- </span>
601
- );
1262
+ return <span className="ermis-message-list__system-text">{parsedText || message.text}</span>;
602
1263
  };
603
1264
 
604
1265
  /** Signal message: call events */
605
- export const SignalMessage: React.FC<MessageRendererProps> = ({ message }) => {
1266
+ export const SignalMessage: React.FC<MessageRendererProps> = ({ message, signalMessageTranslations }) => {
606
1267
  const { client } = useChatClient();
607
1268
 
608
1269
  const rawText = message.text ?? '';
609
- const result = rawText ? parseSignalMessage(rawText, client.userID || '') : null;
1270
+ const result = rawText ? parseSignalMessage(rawText, client.userID || '', signalMessageTranslations) : null;
610
1271
 
611
1272
  if (!result) {
612
- return (
613
- <span className="ermis-message-list__signal-text">
614
- {rawText}
615
- </span>
616
- );
1273
+ return <span className="ermis-message-list__signal-text">{rawText}</span>;
617
1274
  }
618
1275
 
619
1276
  const isSuccess = !!result.duration;
@@ -624,24 +1281,39 @@ export const SignalMessage: React.FC<MessageRendererProps> = ({ message }) => {
624
1281
  <div className="ermis-signal-message">
625
1282
  <div className={`ermis-signal-message__icon ermis-signal-message__icon--${colorModifier}`}>
626
1283
  {isAudio ? (
627
- <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
+ >
628
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" />
629
1295
  </svg>
630
1296
  ) : (
631
- <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
+ >
632
1307
  <polygon points="23 7 16 12 23 17 23 7" />
633
1308
  <rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
634
1309
  </svg>
635
1310
  )}
636
1311
  </div>
637
1312
  <div className="ermis-signal-message__body">
638
- <span className={`ermis-signal-message__text ermis-signal-message__text--${colorModifier}`}>
639
- {result.text}
640
- </span>
641
- {result.duration && (
642
- <span className="ermis-signal-message__duration">{result.duration}</span>
643
- )}
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>}
644
1315
  </div>
1316
+ <span className="ermis-signal-message__time">{formatTime(message.created_at)}</span>
645
1317
  </div>
646
1318
  );
647
1319
  };
@@ -699,10 +1371,7 @@ export const ErrorMessage: React.FC<MessageRendererProps> = ({ message }) => (
699
1371
  * Map from MessageLabel → component.
700
1372
  * Consumer can override individual renderers via the `messageRenderers` prop.
701
1373
  */
702
- export const defaultMessageRenderers: Record<
703
- MessageLabel,
704
- React.ComponentType<MessageRendererProps>
705
- > = {
1374
+ export const defaultMessageRenderers: Record<MessageLabel, React.ComponentType<MessageRendererProps>> = {
706
1375
  regular: RegularMessage,
707
1376
  system: SystemMessage,
708
1377
  signal: SignalMessage,