@ermis-network/ermis-chat-react 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/index.cjs +3802 -1772
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +836 -25
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +304 -1
  6. package/dist/index.d.ts +304 -1
  7. package/dist/index.mjs +3755 -1761
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +2 -2
  10. package/src/channelRoleUtils.ts +73 -0
  11. package/src/channelTypeUtils.ts +46 -0
  12. package/src/components/Avatar.tsx +57 -31
  13. package/src/components/BannedOverlay.tsx +40 -0
  14. package/src/components/ChannelActions.tsx +233 -0
  15. package/src/components/ChannelHeader.tsx +126 -5
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +128 -24
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +67 -28
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +90 -1
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +5 -4
  20. package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
  21. package/src/components/ChannelList.tsx +514 -47
  22. package/src/components/ClosedTopicOverlay.tsx +38 -0
  23. package/src/components/CreateChannelModal.tsx +53 -16
  24. package/src/components/EditPreview.tsx +2 -1
  25. package/src/components/ForwardMessageModal.tsx +2 -1
  26. package/src/components/MediaLightbox.tsx +314 -0
  27. package/src/components/MessageInput.tsx +21 -3
  28. package/src/components/MessageItem.tsx +10 -12
  29. package/src/components/MessageQuickReactions.tsx +3 -2
  30. package/src/components/MessageReactions.tsx +8 -3
  31. package/src/components/MessageRenderers.tsx +174 -54
  32. package/src/components/PendingOverlay.tsx +51 -0
  33. package/src/components/PinnedMessages.tsx +2 -1
  34. package/src/components/ReplyPreview.tsx +2 -1
  35. package/src/components/SkippedOverlay.tsx +36 -0
  36. package/src/components/TopicModal.tsx +189 -0
  37. package/src/components/UserPicker.tsx +1 -1
  38. package/src/components/VirtualMessageList.tsx +162 -47
  39. package/src/hooks/useBannedState.ts +27 -3
  40. package/src/hooks/useBlockedState.ts +3 -2
  41. package/src/hooks/useChannelCapabilities.ts +10 -8
  42. package/src/hooks/useChannelData.ts +1 -1
  43. package/src/hooks/useChannelListUpdates.ts +28 -5
  44. package/src/hooks/useChannelMessages.ts +2 -3
  45. package/src/hooks/useChannelRowUpdates.ts +9 -2
  46. package/src/hooks/useMessageActions.ts +23 -9
  47. package/src/hooks/useOnlineStatus.ts +71 -0
  48. package/src/hooks/useOnlineUsers.ts +115 -0
  49. package/src/hooks/usePendingState.ts +8 -3
  50. package/src/index.ts +67 -10
  51. package/src/messageTypeUtils.ts +64 -0
  52. package/src/styles/_channel-info.css +21 -0
  53. package/src/styles/_channel-list.css +276 -6
  54. package/src/styles/_media-lightbox.css +263 -0
  55. package/src/styles/_message-bubble.css +170 -13
  56. package/src/styles/_message-input.css +24 -0
  57. package/src/styles/_message-list.css +76 -6
  58. package/src/styles/_message-quick-reactions.css +5 -0
  59. package/src/styles/_message-reactions.css +7 -0
  60. package/src/styles/_topic-modal.css +154 -0
  61. package/src/styles/index.css +2 -0
  62. package/src/types.ts +203 -3
@@ -4,6 +4,7 @@ import type { Channel, Event, ChannelFilters } from '@ermis-network/ermis-chat-s
4
4
  import { parseSystemMessage, parseSignalMessage } from '@ermis-network/ermis-chat-sdk';
5
5
  import { useChatClient } from '../hooks/useChatClient';
6
6
  import { useChannelListUpdates } from '../hooks/useChannelListUpdates';
7
+ import { useOnlineUsers } from '../hooks/useOnlineUsers';
7
8
  import { replaceMentionsForPreview, buildUserMap } from '../utils';
8
9
  import { useChannelRowUpdates } from '../hooks/useChannelRowUpdates';
9
10
  import { usePendingState } from '../hooks/usePendingState';
@@ -11,6 +12,14 @@ import { Avatar } from './Avatar';
11
12
  import type { ChannelItemProps, ChannelListProps } from '../types';
12
13
 
13
14
  export type { ChannelListProps, ChannelItemProps } from '../types';
15
+ import type { ChannelActionsProps } from '../types';
16
+ import { TopicModal } from './TopicModal';
17
+ import { DefaultChannelActions, computeDefaultActions } from './ChannelActions';
18
+ import { isDirectChannel, hasTopicsEnabled } from '../channelTypeUtils';
19
+ import { canManageChannel, isPendingMember, isSkippedMember, isFriendChannel } from '../channelRoleUtils';
20
+
21
+ export { DefaultChannelActions } from './ChannelActions';
22
+ export type { ChannelAction, ChannelActionsProps } from '../types';
14
23
 
