@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
@@ -11,6 +11,12 @@ import { Avatar } from './Avatar';
11
11
  import type { ChannelItemProps, ChannelListProps } from '../types';
12
12
 
13
13
  export type { ChannelListProps, ChannelItemProps } from '../types';
14
+ import type { ChannelActionsProps } from '../types';
15
+ import { TopicModal } from './TopicModal';
16
+ import { DefaultChannelActions, computeDefaultActions } from './ChannelActions';
17
+
18
+ export { DefaultChannelActions } from './ChannelActions';
19
+ export type { ChannelAction, ChannelActionsProps } from '../types';
14
20
 
15
21
  /**
16
22
  * Get a human-readable preview string for the last message,
@@ -19,26 +25,28 @@ export type { ChannelListProps, ChannelItemProps } from '../types';
19
25
  function getLastMessagePreview(
20
26
  channel: Channel,
21
27
  myUserId?: string,
22
- ): { text: string; user: string } {
28
+ ): { text: string; user: string; timestamp?: string | Date } {
23
29
  const lastMsg = channel.state?.latestMessages?.slice(-1)[0];
24
30
  if (!lastMsg) return { text: '', user: '' };
25
31
 
32
+ const timestamp = lastMsg.created_at;
33
+
26
34
  const msgType = lastMsg.type || 'regular';
27
35
  const rawText = lastMsg.text ?? '';
28
36
 
29
37
  if (msgType === 'system') {
30
38
  const userMap = buildUserMap(channel.state);
31
- return { text: parseSystemMessage(rawText, userMap), user: '' };
39
+ return { text: parseSystemMessage(rawText, userMap), user: '', timestamp };
32
40
  }
33
41
 
34
42
  if (msgType === 'signal') {
35
43
  const result = parseSignalMessage(rawText, myUserId || '');
36
- return { text: result?.text || rawText, user: '' };
44
+ return { text: result?.text || rawText, user: '', timestamp };
37
45
  }
38
46
 
39
47
  // Display 'Sticker' if message is a sticker
40
48
  if (msgType === 'sticker' || (lastMsg as Record<string, unknown>).sticker_url) {
41
- return { text: 'Sticker', user: lastMsg.user?.name || lastMsg.user_id || '' };
49
+ return { text: 'Sticker', user: lastMsg.user?.name || lastMsg.user_id || '', timestamp };
42
50
  }
43
51
 
44
52
  // Regular / other
@@ -78,6 +86,7 @@ function getLastMessagePreview(
78
86
  return {
79
87
  text: displayText,
80
88
  user: lastMsg.user?.name || lastMsg.user_id || '',
89
+ timestamp,
81
90
  };
82
91
  }
83
92
 
@@ -91,25 +100,68 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
91
100
  unreadCount,
92
101
  lastMessageText,
93
102
  lastMessageUser,
103
+ lastMessageTimestamp,
94
104
  onSelect,
95
105
  AvatarComponent,
96
106
  isBlocked,
97
107
  isPending,
98
108
  pendingBadgeLabel,
99
109
  blockedBadgeLabel,
110
+ isClosedTopic,
111
+ closedTopicIcon,
112
+ PinnedIconComponent,
113
+ ChannelActionsComponent,
114
+ onAddTopic,
115
+ onEditTopic,
116
+ onToggleCloseTopic,
117
+ hiddenActions,
118
+ actionLabels,
119
+ actionIcons,
100
120
  }) => {
121
+ const { client } = useChatClient();
122
+ const currentUserId = client.userID;
123
+
101
124
  // Subscribe to channel.updated so that when name/image/description change,
102
125
  // we re-render from within (bypasses React.memo which only blocks parent-driven re-renders)
103
- const [, forceUpdate] = useState(0);
126
+ const [updateCount, forceUpdate] = useState(0);
104
127
  useEffect(() => {
105
- const sub = channel.on('channel.updated', () => forceUpdate((c) => c + 1));
106
- return () => sub.unsubscribe();
128
+ const handleUpdate = () => forceUpdate((c) => c + 1);
129
+ const sub1 = channel.on('channel.updated', handleUpdate);
130
+ const sub2 = channel.on('channel.pinned', handleUpdate);
131
+ const sub3 = channel.on('channel.unpinned', handleUpdate);
132
+ return () => {
133
+ sub1.unsubscribe();
134
+ sub2.unsubscribe();
135
+ sub3.unsubscribe();
136
+ };
107
137
  }, [channel]);
108
138
 
139
+ const defaultActions = useMemo(
140
+ () => computeDefaultActions(channel, currentUserId, { onAddTopic, onEditTopic, onToggleCloseTopic, isBlocked, actionLabels, actionIcons }),
141
+ [channel, currentUserId, updateCount, onAddTopic, onEditTopic, onToggleCloseTopic, isBlocked, actionLabels, actionIcons],
142
+ );
143
+
144
+ const filteredActions = useMemo(() => {
145
+ if (!hiddenActions || hiddenActions.length === 0) return defaultActions;
146
+ return defaultActions.filter(a => !hiddenActions.includes(a.id));
147
+ }, [defaultActions, hiddenActions]);
148
+ const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
149
+
109
150
  const name = channel.data?.name || channel.cid;
110
151
  const image = channel.data?.image as string | undefined;
111
152
  const showUnread = hasUnread && !isActive;
112
153
 
154
+ const timestampText = useMemo(() => {
155
+ if (!lastMessageTimestamp) return null;
156
+ const d = new Date(lastMessageTimestamp);
157
+ if (isNaN(d.getTime())) return null;
158
+ const today = new Date();
159
+ const isToday = d.toDateString() === today.toDateString();
160
+ return isToday
161
+ ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
162
+ : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
163
+ }, [lastMessageTimestamp]);
164
+
113
165
  const handleClick = useCallback(() => {
114
166
  onSelect(channel);
115
167
  }, [channel, onSelect]);
@@ -118,7 +170,6 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
118
170
  'ermis-channel-list__item',
119
171
  isActive ? 'ermis-channel-list__item--active' : '',
120
172
  showUnread ? 'ermis-channel-list__item--unread' : '',
121
- isBlocked ? 'ermis-channel-list__item--blocked' : '',
122
173
  isPending ? 'ermis-channel-list__item--pending' : '',
123
174
  ].filter(Boolean).join(' ');
124
175
 
@@ -126,39 +177,78 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
126
177
  <div className={itemClass} onClick={handleClick}>
127
178
  <AvatarComponent image={image} name={name} size={40} />
128
179
  <div className="ermis-channel-list__item-content">
129
- <div className="ermis-channel-list__item-name">{name}</div>
130
- {lastMessageText && (
131
- <div className="ermis-channel-list__item-last-message">
132
- {lastMessageUser && (
133
- <span className="ermis-channel-list__item-last-message-user">
134
- {lastMessageUser}:{' '}
135
- </span>
136
- )}
137
- <span>{lastMessageText}</span>
138
- </div>
139
- )}
180
+ <div className="ermis-channel-list__item-top-row">
181
+ <div className="ermis-channel-list__item-name">{name}</div>
182
+ {channel.data?.is_pinned === true && !isClosedTopic && PinnedIconComponent && (
183
+ <span className="ermis-channel-list__pinned-icon" title="Pinned">
184
+ <PinnedIconComponent />
185
+ </span>
186
+ )}
187
+ {isClosedTopic && (
188
+ <span className="ermis-channel-list__closed-icon">
189
+ {closedTopicIcon || (
190
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
191
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
192
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
193
+ </svg>
194
+ )}
195
+ </span>
196
+ )}
197
+ {!isClosedTopic && timestampText && <div className="ermis-channel-list__item-timestamp">{timestampText}</div>}
198
+
199
+ {isPending && (
200
+ <span className="ermis-channel-list__pending-badge">{pendingBadgeLabel || 'Invited'}</span>
201
+ )}
202
+
203
+ {isBlocked && (
204
+ <span className="ermis-channel-list__blocked-icon" title={blockedBadgeLabel || "Blocked"}>
205
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
206
+ <circle cx="12" cy="12" r="10" />
207
+ <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
208
+ </svg>
209
+ </span>
210
+ )}
211
+ </div>
212
+ <div className="ermis-channel-list__item-bottom-row">
213
+ {!isClosedTopic && lastMessageText && (
214
+ <div className="ermis-channel-list__item-last-message">
215
+ {lastMessageUser && (
216
+ <span className="ermis-channel-list__item-last-message-user">
217
+ {lastMessageUser}:{' '}
218
+ </span>
219
+ )}
220
+ <span>{lastMessageText}</span>
221
+ </div>
222
+ )}
223
+
224
+ {!isClosedTopic && (
225
+ <div className="ermis-channel-list__item-badges">
226
+ {showUnread && unreadCount > 0 && (
227
+ <span className="ermis-channel-list__unread-badge">
228
+ {unreadCount > 99 ? '99+' : unreadCount}
229
+ </span>
230
+ )}
231
+ </div>
232
+ )}
233
+ </div>
140
234
  </div>
141
- {showUnread && unreadCount > 0 && (
142
- <span className="ermis-channel-list__unread-badge">
143
- {unreadCount > 99 ? '99+' : unreadCount}
144
- </span>
145
- )}
146
- {isBlocked && (
147
- <span className="ermis-channel-list__blocked-icon" title={blockedBadgeLabel || "Blocked"}>
148
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
149
- <circle cx="12" cy="12" r="10" />
150
- <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
151
- </svg>
152
- </span>
153
- )}
154
- {isPending && (
155
- <span className="ermis-channel-list__pending-badge">{pendingBadgeLabel || 'Invited'}</span>
235
+ {!isPending && (
236
+ <div className="ermis-channel-list__item-actions-wrapper">
237
+ <ActionsComponent channel={channel} actions={filteredActions} onClose={() => { }} />
238
+ </div>
156
239
  )}
157
240
  </div>
158
241
  );
159
242
  });
160
243
  ChannelItem.displayName = 'ChannelItem';
161
244
 
245
+ export const DefaultPinnedIcon = React.memo(() => (
246
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
247
+ <path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
248
+ </svg>
249
+ ));
250
+ DefaultPinnedIcon.displayName = 'DefaultPinnedIcon';
251
+
162
252
  const DefaultLoading = React.memo(({ text }: { text?: string }) => (
163
253
  <div className="ermis-channel-list__loading">{text || 'Loading channels...'}</div>
164
254
  ));
@@ -182,6 +272,15 @@ type ChannelRowProps = {
182
272
  currentUserId?: string;
183
273
  pendingBadgeLabel?: string;
184
274
  blockedBadgeLabel?: string;
275
+ closedTopicIcon?: React.ReactNode;
276
+ PinnedIconComponent?: React.ComponentType;
277
+ ChannelActionsComponent?: React.ComponentType<ChannelActionsProps>;
278
+ onAddTopic?: (channel: Channel) => void;
279
+ onEditTopic?: (channel: Channel) => void;
280
+ onToggleCloseTopic?: (channel: Channel, isClosed: boolean) => void;
281
+ hiddenActions?: string[];
282
+ actionLabels?: import('../types').ChannelActionLabels;
283
+ actionIcons?: import('../types').ChannelActionIcons;
185
284
  };
186
285
 
187
286
  const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
@@ -194,6 +293,15 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
194
293
  currentUserId,
195
294
  pendingBadgeLabel,
196
295
  blockedBadgeLabel,
296
+ closedTopicIcon,
297
+ PinnedIconComponent,
298
+ ChannelActionsComponent,
299
+ onAddTopic,
300
+ onEditTopic,
301
+ onToggleCloseTopic,
302
+ hiddenActions,
303
+ actionLabels,
304
+ actionIcons,
197
305
  }) => {
198
306
  // Use the new custom hook to handle all row-level realtime updates
199
307
  const { isBannedInChannel, isBlockedInChannel, updateCount } = useChannelRowUpdates(channel, currentUserId);
@@ -201,12 +309,15 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
201
309
 
202
310
  const channelState = channel.state as unknown as Record<string, unknown> | undefined;
203
311
  const rawUnreadCount = (channelState?.unreadCount as number) ?? 0;
312
+
313
+ const isClosedTopic = channel.data?.is_closed_topic === true;
314
+
315
+ // Render logic continues...
204
316
  const unreadCount = (isBannedInChannel || isBlockedInChannel || isPending) ? 0 : rawUnreadCount;
205
317
  const hasUnread = unreadCount > 0;
206
318
 
207
- // Derive last message preview computation is deferred here,
208
- // so it only executes when VList actually mounts this visible item
209
- const { text: rawLastMessageText, user: rawLastMessageUser } = useMemo(
319
+ // Derive last message preview computation
320
+ const { text: rawLastMessageText, user: rawLastMessageUser, timestamp: rawLastMessageTimestamp } = useMemo(
210
321
  () => getLastMessagePreview(channel, currentUserId),
211
322
  // Recompute if latestMessage changes or we get a force update
212
323
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -216,6 +327,7 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
216
327
  // Hide last message preview when banned, blocked, or pending
217
328
  const lastMessageText = (isBannedInChannel || isBlockedInChannel || isPending) ? '' : rawLastMessageText;
218
329
  const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending) ? '' : rawLastMessageUser;
330
+ const lastMessageTimestamp = (isBannedInChannel || isBlockedInChannel || isPending) ? null : rawLastMessageTimestamp;
219
331
 
220
332
  if (renderChannel) {
221
333
  return (
@@ -233,19 +345,209 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
233
345
  unreadCount={unreadCount}
234
346
  lastMessageText={lastMessageText}
235
347
  lastMessageUser={lastMessageUser}
348
+ lastMessageTimestamp={lastMessageTimestamp}
236
349
  onSelect={handleSelect}
237
350
  AvatarComponent={AvatarComponent}
238
351
  isBlocked={isBlockedInChannel}
239
352
  isPending={isPending}
240
353
  pendingBadgeLabel={pendingBadgeLabel}
241
354
  blockedBadgeLabel={blockedBadgeLabel}
355
+ isClosedTopic={isClosedTopic}
356
+ closedTopicIcon={closedTopicIcon}
357
+ PinnedIconComponent={PinnedIconComponent}
358
+ ChannelActionsComponent={ChannelActionsComponent}
359
+ onAddTopic={onAddTopic}
360
+ onEditTopic={onEditTopic}
361
+ onToggleCloseTopic={onToggleCloseTopic}
362
+ hiddenActions={hiddenActions}
363
+ actionLabels={actionLabels}
364
+ actionIcons={actionIcons}
242
365
  />
243
366
  );
244
367
  });
245
368
  ChannelRow.displayName = 'ChannelRow';
246
369
 
370
+ export const ChannelTopicGroup = React.memo(({
371
+ channel,
372
+ activeChannel,
373
+ handleSelect,
374
+ renderChannel,
375
+ ChannelItemComponent,
376
+ AvatarComponent,
377
+ GeneralTopicAvatarComponent,
378
+ TopicAvatarComponent,
379
+ currentUserId,
380
+ pendingBadgeLabel,
381
+ blockedBadgeLabel,
382
+ generalTopicLabel,
383
+ closedTopicIcon,
384
+ PinnedIconComponent,
385
+ ChannelActionsComponent,
386
+ onAddTopic,
387
+ onEditTopic,
388
+ onToggleCloseTopic,
389
+ hiddenActions,
390
+ actionLabels,
391
+ actionIcons,
392
+ }: any) => {
393
+ const { updateCount } = useChannelRowUpdates(channel, currentUserId);
394
+ const [isExpanded, setIsExpanded] = useState(true);
395
+ const [topicUpdateCount, setTopicUpdateCount] = useState(0);
396
+
397
+ useEffect(() => {
398
+ const subs: { unsubscribe: () => void }[] = [];
399
+ const handleUpdate = () => setTopicUpdateCount((c) => c + 1);
400
+ const currentTopics = channel.state?.topics || [];
401
+ currentTopics.forEach((t: Channel) => {
402
+ subs.push(t.on('channel.pinned', handleUpdate));
403
+ subs.push(t.on('channel.unpinned', handleUpdate));
404
+ subs.push(t.on('message.new', handleUpdate));
405
+ subs.push(t.on('message.deleted', handleUpdate));
406
+ });
407
+ return () => {
408
+ subs.forEach((s) => s.unsubscribe());
409
+ };
410
+ }, [channel.state?.topics]);
411
+
412
+ const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
413
+
414
+ const userRole = channel.state?.members?.[currentUserId]?.channel_role;
415
+ const hasTopicAddPermission = Boolean(userRole === 'owner' || userRole === 'moder');
416
+
417
+ const getTopicTime = (t: Channel) => {
418
+ const lastMsg = t.state?.latestMessages?.slice(-1)[0];
419
+ if (lastMsg?.created_at) return new Date(lastMsg.created_at).getTime();
420
+ if (t.data?.last_message_at) return new Date(t.data.last_message_at as string | Date).getTime();
421
+ if (t.data?.created_at) return new Date(t.data.created_at as string | Date).getTime();
422
+ return 0;
423
+ };
424
+
425
+ const topics = useMemo(() => {
426
+ const allTopics = channel.state?.topics || [];
427
+ return [...allTopics].sort((a: any, b: any) => {
428
+ const aPinned = a.data?.is_pinned === true;
429
+ const bPinned = b.data?.is_pinned === true;
430
+ if (aPinned && !bPinned) return -1;
431
+ if (!aPinned && bPinned) return 1;
432
+
433
+ return getTopicTime(b) - getTopicTime(a);
434
+ });
435
+ }, [channel.state?.topics, topicUpdateCount]);
436
+ const name = channel.data?.name || channel.cid;
437
+ const image = channel.data?.image as string | undefined;
438
+
439
+ const GeneralAvatar = useCallback(() => (
440
+ <div className="ermis-channel-list__topic-hashtag">#</div>
441
+ ), []);
442
+
443
+ const TopicEmojiAvatar = useCallback(({ image }: any) => {
444
+ let emoji = '💬';
445
+ if (image && typeof image === 'string' && image.startsWith('emoji://')) {
446
+ emoji = image.replace('emoji://', '');
447
+ }
448
+ return <div className="ermis-channel-list__topic-hashtag">{emoji}</div>;
449
+ }, []);
450
+
451
+ const generalChannelProxy = useMemo(() => {
452
+ return new Proxy(channel, {
453
+ get(target, prop, receiver) {
454
+ if (prop === 'data') {
455
+ return { ...target.data, name: generalTopicLabel || 'general', is_pinned: false };
456
+ }
457
+ const value = Reflect.get(target, prop, receiver);
458
+ return typeof value === 'function' ? value.bind(target) : value;
459
+ }
460
+ });
461
+ }, [channel, generalTopicLabel]);
462
+
463
+ const defaultActions = useMemo(
464
+ () => computeDefaultActions(channel, currentUserId, { onAddTopic, actionLabels, actionIcons }),
465
+ [channel, currentUserId, updateCount, onAddTopic, actionLabels, actionIcons],
466
+ );
467
+
468
+ const filteredActions = useMemo(() => {
469
+ if (!hiddenActions || hiddenActions.length === 0) return defaultActions;
470
+ return defaultActions.filter((a: any) => !hiddenActions.includes(a.id));
471
+ }, [defaultActions, hiddenActions]);
472
+ const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
473
+
474
+ return (
475
+ <div className="ermis-channel-list__topic-group">
476
+ <div
477
+ className={`ermis-channel-list__topic-header ${isExpanded ? 'ermis-channel-list__topic-header--expanded' : ''}`}
478
+ onClick={handleToggle}
479
+ >
480
+ <AvatarComponent image={image} name={name} size={40} />
481
+ <div className="ermis-channel-list__topic-header-name">{name}</div>
482
+
483
+ {channel.data?.is_pinned === true && PinnedIconComponent && (
484
+ <span className="ermis-channel-list__pinned-icon" title="Pinned">
485
+ <PinnedIconComponent />
486
+ </span>
487
+ )}
488
+
489
+ <div className="ermis-channel-list__topic-actions-wrapper">
490
+ <ActionsComponent channel={channel} actions={filteredActions} onClose={() => { }} />
491
+ </div>
492
+
493
+ <svg
494
+ className="ermis-channel-list__accordion-icon"
495
+ width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
496
+ >
497
+ <polyline points="6 9 12 15 18 9"></polyline>
498
+ </svg>
499
+ </div>
500
+
501
+ {isExpanded && (
502
+ <div className="ermis-channel-list__topic-sublist">
503
+ <ChannelRow
504
+ channel={generalChannelProxy as any}
505
+ isActive={activeChannel?.cid === channel.cid}
506
+ handleSelect={handleSelect}
507
+ renderChannel={renderChannel}
508
+ ChannelItemComponent={ChannelItemComponent}
509
+ AvatarComponent={GeneralTopicAvatarComponent || GeneralAvatar}
510
+ currentUserId={currentUserId}
511
+ pendingBadgeLabel={pendingBadgeLabel}
512
+ blockedBadgeLabel={blockedBadgeLabel}
513
+ closedTopicIcon={closedTopicIcon}
514
+ PinnedIconComponent={PinnedIconComponent}
515
+ ChannelActionsComponent={() => null}
516
+ hiddenActions={hiddenActions}
517
+ actionLabels={actionLabels}
518
+ actionIcons={actionIcons}
519
+ />
520
+ {topics.map((topicChannel: any) => (
521
+ <ChannelRow
522
+ key={topicChannel.cid}
523
+ channel={topicChannel}
524
+ isActive={activeChannel?.cid === topicChannel.cid}
525
+ handleSelect={handleSelect}
526
+ renderChannel={renderChannel}
527
+ ChannelItemComponent={ChannelItemComponent}
528
+ AvatarComponent={TopicAvatarComponent || TopicEmojiAvatar}
529
+ currentUserId={currentUserId}
530
+ pendingBadgeLabel={pendingBadgeLabel}
531
+ blockedBadgeLabel={blockedBadgeLabel}
532
+ closedTopicIcon={closedTopicIcon}
533
+ PinnedIconComponent={PinnedIconComponent}
534
+ ChannelActionsComponent={ChannelActionsComponent}
535
+ onEditTopic={onEditTopic}
536
+ onToggleCloseTopic={onToggleCloseTopic}
537
+ hiddenActions={hiddenActions}
538
+ actionLabels={actionLabels}
539
+ actionIcons={actionIcons}
540
+ />
541
+ ))}
542
+ </div>
543
+ )}
544
+ </div>
545
+ );
546
+ });
547
+ ChannelTopicGroup.displayName = 'ChannelTopicGroup';
548
+
247
549
  export const ChannelList: React.FC<ChannelListProps> = React.memo(({
248
- filters = { type: ['messaging', 'team'], include_pinned_messages: true } as unknown as ChannelFilters,
550
+ filters = { type: ['messaging', 'team', 'meeting'], include_pinned_messages: true } as unknown as ChannelFilters,
249
551
  sort = [],
250
552
  options = { message_limit: 25 } as unknown as ChannelListProps['options'],
251
553
  renderChannel,
@@ -261,28 +563,86 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
261
563
  loadingLabel,
262
564
  emptyStateLabel = 'No channels found',
263
565
  blockedBadgeLabel = 'Blocked',
566
+ ChannelTopicGroupComponent,
567
+ GeneralTopicAvatarComponent,
568
+ TopicAvatarComponent,
569
+ generalTopicLabel = 'general',
570
+ onAddTopic,
571
+ TopicEmojiPickerComponent,
572
+ closedTopicIcon,
573
+ PinnedIconComponent = DefaultPinnedIcon,
574
+ ChannelActionsComponent,
575
+ onEditTopic,
576
+ onToggleCloseTopic,
577
+ hiddenActions,
578
+ actionLabels,
579
+ actionIcons,
264
580
  }) => {
265
581
  const { client, activeChannel, setActiveChannel } = useChatClient();
266
582
  const [channels, setChannels] = useState<Channel[]>([]);
267
583
  const [loading, setLoading] = useState(true);
268
584
  const [isPendingExpanded, setIsPendingExpanded] = useState(true);
585
+ const [addingTopicForChannel, setAddingTopicForChannel] = useState<Channel | null>(null);
586
+ const [editingTopicForChannel, setEditingTopicForChannel] = useState<Channel | null>(null);
587
+
588
+ const handleAddTopicClick = useCallback((channel: Channel) => {
589
+ if (onAddTopic) {
590
+ onAddTopic(channel);
591
+ } else {
592
+ setAddingTopicForChannel(channel);
593
+ }
594
+ }, [onAddTopic]);
595
+
596
+ const handleEditTopicClick = useCallback((channel: Channel) => {
597
+ if (onEditTopic) {
598
+ onEditTopic(channel);
599
+ } else {
600
+ setEditingTopicForChannel(channel);
601
+ }
602
+ }, [onEditTopic]);
603
+
604
+ const handleToggleCloseTopicClick = useCallback(async (channel: Channel, isClosed: boolean) => {
605
+ if (onToggleCloseTopic) {
606
+ onToggleCloseTopic(channel, isClosed);
607
+ return;
608
+ }
609
+
610
+ const parentCid = channel.data?.parent_cid as string | undefined;
611
+ if (!parentCid) return;
612
+
613
+ const parentChannel = client.activeChannels[parentCid];
614
+ if (!parentChannel) return;
615
+
616
+ try {
617
+ if (isClosed) {
618
+ await parentChannel.reopenTopic(channel.cid);
619
+ } else {
620
+ await parentChannel.closeTopic(channel.cid);
621
+ }
622
+ } catch (err) {
623
+ console.error('Failed to toggle topic close state', err);
624
+ }
625
+ }, [client.activeChannels, onToggleCloseTopic]);
269
626
 
270
627
  // Group channels into pending and regular
271
628
  const { pendingChannels, regularChannels } = useMemo<{ pendingChannels: Channel[], regularChannels: Channel[] }>(() => {
272
629
  const pending: Channel[] = [];
630
+ const pinned: Channel[] = [];
273
631
  const regular: Channel[] = [];
274
-
632
+
275
633
  channels.forEach(ch => {
276
634
  const ms = ch.state?.membership as Record<string, unknown> | undefined;
277
635
  const isPending = ms?.channel_role === 'pending' || ms?.role === 'pending';
278
636
  if (isPending) {
279
637
  pending.push(ch);
638
+ } else if (ch.data?.is_pinned) {
639
+ pinned.push(ch);
280
640
  } else {
281
641
  regular.push(ch);
282
642
  }
283
643
  });
284
644
 
285
- return { pendingChannels: pending, regularChannels: regular };
645
+ return { pendingChannels: pending, regularChannels: [...pinned, ...regular] };
286
646
  }, [channels]);
287
647
 
288
648
  const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);
@@ -317,7 +677,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
317
677
  const isBannedInChannel = Boolean(ms?.banned);
318
678
  const isBlockedInChannel = channel.type === 'messaging' && Boolean(ms?.blocked);
319
679
  const isPending = ms?.channel_role === 'pending' || ms?.role === 'pending';
320
-
680
+
321
681
  if (!isBannedInChannel && !isBlockedInChannel && !isPending && (chState?.unreadCount as number) > 0) {
322
682
  channel.markRead().catch(() => { });
323
683
  // Optimistically reset unread to update UI immediately
@@ -336,8 +696,8 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
336
696
  {/* VList requires its container to have a height to work. */}
