@ermis-network/ermis-chat-react 1.0.9 → 2.0.1

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 (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. package/src/utils.ts +219 -16
@@ -1,15 +1,35 @@
1
- import { useEffect, useCallback } from 'react';
1
+ import { useEffect, useCallback, useRef, useLayoutEffect } from 'react';
2
2
  import type { Event } from '@ermis-network/ermis-chat-sdk';
3
3
  import { useChatClient } from './useChatClient';
4
4
  import { isPendingMember } from '../channelRoleUtils';
5
5
 
6
6
  export type UseChannelMessagesOptions = {
7
7
  scrollToBottom: (smooth: boolean) => void;
8
+ /** Reads the live virtual-list metrics to decide whether the viewport is near the bottom. */
9
+ isNearBottom?: () => boolean;
10
+ /** Temporarily blocks scroll-triggered pagination while auto-following new messages. */
11
+ holdScrollLoadLock?: (duration?: number) => void;
8
12
  /** Shared guard ref — blocks scroll-triggered loads during channel switch */
9
13
  jumpingRef: React.MutableRefObject<boolean>;
10
14
  isAtBottomRef: React.MutableRefObject<boolean>;
11
15
  /** Called to reset load-more state when channel switches */
12
16
  onChannelSwitch?: () => void;
17
+ /** Whether to include hidden (deleted) messages in the initial channel query */
18
+ includeHiddenMessages?: boolean;
19
+ /** Ref to the message list container for smooth opacity transitions */
20
+ containerRef?: React.RefObject<HTMLDivElement>;
21
+ };
22
+
23
+ // Track channels that have already been queried with include_hidden_messages globally for the session
24
+ const fullyQueriedChannels = new Set<string>();
25
+ export const markChannelAsFullyQueried = (cid: string) => fullyQueriedChannels.add(cid);
26
+
27
+ const isInactiveInviteRole = (role?: string) => isPendingMember(role) || role === 'rejected' || role === 'skipped';
28
+ const isE2eeChannel = (channel: any, client: any) => {
29
+ if (channel?.data?.mls_enabled === true) return true;
30
+ const parentCid = channel?.data?.parent_cid as string | undefined;
31
+ if (!parentCid) return false;
32
+ return client?.activeChannels?.[parentCid]?.data?.mls_enabled === true;
13
33
  };
14
34
 
15
35
  /**
@@ -26,70 +46,337 @@ const SCROLL_DELAYS = [50, 200, 500, 1000];
26
46
  */
27
47
  export function useChannelMessages({
28
48
  scrollToBottom,
49
+ isNearBottom,
50
+ holdScrollLoadLock,
29
51
  jumpingRef,
30
52
  isAtBottomRef,
31
53
  onChannelSwitch,
54
+ includeHiddenMessages = true,
55
+ containerRef,
32
56
  }: UseChannelMessagesOptions): void {
33
- const { client, activeChannel, syncMessages, setReadState } = useChatClient();
57
+ const { client, activeChannel, syncMessages, setMessages, setReadState } = useChatClient();
58
+ const inviteRefreshInFlightRef = useRef<Set<string>>(new Set());
59
+
60
+ const shouldAutoScroll = useCallback(
61
+ () => isAtBottomRef.current || Boolean(isNearBottom?.()),
62
+ [isAtBottomRef, isNearBottom],
63
+ );
64
+
65
+ const snapToBottomAfterCommit = useCallback(
66
+ (force = false) => {
67
+ if (force) {
68
+ isAtBottomRef.current = true;
69
+ }
70
+ if (force || shouldAutoScroll()) {
71
+ holdScrollLoadLock?.(750);
72
+ }
73
+
74
+ requestAnimationFrame(() => {
75
+ requestAnimationFrame(() => {
76
+ if (force || shouldAutoScroll()) {
77
+ scrollToBottom(false);
78
+ }
79
+ });
80
+ });
81
+
82
+ [80, 180, 360].forEach((delay) => {
83
+ setTimeout(() => {
84
+ if (force || shouldAutoScroll()) {
85
+ scrollToBottom(false);
86
+ }
87
+ }, delay);
88
+ });
89
+ },
90
+ [scrollToBottom, shouldAutoScroll, isAtBottomRef, holdScrollLoadLock],
91
+ );
34
92
 
35
93
  const scheduleScrollToBottom = useCallback(
36
- (smooth: boolean) => {
94
+ (smooth: boolean, force = false) => {
95
+ if (force) {
96
+ isAtBottomRef.current = true;
97
+ }
37
98
  if (smooth) {
38
99
  // Trigger smooth scroll exactly once, otherwise browsers will
39
100
  // cancel the smooth animation if called multiple times in a row
40
- setTimeout(() => scrollToBottom(true), 100);
101
+ setTimeout(() => {
102
+ if (!force && !shouldAutoScroll()) return;
103
+ scrollToBottom(true);
104
+ }, 100);
41
105
  } else {
42
106
  SCROLL_DELAYS.forEach((delay) => {
43
- setTimeout(() => scrollToBottom(false), delay);
107
+ setTimeout(() => {
108
+ if (!force && !shouldAutoScroll()) return;
109
+ scrollToBottom(false);
110
+ }, delay);
44
111
  });
45
112
  }
46
113
  },
47
- [scrollToBottom],
114
+ [scrollToBottom, isAtBottomRef, shouldAutoScroll],
48
115
  );
49
116
 
117
+ // Block scroll-triggered loadMore SYNCHRONOUSLY before browser paint.
118
+ // VList remounts (key change) and fires onScroll during layout — useEffect
119
+ // runs too late to block it. useLayoutEffect runs before paint/scroll events.
120
+ useLayoutEffect(() => {
121
+ if (!activeChannel) return;
122
+ jumpingRef.current = true;
123
+ isAtBottomRef.current = true;
124
+ }, [activeChannel]);
125
+
50
126
  useEffect(() => {
51
127
  if (!activeChannel) return;
52
128
 
53
129
  // Reset state for the new channel
54
130
  onChannelSwitch?.();
55
131
 
56
- // Manually force isAtBottom to true because we are jumping to the bottom.
57
- // jumpingRef blocks the resulting scroll event from updating isAtBottomRef,
58
- // so if it was false in the previous channel, it would stay false!
59
- isAtBottomRef.current = true;
132
+ // Instantly hide the list when channel changes
133
+ const el = containerRef?.current;
134
+ if (el) {
135
+ el.style.opacity = '0';
136
+ el.style.transition = 'none';
137
+ }
60
138
 
61
- // Block scroll triggers during channel-switch scroll
62
- jumpingRef.current = true;
63
- // Defer scroll outside React lifecycle to avoid virtua flushSync warning
64
- setTimeout(() => {
65
- scrollToBottom(false);
66
- // Wait long enough for scrollToBottom's internal retries and the browser
67
- // to execute the scroll event
139
+ const fadeListIn = () => {
140
+ if (!el) return;
141
+ // Allow virtua a brief moment to measure items after scroll before showing
142
+ setTimeout(() => {
143
+ el.style.transition = 'opacity 0.1s ease-out';
144
+ el.style.opacity = '1';
145
+ }, 50);
146
+ };
147
+
148
+ const isDecryptedPlaintextMessage = (message: any) => {
149
+ if (!message || message.e2ee_status === 'failed' || message.e2ee_status === 'decrypting') return false;
150
+ return (
151
+ typeof message.text === 'string' ||
152
+ Boolean(message.attachments?.length) ||
153
+ Boolean(message.sticker_url) ||
154
+ Boolean(message.poll_type) ||
155
+ Boolean(message.poll_choice_counts) ||
156
+ Boolean(message.latest_poll_choices)
157
+ );
158
+ };
159
+
160
+ const normalizeDecryptedMessage = (message: any) => {
161
+ if (!isDecryptedPlaintextMessage(message)) return message;
162
+ return {
163
+ ...message,
164
+ content_type: 'standard',
165
+ type: message.sticker_url ? 'sticker' : message.type,
166
+ };
167
+ };
168
+
169
+ const getMessageAndQuoteIds = (messages: any[]) =>
170
+ Array.from(
171
+ new Set(
172
+ messages.flatMap((message: any) =>
173
+ [message?.id, message?.quoted_message_id].filter(
174
+ (id): id is string => typeof id === 'string' && id.length > 0,
175
+ ),
176
+ ),
177
+ ),
178
+ );
179
+
180
+ const loadStoredE2eeMessagesById = async (messageIds: string[]) => {
181
+ const storage = client.encryptionManager?.storage;
182
+ if (!storage || messageIds.length === 0) return [];
183
+
184
+ const uniqueIds = Array.from(new Set(messageIds));
185
+ if (storage.loadE2eeMessages) {
186
+ const stored = await storage.loadE2eeMessages(uniqueIds);
187
+ return Array.from(stored.values());
188
+ }
189
+
190
+ const stored = await Promise.all(uniqueIds.map((id) => storage.loadE2eeMessage(id).catch(() => null)));
191
+ return stored.filter(Boolean);
192
+ };
193
+
194
+ const mergeAndFilterE2eeMessages = (
195
+ baseMessages: any[],
196
+ decryptedMessages: any[],
197
+ options: { includeMissing?: boolean } = {},
198
+ ) => {
199
+ const includeMissing = options.includeMissing ?? true;
200
+ const byId = new Map(baseMessages.map((msg: any) => [msg.id, msg]));
201
+ for (const decrypted of decryptedMessages) {
202
+ const normalized = normalizeDecryptedMessage(decrypted);
203
+ const hasPlaintext = isDecryptedPlaintextMessage(normalized);
204
+ const current: any = byId.get(decrypted.id);
205
+ if (!includeMissing && !current) continue;
206
+ byId.set(decrypted.id, {
207
+ ...(current || {}),
208
+ ...normalized,
209
+ content_type: hasPlaintext
210
+ ? normalized.content_type || current?.content_type || 'standard'
211
+ : normalized.content_type || current?.content_type,
212
+ status: normalized.status ?? (hasPlaintext ? 'received' : current?.status ?? null),
213
+ });
214
+ }
215
+
216
+ return Array.from(byId.values()).sort((a: any, b: any) => {
217
+ const aTime = new Date(a.created_at || 0).getTime();
218
+ const bTime = new Date(b.created_at || 0).getTime();
219
+ return aTime - bTime;
220
+ });
221
+ };
222
+
223
+ const mergeDecryptedMessages = (decryptedMessages: any[], includeMissing = false) => {
224
+ if (!decryptedMessages.length) {
225
+ setMessages((prev) => mergeAndFilterE2eeMessages(prev, []));
226
+ return;
227
+ }
228
+ setMessages((prev) =>
229
+ mergeAndFilterE2eeMessages(prev, decryptedMessages, { includeMissing: includeMissing || prev.length === 0 }),
230
+ );
231
+ };
232
+
233
+ const syncMessagesWithE2eeCache = (options: { includeStoredWindow?: boolean } = {}) => {
234
+ if (!isE2eeChannel(activeChannel, client) || !client.encryptionManager?.storage || !activeChannel.cid) {
235
+ syncMessages();
236
+ return;
237
+ }
238
+
239
+ const baseMessages = [...activeChannel.state.latestMessages];
240
+ setMessages(mergeAndFilterE2eeMessages(baseMessages, []));
241
+
242
+ const loadStoredMessages = options.includeStoredWindow
243
+ ? client.encryptionManager.storage.getE2eeMessages(activeChannel.cid, 100)
244
+ : loadStoredE2eeMessagesById(getMessageAndQuoteIds(baseMessages));
245
+
246
+ loadStoredMessages
247
+ .then((decryptedMessages: any[]) => {
248
+ setMessages((prev) =>
249
+ mergeAndFilterE2eeMessages(prev.length ? prev : baseMessages, decryptedMessages, {
250
+ includeMissing: options.includeStoredWindow === true,
251
+ }),
252
+ );
253
+ })
254
+ .catch((err: any) => console.warn('[E2EE] Failed to load decrypted message cache', err));
255
+ };
256
+
257
+ const syncStoredE2eeMessages = (includeStoredWindow = false) => {
258
+ if (!isE2eeChannel(activeChannel, client) || !client.encryptionManager?.storage || !activeChannel.cid) return;
259
+ const baseMessages = [...activeChannel.state.latestMessages];
260
+ const loadStoredMessages = includeStoredWindow
261
+ ? client.encryptionManager.storage.getE2eeMessages(activeChannel.cid, 100)
262
+ : loadStoredE2eeMessagesById(getMessageAndQuoteIds(baseMessages));
263
+
264
+ loadStoredMessages
265
+ .then((storedMessages: any[]) => mergeDecryptedMessages(storedMessages, includeStoredWindow))
266
+ .catch((err: any) => console.warn('[E2EE] Failed to load decrypted message cache', err));
267
+ };
268
+
269
+ const ensureE2eeChannelReady = () => {
270
+ if (!isE2eeChannel(activeChannel, client) || !client.encryptionManager?.initialized || !activeChannel.cid) return;
271
+ if (isInactiveInviteRole(activeChannel.state?.membership?.channel_role as string)) return;
272
+ client.encryptionManager
273
+ .ensureChannelReady(activeChannel.type, activeChannel.id, activeChannel.cid, { source: 'open' })
274
+ .then(() => syncMessagesWithE2eeCache({ includeStoredWindow: true }))
275
+ .catch((err: any) => console.warn('[E2EE] Failed to ensure channel ready', err));
276
+ };
277
+
278
+ // Fetch hidden messages if not already done for this channel
279
+ const cid = activeChannel.cid;
280
+ if (includeHiddenMessages && cid && !fullyQueriedChannels.has(cid)) {
281
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
282
+ activeChannel
283
+ .query({
284
+ messages: { limit: 25, include_hidden_messages: true },
285
+ })
286
+ .then(() => {
287
+ fullyQueriedChannels.add(cid);
288
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
289
+ ensureE2eeChannelReady();
290
+ // Sync initial read state from SDK so read receipts show immediately
291
+ setReadState({ ...activeChannel.state.read });
292
+ scheduleScrollToBottom(false);
293
+ fadeListIn(); // Fade in AFTER query finishes and sync is called
294
+ // Release jumping guard AFTER scrollToBottom has had time to execute.
295
+ // syncMessages() triggers a VList re-render which fires onScroll at
296
+ // offset≈0, and scheduleScrollToBottom's first scroll is at +50ms.
297
+ // If we release jumpingRef synchronously, loadMore fires before the
298
+ // scroll. Delay to 150ms so the +50ms scroll runs first.
299
+ setTimeout(() => {
300
+ jumpingRef.current = false;
301
+ }, 150);
302
+ })
303
+ .catch((err: any) => {
304
+ console.error('Failed to query channel on select', err);
305
+ fadeListIn(); // Fade in anyway on error
306
+ setTimeout(() => {
307
+ jumpingRef.current = false;
308
+ }, 100);
309
+ });
310
+ } else {
311
+ // Already queried: sync cache immediately for instant UI, scroll and fade in quickly
312
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
313
+ ensureE2eeChannelReady();
314
+ // Sync initial read state from SDK so read receipts show immediately
315
+ setReadState({ ...activeChannel.state.read });
316
+ setTimeout(() => {
317
+ scheduleScrollToBottom(false);
318
+ fadeListIn();
319
+ }, 0);
320
+ // Release after a short delay so scrollToBottom's scroll event doesn't
321
+ // trigger loadMore
68
322
  setTimeout(() => {
69
323
  jumpingRef.current = false;
70
324
  }, 100);
71
- }, 0);
325
+
326
+ // Background re-query to ensure messages are fresh (e.g. after scrollToMessage
327
+ // replaced messages with a small window, or after a stale reconnect).
328
+ // This does NOT block the UI — cached messages are already visible.
329
+ activeChannel
330
+ .query({ messages: { limit: 25, include_hidden_messages: includeHiddenMessages } })
331
+ .then(() => {
332
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
333
+ setReadState({ ...activeChannel.state.read });
334
+ })
335
+ .catch((err: any) => {
336
+ console.warn('Background re-query for channel messages failed', err);
337
+ });
338
+ }
72
339
 
73
340
  const handleNewMessage = (event: Event) => {
74
341
  // Capture scroll state BEFORE sync causes re-render
75
- const wasAtBottom = isAtBottomRef.current;
76
-
77
- syncMessages();
78
-
342
+ const wasAtBottom = shouldAutoScroll();
79
343
  const isOwnMessage = event.message?.user?.id === client.userID || event.message?.user_id === client.userID;
344
+ const shouldFollowBottom = isOwnMessage || wasAtBottom;
345
+ if (shouldFollowBottom) {
346
+ isAtBottomRef.current = true;
347
+ holdScrollLoadLock?.(750);
348
+ }
80
349
 
81
- if (isOwnMessage || wasAtBottom) {
82
- scheduleScrollToBottom(true);
350
+ syncMessagesWithE2eeCache();
351
+
352
+ if (isOwnMessage) {
353
+ // Own/realtime-at-bottom messages use INSTANT scroll to avoid
354
+ // animation overlap jank during rapid typing. Multiple smooth scrolls
355
+ // in quick succession cause the visible "jump/snap" effect because
356
+ // each new animation cancels the previous one mid-way.
357
+ snapToBottomAfterCommit(true);
358
+ } else if (wasAtBottom) {
359
+ snapToBottomAfterCommit(true);
83
360
  }
84
361
  };
85
362
 
86
- const handleMessageChange = (_event: Event) => {
87
- syncMessages();
363
+ const handleMessageChange = (event: Event) => {
364
+ syncMessagesWithE2eeCache();
88
365
  };
89
366
 
90
367
  const handleMessageRead = (_event: Event) => {
91
368
  // SDK already updated channel.state.read — sync into React state
92
369
  setReadState({ ...activeChannel.state.read });
370
+ // Read receipt avatars appear below the last message, increasing content
371
+ // height. Auto-scroll so the user doesn't have to manually scroll down
372
+ // to see the "seen" indicator.
373
+ if (shouldAutoScroll()) {
374
+ setTimeout(() => {
375
+ if (shouldAutoScroll()) {
376
+ scrollToBottom(false);
377
+ }
378
+ }, 100);
379
+ }
93
380
  };
94
381
 
95
382
  const handleUnblocked = (event: Event) => {
@@ -99,36 +386,81 @@ export function useChannelMessages({
99
386
  activeChannel
100
387
  .query({ messages: { limit: 30 } })
101
388
  .then(() => {
102
- syncMessages();
389
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
103
390
  scheduleScrollToBottom(false);
104
391
  const isPending = isPendingMember(activeChannel.state?.membership?.channel_role as string);
105
392
  if (!isPending) {
106
393
  activeChannel.markRead().catch(() => {});
107
394
  }
108
395
  })
109
- .catch((e) => console.error('Failed to sync messages after unblock', e));
396
+ .catch((e: any) => console.error('Failed to sync messages after unblock', e));
110
397
  }
111
398
  };
112
399
 
113
- const handleInviteAccepted = (event: Event) => {
114
- // Make sure the accepted invite corresponds to the actively opened channel
400
+ const refreshAfterOwnInviteMembership = (event: Event) => {
115
401
  const eventCid =
116
402
  event.cid ||
117
403
  event.channel?.cid ||
118
404
  ((event as any).channel_id ? `${(event as any).channel_type}:${(event as any).channel_id}` : undefined);
119
- if (eventCid === activeChannel.cid) {
120
- activeChannel
121
- .query({ messages: { limit: 30 } })
122
- .then(() => {
123
- syncMessages();
124
- scheduleScrollToBottom(false);
125
- activeChannel.markRead().catch(() => {});
126
- })
127
- .catch((e) => console.error('Failed to sync messages after accepting invite', e));
405
+ if (eventCid !== activeChannel.cid) return;
406
+
407
+ const memberUserId = (event as any).member?.user_id;
408
+ if (memberUserId && memberUserId !== client.userID) return;
409
+ if (inviteRefreshInFlightRef.current.has(eventCid)) return;
410
+
411
+ inviteRefreshInFlightRef.current.add(eventCid);
412
+ activeChannel
413
+ .query({ messages: { limit: 30 } })
414
+ .then(() => {
415
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
416
+ scheduleScrollToBottom(false);
417
+ activeChannel.markRead().catch(() => {});
418
+ })
419
+ .catch((e: any) => console.error('Failed to refresh channel after invite membership update', e))
420
+ .finally(() => {
421
+ inviteRefreshInFlightRef.current.delete(eventCid);
422
+ });
423
+ };
424
+
425
+ const handleRecovery = () => {
426
+ // recoverState() only fetches channels with message_limit: 1 (for sidebar previews).
427
+ // Re-query the active channel with a proper limit to load all missed messages.
428
+ activeChannel
429
+ .query({ messages: { limit: 25, include_hidden_messages: true } })
430
+ .then(() => {
431
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
432
+ ensureE2eeChannelReady();
433
+ setReadState({ ...activeChannel.state.read });
434
+ scheduleScrollToBottom(false);
435
+ })
436
+ .catch((err: any) => {
437
+ console.error('Failed to recover channel messages after reconnect', err);
438
+ // Fallback: sync whatever we have from recoverState
439
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
440
+ ensureE2eeChannelReady();
441
+ scheduleScrollToBottom(false);
442
+ });
443
+ };
444
+
445
+ const handleE2eeDecrypted = (event: any) => {
446
+ if (!event?.message?.id || event.cid !== activeChannel.cid) return;
447
+ const wasAtBottom = shouldAutoScroll();
448
+ mergeDecryptedMessages([event.message]);
449
+ if (wasAtBottom) {
450
+ snapToBottomAfterCommit(true);
128
451
  }
129
452
  };
130
453
 
131
- const client = activeChannel.getClient();
454
+ const handleE2eeRefresh = (event: any) => {
455
+ if (event?.cid === activeChannel.cid) {
456
+ if (Array.isArray(event.messages) && event.messages.length > 0) {
457
+ mergeDecryptedMessages(event.messages);
458
+ }
459
+ syncStoredE2eeMessages();
460
+ }
461
+ };
462
+
463
+ const eventClient = activeChannel.getClient();
132
464
  const sub1 = activeChannel.on('message.new', handleNewMessage);
133
465
  const sub2 = activeChannel.on('message.updated', handleMessageChange);
134
466
  const sub3 = activeChannel.on('message.deleted', handleMessageChange);
@@ -139,7 +471,16 @@ export function useChannelMessages({
139
471
  const sub8 = activeChannel.on('reaction.new', handleMessageChange);
140
472
  const sub9 = activeChannel.on('reaction.deleted', handleMessageChange);
141
473
  const sub10 = activeChannel.on('member.unblocked', handleUnblocked);
142
- const sub11 = client.on('notification.invite_accepted', handleInviteAccepted);
474
+ const sub11 = activeChannel.on('channel.truncate', handleMessageChange);
475
+ const sub12 = activeChannel.on('channel.truncate_for_me', handleMessageChange);
476
+
477
+ const sub13 = eventClient.on('notification.invite_accepted', refreshAfterOwnInviteMembership);
478
+ const sub14 = eventClient.on('member.joined', refreshAfterOwnInviteMembership);
479
+ const sub15 = eventClient.on('connection.recovered', handleRecovery);
480
+ const sub16 = eventClient.on('e2ee.message_decrypted' as any, handleE2eeDecrypted);
481
+ const sub17 = eventClient.on('e2ee.post_join_sync' as any, handleE2eeRefresh);
482
+ const sub18 = eventClient.on('e2ee.channel_ready' as any, handleE2eeRefresh);
483
+ const sub19 = eventClient.on('e2ee.local_messages_loaded' as any, handleE2eeRefresh);
143
484
 
144
485
  return () => {
145
486
  sub1.unsubscribe();
@@ -153,6 +494,14 @@ export function useChannelMessages({
153
494
  sub9.unsubscribe();
154
495
  sub10.unsubscribe();
155
496
  sub11.unsubscribe();
497
+ sub12.unsubscribe();
498
+ sub13.unsubscribe();
499
+ sub14.unsubscribe();
500
+ sub15.unsubscribe();
501
+ sub16.unsubscribe();
502
+ sub17.unsubscribe();
503
+ sub18.unsubscribe();
504
+ sub19.unsubscribe();
156
505
  };
157
- }, [activeChannel, scrollToBottom, scheduleScrollToBottom, syncMessages, onChannelSwitch, setReadState]);
506
+ }, [activeChannel, client, scrollToBottom, scheduleScrollToBottom, shouldAutoScroll, snapToBottomAfterCommit, syncMessages, setMessages, onChannelSwitch, setReadState, holdScrollLoadLock]);
158
507
  }
@@ -18,10 +18,17 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
18
18
  const [updateCount, setUpdateCount] = useState(0);
19
19
 
20
20
  useEffect(() => {
21
- setIsBannedInChannel(Boolean(channel.state?.membership?.banned));
22
- setIsBlockedInChannel(
23
- isDirectChannel(channel) ? Boolean(channel.state?.membership?.blocked) : false
24
- );
21
+ const parentCid = channel.data?.parent_cid as string | undefined;
22
+ const parentChannel = parentCid ? channel.getClient().activeChannels[parentCid] : undefined;
23
+
24
+ const computeIsBanned = () => {
25
+ const selfBanned = Boolean(channel.state?.membership?.banned);
26
+ const parentBanned = Boolean(parentChannel?.state?.membership?.banned);
27
+ return selfBanned || parentBanned;
28
+ };
29
+
30
+ setIsBannedInChannel(computeIsBanned());
31
+ setIsBlockedInChannel(isDirectChannel(channel) ? Boolean(channel.state?.membership?.blocked) : false);
25
32
 
26
33
  const handleBanned = (event: any) => {
27
34
  if (event.member?.user_id === currentUserId) {
@@ -30,11 +37,16 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
30
37
  };
31
38
  const handleUnbanned = (event: any) => {
32
39
  if (event.member?.user_id === currentUserId) {
33
- setIsBannedInChannel(false);
40
+ setIsBannedInChannel(computeIsBanned());
34
41
  }
35
42
  };
36
43
 
37
44
  const handleUpdate = () => setUpdateCount((c) => c + 1);
45
+ const handleE2eePreviewUpdate = (event: any) => {
46
+ if (event?.cid === channel.cid) {
47
+ handleUpdate();
48
+ }
49
+ };
38
50
 
39
51
  const sub1 = channel.on('member.banned', handleBanned);
40
52
  const sub2 = channel.on('member.unbanned', handleUnbanned);
@@ -42,6 +54,7 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
42
54
  const sub4 = channel.on('message.read', handleUpdate);
43
55
  const sub5 = channel.on('message.updated', handleUpdate);
44
56
  const sub6 = channel.on('message.deleted', handleUpdate);
57
+ const sub6_me = channel.on('message.deleted_for_me', handleUpdate);
45
58
  const sub7 = channel.on('channel.updated', handleUpdate);
46
59
  const sub8 = channel.on('member.added', handleUpdate);
47
60
  const sub9 = channel.on('member.removed', handleUpdate);
@@ -62,6 +75,18 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
62
75
  const sub12 = channel.on('channel.topic.created', handleUpdate);
63
76
  const sub13 = channel.on('channel.pinned', handleUpdate);
64
77
  const sub14 = channel.on('channel.unpinned', handleUpdate);
78
+ const client = channel.getClient();
79
+ const sub15 = client.on('e2ee.message_decrypted' as any, handleE2eePreviewUpdate);
80
+ const sub16 = client.on('e2ee.local_messages_loaded' as any, handleE2eePreviewUpdate);
81
+ const sub17 = client.on('e2ee.post_join_sync' as any, handleE2eePreviewUpdate);
82
+
83
+ // Topic support: listen for ban events on parent channel too
84
+ let sub18: { unsubscribe: () => void } | undefined;
85
+ let sub19: { unsubscribe: () => void } | undefined;
86
+ if (parentChannel) {
87
+ sub18 = parentChannel.on('member.banned', handleBanned);
88
+ sub19 = parentChannel.on('member.unbanned', handleUnbanned);
89
+ }
65
90
 
66
91
  return () => {
67
92
  sub1.unsubscribe();
@@ -70,6 +95,7 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
70
95
  sub4.unsubscribe();
71
96
  sub5.unsubscribe();
72
97
  sub6.unsubscribe();
98
+ sub6_me.unsubscribe();
73
99
  sub7.unsubscribe();
74
100
  sub8.unsubscribe();
75
101
  sub9.unsubscribe();
@@ -78,6 +104,11 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
78
104
  sub12.unsubscribe();
79
105
  sub13.unsubscribe();
80
106
  sub14.unsubscribe();
107
+ sub15.unsubscribe();
108
+ sub16.unsubscribe();
109
+ sub17.unsubscribe();
110
+ if (sub18) sub18.unsubscribe();
111
+ if (sub19) sub19.unsubscribe();
81
112
  };
82
113
  }, [channel, currentUserId]);
83
114
 
@@ -0,0 +1,39 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useChatClient } from './useChatClient';
3
+ import type { UserResponse, ExtendableGenerics, DefaultGenerics } from '@ermis-network/ermis-chat-sdk';
4
+
5
+ export const useChatUser = <ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics>() => {
6
+ const { client } = useChatClient();
7
+ const [user, setUser] = useState<UserResponse<ErmisChatGenerics> | undefined>(client?.user);
8
+
9
+ useEffect(() => {
10
+ if (!client) return;
11
+
12
+ // Set initial user in case it changed before the effect runs
13
+ setUser(client.user);
14
+
15
+ const handleUserUpdated = (event: any) => {
16
+ if (event.me) {
17
+ setUser((prev) => {
18
+ const update = { ...event.me };
19
+ // Do not let periodic health checks wipe out the user's name/avatar with empty strings
20
+ if (event.type === 'health.check') {
21
+ if (!update.name) delete update.name;
22
+ if (!update.avatar) delete update.avatar;
23
+ }
24
+ return { ...prev, ...update };
25
+ });
26
+ }
27
+ };
28
+
29
+ const listener = client.on('user.updated', handleUserUpdated);
30
+ const healthListener = client.on('health.check', handleUserUpdated);
31
+
32
+ return () => {
33
+ listener.unsubscribe();
34
+ healthListener.unsubscribe();
35
+ };
36
+ }, [client]);
37
+
38
+ return { user };
39
+ };