@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.
Files changed (88) hide show
  1. package/dist/index.cjs +6593 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.css +3375 -0
  4. package/dist/index.css.map +1 -0
  5. package/dist/index.d.mts +1138 -0
  6. package/dist/index.d.ts +1138 -0
  7. package/dist/index.mjs +6500 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +42 -0
  10. package/src/components/Avatar.tsx +102 -0
  11. package/src/components/Channel.tsx +77 -0
  12. package/src/components/ChannelHeader.tsx +85 -0
  13. package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
  14. package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
  15. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
  16. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
  17. package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
  18. package/src/components/ChannelInfo/FileListItem.tsx +49 -0
  19. package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
  21. package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
  23. package/src/components/ChannelInfo/States.tsx +36 -0
  24. package/src/components/ChannelInfo/index.ts +10 -0
  25. package/src/components/ChannelInfo/utils.tsx +49 -0
  26. package/src/components/ChannelList.tsx +395 -0
  27. package/src/components/Dropdown.tsx +120 -0
  28. package/src/components/EditPreview.tsx +102 -0
  29. package/src/components/FilesPreview.tsx +108 -0
  30. package/src/components/ForwardMessageModal.tsx +234 -0
  31. package/src/components/MentionSuggestions.tsx +59 -0
  32. package/src/components/MessageActionsBox.tsx +186 -0
  33. package/src/components/MessageInput.tsx +513 -0
  34. package/src/components/MessageInputDefaults.tsx +50 -0
  35. package/src/components/MessageItem.tsx +218 -0
  36. package/src/components/MessageQuickReactions.tsx +73 -0
  37. package/src/components/MessageReactions.tsx +59 -0
  38. package/src/components/MessageRenderers.tsx +565 -0
  39. package/src/components/Modal.tsx +58 -0
  40. package/src/components/Panel.tsx +64 -0
  41. package/src/components/PinnedMessages.tsx +165 -0
  42. package/src/components/QuotedMessagePreview.tsx +55 -0
  43. package/src/components/ReadReceipts.tsx +80 -0
  44. package/src/components/ReplyPreview.tsx +98 -0
  45. package/src/components/TypingIndicator.tsx +57 -0
  46. package/src/components/VirtualMessageList.tsx +425 -0
  47. package/src/context/ChatProvider.tsx +73 -0
  48. package/src/hooks/useBannedState.ts +48 -0
  49. package/src/hooks/useBlockedState.ts +55 -0
  50. package/src/hooks/useChannel.ts +18 -0
  51. package/src/hooks/useChannelCapabilities.ts +42 -0
  52. package/src/hooks/useChannelData.ts +55 -0
  53. package/src/hooks/useChannelListUpdates.ts +224 -0
  54. package/src/hooks/useChannelMessages.ts +159 -0
  55. package/src/hooks/useChannelRowUpdates.ts +78 -0
  56. package/src/hooks/useChatClient.ts +11 -0
  57. package/src/hooks/useEmojiPicker.ts +53 -0
  58. package/src/hooks/useFileUpload.ts +128 -0
  59. package/src/hooks/useLoadMessages.ts +178 -0
  60. package/src/hooks/useMentions.ts +287 -0
  61. package/src/hooks/useMessageActions.ts +87 -0
  62. package/src/hooks/useMessageSend.ts +164 -0
  63. package/src/hooks/usePendingState.ts +63 -0
  64. package/src/hooks/useScrollToMessage.ts +155 -0
  65. package/src/hooks/useTypingIndicator.ts +86 -0
  66. package/src/index.ts +129 -0
  67. package/src/styles/_add-member-modal.css +122 -0
  68. package/src/styles/_base.css +32 -0
  69. package/src/styles/_channel-info.css +941 -0
  70. package/src/styles/_channel-list.css +217 -0
  71. package/src/styles/_dropdown.css +69 -0
  72. package/src/styles/_forward-modal.css +191 -0
  73. package/src/styles/_mentions.css +102 -0
  74. package/src/styles/_message-actions.css +61 -0
  75. package/src/styles/_message-bubble.css +656 -0
  76. package/src/styles/_message-input.css +389 -0
  77. package/src/styles/_message-list.css +416 -0
  78. package/src/styles/_message-quick-reactions.css +62 -0
  79. package/src/styles/_message-reactions.css +67 -0
  80. package/src/styles/_modal.css +113 -0
  81. package/src/styles/_panel.css +69 -0
  82. package/src/styles/_pinned-messages.css +140 -0
  83. package/src/styles/_search-panel.css +219 -0
  84. package/src/styles/_tokens.css +92 -0
  85. package/src/styles/_typing-indicator.css +59 -0
  86. package/src/styles/index.css +24 -0
  87. package/src/types.ts +955 -0
  88. 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
+ };