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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +5087 -11279
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +632 -152
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +273 -9
  7. package/dist/index.d.ts +273 -9
  8. package/dist/index.mjs +5085 -11295
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/components/Channel.tsx +0 -3
  12. package/src/components/ChannelActions.tsx +6 -1
  13. package/src/components/ChannelHeader.tsx +8 -32
  14. package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
  17. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
  18. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
  19. package/src/components/ChannelList.tsx +72 -13
  20. package/src/components/CreateChannelModal.tsx +131 -12
  21. package/src/components/FilesPreview.tsx +8 -12
  22. package/src/components/FlatTopicGroupItem.tsx +27 -16
  23. package/src/components/ForwardMessageModal.tsx +11 -3
  24. package/src/components/MediaLightbox.tsx +444 -304
  25. package/src/components/MessageActionsBox.tsx +2 -0
  26. package/src/components/MessageInput.tsx +41 -12
  27. package/src/components/MessageItem.tsx +70 -25
  28. package/src/components/MessageQuickReactions.tsx +131 -128
  29. package/src/components/MessageReactions.tsx +47 -2
  30. package/src/components/MessageRenderers.tsx +1030 -433
  31. package/src/components/PinnedMessages.tsx +40 -12
  32. package/src/components/QuotedMessagePreview.tsx +99 -8
  33. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  34. package/src/components/RecoveryPin/index.ts +19 -0
  35. package/src/components/TopicList.tsx +20 -5
  36. package/src/components/TypingIndicator.tsx +3 -3
  37. package/src/components/UserPicker.tsx +26 -25
  38. package/src/components/VirtualMessageList.tsx +345 -125
  39. package/src/context/ChatProvider.tsx +27 -1
  40. package/src/hooks/useChannelListUpdates.ts +22 -1
  41. package/src/hooks/useChannelMessages.ts +338 -51
  42. package/src/hooks/useChannelRowUpdates.ts +18 -6
  43. package/src/hooks/useChatUser.ts +9 -1
  44. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  45. package/src/hooks/useE2eeFileUpload.ts +38 -0
  46. package/src/hooks/useFileUpload.ts +25 -5
  47. package/src/hooks/useForwardMessage.ts +210 -13
  48. package/src/hooks/useLoadMessages.ts +16 -4
  49. package/src/hooks/useMentions.ts +60 -6
  50. package/src/hooks/useMessageActions.ts +14 -8
  51. package/src/hooks/useMessageSend.ts +64 -12
  52. package/src/hooks/usePendingE2eeSends.ts +29 -0
  53. package/src/hooks/useRecoveryPin.ts +287 -0
  54. package/src/hooks/useScrollToMessage.ts +29 -4
  55. package/src/hooks/useTopicGroupUpdates.ts +49 -11
  56. package/src/index.ts +23 -0
  57. package/src/messageTypeUtils.ts +14 -0
  58. package/src/styles/_channel-info.css +9 -0
  59. package/src/styles/_channel-list.css +37 -14
  60. package/src/styles/_media-lightbox.css +36 -3
  61. package/src/styles/_message-bubble.css +381 -41
  62. package/src/styles/_message-input.css +8 -0
  63. package/src/styles/_message-list.css +67 -10
  64. package/src/styles/_message-quick-reactions.css +101 -59
  65. package/src/styles/_message-reactions.css +18 -32
  66. package/src/styles/_recovery-pin.css +97 -0
  67. package/src/styles/_tokens.css +5 -5
  68. package/src/styles/_typing-indicator.css +23 -13
  69. package/src/styles/index.css +1 -0
  70. package/src/types.ts +115 -1
  71. package/src/utils/avatarColors.ts +1 -1
  72. package/src/utils.ts +38 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ermis-network/ermis-chat-react",
3
- "version": "2.0.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
- await ch.removeMembers([currentUserId]);
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
- isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
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 = useMemo(() => {
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={32} />
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
- await channel.addMembers(selectedUsers.map(u => u.id));
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
- await channel.removeMembers([currentUserId]);
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
- await channel.removeMembers([memberId]);
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.sendFile(selectedFile, selectedFile.name, selectedFile.type);
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.sendFile(selectedFile, selectedFile.name, selectedFile.type);
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
- export const MediaGridItem: React.FC<{
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
- onClick: (url: string) => void;
8
- }> = React.memo(({ item, onClick }) => {
9
- const src = item.thumb_url || item.url;
10
- const alreadyCached = isImagePreloaded(src);
11
- const [loaded, setLoaded] = useState(alreadyCached);
12
- const imgRef = React.useRef<HTMLImageElement>(null);
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
- // Trigger background preload (no-op if already cached)
15
- useMemo(() => { preloadImage(src); }, [src]);
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
- // Fallback checks for browser cache when JS preload didn't catch it
18
- React.useEffect(() => {
19
- if (!loaded && imgRef.current?.complete) {
20
- setLoaded(true);
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
- }, [loaded, src]);
77
+ await original.download(item.file_name);
78
+ }, [isImage, isVideo, item.file_name, manifest, original]);
23
79
 
24
- const isVideo = item.attachment_type === 'video';
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-item"
29
- onClick={() => onClick(item.url)}
30
- title={item.file_name}
31
- >
32
- {/* Shimmer placeholder while loading */}
33
- {!loaded && <div className="ermis-channel-info__media-shimmer" />}
34
-
35
- {isVideo ? (
36
- <div className="ermis-channel-info__media-video-thumb">
37
- {item.thumb_url ? (
38
- <img
39
- ref={imgRef}
40
- src={item.thumb_url}
41
- alt={item.file_name || 'video'}
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
- <img
63
- ref={imgRef}
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
- }, (prev, next) => prev.item.id === next.item.id);
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
- row,
79
- onClick,
80
- MediaItemComponent = MediaGridItem
81
- }: {
82
- row: AttachmentItem[],
83
- onClick: (url: string) => void,
84
- MediaItemComponent?: React.ComponentType<{ item: AttachmentItem, onClick: (url: string) => void }>
85
- }) => {
86
- return (
87
- <div className="ermis-channel-info__media-grid-row">
88
- {row.map(item => (
89
- <MediaItemComponent key={item.id} item={item} onClick={onClick} />
90
- ))}
91
- {row.length < 3 && Array.from({ length: 3 - row.length }).map((_, i) => (
92
- <div key={`empty-${i}`} className="ermis-channel-info__media-item ermis-channel-info__media-item--empty" />
93
- ))}
94
- </div>
95
- );
96
- }, (prev, next) => {
97
- if (prev.row.length !== next.row.length) return false;
98
- return prev.row.every((item, i) => item.id === next.row[i].id);
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';