@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
@@ -256,7 +256,7 @@ export const UserPicker: React.FC<UserPickerProps> = ({
256
256
  cancelled = true;
257
257
  clearTimeout(timer);
258
258
  };
259
- }, [search, localFilteredUsers.length, client, excludeSet]);
259
+ }, [search, localFilteredUsers.length, client]);
260
260
 
261
261
  /* ---------- 5. Derived display list ---------- */
262
262
  const usersToDisplay = (search.trim() && localFilteredUsers.length === 0)
@@ -12,6 +12,8 @@ import { useChannelProfile } from '../hooks/useChannelData';
12
12
  import { Avatar } from './Avatar';
13
13
  import { MessageItem } from './MessageItem';
14
14
  import { SystemMessageItem } from './MessageItem';
15
+ import { isPublicGroupChannel, isDirectChannel } from '../channelTypeUtils';
16
+ import { canManageChannel, isSkippedMember, isPendingMember } from '../channelRoleUtils';
15
17
  import {
16
18
  defaultMessageRenderers,
17
19
  type MessageBubbleProps,
@@ -21,6 +23,10 @@ import { QuotedMessagePreview } from './QuotedMessagePreview';
21
23
  import { PinnedMessages } from './PinnedMessages';
22
24
  import { ReadReceipts } from './ReadReceipts';
23
25
  import { TypingIndicator } from './TypingIndicator';
26
+ import { PendingOverlay } from './PendingOverlay';
27
+ import { SkippedOverlay } from './SkippedOverlay';
28
+ import { BannedOverlay } from './BannedOverlay';
29
+ import { ClosedTopicOverlay } from './ClosedTopicOverlay';
24
30
  import type { MessageListProps } from '../types';
25
31
 
26
32
  /* ----------------------------------------------------------
@@ -75,6 +81,23 @@ const DefaultBubble: React.FC<MessageBubbleProps> = React.memo(({
75
81
  ));
76
82
  (DefaultBubble as any).displayName = 'DefaultBubble';
77
83
 
84
+ const DefaultPendingInviteeNotification = React.memo(({ inviteeName, label }: { inviteeName?: string, label?: string }) => {
85
+ const defaultLabel = inviteeName ? `${inviteeName} needs to accept your invitation to see the messages you've sent` : 'The invited user needs to accept your invitation to see the messages you\'ve sent';
86
+ return (
87
+ <div className="ermis-message-list__pending-invitee">
88
+ <div className="ermis-message-list__pending-invitee-content">
89
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
90
+ <circle cx="12" cy="12" r="10" />
91
+ <line x1="12" y1="8" x2="12" y2="12" />
92
+ <line x1="12" y1="16" x2="12.01" y2="16" />
93
+ </svg>
94
+ <span>{label || defaultLabel}</span>
95
+ </div>
96
+ </div>
97
+ );
98
+ });
99
+ DefaultPendingInviteeNotification.displayName = 'DefaultPendingInviteeNotification';
100
+
78
101
  /* ----------------------------------------------------------
79
102
  VirtualMessageList
80
103
  ---------------------------------------------------------- */
@@ -104,7 +127,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
104
127
  emptyTitle = 'No messages yet',
105
128
  emptySubtitle = 'Send a message to start the conversation',
106
129
  jumpToLatestLabel = '↓ Jump to latest',
107
- bannedOverlayTitle = 'You have been blocked from this channel',
130
+ bannedOverlayTitle = 'You have been banned from this channel',
108
131
  bannedOverlaySubtitle = 'You can no longer read or send messages here',
109
132
  blockedOverlayTitle = 'You have blocked this user',
110
133
  blockedOverlaySubtitle = 'Unblock to continue the conversation',
@@ -112,11 +135,29 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
112
135
  pendingOverlaySubtitle = 'Accept the invitation to view messages and interact',
113
136
  pendingAcceptLabel = 'Accept',
114
137
  pendingRejectLabel = 'Reject',
138
+ pendingSkipLabel = 'Skip',
139
+ skippedOverlayTitle = 'You skipped this conversation',
140
+ skippedOverlaySubtitle = 'Accept the invitation to start chatting',
141
+ skippedAcceptLabel = 'Accept',
142
+ closedTopicOverlayTitle = 'This topic has been closed',
143
+ closedTopicOverlaySubtitle = 'You can no longer read or send messages in this topic.',
144
+ closedTopicReopenLabel = 'Reopen Topic',
145
+ PendingInviteeNotificationComponent = DefaultPendingInviteeNotification,
146
+ pendingInviteeLabel,
115
147
  }) => {
116
- const { client, messages, readState, activeChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
148
+ const { client, messages, readState, activeChannel, setActiveChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
117
149
  const { isBanned } = useBannedState(activeChannel, client.userID);
118
150
  const { isBlocked } = useBlockedState(activeChannel, client.userID);
119
151
  const { isPending } = usePendingState(activeChannel, client.userID);
152
+
153
+ const isSkipped = client.userID
154
+ ? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
155
+ isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
156
+ : false;
157
+
158
+ const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
159
+ const parentCid = activeChannel?.data?.parent_cid as string | undefined;
160
+ const parentChannel = parentCid && client ? client.activeChannels[parentCid] : undefined;
120
161
 
121
162
  const { channelName, channelImage } = useChannelProfile(activeChannel);
122
163
 
@@ -124,6 +165,21 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
124
165
  const messagesRef = useRef(messages);
125
166
  messagesRef.current = messages;
126
167
  const currentUserId = client.userID;
168
+ const currentUserRole = currentUserId ? activeChannel?.state?.members?.[currentUserId]?.channel_role : undefined;
169
+ const canManageTopic = canManageChannel(currentUserRole);
170
+
171
+ const pendingInviteeName = useMemo(() => {
172
+ if (!activeChannel || !currentUserId) return null;
173
+ if (!isDirectChannel(activeChannel)) return null;
174
+ const membersList = Object.values(activeChannel.state?.members || {});
175
+ if (membersList.length === 2 && !isPending) {
176
+ const otherUser = membersList.find(m => m.user_id !== currentUserId);
177
+ if (otherUser && isPendingMember(otherUser.channel_role)) {
178
+ return otherUser.user?.name || otherUser.user?.id || 'User';
179
+ }
180
+ }
181
+ return null;
182
+ }, [activeChannel, currentUserId, isPending]);
127
183
 
128
184
  // Ref to scope DOM queries (safe for multiple instances)
129
185
  const containerRef = useRef<HTMLDivElement>(null);
@@ -134,18 +190,33 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
134
190
  const handleAcceptInvite = useCallback(async () => {
135
191
  if (!activeChannel) return;
136
192
  try {
137
- const isPublicTeam = activeChannel.type === 'team' && Boolean(activeChannel.data?.public);
138
- const action = isPublicTeam ? 'join' : 'accept';
193
+ const isPublicTeamOrMeeting = isPublicGroupChannel(activeChannel);
194
+ const action = isPublicTeamOrMeeting ? 'join' : 'accept';
139
195
  await activeChannel.acceptInvite(action);
140
196
  } catch (e: any) {
141
197
  console.error('Error accepting invite', e);
142
198
  }
143
199
  }, [activeChannel]);
144
200
 
145
- const handleRejectInvite = useCallback(() => {
201
+ const handleRejectInvite = useCallback(async () => {
146
202
  if (!activeChannel) return;
147
- activeChannel.rejectInvite().catch((e: any) => console.error('Error rejecting invite', e));
148
- }, [activeChannel]);
203
+ try {
204
+ await activeChannel.rejectInvite();
205
+ if (setActiveChannel) setActiveChannel(null);
206
+ } catch (e: any) {
207
+ console.error('Error rejecting invite', e);
208
+ }
209
+ }, [activeChannel, setActiveChannel]);
210
+
211
+ const handleSkipInvite = useCallback(async () => {
212
+ if (!activeChannel) return;
213
+ try {
214
+ await activeChannel.skipInvite();
215
+ if (setActiveChannel) setActiveChannel(null);
216
+ } catch (e: any) {
217
+ console.error('Error skipping invite', e);
218
+ }
219
+ }, [activeChannel, setActiveChannel]);
149
220
 
150
221
  const scrollToBottom = useCallback((smooth = false, attempts = 0) => {
151
222
  const handle = vlistRef.current;
@@ -212,6 +283,20 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
212
283
  }, [setHasMore, setHasNewer]),
213
284
  });
214
285
 
286
+ const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked || isSkipped);
287
+ const prevOverlayRef = useRef(hasOverlay);
288
+
289
+ useEffect(() => {
290
+ if (prevOverlayRef.current && !hasOverlay) {
291
+ // Transitioned from having an overlay to normal view.
292
+ // Give VList a moment to measure its new DOM size via ResizeObserver, then jump to the bottom.
293
+ setTimeout(() => scrollToBottom(false), 50);
294
+ setTimeout(() => scrollToBottom(false), 200);
295
+ setTimeout(() => scrollToBottom(false), 500);
296
+ }
297
+ prevOverlayRef.current = hasOverlay;
298
+ }, [hasOverlay, scrollToBottom]);
299
+
215
300
  const renderers = useMemo(
216
301
  () => ({ ...defaultMessageRenderers, ...customRenderers }),
217
302
  [customRenderers],
@@ -354,19 +439,81 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
354
439
  readReceiptsMaxAvatars,
355
440
  ]);