337
697
  <VList style={{ height: '100%' }}>
338
698
  {pendingChannels.length > 0 && (
339
- <div
340
- className="ermis-channel-list__accordion-header"
699
+ <div
700
+ className="ermis-channel-list__accordion-header"
341
701
  onClick={() => setIsPendingExpanded(prev => !prev)}
342
702
  >
343
703
  <span>
@@ -345,9 +705,9 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
345
705
  ? pendingInvitesLabel(pendingChannels.length)
346
706
  : pendingInvitesLabel || `Invites (${pendingChannels.length})`}
347
707
  </span>
348
- <svg
708
+ <svg
349
709
  className={`ermis-channel-list__accordion-icon ${isPendingExpanded ? 'ermis-channel-list__accordion-icon--expanded' : ''}`}
350
- width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
710
+ width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
351
711
  >
352
712
  <polyline points="6 9 12 15 18 9"></polyline>
353
713
  </svg>
@@ -367,6 +727,12 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
367
727
  currentUserId={client.userID}
368
728
  pendingBadgeLabel={pendingBadgeLabel}
369
729
  blockedBadgeLabel={blockedBadgeLabel}
730
+ closedTopicIcon={closedTopicIcon}
731
+ PinnedIconComponent={PinnedIconComponent}
732
+ ChannelActionsComponent={ChannelActionsComponent}
733
+ hiddenActions={hiddenActions}
734
+ actionLabels={actionLabels}
735
+ actionIcons={actionIcons}
370
736
  />
371
737
  );
