@ermis-network/ermis-chat-react 2.0.0 → 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 (72) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +5087 -11279
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +632 -152
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +273 -9
  7. package/dist/index.d.ts +273 -9
  8. package/dist/index.mjs +5085 -11295
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/components/Channel.tsx +0 -3
  12. package/src/components/ChannelActions.tsx +6 -1
  13. package/src/components/ChannelHeader.tsx +8 -32
  14. package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
  17. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
  18. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
  19. package/src/components/ChannelList.tsx +72 -13
  20. package/src/components/CreateChannelModal.tsx +131 -12
  21. package/src/components/FilesPreview.tsx +8 -12
  22. package/src/components/FlatTopicGroupItem.tsx +27 -16
  23. package/src/components/ForwardMessageModal.tsx +11 -3
  24. package/src/components/MediaLightbox.tsx +444 -304
  25. package/src/components/MessageActionsBox.tsx +2 -0
  26. package/src/components/MessageInput.tsx +41 -12
  27. package/src/components/MessageItem.tsx +70 -25
  28. package/src/components/MessageQuickReactions.tsx +131 -128
  29. package/src/components/MessageReactions.tsx +47 -2
  30. package/src/components/MessageRenderers.tsx +1030 -433
  31. package/src/components/PinnedMessages.tsx +40 -12
  32. package/src/components/QuotedMessagePreview.tsx +99 -8
  33. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  34. package/src/components/RecoveryPin/index.ts +19 -0
  35. package/src/components/TopicList.tsx +20 -5
  36. package/src/components/TypingIndicator.tsx +3 -3
  37. package/src/components/UserPicker.tsx +26 -25
  38. package/src/components/VirtualMessageList.tsx +345 -125
  39. package/src/context/ChatProvider.tsx +27 -1
  40. package/src/hooks/useChannelListUpdates.ts +22 -1
  41. package/src/hooks/useChannelMessages.ts +338 -51
  42. package/src/hooks/useChannelRowUpdates.ts +18 -6
  43. package/src/hooks/useChatUser.ts +9 -1
  44. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  45. package/src/hooks/useE2eeFileUpload.ts +38 -0
  46. package/src/hooks/useFileUpload.ts +25 -5
  47. package/src/hooks/useForwardMessage.ts +210 -13
  48. package/src/hooks/useLoadMessages.ts +16 -4
  49. package/src/hooks/useMentions.ts +60 -6
  50. package/src/hooks/useMessageActions.ts +14 -8
  51. package/src/hooks/useMessageSend.ts +64 -12
  52. package/src/hooks/usePendingE2eeSends.ts +29 -0
  53. package/src/hooks/useRecoveryPin.ts +287 -0
  54. package/src/hooks/useScrollToMessage.ts +29 -4
  55. package/src/hooks/useTopicGroupUpdates.ts +49 -11
  56. package/src/index.ts +23 -0
  57. package/src/messageTypeUtils.ts +14 -0
  58. package/src/styles/_channel-info.css +9 -0
  59. package/src/styles/_channel-list.css +37 -14
  60. package/src/styles/_media-lightbox.css +36 -3
  61. package/src/styles/_message-bubble.css +381 -41
  62. package/src/styles/_message-input.css +8 -0
  63. package/src/styles/_message-list.css +67 -10
  64. package/src/styles/_message-quick-reactions.css +101 -59
  65. package/src/styles/_message-reactions.css +18 -32
  66. package/src/styles/_recovery-pin.css +97 -0
  67. package/src/styles/_tokens.css +5 -5
  68. package/src/styles/_typing-indicator.css +23 -13
  69. package/src/styles/index.css +1 -0
  70. package/src/types.ts +115 -1
  71. package/src/utils/avatarColors.ts +1 -1
  72. package/src/utils.ts +38 -18
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import type { Channel, Event } from '@ermis-network/ermis-chat-sdk';
3
3
  import { useChatClient } from './useChatClient';
4
- import { isDirectChannel } from '../channelTypeUtils';
4
+ import { isDirectChannel, isGroupChannel } from '../channelTypeUtils';
5
5
  import { isPendingMember } from '../channelRoleUtils';
6
6
 
