@ermis-network/ermis-chat-react 1.0.6 → 1.0.7
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 +2410 -1308
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +471 -16
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +145 -1
- package/dist/index.d.ts +145 -1
- package/dist/index.mjs +2339 -1241
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/BannedOverlay.tsx +40 -0
- package/src/components/ChannelActions.tsx +231 -0
- package/src/components/ChannelHeader.tsx +38 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +118 -20
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +10 -2
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +88 -1
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -4
- package/src/components/ChannelList.tsx +460 -38
- package/src/components/ClosedTopicOverlay.tsx +38 -0
- package/src/components/MessageInput.tsx +19 -2
- package/src/components/MessageItem.tsx +8 -11
- package/src/components/MessageQuickReactions.tsx +3 -2
- package/src/components/MessageReactions.tsx +8 -3
- package/src/components/MessageRenderers.tsx +7 -9
- package/src/components/PendingOverlay.tsx +41 -0
- package/src/components/TopicModal.tsx +189 -0
- package/src/components/VirtualMessageList.tsx +74 -43
- package/src/hooks/useBannedState.ts +27 -3
- package/src/hooks/useChannelCapabilities.ts +7 -3
- package/src/hooks/useChannelData.ts +1 -1
- package/src/hooks/useChannelListUpdates.ts +24 -3
- package/src/hooks/useChannelRowUpdates.ts +6 -0
- package/src/hooks/useMessageActions.ts +1 -1
- package/src/index.ts +6 -1
- package/src/styles/_channel-info.css +21 -0
- package/src/styles/_channel-list.css +217 -6
- package/src/styles/_message-bubble.css +75 -9
- package/src/styles/_message-input.css +24 -0
- package/src/styles/_message-list.css +51 -6
- package/src/styles/_message-quick-reactions.css +5 -0
- package/src/styles/_message-reactions.css +7 -0
- package/src/styles/_topic-modal.css +154 -0
- package/src/styles/index.css +1 -0
- package/src/types.ts +157 -3
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export type ClosedTopicOverlayProps = {
|
|
4
|
+
title: string;
|
|
5
|
+
subtitle: string;
|
|
6
|
+
canManageTopic: boolean;
|
|
7
|
+
reopenLabel: string;
|
|
8
|
+
onReopen?: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const ClosedTopicOverlay: React.FC<ClosedTopicOverlayProps> = React.memo(({
|
|
12
|
+
title,
|
|
13
|
+
subtitle,
|
|
14
|
+
canManageTopic,
|
|
15
|
+
reopenLabel,
|
|
16
|
+
onReopen,
|
|
17
|
+
}) => (
|
|
18
|
+
<div className="ermis-message-list__closed-overlay">
|
|
19
|
+
<div className="ermis-message-list__closed-overlay-icon">
|
|
20
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
21
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
22
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
23
|
+
</svg>
|
|
24
|
+
</div>
|
|
25
|
+
<span className="ermis-message-list__closed-overlay-title">{title}</span>
|
|
26
|
+
<span className="ermis-message-list__closed-overlay-subtitle">{subtitle}</span>
|
|
27
|
+
{canManageTopic && onReopen && (
|
|
28
|
+
<button
|
|
29
|
+
className="ermis-message-list__reopen-btn"
|
|
30
|
+
onClick={onReopen}
|
|
31
|
+
>
|
|
32
|
+
{reopenLabel}
|
|
33
|
+
</button>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
));
|
|
37
|
+
|
|
38
|
+
ClosedTopicOverlay.displayName = 'ClosedTopicOverlay';
|
|
@@ -35,7 +35,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
35
35
|
EmojiButtonComponent = DefaultEmojiButton,
|
|
36
36
|
ReplyPreviewComponent = ReplyPreview,
|
|
37
37
|
EditPreviewComponent = EditPreview,
|
|
38
|
-
bannedLabel = 'You have been
|
|
38
|
+
bannedLabel = 'You have been banned from this channel',
|
|
39
39
|
blockedLabel = 'You have blocked this user. Unblock to send messages.',
|
|
40
40
|
linksDisabledLabel = 'Message blocked: Sending links is disabled for members.',
|
|
41
41
|
keywordBlockedLabel = (match: string) => `Message blocked: Contains restricted word "${match}".`,
|
|
@@ -43,6 +43,7 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
43
43
|
slowModeLabel = (cooldown: number) => (
|
|
44
44
|
<>Slow mode is active. You can send another message in <strong>{cooldown}s</strong>.</>
|
|
45
45
|
),
|
|
46
|
+
closedTopicLabel = 'This topic is closed.',
|
|
46
47
|
}) => {
|
|
47
48
|
const { client, activeChannel, syncMessages, quotedMessage, setQuotedMessage, editingMessage, setEditingMessage } = useChatClient();
|
|
48
49
|
const { isBanned } = useBannedState(activeChannel, client.userID);
|
|
@@ -51,7 +52,8 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
51
52
|
const editableRef = React.useRef<HTMLDivElement>(null);
|
|
52
53
|
const [hasContent, setHasContent] = useState(false);
|
|
53
54
|
|
|
54
|
-
const { role, isTeamChannel, hasCapability } = useChannelCapabilities();
|
|
55
|
+
const { role, isTeamOrMeetingChannel: isTeamChannel, hasCapability } = useChannelCapabilities();
|
|
56
|
+
const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
|
|
55
57
|
|
|
56
58
|
// Slow Mode Logic
|
|
57
59
|
const [memberMessageCooldown, setMemberMessageCooldown] = useState(Number(activeChannel?.data?.member_message_cooldown) || 0);
|
|
@@ -398,6 +400,21 @@ export const MessageInput: React.FC<MessageInputProps> = React.memo(({
|
|
|
398
400
|
);
|
|
399
401
|
}
|
|
400
402
|
|
|
403
|
+
// Show closed topic banner instead of input
|
|
404
|
+
if (isClosedTopic) {
|
|
405
|
+
return (
|
|
406
|
+
<div className={`ermis-message-input ermis-message-input--closed${className ? ` ${className}` : ''}`}>
|
|
407
|
+
<div className="ermis-message-input__closed-banner">
|
|
408
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
409
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
410
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
411
|
+
</svg>
|
|
412
|
+
<span>{closedTopicLabel}</span>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
401
418
|
const isStillUploading = files.some((f) => f.status === 'uploading');
|
|
402
419
|
|
|
403
420
|
return (
|
|
@@ -155,9 +155,7 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
155
155
|
/>
|
|
156
156
|
)}
|
|
157
157
|
<div className="ermis-message-list__bubble-wrapper">
|
|
158
|
-
<
|
|
159
|
-
<MessageQuickReactions message={message} isOwnMessage={isOwnMessage} />
|
|
160
|
-
</div>
|
|
158
|
+
<MessageQuickReactions message={message} isOwnMessage={isOwnMessage} disabled={!canReact} />
|
|
161
159
|
<MessageBubble message={message} isOwnMessage={isOwnMessage}>
|
|
162
160
|
{isForwarded && (
|
|
163
161
|
<span className="ermis-message-list__forwarded-indicator">{forwardedLabel}</span>
|
|
@@ -187,14 +185,13 @@ export const MessageItem: React.FC<MessageItemProps> = React.memo(({
|
|
|
187
185
|
|
|
188
186
|
{/* Message Reactions */}
|
|
189
187
|
{MessageReactionsComponent && (
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
</div>
|
|
188
|
+
<MessageReactionsComponent
|
|
189
|
+
reactionCounts={(message as any).reaction_counts}
|
|
190
|
+
ownReactions={(message as any).own_reactions}
|
|
191
|
+
latestReactions={(message as any).latest_reactions}
|
|
192
|
+
onClickReaction={handleReactionToggle}
|
|
193
|
+
disabled={!canReact}
|
|
194
|
+
/>
|
|
198
195
|
)}
|
|
199
196
|
</div>
|
|
200
197
|
</div>
|
|
@@ -14,7 +14,8 @@ const EMOJI_MAP: Record<string, string> = {
|
|
|
14
14
|
export const MessageQuickReactions: React.FC<{
|
|
15
15
|
message: FormatMessageResponse;
|
|
16
16
|
isOwnMessage: boolean;
|
|
17
|
-
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}> = React.memo(({ message, isOwnMessage, disabled }) => {
|
|
18
19
|
const { activeChannel, client } = useChatClient();
|
|
19
20
|
const currentUserId = client?.userID;
|
|
20
21
|
|
|
@@ -41,7 +42,7 @@ export const MessageQuickReactions: React.FC<{
|
|
|
41
42
|
);
|
|
42
43
|
|
|
43
44
|
return (
|
|
44
|
-
<div className={`ermis-message-quick-reactions ${isOwnMessage ? 'ermis-message-quick-reactions--own' : ''}`}>
|
|
45
|
+
<div className={`ermis-message-quick-reactions ${isOwnMessage ? 'ermis-message-quick-reactions--own' : ''} ${disabled ? 'ermis-message-quick-reactions--disabled' : ''}`}>
|
|
45
46
|
{QUICK_REACTIONS.map((type) => {
|
|
46
47
|
const isOwn =
|
|
47
48
|
(message as any).own_reactions?.some((r: any) => r.type === type) ||
|
|
@@ -16,6 +16,7 @@ export const MessageReactions: React.FC<MessageReactionsProps> = React.memo(({
|
|
|
16
16
|
ownReactions,
|
|
17
17
|
latestReactions,
|
|
18
18
|
onClickReaction,
|
|
19
|
+
disabled,
|
|
19
20
|
}) => {
|
|
20
21
|
const { client } = useChatClient();
|
|
21
22
|
const currentUserId = client?.userID;
|
|
@@ -23,18 +24,22 @@ export const MessageReactions: React.FC<MessageReactionsProps> = React.memo(({
|
|
|
23
24
|
if (!reactionCounts || Object.keys(reactionCounts).length === 0) return null;
|
|
24
25
|
|
|
25
26
|
return (
|
|
26
|
-
<div className=
|
|
27
|
+
<div className={`ermis-message-reactions${disabled ? ' ermis-message-reactions--disabled' : ''}`}>
|
|
27
28
|
{Object.entries(reactionCounts).map(([type, count]) => {
|
|
28
29
|
const isOwn =
|
|
29
30
|
ownReactions?.some((r) => r.type === type) ||
|
|
30
31
|
latestReactions?.some((r) => r.type === type && (r.user?.id === currentUserId || (r as any).user_id === currentUserId));
|
|
31
32
|
|
|
32
33
|
// Find users who reacted with this type for the tooltip
|
|
33
|
-
const
|
|
34
|
+
const rawUserNames = latestReactions
|
|
34
35
|
?.filter((r) => r.type === type)
|
|
35
36
|
.map((r: any) => r.user?.name || r.user?.id || r.user_id || 'Someone');
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
+
const userNames = Array.from(new Set(rawUserNames || []))
|
|
39
|
+
.map((n: any) => typeof n === 'string' ? n.replace(/‎|\u200E/gi, '').trim() : n)
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
|
|
42
|
+
const tooltip = userNames.length > 0 ? userNames.join(', ') : type;
|
|
38
43
|
const emoji = defaultReactionEmojiMap[type] || type;
|
|
39
44
|
|
|
40
45
|
return (
|
|
@@ -52,7 +52,7 @@ const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
52
52
|
}, [loaded, src]);
|
|
53
53
|
|
|
54
54
|
return (
|
|
55
|
-
<div className="ermis-attachment-aspect-box"
|
|
55
|
+
<div className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3">
|
|
56
56
|
{/* Blur placeholder: use thumb if available, otherwise shimmer */}
|
|
57
57
|
{!loaded && (
|
|
58
58
|
thumbSrc && thumbSrc !== src ? (
|
|
@@ -104,7 +104,7 @@ const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
104
104
|
}, [loaded, posterSrc]);
|
|
105
105
|
|
|
106
106
|
return (
|
|
107
|
-
<div className="ermis-attachment-aspect-box"
|
|
107
|
+
<div className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3">
|
|
108
108
|
{!loaded && (
|
|
109
109
|
blurThumb && blurThumb !== posterSrc ? (
|
|
110
110
|
<img
|
|
@@ -121,7 +121,7 @@ const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
121
121
|
<img
|
|
122
122
|
ref={imgRef}
|
|
123
123
|
src={posterSrc}
|
|
124
|
-
|
|
124
|
+
className="ermis-attachment--hidden-loader"
|
|
125
125
|
onLoad={() => setLoaded(true)}
|
|
126
126
|
alt="poster-loader"
|
|
127
127
|
/>
|
|
@@ -133,7 +133,7 @@ const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
133
133
|
controls
|
|
134
134
|
preload="metadata"
|
|
135
135
|
onLoadedData={() => {
|
|
136
|
-
|
|
136
|
+
if (!posterSrc) setLoaded(true);
|
|
137
137
|
}}
|
|
138
138
|
/>
|
|
139
139
|
</div>
|
|
@@ -226,7 +226,7 @@ const LinkPreviewAttachment: React.FC<AttachmentProps> = React.memo(({ attachmen
|
|
|
226
226
|
rel="noopener noreferrer"
|
|
227
227
|
>
|
|
228
228
|
{image && (
|
|
229
|
-
<div
|
|
229
|
+
<div className="ermis-attachment__link-image-wrapper">
|
|
230
230
|
{!loaded && <div className="ermis-attachment-shimmer" />}
|
|
231
231
|
<img
|
|
232
232
|
ref={imgRef}
|
|
@@ -235,7 +235,6 @@ const LinkPreviewAttachment: React.FC<AttachmentProps> = React.memo(({ attachmen
|
|
|
235
235
|
alt={title || 'preview'}
|
|
236
236
|
loading="lazy"
|
|
237
237
|
onLoad={() => setLoaded(true)}
|
|
238
|
-
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease', display: 'block', width: '100%', height: '100%', objectFit: 'cover', position: 'absolute', top: 0, left: 0 }}
|
|
239
238
|
/>
|
|
240
239
|
</div>
|
|
241
240
|
)}
|
|
@@ -553,16 +552,15 @@ export const StickerMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
|
553
552
|
|
|
554
553
|
if (stickerUrl) {
|
|
555
554
|
return (
|
|
556
|
-
<div
|
|
555
|
+
<div className="ermis-message-sticker-wrapper">
|
|
557
556
|
{!loaded && <div className="ermis-attachment-shimmer" />}
|
|
558
557
|
<img
|
|
559
558
|
ref={imgRef}
|
|
560
|
-
className=
|
|
559
|
+
className={`ermis-message-sticker${loaded ? ' ermis-attachment--loaded' : ''}`}
|
|
561
560
|
src={stickerUrl}
|
|
562
561
|
alt="sticker"
|
|
563
562
|
loading="lazy"
|
|
564
563
|
onLoad={() => setLoaded(true)}
|
|
565
|
-
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease', position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'contain' }}
|
|
566
564
|
/>
|
|
567
565
|
</div>
|
|
568
566
|
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { AvatarProps } from '../types';
|
|
3
|
+
|
|
4
|
+
export type PendingOverlayProps = {
|
|
5
|
+
channelImage?: string;
|
|
6
|
+
channelName?: string;
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle: string;
|
|
9
|
+
acceptLabel: string;
|
|
10
|
+
rejectLabel: string;
|
|
11
|
+
onAccept: () => void;
|
|
12
|
+
onReject: () => void;
|
|
13
|
+
AvatarComponent: React.ComponentType<AvatarProps>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const PendingOverlay: React.FC<PendingOverlayProps> = React.memo(({
|
|
17
|
+
channelImage,
|
|
18
|
+
channelName,
|
|
19
|
+
title,
|
|
20
|
+
subtitle,
|
|
21
|
+
acceptLabel,
|
|
22
|
+
rejectLabel,
|
|
23
|
+
onAccept,
|
|
24
|
+
onReject,
|
|
25
|
+
AvatarComponent,
|
|
26
|
+
}) => (
|
|
27
|
+
<div className="ermis-message-list__pending-overlay">
|
|
28
|
+
<div className="ermis-message-list__pending-card">
|
|
29
|
+
<AvatarComponent image={channelImage} name={channelName} size={64} className="ermis-message-list__pending-avatar" />
|
|
30
|
+
<span className="ermis-message-list__pending-overlay-title">{title}</span>
|
|
31
|
+
<div className="ermis-message-list__pending-channel-name">{channelName}</div>
|
|
32
|
+
<span className="ermis-message-list__pending-overlay-subtitle">{subtitle}</span>
|
|
33
|
+
<div className="ermis-message-list__pending-actions">
|
|
34
|
+
<button className="ermis-message-list__reject-btn" onClick={onReject}>{rejectLabel}</button>
|
|
35
|
+
<button className="ermis-message-list__accept-btn" onClick={onAccept}>{acceptLabel}</button>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
));
|
|
40
|
+
|
|
41
|
+
PendingOverlay.displayName = 'PendingOverlay';
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import type { CreateTopicData, EditTopicData } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { Modal } from './Modal';
|
|
4
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
5
|
+
import type { TopicModalProps } from '../types';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TOPIC_ICONS = ['💬', '🔥', '🚀', '⭐', '💡', '🎉', '📌', '📁', '🎨', '💻', '📈', '🤝'];
|
|
8
|
+
|
|
9
|
+
export const TopicModal: React.FC<TopicModalProps> = React.memo(({
|
|
10
|
+
isOpen,
|
|
11
|
+
onClose,
|
|
12
|
+
onSuccess,
|
|
13
|
+
EmojiPickerComponent,
|
|
14
|
+
parentChannel,
|
|
15
|
+
topic,
|
|
16
|
+
title = topic ? 'Edit Topic' : 'Create Topic',
|
|
17
|
+
nameLabel = 'Topic Name',
|
|
18
|
+
namePlaceholder = 'Enter topic name',
|
|
19
|
+
emojiLabel = 'Topic icon',
|
|
20
|
+
descriptionLabel = 'Description',
|
|
21
|
+
descriptionPlaceholder = 'Enter topic description',
|
|
22
|
+
cancelButtonLabel = 'Cancel',
|
|
23
|
+
saveButtonLabel = topic ? 'Save' : 'Create',
|
|
24
|
+
savingButtonLabel = topic ? 'Saving...' : 'Creating...',
|
|
25
|
+
}) => {
|
|
26
|
+
const { activeChannel, client } = useChatClient();
|
|
27
|
+
|
|
28
|
+
const originalName = (topic?.data?.name as string) || '';
|
|
29
|
+
const originalImage = (topic?.data?.image as string) || '';
|
|
30
|
+
const originalEmoji = originalImage.startsWith('emoji://') ? originalImage.replace('emoji://', '') : '';
|
|
31
|
+
const originalDescription = (topic?.data?.description as string) || '';
|
|
32
|
+
|
|
33
|
+
const [name, setName] = useState(originalName);
|
|
34
|
+
const [emoji, setEmoji] = useState(originalEmoji);
|
|
35
|
+
const [description, setDescription] = useState(originalDescription);
|
|
36
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
const targetParent = parentChannel || activeChannel;
|
|
40
|
+
|
|
41
|
+
const handleSave = useCallback(async () => {
|
|
42
|
+
if (!name.trim() || !emoji) return;
|
|
43
|
+
|
|
44
|
+
// Resolve parent channel (owner of topics)
|
|
45
|
+
let editorParent = targetParent;
|
|
46
|
+
if (topic && topic.data?.parent_cid) {
|
|
47
|
+
editorParent = client.activeChannels[topic.data.parent_cid as string] || editorParent;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!editorParent && !topic) return;
|
|
51
|
+
|
|
52
|
+
setIsSaving(true);
|
|
53
|
+
setError(null);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
if (topic) {
|
|
57
|
+
if (!editorParent) throw new Error("Parent channel not found");
|
|
58
|
+
|
|
59
|
+
const payload: EditTopicData = {};
|
|
60
|
+
if (name.trim() !== originalName) payload.name = name.trim();
|
|
61
|
+
if (emoji !== originalEmoji) payload.image = emoji ? `emoji://${emoji}` : '';
|
|
62
|
+
if (description.trim() !== originalDescription) payload.description = description.trim();
|
|
63
|
+
|
|
64
|
+
if (Object.keys(payload).length > 0 && topic.cid) {
|
|
65
|
+
await editorParent.editTopic(topic.cid, payload);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (onSuccess) {
|
|
69
|
+
onSuccess(topic);
|
|
70
|
+
} else {
|
|
71
|
+
onClose();
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
if (!editorParent) return;
|
|
75
|
+
const payload: CreateTopicData = {
|
|
76
|
+
name: name.trim(),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (emoji) {
|
|
80
|
+
payload.image = `emoji://${emoji}`;
|
|
81
|
+
}
|
|
82
|
+
if (description.trim()) {
|
|
83
|
+
payload.description = description.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await editorParent.createTopic(payload);
|
|
87
|
+
|
|
88
|
+
if (onSuccess) {
|
|
89
|
+
onSuccess(editorParent);
|
|
90
|
+
} else {
|
|
91
|
+
onClose();
|
|
92
|
+
setName('');
|
|
93
|
+
setEmoji('');
|
|
94
|
+
setDescription('');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
setError(err?.message || (topic ? 'Failed to save topic' : 'Failed to create topic'));
|
|
99
|
+
} finally {
|
|
100
|
+
setIsSaving(false);
|
|
101
|
+
}
|
|
102
|
+
}, [targetParent, topic, name, emoji, description, originalName, originalEmoji, originalDescription, onSuccess, onClose, client.activeChannels]);
|
|
103
|
+
|
|
104
|
+
const isValid = name.trim().length > 0 && emoji.length > 0;
|
|
105
|
+
|
|
106
|
+
const footer = (
|
|
107
|
+
<div className="ermis-create-topic__footer">
|
|
108
|
+
<button className="ermis-create-topic__btn ermis-create-topic__btn--cancel" onClick={onClose} disabled={isSaving}>{cancelButtonLabel}</button>
|
|
109
|
+
<button className="ermis-create-topic__btn ermis-create-topic__btn--create" onClick={handleSave} disabled={isSaving || !isValid}>
|
|
110
|
+
{isSaving ? savingButtonLabel : saveButtonLabel}
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Modal isOpen={isOpen} onClose={isSaving ? () => { } : onClose} title={title} maxWidth="400px" footer={footer}>
|
|
117
|
+
<div className="ermis-create-topic__body">
|
|
118
|
+
<div className="ermis-create-topic__live-preview">
|
|
119
|
+
<span className="ermis-create-topic__live-preview-emoji">{emoji || <span style={{opacity: 0.3}}>#</span>}</span>
|
|
120
|
+
<span className="ermis-create-topic__live-preview-name">{name || namePlaceholder}</span>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="ermis-create-topic__field">
|
|
124
|
+
<label className="ermis-create-topic__label">{nameLabel} <span className="ermis-create-topic__required">*</span></label>
|
|
125
|
+
<input
|
|
126
|
+
className="ermis-create-topic__input"
|
|
127
|
+
value={name}
|
|
128
|
+
onChange={(e) => setName(e.target.value)}
|
|
129
|
+
placeholder={namePlaceholder}
|
|
130
|
+
disabled={isSaving}
|
|
131
|
+
maxLength={100}
|
|
132
|
+
autoFocus
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="ermis-create-topic__field">
|
|
137
|
+
<label className="ermis-create-topic__label">{emojiLabel} <span className="ermis-create-topic__required">*</span></label>
|
|
138
|
+
|
|
139
|
+
<div className="ermis-create-topic__emoji-picker">
|
|
140
|
+
{EmojiPickerComponent ? (
|
|
141
|
+
<EmojiPickerComponent onSelect={(e: any) => setEmoji(e.native || e.emoji || e.id || e)} />
|
|
142
|
+
) : (
|
|
143
|
+
<div className="ermis-create-topic__default-icons">
|
|
144
|
+
{DEFAULT_TOPIC_ICONS.map(icon => (
|
|
145
|
+
<button
|
|
146
|
+
key={icon}
|
|
147
|
+
type="button"
|
|
148
|
+
className={`ermis-create-topic__default-icon ${icon === emoji ? 'ermis-create-topic__default-icon--active' : ''}`}
|
|
149
|
+
onClick={() => setEmoji(icon)}
|
|
150
|
+
disabled={isSaving}
|
|
151
|
+
>
|
|
152
|
+
{icon}
|
|
153
|
+
</button>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div className="ermis-create-topic__field">
|
|
161
|
+
<label className="ermis-create-topic__label">{descriptionLabel}</label>
|
|
162
|
+
<textarea
|
|
163
|
+
className="ermis-create-topic__input"
|
|
164
|
+
value={description}
|
|
165
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
166
|
+
placeholder={descriptionPlaceholder}
|
|
167
|
+
disabled={isSaving}
|
|
168
|
+
rows={3}
|
|
169
|
+
maxLength={500}
|
|
170
|
+
style={{ resize: 'vertical' }}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{error && (
|
|
175
|
+
<div className="ermis-create-topic__error">
|
|
176
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
177
|
+
<circle cx="12" cy="12" r="10" />
|
|
178
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
179
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
180
|
+
</svg>
|
|
181
|
+
{error}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</Modal>
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
TopicModal.displayName = 'TopicModal';
|
|
@@ -21,6 +21,9 @@ import { QuotedMessagePreview } from './QuotedMessagePreview';
|
|
|
21
21
|
import { PinnedMessages } from './PinnedMessages';
|
|
22
22
|
import { ReadReceipts } from './ReadReceipts';
|
|
23
23
|
import { TypingIndicator } from './TypingIndicator';
|
|
24
|
+
import { PendingOverlay } from './PendingOverlay';
|
|
25
|
+
import { BannedOverlay } from './BannedOverlay';
|
|
26
|
+
import { ClosedTopicOverlay } from './ClosedTopicOverlay';
|
|
24
27
|
import type { MessageListProps } from '../types';
|
|
25
28
|
|
|
26
29
|
/* ----------------------------------------------------------
|
|
@@ -104,7 +107,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
104
107
|
emptyTitle = 'No messages yet',
|
|
105
108
|
emptySubtitle = 'Send a message to start the conversation',
|
|
106
109
|
jumpToLatestLabel = '↓ Jump to latest',
|
|
107
|
-
bannedOverlayTitle = 'You have been
|
|
110
|
+
bannedOverlayTitle = 'You have been banned from this channel',
|
|
108
111
|
bannedOverlaySubtitle = 'You can no longer read or send messages here',
|
|
109
112
|
blockedOverlayTitle = 'You have blocked this user',
|
|
110
113
|
blockedOverlaySubtitle = 'Unblock to continue the conversation',
|
|
@@ -112,11 +115,17 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
112
115
|
pendingOverlaySubtitle = 'Accept the invitation to view messages and interact',
|
|
113
116
|
pendingAcceptLabel = 'Accept',
|
|
114
117
|
pendingRejectLabel = 'Reject',
|
|
118
|
+
closedTopicOverlayTitle = 'This topic has been closed',
|
|
119
|
+
closedTopicOverlaySubtitle = 'You can no longer read or send messages in this topic.',
|
|
120
|
+
closedTopicReopenLabel = 'Reopen Topic',
|
|
115
121
|
}) => {
|
|
116
122
|
const { client, messages, readState, activeChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
|
|
117
123
|
const { isBanned } = useBannedState(activeChannel, client.userID);
|
|
118
124
|
const { isBlocked } = useBlockedState(activeChannel, client.userID);
|
|
119
125
|
const { isPending } = usePendingState(activeChannel, client.userID);
|
|
126
|
+
const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
|
|
127
|
+
const parentCid = activeChannel?.data?.parent_cid as string | undefined;
|
|
128
|
+
const parentChannel = parentCid && client ? client.activeChannels[parentCid] : undefined;
|
|
120
129
|
|
|
121
130
|
const { channelName, channelImage } = useChannelProfile(activeChannel);
|
|
122
131
|
|
|
@@ -124,6 +133,8 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
124
133
|
const messagesRef = useRef(messages);
|
|
125
134
|
messagesRef.current = messages;
|
|
126
135
|
const currentUserId = client.userID;
|
|
136
|
+
const currentUserRole = currentUserId ? activeChannel?.state?.members?.[currentUserId]?.channel_role : undefined;
|
|
137
|
+
const canManageTopic = currentUserRole === 'owner' || currentUserRole === 'moder';
|
|
127
138
|
|
|
128
139
|
// Ref to scope DOM queries (safe for multiple instances)
|
|
129
140
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -134,8 +145,8 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
134
145
|
const handleAcceptInvite = useCallback(async () => {
|
|
135
146
|
if (!activeChannel) return;
|
|
136
147
|
try {
|
|
137
|
-
const
|
|
138
|
-
const action =
|
|
148
|
+
const isPublicTeamOrMeeting = (activeChannel.type === 'team' || activeChannel.type === 'meeting') && Boolean(activeChannel.data?.public);
|
|
149
|
+
const action = isPublicTeamOrMeeting ? 'join' : 'accept';
|
|
139
150
|
await activeChannel.acceptInvite(action);
|
|
140
151
|
} catch (e: any) {
|
|
141
152
|
console.error('Error accepting invite', e);
|
|
@@ -212,6 +223,20 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
212
223
|
}, [setHasMore, setHasNewer]),
|
|
213
224
|
});
|
|
214
225
|
|
|
226
|
+
const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked);
|
|
227
|
+
const prevOverlayRef = useRef(hasOverlay);
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (prevOverlayRef.current && !hasOverlay) {
|
|
231
|
+
// Transitioned from having an overlay to normal view.
|
|
232
|
+
// Give VList a moment to measure its new DOM size via ResizeObserver, then jump to the bottom.
|
|
233
|
+
setTimeout(() => scrollToBottom(false), 50);
|
|
234
|
+
setTimeout(() => scrollToBottom(false), 200);
|
|
235
|
+
setTimeout(() => scrollToBottom(false), 500);
|
|
236
|
+
}
|
|
237
|
+
prevOverlayRef.current = hasOverlay;
|
|
238
|
+
}, [hasOverlay, scrollToBottom]);
|
|
239
|
+
|
|
215
240
|
const renderers = useMemo(
|
|
216
241
|
() => ({ ...defaultMessageRenderers, ...customRenderers }),
|
|
217
242
|
[customRenderers],
|
|
@@ -354,19 +379,57 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
354
379
|
readReceiptsMaxAvatars,
|
|
355
380
|
]);
|
|
356
381
|
|
|
357
|
-
|
|
382
|
+
if (isBanned || isBlocked) {
|
|
383
|
+
return (
|
|
384
|
+
<BannedOverlay
|
|
385
|
+
isBlocked={isBlocked}
|
|
386
|
+
blockedTitle={blockedOverlayTitle}
|
|
387
|
+
bannedTitle={bannedOverlayTitle}
|
|
388
|
+
blockedSubtitle={blockedOverlaySubtitle}
|
|
389
|
+
bannedSubtitle={bannedOverlaySubtitle}
|
|
390
|
+
onUnblock={() => { activeChannel?.unblockUser().catch((e: any) => console.error('Error unblocking user', e)); }}
|
|
391
|
+
/>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (isPending) {
|
|
396
|
+
return (
|
|
397
|
+
<PendingOverlay
|
|
398
|
+
channelImage={channelImage}
|
|
399
|
+
channelName={channelName}
|
|
400
|
+
title={pendingOverlayTitle}
|
|
401
|
+
subtitle={pendingOverlaySubtitle}
|
|
402
|
+
rejectLabel={pendingRejectLabel}
|
|
403
|
+
acceptLabel={pendingAcceptLabel}
|
|
404
|
+
onReject={handleRejectInvite}
|
|
405
|
+
onAccept={handleAcceptInvite}
|
|
406
|
+
AvatarComponent={AvatarComponent}
|
|
407
|
+
/>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (isClosedTopic) {
|
|
412
|
+
return (
|
|
413
|
+
<ClosedTopicOverlay
|
|
414
|
+
title={closedTopicOverlayTitle}
|
|
415
|
+
subtitle={closedTopicOverlaySubtitle}
|
|
416
|
+
canManageTopic={Boolean(canManageTopic && activeChannel && parentChannel)}
|
|
417
|
+
reopenLabel={closedTopicReopenLabel}
|
|
418
|
+
onReopen={() => { parentChannel?.reopenTopic(activeChannel!.cid).catch((e: any) => console.error('Error reopening topic', e)); }}
|
|
419
|
+
/>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
358
422
|
|
|
359
423
|
return (
|
|
360
|
-
<div ref={containerRef} className={`ermis-message-list${
|
|
361
|
-
{
|
|
424
|
+
<div ref={containerRef} className={`ermis-message-list${className ? ` ${className}` : ''}`}>
|
|
425
|
+
{showPinnedMessages && <PinnedMessagesComponent onClickMessage={scrollToMessage} AvatarComponent={AvatarComponent} />}
|
|
362
426
|
|
|
363
|
-
{messages.length === 0 &&
|
|
427
|
+
{messages.length === 0 && (
|
|
364
428
|
EmptyStateIndicator === DefaultEmpty
|
|
365
429
|
? <DefaultEmpty title={emptyTitle} subtitle={emptySubtitle} />
|
|
366
430
|
: <EmptyStateIndicator />
|
|
367
431
|
)}
|
|
368
432
|
|
|
369
|
-
{/* VList always rendered so virtua keeps its viewport measurement */}
|
|
370
433
|
<VList
|
|
371
434
|
key={activeChannel?.cid || 'empty'}
|
|
372
435
|
ref={vlistRef}
|
|
@@ -374,46 +437,14 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
374
437
|
onScroll={handleScroll}
|
|
375
438
|
className="ermis-message-list__vlist"
|
|
376
439
|
>
|
|
377
|
-
{
|
|
378
|
-
<div className="ermis-message-list__pending-overlay">
|
|
379
|
-
<div className="ermis-message-list__pending-card">
|
|
380
|
-
<Avatar image={channelImage} name={channelName} size={64} className="ermis-message-list__pending-avatar" />
|
|
381
|
-
<span className="ermis-message-list__pending-overlay-title">{pendingOverlayTitle}</span>
|
|
382
|
-
<div className="ermis-message-list__pending-channel-name">{channelName}</div>
|
|
383
|
-
<span className="ermis-message-list__pending-overlay-subtitle">{pendingOverlaySubtitle}</span>
|
|
384
|
-
<div className="ermis-message-list__pending-actions">
|
|
385
|
-
<button className="ermis-message-list__reject-btn" onClick={handleRejectInvite}>{pendingRejectLabel}</button>
|
|
386
|
-
<button className="ermis-message-list__accept-btn" onClick={handleAcceptInvite}>{pendingAcceptLabel}</button>
|
|
387
|
-
</div>
|
|
388
|
-
</div>
|
|
389
|
-
</div>
|
|
390
|
-
) : (isBanned || isBlocked) && !isPending ? (
|
|
391
|
-
<div className="ermis-message-list__banned-overlay">
|
|
392
|
-
<div className="ermis-message-list__banned-overlay-icon">
|
|
393
|
-
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
394
|
-
<circle cx="12" cy="12" r="10" />
|
|
395
|
-
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
|
396
|
-
</svg>
|
|
397
|
-
</div>
|
|
398
|
-
<span className="ermis-message-list__banned-overlay-title">{isBlocked ? blockedOverlayTitle : bannedOverlayTitle}</span>
|
|
399
|
-
<span className="ermis-message-list__banned-overlay-subtitle">{isBlocked ? blockedOverlaySubtitle : bannedOverlaySubtitle}</span>
|
|
400
|
-
{isBlocked && activeChannel && (
|
|
401
|
-
<button
|
|
402
|
-
className="ermis-message-list__unblock-btn"
|
|
403
|
-
onClick={() => { activeChannel.unblockUser().catch((e: any) => console.error('Error unblocking user', e)); }}
|
|
404
|
-
>
|
|
405
|
-
Unblock
|
|
406
|
-
</button>
|
|
407
|
-
)}
|
|
408
|
-
</div>
|
|
409
|
-
) : messageElements}
|
|
440
|
+
{messageElements}
|
|
410
441
|
</VList>
|
|
411
442
|
|
|
412
443
|
{/* Typing indicator */}
|
|
413
|
-
{
|
|
444
|
+
{showTypingIndicator && <TypingIndicatorComponent />}
|
|
414
445
|
|
|
415
446
|
{/* Jump to latest button */}
|
|
416
|
-
{
|
|
447
|
+
{hasNewer && (
|
|
417
448
|
JumpToLatestButton === DefaultJumpToLatest
|
|
418
449
|
? <DefaultJumpToLatest onClick={jumpToLatest} label={jumpToLatestLabel} />
|
|
419
450
|
: <JumpToLatestButton onClick={jumpToLatest} />
|