@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ermis-network/ermis-chat-react",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "React UI components for Ermis Chat",
|
|
5
5
|
"author": "Ermis",
|
|
6
6
|
"homepage": "https://ermis.network/",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"/src"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ermis-network/ermis-chat-sdk": "
|
|
23
|
+
"@ermis-network/ermis-chat-sdk": "2.0.1",
|
|
24
24
|
"react-ts-audio-recorder": "^1.1.4",
|
|
25
25
|
"virtua": "^0.48.8",
|
|
26
26
|
"frimousse": "^0.3.0"
|
|
@@ -38,9 +38,6 @@ export const Channel: React.FC<ChannelProps> = React.memo(({
|
|
|
38
38
|
// Force re-render when channel info is updated via WS
|
|
39
39
|
const [channelUpdateCount, setChannelUpdateCount] = useState(0);
|
|
40
40
|
useEffect(() => {
|
|
41
|
-
|
|
42
|
-
console.log('---activeChannel--', activeChannel)
|
|
43
|
-
|
|
44
41
|
if (!activeChannel) return;
|
|
45
42
|
const sub = activeChannel.on('channel.updated', () => setChannelUpdateCount((c) => c + 1));
|
|
46
43
|
return () => sub.unsubscribe();
|
|
@@ -216,9 +216,14 @@ export function computeDefaultActions(
|
|
|
216
216
|
isDanger: true,
|
|
217
217
|
onClick: async (ch) => {
|
|
218
218
|
try {
|
|
219
|
-
|
|
219
|
+
if (ch.data?.mls_enabled) {
|
|
220
|
+
await ch.leaveChannelE2ee(currentUserId);
|
|
221
|
+
} else {
|
|
222
|
+
await ch.removeMembers([currentUserId]);
|
|
223
|
+
}
|
|
220
224
|
} catch (e) {
|
|
221
225
|
console.error('Error leaving channel', e);
|
|
226
|
+
throw e;
|
|
222
227
|
}
|
|
223
228
|
},
|
|
224
229
|
});
|
|
@@ -47,9 +47,9 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
|
|
|
47
47
|
const { isPending } = usePendingState(activeChannel, client.userID);
|
|
48
48
|
const callContext = useContext(ErmisCallContext);
|
|
49
49
|
|
|
50
|
-
const isSkipped = client.userID
|
|
51
|
-
? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
|
|
52
|
-
|
|
50
|
+
const isSkipped = client.userID
|
|
51
|
+
? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
|
|
52
|
+
isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
|
|
53
53
|
: false;
|
|
54
54
|
|
|
55
55
|
const actionDisabled = isPending || isSkipped;
|
|
@@ -89,26 +89,7 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
|
|
|
89
89
|
[image, activeChannel?.data?.image, channelUpdateCount],
|
|
90
90
|
);
|
|
91
91
|
|
|
92
|
-
const teamName =
|
|
93
|
-
if (!activeChannel) return undefined;
|
|
94
|
-
|
|
95
|
-
// If it's a topic, derive from parent_cid
|
|
96
|
-
const parentCid = activeChannel.data?.parent_cid as string | undefined;
|
|
97
|
-
if (parentCid && client.activeChannels[parentCid]) {
|
|
98
|
-
return client.activeChannels[parentCid].data?.name || client.activeChannels[parentCid].cid;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// If it's a topics-enabled team channel (the general proxy), the proxy overrides data.name.
|
|
102
|
-
// We can pull the original name from the SDK cache.
|
|
103
|
-
if (hasTopicsEnabled(activeChannel)) {
|
|
104
|
-
const rawChannel = client.activeChannels[activeChannel.cid];
|
|
105
|
-
if (rawChannel && rawChannel.data?.name && rawChannel.data.name !== activeChannel.data?.name) {
|
|
106
|
-
return rawChannel.data.name;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return undefined;
|
|
111
|
-
}, [activeChannel, client.activeChannels]);
|
|
92
|
+
const teamName = undefined;
|
|
112
93
|
|
|
113
94
|
// ── Online Status (direct friend channels only) ──
|
|
114
95
|
const currentUserId = client.userID;
|
|
@@ -172,12 +153,12 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
|
|
|
172
153
|
<div className={`ermis-channel-header${className ? ` ${className}` : ''}`}>
|
|
173
154
|
{activeChannel.data?.parent_cid ? (
|
|
174
155
|
<div className="ermis-channel-header__topic-avatar">
|
|
175
|
-
{channelImage && typeof channelImage === 'string' && channelImage.startsWith('emoji://')
|
|
176
|
-
? channelImage.replace('emoji://', '')
|
|
156
|
+
{channelImage && typeof channelImage === 'string' && channelImage.startsWith('emoji://')
|
|
157
|
+
? channelImage.replace('emoji://', '')
|
|
177
158
|
: '#'}
|
|
178
159
|
</div>
|
|
179
160
|
) : (
|
|
180
|
-
<AvatarComponent image={channelImage} name={teamName || channelName} size={
|
|
161
|
+
<AvatarComponent image={channelImage} name={teamName || channelName} size={44} />
|
|
181
162
|
)}
|
|
182
163
|
|
|
183
164
|
<div className="ermis-channel-header__info">
|
|
@@ -185,11 +166,6 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
|
|
|
185
166
|
renderTitle(activeChannel)
|
|
186
167
|
) : (
|
|
187
168
|
<div className="ermis-channel-header__title-container">
|
|
188
|
-
{teamName && (
|
|
189
|
-
<div className="ermis-channel-header__team-name">
|
|
190
|
-
{teamName}
|
|
191
|
-
</div>
|
|
192
|
-
)}
|
|
193
169
|
<div className="ermis-channel-header__name">{channelName}</div>
|
|
194
170
|
</div>
|
|
195
171
|
)}
|
|
@@ -230,7 +206,7 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
|
|
|
230
206
|
</svg>
|
|
231
207
|
</button>
|
|
232
208
|
)}
|
|
233
|
-
|
|
209
|
+
|
|
234
210
|
{renderVideoCallButton ? (
|
|
235
211
|
renderVideoCallButton(() => callContext.createCall('video', activeChannel.cid || ''), actionDisabled)
|
|
236
212
|
) : (
|
|
@@ -40,7 +40,13 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({
|
|
|
40
40
|
if (selectedUsers.length === 0 || isAdding) return;
|
|
41
41
|
try {
|
|
42
42
|
setIsAdding(true);
|
|
43
|
-
|
|
43
|
+
const memberIds = selectedUsers.map(u => u.id);
|
|
44
|
+
const encryptionManager = channel.getClient().encryptionManager;
|
|
45
|
+
if (channel.data?.mls_enabled && encryptionManager?.initialized && channel.id && channel.cid) {
|
|
46
|
+
await encryptionManager.addMembers(channel.type, channel.id, channel.cid, memberIds);
|
|
47
|
+
} else {
|
|
48
|
+
await channel.addMembers(memberIds);
|
|
49
|
+
}
|
|
44
50
|
onClose();
|
|
45
51
|
} catch (err) {
|
|
46
52
|
console.error('Failed to add members:', err);
|
|
@@ -326,6 +326,14 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
326
326
|
const parentCid = channel?.data?.parent_cid as string | undefined;
|
|
327
327
|
const parentChannel = parentCid && client ? client.activeChannels[parentCid] : undefined;
|
|
328
328
|
let parentChannelName = parentChannel?.data?.name || (parentCid ? 'Unknown' : undefined);
|
|
329
|
+
const e2eeChannel = parentChannel || channel;
|
|
330
|
+
const isE2ee = Boolean(e2eeChannel?.data?.mls_enabled);
|
|
331
|
+
const [isRotatingKey, setIsRotatingKey] = useState(false);
|
|
332
|
+
const [isEnablingE2ee, setIsEnablingE2ee] = useState(false);
|
|
333
|
+
const encryptionEpoch =
|
|
334
|
+
isE2ee && e2eeChannel?.cid && typeof client?.encryptionManager?.getEpoch === 'function'
|
|
335
|
+
? client.encryptionManager.getEpoch(e2eeChannel.cid)
|
|
336
|
+
: undefined;
|
|
329
337
|
|
|
330
338
|
const handleDeleteChannel = useCallback(async () => {
|
|
331
339
|
if (onDeleteChannelProp) return onDeleteChannelProp();
|
|
@@ -341,9 +349,14 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
341
349
|
if (onLeaveChannelProp) return onLeaveChannelProp();
|
|
342
350
|
if (!channel || !currentUserId) return;
|
|
343
351
|
try {
|
|
344
|
-
|
|
352
|
+
if (channel.data?.mls_enabled) {
|
|
353
|
+
await channel.leaveChannelE2ee(currentUserId);
|
|
354
|
+
} else {
|
|
355
|
+
await channel.removeMembers([currentUserId]);
|
|
356
|
+
}
|
|
345
357
|
} catch (e) {
|
|
346
358
|
console.error("Error leaving channel", e);
|
|
359
|
+
throw e;
|
|
347
360
|
}
|
|
348
361
|
}, [channel, currentUserId, onLeaveChannelProp]);
|
|
349
362
|
|
|
@@ -351,9 +364,18 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
351
364
|
if (onRemoveMemberProp) return onRemoveMemberProp(memberId);
|
|
352
365
|
if (!channel) return;
|
|
353
366
|
try {
|
|
354
|
-
|
|
367
|
+
const encryptionManager = channel.getClient().encryptionManager;
|
|
368
|
+
if (channel.data?.mls_enabled) {
|
|
369
|
+
if (!encryptionManager?.initialized || !channel.id || !channel.cid) {
|
|
370
|
+
throw new Error('[E2EE] Cannot remove member from E2EE channel before encryption is initialized');
|
|
371
|
+
}
|
|
372
|
+
await encryptionManager.evictMember(channel.type, channel.id, channel.cid, memberId);
|
|
373
|
+
} else {
|
|
374
|
+
await channel.removeMembers([memberId]);
|
|
375
|
+
}
|
|
355
376
|
} catch (e) {
|
|
356
377
|
console.error("Error removing member", e);
|
|
378
|
+
throw e;
|
|
357
379
|
}
|
|
358
380
|
}, [channel, onRemoveMemberProp]);
|
|
359
381
|
|
|
@@ -393,6 +415,54 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
393
415
|
try { await channel.unblockUser(); } catch (e) { console.error('Error unblocking user', e); }
|
|
394
416
|
}, [channel, onUnblockUserProp]);
|
|
395
417
|
|
|
418
|
+
const handleRotateKey = useCallback(async () => {
|
|
419
|
+
if (!e2eeChannel?.cid || !client?.encryptionManager?.initialized || parentCid) return;
|
|
420
|
+
try {
|
|
421
|
+
setIsRotatingKey(true);
|
|
422
|
+
await client.encryptionManager.keyRotation(e2eeChannel.cid);
|
|
423
|
+
} catch (e) {
|
|
424
|
+
console.error('Error rotating E2EE key', e);
|
|
425
|
+
} finally {
|
|
426
|
+
setIsRotatingKey(false);
|
|
427
|
+
}
|
|
428
|
+
}, [client, e2eeChannel?.cid, parentCid]);
|
|
429
|
+
|
|
430
|
+
const handleEnableE2ee = useCallback(async () => {
|
|
431
|
+
if (!channel?.id || !channel?.cid || !client?.encryptionManager?.initialized || parentCid || isE2ee) return;
|
|
432
|
+
try {
|
|
433
|
+
setIsEnablingE2ee(true);
|
|
434
|
+
const memberUserIds = Object.keys(channel.state?.members || {});
|
|
435
|
+
const recoveryPolicy = (channel.data as any)?.e2ee_recovery_policy || 'member_assisted';
|
|
436
|
+
const result = await client.encryptionManager.enableE2ee(
|
|
437
|
+
channel.type,
|
|
438
|
+
channel.id,
|
|
439
|
+
channel.cid,
|
|
440
|
+
memberUserIds,
|
|
441
|
+
recoveryPolicy,
|
|
442
|
+
);
|
|
443
|
+
channel.data = {
|
|
444
|
+
...channel.data,
|
|
445
|
+
mls_enabled: true,
|
|
446
|
+
e2ee_recovery_policy:
|
|
447
|
+
result?.channel?.e2ee_recovery_policy || result?.e2ee_recovery_policy || recoveryPolicy,
|
|
448
|
+
mls_enabled_at: result?.channel?.mls_enabled_at || result?.mls_enabled_at || new Date().toISOString(),
|
|
449
|
+
mls_epoch: result?.channel?.mls_epoch ?? result?.epoch ?? channel.data?.mls_epoch,
|
|
450
|
+
} as any;
|
|
451
|
+
channel.getClient().dispatchEvent({
|
|
452
|
+
type: 'channel.updated',
|
|
453
|
+
cid: channel.cid,
|
|
454
|
+
channel_type: channel.type,
|
|
455
|
+
channel_id: channel.id,
|
|
456
|
+
channel: channel.data,
|
|
457
|
+
user: channel.getClient().user,
|
|
458
|
+
} as any);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
console.error('Error enabling E2EE', e);
|
|
461
|
+
} finally {
|
|
462
|
+
setIsEnablingE2ee(false);
|
|
463
|
+
}
|
|
464
|
+
}, [channel, client, parentCid, isE2ee]);
|
|
465
|
+
|
|
396
466
|
const handlePinChannel = useCallback(async () => {
|
|
397
467
|
if (onPinChannelProp) return onPinChannelProp();
|
|
398
468
|
if (!channel) return;
|
|
@@ -515,6 +585,8 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
515
585
|
isTeamChannel={isTeamChannel}
|
|
516
586
|
parentChannelName={finalParentChannelName}
|
|
517
587
|
isTopic={isTopic}
|
|
588
|
+
isE2ee={isE2ee}
|
|
589
|
+
encryptionEpoch={encryptionEpoch}
|
|
518
590
|
/>
|
|
519
591
|
|
|
520
592
|
{isBanned && (
|
|
@@ -540,6 +612,7 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
540
612
|
<>
|
|
541
613
|
{!isPreviewMode && (
|
|
542
614
|
<ActionsComponent
|
|
615
|
+
channel={channel}
|
|
543
616
|
onSearchClick={() => setShowSearchPanel(true)}
|
|
544
617
|
onSettingsClick={() => setShowSettingsPanel(true)}
|
|
545
618
|
onLeaveChannel={handleLeaveChannel}
|
|
@@ -560,6 +633,13 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
560
633
|
isPinned={isPinned}
|
|
561
634
|
topicsEnabled={channel?.data?.topics_enabled === true}
|
|
562
635
|
currentUserRole={currentUserRole}
|
|
636
|
+
isE2ee={isE2ee}
|
|
637
|
+
encryptionInitialized={Boolean(client?.encryptionManager?.initialized)}
|
|
638
|
+
encryptionEpoch={encryptionEpoch}
|
|
639
|
+
onRotateKey={!isTopic && isE2ee && canManageChannel(currentUserRole) ? handleRotateKey : undefined}
|
|
640
|
+
rotateKeyDisabled={isRotatingKey || isBlocked || isClosedTopic}
|
|
641
|
+
onEnableE2ee={!isTopic && !isE2ee && currentUserRole === CHANNEL_ROLES.OWNER ? handleEnableE2ee : undefined}
|
|
642
|
+
enableE2eeDisabled={isEnablingE2ee || isBlocked || isClosedTopic || !client?.encryptionManager?.initialized}
|
|
563
643
|
searchLabel={actionsSearchLabel}
|
|
564
644
|
settingsLabel={actionsSettingsLabel}
|
|
565
645
|
deleteLabel={actionsDeleteLabel}
|
|
@@ -122,7 +122,7 @@ export const EditChannelModal: React.FC<EditChannelModalProps> = React.memo(({
|
|
|
122
122
|
// If consumer provides custom save handler, delegate entirely
|
|
123
123
|
if (onSave) {
|
|
124
124
|
if (selectedFile) {
|
|
125
|
-
const response = await channel.
|
|
125
|
+
const response = await channel.uploadFilePresigned(selectedFile, selectedFile.name, selectedFile.type);
|
|
126
126
|
(payload || {} as EditChannelData).image = response.file;
|
|
127
127
|
}
|
|
128
128
|
await onSave(payload || {});
|
|
@@ -135,7 +135,7 @@ export const EditChannelModal: React.FC<EditChannelModalProps> = React.memo(({
|
|
|
135
135
|
|
|
136
136
|
// Upload image if changed
|
|
137
137
|
if (selectedFile) {
|
|
138
|
-
const response = await channel.
|
|
138
|
+
const response = await channel.uploadFilePresigned(selectedFile, selectedFile.name, selectedFile.type);
|
|
139
139
|
finalPayload.image = response.file;
|
|
140
140
|
}
|
|
141
141
|
|
|
@@ -1,100 +1,237 @@
|
|
|
1
|
-
import React, { useState, useMemo } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
|
2
2
|
import { preloadImage, isImagePreloaded } from '../../utils';
|
|
3
|
-
import type { AttachmentItem } from '../../types';
|
|
3
|
+
import type { AttachmentItem, MediaLightboxItem } from '../../types';
|
|
4
|
+
import { MediaLightbox } from '../MediaLightbox';
|
|
5
|
+
import { useChatClient } from '../../hooks/useChatClient';
|
|
6
|
+
import { E2EE_PREVIEW_MAX_CONCURRENT, useE2eeAttachmentRenderer } from '../../hooks/useE2eeAttachmentRenderer';
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
let activeChannelInfoPreviewLoads = 0;
|
|
9
|
+
const queuedChannelInfoPreviewLoads: Array<() => void> = [];
|
|
10
|
+
|
|
11
|
+
function scheduleChannelInfoPreviewLoad(load: () => Promise<unknown>): void {
|
|
12
|
+
const run = () => {
|
|
13
|
+
activeChannelInfoPreviewLoads += 1;
|
|
14
|
+
void load().finally(() => {
|
|
15
|
+
activeChannelInfoPreviewLoads = Math.max(0, activeChannelInfoPreviewLoads - 1);
|
|
16
|
+
const next = queuedChannelInfoPreviewLoads.shift();
|
|
17
|
+
if (next) next();
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
if (activeChannelInfoPreviewLoads < E2EE_PREVIEW_MAX_CONCURRENT) run();
|
|
21
|
+
else queuedChannelInfoPreviewLoads.push(run);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const E2eeMediaGridItem: React.FC<{
|
|
6
25
|
item: AttachmentItem;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
26
|
+
}> = ({ item }) => {
|
|
27
|
+
const { activeChannel } = useChatClient();
|
|
28
|
+
const previewRef = useRef<HTMLDivElement | null>(null);
|
|
29
|
+
const manifest = item.e2ee_manifest;
|
|
30
|
+
const preview = useE2eeAttachmentRenderer(activeChannel, manifest, 'preview');
|
|
31
|
+
const original = useE2eeAttachmentRenderer(activeChannel, manifest, 'original');
|
|
32
|
+
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
33
|
+
const hasPreview = Boolean(manifest?.assets.some((asset) => asset.kind === 'preview'));
|
|
34
|
+
const isVideo = item.attachment_type === 'video';
|
|
35
|
+
const isImage = item.attachment_type === 'image';
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!manifest || !hasPreview || preview.url || preview.loading || preview.error) return;
|
|
39
|
+
const element = previewRef.current;
|
|
40
|
+
if (!element || typeof IntersectionObserver === 'undefined') {
|
|
41
|
+
scheduleChannelInfoPreviewLoad(preview.load);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
let scheduled = false;
|
|
45
|
+
const observer = new IntersectionObserver(
|
|
46
|
+
(entries) => {
|
|
47
|
+
if (scheduled) return;
|
|
48
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
49
|
+
scheduled = true;
|
|
50
|
+
observer.disconnect();
|
|
51
|
+
scheduleChannelInfoPreviewLoad(preview.load);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{ rootMargin: '120px' },
|
|
55
|
+
);
|
|
56
|
+
observer.observe(element);
|
|
57
|
+
return () => observer.disconnect();
|
|
58
|
+
}, [hasPreview, manifest, preview.error, preview.load, preview.loading, preview.url]);
|
|
13
59
|
|
|
14
|
-
|
|
15
|
-
|
|
60
|
+
const progressLabel = original.progress?.percentage
|
|
61
|
+
? `${original.progress.phase} ${original.progress.percentage}%`
|
|
62
|
+
: original.loading
|
|
63
|
+
? original.progress?.phase || 'Loading'
|
|
64
|
+
: undefined;
|
|
16
65
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
66
|
+
const openOriginal = useCallback(async () => {
|
|
67
|
+
if (!manifest) return;
|
|
68
|
+
if (isImage || isVideo) {
|
|
69
|
+
setLightboxOpen(true);
|
|
70
|
+
if (isVideo && !original.streamUrl && !original.streamLoading) {
|
|
71
|
+
void original.loadStream().then((streamUrl) => {
|
|
72
|
+
if (!streamUrl && !original.url && !original.loading) void original.load();
|
|
73
|
+
});
|
|
74
|
+
} else if (!original.url && !original.loading && !original.streamUrl) void original.load();
|
|
75
|
+
return;
|
|
21
76
|
}
|
|
22
|
-
|
|
77
|
+
await original.download(item.file_name);
|
|
78
|
+
}, [isImage, isVideo, item.file_name, manifest, original]);
|
|
23
79
|
|
|
24
|
-
const
|
|
80
|
+
const lightboxItems = useMemo<MediaLightboxItem[]>(
|
|
81
|
+
() => [
|
|
82
|
+
{
|
|
83
|
+
type: isVideo ? 'video' : 'image',
|
|
84
|
+
src: original.streamUrl || original.url,
|
|
85
|
+
posterSrc: preview.url,
|
|
86
|
+
alt: item.file_name,
|
|
87
|
+
loading: original.loading || (lightboxOpen && !original.streamUrl && !original.url && !original.error),
|
|
88
|
+
progressLabel,
|
|
89
|
+
download: async () => {
|
|
90
|
+
await original.download(item.file_name);
|
|
91
|
+
},
|
|
92
|
+
onPlaybackError: async () => {
|
|
93
|
+
await original.disposeStream();
|
|
94
|
+
if (!original.url && !original.loading) await original.load();
|
|
95
|
+
},
|
|
96
|
+
onDispose: original.disposeStream,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
[isVideo, item.file_name, lightboxOpen, original, preview.url, progressLabel],
|
|
100
|
+
);
|
|
25
101
|
|
|
26
102
|
return (
|
|
27
|
-
<div
|
|
28
|
-
className="ermis-channel-info__media-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
loading="lazy"
|
|
43
|
-
decoding="async"
|
|
44
|
-
onLoad={() => setLoaded(true)}
|
|
45
|
-
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
46
|
-
/>
|
|
47
|
-
) : (
|
|
48
|
-
<video
|
|
49
|
-
src={item.url}
|
|
50
|
-
preload="metadata"
|
|
51
|
-
onLoadedData={() => setLoaded(true)}
|
|
52
|
-
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
53
|
-
/>
|
|
103
|
+
<div className="ermis-channel-info__media-item" onClick={openOriginal} ref={previewRef} title={item.file_name}>
|
|
104
|
+
{!preview.url && <div className="ermis-channel-info__media-shimmer" />}
|
|
105
|
+
{preview.url ? (
|
|
106
|
+
<div className={isVideo ? 'ermis-channel-info__media-video-thumb' : undefined}>
|
|
107
|
+
<img src={preview.url} alt={item.file_name || 'encrypted media'} loading="lazy" decoding="async" />
|
|
108
|
+
{(isVideo || original.loading) && (
|
|
109
|
+
<div className="ermis-channel-info__media-play-icon">
|
|
110
|
+
{original.loading ? (
|
|
111
|
+
<span className="ermis-channel-info__media-spinner" />
|
|
112
|
+
) : (
|
|
113
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
114
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
115
|
+
</svg>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
54
118
|
)}
|
|
119
|
+
</div>
|
|
120
|
+
) : (
|
|
121
|
+
<div className="ermis-channel-info__media-video-thumb">
|
|
55
122
|
<div className="ermis-channel-info__media-play-icon">
|
|
56
123
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
57
124
|
<polygon points="5 3 19 12 5 21 5 3" />
|
|
58
125
|
</svg>
|
|
59
126
|
</div>
|
|
60
127
|
</div>
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
src={src}
|
|
65
|
-
alt={item.file_name || 'media'}
|
|
66
|
-
loading="lazy"
|
|
67
|
-
decoding="async"
|
|
68
|
-
onLoad={() => setLoaded(true)}
|
|
69
|
-
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
70
|
-
/>
|
|
128
|
+
)}
|
|
129
|
+
{lightboxOpen && (
|
|
130
|
+
<MediaLightbox items={lightboxItems} isOpen={lightboxOpen} onClose={() => setLightboxOpen(false)} />
|
|
71
131
|
)}
|
|
72
132
|
</div>
|
|
73
133
|
);
|
|
74
|
-
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const MediaGridItem: React.FC<{
|
|
137
|
+
item: AttachmentItem;
|
|
138
|
+
onClick: (url: string) => void;
|
|
139
|
+
}> = React.memo(
|
|
140
|
+
({ item, onClick }) => {
|
|
141
|
+
if (item.e2ee_manifest || item.e2ee_manifest_missing) return <E2eeMediaGridItem item={item} />;
|
|
142
|
+
const src = item.thumb_url || item.url;
|
|
143
|
+
const alreadyCached = isImagePreloaded(src);
|
|
144
|
+
const [loaded, setLoaded] = useState(alreadyCached);
|
|
145
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
146
|
+
|
|
147
|
+
// Trigger background preload (no-op if already cached)
|
|
148
|
+
useMemo(() => {
|
|
149
|
+
preloadImage(src);
|
|
150
|
+
}, [src]);
|
|
151
|
+
|
|
152
|
+
// Fallback checks for browser cache when JS preload didn't catch it
|
|
153
|
+
React.useEffect(() => {
|
|
154
|
+
if (!loaded && imgRef.current?.complete) {
|
|
155
|
+
setLoaded(true);
|
|
156
|
+
}
|
|
157
|
+
}, [loaded, src]);
|
|
158
|
+
|
|
159
|
+
const isVideo = item.attachment_type === 'video';
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="ermis-channel-info__media-item" onClick={() => onClick(item.url)} title={item.file_name}>
|
|
163
|
+
{/* Shimmer placeholder while loading */}
|
|
164
|
+
{!loaded && <div className="ermis-channel-info__media-shimmer" />}
|
|
165
|
+
|
|
166
|
+
{isVideo ? (
|
|
167
|
+
<div className="ermis-channel-info__media-video-thumb">
|
|
168
|
+
{item.thumb_url ? (
|
|
169
|
+
<img
|
|
170
|
+
ref={imgRef}
|
|
171
|
+
src={item.thumb_url}
|
|
172
|
+
alt={item.file_name || 'video'}
|
|
173
|
+
loading="lazy"
|
|
174
|
+
decoding="async"
|
|
175
|
+
onLoad={() => setLoaded(true)}
|
|
176
|
+
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
177
|
+
/>
|
|
178
|
+
) : (
|
|
179
|
+
<video
|
|
180
|
+
src={item.url}
|
|
181
|
+
preload="metadata"
|
|
182
|
+
onLoadedData={() => setLoaded(true)}
|
|
183
|
+
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
<div className="ermis-channel-info__media-play-icon">
|
|
187
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
188
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
189
|
+
</svg>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
) : (
|
|
193
|
+
<img
|
|
194
|
+
ref={imgRef}
|
|
195
|
+
src={src}
|
|
196
|
+
alt={item.file_name || 'media'}
|
|
197
|
+
loading="lazy"
|
|
198
|
+
decoding="async"
|
|
199
|
+
onLoad={() => setLoaded(true)}
|
|
200
|
+
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
(prev, next) => prev.item.id === next.item.id,
|
|
207
|
+
);
|
|
75
208
|
(MediaGridItem as any).displayName = 'MediaGridItem';
|
|
76
209
|
|
|
77
|
-
export const MediaRow = React.memo(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
210
|
+
export const MediaRow = React.memo(
|
|
211
|
+
({
|
|
212
|
+
row,
|
|
213
|
+
onClick,
|
|
214
|
+
MediaItemComponent = MediaGridItem,
|
|
215
|
+
}: {
|
|
216
|
+
row: AttachmentItem[];
|
|
217
|
+
onClick: (url: string) => void;
|
|
218
|
+
MediaItemComponent?: React.ComponentType<{ item: AttachmentItem; onClick: (url: string) => void }>;
|
|
219
|
+
}) => {
|
|
220
|
+
return (
|
|
221
|
+
<div className="ermis-channel-info__media-grid-row">
|
|
222
|
+
{row.map((item) => (
|
|
223
|
+
<MediaItemComponent key={item.id} item={item} onClick={onClick} />
|
|
224
|
+
))}
|
|
225
|
+
{row.length < 3 &&
|
|
226
|
+
Array.from({ length: 3 - row.length }).map((_, i) => (
|
|
227
|
+
<div key={`empty-${i}`} className="ermis-channel-info__media-item ermis-channel-info__media-item--empty" />
|
|
228
|
+
))}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
},
|
|
232
|
+
(prev, next) => {
|
|
233
|
+
if (prev.row.length !== next.row.length) return false;
|
|
234
|
+
return prev.row.every((item, i) => item.id === next.row[i].id);
|
|
235
|
+
},
|
|
236
|
+
);
|
|
100
237
|
(MediaRow as any).displayName = 'MediaRow';
|