@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.
- package/README.md +144 -0
- package/dist/index.cjs +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- 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 {
|
|
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(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
44
|
-
className=
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
195
|
+
function extensionForName(name: string): string {
|
|
196
|
+
const ext = name.split('.').pop();
|
|
197
|
+
return ext && ext !== name ? ext.toUpperCase() : 'E2EE';
|
|
198
|
+
}
|
|
102
199
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
}
|
|
817
|
+
};
|
|
222
818
|
|
|
223
819
|
return (
|
|
224
|
-
<div className="ermis-
|
|
225
|
-
<
|
|
226
|
-
{
|
|
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
|
-
}
|
|
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(
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
874
|
+
const alreadyCached = image ? isImagePreloaded(image) : false;
|
|
875
|
+
const [loaded, setLoaded] = useState(alreadyCached);
|
|
876
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
288
877
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
878
|
+
useMemo(() => {
|
|
879
|
+
if (image) preloadImage(image);
|
|
880
|
+
}, [image]);
|
|
292
881
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
882
|
+
React.useEffect(() => {
|
|
883
|
+
if (!loaded && imgRef.current?.complete) {
|
|
884
|
+
setLoaded(true);
|
|
885
|
+
}
|
|
886
|
+
}, [loaded, image]);
|
|
298
887
|
|
|
299
|
-
|
|
888
|
+
if (!title) return null;
|
|
300
889
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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 (
|
|
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<{
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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: '
|
|
365
|
-
src: att.
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
975
|
+
const openLightbox = useCallback((index: number) => {
|
|
976
|
+
setLightboxIndex(index);
|
|
977
|
+
setLightboxOpen(true);
|
|
978
|
+
}, []);
|
|
382
979
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
980
|
+
const closeLightbox = useCallback(() => {
|
|
981
|
+
setLightboxOpen(false);
|
|
982
|
+
}, []);
|
|
386
983
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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}` :
|
|
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:
|
|
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: `@${
|
|
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
|
|
1130
|
+
const patternToLabel = new Map(replacements.map((r) => [r.pattern, r]));
|
|
515
1131
|
|
|
516
1132
|
return parts.flatMap((part, i) => {
|
|
517
|
-
const
|
|
518
|
-
if (
|
|
1133
|
+
const info = patternToLabel.get(part);
|
|
1134
|
+
if (info) {
|
|
519
1135
|
// Mention — render as span, do NOT linkify
|
|
520
1136
|
return (
|
|
521
|
-
<span
|
|
522
|
-
{
|
|
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(
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
return
|
|
553
|
-
});
|
|
554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
561
|
-
{textContent &&
|
|
562
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|