@ermis-network/ermis-chat-react 1.0.5 → 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.
Files changed (43) hide show
  1. package/dist/index.cjs +2411 -1309
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +471 -16
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +145 -1
  6. package/dist/index.d.ts +145 -1
  7. package/dist/index.mjs +2340 -1242
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +2 -2
  10. package/src/components/BannedOverlay.tsx +40 -0
  11. package/src/components/ChannelActions.tsx +231 -0
  12. package/src/components/ChannelHeader.tsx +38 -2
  13. package/src/components/ChannelInfo/ChannelInfo.tsx +118 -20
  14. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +10 -2
  15. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +88 -1
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -4
  17. package/src/components/ChannelList.tsx +467 -45
  18. package/src/components/ClosedTopicOverlay.tsx +38 -0
  19. package/src/components/MessageInput.tsx +19 -2
  20. package/src/components/MessageItem.tsx +8 -11
  21. package/src/components/MessageQuickReactions.tsx +3 -2
  22. package/src/components/MessageReactions.tsx +8 -3
  23. package/src/components/MessageRenderers.tsx +7 -9
  24. package/src/components/PendingOverlay.tsx +41 -0
  25. package/src/components/TopicModal.tsx +189 -0
  26. package/src/components/VirtualMessageList.tsx +74 -43
  27. package/src/hooks/useBannedState.ts +27 -3
  28. package/src/hooks/useChannelCapabilities.ts +7 -3
  29. package/src/hooks/useChannelData.ts +1 -1
  30. package/src/hooks/useChannelListUpdates.ts +24 -3
  31. package/src/hooks/useChannelRowUpdates.ts +6 -0
  32. package/src/hooks/useMessageActions.ts +1 -1
  33. package/src/index.ts +6 -1
  34. package/src/styles/_channel-info.css +21 -0
  35. package/src/styles/_channel-list.css +217 -6
  36. package/src/styles/_message-bubble.css +75 -9
  37. package/src/styles/_message-input.css +24 -0
  38. package/src/styles/_message-list.css +51 -6
  39. package/src/styles/_message-quick-reactions.css +5 -0
  40. package/src/styles/_message-reactions.css +7 -0
  41. package/src/styles/_topic-modal.css +154 -0
  42. package/src/styles/index.css +1 -0
  43. 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 blocked from this channel',
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
- <div style={!canReact ? { opacity: 0.5, pointerEvents: 'none' } : {}}>
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
- <div style={!canReact ? { opacity: 0.8, pointerEvents: 'none' } : {}}>
191
- <MessageReactionsComponent
192
- reactionCounts={(message as any).reaction_counts}
193
- ownReactions={(message as any).own_reactions}
194
- latestReactions={(message as any).latest_reactions}
195
- onClickReaction={handleReactionToggle}
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
- }> = React.memo(({ message, isOwnMessage }) => {
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="ermis-message-reactions">
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 userNames = latestReactions
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 tooltip = userNames && userNames.length > 0 ? userNames.join('\n') : type;
38
+ const userNames = Array.from(new Set(rawUserNames || []))
39
+ .map((n: any) => typeof n === 'string' ? n.replace(/&lrm;|\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" style={{ paddingBottom: '75%' }}>
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" style={{ paddingBottom: '75%' }}>
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
- style={{ display: 'none' }}
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
- if (!posterSrc) setLoaded(true);
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 style={{ position: 'relative', width: '100%', minHeight: '120px', backgroundColor: 'var(--ermis-bg-hover, #2a2a4a)', overflow: 'hidden' }}>
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 style={{ position: 'relative', width: '120px', height: '120px', overflow: 'hidden' }}>
555
+ <div className="ermis-message-sticker-wrapper">
557
556
  {!loaded && <div className="ermis-attachment-shimmer" />}
558
557
  <img
559
558
  ref={imgRef}
560
- className="ermis-message-sticker"
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 blocked from this channel',
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 isPublicTeam = activeChannel.type === 'team' && Boolean(activeChannel.data?.public);
138
- const action = isPublicTeam ? 'join' : 'accept';
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
- const blockedClass = isBlocked ? ' ermis-message-list--blocked' : '';
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${isBanned ? ' ermis-message-list--banned' : ''}${blockedClass}${className ? ` ${className}` : ''}`}>
361
- {!isBanned && !isBlocked && showPinnedMessages && <PinnedMessagesComponent onClickMessage={scrollToMessage} AvatarComponent={AvatarComponent} />}
424
+ <div ref={containerRef} className={`ermis-message-list${className ? ` ${className}` : ''}`}>
425
+ {showPinnedMessages && <PinnedMessagesComponent onClickMessage={scrollToMessage} AvatarComponent={AvatarComponent} />}
362
426
 
363
- {messages.length === 0 && !isBanned && !isPending && (
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
- {isPending && !isBanned && !isBlocked ? (
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
- {!isBanned && !isBlocked && !isPending && showTypingIndicator && <TypingIndicatorComponent />}
444
+ {showTypingIndicator && <TypingIndicatorComponent />}
414
445
 
415
446
  {/* Jump to latest button */}
416
- {!isBanned && !isBlocked && !isPending && hasNewer && (
447
+ {hasNewer && (
417
448
  JumpToLatestButton === DefaultJumpToLatest
418
449
  ? <DefaultJumpToLatest onClick={jumpToLatest} label={jumpToLatestLabel} />
419
450
  : <JumpToLatestButton onClick={jumpToLatest} />