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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. package/src/utils.ts +219 -16
@@ -1,314 +1,476 @@
1
1
  import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
2
2
  import ReactDOM from 'react-dom';
3
3
  import { preloadImage } from '../utils';
4
- import { useChatClient } from '../hooks/useChatClient';
5
- import type { MediaLightboxProps } from '../types';
6
-
7
- /** Extract a reasonable filename from a URL or alt text */
8
- const getFilename = (src: string, alt?: string): string => {
9
- if (alt) return alt;
10
- try {
11
- const pathname = new URL(src).pathname;
12
- const segments = pathname.split('/');
13
- return segments[segments.length - 1] || 'download';
14
- } catch {
15
- return 'download';
16
- }
17
- };
4
+ import { useDownloadHandler } from '../hooks/useDownloadHandler';
5
+ import type { MediaLightboxItem, MediaLightboxProps } from '../types';
6
+
7
+ /** Max retry attempts for video loading (CDN may not be ready for large uploads) */
8
+ const VIDEO_MAX_RETRIES = 3;
9
+ /** Base delay in ms for exponential backoff: 1s, 2s, 4s */
10
+ const VIDEO_RETRY_BASE_DELAY = 1000;
18
11
 
19
12
  /**
20
13
  * MediaLightbox – full-screen overlay for viewing images & videos.
21
14
  * Supports prev/next navigation, keyboard controls, and image zoom.
22
15
  * Renders via React portal into document.body.
23
16
  */
