@ermis-network/ermis-chat-react 1.0.7 → 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 (50) hide show
  1. package/dist/index.cjs +2780 -1852
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +364 -8
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +160 -1
  6. package/dist/index.d.ts +160 -1
  7. package/dist/index.mjs +2780 -1884
  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/ChannelActions.tsx +13 -11
  14. package/src/components/ChannelHeader.tsx +89 -4
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +23 -17
  16. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +57 -26
  17. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +4 -2
  18. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -1
  19. package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
  20. package/src/components/ChannelList.tsx +59 -14
  21. package/src/components/CreateChannelModal.tsx +53 -16
  22. package/src/components/EditPreview.tsx +2 -1
  23. package/src/components/ForwardMessageModal.tsx +2 -1
  24. package/src/components/MediaLightbox.tsx +314 -0
  25. package/src/components/MessageInput.tsx +3 -2
  26. package/src/components/MessageItem.tsx +2 -1
  27. package/src/components/MessageRenderers.tsx +168 -46
  28. package/src/components/PendingOverlay.tsx +11 -1
  29. package/src/components/PinnedMessages.tsx +2 -1
  30. package/src/components/ReplyPreview.tsx +2 -1
  31. package/src/components/SkippedOverlay.tsx +36 -0
  32. package/src/components/UserPicker.tsx +1 -1
  33. package/src/components/VirtualMessageList.tsx +91 -7
  34. package/src/hooks/useBlockedState.ts +3 -2
  35. package/src/hooks/useChannelCapabilities.ts +10 -12
  36. package/src/hooks/useChannelListUpdates.ts +6 -4
  37. package/src/hooks/useChannelMessages.ts +2 -3
  38. package/src/hooks/useChannelRowUpdates.ts +3 -2
  39. package/src/hooks/useMessageActions.ts +23 -9
  40. package/src/hooks/useOnlineStatus.ts +71 -0
  41. package/src/hooks/useOnlineUsers.ts +115 -0
  42. package/src/hooks/usePendingState.ts +8 -3
  43. package/src/index.ts +61 -9
  44. package/src/messageTypeUtils.ts +64 -0
  45. package/src/styles/_channel-list.css +59 -0
  46. package/src/styles/_media-lightbox.css +263 -0
  47. package/src/styles/_message-bubble.css +99 -8
  48. package/src/styles/_message-list.css +25 -0
  49. package/src/styles/index.css +1 -0
  50. package/src/types.ts +46 -0
@@ -10,6 +10,10 @@ export type PendingOverlayProps = {
10
10
  rejectLabel: string;
11
11
  onAccept: () => void;
12
12
  onReject: () => void;
13
+ /** Label for the skip button (direct messaging channels) */
14
+ skipLabel?: string;
15
+ /** Handler for the skip action (direct messaging channels) */
16
+ onSkip?: () => void;
13
17
  AvatarComponent: React.ComponentType<AvatarProps>;
14
18
  };
15
19
 
