@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.
- package/dist/index.cjs +3802 -1772
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +836 -25
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +304 -1
- package/dist/index.d.ts +304 -1
- package/dist/index.mjs +3755 -1761
- 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/BannedOverlay.tsx +40 -0
- package/src/components/ChannelActions.tsx +233 -0
- package/src/components/ChannelHeader.tsx +126 -5
- package/src/components/ChannelInfo/ChannelInfo.tsx +128 -24
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +67 -28
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +90 -1
- package/src/components/ChannelInfo/EditChannelModal.tsx +5 -4
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
- package/src/components/ChannelList.tsx +514 -47
- package/src/components/ClosedTopicOverlay.tsx +38 -0
- 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 +21 -3
- package/src/components/MessageItem.tsx +10 -12
- package/src/components/MessageQuickReactions.tsx +3 -2
- package/src/components/MessageReactions.tsx +8 -3
- package/src/components/MessageRenderers.tsx +174 -54
- package/src/components/PendingOverlay.tsx +51 -0
- package/src/components/PinnedMessages.tsx +2 -1
- package/src/components/ReplyPreview.tsx +2 -1
- package/src/components/SkippedOverlay.tsx +36 -0
- package/src/components/TopicModal.tsx +189 -0
- package/src/components/UserPicker.tsx +1 -1
- package/src/components/VirtualMessageList.tsx +162 -47
- package/src/hooks/useBannedState.ts +27 -3
- package/src/hooks/useBlockedState.ts +3 -2
- package/src/hooks/useChannelCapabilities.ts +10 -8
- package/src/hooks/useChannelData.ts +1 -1
- package/src/hooks/useChannelListUpdates.ts +28 -5
- package/src/hooks/useChannelMessages.ts +2 -3
- package/src/hooks/useChannelRowUpdates.ts +9 -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 +67 -10
- package/src/messageTypeUtils.ts +64 -0
- package/src/styles/_channel-info.css +21 -0
- package/src/styles/_channel-list.css +276 -6
- package/src/styles/_media-lightbox.css +263 -0
- package/src/styles/_message-bubble.css +170 -13
- package/src/styles/_message-input.css +24 -0
- package/src/styles/_message-list.css +76 -6
- package/src/styles/_message-quick-reactions.css +5 -0
- package/src/styles/_message-reactions.css +7 -0
- package/src/styles/_topic-modal.css +154 -0
- package/src/styles/index.css +2 -0
- 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
|
|
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
|
|
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
|
|
138
|
-
const action =
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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${
|
|
361
|
-
{
|
|
501
|
+
<div ref={containerRef} className={`ermis-message-list${className ? ` ${className}` : ''}`}>
|
|
502
|
+
{showPinnedMessages && <PinnedMessagesComponent onClickMessage={scrollToMessage} AvatarComponent={AvatarComponent} />}
|
|
362
503
|
|
|
363
|
-
{messages.length === 0 &&
|
|
504
|
+
{messages.length === 0 && (
|
|
364
505
|
EmptyStateIndicator === DefaultEmpty
|
|
365
506
|
? <DefaultEmpty title={emptyTitle} subtitle={emptySubtitle} />
|
|
366
507
|
: <EmptyStateIndicator />
|
|
367
508
|
)}
|
|
368
509
|
|
|
369
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
528
|
+
{showTypingIndicator && <TypingIndicatorComponent />}
|
|
414
529
|
|
|
415
530
|
{/* Jump to latest button */}
|
|
416
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,21 +19,21 @@ export const useChannelCapabilities = () => {
|
|
|
17
19
|
}, [activeChannel]);
|
|
18
20
|
|
|
19
21
|
const currentUserId = client?.userID || '';
|
|
20
|
-
const
|
|
22
|
+
const isGroupCh = isGroupChannel(activeChannel);
|
|
21
23
|
const role = (activeChannel?.state as any)?.members?.[currentUserId]?.channel_role;
|
|
22
24
|
|
|
23
|
-
const isOwner = role ===
|
|
24
|
-
const isModerator = role ===
|
|
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[] =
|
|
29
|
+
const capabilities: string[] = isGroupCh ? (activeChannel?.data as any)?.member_capabilities || [] : [];
|
|
28
30
|
|
|
29
31
|
const hasCapability = useCallback((cap: string) => {
|
|
30
|
-
return !
|
|
31
|
-
}, [
|
|
32
|
+
return !isGroupCh || isOwnerOrModerator || capabilities.includes(cap);
|
|
33
|
+
}, [isGroupCh, isOwnerOrModerator, capabilities, updateTick]); // React to updateTick correctly
|
|
32
34
|
|
|
33
35
|
return {
|
|
34
|
-
|
|
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
|
|
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 = {
|
|
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
|
|
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
|
|
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) => {
|
|
@@ -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 {
|
|
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
|
};
|