@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.
- package/README.md +144 -0
- package/dist/index.cjs +5087 -11279
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +632 -152
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +273 -9
- package/dist/index.d.ts +273 -9
- package/dist/index.mjs +5085 -11295
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/Channel.tsx +0 -3
- package/src/components/ChannelActions.tsx +6 -1
- package/src/components/ChannelHeader.tsx +8 -32
- package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
- package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
- package/src/components/ChannelList.tsx +72 -13
- package/src/components/CreateChannelModal.tsx +131 -12
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +27 -16
- package/src/components/ForwardMessageModal.tsx +11 -3
- package/src/components/MediaLightbox.tsx +444 -304
- package/src/components/MessageActionsBox.tsx +2 -0
- package/src/components/MessageInput.tsx +41 -12
- package/src/components/MessageItem.tsx +70 -25
- package/src/components/MessageQuickReactions.tsx +131 -128
- package/src/components/MessageReactions.tsx +47 -2
- package/src/components/MessageRenderers.tsx +1030 -433
- package/src/components/PinnedMessages.tsx +40 -12
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +20 -5
- package/src/components/TypingIndicator.tsx +3 -3
- package/src/components/UserPicker.tsx +26 -25
- package/src/components/VirtualMessageList.tsx +345 -125
- package/src/context/ChatProvider.tsx +27 -1
- package/src/hooks/useChannelListUpdates.ts +22 -1
- package/src/hooks/useChannelMessages.ts +338 -51
- package/src/hooks/useChannelRowUpdates.ts +18 -6
- package/src/hooks/useChatUser.ts +9 -1
- package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +210 -13
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -6
- package/src/hooks/useMessageActions.ts +14 -8
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useTopicGroupUpdates.ts +49 -11
- package/src/index.ts +23 -0
- package/src/messageTypeUtils.ts +14 -0
- package/src/styles/_channel-info.css +9 -0
- package/src/styles/_channel-list.css +37 -14
- package/src/styles/_media-lightbox.css +36 -3
- package/src/styles/_message-bubble.css +381 -41
- package/src/styles/_message-input.css +8 -0
- package/src/styles/_message-list.css +67 -10
- package/src/styles/_message-quick-reactions.css +101 -59
- package/src/styles/_message-reactions.css +18 -32
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +5 -5
- package/src/styles/_typing-indicator.css +23 -13
- package/src/styles/index.css +1 -0
- package/src/types.ts +115 -1
- package/src/utils/avatarColors.ts +1 -1
- 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 {
|
|
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(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
const [loaded, setLoaded] = useState(alreadyCached);
|
|
30
|
-
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
45
|
+
const clickable = Boolean(onClick);
|
|
31
46
|
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
45
|
-
className=
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
195
|
+
function extensionForName(name: string): string {
|
|
196
|
+
const ext = name.split('.').pop();
|
|
197
|
+
return ext && ext !== name ? ext.toUpperCase() : 'E2EE';
|
|
198
|
+
}
|
|
103
199
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
className="ermis-attachment__file-download"
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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="
|
|
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="
|
|
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
|
|
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
|
|
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 ?
|
|
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">{
|
|
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(
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
874
|
+
const alreadyCached = image ? isImagePreloaded(image) : false;
|
|
875
|
+
const [loaded, setLoaded] = useState(alreadyCached);
|
|
876
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
351
877
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
878
|
+
useMemo(() => {
|
|
879
|
+
if (image) preloadImage(image);
|
|
880
|
+
}, [image]);
|
|
355
881
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
882
|
+
React.useEffect(() => {
|
|
883
|
+
if (!loaded && imgRef.current?.complete) {
|
|
884
|
+
setLoaded(true);
|
|
885
|
+
}
|
|
886
|
+
}, [loaded, image]);
|
|
361
887
|
|
|
362
|
-
|
|
888
|
+
if (!title) return null;
|
|
363
889
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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 (
|
|
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<{
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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: '
|
|
428
|
-
src: att.
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
975
|
+
const openLightbox = useCallback((index: number) => {
|
|
976
|
+
setLightboxIndex(index);
|
|
977
|
+
setLightboxOpen(true);
|
|
978
|
+
}, []);
|
|
445
979
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
980
|
+
const closeLightbox = useCallback(() => {
|
|
981
|
+
setLightboxOpen(false);
|
|
982
|
+
}, []);
|
|
449
983
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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}` :
|
|
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:
|
|
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
|
|
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: `@${
|
|
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={
|
|
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(
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
return
|
|
622
|
-
});
|
|
623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
630
|
-
{textContent &&
|
|
631
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|