@@ -22,6 +26,8 @@ export const PendingOverlay: React.FC<PendingOverlayProps> = React.memo(({
22
26
  rejectLabel,
23
27
  onAccept,
24
28
  onReject,
29
+ skipLabel,
30
+ onSkip,
25
31
  AvatarComponent,
26
32
  }) => (
27
33
  <div className="ermis-message-list__pending-overlay">
@@ -31,7 +37,11 @@ export const PendingOverlay: React.FC<PendingOverlayProps> = React.memo(({
31
37
  <div className="ermis-message-list__pending-channel-name">{channelName}</div>
32
38
  <span className="ermis-message-list__pending-overlay-subtitle">{subtitle}</span>
33
39
  <div className="ermis-message-list__pending-actions">
34
- <button className="ermis-message-list__reject-btn" onClick={onReject}>{rejectLabel}</button>
40
+ {onSkip ? (
41
+ <button className="ermis-message-list__reject-btn" onClick={onSkip}>{skipLabel || 'Skip'}</button>
42
+ ) : (
43
+ <button className="ermis-message-list__reject-btn" onClick={onReject}>{rejectLabel}</button>
44
+ )}
35
45
  <button className="ermis-message-list__accept-btn" onClick={onAccept}>{acceptLabel}</button>
36
46
  </div>
37
47
  </div>
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useEffect, useMemo, useCallback } from 'react';
2
2
  import { useChatClient } from '../hooks/useChatClient';
3
3
  import { Avatar } from './Avatar';
4
+ import { isStickerMessage } from '../messageTypeUtils';
4
5
  import { replaceMentionsForPreview, buildUserMap } from '../utils';
5
6
  import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
6
7
  import type { PinnedMessageItemProps, PinnedMessagesProps } from '../types';
@@ -25,7 +26,7 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
25
26
  }, [activeChannel?.state]);
26
27
 
27
28
  let previewText = message.text || '';
28
- const isSticker = message.type === 'sticker';
29
+ const isSticker = isStickerMessage(message);
29
30
 
30
31
  if (!previewText && hasAttachments) {
31
32
  const firstAttach = message.attachments![0];
@@ -1,6 +1,7 @@
1
1
  import React, { useMemo } from 'react';
2
2
  import { useChatClient } from '../hooks/useChatClient';
3
3
  import { replaceMentionsForPreview, buildUserMap } from '../utils';
4
+ import { isStickerMessage } from '../messageTypeUtils';
4
5
  import type { ReplyPreviewProps } from '../types';
5
6
 
6
7
  const MAX_PREVIEW_LENGTH = 120;
@@ -53,7 +54,7 @@ export const ReplyPreview: React.FC<ReplyPreviewProps> = React.memo(({
53
54
  const formattedText = useMemo(() => replaceMentionsForPreview(rawText, message, userMap), [rawText, message, userMap]);
54
55
  const hasText = !!formattedText.trim();
55
56
  const hasAttachments = message.attachments && message.attachments.length > 0;
56
- const isSticker = message.type === 'sticker';
57
+ const isSticker = isStickerMessage(message);
57
58
  const attachmentSummary = hasAttachments ? getAttachmentSummary(message.attachments!) : '';
58
59
 
59
60
  // Build preview content
@@ -0,0 +1,36 @@
1
+ import React from 'react';
2
+ import type { AvatarProps } from '../types';
3
+
4
+ export type SkippedOverlayProps = {
5
+ channelImage?: string;
6
+ channelName?: string;
7
+ title: string;
8
+ subtitle: string;
9
+ acceptLabel: string;
10
+ onAccept: () => void;
11
+ AvatarComponent: React.ComponentType<AvatarProps>;
12
+ };
13
+
14
+ export const SkippedOverlay: React.FC<SkippedOverlayProps> = React.memo(({
15
+ channelImage,
16
+ channelName,
17
+ title,
18
+ subtitle,
19
+ acceptLabel,
20
+ onAccept,
21
+ AvatarComponent,
22
+ }) => (
23
+ <div className="ermis-message-list__pending-overlay">
24
+ <div className="ermis-message-list__pending-card">
25
+ <AvatarComponent image={channelImage} name={channelName} size={64} className="ermis-message-list__pending-avatar" />
26
+ <span className="ermis-message-list__pending-overlay-title">{title}</span>
27
+ <div className="ermis-message-list__pending-channel-name">{channelName}</div>
28
+ <span className="ermis-message-list__pending-overlay-subtitle">{subtitle}</span>
29
+ <div className="ermis-message-list__pending-actions">
30
+ <button className="ermis-message-list__accept-btn" onClick={onAccept}>{acceptLabel}</button>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ ));
35
+
36
+ SkippedOverlay.displayName = 'SkippedOverlay';
@@ -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,
@@ -22,6 +24,7 @@ import { PinnedMessages } from './PinnedMessages';
22
24
  import { ReadReceipts } from './ReadReceipts';
23
25
  import { TypingIndicator } from './TypingIndicator';
24
26
  import { PendingOverlay } from './PendingOverlay';
27
+ import { SkippedOverlay } from './SkippedOverlay';
25
28
  import { BannedOverlay } from './BannedOverlay';
26
29
  import { ClosedTopicOverlay } from './ClosedTopicOverlay';
27
30
  import type { MessageListProps } from '../types';
@@ -78,6 +81,23 @@ const DefaultBubble: React.FC<MessageBubbleProps> = React.memo(({
78
81
  ));
79
82
  (DefaultBubble as any).displayName = 'DefaultBubble';
80
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
+
81
101
  /* ----------------------------------------------------------
82
102
  VirtualMessageList
83
103
  ---------------------------------------------------------- */
@@ -115,14 +135,26 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
115
135
  pendingOverlaySubtitle = 'Accept the invitation to view messages and interact',
116
136
  pendingAcceptLabel = 'Accept',
117
137
  pendingRejectLabel = 'Reject',
138
+ pendingSkipLabel = 'Skip',
139
+ skippedOverlayTitle = 'You skipped this conversation',
140
+ skippedOverlaySubtitle = 'Accept the invitation to start chatting',
141
+ skippedAcceptLabel = 'Accept',
118
142
  closedTopicOverlayTitle = 'This topic has been closed',
119
143
  closedTopicOverlaySubtitle = 'You can no longer read or send messages in this topic.',
120
144
  closedTopicReopenLabel = 'Reopen Topic',
145
+ PendingInviteeNotificationComponent = DefaultPendingInviteeNotification,
146
+ pendingInviteeLabel,
121
147
  }) => {
122
- const { client, messages, readState, activeChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
148
+ const { client, messages, readState, activeChannel, setActiveChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
123
149
  const { isBanned } = useBannedState(activeChannel, client.userID);
124
150
  const { isBlocked } = useBlockedState(activeChannel, client.userID);
125
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
+
126
158
  const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
127
159
  const parentCid = activeChannel?.data?.parent_cid as string | undefined;
128
160
  const parentChannel = parentCid && client ? client.activeChannels[parentCid] : undefined;
@@ -134,7 +166,20 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
134
166
  messagesRef.current = messages;
135
167
  const currentUserId = client.userID;
136
168
  const currentUserRole = currentUserId ? activeChannel?.state?.members?.[currentUserId]?.channel_role : undefined;
137
- const canManageTopic = currentUserRole === 'owner' || currentUserRole === 'moder';
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]);
138
183
 
139
184
  // Ref to scope DOM queries (safe for multiple instances)
140
185
  const containerRef = useRef<HTMLDivElement>(null);
@@ -145,7 +190,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
145
190
  const handleAcceptInvite = useCallback(async () => {
146
191
  if (!activeChannel) return;
147
192
  try {
148
- const isPublicTeamOrMeeting = (activeChannel.type === 'team' || activeChannel.type === 'meeting') && Boolean(activeChannel.data?.public);
193
+ const isPublicTeamOrMeeting = isPublicGroupChannel(activeChannel);
149
194
  const action = isPublicTeamOrMeeting ? 'join' : 'accept';
150
195
  await activeChannel.acceptInvite(action);
151
196
  } catch (e: any) {
@@ -153,10 +198,25 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
153
198
  }
154
199
  }, [activeChannel]);
155
200
 
156
- const handleRejectInvite = useCallback(() => {
201
+ const handleRejectInvite = useCallback(async () => {
157
202
  if (!activeChannel) return;
158
- activeChannel.rejectInvite().catch((e: any) => console.error('Error rejecting invite', e));
159
- }, [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]);
160
220
 
161
221
  const scrollToBottom = useCallback((smooth = false, attempts = 0) => {
162
222
  const handle = vlistRef.current;
@@ -223,7 +283,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
223
283
  }, [setHasMore, setHasNewer]),
224
284
  });
225
285
 
226
- const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked);
286
+ const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked || isSkipped);
227
287
  const prevOverlayRef = useRef(hasOverlay);
228
288
 
229
289
  useEffect(() => {
@@ -393,6 +453,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
393
453
  }
394
454
 
395
455
  if (isPending) {
456
+ const isDirect = activeChannel ? isDirectChannel(activeChannel) : false;
396
457
  return (
397
458
  <PendingOverlay
398
459
  channelImage={channelImage}
@@ -403,6 +464,22 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
403
464
  acceptLabel={pendingAcceptLabel}
404
465
  onReject={handleRejectInvite}
405
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}
406
483
  AvatarComponent={AvatarComponent}
407
484
  />
408
485
  );
@@ -430,6 +507,13 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
430
507
  : <EmptyStateIndicator />
431
508
  )}
432
509
 
510
+ {pendingInviteeName && (
511
+ <PendingInviteeNotificationComponent
512
+ inviteeName={pendingInviteeName}
513
+ label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
514
+ />
515
+ )}
516
+
433
517
  <VList
434
518
  key={activeChannel?.cid || 'empty'}
435
519
  ref={vlistRef}
@@ -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,25 +19,21 @@ export const useChannelCapabilities = () => {
17
19
  }, [activeChannel]);
18
20
 
19
21
  const currentUserId = client?.userID || '';
20
- const isTeamChannel = activeChannel?.type === 'team';
21
- const isMeetingChannel = activeChannel?.type === 'meeting';
22
- const isTeamOrMeetingChannel = isTeamChannel || isMeetingChannel;
22
+ const isGroupCh = isGroupChannel(activeChannel);
23
23
  const role = (activeChannel?.state as any)?.members?.[currentUserId]?.channel_role;
24
24
 
25
- const isOwner = role === 'owner' || activeChannel?.data?.created_by_id === currentUserId;
26
- const isModerator = role === 'moder';
27
- 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);
28
28
 
29
- const capabilities: string[] = isTeamOrMeetingChannel ? (activeChannel?.data as any)?.member_capabilities || [] : [];
29
+ const capabilities: string[] = isGroupCh ? (activeChannel?.data as any)?.member_capabilities || [] : [];
30
30
 
31
31
  const hasCapability = useCallback((cap: string) => {
32
- return !isTeamOrMeetingChannel || isOwnerOrModerator || capabilities.includes(cap);
33
- }, [isTeamOrMeetingChannel, isOwnerOrModerator, capabilities, updateTick]); // React to updateTick correctly
32
+ return !isGroupCh || isOwnerOrModerator || capabilities.includes(cap);
33
+ }, [isGroupCh, isOwnerOrModerator, capabilities, updateTick]); // React to updateTick correctly
34
34
 
