@ermis-network/ermis-chat-react 1.0.7 → 1.0.8
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/dist/index.cjs +2780 -1852
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +364 -8
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +160 -1
- package/dist/index.d.ts +160 -1
- package/dist/index.mjs +2780 -1884
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/channelRoleUtils.ts +73 -0
- package/src/channelTypeUtils.ts +46 -0
- package/src/components/Avatar.tsx +57 -31
- package/src/components/ChannelActions.tsx +13 -11
- package/src/components/ChannelHeader.tsx +89 -4
- package/src/components/ChannelInfo/ChannelInfo.tsx +23 -17
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +57 -26
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +4 -2
- package/src/components/ChannelInfo/EditChannelModal.tsx +2 -1
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
- package/src/components/ChannelList.tsx +59 -14
- package/src/components/CreateChannelModal.tsx +53 -16
- package/src/components/EditPreview.tsx +2 -1
- package/src/components/ForwardMessageModal.tsx +2 -1
- package/src/components/MediaLightbox.tsx +314 -0
- package/src/components/MessageInput.tsx +3 -2
- package/src/components/MessageItem.tsx +2 -1
- package/src/components/MessageRenderers.tsx +168 -46
- package/src/components/PendingOverlay.tsx +11 -1
- package/src/components/PinnedMessages.tsx +2 -1
- package/src/components/ReplyPreview.tsx +2 -1
- package/src/components/SkippedOverlay.tsx +36 -0
- package/src/components/UserPicker.tsx +1 -1
- package/src/components/VirtualMessageList.tsx +91 -7
- package/src/hooks/useBlockedState.ts +3 -2
- package/src/hooks/useChannelCapabilities.ts +10 -12
- package/src/hooks/useChannelListUpdates.ts +6 -4
- package/src/hooks/useChannelMessages.ts +2 -3
- package/src/hooks/useChannelRowUpdates.ts +3 -2
- package/src/hooks/useMessageActions.ts +23 -9
- package/src/hooks/useOnlineStatus.ts +71 -0
- package/src/hooks/useOnlineUsers.ts +115 -0
- package/src/hooks/usePendingState.ts +8 -3
- package/src/index.ts +61 -9
- package/src/messageTypeUtils.ts +64 -0
- package/src/styles/_channel-list.css +59 -0
- package/src/styles/_media-lightbox.css +263 -0
- package/src/styles/_message-bubble.css +99 -8
- package/src/styles/_message-list.css +25 -0
- package/src/styles/index.css +1 -0
- package/src/types.ts +46 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom';
|
|
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
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* MediaLightbox – full-screen overlay for viewing images & videos.
|
|
21
|
+
* Supports prev/next navigation, keyboard controls, and image zoom.
|
|
22
|
+
* Renders via React portal into document.body.
|
|
23
|
+
*/
|
|
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);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}, [isOpen, currentIndex, items]);
|
|
58
|
+
|
|
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;
|
|
106
|
+
}
|
|
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 {
|
|
120
|
+
setZoom(1);
|
|
121
|
+
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') {
|
|
194
|
+
return (
|
|
195
|
+
<video
|
|
196
|
+
ref={videoRef}
|
|
197
|
+
className="ermis-lightbox__video"
|
|
198
|
+
src={currentItem.src}
|
|
199
|
+
poster={currentItem.posterSrc}
|
|
200
|
+
controls
|
|
201
|
+
autoPlay
|
|
202
|
+
preload="metadata"
|
|
203
|
+
onClick={(e) => e.stopPropagation()}
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
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>
|
|
267
|
+
</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
|
+
});
|
|
314
|
+
MediaLightbox.displayName = 'MediaLightbox';
|
|
@@ -15,6 +15,7 @@ import { EditPreview } from './EditPreview';
|
|
|
15
15
|
import { buildUserMap, replaceMentionsForPreview, moveCaretToEnd } from '../utils';
|
|
16
16
|
import { getMentionHtml } from '../hooks/useMentions';
|
|
17
17
|
import { useChannelCapabilities } from '../hooks/useChannelCapabilities';
|
|
18
|
+
import { CHANNEL_ROLES } from '../channelRoleUtils';
|
|
18
19
|
import type { MentionMember, MessageInputProps, FilePreviewItem } from '../types';
|
|
19
20
|
|
|
20
21
|
export type { MessageInputProps, SendButtonProps, AttachButtonProps, EmojiPickerProps, EmojiButtonProps } from '../types';
|
|
@@ -52,7 +53,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
52
53
|
const editableRef = React.useRef<HTMLDivElement>(null);
|
|
53
54
|
const [hasContent, setHasContent] = useState(false);
|
|
54
55
|
|
|
55
|
-
const { role,
|
|
56
|
+
const { role, isGroupChannel: isTeamChannel, hasCapability } = useChannelCapabilities();
|
|
56
57
|
const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
|
|
57
58
|
|
|
58
59
|
// Slow Mode Logic
|
|
@@ -71,7 +72,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
71
72
|
};
|
|
72
73
|
}, [activeChannel]);
|
|
73
74
|
|
|
74
|
-
const isSlowModeApplied = isTeamChannel && role ===
|
|
75
|
+
const isSlowModeApplied = isTeamChannel && role === CHANNEL_ROLES.MEMBER && memberMessageCooldown > 0;
|
|
75
76
|
|
|
76
77
|
const [cooldownEnd, setCooldownEnd] = useState<number | null>(null);
|
|
77
78
|
const [cooldown, setCooldown] = useState(0);
|
|
@@ -7,6 +7,7 @@ import { MessageQuickReactions } from './MessageQuickReactions';
|
|
|
7
7
|
import { useChannelCapabilities } from '../hooks/useChannelCapabilities';
|
|
8
8
|
import { useChatClient } from '../hooks/useChatClient';
|
|
9
9
|
import { formatTime } from '../utils';
|
|
10
|
+
import { isSystemMessage } from '../messageTypeUtils';
|
|
10
11
|
|
|
11
12
|
export type { MessageItemProps, SystemMessageItemProps } from '../types';
|
|
12
13
|
|
|
@@ -176,7 +177,7 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
176
177
|
</MessageBubble>
|
|
177
178
|
|
|
178
179
|
{/* Actions: hover buttons + dropdown menu */}
|
|
179
|
-
{message
|
|
180
|
+
{!isSystemMessage(message) && (
|
|
180
181
|
<MessageActionsBoxComponent
|
|
181
182
|
message={message}
|
|
182
183
|
isOwnMessage={isOwnMessage}
|
|
@@ -1,39 +1,25 @@
|
|
|
1
|
-
import React, { useState, useMemo } from 'react';
|
|
1
|
+
import React, { useState, useMemo, useCallback } from 'react';
|
|
2
2
|
import { preloadImage, isImagePreloaded } from '../utils';
|
|
3
3
|
import type { FormatMessageResponse, Attachment, MessageLabel } from '@ermis-network/ermis-chat-sdk';
|
|
4
4
|
import { parseSystemMessage, parseSignalMessage, CallType } from '@ermis-network/ermis-chat-sdk';
|
|
5
5
|
import { useChatClient } from '../hooks/useChatClient';
|
|
6
6
|
import { buildUserMap } from '../utils';
|
|
7
|
-
import
|
|
7
|
+
import { MediaLightbox } from './MediaLightbox';
|
|
8
|
+
import { getFileIcon } from './ChannelInfo/utils';
|
|
9
|
+
import type { AttachmentProps, MessageRendererProps, MessageBubbleProps, MediaLightboxItem } from '../types';
|
|
8
10
|
|
|
9
11
|
export type { AttachmentProps, MessageRendererProps, MessageBubbleProps } from '../types';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
attachment.type === 'image' ||
|
|
17
|
-
(!attachment.type && (attachment.mime_type?.startsWith('image/') || attachment.image_url))
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function isVideo(attachment: Attachment): boolean {
|
|
22
|
-
return !!(attachment.type === 'video' || (!attachment.type && attachment.mime_type?.startsWith('video/')));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function isVoiceRecording(attachment: Attachment): boolean {
|
|
26
|
-
return attachment.type === 'voiceRecording';
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function isLinkPreview(attachment: Attachment): boolean {
|
|
30
|
-
return attachment.type === 'linkPreview';
|
|
31
|
-
}
|
|
12
|
+
import {
|
|
13
|
+
isVoiceRecordingAttachment,
|
|
14
|
+
isLinkPreviewAttachment,
|
|
15
|
+
isImage,
|
|
16
|
+
isVideo
|
|
17
|
+
} from '../messageTypeUtils';
|
|
32
18
|
|
|
33
19
|
/* ----------------------------------------------------------
|
|
34
20
|
Attachment renderers
|
|
35
21
|
---------------------------------------------------------- */
|
|
36
|
-
const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
|
|
22
|
+
const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment, onClick }) => {
|
|
37
23
|
const src = attachment.image_url || attachment.thumb_url || attachment.url;
|
|
38
24
|
const thumbSrc = attachment.thumb_url;
|
|
39
25
|
if (!src) return null;
|
|
@@ -51,8 +37,15 @@ const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
51
37
|
}
|
|
52
38
|
}, [loaded, src]);
|
|
53
39
|
|
|
40
|
+
const clickable = Boolean(onClick);
|
|
41
|
+
|
|
54
42
|
return (
|
|
55
|
-
<div
|
|
43
|
+
<div
|
|
44
|
+
className={`ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3${clickable ? ' ermis-attachment--clickable' : ''}`}
|
|
45
|
+
onClick={onClick}
|
|
46
|
+
role={clickable ? 'button' : undefined}
|
|
47
|
+
tabIndex={clickable ? 0 : undefined}
|
|
48
|
+
>
|
|
56
49
|
{/* Blur placeholder: use thumb if available, otherwise shimmer */}
|
|
57
50
|
{!loaded && (
|
|
58
51
|
thumbSrc && thumbSrc !== src ? (
|
|
@@ -74,16 +67,26 @@ const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
74
67
|
loading="lazy"
|
|
75
68
|
onLoad={() => setLoaded(true)}
|
|
76
69
|
/>
|
|
70
|
+
{clickable && (
|
|
71
|
+
<div className="ermis-attachment__overlay">
|
|
72
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
73
|
+
<circle cx="11" cy="11" r="8" />
|
|
74
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
75
|
+
<line x1="11" y1="8" x2="11" y2="14" />
|
|
76
|
+
<line x1="8" y1="11" x2="14" y2="11" />
|
|
77
|
+
</svg>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
77
80
|
</div>
|
|
78
81
|
);
|
|
79
82
|
}, (prev, next) => {
|
|
80
83
|
const prevSrc = prev.attachment.image_url || prev.attachment.thumb_url || prev.attachment.url;
|
|
81
84
|
const nextSrc = next.attachment.image_url || next.attachment.thumb_url || next.attachment.url;
|
|
82
|
-
return prevSrc === nextSrc;
|
|
85
|
+
return prevSrc === nextSrc && prev.onClick === next.onClick;
|
|
83
86
|
});
|
|
84
87
|
(ImageAttachment as any).displayName = 'ImageAttachment';
|
|
85
88
|
|
|
86
|
-
const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
|
|
89
|
+
const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment, onClick }) => {
|
|
87
90
|
const src = attachment.asset_url || attachment.url;
|
|
88
91
|
const posterSrc = attachment.image_url || attachment.thumb_url;
|
|
89
92
|
const blurThumb = attachment.thumb_url;
|
|
@@ -103,6 +106,51 @@ const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
103
106
|
}
|
|
104
107
|
}, [loaded, posterSrc]);
|
|
105
108
|
|
|
109
|
+
const clickable = Boolean(onClick);
|
|
110
|
+
|
|
111
|
+
// When clickable (lightbox mode): show poster thumbnail + play icon overlay
|
|
112
|
+
if (clickable) {
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3 ermis-attachment--clickable"
|
|
116
|
+
onClick={onClick}
|
|
117
|
+
role="button"
|
|
118
|
+
tabIndex={0}
|
|
119
|
+
>
|
|
120
|
+
{!loaded && (
|
|
121
|
+
blurThumb && blurThumb !== posterSrc ? (
|
|
122
|
+
<img className="ermis-attachment-blur-preview" src={blurThumb} alt="" aria-hidden />
|
|
123
|
+
) : (
|
|
124
|
+
<div className="ermis-attachment-shimmer" />
|
|
125
|
+
)
|
|
126
|
+
)}
|
|
127
|
+
{posterSrc ? (
|
|
128
|
+
<img
|
|
129
|
+
ref={imgRef}
|
|
130
|
+
className={`ermis-attachment ermis-attachment--video-poster${loaded ? ' ermis-attachment--loaded' : ''}`}
|
|
131
|
+
src={posterSrc}
|
|
132
|
+
alt={attachment.file_name || 'video'}
|
|
133
|
+
loading="lazy"
|
|
134
|
+
onLoad={() => setLoaded(true)}
|
|
135
|
+
/>
|
|
136
|
+
) : (
|
|
137
|
+
<video
|
|
138
|
+
className={`ermis-attachment ermis-attachment--video${loaded ? ' ermis-attachment--loaded' : ''}`}
|
|
139
|
+
src={src}
|
|
140
|
+
preload="metadata"
|
|
141
|
+
onLoadedData={() => setLoaded(true)}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
144
|
+
<div className="ermis-attachment__overlay">
|
|
145
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
146
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
147
|
+
</svg>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Default inline video player (no lightbox)
|
|
106
154
|
return (
|
|
107
155
|
<div className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3">
|
|
108
156
|
{!loaded && (
|
|
@@ -140,7 +188,7 @@ const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
140
188
|
);
|
|
141
189
|
}, (prev, next) => {
|
|
142
190
|
return (prev.attachment.asset_url || prev.attachment.url) ===
|
|
143
|
-
(next.attachment.asset_url || next.attachment.url);
|
|
191
|
+
(next.attachment.asset_url || next.attachment.url) && prev.onClick === next.onClick;
|
|
144
192
|
});
|
|
145
193
|
(VideoAttachment as any).displayName = 'VideoAttachment';
|
|
146
194
|
|
|
@@ -148,16 +196,36 @@ const FileAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =>
|
|
|
148
196
|
const url = attachment.url || attachment.asset_url;
|
|
149
197
|
const name = attachment.file_name || attachment.title || 'File';
|
|
150
198
|
const size = attachment.file_size;
|
|
199
|
+
const mimeType = attachment.mime_type || attachment.type || '';
|
|
200
|
+
const ext = name.split('.').pop()?.toUpperCase() || 'FILE';
|
|
201
|
+
const { client } = useChatClient();
|
|
202
|
+
|
|
203
|
+
const handleDownload = useCallback(async (e: React.MouseEvent) => {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
e.stopPropagation();
|
|
206
|
+
if (!url) return;
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const blob = await client.downloadMedia(url);
|
|
210
|
+
const urlBlob = window.URL.createObjectURL(blob);
|
|
211
|
+
const a = document.createElement('a');
|
|
212
|
+
a.href = urlBlob;
|
|
213
|
+
a.download = name;
|
|
214
|
+
document.body.appendChild(a);
|
|
215
|
+
a.click();
|
|
216
|
+
a.remove();
|
|
217
|
+
window.URL.revokeObjectURL(urlBlob);
|
|
218
|
+
} catch {
|
|
219
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
220
|
+
}
|
|
221
|
+
}, [client, url, name]);
|
|
151
222
|
|
|
152
223
|
return (
|
|
153
|
-
<
|
|
154
|
-
className="ermis-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
rel="noopener noreferrer"
|
|
159
|
-
>
|
|
160
|
-
<span className="ermis-attachment__file-icon">⬇️</span>
|
|
224
|
+
<div className="ermis-attachment ermis-attachment--file">
|
|
225
|
+
<span className="ermis-attachment__file-icon">
|
|
226
|
+
{getFileIcon(mimeType, name)}
|
|
227
|
+
<span className="ermis-attachment__file-ext">{ext}</span>
|
|
228
|
+
</span>
|
|
161
229
|
<span className="ermis-attachment__file-info">
|
|
162
230
|
<span className="ermis-attachment__file-name">{name}</span>
|
|
163
231
|
{size && (
|
|
@@ -166,7 +234,19 @@ const FileAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =>
|
|
|
166
234
|
</span>
|
|
167
235
|
)}
|
|
168
236
|
</span>
|
|
169
|
-
|
|
237
|
+
<button
|
|
238
|
+
className="ermis-attachment__file-download"
|
|
239
|
+
onClick={handleDownload}
|
|
240
|
+
title="Download"
|
|
241
|
+
type="button"
|
|
242
|
+
>
|
|
243
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
244
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
245
|
+
<polyline points="7 10 12 15 17 10" />
|
|
246
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
247
|
+
</svg>
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
170
250
|
);
|
|
171
251
|
}, (prev, next) => {
|
|
172
252
|
return (prev.attachment.url || prev.attachment.asset_url) ===
|
|
@@ -258,8 +338,8 @@ const LinkPreviewAttachment: React.FC<AttachmentProps> = React.memo(({ attachmen
|
|
|
258
338
|
export const MessageAttachment: React.FC<AttachmentProps> = ({ attachment }) => {
|
|
259
339
|
if (isImage(attachment)) return <ImageAttachment attachment={attachment} />;
|
|
260
340
|
if (isVideo(attachment)) return <VideoAttachment attachment={attachment} />;
|
|
261
|
-
if (
|
|
262
|
-
if (
|
|
341
|
+
if (isVoiceRecordingAttachment(attachment)) return <VoiceRecordingAttachment attachment={attachment} />;
|
|
342
|
+
if (isLinkPreviewAttachment(attachment)) return <LinkPreviewAttachment attachment={attachment} />;
|
|
263
343
|
return <FileAttachment attachment={attachment} />;
|
|
264
344
|
};
|
|
265
345
|
|
|
@@ -268,9 +348,41 @@ export const AttachmentList: React.FC<{ attachments?: Attachment[] }> = React.me
|
|
|
268
348
|
|
|
269
349
|
// Group by type
|
|
270
350
|
const media = attachments.filter((a) => isImage(a) || isVideo(a));
|
|
271
|
-
const files = attachments.filter((a) => !isImage(a) && !isVideo(a) && !
|
|
272
|
-
const voices = attachments.filter(
|
|
273
|
-
const links = attachments.filter(
|
|
351
|
+
const files = attachments.filter((a) => !isImage(a) && !isVideo(a) && !isVoiceRecordingAttachment(a) && !isLinkPreviewAttachment(a));
|
|
352
|
+
const voices = attachments.filter(isVoiceRecordingAttachment);
|
|
353
|
+
const links = attachments.filter(isLinkPreviewAttachment);
|
|
354
|
+
|
|
355
|
+
// Lightbox state
|
|
356
|
+
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
357
|
+
const [lightboxIndex, setLightboxIndex] = useState(0);
|
|
358
|
+
|
|
359
|
+
// Build lightbox items from media attachments
|
|
360
|
+
const lightboxItems = useMemo<MediaLightboxItem[]>(() => {
|
|
361
|
+
return media.map(att => {
|
|
362
|
+
if (isImage(att)) {
|
|
363
|
+
return {
|
|
364
|
+
type: 'image' as const,
|
|
365
|
+
src: att.image_url || att.thumb_url || att.url || '',
|
|
366
|
+
alt: att.file_name || att.title,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
type: 'video' as const,
|
|
371
|
+
src: att.asset_url || att.url || '',
|
|
372
|
+
alt: att.file_name || att.title,
|
|
373
|
+
posterSrc: att.image_url || att.thumb_url,
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
}, [media]);
|
|
377
|
+
|
|
378
|
+
const openLightbox = useCallback((index: number) => {
|
|
379
|
+
setLightboxIndex(index);
|
|
380
|
+
setLightboxOpen(true);
|
|
381
|
+
}, []);
|
|
382
|
+
|
|
383
|
+
const closeLightbox = useCallback(() => {
|
|
384
|
+
setLightboxOpen(false);
|
|
385
|
+
}, []);
|
|
274
386
|
|
|
275
387
|
const mediaGridClass = media.length === 1
|
|
276
388
|
? 'ermis-attachment-grid ermis-attachment-grid--single'
|
|
@@ -283,8 +395,8 @@ export const AttachmentList: React.FC<{ attachments?: Attachment[] }> = React.me
|
|
|
283
395
|
<div className={mediaGridClass}>
|
|
284
396
|
{media.map((att, i) => (
|
|
285
397
|
isImage(att)
|
|
286
|
-
? <ImageAttachment key={att.id || `img-${i}`} attachment={att} />
|
|
287
|
-
: <VideoAttachment key={att.id || `vid-${i}`} attachment={att} />
|
|
398
|
+
? <ImageAttachment key={att.id || `img-${i}`} attachment={att} onClick={() => openLightbox(i)} />
|
|
399
|
+
: <VideoAttachment key={att.id || `vid-${i}`} attachment={att} onClick={() => openLightbox(i)} />
|
|
288
400
|
))}
|
|
289
401
|
</div>
|
|
290
402
|
)}
|
|
@@ -300,6 +412,16 @@ export const AttachmentList: React.FC<{ attachments?: Attachment[] }> = React.me
|
|
|
300
412
|
{links.map((att, i) => (
|
|
301
413
|
<LinkPreviewAttachment key={att.id || `link-${i}`} attachment={att} />
|
|
302
414
|
))}
|
|
415
|
+
|
|
416
|
+
{/* Media Lightbox */}
|
|
417
|
+
{lightboxItems.length > 0 && (
|
|
418
|
+
<MediaLightbox
|
|
419
|
+
items={lightboxItems}
|
|
420
|
+
initialIndex={lightboxIndex}
|
|
421
|
+
isOpen={lightboxOpen}
|
|
422
|
+
onClose={closeLightbox}
|
|
423
|
+
/>
|
|
424
|
+
)}
|
|
303
425
|
</div>
|
|
304
426
|
);
|
|
305
427
|
}, (prev, next) => {
|
|
@@ -426,7 +548,7 @@ export const RegularMessage: React.FC<MessageRendererProps> = React.memo(({ mess
|
|
|
426
548
|
const isOnlyUrl = URL_REGEX_STRICT.test(text);
|
|
427
549
|
|
|
428
550
|
return message.attachments.filter(att => {
|
|
429
|
-
if (
|
|
551
|
+
if (isLinkPreviewAttachment(att)) return isOnlyUrl;
|
|
430
552
|
return true;
|
|
431
553
|
});
|
|
432
554
|
}, [message.attachments, message.text]);
|