24
- export const MediaLightbox: React.FC<MediaLightboxProps> = React.memo(({
25
- items,
26
- initialIndex = 0,
27
- isOpen,
28
- onClose,
29
- }) => {
30
- const [currentIndex, setCurrentIndex] = useState(initialIndex);
31
- const [zoom, setZoom] = useState(1);
32
- const [pan, setPan] = useState({ x: 0, y: 0 });
33
- const [isDragging, setIsDragging] = useState(false);
34
- const dragStart = useRef({ x: 0, y: 0 });
35
- const panStart = useRef({ x: 0, y: 0 });
36
- const videoRef = useRef<HTMLVideoElement>(null);
37
- const containerRef = useRef<HTMLDivElement>(null);
38
-
39
- // Reset state when opening or when items change
40
- useEffect(() => {
41
- if (isOpen) {
42
- setCurrentIndex(initialIndex);
43
- setZoom(1);
44
- setPan({ x: 0, y: 0 });
45
- }
46
- }, [isOpen, initialIndex]);
47
-
48
- // Preload adjacent images
49
- useEffect(() => {
50
- if (!isOpen) return;
51
- const preloadIdx = [currentIndex - 1, currentIndex + 1];
52
- preloadIdx.forEach(idx => {
53
- if (idx >= 0 && idx < items.length && items[idx].type === 'image') {
54
- preloadImage(items[idx].src);
17
+ export const MediaLightbox: React.FC<MediaLightboxProps> = React.memo(
18
+ ({ items, initialIndex = 0, isOpen, onClose }) => {
19
+ const [currentIndex, setCurrentIndex] = useState(initialIndex);
20
+ const [zoom, setZoom] = useState(1);
21
+ const [pan, setPan] = useState({ x: 0, y: 0 });
22
+ const [isDragging, setIsDragging] = useState(false);
23
+ const dragStart = useRef({ x: 0, y: 0 });
24
+ const panStart = useRef({ x: 0, y: 0 });
25
+ const videoRef = useRef<HTMLVideoElement>(null);
26
+ const containerRef = useRef<HTMLDivElement>(null);
27
+ const currentDisposeRef = useRef<MediaLightboxItem['onDispose']>();
28
+
29
+ // Video retry state — handles CDN not-ready for large recently-uploaded files
30
+ const [videoRetryCount, setVideoRetryCount] = useState(0);
31
+ const [videoLoading, setVideoLoading] = useState(false);
32
+ const videoRetryTimerRef = useRef<ReturnType<typeof setTimeout>>();
33
+ const pendingVideoSeekTimeRef = useRef<number | undefined>();
34
+
35
+ // Reset state when opening or when items change
36
+ useEffect(() => {
37
+ if (isOpen) {
38
+ setCurrentIndex(initialIndex);
39
+ setZoom(1);
40
+ setPan({ x: 0, y: 0 });
41
+ setVideoRetryCount(0);
42
+ setVideoLoading(false);
43
+ pendingVideoSeekTimeRef.current = undefined;
55
44
  }
45
+ return () => {
46
+ if (videoRetryTimerRef.current) clearTimeout(videoRetryTimerRef.current);
47
+ };
48
+ }, [isOpen, initialIndex]);
49
+
50
+ // Preload adjacent images
51
+ useEffect(() => {
52
+ if (!isOpen) return;
53
+ const preloadIdx = [currentIndex - 1, currentIndex + 1];
54
+ preloadIdx.forEach((idx) => {
55
+ if (idx >= 0 && idx < items.length && items[idx].type === 'image' && items[idx].src) {
56
+ preloadImage(items[idx].src);
57
+ }
58
+ });
59
+ }, [isOpen, currentIndex, items]);
60
+
61
+ useEffect(() => {
62
+ currentDisposeRef.current = items[currentIndex]?.onDispose;
56
63
  });
57
- }, [isOpen, currentIndex, items]);
58
64
 
59
- // Pause video when navigating away or closing
60
- useEffect(() => {
61
- return () => {
62
- if (videoRef.current) {
63
- videoRef.current.pause();
64
- }
65
- };
66
- }, [currentIndex]);
67
-
68
- // Lock body scroll when open
69
- useEffect(() => {
70
- if (isOpen) {
71
- const prev = document.body.style.overflow;
72
- document.body.style.overflow = 'hidden';
73
- return () => { document.body.style.overflow = prev; };
74
- }
75
- }, [isOpen]);
76
-
77
- const goTo = useCallback((idx: number) => {
78
- if (videoRef.current) videoRef.current.pause();
79
- setCurrentIndex(idx);
80
- setZoom(1);
81
- setPan({ x: 0, y: 0 });
82
- }, []);
83
-
84
- const goPrev = useCallback(() => {
85
- if (currentIndex > 0) goTo(currentIndex - 1);
86
- }, [currentIndex, goTo]);
87
-
88
- const goNext = useCallback(() => {
89
- if (currentIndex < items.length - 1) goTo(currentIndex + 1);
90
- }, [currentIndex, items.length, goTo]);
91
-
92
- // Keyboard navigation
93
- useEffect(() => {
94
- if (!isOpen) return;
95
- const handleKey = (e: KeyboardEvent) => {
96
- switch (e.key) {
97
- case 'Escape':
98
- onClose();
99
- break;
100
- case 'ArrowLeft':
101
- goPrev();
102
- break;
103
- case 'ArrowRight':
104
- goNext();
105
- break;
65
+ // Pause video and dispose virtual E2EE stream sessions when navigating away or closing.
66
+ // Do not depend on items: callers often rebuild the items array after progress/state changes.
67
+ useEffect(() => {
68
+ return () => {
69
+ if (videoRef.current) {
70
+ videoRef.current.pause();
71
+ }
72
+ const dispose = currentDisposeRef.current;
73
+ currentDisposeRef.current = undefined;
74
+ void dispose?.();
75
+ };
76
+ }, [currentIndex]);
77
+
78
+ // Lock body scroll when open
79
+ useEffect(() => {
80
+ if (isOpen) {
81
+ const prev = document.body.style.overflow;
82
+ document.body.style.overflow = 'hidden';
83
+ return () => {
84
+ document.body.style.overflow = prev;
85
+ };
106
86
  }
107
- };
108
- document.addEventListener('keydown', handleKey);
109
- return () => document.removeEventListener('keydown', handleKey);
110
- }, [isOpen, onClose, goPrev, goNext]);
111
-
112
- // Double-click zoom toggle (image only)
113
- const handleDoubleClick = useCallback(() => {
114
- const current = items[currentIndex];
115
- if (current?.type !== 'image') return;
116
-
117
- if (zoom === 1) {
118
- setZoom(2);
119
- } else {
87
+ }, [isOpen]);
88
+
89
+ const goTo = useCallback((idx: number) => {
90
+ if (videoRef.current) videoRef.current.pause();
91
+ if (videoRetryTimerRef.current) clearTimeout(videoRetryTimerRef.current);
92
+ setCurrentIndex(idx);
120
93
  setZoom(1);
121
94
  setPan({ x: 0, y: 0 });
122
- }
123
- }, [currentIndex, items, zoom]);
124
-
125
- // Wheel zoom (image only)
126
- const handleWheel = useCallback((e: React.WheelEvent) => {
127
- const current = items[currentIndex];
128
- if (current?.type !== 'image') return;
129
- e.preventDefault();
130
-
131
- setZoom(prev => {
132
- const next = prev - e.deltaY * 0.002;
133
- const clamped = Math.max(1, Math.min(3, next));
134
- if (clamped === 1) setPan({ x: 0, y: 0 });
135
- return clamped;
136
- });
137
- }, [currentIndex, items]);
138
-
139
- // Mouse drag for panning (image zoomed)
140
- const handleMouseDown = useCallback((e: React.MouseEvent) => {
141
- if (zoom <= 1) return;
142
- e.preventDefault();
143
- setIsDragging(true);
144
- dragStart.current = { x: e.clientX, y: e.clientY };
145
- panStart.current = { ...pan };
146
- }, [zoom, pan]);
147
-
148
- const handleMouseMove = useCallback((e: React.MouseEvent) => {
149
- if (!isDragging) return;
150
- const dx = e.clientX - dragStart.current.x;
151
- const dy = e.clientY - dragStart.current.y;
152
- setPan({ x: panStart.current.x + dx, y: panStart.current.y + dy });
153
- }, [isDragging]);
154
-
155
- const handleMouseUp = useCallback(() => {
156
- setIsDragging(false);
157
- }, []);
158
-
159
- // Click on backdrop closes
160
- const handleBackdropClick = useCallback((e: React.MouseEvent) => {
161
- if (e.target === containerRef.current) {
162
- onClose();
163
- }
164
- }, [onClose]);
165
-
166
- const { client } = useChatClient();
167
-
168
- const currentItem = items[currentIndex];
169
- const hasMultiple = items.length > 1;
170
-
171
- const handleDownload = useCallback(async () => {
172
- if (!currentItem) return;
173
- const filename = getFilename(currentItem.src, currentItem.alt);
174
-
175
- try {
176
- const blob = await client.downloadMedia(currentItem.src);
177
- const urlBlob = window.URL.createObjectURL(blob);
178
- const a = document.createElement('a');
179
- a.href = urlBlob;
180
- a.download = filename;
181
- document.body.appendChild(a);
182
- a.click();
183
- a.remove();
184
- window.URL.revokeObjectURL(urlBlob);
185
- } catch {
186
- window.open(currentItem.src, '_blank', 'noopener,noreferrer');
187
- }
188
- }, [client, currentItem]);
189
-
190
- const content = useMemo(() => {
191
- if (!currentItem) return null;
192
-
193
- if (currentItem.type === 'video') {
95
+ setVideoRetryCount(0);
96
+ setVideoLoading(false);
97
+ pendingVideoSeekTimeRef.current = undefined;
98
+ }, []);
99
+
100
+ const goPrev = useCallback(() => {
101
+ if (currentIndex > 0) goTo(currentIndex - 1);
102
+ }, [currentIndex, goTo]);
103
+
104
+ const goNext = useCallback(() => {
105
+ if (currentIndex < items.length - 1) goTo(currentIndex + 1);
106
+ }, [currentIndex, items.length, goTo]);
107
+
108
+ // Keyboard navigation
109
+ useEffect(() => {
110
+ if (!isOpen) return;
111
+ const handleKey = (e: KeyboardEvent) => {
112
+ switch (e.key) {
113
+ case 'Escape':
114
+ onClose();
115
+ break;
116
+ case 'ArrowLeft':
117
+ goPrev();
118
+ break;
119
+ case 'ArrowRight':
120
+ goNext();
121
+ break;
122
+ }
123
+ };
124
+ document.addEventListener('keydown', handleKey);
125
+ return () => document.removeEventListener('keydown', handleKey);
126
+ }, [isOpen, onClose, goPrev, goNext]);
127
+
128
+ // Double-click zoom toggle (image only)
129
+ const handleDoubleClick = useCallback(() => {
130
+ const current = items[currentIndex];
131
+ if (current?.type !== 'image') return;
132
+
133
+ if (zoom === 1) {
134
+ setZoom(2);
135
+ } else {
136
+ setZoom(1);
137
+ setPan({ x: 0, y: 0 });
138
+ }
139
+ }, [currentIndex, items, zoom]);
140
+
141
+ // Wheel zoom (image only)
142
+ const handleWheel = useCallback(
143
+ (e: React.WheelEvent) => {
144
+ const current = items[currentIndex];
145
+ if (current?.type !== 'image') return;
146
+ e.preventDefault();
147
+
148
+ setZoom((prev) => {
149
+ const next = prev - e.deltaY * 0.002;
150
+ const clamped = Math.max(1, Math.min(3, next));
151
+ if (clamped === 1) setPan({ x: 0, y: 0 });
152
+ return clamped;
153
+ });
154
+ },
155
+ [currentIndex, items],
156
+ );
157
+
158
+ // Mouse drag for panning (image zoomed)
159
+ const handleMouseDown = useCallback(
160
+ (e: React.MouseEvent) => {
161
+ if (zoom <= 1) return;
162
+ e.preventDefault();
163
+ setIsDragging(true);
164
+ dragStart.current = { x: e.clientX, y: e.clientY };
165
+ panStart.current = { ...pan };
166
+ },
167
+ [zoom, pan],
168
+ );
169
+
170
+ const handleMouseMove = useCallback(
171
+ (e: React.MouseEvent) => {
172
+ if (!isDragging) return;
173
+ const dx = e.clientX - dragStart.current.x;
174
+ const dy = e.clientY - dragStart.current.y;
175
+ setPan({ x: panStart.current.x + dx, y: panStart.current.y + dy });
176
+ },
177
+ [isDragging],
178
+ );
179
+
180
+ const handleMouseUp = useCallback(() => {
181
+ setIsDragging(false);
182
+ }, []);
183
+
184
+ // Click on backdrop closes
185
+ const handleBackdropClick = useCallback(
186
+ (e: React.MouseEvent) => {
187
+ if (e.target === containerRef.current) {
188
+ onClose();
189
+ }
190
+ },
191
+ [onClose],
192
+ );
193
+
194
+ const { downloadFile } = useDownloadHandler();
195
+
196
+ const currentItem = items[currentIndex];
197
+ const hasMultiple = items.length > 1;
198
+
199
+ const handleDownload = useCallback(async () => {
200
+ if (!currentItem) return;
201
+ if (currentItem.download) {
202
+ await currentItem.download();
203
+ return;
204
+ }
205
+ if (!currentItem.src) return;
206
+ await downloadFile(currentItem.src, currentItem.alt || 'media');
207
+ }, [currentItem, downloadFile]);
208
+
209
+ const restorePendingVideoSeekTime = useCallback(() => {
210
+ setVideoLoading(false);
211
+ const seekTime = pendingVideoSeekTimeRef.current;
212
+ const video = videoRef.current;
213
+ if (seekTime === undefined || !video || !Number.isFinite(seekTime) || seekTime <= 0) return;
214
+ try {
215
+ if (!Number.isFinite(video.duration) || seekTime < video.duration) {
216
+ video.currentTime = seekTime;
217
+ }
218
+ pendingVideoSeekTimeRef.current = undefined;
219
+ } catch {
220
+ pendingVideoSeekTimeRef.current = undefined;
221
+ }
222
+ }, []);
223
+
224
+ // Video error handler — retries loading with exponential backoff
225
+ // Handles CDN not-ready scenario for large recently-uploaded files
226
+ const handleVideoError = useCallback(() => {
227
+ if (currentItem?.onPlaybackError) {
228
+ if (videoRetryTimerRef.current) clearTimeout(videoRetryTimerRef.current);
229
+ const currentTime = videoRef.current?.currentTime;
230
+ pendingVideoSeekTimeRef.current =
231
+ currentTime !== undefined && Number.isFinite(currentTime) && currentTime > 0 ? currentTime : undefined;
232
+ setVideoLoading(true);
233
+ void Promise.resolve(currentItem.onPlaybackError({ currentTime: pendingVideoSeekTimeRef.current })).finally(
234
+ () => {
235
+ setVideoLoading(false);
236
+ setVideoRetryCount(0);
237
+ },
238
+ );
239
+ return;
240
+ }
241
+ setVideoRetryCount((prev) => {
242
+ if (prev >= VIDEO_MAX_RETRIES) return prev;
243
+ const nextAttempt = prev + 1;
244
+ const delay = VIDEO_RETRY_BASE_DELAY * Math.pow(2, prev); // 1s, 2s, 4s
245
+ setVideoLoading(true);
246
+ videoRetryTimerRef.current = setTimeout(() => {
247
+ // Force the video element to re-attempt loading by resetting src
248
+ if (videoRef.current) {
249
+ const src = videoRef.current.src;
250
+ videoRef.current.src = '';
251
+ videoRef.current.src = src;
252
+ videoRef.current.load();
253
+ }
254
+ setVideoLoading(false);
255
+ }, delay);
256
+ return nextAttempt;
257
+ });
258
+ }, [currentItem]);
259
+
260
+ const content = useMemo(() => {
261
+ if (!currentItem) return null;
262
+
263
+ const loadingOverlay = currentItem.loading || !currentItem.src;
264
+ if (currentItem.type === 'video') {
265
+ return (
266
+ <div className="ermis-lightbox__video-wrapper">
267
+ {currentItem.src ? (
268
+ <video
269
+ ref={videoRef}
270
+ className="ermis-lightbox__video"
271
+ src={currentItem.src}
272
+ poster={currentItem.posterSrc}
273
+ controls
274
+ autoPlay
275
+ preload="metadata"
276
+ onClick={(e) => e.stopPropagation()}
277
+ onLoadedMetadata={restorePendingVideoSeekTime}
278
+ onCanPlay={restorePendingVideoSeekTime}
279
+ onPlaying={() => setVideoLoading(false)}
280
+ onError={handleVideoError}
281
+ />
282
+ ) : currentItem.posterSrc ? (
283
+ <img
284
+ className="ermis-lightbox__image ermis-lightbox__image--poster"
285
+ src={currentItem.posterSrc}
286
+ alt={currentItem.alt || ''}
287
+ />
288
+ ) : (
289
+ <div className="ermis-lightbox__media-placeholder" />
290
+ )}
291
+ {(videoLoading || loadingOverlay) && (
292
+ <div className="ermis-lightbox__video-retry">
293
+ <div className="ermis-lightbox__video-spinner" />
294
+ {currentItem.progressLabel && (
295
+ <span className="ermis-lightbox__progress-label">{currentItem.progressLabel}</span>
296
+ )}
297
+ </div>
298
+ )}
299
+ </div>
300
+ );
301
+ }
302
+
303
+ const imgStyle: React.CSSProperties = {
304
+ transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
305
+ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
306
+ };
307
+
308
+ if (!currentItem.src) {
309
+ return (
310
+ <div className="ermis-lightbox__video-wrapper">
311
+ {currentItem.posterSrc ? (
312
+ <img
313
+ className="ermis-lightbox__image ermis-lightbox__image--poster"
314
+ src={currentItem.posterSrc}
315
+ alt={currentItem.alt || ''}
316
+ />
317
+ ) : (
318
+ <div className="ermis-lightbox__media-placeholder" />
319
+ )}
320
+ <div className="ermis-lightbox__video-retry">
321
+ <div className="ermis-lightbox__video-spinner" />
322
+ {currentItem.progressLabel && (
323
+ <span className="ermis-lightbox__progress-label">{currentItem.progressLabel}</span>
324
+ )}
325
+ </div>
326
+ </div>
327
+ );
328
+ }
329
+
194
330
  return (
195
- <video
196
- ref={videoRef}
197
- className="ermis-lightbox__video"
331
+ <img
332
+ className={`ermis-lightbox__image${zoom > 1 ? ' ermis-lightbox__image--zoomed' : ''}`}
198
333
  src={currentItem.src}
199
- poster={currentItem.posterSrc}
200
- controls
201
- autoPlay
202
- preload="metadata"
334
+ alt={currentItem.alt || ''}
335
+ style={imgStyle}
336
+ draggable={false}
337
+ onDoubleClick={handleDoubleClick}
338
+ onMouseDown={handleMouseDown}
339
+ onMouseMove={handleMouseMove}
340
+ onMouseUp={handleMouseUp}
341
+ onMouseLeave={handleMouseUp}
203
342
  onClick={(e) => e.stopPropagation()}
204
343
  />
205
344
  );
206
- }
207
-
208
- const imgStyle: React.CSSProperties = {
209
- transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
210
- cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
211
- };
212
-
213
- return (
214
- <img
215
- className={`ermis-lightbox__image${zoom > 1 ? ' ermis-lightbox__image--zoomed' : ''}`}
216
- src={currentItem.src}
217
- alt={currentItem.alt || ''}
218
- style={imgStyle}
219
- draggable={false}
220
- onDoubleClick={handleDoubleClick}
221
- onMouseDown={handleMouseDown}
222
- onMouseMove={handleMouseMove}
223
- onMouseUp={handleMouseUp}
224
- onMouseLeave={handleMouseUp}
225
- onClick={(e) => e.stopPropagation()}
226
- />
227
- );
228
- }, [currentItem, zoom, pan, isDragging, handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp]);
229
-
230
- if (!isOpen || !currentItem) return null;
231
-
232
- return ReactDOM.createPortal(
233
- <div className="ermis-lightbox" onWheel={handleWheel}>
234
- <div className="ermis-lightbox__backdrop" />
235
-
236
- {/* Header: counter + actions */}
237
- <div className="ermis-lightbox__header">
238
- {hasMultiple && (
239
- <span className="ermis-lightbox__counter">
240
- {currentIndex + 1} / {items.length}
241
- </span>
242
- )}
243
- <div className="ermis-lightbox__actions">
244
- <button
245
- className="ermis-lightbox__action-btn"
246
- onClick={handleDownload}
247
- aria-label="Download"
248
- title="Download"
249
- >
250
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
251
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
252
- <polyline points="7 10 12 15 17 10" />
253
- <line x1="12" y1="15" x2="12" y2="3" />
254
- </svg>
255
- </button>
256
- <button
257
- className="ermis-lightbox__action-btn"
258
- onClick={onClose}
259
- aria-label="Close"
260
- title="Close"
261
- >
262
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
263
- <line x1="18" y1="6" x2="6" y2="18" />
264
- <line x1="6" y1="6" x2="18" y2="18" />
265
- </svg>
266
- </button>
345
+ }, [
346
+ currentItem,
347
+ zoom,
348
+ pan,
349
+ isDragging,
350
+ videoLoading,
351
+ handleDoubleClick,
352
+ handleVideoError,
353
+ restorePendingVideoSeekTime,
354
+ handleMouseDown,
355
+ handleMouseMove,
356
+ handleMouseUp,
357
+ ]);
358
+
359
+ if (!isOpen || !currentItem) return null;
360
+
361
+ return ReactDOM.createPortal(
362
+ <div className="ermis-lightbox" onWheel={handleWheel}>
363
+ <div className="ermis-lightbox__backdrop" />
364
+
365
+ {/* Header: counter + actions */}
366
+ <div className="ermis-lightbox__header">
367
+ {hasMultiple && (
368
+ <span className="ermis-lightbox__counter">
369
+ {currentIndex + 1} / {items.length}
370
+ </span>
371
+ )}
372
+ <div className="ermis-lightbox__actions">
373
+ <button
374
+ className="ermis-lightbox__action-btn"
375
+ onClick={handleDownload}
376
+ aria-label="Download"
377
+ title="Download"
378
+ disabled={Boolean(currentItem.loading) || (!currentItem.src && !currentItem.download)}
379
+ >
380
+ <svg
381
+ width="22"
382
+ height="22"
383
+ viewBox="0 0 24 24"
384
+ fill="none"
385
+ stroke="currentColor"
386
+ strokeWidth="2"
387
+ strokeLinecap="round"
388
+ strokeLinejoin="round"
389
+ >
390
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
391
+ <polyline points="7 10 12 15 17 10" />
392
+ <line x1="12" y1="15" x2="12" y2="3" />
393
+ </svg>
394
+ </button>
395
+ <button className="ermis-lightbox__action-btn" onClick={onClose} aria-label="Close" title="Close">
396
+ <svg
397
+ width="22"
398
+ height="22"
399
+ viewBox="0 0 24 24"
400
+ fill="none"
401
+ stroke="currentColor"
402
+ strokeWidth="2"
403
+ strokeLinecap="round"
404
+ strokeLinejoin="round"
405
+ >
406
+ <line x1="18" y1="6" x2="6" y2="18" />
407
+ <line x1="6" y1="6" x2="18" y2="18" />
408
+ </svg>
409
+ </button>
410
+ </div>
267
411
  </div>
268
- </div>
269
-
270
- {/* Main content area */}
271
- <div
272
- ref={containerRef}
273
- className="ermis-lightbox__content"
274
- onClick={handleBackdropClick}
275
- >
276
- {/* Prev button */}
277
- {hasMultiple && currentIndex > 0 && (
278
- <button
279
- className="ermis-lightbox__nav ermis-lightbox__nav--prev"
280
- onClick={(e) => { e.stopPropagation(); goPrev(); }}
281
- aria-label="Previous"
282
- >
283
- <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
284
- <polyline points="15 18 9 12 15 6" />
285
- </svg>
286
- </button>
287
- )}
288
-
289
- {/* Media */}
290
- {content}
291
-
292
- {/* Next button */}
293
- {hasMultiple && currentIndex < items.length - 1 && (
294
- <button
295
- className="ermis-lightbox__nav ermis-lightbox__nav--next"
296
- onClick={(e) => { e.stopPropagation(); goNext(); }}
297
- aria-label="Next"
298
- >
299
- <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
300
- <polyline points="9 18 15 12 9 6" />
301
- </svg>
302
- </button>
303
- )}
304
- </div>
305
-
306
- {/* Filename */}
307
- {currentItem.alt && (
308
- <div className="ermis-lightbox__filename">{currentItem.alt}</div>
309
- )}
310
- </div>,
311
- document.body
312
- );
313
- });
412
+
413
+ {/* Main content area */}
414
+ <div ref={containerRef} className="ermis-lightbox__content" onClick={handleBackdropClick}>
415
+ {/* Prev button */}
416
+ {hasMultiple && currentIndex > 0 && (
417
+ <button
418
+ className="ermis-lightbox__nav ermis-lightbox__nav--prev"
419
+ onClick={(e) => {
420
+ e.stopPropagation();
421
+ goPrev();
422
+ }}
423
+ aria-label="Previous"
424
+ >
425
+ <svg
426
+ width="28"
427
+ height="28"
428
+ viewBox="0 0 24 24"
429
+ fill="none"
430
+ stroke="currentColor"
431
+ strokeWidth="2"
432
+ strokeLinecap="round"
433
+ strokeLinejoin="round"
434
+ >
435
+ <polyline points="15 18 9 12 15 6" />
436
+ </svg>
437
+ </button>
438
+ )}
439
+
440
+ {/* Media */}
441
+ {content}
442
+
443
+ {/* Next button */}
444
+ {hasMultiple && currentIndex < items.length - 1 && (
445
+ <button
446
+ className="ermis-lightbox__nav ermis-lightbox__nav--next"
447
+ onClick={(e) => {
448
+ e.stopPropagation();
449
+ goNext();
450
+ }}
451
+ aria-label="Next"
452
+ >
453
+ <svg
454
+ width="28"
455
+ height="28"
456
+ viewBox="0 0 24 24"
457
+ fill="none"
458
+ stroke="currentColor"
459
+ strokeWidth="2"
460
+ strokeLinecap="round"
461
+ strokeLinejoin="round"
462
+ >
463
+ <polyline points="9 18 15 12 9 6" />
464
+ </svg>
465
+ </button>
466
+ )}
467
+ </div>
468
+
469
+ {/* Filename */}
470
+ {currentItem.alt && <div className="ermis-lightbox__filename">{currentItem.alt}</div>}
471
+ </div>,
472
+ document.body,
473
+ );
474
+ },
475
+ );
314
476
  MediaLightbox.displayName = 'MediaLightbox';