@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.
- package/dist/index.cjs +2780 -1852
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +364 -8
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +160 -1
- package/dist/index.d.ts +160 -1
- package/dist/index.mjs +2780 -1884
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/channelRoleUtils.ts +73 -0
- package/src/channelTypeUtils.ts +46 -0
- package/src/components/Avatar.tsx +57 -31
- package/src/components/ChannelActions.tsx +13 -11
- package/src/components/ChannelHeader.tsx +89 -4
- package/src/components/ChannelInfo/ChannelInfo.tsx +23 -17
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +57 -26
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +4 -2
- package/src/components/ChannelInfo/EditChannelModal.tsx +2 -1
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
- package/src/components/ChannelList.tsx +59 -14
- package/src/components/CreateChannelModal.tsx +53 -16
- package/src/components/EditPreview.tsx +2 -1
- package/src/components/ForwardMessageModal.tsx +2 -1
- package/src/components/MediaLightbox.tsx +314 -0
- package/src/components/MessageInput.tsx +3 -2
- package/src/components/MessageItem.tsx +2 -1
- package/src/components/MessageRenderers.tsx +168 -46
- package/src/components/PendingOverlay.tsx +11 -1
- package/src/components/PinnedMessages.tsx +2 -1
- package/src/components/ReplyPreview.tsx +2 -1
- package/src/components/SkippedOverlay.tsx +36 -0
- package/src/components/UserPicker.tsx +1 -1
- package/src/components/VirtualMessageList.tsx +91 -7
- package/src/hooks/useBlockedState.ts +3 -2
- package/src/hooks/useChannelCapabilities.ts +10 -12
- package/src/hooks/useChannelListUpdates.ts +6 -4
- package/src/hooks/useChannelMessages.ts +2 -3
- package/src/hooks/useChannelRowUpdates.ts +3 -2
- package/src/hooks/useMessageActions.ts +23 -9
- package/src/hooks/useOnlineStatus.ts +71 -0
- package/src/hooks/useOnlineUsers.ts +115 -0
- package/src/hooks/usePendingState.ts +8 -3
- package/src/index.ts +61 -9
- package/src/messageTypeUtils.ts +64 -0
- package/src/styles/_channel-list.css +59 -0
- package/src/styles/_media-lightbox.css +263 -0
- package/src/styles/_message-bubble.css +99 -8
- package/src/styles/_message-list.css +25 -0
- package/src/styles/index.css +1 -0
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ===
|
|
26
|
-
const isModerator = role ===
|
|
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[] =
|
|
29
|
+
const capabilities: string[] = isGroupCh ? (activeChannel?.data as any)?.member_capabilities || [] : [];
|
|
30
30
|
|
|
31
31
|
const hasCapability = useCallback((cap: string) => {
|
|
32
|
-
return !
|
|
33
|
-
}, [
|
|
32
|
+
return !isGroupCh || isOwnerOrModerator || capabilities.includes(cap);
|
|
33
|
+
}, [isGroupCh, isOwnerOrModerator, capabilities, updateTick]); // React to updateTick correctly
|
|
34
34
|
|
|
35
35
|
return {
|
|
36
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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 =
|
|
54
|
-
const isSignal =
|
|
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,
|
|
84
|
-
|
|
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
|
+
}
|