356
441
 
357
- const blockedClass = isBlocked ? ' ermis-message-list--blocked' : '';
442
+ if (isBanned || isBlocked) {
443
+ return (
444
+ <BannedOverlay
445
+ isBlocked={isBlocked}
446
+ blockedTitle={blockedOverlayTitle}
447
+ bannedTitle={bannedOverlayTitle}
448
+ blockedSubtitle={blockedOverlaySubtitle}
449
+ bannedSubtitle={bannedOverlaySubtitle}
450
+ onUnblock={() => { activeChannel?.unblockUser().catch((e: any) => console.error('Error unblocking user', e)); }}
451
+ />
452
+ );
453
+ }
454
+
455
+ if (isPending) {
456
+ const isDirect = activeChannel ? isDirectChannel(activeChannel) : false;
457
+ return (
458
+ <PendingOverlay
459
+ channelImage={channelImage}
460
+ channelName={channelName}
461
+ title={pendingOverlayTitle}
462
+ subtitle={pendingOverlaySubtitle}
463
+ rejectLabel={pendingRejectLabel}
464
+ acceptLabel={pendingAcceptLabel}
465
+ onReject={handleRejectInvite}
466
+ onAccept={handleAcceptInvite}
467
+ skipLabel={isDirect ? pendingSkipLabel : undefined}
468
+ onSkip={isDirect ? handleSkipInvite : undefined}
469
+ AvatarComponent={AvatarComponent}
470
+ />
471
+ );
472
+ }
473
+
474
+ if (isSkipped) {
475
+ return (
476
+ <SkippedOverlay
477
+ channelImage={channelImage}
478
+ channelName={channelName}
479
+ title={skippedOverlayTitle}
480
+ subtitle={skippedOverlaySubtitle}
481
+ acceptLabel={skippedAcceptLabel}
482
+ onAccept={handleAcceptInvite}
483
+ AvatarComponent={AvatarComponent}
484
+ />
485
+ );
486
+ }
487
+
488
+ if (isClosedTopic) {
489
+ return (
490
+ <ClosedTopicOverlay
491
+ title={closedTopicOverlayTitle}
492
+ subtitle={closedTopicOverlaySubtitle}
493
+ canManageTopic={Boolean(canManageTopic && activeChannel && parentChannel)}
494
+ reopenLabel={closedTopicReopenLabel}
495
+ onReopen={() => { parentChannel?.reopenTopic(activeChannel!.cid).catch((e: any) => console.error('Error reopening topic', e)); }}
496
+ />
497
+ );
498
+ }
358
499
 