372
738
  })}
@@ -377,6 +743,37 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
377
743
  )}
378
744
  {regularChannels.map((channel: Channel) => {
379
745
  const isActive = activeChannel?.cid === channel.cid;
746
+ const isTeamWithTopics = (channel.type === 'team' || channel.type === 'meeting') && channel.data?.topics_enabled;
747
+
748
+ if (isTeamWithTopics) {
749
+ const GroupComponent = ChannelTopicGroupComponent || ChannelTopicGroup;
750
+ return (
751
+ <GroupComponent
752
+ key={channel.cid}
753
+ channel={channel}
754
+ activeChannel={activeChannel}
755
+ handleSelect={handleSelect}
756
+ renderChannel={renderChannel}
757
+ ChannelItemComponent={ChannelItemComponent}
758
+ AvatarComponent={AvatarComponent}
759
+ GeneralTopicAvatarComponent={GeneralTopicAvatarComponent}
760
+ TopicAvatarComponent={TopicAvatarComponent}
761
+ currentUserId={client.userID}
762
+ pendingBadgeLabel={pendingBadgeLabel}
763
+ blockedBadgeLabel={blockedBadgeLabel}
764
+ generalTopicLabel={generalTopicLabel}
765
+ onAddTopic={handleAddTopicClick}
766
+ closedTopicIcon={closedTopicIcon}
767
+ PinnedIconComponent={PinnedIconComponent}
768
+ ChannelActionsComponent={ChannelActionsComponent}
769
+ onEditTopic={handleEditTopicClick}
770
+ onToggleCloseTopic={handleToggleCloseTopicClick}
771
+ hiddenActions={hiddenActions}
772
+ actionLabels={actionLabels}
773
+ actionIcons={actionIcons}
774
+ />
775
+ );
776
+ }
380
777
 
381
778
  return (
382
779
  <ChannelRow
@@ -390,10 +787,35 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
390
787
  currentUserId={client.userID}
391
788
  pendingBadgeLabel={pendingBadgeLabel}
392
789
  blockedBadgeLabel={blockedBadgeLabel}
790
+ closedTopicIcon={closedTopicIcon}
791
+ PinnedIconComponent={PinnedIconComponent}
792
+ ChannelActionsComponent={ChannelActionsComponent}
793
+ onAddTopic={handleAddTopicClick}
794
+ onEditTopic={handleEditTopicClick}
795
+ onToggleCloseTopic={handleToggleCloseTopicClick}
796
+ hiddenActions={hiddenActions}
797
+ actionLabels={actionLabels}
798
+ actionIcons={actionIcons}
393
799
  />
394
800
  );
395
801
  })}
396
802
  </VList>
803
+ {addingTopicForChannel && (
804
+ <TopicModal
805
+ isOpen={true}
806
+ onClose={() => setAddingTopicForChannel(null)}
807
+ parentChannel={addingTopicForChannel}
808
+ EmojiPickerComponent={TopicEmojiPickerComponent}
809
+ />
810
+ )}
811
+ {editingTopicForChannel && (
812
+ <TopicModal
813
+ isOpen={true}
814
+ onClose={() => setEditingTopicForChannel(null)}
815
+ topic={editingTopicForChannel}
816
+ EmojiPickerComponent={TopicEmojiPickerComponent}
817
+ />
818
+ )}
397
819
  </div>
398
820
  );
399
821
  });