7
7
  /**
@@ -227,6 +227,27 @@ export function useChannelListUpdates(
227
227
  return [...prev];
228
228
  });
229
229
 
230
+ // For team channels with topics: re-watch to load topics from server.
231
+ // When the user was pending, queryChannels did not return topics.
232
+ // After accepting the invite, we need a fresh query to hydrate them.
233
+ if (eventCid) {
234
+ const existingChannel = client.activeChannels[eventCid];
235
+ if (existingChannel && isGroupChannel(existingChannel) && existingChannel.data?.topics_enabled) {
236
+ existingChannel.watch().then(() => {
237
+ // Notify React hooks (useTopicGroupUpdates) that topics have been loaded
238
+ existingChannel._callChannelListeners({
239
+ type: 'channel.updated',
240
+ cid: existingChannel.cid,
241
+ channel: existingChannel.data,
242
+ } as any);
243
+ // Also trigger channel list re-render
244
+ setChannels((p) => [...p]);
245
+ }).catch((err) => {
246
+ console.error('Failed to re-watch team channel after invite accepted:', err);
247
+ });
248
+ }
249
+ }
250
+
230
251
  // If the channel is NOT in the list yet (e.g. user just joined a public channel
231
252
  // from search), add it — same logic as handleChannelCreated
232
253
  if (eventCid) {
@@ -1,10 +1,14 @@
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>;
@@ -20,6 +24,14 @@ export type UseChannelMessagesOptions = {
20
24
  const fullyQueriedChannels = new Set<string>();
21
25
  export const markChannelAsFullyQueried = (cid: string) => fullyQueriedChannels.add(cid);
22
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;
33
+ };
34
+
23
35
  /**
24
36
  * Schedule multiple scroll-to-bottom attempts with increasing delays.
25
37
  * Handles content that changes height after initial render (images, embeds).
@@ -34,43 +46,89 @@ const SCROLL_DELAYS = [50, 200, 500, 1000];
34
46
  */
35
47
  export function useChannelMessages({
36
48
  scrollToBottom,
49
+ isNearBottom,
50
+ holdScrollLoadLock,
37
51
  jumpingRef,
38
52
  isAtBottomRef,
39
53
  onChannelSwitch,
40
54
  includeHiddenMessages = true,
41
55
  containerRef,
42
56
  }: UseChannelMessagesOptions): void {
43
- 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
+ );
44
92
 