359
500
  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} />}
501
+ <div ref={containerRef} className={`ermis-message-list${className ? ` ${className}` : ''}`}>
502
+ {showPinnedMessages && <PinnedMessagesComponent onClickMessage={scrollToMessage} AvatarComponent={AvatarComponent} />}
362
503
 
363
- {messages.length === 0 && !isBanned && !isPending && (
504
+ {messages.length === 0 && (
364
505
  EmptyStateIndicator === DefaultEmpty
365
506
  ? <DefaultEmpty title={emptyTitle} subtitle={emptySubtitle} />
366
507
  : <EmptyStateIndicator />
367
508
  )}
368
509
 
369
- {/* VList always rendered so virtua keeps its viewport measurement */}
510
+ {pendingInviteeName && (
511
+ <PendingInviteeNotificationComponent
512
+ inviteeName={pendingInviteeName}
513
+ label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
514
+ />
515
+ )}
516
+
370
517
  <VList
371
518
  key={activeChannel?.cid || 'empty'}
372
519
  ref={vlistRef}
@@ -374,46 +521,14 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
374
521
  onScroll={handleScroll}
375
522
  className="ermis-message-list__vlist"
376
523
  >
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}
524
+ {messageElements}
410
525
  </VList>
411
526
 
412
527
  {/* Typing indicator */}
413
- {!isBanned && !isBlocked && !isPending && showTypingIndicator && <TypingIndicatorComponent />}
528
+ {showTypingIndicator && <TypingIndicatorComponent />}
414
529
 
415
530
  {/* Jump to latest button */}
416
- {!isBanned && !isBlocked && !isPending && hasNewer && (
531
+ {hasNewer && (
417
532
  JumpToLatestButton === DefaultJumpToLatest
418
533
  ? <DefaultJumpToLatest onClick={jumpToLatest} label={jumpToLatestLabel} />
419
534
  : <JumpToLatestButton onClick={jumpToLatest} />
@@ -6,12 +6,16 @@ import type { Channel } from '@ermis-network/ermis-chat-sdk';
6
6
  *
7
7
  * Reads the initial value from `channel.state.membership.banned` and subscribes
8
8
  * to `member.banned` / `member.unbanned` WebSocket events for real-time updates.
9
+ * If the channel is a topic, it also synchronizes with the parent channel's ban state.
9
10
  *
10
11
  * Only triggers a re-render when the *current user* is the target of the event.
11
12
  */
12
13
  export function useBannedState(channel: Channel | null | undefined, currentUserId?: string) {
13
14
  const [isBanned, setIsBanned] = useState<boolean>(() => {
14
- return Boolean(channel?.state?.membership?.banned);
15
+ if (!channel) return false;
16
+ const parentCid = channel.data?.parent_cid as string | undefined;
17
+ const parentChannel = parentCid ? channel.getClient().activeChannels[parentCid] : undefined;
18
+ return Boolean(channel.state?.membership?.banned || parentChannel?.state?.membership?.banned);
15
19
  });
16
20
 
17
21
  useEffect(() => {
@@ -20,8 +24,11 @@ export function useBannedState(channel: Channel | null | undefined, currentUserI
20
24
  return;
21
25
  }
22
26
 
27
+ const parentCid = channel.data?.parent_cid as string | undefined;
28
+ const parentChannel = parentCid ? channel.getClient().activeChannels[parentCid] : undefined;
29
+
23
30
  // Sync initial state when channel changes
24
- setIsBanned(Boolean(channel.state?.membership?.banned));
31
+ setIsBanned(Boolean(channel.state?.membership?.banned || parentChannel?.state?.membership?.banned));
25
32
 
26
33
  const handleBanned = (event: any) => {
27
34
  if (event.member?.user_id === currentUserId) {
@@ -31,16 +38,33 @@ export function useBannedState(channel: Channel | null | undefined, currentUserI
31
38
 
32
39
  const handleUnbanned = (event: any) => {
33
40
  if (event.member?.user_id === currentUserId) {
34
- setIsBanned(false);
41
+ const eventCid = event.cid || (event.channel_type ? `${event.channel_type}:${event.channel_id}` : undefined);
42
+ let cBanned = Boolean(channel.state?.membership?.banned);
43
+ let pBanned = Boolean(parentChannel?.state?.membership?.banned);
44
+
45
+ if (eventCid === channel.cid) cBanned = false;
46
+ if (parentChannel && eventCid === parentChannel.cid) pBanned = false;
47
+
48
+ setIsBanned(cBanned || pBanned);
35
49
  }
36
50
  };
37
51
 
38
52
  const sub1 = channel.on('member.banned', handleBanned);
39
53
  const sub2 = channel.on('member.unbanned', handleUnbanned);
40
54
 
55
+ let sub3: { unsubscribe: () => void } | undefined;
56
+ let sub4: { unsubscribe: () => void } | undefined;
57
+
58
+ if (parentChannel) {
59
+ sub3 = parentChannel.on('member.banned', handleBanned);
60
+ sub4 = parentChannel.on('member.unbanned', handleUnbanned);
61
+ }
62
+
41
63
  return () => {
42
64
  sub1.unsubscribe();
43
65
  sub2.unsubscribe();
66
+ if (sub3) sub3.unsubscribe();
67
+ if (sub4) sub4.unsubscribe();
44
68
  };
45
69
  }, [channel, currentUserId]);
46
70
 
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect } from 'react';
2
2
  import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
+ import { isDirectChannel } from '../channelTypeUtils';
3
4
 
4
5
  /**
5
6
  * Hook that tracks whether the current user has blocked the other party
@@ -17,12 +18,12 @@ import type { Channel } from '@ermis-network/ermis-chat-sdk';
17
18
  */
18
19
  export function useBlockedState(channel: Channel | null | undefined, currentUserId?: string) {
19
20
  const [isBlocked, setIsBlocked] = useState<boolean>(() => {
20
- if (channel?.type !== 'messaging') return false;
21
+ if (!isDirectChannel(channel)) return false;
21
22
  return Boolean(channel?.state?.membership?.blocked);
22
23
  });
23
24
 
24
25
  useEffect(() => {
25
- if (!channel || channel.type !== 'messaging') {
26
+ if (!channel || !isDirectChannel(channel)) {
26
27
  setIsBlocked(false);
27
28
  return;
28
29
  }
@@ -1,5 +1,7 @@
1
1
  import { useState, useEffect, useCallback } from 'react';
2
2
  import { useChatClient } from './useChatClient';
3
+ import { isGroupChannel } from '../channelTypeUtils';
4
+ import { canManageChannel, CHANNEL_ROLES } from '../channelRoleUtils';
3
5
 
4
6
  export const useChannelCapabilities = () => {
5
7
  const { activeChannel, client } = useChatClient();
@@ -17,21 +19,21 @@ export const useChannelCapabilities = () => {
17
19
  }, [activeChannel]);
18
20
 
19
21
  const currentUserId = client?.userID || '';
20
- const isTeamChannel = activeChannel?.type === 'team';
22
+ const isGroupCh = isGroupChannel(activeChannel);
21
23
  const role = (activeChannel?.state as any)?.members?.[currentUserId]?.channel_role;
22
24
 
23
- const isOwner = role === 'owner' || activeChannel?.data?.created_by_id === currentUserId;
24
- const isModerator = role === 'moder';
25
- const isOwnerOrModerator = isOwner || isModerator;
25
+ const isOwner = role === CHANNEL_ROLES.OWNER || activeChannel?.data?.created_by_id === currentUserId;
26
+ const isModerator = role === CHANNEL_ROLES.MODERATOR;
27
+ const isOwnerOrModerator = isOwner || isModerator || canManageChannel(role);
26
28
 
27
- const capabilities: string[] = isTeamChannel ? (activeChannel?.data as any)?.member_capabilities || [] : [];
29
+ const capabilities: string[] = isGroupCh ? (activeChannel?.data as any)?.member_capabilities || [] : [];
28
30
 
29
31
  const hasCapability = useCallback((cap: string) => {
30
- return !isTeamChannel || isOwnerOrModerator || capabilities.includes(cap);
31
- }, [isTeamChannel, isOwnerOrModerator, capabilities, updateTick]); // React to updateTick correctly
32
+ return !isGroupCh || isOwnerOrModerator || capabilities.includes(cap);
33
+ }, [isGroupCh, isOwnerOrModerator, capabilities, updateTick]); // React to updateTick correctly
32
34
 
33
35
  return {
34
- isTeamChannel,
36
+ isGroupChannel: isGroupCh,
35
37
  isOwner,
36
38
  isModerator,
37
39
  isOwnerOrModerator,
@@ -47,7 +47,7 @@ export const useChannelProfile = (channel: Channel | null | undefined) => {
47
47
  return () => sub.unsubscribe();
48
48
  }, [channel]);
49
49
 
50
- const channelName = useMemo(() => channel?.data?.name || channel?.cid || 'Unknown Channel', [channel?.data?.name, channel?.cid, channelUpdateCount]);
50
+ const channelName = useMemo(() => channel?.data?.name || channel?.cid || 'Unknown Channel', [channel?.data?.name, channel?.cid, channel?.type, channelUpdateCount]);
51
51
  const channelImage = useMemo(() => channel?.data?.image as string | undefined, [channel?.data?.image, channelUpdateCount]);
52
52
  const channelDescription = useMemo(() => channel?.data?.description as string | undefined, [channel?.data?.description, channelUpdateCount]);
53
53
 
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import type { Channel, Event } from '@ermis-network/ermis-chat-sdk';
3
3
  import { useChatClient } from './useChatClient';
4
+ import { isDirectChannel } from '../channelTypeUtils';
5
+ import { isPendingMember } from '../channelRoleUtils';
4
6
 
5
7
  /**
6
8
  * Subscribes to real-time events and keeps the channel list in sync:
@@ -35,9 +37,8 @@ export function useChannelListUpdates(
35
37
  const active = activeChannelRef.current;
36
38
  if (active?.cid === eventCid && event.user?.id !== client.userID) {
37
39
  const isBannedInActive = Boolean(active.state?.membership?.banned);
38
- const isBlockedInActive = active.type === 'messaging' && Boolean(active.state?.membership?.blocked);
39
- const isPendingActive =
40
- active.state?.membership?.channel_role === 'pending' || (active.state?.membership as Record<string, unknown>)?.role === 'pending';
40
+ const isBlockedInActive = isDirectChannel(active) && Boolean(active.state?.membership?.blocked);
41
+ const isPendingActive = isPendingMember(active.state?.membership?.channel_role as string);
41
42
 
42
43
  if (!isBannedInActive && !isBlockedInActive && !isPendingActive) {
43
44
  active.markRead().catch(() => {
@@ -121,7 +122,10 @@ export function useChannelListUpdates(
121
122
  // we optimistically inject the membership so it instantly jumps into pending invites!
122
123
  // We DO NOT do this for channel.created, because in channel.created, event.member is the creator (owner).
123
124
  if (!forceWatch && event.type === 'member.added' && event.member && channelInstance.state) {
124
- channelInstance.state.membership = { ...channelInstance.state.membership, ...event.member } as unknown as Record<string, unknown>;
125
+ channelInstance.state.membership = {
126
+ ...channelInstance.state.membership,
127
+ ...event.member,
128
+ } as unknown as Record<string, unknown>;
125
129
  }
126
130
 
127
131
  // If the caller requested an explicit api call (e.g. for channel.created)
@@ -183,7 +187,9 @@ export function useChannelListUpdates(
183
187
  const eventCid =
184
188
  event.cid ||
185
189
  event.channel?.cid ||
186
- ((event as Record<string, unknown>).channel_id ? `${(event as Record<string, unknown>).channel_type}:${(event as Record<string, unknown>).channel_id}` : undefined);
190
+ ((event as Record<string, unknown>).channel_id
191
+ ? `${(event as Record<string, unknown>).channel_type}:${(event as Record<string, unknown>).channel_id}`
192
+ : undefined);
187
193
 
188
194
  if (eventCid && event.member) {
189
195
  const targetChannel = prev.find((c) => c.cid === eventCid);
@@ -201,6 +207,11 @@ export function useChannelListUpdates(
201
207
  }
202
208
  };
203
209
 
210
+ // --- channel.topic.enabled / disabled / created / channel.pinned / channel.unpinned: force re-render so ChannelList toggles Accordion UI, inserts new topic, or updates pinned channels ---
211
+ const handleGenericUpdate = (event: Event) => {
212
+ setChannels((prev) => [...prev]);
213
+ };
214
+
204
215
  const sub1 = client.on('message.new', handleNewMessage);
205
216
  const sub2 = client.on('channel.deleted', handleChannelDeleted);
206
217
  const sub3 = client.on('member.removed', handleMemberRemoved);
@@ -209,6 +220,12 @@ export function useChannelListUpdates(
209
220
  const sub6 = client.on('notification.added_to_channel', handleMemberAdded);
210
221
  const sub7 = client.on('notification.invite_rejected', handleMemberRemoved);
211
222
  const sub8 = client.on('notification.invite_accepted', handleMemberUpdated);
223
+ const sub9 = client.on('channel.topic.enabled', handleGenericUpdate);
224
+ const sub10 = client.on('channel.topic.disabled', handleGenericUpdate);
225
+ const sub11 = client.on('channel.topic.created', handleGenericUpdate);
226
+ const sub12 = client.on('channel.pinned', handleGenericUpdate);
227
+ const sub13 = client.on('channel.unpinned', handleGenericUpdate);
228
+ const sub14 = client.on('notification.invite_messaging_skipped', handleMemberUpdated);
212
229
 
213
230
  return () => {
214
231
  sub1.unsubscribe();
@@ -219,6 +236,12 @@ export function useChannelListUpdates(
219
236
  sub6.unsubscribe();
220
237
  sub7.unsubscribe();
221
238
  sub8.unsubscribe();
239
+ sub9.unsubscribe();
240
+ sub10.unsubscribe();
241
+ sub11.unsubscribe();
242
+ sub12.unsubscribe();
243
+ sub13.unsubscribe();
244
+ sub14.unsubscribe();
222
245
  };
223
246
  }, [client, setChannels, setActiveChannel]);
224
247
  }
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useCallback } from 'react';
2
2
  import type { Event } from '@ermis-network/ermis-chat-sdk';
3
3
  import { useChatClient } from './useChatClient';
4
+ import { isPendingMember } from '../channelRoleUtils';
4
5
 
5
6
  export type UseChannelMessagesOptions = {
6
7
  scrollToBottom: (smooth: boolean) => void;
@@ -100,9 +101,7 @@ export function useChannelMessages({
100
101
  .then(() => {
101
102
  syncMessages();
102
103
  scheduleScrollToBottom(false);
103
- const isPending =
104
- activeChannel.state?.membership?.channel_role === 'pending' ||
105
- (activeChannel.state?.membership as any)?.role === 'pending';
104
+ const isPending = isPendingMember(activeChannel.state?.membership?.channel_role as string);
106
105
  if (!isPending) {
107
106
  activeChannel.markRead().catch(() => {});
108
107
  }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
+ import { isDirectChannel } from '../channelTypeUtils';
3
4
 
4
5
  /**
5
6
  * Custom hook to abstract real-time row-level updates for a single channel.
@@ -9,7 +10,7 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
9
10
  // Track banned state for the current user in this channel
10
11
  const [isBannedInChannel, setIsBannedInChannel] = useState(() => Boolean(channel.state?.membership?.banned));
11
12
  const [isBlockedInChannel, setIsBlockedInChannel] = useState(() => {
12
- if (channel.type !== 'messaging') return false;
13
+ if (!isDirectChannel(channel)) return false;
13
14
  return Boolean(channel.state?.membership?.blocked);
14
15
  });
15
16
 
@@ -19,7 +20,7 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
19
20
  useEffect(() => {
20
21
  setIsBannedInChannel(Boolean(channel.state?.membership?.banned));
21
22
  setIsBlockedInChannel(
22
- channel.type === 'messaging' ? Boolean(channel.state?.membership?.blocked) : false
23
+ isDirectChannel(channel) ? Boolean(channel.state?.membership?.blocked) : false
23
24
  );
24
25
 
25
26
  const handleBanned = (event: any) => {
@@ -58,6 +59,9 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
58
59
  };
59
60
  const sub10 = channel.on('member.blocked', handleBlocked);
60
61
  const sub11 = channel.on('member.unblocked', handleUnblocked);
62
+ const sub12 = channel.on('channel.topic.created', handleUpdate);
63
+ const sub13 = channel.on('channel.pinned', handleUpdate);
64
+ const sub14 = channel.on('channel.unpinned', handleUpdate);
61
65
 
62
66
  return () => {
63
67
  sub1.unsubscribe();
@@ -71,6 +75,9 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
71
75
  sub9.unsubscribe();
72
76
  sub10.unsubscribe();
73
77
  sub11.unsubscribe();
78
+ sub12.unsubscribe();
79
+ sub13.unsubscribe();
80
+ sub14.unsubscribe();
74
81
  };
75
82
  }, [channel, currentUserId]);
76
83
 
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
2
2
  import { useChatClient } from './useChatClient';
3
3
  import { useChannelCapabilities } from './useChannelCapabilities';
4
4
  import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
5
+ import { isSignalMessage, isSystemMessage } from '../messageTypeUtils';
5
6
 
6
7
  export type MessageActionList = {
7
8
  canEdit: boolean;
@@ -23,7 +24,7 @@ export type MessageActionList = {
23
24
 
24
25
  export const useMessageActions = (message: FormatMessageResponse, isOwnMessage: boolean): MessageActionList => {
25
26
  const { activeChannel, client } = useChatClient();
26
- const { isTeamChannel: isTeam, isOwner, hasCapability } = useChannelCapabilities();
27
+ const { isGroupChannel: isTeam, isOwner, hasCapability } = useChannelCapabilities();
27
28
 
28
29
  // Only depend on the specific message fields we actually read
29
30
  const messageType = message.type;
@@ -50,18 +51,18 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
50
51
  };
51
52
  }
52
53
 
53
- const isSystem = messageType === 'system';
54
- const isSignal = messageType === 'signal';
54
+ const isSystem = isSystemMessage(message);
55
+ const isSignal = isSignalMessage(message);
55
56
  const isPinned = isPinnedFlag;
56
57
 
57
58
  const canEdit = !isSystem && !isSignal && isOwnMessage;
58
-
59
+
59
60
  // Delete for everyone:
60
61
  // + Team channel: only the owner can perform this action natively.
61
62
  // + Messaging channel: only own messages can be deleted
62
63
  const canDeleteForEveryoneTeam = isTeam && isOwner;
63
64
  const canDeleteForEveryoneMessaging = !isTeam && isOwnMessage;
64
-
65
+
65
66
  const canDelete = !isSystem && (canDeleteForEveryoneTeam || canDeleteForEveryoneMessaging);
66
67
  const canDeleteForMe = !isSystem;
67
68
  const canReply = !isSystem && !isSignal;
@@ -74,14 +75,27 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
74
75
  const hasCapDelete = !isTeam || isOwner || (isOwnMessage && hasCapability('delete-own-message'));
75
76
  // Apply the delete-own-message capability to the "delete for me" action for own messages
76
77
  const hasCapDeleteForMe = !isTeam || isOwner || !isOwnMessage || hasCapability('delete-own-message');
77
-
78
+
78
79
  const hasCapReply = hasCapability('send-reply');
79
80
  const hasCapQuote = hasCapability('quote-message');
80
81
  const hasCapPin = hasCapability('pin-message');
81
82
 
82
- return {
83
- canEdit, canDelete, canDeleteForMe, canReply, canQuote, canForward, canPin, canCopy, isPinned,
84
- hasCapEdit, hasCapDelete, hasCapDeleteForMe, hasCapPin, hasCapReply, hasCapQuote
83
+ return {
84
+ canEdit,
85
+ canDelete,
86
+ canDeleteForMe,
87
+ canReply,
88
+ canQuote,
89
+ canForward,
90
+ canPin,
91
+ canCopy,
92
+ isPinned,
93
+ hasCapEdit,
94
+ hasCapDelete,
95
+ hasCapDeleteForMe,
96
+ hasCapPin,
97
+ hasCapReply,
98
+ hasCapQuote,
85
99
  };
86
100
  }, [activeChannel, isTeam, isOwner, hasCapability, messageType, message.text, isPinnedFlag, isOwnMessage]); // Use capabilities from hook
87
101
  };