@ermis-network/ermis-chat-react 1.0.7 → 1.0.9
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 +2787 -1858
- 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 +2787 -1890
- 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 +14 -11
- 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
|
@@ -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]);
|
|
@@ -10,6 +10,10 @@ export type PendingOverlayProps = {
|
|
|
10
10
|
rejectLabel: string;
|
|
11
11
|
onAccept: () => void;
|
|
12
12
|
onReject: () => void;
|
|
13
|
+
/** Label for the skip button (direct messaging channels) */
|
|
14
|
+
skipLabel?: string;
|
|
15
|
+
/** Handler for the skip action (direct messaging channels) */
|
|
16
|
+
onSkip?: () => void;
|
|
13
17
|
AvatarComponent: React.ComponentType<AvatarProps>;
|
|
14
18
|
};
|
|
15
19
|
|
|
@@ -22,6 +26,8 @@ export const PendingOverlay: React.FC<PendingOverlayProps> = React.memo(({
|
|
|
22
26
|
rejectLabel,
|
|
23
27
|
onAccept,
|
|
24
28
|
onReject,
|
|
29
|
+
skipLabel,
|
|
30
|
+
onSkip,
|
|
25
31
|
AvatarComponent,
|
|
26
32
|
}) => (
|
|
27
33
|
<div className="ermis-message-list__pending-overlay">
|
|
@@ -31,7 +37,11 @@ export const PendingOverlay: React.FC<PendingOverlayProps> = React.memo(({
|
|
|
31
37
|
<div className="ermis-message-list__pending-channel-name">{channelName}</div>
|
|
32
38
|
<span className="ermis-message-list__pending-overlay-subtitle">{subtitle}</span>
|
|
33
39
|
<div className="ermis-message-list__pending-actions">
|
|
34
|
-
|
|
40
|
+
{onSkip ? (
|
|
41
|
+
<button className="ermis-message-list__reject-btn" onClick={onSkip}>{skipLabel || 'Skip'}</button>
|
|
42
|
+
) : (
|
|
43
|
+
<button className="ermis-message-list__reject-btn" onClick={onReject}>{rejectLabel}</button>
|
|
44
|
+
)}
|
|
35
45
|
<button className="ermis-message-list__accept-btn" onClick={onAccept}>{acceptLabel}</button>
|
|
36
46
|
</div>
|
|
37
47
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
2
|
import { useChatClient } from '../hooks/useChatClient';
|
|
3
3
|
import { Avatar } from './Avatar';
|
|
4
|
+
import { isStickerMessage } from '../messageTypeUtils';
|
|
4
5
|
import { replaceMentionsForPreview, buildUserMap } from '../utils';
|
|
5
6
|
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
6
7
|
import type { PinnedMessageItemProps, PinnedMessagesProps } from '../types';
|
|
@@ -25,7 +26,7 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
|
|
|
25
26
|
}, [activeChannel?.state]);
|
|
26
27
|
|
|
27
28
|
let previewText = message.text || '';
|
|
28
|
-
const isSticker = message
|
|
29
|
+
const isSticker = isStickerMessage(message);
|
|
29
30
|
|
|
30
31
|
if (!previewText && hasAttachments) {
|
|
31
32
|
const firstAttach = message.attachments![0];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useMemo } from 'react';
|
|
2
2
|
import { useChatClient } from '../hooks/useChatClient';
|
|
3
3
|
import { replaceMentionsForPreview, buildUserMap } from '../utils';
|
|
4
|
+
import { isStickerMessage } from '../messageTypeUtils';
|
|
4
5
|
import type { ReplyPreviewProps } from '../types';
|
|
5
6
|
|
|
6
7
|
const MAX_PREVIEW_LENGTH = 120;
|
|
@@ -53,7 +54,7 @@ export const ReplyPreview: React.FC<ReplyPreviewProps> = React.memo(({
|
|
|
53
54
|
const formattedText = useMemo(() => replaceMentionsForPreview(rawText, message, userMap), [rawText, message, userMap]);
|
|
54
55
|
const hasText = !!formattedText.trim();
|
|
55
56
|
const hasAttachments = message.attachments && message.attachments.length > 0;
|
|
56
|
-
const isSticker = message
|
|
57
|
+
const isSticker = isStickerMessage(message);
|
|
57
58
|
const attachmentSummary = hasAttachments ? getAttachmentSummary(message.attachments!) : '';
|
|
58
59
|
|
|
59
60
|
// Build preview content
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { AvatarProps } from '../types';
|
|
3
|
+
|
|
4
|
+
export type SkippedOverlayProps = {
|
|
5
|
+
channelImage?: string;
|
|
6
|
+
channelName?: string;
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle: string;
|
|
9
|
+
acceptLabel: string;
|
|
10
|
+
onAccept: () => void;
|
|
11
|
+
AvatarComponent: React.ComponentType<AvatarProps>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const SkippedOverlay: React.FC<SkippedOverlayProps> = React.memo(({
|
|
15
|
+
channelImage,
|
|
16
|
+
channelName,
|
|
17
|
+
title,
|
|
18
|
+
subtitle,
|
|
19
|
+
acceptLabel,
|
|
20
|
+
onAccept,
|
|
21
|
+
AvatarComponent,
|
|
22
|
+
}) => (
|
|
23
|
+
<div className="ermis-message-list__pending-overlay">
|
|
24
|
+
<div className="ermis-message-list__pending-card">
|
|
25
|
+
<AvatarComponent image={channelImage} name={channelName} size={64} className="ermis-message-list__pending-avatar" />
|
|
26
|
+
<span className="ermis-message-list__pending-overlay-title">{title}</span>
|
|
27
|
+
<div className="ermis-message-list__pending-channel-name">{channelName}</div>
|
|
28
|
+
<span className="ermis-message-list__pending-overlay-subtitle">{subtitle}</span>
|
|
29
|
+
<div className="ermis-message-list__pending-actions">
|
|
30
|
+
<button className="ermis-message-list__accept-btn" onClick={onAccept}>{acceptLabel}</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
));
|
|
35
|
+
|
|
36
|
+
SkippedOverlay.displayName = 'SkippedOverlay';
|
|
@@ -256,7 +256,7 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
256
256
|
cancelled = true;
|
|
257
257
|
clearTimeout(timer);
|
|
258
258
|
};
|
|
259
|
-
}, [search, localFilteredUsers.length, client
|
|
259
|
+
}, [search, localFilteredUsers.length, client]);
|
|
260
260
|
|
|
261
261
|
/* ---------- 5. Derived display list ---------- */
|
|
262
262
|
const usersToDisplay = (search.trim() && localFilteredUsers.length === 0)
|
|
@@ -12,6 +12,8 @@ import { useChannelProfile } from '../hooks/useChannelData';
|
|
|
12
12
|
import { Avatar } from './Avatar';
|
|
13
13
|
import { MessageItem } from './MessageItem';
|
|
14
14
|
import { SystemMessageItem } from './MessageItem';
|
|
15
|
+
import { isPublicGroupChannel, isDirectChannel } from '../channelTypeUtils';
|
|
16
|
+
import { canManageChannel, isSkippedMember, isPendingMember } from '../channelRoleUtils';
|
|
15
17
|
import {
|
|
16
18
|
defaultMessageRenderers,
|
|
17
19
|
type MessageBubbleProps,
|
|
@@ -22,6 +24,7 @@ import { PinnedMessages } from './PinnedMessages';
|
|
|
22
24
|
import { ReadReceipts } from './ReadReceipts';
|
|
23
25
|
import { TypingIndicator } from './TypingIndicator';
|
|
24
26
|
import { PendingOverlay } from './PendingOverlay';
|
|
27
|
+
import { SkippedOverlay } from './SkippedOverlay';
|
|
25
28
|
import { BannedOverlay } from './BannedOverlay';
|
|
26
29
|
import { ClosedTopicOverlay } from './ClosedTopicOverlay';
|
|
27
30
|
import type { MessageListProps } from '../types';
|
|
@@ -78,6 +81,23 @@ const DefaultBubble: React.FC<MessageBubbleProps> = React.memo(({
|
|
|
78
81
|
));
|
|
79
82
|
(DefaultBubble as any).displayName = 'DefaultBubble';
|
|
80
83
|
|
|
84
|
+
const DefaultPendingInviteeNotification = React.memo(({ inviteeName, label }: { inviteeName?: string, label?: string }) => {
|
|
85
|
+
const defaultLabel = inviteeName ? `${inviteeName} needs to accept your invitation to see the messages you've sent` : 'The invited user needs to accept your invitation to see the messages you\'ve sent';
|
|
86
|
+
return (
|
|
87
|
+
<div className="ermis-message-list__pending-invitee">
|
|
88
|
+
<div className="ermis-message-list__pending-invitee-content">
|
|
89
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
90
|
+
<circle cx="12" cy="12" r="10" />
|
|
91
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
92
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
93
|
+
</svg>
|
|
94
|
+
<span>{label || defaultLabel}</span>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
DefaultPendingInviteeNotification.displayName = 'DefaultPendingInviteeNotification';
|
|
100
|
+
|
|
81
101
|
/* ----------------------------------------------------------
|
|
82
102
|
VirtualMessageList
|
|
83
103
|
---------------------------------------------------------- */
|
|
@@ -115,14 +135,26 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
115
135
|
pendingOverlaySubtitle = 'Accept the invitation to view messages and interact',
|
|
116
136
|
pendingAcceptLabel = 'Accept',
|
|
117
137
|
pendingRejectLabel = 'Reject',
|
|
138
|
+
pendingSkipLabel = 'Skip',
|
|
139
|
+
skippedOverlayTitle = 'You skipped this conversation',
|
|
140
|
+
skippedOverlaySubtitle = 'Accept the invitation to start chatting',
|
|
141
|
+
skippedAcceptLabel = 'Accept',
|
|
118
142
|
closedTopicOverlayTitle = 'This topic has been closed',
|
|
119
143
|
closedTopicOverlaySubtitle = 'You can no longer read or send messages in this topic.',
|
|
120
144
|
closedTopicReopenLabel = 'Reopen Topic',
|
|
145
|
+
PendingInviteeNotificationComponent = DefaultPendingInviteeNotification,
|
|
146
|
+
pendingInviteeLabel,
|
|
121
147
|
}) => {
|
|
122
|
-
const { client, messages, readState, activeChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
|
|
148
|
+
const { client, messages, readState, activeChannel, setActiveChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
|
|
123
149
|
const { isBanned } = useBannedState(activeChannel, client.userID);
|
|
124
150
|
const { isBlocked } = useBlockedState(activeChannel, client.userID);
|
|
125
151
|
const { isPending } = usePendingState(activeChannel, client.userID);
|
|
152
|
+
|
|
153
|
+
const isSkipped = client.userID
|
|
154
|
+
? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
|
|
155
|
+
isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
|
|
156
|
+
: false;
|
|
157
|
+
|
|
126
158
|
const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
|
|
127
159
|
const parentCid = activeChannel?.data?.parent_cid as string | undefined;
|
|
128
160
|
const parentChannel = parentCid && client ? client.activeChannels[parentCid] : undefined;
|
|
@@ -134,7 +166,20 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
134
166
|
messagesRef.current = messages;
|
|
135
167
|
const currentUserId = client.userID;
|
|
136
168
|
const currentUserRole = currentUserId ? activeChannel?.state?.members?.[currentUserId]?.channel_role : undefined;
|
|
137
|
-
const canManageTopic = currentUserRole
|
|
169
|
+
const canManageTopic = canManageChannel(currentUserRole);
|
|
170
|
+
|
|
171
|
+
const pendingInviteeName = useMemo(() => {
|
|
172
|
+
if (!activeChannel || !currentUserId) return null;
|
|
173
|
+
if (!isDirectChannel(activeChannel)) return null;
|
|
174
|
+
const membersList = Object.values(activeChannel.state?.members || {});
|
|
175
|
+
if (membersList.length === 2 && !isPending) {
|
|
176
|
+
const otherUser = membersList.find(m => m.user_id !== currentUserId);
|
|
177
|
+
if (otherUser && isPendingMember(otherUser.channel_role)) {
|
|
178
|
+
return otherUser.user?.name || otherUser.user?.id || 'User';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}, [activeChannel, currentUserId, isPending]);
|
|
138
183
|
|
|
139
184
|
// Ref to scope DOM queries (safe for multiple instances)
|
|
140
185
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -145,7 +190,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
145
190
|
const handleAcceptInvite = useCallback(async () => {
|
|
146
191
|
if (!activeChannel) return;
|
|
147
192
|
try {
|
|
148
|
-
const isPublicTeamOrMeeting = (activeChannel
|
|
193
|
+
const isPublicTeamOrMeeting = isPublicGroupChannel(activeChannel);
|
|
149
194
|
const action = isPublicTeamOrMeeting ? 'join' : 'accept';
|
|
150
195
|
await activeChannel.acceptInvite(action);
|
|
151
196
|
} catch (e: any) {
|
|
@@ -153,10 +198,25 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
153
198
|
}
|
|
154
199
|
}, [activeChannel]);
|
|
155
200
|
|
|
156
|
-
const handleRejectInvite = useCallback(() => {
|
|
201
|
+
const handleRejectInvite = useCallback(async () => {
|
|
157
202
|
if (!activeChannel) return;
|
|
158
|
-
|
|
159
|
-
|
|
203
|
+
try {
|
|
204
|
+
await activeChannel.rejectInvite();
|
|
205
|
+
if (setActiveChannel) setActiveChannel(null);
|
|
206
|
+
} catch (e: any) {
|
|
207
|
+
console.error('Error rejecting invite', e);
|
|
208
|
+
}
|
|
209
|
+
}, [activeChannel, setActiveChannel]);
|
|
210
|
+
|
|
211
|
+
const handleSkipInvite = useCallback(async () => {
|
|
212
|
+
if (!activeChannel) return;
|
|
213
|
+
try {
|
|
214
|
+
await activeChannel.skipInvite();
|
|
215
|
+
if (setActiveChannel) setActiveChannel(null);
|
|
216
|
+
} catch (e: any) {
|
|
217
|
+
console.error('Error skipping invite', e);
|
|
218
|
+
}
|
|
219
|
+
}, [activeChannel, setActiveChannel]);
|
|
160
220
|
|
|
161
221
|
const scrollToBottom = useCallback((smooth = false, attempts = 0) => {
|
|
162
222
|
const handle = vlistRef.current;
|
|
@@ -223,7 +283,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
223
283
|
}, [setHasMore, setHasNewer]),
|
|
224
284
|
});
|
|
225
285
|
|
|
226
|
-
const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked);
|
|
286
|
+
const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked || isSkipped);
|
|
227
287
|
const prevOverlayRef = useRef(hasOverlay);
|
|
228
288
|
|
|
229
289
|
useEffect(() => {
|
|
@@ -393,6 +453,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
393
453
|
}
|
|
394
454
|
|
|
395
455
|
if (isPending) {
|
|
456
|
+
const isDirect = activeChannel ? isDirectChannel(activeChannel) : false;
|
|
396
457
|
return (
|
|
397
458
|
<PendingOverlay
|
|
398
459
|
channelImage={channelImage}
|
|
@@ -403,6 +464,22 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
403
464
|
acceptLabel={pendingAcceptLabel}
|
|
404
465
|
onReject={handleRejectInvite}
|
|
405
466
|
onAccept={handleAcceptInvite}
|
|
467
|
+
skipLabel={isDirect ? pendingSkipLabel : undefined}
|
|
468
|
+
onSkip={isDirect ? handleSkipInvite : undefined}
|
|
469
|
+
AvatarComponent={AvatarComponent}
|
|
470
|
+
/>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (isSkipped) {
|
|
475
|
+
return (
|
|
476
|
+
<SkippedOverlay
|
|
477
|
+
channelImage={channelImage}
|
|
478
|
+
channelName={channelName}
|
|
479
|
+
title={skippedOverlayTitle}
|
|
480
|
+
subtitle={skippedOverlaySubtitle}
|
|
481
|
+
acceptLabel={skippedAcceptLabel}
|
|
482
|
+
onAccept={handleAcceptInvite}
|
|
406
483
|
AvatarComponent={AvatarComponent}
|
|
407
484
|
/>
|
|
408
485
|
);
|
|
@@ -430,6 +507,13 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
430
507
|
: <EmptyStateIndicator />
|
|
431
508
|
)}
|
|
432
509
|
|
|
510
|
+
{pendingInviteeName && (
|
|
511
|
+
<PendingInviteeNotificationComponent
|
|
512
|
+
inviteeName={pendingInviteeName}
|
|
513
|
+
label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
|
|
514
|
+
/>
|
|
515
|
+
)}
|
|
516
|
+
|
|
433
517
|
<VList
|
|
434
518
|
key={activeChannel?.cid || 'empty'}
|
|
435
519
|
ref={vlistRef}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { isDirectChannel } from '../channelTypeUtils';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Hook that tracks whether the current user has blocked the other party
|
|
@@ -17,12 +18,12 @@ import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
|
17
18
|
*/
|
|
18
19
|
export function useBlockedState(channel: Channel | null | undefined, currentUserId?: string) {
|
|
19
20
|
const [isBlocked, setIsBlocked] = useState<boolean>(() => {
|
|
20
|
-
if (channel
|
|
21
|
+
if (!isDirectChannel(channel)) return false;
|
|
21
22
|
return Boolean(channel?.state?.membership?.blocked);
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
useEffect(() => {
|
|
25
|
-
if (!channel || channel
|
|
26
|
+
if (!channel || !isDirectChannel(channel)) {
|
|
26
27
|
setIsBlocked(false);
|
|
27
28
|
return;
|
|
28
29
|
}
|