15
24
  /**
16
25
  * Get a human-readable preview string for the last message,
@@ -19,26 +28,28 @@ export type { ChannelListProps, ChannelItemProps } from '../types';
19
28
  function getLastMessagePreview(
20
29
  channel: Channel,
21
30
  myUserId?: string,
22
- ): { text: string; user: string } {
31
+ ): { text: string; user: string; timestamp?: string | Date } {
23
32
  const lastMsg = channel.state?.latestMessages?.slice(-1)[0];
24
33
  if (!lastMsg) return { text: '', user: '' };
25
34
 
35
+ const timestamp = lastMsg.created_at;
36
+
26
37
  const msgType = lastMsg.type || 'regular';
27
38
  const rawText = lastMsg.text ?? '';
28
39
 
29
40
  if (msgType === 'system') {
30
41
  const userMap = buildUserMap(channel.state);
31
- return { text: parseSystemMessage(rawText, userMap), user: '' };
42
+ return { text: parseSystemMessage(rawText, userMap), user: '', timestamp };
32
43
  }
33
44
 
34
45
  if (msgType === 'signal') {
35
46
  const result = parseSignalMessage(rawText, myUserId || '');
36
- return { text: result?.text || rawText, user: '' };
47
+ return { text: result?.text || rawText, user: '', timestamp };
37
48
  }
38
49
 
39
50
  // Display 'Sticker' if message is a sticker
40
51
  if (msgType === 'sticker' || (lastMsg as Record<string, unknown>).sticker_url) {
41
- return { text: 'Sticker', user: lastMsg.user?.name || lastMsg.user_id || '' };
52
+ return { text: 'Sticker', user: lastMsg.user?.name || lastMsg.user_id || '', timestamp };
42
53
  }
43
54
 
44
55
  // Regular / other
@@ -78,6 +89,7 @@ function getLastMessagePreview(
78
89
  return {
79
90
  text: displayText,
80
91
  user: lastMsg.user?.name || lastMsg.user_id || '',
92
+ timestamp,
81
93
  };
82
94
  }
83
95
 
@@ -91,25 +103,69 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
91
103
  unreadCount,
92
104
  lastMessageText,
93
105
  lastMessageUser,
106
+ lastMessageTimestamp,
94
107
  onSelect,
95
108
  AvatarComponent,
96
109
  isBlocked,
97
110
  isPending,
98
111
  pendingBadgeLabel,
99
112
  blockedBadgeLabel,
113
+ isClosedTopic,
114
+ closedTopicIcon,
115
+ PinnedIconComponent,
116
+ ChannelActionsComponent,
117
+ onAddTopic,
118
+ onEditTopic,
119
+ onToggleCloseTopic,
120
+ hiddenActions,
121
+ actionLabels,
122
+ actionIcons,
123
+ isOnline,
100
124
  }) => {
125
+ const { client } = useChatClient();
126
+ const currentUserId = client.userID;
127
+
101
128
  // Subscribe to channel.updated so that when name/image/description change,
102
129
  // we re-render from within (bypasses React.memo which only blocks parent-driven re-renders)
103
- const [, forceUpdate] = useState(0);
130
+ const [updateCount, forceUpdate] = useState(0);
104
131
  useEffect(() => {
105
- const sub = channel.on('channel.updated', () => forceUpdate((c) => c + 1));
106
- return () => sub.unsubscribe();
132
+ const handleUpdate = () => forceUpdate((c) => c + 1);
133
+ const sub1 = channel.on('channel.updated', handleUpdate);
134
+ const sub2 = channel.on('channel.pinned', handleUpdate);
135
+ const sub3 = channel.on('channel.unpinned', handleUpdate);
136
+ return () => {
137
+ sub1.unsubscribe();
138
+ sub2.unsubscribe();
139
+ sub3.unsubscribe();
140
+ };
107
141
  }, [channel]);
108
142
 
143
+ const defaultActions = useMemo(
144
+ () => computeDefaultActions(channel, currentUserId, { onAddTopic, onEditTopic, onToggleCloseTopic, isBlocked, actionLabels, actionIcons }),
145
+ [channel, currentUserId, updateCount, onAddTopic, onEditTopic, onToggleCloseTopic, isBlocked, actionLabels, actionIcons],
146
+ );
147
+
148
+ const filteredActions = useMemo(() => {
149
+ if (!hiddenActions || hiddenActions.length === 0) return defaultActions;
150
+ return defaultActions.filter(a => !hiddenActions.includes(a.id));
151
+ }, [defaultActions, hiddenActions]);
152
+ const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
153
+
109
154
  const name = channel.data?.name || channel.cid;
110
155
  const image = channel.data?.image as string | undefined;
111
156
  const showUnread = hasUnread && !isActive;
112
157
 
158
+ const timestampText = useMemo(() => {
159
+ if (!lastMessageTimestamp) return null;
160
+ const d = new Date(lastMessageTimestamp);
161
+ if (isNaN(d.getTime())) return null;
162
+ const today = new Date();
163
+ const isToday = d.toDateString() === today.toDateString();
164
+ return isToday
165
+ ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
166
+ : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
167
+ }, [lastMessageTimestamp]);
168
+
113
169
  const handleClick = useCallback(() => {
114
170
  onSelect(channel);
115
171
  }, [channel, onSelect]);
@@ -118,47 +174,90 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
118
174
  'ermis-channel-list__item',
119
175
  isActive ? 'ermis-channel-list__item--active' : '',
120
176
  showUnread ? 'ermis-channel-list__item--unread' : '',
121
- isBlocked ? 'ermis-channel-list__item--blocked' : '',
122
177
  isPending ? 'ermis-channel-list__item--pending' : '',
123
178
  ].filter(Boolean).join(' ');
124
179
 
125
180
  return (
126
181
  <div className={itemClass} onClick={handleClick}>
127
- <AvatarComponent image={image} name={name} size={40} />
128
- <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>
182
+ <div className="ermis-channel-list__item-avatar-wrapper">
183
+ <AvatarComponent image={image} name={name} size={40} disableLightbox />
184
+ {isOnline !== undefined && (
185
+ <span className={`ermis-channel-list__online-dot ermis-channel-list__online-dot--${isOnline ? 'online' : 'offline'}`} />
139
186
  )}
140
187
  </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>
188
+ <div className="ermis-channel-list__item-content">
189
+ <div className="ermis-channel-list__item-top-row">
190
+ <div className="ermis-channel-list__item-name">{name}</div>
191
+ {channel.data?.is_pinned === true && !isClosedTopic && PinnedIconComponent && (
192
+ <span className="ermis-channel-list__pinned-icon" title="Pinned">
193
+ <PinnedIconComponent />
194
+ </span>
195
+ )}
196
+ {isClosedTopic && (
197
+ <span className="ermis-channel-list__closed-icon">
198
+ {closedTopicIcon || (
199
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
200
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
201
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
202
+ </svg>
203
+ )}
204
+ </span>
205
+ )}
206
+ {!isClosedTopic && timestampText && <div className="ermis-channel-list__item-timestamp">{timestampText}</div>}
207
+
208
+ {isPending && (
209
+ <span className="ermis-channel-list__pending-badge">{pendingBadgeLabel || 'Invited'}</span>
210
+ )}
211
+
212
+ {isBlocked && (
213
+ <span className="ermis-channel-list__blocked-icon" title={blockedBadgeLabel || "Blocked"}>
214
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
215
+ <circle cx="12" cy="12" r="10" />
216
+ <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
217
+ </svg>
218
+ </span>
219
+ )}
220
+ </div>
221
+ <div className="ermis-channel-list__item-bottom-row">
222
+ {!isClosedTopic && lastMessageText && (
223
+ <div className="ermis-channel-list__item-last-message">
224
+ {lastMessageUser && (
225
+ <span className="ermis-channel-list__item-last-message-user">
226
+ {lastMessageUser}:{' '}
227
+ </span>
228
+ )}
229
+ <span>{lastMessageText}</span>
230
+ </div>
231
+ )}
232
+
233
+ {!isClosedTopic && (
234
+ <div className="ermis-channel-list__item-badges">
235
+ {showUnread && unreadCount > 0 && (
236
+ <span className="ermis-channel-list__unread-badge">
237
+ {unreadCount > 99 ? '99+' : unreadCount}
238
+ </span>
239
+ )}
240
+ </div>
241
+ )}
242
+ </div>
243
+ </div>
244
+ {!isPending && (
245
+ <div className="ermis-channel-list__item-actions-wrapper">
246
+ <ActionsComponent channel={channel} actions={filteredActions} onClose={() => { }} />
247
+ </div>
156
248
  )}
157
249
  </div>
158
250
  );
159
251
  });
160
252
  ChannelItem.displayName = 'ChannelItem';
161
253
 
254
+ export const DefaultPinnedIcon = React.memo(() => (
255
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
256
+ <path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
257
+ </svg>
258
+ ));
259
+ DefaultPinnedIcon.displayName = 'DefaultPinnedIcon';
260
+
162
261
  const DefaultLoading = React.memo(({ text }: { text?: string }) => (
163
262
  <div className="ermis-channel-list__loading">{text || 'Loading channels...'}</div>
164
263
  ));
@@ -182,6 +281,16 @@ type ChannelRowProps = {
182
281
  currentUserId?: string;
183
282
  pendingBadgeLabel?: string;
184
283
  blockedBadgeLabel?: string;
284
+ closedTopicIcon?: React.ReactNode;
285
+ PinnedIconComponent?: React.ComponentType;
286
+ ChannelActionsComponent?: React.ComponentType<ChannelActionsProps>;
287
+ onAddTopic?: (channel: Channel) => void;
288
+ onEditTopic?: (channel: Channel) => void;
289
+ onToggleCloseTopic?: (channel: Channel, isClosed: boolean) => void;
290
+ hiddenActions?: string[];
291
+ actionLabels?: import('../types').ChannelActionLabels;
292
+ actionIcons?: import('../types').ChannelActionIcons;
293
+ isOnline?: boolean;
185
294
  };
186
295
 
187
296
  const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
@@ -194,28 +303,43 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
194
303
  currentUserId,
195
304
  pendingBadgeLabel,
196
305
  blockedBadgeLabel,
306
+ closedTopicIcon,
307
+ PinnedIconComponent,
308
+ ChannelActionsComponent,
309
+ onAddTopic,
310
+ onEditTopic,
311
+ onToggleCloseTopic,
312
+ hiddenActions,
313
+ actionLabels,
314
+ actionIcons,
315
+ isOnline,
197
316
  }) => {
198
317
  // Use the new custom hook to handle all row-level realtime updates
199
318
  const { isBannedInChannel, isBlockedInChannel, updateCount } = useChannelRowUpdates(channel, currentUserId);
200
319
  const { isPending } = usePendingState(channel, currentUserId);
320
+ const isSkipped = isSkippedMember(channel.state?.membership?.channel_role as string);
201
321
 
202
322
  const channelState = channel.state as unknown as Record<string, unknown> | undefined;
203
323
  const rawUnreadCount = (channelState?.unreadCount as number) ?? 0;
204
- const unreadCount = (isBannedInChannel || isBlockedInChannel || isPending) ? 0 : rawUnreadCount;
324
+
325
+ const isClosedTopic = channel.data?.is_closed_topic === true;
326
+
327
+ // Render logic continues...
328
+ const unreadCount = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? 0 : rawUnreadCount;
205
329
  const hasUnread = unreadCount > 0;
206
330
 
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(
331
+ // Derive last message preview computation
332
+ const { text: rawLastMessageText, user: rawLastMessageUser, timestamp: rawLastMessageTimestamp } = useMemo(
210
333
  () => getLastMessagePreview(channel, currentUserId),
211
334
  // Recompute if latestMessage changes or we get a force update
212
335
  // eslint-disable-next-line react-hooks/exhaustive-deps
213
336
  [channel, channel.state?.latestMessages, updateCount]
214
337
  );
215
338
 
216
- // Hide last message preview when banned, blocked, or pending
217
- const lastMessageText = (isBannedInChannel || isBlockedInChannel || isPending) ? '' : rawLastMessageText;
218
- const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending) ? '' : rawLastMessageUser;
339
+ // Hide last message preview when banned, blocked, pending or skipped
340
+ const lastMessageText = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? '' : rawLastMessageText;
341
+ const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? '' : rawLastMessageUser;
342
+ const lastMessageTimestamp = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? null : rawLastMessageTimestamp;
219
343
 
220
344
  if (renderChannel) {
221
345
  return (
@@ -233,17 +357,208 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
233
357
  unreadCount={unreadCount}
234
358
  lastMessageText={lastMessageText}
235
359
  lastMessageUser={lastMessageUser}
360
+ lastMessageTimestamp={lastMessageTimestamp}
236
361
  onSelect={handleSelect}
237
362
  AvatarComponent={AvatarComponent}
238
363
  isBlocked={isBlockedInChannel}
239
364
  isPending={isPending}
240
365
  pendingBadgeLabel={pendingBadgeLabel}
241
366
  blockedBadgeLabel={blockedBadgeLabel}
367
+ isClosedTopic={isClosedTopic}
368
+ closedTopicIcon={closedTopicIcon}
369
+ PinnedIconComponent={PinnedIconComponent}
370
+ ChannelActionsComponent={ChannelActionsComponent}
371
+ onAddTopic={onAddTopic}
372
+ onEditTopic={onEditTopic}
373
+ onToggleCloseTopic={onToggleCloseTopic}
374
+ hiddenActions={hiddenActions}
375
+ actionLabels={actionLabels}
376
+ actionIcons={actionIcons}
377
+ isOnline={isOnline}
242
378
  />
243
379
  );
244
380
  });
245
381
  ChannelRow.displayName = 'ChannelRow';
246
382
 
383
+ export const ChannelTopicGroup = React.memo(({
384
+ channel,
385
+ activeChannel,
386
+ handleSelect,
387
+ renderChannel,
388
+ ChannelItemComponent,
389
+ AvatarComponent,
390
+ GeneralTopicAvatarComponent,
391
+ TopicAvatarComponent,
392
+ currentUserId,
393
+ pendingBadgeLabel,
394
+ blockedBadgeLabel,
395
+ generalTopicLabel,
396
+ closedTopicIcon,
397
+ PinnedIconComponent,
398
+ ChannelActionsComponent,
399
+ onAddTopic,
400
+ onEditTopic,
401
+ onToggleCloseTopic,
402
+ hiddenActions,
403
+ actionLabels,
404
+ actionIcons,
405
+ }: any) => {
406
+ const { updateCount } = useChannelRowUpdates(channel, currentUserId);
407
+ const [isExpanded, setIsExpanded] = useState(true);
408
+ const [topicUpdateCount, setTopicUpdateCount] = useState(0);
409
+
410
+ useEffect(() => {
411
+ const subs: { unsubscribe: () => void }[] = [];
412
+ const handleUpdate = () => setTopicUpdateCount((c) => c + 1);
413
+ const currentTopics = channel.state?.topics || [];
414
+ currentTopics.forEach((t: Channel) => {
415
+ subs.push(t.on('channel.pinned', handleUpdate));
416
+ subs.push(t.on('channel.unpinned', handleUpdate));
417
+ subs.push(t.on('message.new', handleUpdate));
418
+ subs.push(t.on('message.deleted', handleUpdate));
419
+ });
420
+ return () => {
421
+ subs.forEach((s) => s.unsubscribe());
422
+ };
423
+ }, [channel.state?.topics]);
424
+
425
+ const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
426
+
427
+ const userRole = channel.state?.members?.[currentUserId]?.channel_role;
428
+ const hasTopicAddPermission = canManageChannel(userRole);
429
+
430
+ const getTopicTime = (t: Channel) => {
431
+ const lastMsg = t.state?.latestMessages?.slice(-1)[0];
432
+ if (lastMsg?.created_at) return new Date(lastMsg.created_at).getTime();
433
+ if (t.data?.last_message_at) return new Date(t.data.last_message_at as string | Date).getTime();
434
+ if (t.data?.created_at) return new Date(t.data.created_at as string | Date).getTime();
435
+ return 0;
436
+ };
437
+
438
+ const topics = useMemo(() => {
439
+ const allTopics = channel.state?.topics || [];
440
+ return [...allTopics].sort((a: any, b: any) => {
441
+ const aPinned = a.data?.is_pinned === true;
442
+ const bPinned = b.data?.is_pinned === true;
443
+ if (aPinned && !bPinned) return -1;
444
+ if (!aPinned && bPinned) return 1;
445
+
446
+ return getTopicTime(b) - getTopicTime(a);
447
+ });
448
+ }, [channel.state?.topics, topicUpdateCount]);
449
+ const name = channel.data?.name || channel.cid;
450
+ const image = channel.data?.image as string | undefined;
451
+
452
+ const GeneralAvatar = useCallback(() => (
453
+ <div className="ermis-channel-list__topic-hashtag">#</div>
454
+ ), []);
455
+
456
+ const TopicEmojiAvatar = useCallback(({ image }: any) => {
457
+ let emoji = '💬';
458
+ if (image && typeof image === 'string' && image.startsWith('emoji://')) {
459
+ emoji = image.replace('emoji://', '');
460
+ }
461
+ return <div className="ermis-channel-list__topic-hashtag">{emoji}</div>;
462
+ }, []);
463
+
464
+ const generalChannelProxy = useMemo(() => {
465
+ return new Proxy(channel, {
466
+ get(target, prop, receiver) {
467
+ if (prop === 'data') {
468
+ return { ...target.data, name: generalTopicLabel || 'general', is_pinned: false };
469
+ }
470
+ const value = Reflect.get(target, prop, receiver);
471
+ return typeof value === 'function' ? value.bind(target) : value;
472
+ }
473
+ });
474
+ }, [channel, generalTopicLabel]);
475
+
476
+ const defaultActions = useMemo(
477
+ () => computeDefaultActions(channel, currentUserId, { onAddTopic, actionLabels, actionIcons }),
478
+ [channel, currentUserId, updateCount, onAddTopic, actionLabels, actionIcons],
479
+ );
480
+
481
+ const filteredActions = useMemo(() => {
482
+ if (!hiddenActions || hiddenActions.length === 0) return defaultActions;
483
+ return defaultActions.filter((a: any) => !hiddenActions.includes(a.id));
484
+ }, [defaultActions, hiddenActions]);
485
+ const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
486
+
487
+ return (
488
+ <div className="ermis-channel-list__topic-group">
489
+ <div
490
+ className={`ermis-channel-list__topic-header ${isExpanded ? 'ermis-channel-list__topic-header--expanded' : ''}`}
491
+ onClick={handleToggle}
492
+ >
493
+ <AvatarComponent image={image} name={name} size={40} disableLightbox />
494
+ <div className="ermis-channel-list__topic-header-name">{name}</div>
495
+
496
+ {channel.data?.is_pinned === true && PinnedIconComponent && (
497
+ <span className="ermis-channel-list__pinned-icon" title="Pinned">
498
+ <PinnedIconComponent />
499
+ </span>
500
+ )}
501
+
502
+ <div className="ermis-channel-list__topic-actions-wrapper">
503
+ <ActionsComponent channel={channel} actions={filteredActions} onClose={() => { }} />
504
+ </div>
505
+
506
+ <svg
507
+ className="ermis-channel-list__accordion-icon"
508
+ width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
509
+ >
510
+ <polyline points="6 9 12 15 18 9"></polyline>
511
+ </svg>
512
+ </div>
513
+
514
+ {isExpanded && (
515
+ <div className="ermis-channel-list__topic-sublist">
516
+ <ChannelRow
517
+ channel={generalChannelProxy as any}
518
+ isActive={activeChannel?.cid === channel.cid}
519
+ handleSelect={handleSelect}
520
+ renderChannel={renderChannel}
521
+ ChannelItemComponent={ChannelItemComponent}
522
+ AvatarComponent={GeneralTopicAvatarComponent || GeneralAvatar}
523
+ currentUserId={currentUserId}
524
+ pendingBadgeLabel={pendingBadgeLabel}
525
+ blockedBadgeLabel={blockedBadgeLabel}
526
+ closedTopicIcon={closedTopicIcon}
527
+ PinnedIconComponent={PinnedIconComponent}
528
+ ChannelActionsComponent={() => null}
529
+ hiddenActions={hiddenActions}
530
+ actionLabels={actionLabels}
531
+ actionIcons={actionIcons}
532
+ />
533
+ {topics.map((topicChannel: any) => (
534
+ <ChannelRow
535
+ key={topicChannel.cid}
536
+ channel={topicChannel}
537
+ isActive={activeChannel?.cid === topicChannel.cid}
538
+ handleSelect={handleSelect}
539
+ renderChannel={renderChannel}
540
+ ChannelItemComponent={ChannelItemComponent}
541
+ AvatarComponent={TopicAvatarComponent || TopicEmojiAvatar}
542
+ currentUserId={currentUserId}
543
+ pendingBadgeLabel={pendingBadgeLabel}
544
+ blockedBadgeLabel={blockedBadgeLabel}
545
+ closedTopicIcon={closedTopicIcon}
546
+ PinnedIconComponent={PinnedIconComponent}
547
+ ChannelActionsComponent={ChannelActionsComponent}
548
+ onEditTopic={onEditTopic}
549
+ onToggleCloseTopic={onToggleCloseTopic}
550
+ hiddenActions={hiddenActions}
551
+ actionLabels={actionLabels}
552
+ actionIcons={actionIcons}
553
+ />
554
+ ))}
555
+ </div>
556
+ )}
557
+ </div>
558
+ );
559
+ });
560
+ ChannelTopicGroup.displayName = 'ChannelTopicGroup';
561
+
247
562
  export const ChannelList: React.FC<ChannelListProps> = React.memo(({
248
563
  filters = { type: ['messaging', 'team', 'meeting'], include_pinned_messages: true } as unknown as ChannelFilters,
249
564
  sort = [],
@@ -261,28 +576,93 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
261
576
  loadingLabel,
262
577
  emptyStateLabel = 'No channels found',
263
578
  blockedBadgeLabel = 'Blocked',
579
+ ChannelTopicGroupComponent,
580
+ GeneralTopicAvatarComponent,
581
+ TopicAvatarComponent,
582
+ generalTopicLabel = 'general',
583
+ onAddTopic,
584
+ TopicEmojiPickerComponent,
585
+ closedTopicIcon,
586
+ PinnedIconComponent = DefaultPinnedIcon,
587
+ ChannelActionsComponent,
588
+ onEditTopic,
589
+ onToggleCloseTopic,
590
+ hiddenActions,
591
+ actionLabels,
592
+ actionIcons,
593
+ showOnlineStatus = true,
264
594
  }) => {
265
595
  const { client, activeChannel, setActiveChannel } = useChatClient();
266
596
  const [channels, setChannels] = useState<Channel[]>([]);
267
597
  const [loading, setLoading] = useState(true);
268
598
  const [isPendingExpanded, setIsPendingExpanded] = useState(true);
599
+ const [addingTopicForChannel, setAddingTopicForChannel] = useState<Channel | null>(null);
600
+ const [editingTopicForChannel, setEditingTopicForChannel] = useState<Channel | null>(null);
601
+
602
+ const handleAddTopicClick = useCallback((channel: Channel) => {
603
+ if (onAddTopic) {
604
+ onAddTopic(channel);
605
+ } else {
606
+ setAddingTopicForChannel(channel);
607
+ }
608
+ }, [onAddTopic]);
609
+
610
+ const handleEditTopicClick = useCallback((channel: Channel) => {
611
+ if (onEditTopic) {
612
+ onEditTopic(channel);
613
+ } else {
614
+ setEditingTopicForChannel(channel);
615
+ }
616
+ }, [onEditTopic]);
617
+
618
+ const handleToggleCloseTopicClick = useCallback(async (channel: Channel, isClosed: boolean) => {
619
+ if (onToggleCloseTopic) {
620
+ onToggleCloseTopic(channel, isClosed);
621
+ return;
622
+ }
623
+
624
+ const parentCid = channel.data?.parent_cid as string | undefined;
625
+ if (!parentCid) return;
626
+
627
+ const parentChannel = client.activeChannels[parentCid];
628
+ if (!parentChannel) return;
629
+
630
+ try {
631
+ if (isClosed) {
632
+ await parentChannel.reopenTopic(channel.cid);
633
+ } else {
634
+ await parentChannel.closeTopic(channel.cid);
635
+ }
636
+ } catch (err) {
637
+ console.error('Failed to toggle topic close state', err);
638
+ }
639
+ }, [client.activeChannels, onToggleCloseTopic]);
269
640
 
270
641
  // Group channels into pending and regular
271
642
  const { pendingChannels, regularChannels } = useMemo<{ pendingChannels: Channel[], regularChannels: Channel[] }>(() => {
272
643
  const pending: Channel[] = [];
644
+ const pinned: Channel[] = [];
273
645
  const regular: Channel[] = [];
274
646
 
275
647
  channels.forEach(ch => {
276
648
  const ms = ch.state?.membership as Record<string, unknown> | undefined;
277
- const isPending = ms?.channel_role === 'pending' || ms?.role === 'pending';
649
+ const isPending = isPendingMember(ms?.channel_role as string);
650
+ const isSkipped = isSkippedMember(ms?.channel_role as string);
651
+
652
+ if (isSkipped) {
653
+ return; // Filter out completely
654
+ }
655
+
278
656
  if (isPending) {
279
657
  pending.push(ch);
658
+ } else if (ch.data?.is_pinned) {
659
+ pinned.push(ch);
280
660
  } else {
281
661
  regular.push(ch);
282
662
  }
283
663
  });
284
664
 
285
- return { pendingChannels: pending, regularChannels: regular };
665
+ return { pendingChannels: pending, regularChannels: [...pinned, ...regular] };
286
666
  }, [channels]);
287
667
 
288
668
  const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);
@@ -306,6 +686,28 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
306
686
  // Real-time: List manipulation (move to top, add, delete)
307
687
  useChannelListUpdates(channels, setChannels);
308
688
 
689
+ // Online status: compute set of online friend user IDs (skip if disabled)
690
+ const onlineUsers = useOnlineUsers(showOnlineStatus ? channels : []);
691
+
692
+ // Helper: get the "other" user ID from a direct channel
693
+ const getOtherUserId = useCallback((channel: Channel): string | undefined => {
694
+ if (!isDirectChannel(channel) || !client.userID) return undefined;
695
+ const members = channel.state?.members;
696
+ if (!members) return undefined;
697
+ for (const memberId of Object.keys(members)) {
698
+ if (memberId !== client.userID) return memberId;
699
+ }
700
+ return undefined;
701
+ }, [client.userID]);
702
+
703
+ // Helper: compute isOnline for a channel (undefined for non-friend channels)
704
+ const getIsOnline = useCallback((channel: Channel): boolean | undefined => {
705
+ const otherUserId = getOtherUserId(channel);
706
+ if (!otherUserId || !client.userID) return undefined;
707
+ if (!isFriendChannel(channel, otherUserId, client.userID)) return undefined;
708
+ return onlineUsers.has(otherUserId);
709
+ }, [getOtherUserId, onlineUsers, client.userID]);
710
+
309
711
  const handleSelect = useCallback(
310
712
  (channel: Channel) => {
311
713
  setActiveChannel(channel);
@@ -315,10 +717,11 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
315
717
  const ms = channel.state?.membership as Record<string, unknown> | undefined;
316
718
  const chState = channel.state as unknown as Record<string, unknown> | undefined;
317
719
  const isBannedInChannel = Boolean(ms?.banned);
318
- const isBlockedInChannel = channel.type === 'messaging' && Boolean(ms?.blocked);
319
- const isPending = ms?.channel_role === 'pending' || ms?.role === 'pending';
720
+ const isBlockedInChannel = isDirectChannel(channel) && Boolean(ms?.blocked);
721
+ const isPending = isPendingMember(ms?.channel_role as string);
722
+ const isSkipped = isSkippedMember(ms?.channel_role as string);
320
723
 
321
- if (!isBannedInChannel && !isBlockedInChannel && !isPending && (chState?.unreadCount as number) > 0) {
724
+ if (!isBannedInChannel && !isBlockedInChannel && !isPending && !isSkipped && (chState?.unreadCount as number) > 0) {
322
725
  channel.markRead().catch(() => { });
323
726
  // Optimistically reset unread to update UI immediately
324
727
  if (chState) chState.unreadCount = 0;
@@ -367,6 +770,13 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
367
770
  currentUserId={client.userID}
368
771
  pendingBadgeLabel={pendingBadgeLabel}
369
772
  blockedBadgeLabel={blockedBadgeLabel}
773
+ closedTopicIcon={closedTopicIcon}
774
+ PinnedIconComponent={PinnedIconComponent}
775
+ ChannelActionsComponent={ChannelActionsComponent}
776
+ hiddenActions={hiddenActions}
777
+ actionLabels={actionLabels}
778
+ actionIcons={actionIcons}
779
+ isOnline={getIsOnline(channel)}
370
780
  />
371
781
  );
372
782
  })}
@@ -377,6 +787,37 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
377
787
  )}
378
788
  {regularChannels.map((channel: Channel) => {
379
789
  const isActive = activeChannel?.cid === channel.cid;
790
+ const isTeamWithTopics = hasTopicsEnabled(channel);
791
+
792
+ if (isTeamWithTopics) {
793
+ const GroupComponent = ChannelTopicGroupComponent || ChannelTopicGroup;
794
+ return (
795
+ <GroupComponent
796
+ key={channel.cid}
797
+ channel={channel}
798
+ activeChannel={activeChannel}
799
+ handleSelect={handleSelect}
800
+ renderChannel={renderChannel}
801
+ ChannelItemComponent={ChannelItemComponent}
802
+ AvatarComponent={AvatarComponent}
803
+ GeneralTopicAvatarComponent={GeneralTopicAvatarComponent}
804
+ TopicAvatarComponent={TopicAvatarComponent}
805
+ currentUserId={client.userID}
806
+ pendingBadgeLabel={pendingBadgeLabel}
807
+ blockedBadgeLabel={blockedBadgeLabel}
808
+ generalTopicLabel={generalTopicLabel}
809
+ onAddTopic={handleAddTopicClick}
810
+ closedTopicIcon={closedTopicIcon}
811
+ PinnedIconComponent={PinnedIconComponent}
812
+ ChannelActionsComponent={ChannelActionsComponent}
813
+ onEditTopic={handleEditTopicClick}
814
+ onToggleCloseTopic={handleToggleCloseTopicClick}
815
+ hiddenActions={hiddenActions}
816
+ actionLabels={actionLabels}
817
+ actionIcons={actionIcons}
818
+ />
819
+ );
820
+ }
380
821
 
381
822
  return (
382
823
  <ChannelRow
@@ -390,10 +831,36 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
390
831
  currentUserId={client.userID}
391
832
  pendingBadgeLabel={pendingBadgeLabel}
392
833
  blockedBadgeLabel={blockedBadgeLabel}
834
+ closedTopicIcon={closedTopicIcon}
835
+ PinnedIconComponent={PinnedIconComponent}
836
+ ChannelActionsComponent={ChannelActionsComponent}
837
+ onAddTopic={handleAddTopicClick}
838
+ onEditTopic={handleEditTopicClick}
839
+ onToggleCloseTopic={handleToggleCloseTopicClick}
840
+ hiddenActions={hiddenActions}
841
+ actionLabels={actionLabels}
842
+ actionIcons={actionIcons}
843
+ isOnline={getIsOnline(channel)}
393
844
  />
394
845
  );
395
846
  })}
396
847
  </VList>
848
+ {addingTopicForChannel && (
849
+ <TopicModal
850
+ isOpen={true}
851
+ onClose={() => setAddingTopicForChannel(null)}
852
+ parentChannel={addingTopicForChannel}
853
+ EmojiPickerComponent={TopicEmojiPickerComponent}
854
+ />
855
+ )}
856
+ {editingTopicForChannel && (
857
+ <TopicModal
858
+ isOpen={true}
859
+ onClose={() => setEditingTopicForChannel(null)}
860
+ topic={editingTopicForChannel}
861
+ EmojiPickerComponent={TopicEmojiPickerComponent}
862
+ />
863
+ )}
397
864
  </div>
398
865
  );
399
866
  });