45
93
  const scheduleScrollToBottom = useCallback(
46
- (smooth: boolean) => {
94
+ (smooth: boolean, force = false) => {
95
+ if (force) {
96
+ isAtBottomRef.current = true;
97
+ }
47
98
  if (smooth) {
48
99
  // Trigger smooth scroll exactly once, otherwise browsers will
49
100
  // cancel the smooth animation if called multiple times in a row
50
- setTimeout(() => scrollToBottom(true), 100);
101
+ setTimeout(() => {
102
+ if (!force && !shouldAutoScroll()) return;
103
+ scrollToBottom(true);
104
+ }, 100);
51
105
  } else {
52
106
  SCROLL_DELAYS.forEach((delay) => {
53
- setTimeout(() => scrollToBottom(false), delay);
107
+ setTimeout(() => {
108
+ if (!force && !shouldAutoScroll()) return;
109
+ scrollToBottom(false);
110
+ }, delay);
54
111
  });
55
112
  }
56
113
  },
57
- [scrollToBottom],
114
+ [scrollToBottom, isAtBottomRef, shouldAutoScroll],
58
115
  );
59
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
+
60
126
  useEffect(() => {
61
127
  if (!activeChannel) return;
62
128
 
63
129
  // Reset state for the new channel
64
130
  onChannelSwitch?.();
65
131
 
66
- // Manually force isAtBottom to true because we are jumping to the bottom.
67
- // jumpingRef blocks the resulting scroll event from updating isAtBottomRef,
68
- // so if it was false in the previous channel, it would stay false!
69
- isAtBottomRef.current = true;
70
-
71
- // Block scroll triggers during channel-switch scroll
72
- jumpingRef.current = true;
73
-
74
132
  // Instantly hide the list when channel changes
75
133
  const el = containerRef?.current;
76
134
  if (el) {
@@ -87,62 +145,238 @@ export function useChannelMessages({
87
145
  }, 50);
88
146
  };
89
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
+
90
278
  // Fetch hidden messages if not already done for this channel
91
279
  const cid = activeChannel.cid;
92
280
  if (includeHiddenMessages && cid && !fullyQueriedChannels.has(cid)) {
281
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
93
282
  activeChannel
94
283
  .query({
95
284
  messages: { limit: 25, include_hidden_messages: true },
96
285
  })
97
286
  .then(() => {
98
287
  fullyQueriedChannels.add(cid);
99
- syncMessages();
288
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
289
+ ensureE2eeChannelReady();
100
290
  // Sync initial read state from SDK so read receipts show immediately
101
291
  setReadState({ ...activeChannel.state.read });
102
292
  scheduleScrollToBottom(false);
103
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);
104
302
  })
105
303
  .catch((err: any) => {
106
304
  console.error('Failed to query channel on select', err);
107
305
  fadeListIn(); // Fade in anyway on error
306
+ setTimeout(() => {
307
+ jumpingRef.current = false;
308
+ }, 100);
108
309
  });
109
310
  } else {
110
- // Already queried or disabled: sync cache, scroll and fade in quickly
111
- syncMessages();
311
+ // Already queried: sync cache immediately for instant UI, scroll and fade in quickly
312
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
313
+ ensureE2eeChannelReady();
112
314
  // Sync initial read state from SDK so read receipts show immediately
113
315
  setReadState({ ...activeChannel.state.read });
114
316
  setTimeout(() => {
115
317
  scheduleScrollToBottom(false);
116
318
  fadeListIn();
117
319
  }, 0);
118
- }
320
+ // Release after a short delay so scrollToBottom's scroll event doesn't
321
+ // trigger loadMore
322
+ setTimeout(() => {
323
+ jumpingRef.current = false;
324
+ }, 100);
119
325
 
120
- // Wait long enough for scrollToBottom's internal retries and the browser
121
- // to execute the scroll event
122
- setTimeout(() => {
123
- jumpingRef.current = false;
124
- }, 100);
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
+ }
125
339
 
126
340
  const handleNewMessage = (event: Event) => {
127
341
  // Capture scroll state BEFORE sync causes re-render
128
- const wasAtBottom = isAtBottomRef.current;
129
-
130
- syncMessages();
131
-
342
+ const wasAtBottom = shouldAutoScroll();
132
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
+ }
349
+
350
+ syncMessagesWithE2eeCache();
133
351
 
134
- if (isOwnMessage || wasAtBottom) {
135
- scheduleScrollToBottom(true);
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);
136
360
  }
137
361
  };
138
362
 
139
- const handleMessageChange = (_event: Event) => {
140
- syncMessages();
363
+ const handleMessageChange = (event: Event) => {
364
+ syncMessagesWithE2eeCache();
141
365
  };
142
366
 
143
367
  const handleMessageRead = (_event: Event) => {
144
368
  // SDK already updated channel.state.read — sync into React state
145
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
+ }
146
380
  };
147
381
 
148
382
  const handleUnblocked = (event: Event) => {
@@ -152,7 +386,7 @@ export function useChannelMessages({
152
386
  activeChannel
153
387
  .query({ messages: { limit: 30 } })
154
388
  .then(() => {
155
- syncMessages();
389
+ syncMessagesWithE2eeCache({ includeStoredWindow: true });
156
390
  scheduleScrollToBottom(false);
157
391
  const isPending = isPendingMember(activeChannel.state?.membership?.channel_role as string);
158
392
  if (!isPending) {
@@ -163,30 +397,70 @@ export function useChannelMessages({
163
397
  }
164
398
  };
165
399
 
166
- const handleInviteAccepted = (event: Event) => {
167
- // Make sure the accepted invite corresponds to the actively opened channel
400
+ const refreshAfterOwnInviteMembership = (event: Event) => {
168
401
  const eventCid =
169
402
  event.cid ||
170
403
  event.channel?.cid ||
171
404
  ((event as any).channel_id ? `${(event as any).channel_type}:${(event as any).channel_id}` : undefined);
172
- if (eventCid === activeChannel.cid) {
173
- activeChannel
174
- .query({ messages: { limit: 30 } })
175
- .then(() => {
176
- syncMessages();
177
- scheduleScrollToBottom(false);
178
- activeChannel.markRead().catch(() => {});
179
- })
180
- .catch((e: any) => console.error('Failed to sync messages after accepting invite', e));
181
- }
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
+ });
182
423
  };
183
424
 
184
425
  const handleRecovery = () => {
185
- syncMessages();
186
- scheduleScrollToBottom(false);
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
+ });
187
443
  };
188
444
 
189
- const client = activeChannel.getClient();
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);
451
+ }
452
+ };
453
+
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();
190
464
  const sub1 = activeChannel.on('message.new', handleNewMessage);
