@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.
- package/README.md +144 -0
- package/dist/index.cjs +5087 -11279
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +632 -152
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +273 -9
- package/dist/index.d.ts +273 -9
- package/dist/index.mjs +5085 -11295
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/Channel.tsx +0 -3
- package/src/components/ChannelActions.tsx +6 -1
- package/src/components/ChannelHeader.tsx +8 -32
- package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
- package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
- package/src/components/ChannelList.tsx +72 -13
- package/src/components/CreateChannelModal.tsx +131 -12
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +27 -16
- package/src/components/ForwardMessageModal.tsx +11 -3
- package/src/components/MediaLightbox.tsx +444 -304
- package/src/components/MessageActionsBox.tsx +2 -0
- package/src/components/MessageInput.tsx +41 -12
- package/src/components/MessageItem.tsx +70 -25
- package/src/components/MessageQuickReactions.tsx +131 -128
- package/src/components/MessageReactions.tsx +47 -2
- package/src/components/MessageRenderers.tsx +1030 -433
- package/src/components/PinnedMessages.tsx +40 -12
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +20 -5
- package/src/components/TypingIndicator.tsx +3 -3
- package/src/components/UserPicker.tsx +26 -25
- package/src/components/VirtualMessageList.tsx +345 -125
- package/src/context/ChatProvider.tsx +27 -1
- package/src/hooks/useChannelListUpdates.ts +22 -1
- package/src/hooks/useChannelMessages.ts +338 -51
- package/src/hooks/useChannelRowUpdates.ts +18 -6
- package/src/hooks/useChatUser.ts +9 -1
- package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +210 -13
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -6
- package/src/hooks/useMessageActions.ts +14 -8
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useTopicGroupUpdates.ts +49 -11
- package/src/index.ts +23 -0
- package/src/messageTypeUtils.ts +14 -0
- package/src/styles/_channel-info.css +9 -0
- package/src/styles/_channel-list.css +37 -14
- package/src/styles/_media-lightbox.css +36 -3
- package/src/styles/_message-bubble.css +381 -41
- package/src/styles/_message-input.css +8 -0
- package/src/styles/_message-list.css +67 -10
- package/src/styles/_message-quick-reactions.css +101 -59
- package/src/styles/_message-reactions.css +18 -32
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +5 -5
- package/src/styles/_typing-indicator.css +23 -13
- package/src/styles/index.css +1 -0
- package/src/types.ts +115 -1
- package/src/utils/avatarColors.ts +1 -1
- 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(() =>
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
if (!force && !shouldAutoScroll()) return;
|
|
103
|
+
scrollToBottom(true);
|
|
104
|
+
}, 100);
|
|
51
105
|
} else {
|
|
52
106
|
SCROLL_DELAYS.forEach((delay) => {
|
|
53
|
-
setTimeout(() =>
|
|
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
|
-
|
|
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
|
|
111
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 =
|
|
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
|
|
135
|
-
|
|
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 = (
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
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 =
|
|
201
|
-
const sub12 =
|
|
202
|
-
|
|
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
|
|
76
|
-
let
|
|
84
|
+
let sub18: { unsubscribe: () => void } | undefined;
|
|
85
|
+
let sub19: { unsubscribe: () => void } | undefined;
|
|
77
86
|
if (parentChannel) {
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
package/src/hooks/useChatUser.ts
CHANGED
|
@@ -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) =>
|
|
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
|
|