35
35
  return {
36
- isTeamChannel,
37
- isMeetingChannel,
38
- isTeamOrMeetingChannel,
36
+ isGroupChannel: isGroupCh,
39
37
  isOwner,
40
38
  isModerator,
41
39
  isOwnerOrModerator,
@@ -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,10 +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' ||
41
- (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);
42
42
 
43
43
  if (!isBannedInActive && !isBlockedInActive && !isPendingActive) {
44
44
  active.markRead().catch(() => {
@@ -225,6 +225,7 @@ export function useChannelListUpdates(
225
225
  const sub11 = client.on('channel.topic.created', handleGenericUpdate);
226
226
  const sub12 = client.on('channel.pinned', handleGenericUpdate);
227
227
  const sub13 = client.on('channel.unpinned', handleGenericUpdate);
228
+ const sub14 = client.on('notification.invite_messaging_skipped', handleMemberUpdated);
228
229
 
229
230
  return () => {
230
231
  sub1.unsubscribe();
@@ -240,6 +241,7 @@ export function useChannelListUpdates(
240
241
  sub11.unsubscribe();
241
242
  sub12.unsubscribe();
242
243
  sub13.unsubscribe();
244
+ sub14.unsubscribe();
243
245
  };
244
246
  }, [client, setChannels, setActiveChannel]);
245
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) => {
@@ -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 { isTeamOrMeetingChannel: 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
  };
@@ -0,0 +1,71 @@
1
+ import { useState, useEffect, useMemo } from 'react';
2
+ import type { Channel, Event } from '@ermis-network/ermis-chat-sdk';
3
+ import { useChatClient } from './useChatClient';
4
+ import { isFriendChannel } from '../channelRoleUtils';
5
+
6
+ export type OnlineStatus = 'online' | 'offline' | 'unknown';
7
+
8
+ /**
9
+ * Hook that returns the online/offline status of a specific user.
10
+ *
11
+ * The status is determined by checking `channel.state.watchers` on the
12
+ * "friend" channel (direct channel where both members have `owner` role).
13
+ * Real-time updates are received via `user.watching.start` and
14
+ * `user.watching.stop` WebSocket events on that channel.
15
+ *
16
+ * Returns `'unknown'` if the user is not a friend (no qualifying channel found).
17
+ *
18
+ * @param userId – The user ID to check the online status of.
19
+ * @param channels – The full list of loaded channels (from ChannelList).
20
+ */
21
+ export function useOnlineStatus(
22
+ userId: string | undefined,
23
+ channels: Channel[],
24
+ ): OnlineStatus {
25
+ const { client } = useChatClient();
26
+ const currentUserId = client.userID;
27
+
28
+ // Find the friend channel for this user — memoized to avoid re-scans.
29
+ const friendChannel = useMemo(() => {
30
+ if (!userId || !currentUserId || userId === currentUserId) return null;
31
+ return channels.find((ch) => isFriendChannel(ch, userId, currentUserId)) || null;
32
+ }, [channels, userId, currentUserId]);
33
+
34
+ // Derive initial status from watchers state.
35
+ const [status, setStatus] = useState<OnlineStatus>(() => {
36
+ if (!friendChannel || !userId) return 'unknown';
37
+ return friendChannel.state?.watchers?.[userId] ? 'online' : 'offline';
38
+ });
39
+
40
+ useEffect(() => {
41
+ if (!friendChannel || !userId) {
42
+ setStatus('unknown');
43
+ return;
44
+ }
45
+
46
+ // Sync initial state (in case friendChannel ref changed).
47
+ setStatus(friendChannel.state?.watchers?.[userId] ? 'online' : 'offline');
48
+
49
+ const handleWatchingStart = (event: Event) => {
50
+ if (event.user?.id === userId) {
51
+ setStatus('online');
52
+ }
53
+ };
54
+
55
+ const handleWatchingStop = (event: Event) => {
56
+ if (event.user?.id === userId) {
57
+ setStatus('offline');
58
+ }
59
+ };
60
+
61
+ const sub1 = friendChannel.on('user.watching.start', handleWatchingStart);
62
+ const sub2 = friendChannel.on('user.watching.stop', handleWatchingStop);
63
+
64
+ return () => {
65
+ sub1.unsubscribe();
66
+ sub2.unsubscribe();
67
+ };
68
+ }, [friendChannel, userId]);
69
+
70
+ return status;
71
+ }