191
465
  const sub2 = activeChannel.on('message.updated', handleMessageChange);
192
466
  const sub3 = activeChannel.on('message.deleted', handleMessageChange);
@@ -197,9 +471,16 @@ export function useChannelMessages({
197
471
  const sub8 = activeChannel.on('reaction.new', handleMessageChange);
198
472
  const sub9 = activeChannel.on('reaction.deleted', handleMessageChange);
199
473
  const sub10 = activeChannel.on('member.unblocked', handleUnblocked);
200
- const sub11 = client.on('notification.invite_accepted', handleInviteAccepted);
201
- const sub12 = client.on('connection.recovered', handleRecovery);
202
- const sub13 = client.on('channels.queried', handleRecovery);
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);
203
484
 
204
485
  return () => {
205
486
  sub1.unsubscribe();
@@ -215,6 +496,12 @@ export function useChannelMessages({
215
496
  sub11.unsubscribe();
216
497
  sub12.unsubscribe();
217
498
  sub13.unsubscribe();
499
+ sub14.unsubscribe();
500
+ sub15.unsubscribe();
501
+ sub16.unsubscribe();
502
+ sub17.unsubscribe();
503
+ sub18.unsubscribe();
504
+ sub19.unsubscribe();
218
505
  };
219
- }, [activeChannel, scrollToBottom, scheduleScrollToBottom, syncMessages, onChannelSwitch, setReadState]);
506
+ }, [activeChannel, client, scrollToBottom, scheduleScrollToBottom, shouldAutoScroll, snapToBottomAfterCommit, syncMessages, setMessages, onChannelSwitch, setReadState, holdScrollLoadLock]);
220
507
  }
@@ -42,6 +42,11 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
42
42
  };
43
43
 
44
44
  const handleUpdate = () => setUpdateCount((c) => c + 1);
45
+ const handleE2eePreviewUpdate = (event: any) => {
46
+ if (event?.cid === channel.cid) {
47
+ handleUpdate();
48
+ }
49
+ };
45
50
 
46
51
  const sub1 = channel.on('member.banned', handleBanned);
47
52
  const sub2 = channel.on('member.unbanned', handleUnbanned);
@@ -70,13 +75,17 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
70
75
  const sub12 = channel.on('channel.topic.created', handleUpdate);
71
76
  const sub13 = channel.on('channel.pinned', handleUpdate);
72
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);
73
82
 
74
83
  // Topic support: listen for ban events on parent channel too
75
- let sub15: { unsubscribe: () => void } | undefined;
76
- let sub16: { unsubscribe: () => void } | undefined;
84
+ let sub18: { unsubscribe: () => void } | undefined;
85
+ let sub19: { unsubscribe: () => void } | undefined;
77
86
  if (parentChannel) {
78
- sub15 = parentChannel.on('member.banned', handleBanned);
79
- sub16 = parentChannel.on('member.unbanned', handleUnbanned);
87
+ sub18 = parentChannel.on('member.banned', handleBanned);
88
+ sub19 = parentChannel.on('member.unbanned', handleUnbanned);
80
89
  }
81
90
 
82
91
  return () => {
@@ -95,8 +104,11 @@ export function useChannelRowUpdates(channel: Channel, currentUserId?: string) {
95
104
  sub12.unsubscribe();
96
105
  sub13.unsubscribe();
97
106
  sub14.unsubscribe();
98
- if (sub15) sub15.unsubscribe();
99
- if (sub16) sub16.unsubscribe();
107
+ sub15.unsubscribe();
108
+ sub16.unsubscribe();
109
+ sub17.unsubscribe();
110
+ if (sub18) sub18.unsubscribe();
111
+ if (sub19) sub19.unsubscribe();
100
112
  };
101
113
  }, [channel, currentUserId]);
102
114
 
@@ -14,7 +14,15 @@ export const useChatUser = <ErmisChatGenerics extends ExtendableGenerics = Defau
14
14
 
15
15
  const handleUserUpdated = (event: any) => {
16
16
  if (event.me) {
17
- setUser((prev) => ({ ...prev, ...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
+ });
18
26
  }
19
27
  };
20
28