@ermis-network/ermis-chat-react 1.0.0
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 +6593 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +3375 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +1138 -0
- package/dist/index.d.ts +1138 -0
- package/dist/index.mjs +6500 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +42 -0
- package/src/components/Avatar.tsx +102 -0
- package/src/components/Channel.tsx +77 -0
- package/src/components/ChannelHeader.tsx +85 -0
- package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
- package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
- package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
- package/src/components/ChannelInfo/FileListItem.tsx +49 -0
- package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
- package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
- package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
- package/src/components/ChannelInfo/States.tsx +36 -0
- package/src/components/ChannelInfo/index.ts +10 -0
- package/src/components/ChannelInfo/utils.tsx +49 -0
- package/src/components/ChannelList.tsx +395 -0
- package/src/components/Dropdown.tsx +120 -0
- package/src/components/EditPreview.tsx +102 -0
- package/src/components/FilesPreview.tsx +108 -0
- package/src/components/ForwardMessageModal.tsx +234 -0
- package/src/components/MentionSuggestions.tsx +59 -0
- package/src/components/MessageActionsBox.tsx +186 -0
- package/src/components/MessageInput.tsx +513 -0
- package/src/components/MessageInputDefaults.tsx +50 -0
- package/src/components/MessageItem.tsx +218 -0
- package/src/components/MessageQuickReactions.tsx +73 -0
- package/src/components/MessageReactions.tsx +59 -0
- package/src/components/MessageRenderers.tsx +565 -0
- package/src/components/Modal.tsx +58 -0
- package/src/components/Panel.tsx +64 -0
- package/src/components/PinnedMessages.tsx +165 -0
- package/src/components/QuotedMessagePreview.tsx +55 -0
- package/src/components/ReadReceipts.tsx +80 -0
- package/src/components/ReplyPreview.tsx +98 -0
- package/src/components/TypingIndicator.tsx +57 -0
- package/src/components/VirtualMessageList.tsx +425 -0
- package/src/context/ChatProvider.tsx +73 -0
- package/src/hooks/useBannedState.ts +48 -0
- package/src/hooks/useBlockedState.ts +55 -0
- package/src/hooks/useChannel.ts +18 -0
- package/src/hooks/useChannelCapabilities.ts +42 -0
- package/src/hooks/useChannelData.ts +55 -0
- package/src/hooks/useChannelListUpdates.ts +224 -0
- package/src/hooks/useChannelMessages.ts +159 -0
- package/src/hooks/useChannelRowUpdates.ts +78 -0
- package/src/hooks/useChatClient.ts +11 -0
- package/src/hooks/useEmojiPicker.ts +53 -0
- package/src/hooks/useFileUpload.ts +128 -0
- package/src/hooks/useLoadMessages.ts +178 -0
- package/src/hooks/useMentions.ts +287 -0
- package/src/hooks/useMessageActions.ts +87 -0
- package/src/hooks/useMessageSend.ts +164 -0
- package/src/hooks/usePendingState.ts +63 -0
- package/src/hooks/useScrollToMessage.ts +155 -0
- package/src/hooks/useTypingIndicator.ts +86 -0
- package/src/index.ts +129 -0
- package/src/styles/_add-member-modal.css +122 -0
- package/src/styles/_base.css +32 -0
- package/src/styles/_channel-info.css +941 -0
- package/src/styles/_channel-list.css +217 -0
- package/src/styles/_dropdown.css +69 -0
- package/src/styles/_forward-modal.css +191 -0
- package/src/styles/_mentions.css +102 -0
- package/src/styles/_message-actions.css +61 -0
- package/src/styles/_message-bubble.css +656 -0
- package/src/styles/_message-input.css +389 -0
- package/src/styles/_message-list.css +416 -0
- package/src/styles/_message-quick-reactions.css +62 -0
- package/src/styles/_message-reactions.css +67 -0
- package/src/styles/_modal.css +113 -0
- package/src/styles/_panel.css +69 -0
- package/src/styles/_pinned-messages.css +140 -0
- package/src/styles/_search-panel.css +219 -0
- package/src/styles/_tokens.css +92 -0
- package/src/styles/_typing-indicator.css +59 -0
- package/src/styles/index.css +24 -0
- package/src/types.ts +955 -0
- package/src/utils.ts +242 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
|
2
|
+
import { VList, type VListHandle } from 'virtua';
|
|
3
|
+
import type { MessageLabel } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
5
|
+
import { useBannedState } from '../hooks/useBannedState';
|
|
6
|
+
import { useBlockedState } from '../hooks/useBlockedState';
|
|
7
|
+
import { usePendingState } from '../hooks/usePendingState';
|
|
8
|
+
import { useLoadMessages } from '../hooks/useLoadMessages';
|
|
9
|
+
import { useScrollToMessage } from '../hooks/useScrollToMessage';
|
|
10
|
+
import { useChannelMessages } from '../hooks/useChannelMessages';
|
|
11
|
+
import { useChannelProfile } from '../hooks/useChannelData';
|
|
12
|
+
import { Avatar } from './Avatar';
|
|
13
|
+
import { MessageItem } from './MessageItem';
|
|
14
|
+
import { SystemMessageItem } from './MessageItem';
|
|
15
|
+
import {
|
|
16
|
+
defaultMessageRenderers,
|
|
17
|
+
type MessageBubbleProps,
|
|
18
|
+
} from './MessageRenderers';
|
|
19
|
+
import { getDateKey, formatDateLabel, getMessageUserId, formatReadTimestamp } from '../utils';
|
|
20
|
+
import { QuotedMessagePreview } from './QuotedMessagePreview';
|
|
21
|
+
import { PinnedMessages } from './PinnedMessages';
|
|
22
|
+
import { ReadReceipts } from './ReadReceipts';
|
|
23
|
+
import { TypingIndicator } from './TypingIndicator';
|
|
24
|
+
import type { MessageListProps } from '../types';
|
|
25
|
+
|
|
26
|
+
/* ----------------------------------------------------------
|
|
27
|
+
Internal sub-components
|
|
28
|
+
---------------------------------------------------------- */
|
|
29
|
+
const DefaultDateSeparator: React.FC<{ label: string }> = React.memo(({ label }) => (
|
|
30
|
+
<div className="ermis-message-list__date-separator">
|
|
31
|
+
<div className="ermis-message-list__date-separator-line" />
|
|
32
|
+
<span className="ermis-message-list__date-separator-label">{label}</span>
|
|
33
|
+
<div className="ermis-message-list__date-separator-line" />
|
|
34
|
+
</div>
|
|
35
|
+
));
|
|
36
|
+
(DefaultDateSeparator as any).displayName = 'DefaultDateSeparator';
|
|
37
|
+
|
|
38
|
+
const DefaultJumpToLatest = React.memo(({ onClick, label = '↓ Jump to latest' }: any) => (
|
|
39
|
+
<button className="ermis-message-list__jump-latest" onClick={onClick}>
|
|
40
|
+
{label}
|
|
41
|
+
</button>
|
|
42
|
+
));
|
|
43
|
+
DefaultJumpToLatest.displayName = 'DefaultJumpToLatest';
|
|
44
|
+
|
|
45
|
+
const DefaultEmpty = React.memo(({ title = 'No messages yet', subtitle = 'Send a message to start the conversation' }: any) => (
|
|
46
|
+
<div className="ermis-message-list__empty">
|
|
47
|
+
<div className="ermis-message-list__empty-icon">
|
|
48
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
49
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
50
|
+
</svg>
|
|
51
|
+
</div>
|
|
52
|
+
<span className="ermis-message-list__empty-title">{title}</span>
|
|
53
|
+
<span className="ermis-message-list__empty-subtitle">{subtitle}</span>
|
|
54
|
+
</div>
|
|
55
|
+
));
|
|
56
|
+
DefaultEmpty.displayName = 'DefaultEmpty';
|
|
57
|
+
|
|
58
|
+
const DefaultBubble: React.FC<MessageBubbleProps> = React.memo(({
|
|
59
|
+
isOwnMessage,
|
|
60
|
+
message,
|
|
61
|
+
children,
|
|
62
|
+
}) => (
|
|
63
|
+
<div
|
|
64
|
+
className={`ermis-message-bubble ${isOwnMessage ? 'ermis-message-bubble--own' : 'ermis-message-bubble--other'}`}
|
|
65
|
+
>
|
|
66
|
+
{message?.pinned && (
|
|
67
|
+
<div className={`ermis-message-list__pinned-indicator ${isOwnMessage ? 'ermis-message-list__pinned-indicator--own' : 'ermis-message-list__pinned-indicator--other'}`}>
|
|
68
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
|
69
|
+
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
|
|
70
|
+
</svg>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
{children}
|
|
74
|
+
</div>
|
|
75
|
+
));
|
|
76
|
+
(DefaultBubble as any).displayName = 'DefaultBubble';
|
|
77
|
+
|
|
78
|
+
/* ----------------------------------------------------------
|
|
79
|
+
VirtualMessageList
|
|
80
|
+
---------------------------------------------------------- */
|
|
81
|
+
export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
82
|
+
renderMessage,
|
|
83
|
+
className,
|
|
84
|
+
EmptyStateIndicator = DefaultEmpty,
|
|
85
|
+
AvatarComponent = Avatar,
|
|
86
|
+
MessageBubble = DefaultBubble,
|
|
87
|
+
messageRenderers: customRenderers,
|
|
88
|
+
loadMoreLimit = 25,
|
|
89
|
+
DateSeparatorComponent = DefaultDateSeparator,
|
|
90
|
+
MessageItemComponent = MessageItem,
|
|
91
|
+
SystemMessageItemComponent = SystemMessageItem,
|
|
92
|
+
JumpToLatestButton = DefaultJumpToLatest,
|
|
93
|
+
QuotedMessagePreviewComponent = QuotedMessagePreview,
|
|
94
|
+
MessageActionsBoxComponent,
|
|
95
|
+
showPinnedMessages = true,
|
|
96
|
+
PinnedMessagesComponent = PinnedMessages,
|
|
97
|
+
showReadReceipts = true,
|
|
98
|
+
ReadReceiptsComponent = ReadReceipts,
|
|
99
|
+
ReadReceiptsTooltipComponent,
|
|
100
|
+
readReceiptsMaxAvatars = 5,
|
|
101
|
+
showTypingIndicator = true,
|
|
102
|
+
TypingIndicatorComponent = TypingIndicator,
|
|
103
|
+
MessageReactionsComponent,
|
|
104
|
+
emptyTitle = 'No messages yet',
|
|
105
|
+
emptySubtitle = 'Send a message to start the conversation',
|
|
106
|
+
jumpToLatestLabel = '↓ Jump to latest',
|
|
107
|
+
bannedOverlayTitle = 'You have been blocked from this channel',
|
|
108
|
+
bannedOverlaySubtitle = 'You can no longer read or send messages here',
|
|
109
|
+
blockedOverlayTitle = 'You have blocked this user',
|
|
110
|
+
blockedOverlaySubtitle = 'Unblock to continue the conversation',
|
|
111
|
+
pendingOverlayTitle = 'You are invited to this channel',
|
|
112
|
+
pendingOverlaySubtitle = 'Accept the invitation to view messages and interact',
|
|
113
|
+
pendingAcceptLabel = 'Accept',
|
|
114
|
+
pendingRejectLabel = 'Reject',
|
|
115
|
+
}) => {
|
|
116
|
+
const { client, messages, readState, activeChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
|
|
117
|
+
const { isBanned } = useBannedState(activeChannel, client.userID);
|
|
118
|
+
const { isBlocked } = useBlockedState(activeChannel, client.userID);
|
|
119
|
+
const { isPending } = usePendingState(activeChannel, client.userID);
|
|
120
|
+
|
|
121
|
+
const { channelName, channelImage } = useChannelProfile(activeChannel);
|
|
122
|
+
|
|
123
|
+
const vlistRef = useRef<VListHandle>(null);
|
|
124
|
+
const messagesRef = useRef(messages);
|
|
125
|
+
messagesRef.current = messages;
|
|
126
|
+
const currentUserId = client.userID;
|
|
127
|
+
|
|
128
|
+
// Ref to scope DOM queries (safe for multiple instances)
|
|
129
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
130
|
+
const getVListElement = useCallback((): HTMLElement | null => {
|
|
131
|
+
return containerRef.current?.querySelector('.ermis-message-list__vlist') ?? null;
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const handleAcceptInvite = useCallback(async () => {
|
|
135
|
+
if (!activeChannel) return;
|
|
136
|
+
try {
|
|
137
|
+
const isPublicTeam = activeChannel.type === 'team' && Boolean(activeChannel.data?.public);
|
|
138
|
+
const action = isPublicTeam ? 'join' : 'accept';
|
|
139
|
+
await activeChannel.acceptInvite(action);
|
|
140
|
+
} catch (e: any) {
|
|
141
|
+
console.error('Error accepting invite', e);
|
|
142
|
+
}
|
|
143
|
+
}, [activeChannel]);
|
|
144
|
+
|
|
145
|
+
const handleRejectInvite = useCallback(() => {
|
|
146
|
+
if (!activeChannel) return;
|
|
147
|
+
activeChannel.rejectInvite().catch((e: any) => console.error('Error rejecting invite', e));
|
|
148
|
+
}, [activeChannel]);
|
|
149
|
+
|
|
150
|
+
const scrollToBottom = useCallback((smooth = false, attempts = 0) => {
|
|
151
|
+
const handle = vlistRef.current;
|
|
152
|
+
if (!handle) return;
|
|
153
|
+
|
|
154
|
+
const count = messagesRef.current.length;
|
|
155
|
+
if (count === 0) return;
|
|
156
|
+
|
|
157
|
+
// Ensure virtua has measured the viewport via ResizeObserver.
|
|
158
|
+
// If viewportSize is unmeasured (0) or scrollSize is 0, align: 'end' calculates wrong.
|
|
159
|
+
if ((!handle.viewportSize || handle.viewportSize === 0) && attempts < 10) {
|
|
160
|
+
requestAnimationFrame(() => scrollToBottom(smooth, attempts + 1));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
handle.scrollToIndex(count - 1, { align: 'end', smooth });
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
// Shared guard: skip scroll-triggered loads during jump transitions
|
|
168
|
+
const jumpingRef = useRef(false);
|
|
169
|
+
|
|
170
|
+
/* ---------- Hooks ---------- */
|
|
171
|
+
const {
|
|
172
|
+
shiftMode,
|
|
173
|
+
hasMore, setHasMore,
|
|
174
|
+
hasNewer, setHasNewer,
|
|
175
|
+
loadingMoreRef, loadingNewerRef,
|
|
176
|
+
handleScroll,
|
|
177
|
+
isAtBottomRef,
|
|
178
|
+
} = useLoadMessages({
|
|
179
|
+
vlistRef,
|
|
180
|
+
messagesRef,
|
|
181
|
+
jumpingRef,
|
|
182
|
+
loadMoreLimit,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const { highlightedId, scrollToMessage, jumpToLatest } = useScrollToMessage({
|
|
186
|
+
vlistRef,
|
|
187
|
+
messagesRef,
|
|
188
|
+
setHasMore,
|
|
189
|
+
setHasNewer,
|
|
190
|
+
getVListElement,
|
|
191
|
+
scrollToBottom,
|
|
192
|
+
jumpingRef,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// React to jumpToMessageId from context (e.g. search panel)
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (jumpToMessageId) {
|
|
198
|
+
scrollToMessage(jumpToMessageId);
|
|
199
|
+
setJumpToMessageId(null);
|
|
200
|
+
}
|
|
201
|
+
}, [jumpToMessageId, scrollToMessage, setJumpToMessageId]);
|
|
202
|
+
|
|
203
|
+
useChannelMessages({
|
|
204
|
+
scrollToBottom,
|
|
205
|
+
jumpingRef,
|
|
206
|
+
isAtBottomRef,
|
|
207
|
+
onChannelSwitch: useCallback(() => {
|
|
208
|
+
setHasMore(true);
|
|
209
|
+
setHasNewer(false);
|
|
210
|
+
loadingMoreRef.current = false;
|
|
211
|
+
loadingNewerRef.current = false;
|
|
212
|
+
}, [setHasMore, setHasNewer]),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const renderers = useMemo(
|
|
216
|
+
() => ({ ...defaultMessageRenderers, ...customRenderers }),
|
|
217
|
+
[customRenderers],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
/* ---------- Compute read-by map (message.id → readers) ---------- */
|
|
221
|
+
const readByMap = useMemo(() => {
|
|
222
|
+
const map: Record<string, Array<{ id: string; name?: string; avatar?: string; last_read?: Date | string }>> = {};
|
|
223
|
+
if (!readState) return map;
|
|
224
|
+
for (const userId of Object.keys(readState)) {
|
|
225
|
+
if (userId === currentUserId) continue; // exclude self
|
|
226
|
+
const entry = readState[userId];
|
|
227
|
+
if (entry.last_read_message_id) {
|
|
228
|
+
if (!map[entry.last_read_message_id]) {
|
|
229
|
+
map[entry.last_read_message_id] = [];
|
|
230
|
+
}
|
|
231
|
+
map[entry.last_read_message_id].push({
|
|
232
|
+
id: userId,
|
|
233
|
+
name: entry.user?.name,
|
|
234
|
+
avatar: entry.user?.avatar,
|
|
235
|
+
last_read: entry.last_read,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return map;
|
|
240
|
+
}, [readState, currentUserId]);
|
|
241
|
+
|
|
242
|
+
/* ---------- Memoized message elements ---------- */
|
|
243
|
+
const messageElements = useMemo(() => {
|
|
244
|
+
return messages.map((message, index) => {
|
|
245
|
+
const isOwnMessage =
|
|
246
|
+
message.user_id === currentUserId || message.user?.id === currentUserId;
|
|
247
|
+
const messageType = (message.type || 'regular') as MessageLabel;
|
|
248
|
+
|
|
249
|
+
// Date separator
|
|
250
|
+
const prevMsg = index > 0 ? messages[index - 1] : null;
|
|
251
|
+
const showDateSeparator =
|
|
252
|
+
!prevMsg || getDateKey(message.created_at) !== getDateKey(prevMsg.created_at);
|
|
253
|
+
const dateSeparator = showDateSeparator ? (
|
|
254
|
+
<DateSeparatorComponent label={formatDateLabel(message.created_at)} />
|
|
255
|
+
) : null;
|
|
256
|
+
|
|
257
|
+
if (renderMessage) {
|
|
258
|
+
return (
|
|
259
|
+
<div key={message.id || `msg-${index}`}>
|
|
260
|
+
{dateSeparator}
|
|
261
|
+
<div>{renderMessage(message, isOwnMessage)}</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (messageType === 'system') {
|
|
267
|
+
return (
|
|
268
|
+
<div key={message.id || `msg-${index}`}>
|
|
269
|
+
{dateSeparator}
|
|
270
|
+
<SystemMessageItemComponent
|
|
271
|
+
message={message}
|
|
272
|
+
isOwnMessage={isOwnMessage}
|
|
273
|
+
SystemRenderer={renderers.system}
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Message grouping
|
|
280
|
+
const prevType = (prevMsg?.type || 'regular') as MessageLabel;
|
|
281
|
+
const isFirstInGroup =
|
|
282
|
+
showDateSeparator ||
|
|
283
|
+
!prevMsg ||
|
|
284
|
+
prevType === 'system' ||
|
|
285
|
+
prevType === 'signal' ||
|
|
286
|
+
getMessageUserId(prevMsg) !== getMessageUserId(message);
|
|
287
|
+
|
|
288
|
+
const nextMsg = index < messages.length - 1 ? messages[index + 1] : null;
|
|
289
|
+
const nextType = (nextMsg?.type || 'regular') as MessageLabel;
|
|
290
|
+
const nextShowDateSeparator = nextMsg
|
|
291
|
+
? getDateKey(nextMsg.created_at) !== getDateKey(message.created_at)
|
|
292
|
+
: false;
|
|
293
|
+
|
|
294
|
+
const isLastInGroup =
|
|
295
|
+
!nextMsg ||
|
|
296
|
+
nextShowDateSeparator ||
|
|
297
|
+
nextType === 'system' ||
|
|
298
|
+
nextType === 'signal' ||
|
|
299
|
+
getMessageUserId(nextMsg) !== getMessageUserId(message);
|
|
300
|
+
|
|
301
|
+
const MessageRenderer = renderers[messageType] || renderers.regular;
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div key={message.id || `msg-${index}`}>
|
|
305
|
+
{dateSeparator}
|
|
306
|
+
<MessageItemComponent
|
|
307
|
+
message={message}
|
|
308
|
+
isOwnMessage={isOwnMessage}
|
|
309
|
+
isFirstInGroup={isFirstInGroup}
|
|
310
|
+
isLastInGroup={isLastInGroup}
|
|
311
|
+
isHighlighted={highlightedId === message.id}
|
|
312
|
+
AvatarComponent={AvatarComponent}
|
|
313
|
+
MessageBubble={MessageBubble}
|
|
314
|
+
MessageRenderer={MessageRenderer}
|
|
315
|
+
onClickQuote={scrollToMessage}
|
|
316
|
+
QuotedMessagePreviewComponent={QuotedMessagePreviewComponent}
|
|
317
|
+
MessageActionsBoxComponent={MessageActionsBoxComponent}
|
|
318
|
+
MessageReactionsComponent={MessageReactionsComponent}
|
|
319
|
+
/>
|
|
320
|
+
{/* Read receipts — full width, right-aligned */}
|
|
321
|
+
{showReadReceipts && (
|
|
322
|
+
<ReadReceiptsComponent
|
|
323
|
+
readers={readByMap[message.id!] || []}
|
|
324
|
+
maxAvatars={readReceiptsMaxAvatars}
|
|
325
|
+
AvatarComponent={AvatarComponent}
|
|
326
|
+
TooltipComponent={ReadReceiptsTooltipComponent}
|
|
327
|
+
isOwnMessage={isOwnMessage}
|
|
328
|
+
isLastInGroup={isLastInGroup}
|
|
329
|
+
status={message.status}
|
|
330
|
+
/>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
}, [
|
|
336
|
+
messages,
|
|
337
|
+
currentUserId,
|
|
338
|
+
highlightedId,
|
|
339
|
+
renderers,
|
|
340
|
+
renderMessage,
|
|
341
|
+
AvatarComponent,
|
|
342
|
+
MessageBubble,
|
|
343
|
+
scrollToMessage,
|
|
344
|
+
DateSeparatorComponent,
|
|
345
|
+
MessageItemComponent,
|
|
346
|
+
SystemMessageItemComponent,
|
|
347
|
+
QuotedMessagePreviewComponent,
|
|
348
|
+
MessageActionsBoxComponent,
|
|
349
|
+
MessageReactionsComponent,
|
|
350
|
+
readByMap,
|
|
351
|
+
showReadReceipts,
|
|
352
|
+
ReadReceiptsComponent,
|
|
353
|
+
ReadReceiptsTooltipComponent,
|
|
354
|
+
readReceiptsMaxAvatars,
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
const blockedClass = isBlocked ? ' ermis-message-list--blocked' : '';
|
|
358
|
+
|
|
359
|
+
return (
|
|
360
|
+
<div ref={containerRef} className={`ermis-message-list${isBanned ? ' ermis-message-list--banned' : ''}${blockedClass}${className ? ` ${className}` : ''}`}>
|
|
361
|
+
{!isBanned && !isBlocked && showPinnedMessages && <PinnedMessagesComponent onClickMessage={scrollToMessage} AvatarComponent={AvatarComponent} />}
|
|
362
|
+
|
|
363
|
+
{messages.length === 0 && !isBanned && !isPending && (
|
|
364
|
+
EmptyStateIndicator === DefaultEmpty
|
|
365
|
+
? <DefaultEmpty title={emptyTitle} subtitle={emptySubtitle} />
|
|
366
|
+
: <EmptyStateIndicator />
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
{/* VList always rendered so virtua keeps its viewport measurement */}
|
|
370
|
+
<VList
|
|
371
|
+
key={activeChannel?.cid || 'empty'}
|
|
372
|
+
ref={vlistRef}
|
|
373
|
+
shift={shiftMode}
|
|
374
|
+
onScroll={handleScroll}
|
|
375
|
+
className="ermis-message-list__vlist"
|
|
376
|
+
>
|
|
377
|
+
{isPending && !isBanned && !isBlocked ? (
|
|
378
|
+
<div className="ermis-message-list__pending-overlay">
|
|
379
|
+
<div className="ermis-message-list__pending-card">
|
|
380
|
+
<Avatar image={channelImage} name={channelName} size={64} className="ermis-message-list__pending-avatar" />
|
|
381
|
+
<span className="ermis-message-list__pending-overlay-title">{pendingOverlayTitle}</span>
|
|
382
|
+
<div className="ermis-message-list__pending-channel-name">{channelName}</div>
|
|
383
|
+
<span className="ermis-message-list__pending-overlay-subtitle">{pendingOverlaySubtitle}</span>
|
|
384
|
+
<div className="ermis-message-list__pending-actions">
|
|
385
|
+
<button className="ermis-message-list__reject-btn" onClick={handleRejectInvite}>{pendingRejectLabel}</button>
|
|
386
|
+
<button className="ermis-message-list__accept-btn" onClick={handleAcceptInvite}>{pendingAcceptLabel}</button>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
) : (isBanned || isBlocked) && !isPending ? (
|
|
391
|
+
<div className="ermis-message-list__banned-overlay">
|
|
392
|
+
<div className="ermis-message-list__banned-overlay-icon">
|
|
393
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
394
|
+
<circle cx="12" cy="12" r="10" />
|
|
395
|
+
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
|
396
|
+
</svg>
|
|
397
|
+
</div>
|
|
398
|
+
<span className="ermis-message-list__banned-overlay-title">{isBlocked ? blockedOverlayTitle : bannedOverlayTitle}</span>
|
|
399
|
+
<span className="ermis-message-list__banned-overlay-subtitle">{isBlocked ? blockedOverlaySubtitle : bannedOverlaySubtitle}</span>
|
|
400
|
+
{isBlocked && activeChannel && (
|
|
401
|
+
<button
|
|
402
|
+
className="ermis-message-list__unblock-btn"
|
|
403
|
+
onClick={() => { activeChannel.unblockUser().catch((e: any) => console.error('Error unblocking user', e)); }}
|
|
404
|
+
>
|
|
405
|
+
Unblock
|
|
406
|
+
</button>
|
|
407
|
+
)}
|
|
408
|
+
</div>
|
|
409
|
+
) : messageElements}
|
|
410
|
+
</VList>
|
|
411
|
+
|
|
412
|
+
{/* Typing indicator */}
|
|
413
|
+
{!isBanned && !isBlocked && !isPending && showTypingIndicator && <TypingIndicatorComponent />}
|
|
414
|
+
|
|
415
|
+
{/* Jump to latest button */}
|
|
416
|
+
{!isBanned && !isBlocked && !isPending && hasNewer && (
|
|
417
|
+
JumpToLatestButton === DefaultJumpToLatest
|
|
418
|
+
? <DefaultJumpToLatest onClick={jumpToLatest} label={jumpToLatestLabel} />
|
|
419
|
+
: <JumpToLatestButton onClick={jumpToLatest} />
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
VirtualMessageList.displayName = 'VirtualMessageList';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React, { createContext, useState, useCallback } from 'react';
|
|
2
|
+
import type { Channel, FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import type { Theme, ChatContextValue, ChatProviderProps, ReadStateEntry } from '../types';
|
|
4
|
+
|
|
5
|
+
export type { Theme, ChatContextValue, ChatProviderProps } from '../types';
|
|
6
|
+
|
|
7
|
+
export const ChatContext = createContext<ChatContextValue | null>(null);
|
|
8
|
+
|
|
9
|
+
export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
10
|
+
client,
|
|
11
|
+
children,
|
|
12
|
+
initialTheme = 'light',
|
|
13
|
+
}) => {
|
|
14
|
+
const [activeChannelRaw, setActiveChannelRaw] = useState<Channel | null>(null);
|
|
15
|
+
const [theme, setTheme] = useState<Theme>(initialTheme);
|
|
16
|
+
const [messages, setMessages] = useState<FormatMessageResponse[]>([]);
|
|
17
|
+
const [quotedMessage, setQuotedMessage] = useState<FormatMessageResponse | null>(null);
|
|
18
|
+
const [editingMessage, setEditingMessage] = useState<FormatMessageResponse | null>(null);
|
|
19
|
+
const [readState, setReadState] = useState<Record<string, ReadStateEntry>>({});
|
|
20
|
+
const [forwardingMessage, setForwardingMessage] = useState<FormatMessageResponse | null>(null);
|
|
21
|
+
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
const activeChannel = activeChannelRaw;
|
|
24
|
+
|
|
25
|
+
const setActiveChannel = useCallback((channel: Channel | null) => {
|
|
26
|
+
setActiveChannelRaw(channel);
|
|
27
|
+
setQuotedMessage(null);
|
|
28
|
+
setEditingMessage(null);
|
|
29
|
+
if (channel) {
|
|
30
|
+
setMessages([...channel.state.latestMessages]);
|
|
31
|
+
setReadState({ ...channel.state.read });
|
|
32
|
+
} else {
|
|
33
|
+
setMessages([]);
|
|
34
|
+
setReadState({});
|
|
35
|
+
}
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
/** Re-read messages from SDK state into React state */
|
|
39
|
+
const syncMessages = useCallback(() => {
|
|
40
|
+
if (activeChannel) {
|
|
41
|
+
setMessages([...activeChannel.state.latestMessages]);
|
|
42
|
+
}
|
|
43
|
+
}, [activeChannel]);
|
|
44
|
+
|
|
45
|
+
const value: ChatContextValue = {
|
|
46
|
+
client,
|
|
47
|
+
activeChannel,
|
|
48
|
+
setActiveChannel,
|
|
49
|
+
theme,
|
|
50
|
+
setTheme,
|
|
51
|
+
messages,
|
|
52
|
+
setMessages,
|
|
53
|
+
syncMessages,
|
|
54
|
+
quotedMessage,
|
|
55
|
+
setQuotedMessage,
|
|
56
|
+
editingMessage,
|
|
57
|
+
setEditingMessage,
|
|
58
|
+
readState,
|
|
59
|
+
setReadState,
|
|
60
|
+
forwardingMessage,
|
|
61
|
+
setForwardingMessage,
|
|
62
|
+
jumpToMessageId,
|
|
63
|
+
setJumpToMessageId,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ChatContext.Provider value={value}>
|
|
68
|
+
<div className={`ermis-chat ermis-chat--${theme}`}>
|
|
69
|
+
{children}
|
|
70
|
+
</div>
|
|
71
|
+
</ChatContext.Provider>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook that tracks whether the current user is banned in the given channel.
|
|
6
|
+
*
|
|
7
|
+
* Reads the initial value from `channel.state.membership.banned` and subscribes
|
|
8
|
+
* to `member.banned` / `member.unbanned` WebSocket events for real-time updates.
|
|
9
|
+
*
|
|
10
|
+
* Only triggers a re-render when the *current user* is the target of the event.
|
|
11
|
+
*/
|
|
12
|
+
export function useBannedState(channel: Channel | null | undefined, currentUserId?: string) {
|
|
13
|
+
const [isBanned, setIsBanned] = useState<boolean>(() => {
|
|
14
|
+
return Boolean(channel?.state?.membership?.banned);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!channel) {
|
|
19
|
+
setIsBanned(false);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Sync initial state when channel changes
|
|
24
|
+
setIsBanned(Boolean(channel.state?.membership?.banned));
|
|
25
|
+
|
|
26
|
+
const handleBanned = (event: any) => {
|
|
27
|
+
if (event.member?.user_id === currentUserId) {
|
|
28
|
+
setIsBanned(true);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const handleUnbanned = (event: any) => {
|
|
33
|
+
if (event.member?.user_id === currentUserId) {
|
|
34
|
+
setIsBanned(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const sub1 = channel.on('member.banned', handleBanned);
|
|
39
|
+
const sub2 = channel.on('member.unbanned', handleUnbanned);
|
|
40
|
+
|
|
41
|
+
return () => {
|
|
42
|
+
sub1.unsubscribe();
|
|
43
|
+
sub2.unsubscribe();
|
|
44
|
+
};
|
|
45
|
+
}, [channel, currentUserId]);
|
|
46
|
+
|
|
47
|
+
return { isBanned };
|
|
48
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook that tracks whether the current user has blocked the other party
|
|
6
|
+
* in a messaging (1-1) channel.
|
|
7
|
+
*
|
|
8
|
+
* Reads the initial value from `channel.state.membership.blocked` and subscribes
|
|
9
|
+
* to `member.blocked` / `member.unblocked` WebSocket events for real-time updates.
|
|
10
|
+
*
|
|
11
|
+
* Only triggers a re-render when the *current user* is the target of the event
|
|
12
|
+
* (i.e., the blocker). When user A blocks user B, only A's membership has
|
|
13
|
+
* `blocked: true`. B is unaffected.
|
|
14
|
+
*
|
|
15
|
+
* This hook is only meaningful for `messaging` channels. For `team` channels,
|
|
16
|
+
* use `useBannedState` instead.
|
|
17
|
+
*/
|
|
18
|
+
export function useBlockedState(channel: Channel | null | undefined, currentUserId?: string) {
|
|
19
|
+
const [isBlocked, setIsBlocked] = useState<boolean>(() => {
|
|
20
|
+
if (channel?.type !== 'messaging') return false;
|
|
21
|
+
return Boolean(channel?.state?.membership?.blocked);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!channel || channel.type !== 'messaging') {
|
|
26
|
+
setIsBlocked(false);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Sync initial state when channel changes
|
|
31
|
+
setIsBlocked(Boolean(channel.state?.membership?.blocked));
|
|
32
|
+
|
|
33
|
+
const handleBlocked = (event: any) => {
|
|
34
|
+
if (event.member?.user_id === currentUserId) {
|
|
35
|
+
setIsBlocked(true);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const handleUnblocked = (event: any) => {
|
|
40
|
+
if (event.member?.user_id === currentUserId) {
|
|
41
|
+
setIsBlocked(false);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const sub1 = channel.on('member.blocked', handleBlocked);
|
|
46
|
+
const sub2 = channel.on('member.unblocked', handleUnblocked);
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
sub1.unsubscribe();
|
|
50
|
+
sub2.unsubscribe();
|
|
51
|
+
};
|
|
52
|
+
}, [channel, currentUserId]);
|
|
53
|
+
|
|
54
|
+
return { isBlocked };
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import type { Channel, Event } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from './useChatClient';
|
|
4
|
+
import type { UseChannelReturn } from '../types';
|
|
5
|
+
|
|
6
|
+
export type { UseChannelReturn } from '../types';
|
|
7
|
+
|
|
8
|
+
export const useChannel = (): UseChannelReturn => {
|
|
9
|
+
const { activeChannel } = useChatClient();
|
|
10
|
+
const [loading, setLoading] = useState(false);
|
|
11
|
+
const [error, setError] = useState<Error | null>(null);
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
channel: activeChannel,
|
|
15
|
+
loading,
|
|
16
|
+
error,
|
|
17
|
+
};
|
|
18
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { useChatClient } from './useChatClient';
|
|
3
|
+
|
|
4
|
+
export const useChannelCapabilities = () => {
|
|
5
|
+
const { activeChannel, client } = useChatClient();
|
|
6
|
+
const [updateTick, setUpdateTick] = useState(0);
|
|
7
|
+
|
|
8
|
+
// Real-time synchronization for channel adjustments
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!activeChannel) return;
|
|
11
|
+
const handleUpdate = () => setUpdateTick(t => t + 1);
|
|
12
|
+
|
|
13
|
+
activeChannel.on('channel.updated', handleUpdate);
|
|
14
|
+
return () => {
|
|
15
|
+
activeChannel.off('channel.updated', handleUpdate);
|
|
16
|
+
};
|
|
17
|
+
}, [activeChannel]);
|
|
18
|
+
|
|
19
|
+
const currentUserId = client?.userID || '';
|
|
20
|
+
const isTeamChannel = activeChannel?.type === 'team';
|
|
21
|
+
const role = (activeChannel?.state as any)?.members?.[currentUserId]?.channel_role;
|
|
22
|
+
|
|
23
|
+
const isOwner = role === 'owner' || activeChannel?.data?.created_by_id === currentUserId;
|
|
24
|
+
const isModerator = role === 'moder';
|
|
25
|
+
const isOwnerOrModerator = isOwner || isModerator;
|
|
26
|
+
|
|
27
|
+
const capabilities: string[] = isTeamChannel ? (activeChannel?.data as any)?.member_capabilities || [] : [];
|
|
28
|
+
|
|
29
|
+
const hasCapability = useCallback((cap: string) => {
|
|
30
|
+
return !isTeamChannel || isOwnerOrModerator || capabilities.includes(cap);
|
|
31
|
+
}, [isTeamChannel, isOwnerOrModerator, capabilities, updateTick]); // React to updateTick correctly
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
isTeamChannel,
|
|
35
|
+
isOwner,
|
|
36
|
+
isModerator,
|
|
37
|
+
isOwnerOrModerator,
|
|
38
|
+
hasCapability,
|
|
39
|
+
role,
|
|
40
|
+
capabilities
|
|
41
|
+
};
|
|
42
|
+
};
|