@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.
- package/README.md +144 -0
- package/dist/index.cjs +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- 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 +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- 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(() =>
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
if (!force && !shouldAutoScroll()) return;
|
|
103
|
+
scrollToBottom(true);
|
|
104
|
+
}, 100);
|
|
41
105
|
} else {
|
|
42
106
|
SCROLL_DELAYS.forEach((delay) => {
|
|
43
|
-
setTimeout(() =>
|
|
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
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
82
|
-
|
|
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 = (
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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(
|
|
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
|
+
};
|