@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
|
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
|
|
2
2
|
import ReactDOM from 'react-dom';
|
|
3
3
|
import { preloadImage } from '../utils';
|
|
4
4
|
import { useDownloadHandler } from '../hooks/useDownloadHandler';
|
|
5
|
-
import type { MediaLightboxProps } from '../types';
|
|
5
|
+
import type { MediaLightboxItem, MediaLightboxProps } from '../types';
|
|
6
6
|
|
|
7
7
|
/** Max retry attempts for video loading (CDN may not be ready for large uploads) */
|
|
8
8
|
const VIDEO_MAX_RETRIES = 3;
|
|
@@ -14,323 +14,463 @@ const VIDEO_RETRY_BASE_DELAY = 1000;
|
|
|
14
14
|
* Supports prev/next navigation, keyboard controls, and image zoom.
|
|
15
15
|
* Renders via React portal into document.body.
|
|
16
16
|
*/
|
|
17
|
-
export const MediaLightbox: React.FC<MediaLightboxProps> = React.memo(
|
|
18
|
-
items,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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;
|
|
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;
|
|
63
|
+
});
|
|
64
|
+
|
|
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
|
+
};
|
|
86
|
+
}
|
|
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);
|
|
41
93
|
setZoom(1);
|
|
42
94
|
setPan({ x: 0, y: 0 });
|
|
43
95
|
setVideoRetryCount(0);
|
|
44
96
|
setVideoLoading(false);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 });
|
|
58
138
|
}
|
|
59
|
-
});
|
|
60
|
-
|
|
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
|
+
);
|
|
61
157
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}, [currentIndex, goTo]);
|
|
93
|
-
|
|
94
|
-
const goNext = useCallback(() => {
|
|
95
|
-
if (currentIndex < items.length - 1) goTo(currentIndex + 1);
|
|
96
|
-
}, [currentIndex, items.length, goTo]);
|
|
97
|
-
|
|
98
|
-
// Keyboard navigation
|
|
99
|
-
useEffect(() => {
|
|
100
|
-
if (!isOpen) return;
|
|
101
|
-
const handleKey = (e: KeyboardEvent) => {
|
|
102
|
-
switch (e.key) {
|
|
103
|
-
case 'Escape':
|
|
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) {
|
|
104
188
|
onClose();
|
|
105
|
-
break;
|
|
106
|
-
case 'ArrowLeft':
|
|
107
|
-
goPrev();
|
|
108
|
-
break;
|
|
109
|
-
case 'ArrowRight':
|
|
110
|
-
goNext();
|
|
111
|
-
break;
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
document.addEventListener('keydown', handleKey);
|
|
115
|
-
return () => document.removeEventListener('keydown', handleKey);
|
|
116
|
-
}, [isOpen, onClose, goPrev, goNext]);
|
|
117
|
-
|
|
118
|
-
// Double-click zoom toggle (image only)
|
|
119
|
-
const handleDoubleClick = useCallback(() => {
|
|
120
|
-
const current = items[currentIndex];
|
|
121
|
-
if (current?.type !== 'image') return;
|
|
122
|
-
|
|
123
|
-
if (zoom === 1) {
|
|
124
|
-
setZoom(2);
|
|
125
|
-
} else {
|
|
126
|
-
setZoom(1);
|
|
127
|
-
setPan({ x: 0, y: 0 });
|
|
128
|
-
}
|
|
129
|
-
}, [currentIndex, items, zoom]);
|
|
130
|
-
|
|
131
|
-
// Wheel zoom (image only)
|
|
132
|
-
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
133
|
-
const current = items[currentIndex];
|
|
134
|
-
if (current?.type !== 'image') return;
|
|
135
|
-
e.preventDefault();
|
|
136
|
-
|
|
137
|
-
setZoom(prev => {
|
|
138
|
-
const next = prev - e.deltaY * 0.002;
|
|
139
|
-
const clamped = Math.max(1, Math.min(3, next));
|
|
140
|
-
if (clamped === 1) setPan({ x: 0, y: 0 });
|
|
141
|
-
return clamped;
|
|
142
|
-
});
|
|
143
|
-
}, [currentIndex, items]);
|
|
144
|
-
|
|
145
|
-
// Mouse drag for panning (image zoomed)
|
|
146
|
-
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
147
|
-
if (zoom <= 1) return;
|
|
148
|
-
e.preventDefault();
|
|
149
|
-
setIsDragging(true);
|
|
150
|
-
dragStart.current = { x: e.clientX, y: e.clientY };
|
|
151
|
-
panStart.current = { ...pan };
|
|
152
|
-
}, [zoom, pan]);
|
|
153
|
-
|
|
154
|
-
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
155
|
-
if (!isDragging) return;
|
|
156
|
-
const dx = e.clientX - dragStart.current.x;
|
|
157
|
-
const dy = e.clientY - dragStart.current.y;
|
|
158
|
-
setPan({ x: panStart.current.x + dx, y: panStart.current.y + dy });
|
|
159
|
-
}, [isDragging]);
|
|
160
|
-
|
|
161
|
-
const handleMouseUp = useCallback(() => {
|
|
162
|
-
setIsDragging(false);
|
|
163
|
-
}, []);
|
|
164
|
-
|
|
165
|
-
// Click on backdrop closes
|
|
166
|
-
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
|
167
|
-
if (e.target === containerRef.current) {
|
|
168
|
-
onClose();
|
|
169
|
-
}
|
|
170
|
-
}, [onClose]);
|
|
171
|
-
|
|
172
|
-
const { downloadFile } = useDownloadHandler();
|
|
173
|
-
|
|
174
|
-
const currentItem = items[currentIndex];
|
|
175
|
-
const hasMultiple = items.length > 1;
|
|
176
|
-
|
|
177
|
-
const handleDownload = useCallback(async () => {
|
|
178
|
-
if (!currentItem) return;
|
|
179
|
-
await downloadFile(currentItem.src, currentItem.alt || 'media');
|
|
180
|
-
}, [currentItem, downloadFile]);
|
|
181
|
-
|
|
182
|
-
// Video error handler — retries loading with exponential backoff
|
|
183
|
-
// Handles CDN not-ready scenario for large recently-uploaded files
|
|
184
|
-
const handleVideoError = useCallback(() => {
|
|
185
|
-
setVideoRetryCount((prev) => {
|
|
186
|
-
if (prev >= VIDEO_MAX_RETRIES) return prev;
|
|
187
|
-
const nextAttempt = prev + 1;
|
|
188
|
-
const delay = VIDEO_RETRY_BASE_DELAY * Math.pow(2, prev); // 1s, 2s, 4s
|
|
189
|
-
setVideoLoading(true);
|
|
190
|
-
videoRetryTimerRef.current = setTimeout(() => {
|
|
191
|
-
// Force the video element to re-attempt loading by resetting src
|
|
192
|
-
if (videoRef.current) {
|
|
193
|
-
const src = videoRef.current.src;
|
|
194
|
-
videoRef.current.src = '';
|
|
195
|
-
videoRef.current.src = src;
|
|
196
|
-
videoRef.current.load();
|
|
197
189
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
});
|
|
202
|
-
}, []);
|
|
190
|
+
},
|
|
191
|
+
[onClose],
|
|
192
|
+
);
|
|
203
193
|
|
|
204
|
-
|
|
205
|
-
if (!currentItem) return null;
|
|
194
|
+
const { downloadFile } = useDownloadHandler();
|
|
206
195
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
)}
|
|
222
320
|
<div className="ermis-lightbox__video-retry">
|
|
223
321
|
<div className="ermis-lightbox__video-spinner" />
|
|
322
|
+
{currentItem.progressLabel && (
|
|
323
|
+
<span className="ermis-lightbox__progress-label">{currentItem.progressLabel}</span>
|
|
324
|
+
)}
|
|
224
325
|
</div>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<img
|
|
332
|
+
className={`ermis-lightbox__image${zoom > 1 ? ' ermis-lightbox__image--zoomed' : ''}`}
|
|
333
|
+
src={currentItem.src}
|
|
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}
|
|
342
|
+
onClick={(e) => e.stopPropagation()}
|
|
343
|
+
/>
|
|
344
|
+
);
|
|
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>
|
|
225
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>
|
|
226
411
|
</div>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
title="Close"
|
|
283
|
-
>
|
|
284
|
-
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
285
|
-
<line x1="18" y1="6" x2="6" y2="18" />
|
|
286
|
-
<line x1="6" y1="6" x2="18" y2="18" />
|
|
287
|
-
</svg>
|
|
288
|
-
</button>
|
|
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
|
+
)}
|
|
289
467
|
</div>
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
{/* Prev button */}
|
|
299
|
-
{hasMultiple && currentIndex > 0 && (
|
|
300
|
-
<button
|
|
301
|
-
className="ermis-lightbox__nav ermis-lightbox__nav--prev"
|
|
302
|
-
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
|
303
|
-
aria-label="Previous"
|
|
304
|
-
>
|
|
305
|
-
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
306
|
-
<polyline points="15 18 9 12 15 6" />
|
|
307
|
-
</svg>
|
|
308
|
-
</button>
|
|
309
|
-
)}
|
|
310
|
-
|
|
311
|
-
{/* Media */}
|
|
312
|
-
{content}
|
|
313
|
-
|
|
314
|
-
{/* Next button */}
|
|
315
|
-
{hasMultiple && currentIndex < items.length - 1 && (
|
|
316
|
-
<button
|
|
317
|
-
className="ermis-lightbox__nav ermis-lightbox__nav--next"
|
|
318
|
-
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
|
319
|
-
aria-label="Next"
|
|
320
|
-
>
|
|
321
|
-
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
322
|
-
<polyline points="9 18 15 12 9 6" />
|
|
323
|
-
</svg>
|
|
324
|
-
</button>
|
|
325
|
-
)}
|
|
326
|
-
</div>
|
|
327
|
-
|
|
328
|
-
{/* Filename */}
|
|
329
|
-
{currentItem.alt && (
|
|
330
|
-
<div className="ermis-lightbox__filename">{currentItem.alt}</div>
|
|
331
|
-
)}
|
|
332
|
-
</div>,
|
|
333
|
-
document.body
|
|
334
|
-
);
|
|
335
|
-
});
|
|
468
|
+
|
|
469
|
+
{/* Filename */}
|
|
470
|
+
{currentItem.alt && <div className="ermis-lightbox__filename">{currentItem.alt}</div>}
|
|
471
|
+
</div>,
|
|
472
|
+
document.body,
|
|
473
|
+
);
|
|
474
|
+
},
|
|
475
|
+
);
|
|
336
476
|
MediaLightbox.displayName = 'MediaLightbox';
|