@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
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
Channel,
|
|
4
|
+
E2eeAttachmentManifest,
|
|
5
|
+
E2eeAttachmentManifestAsset,
|
|
6
|
+
FormatMessageResponse,
|
|
7
|
+
} from '@ermis-network/ermis-chat-sdk';
|
|
8
|
+
import { createForwardMessagePayload } from '@ermis-network/ermis-chat-sdk';
|
|
9
|
+
import { useChatClient } from './useChatClient';
|
|
10
|
+
import { removeAccents, buildUserMap } from '../utils';
|
|
11
|
+
import { isPendingMember, isSkippedMember } from '../channelRoleUtils';
|
|
12
|
+
|
|
13
|
+
function isE2eeAttachmentManifest(attachment: unknown): attachment is E2eeAttachmentManifest {
|
|
14
|
+
return Boolean(
|
|
15
|
+
attachment &&
|
|
16
|
+
typeof attachment === 'object' &&
|
|
17
|
+
(attachment as E2eeAttachmentManifest).version === 1 &&
|
|
18
|
+
typeof (attachment as E2eeAttachmentManifest).attachment_id === 'string' &&
|
|
19
|
+
Array.isArray((attachment as E2eeAttachmentManifest).assets),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isEffectiveE2ee(channel: Channel | null | undefined): boolean {
|
|
24
|
+
if (!channel) return false;
|
|
25
|
+
return typeof (channel as any)._isEffectiveE2ee === 'function'
|
|
26
|
+
? (channel as any)._isEffectiveE2ee()
|
|
27
|
+
: channel.data?.mls_enabled === true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function attachmentUrl(attachment: any): string | undefined {
|
|
31
|
+
return attachment?.asset_url || attachment?.image_url || attachment?.url || attachment?.thumb_url;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function originalAsset(attachment: E2eeAttachmentManifest): E2eeAttachmentManifestAsset | undefined {
|
|
35
|
+
return attachment.assets.find((asset) => asset.kind === 'original') || attachment.assets[0];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function displayString(display: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
39
|
+
const value = display?.[key];
|
|
40
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function displayNumber(display: Record<string, unknown> | undefined, key: string): number | undefined {
|
|
44
|
+
const value = display?.[key];
|
|
45
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extensionForMimeType(mimeType?: string): string {
|
|
49
|
+
if (!mimeType) return '';
|
|
50
|
+
if (mimeType === 'image/jpeg') return '.jpg';
|
|
51
|
+
if (mimeType === 'image/png') return '.png';
|
|
52
|
+
if (mimeType === 'image/webp') return '.webp';
|
|
53
|
+
if (mimeType === 'image/gif') return '.gif';
|
|
54
|
+
if (mimeType === 'video/mp4') return '.mp4';
|
|
55
|
+
if (mimeType === 'video/quicktime') return '.mov';
|
|
56
|
+
if (mimeType === 'video/webm') return '.webm';
|
|
57
|
+
if (mimeType === 'audio/mpeg') return '.mp3';
|
|
58
|
+
if (mimeType === 'audio/webm') return '.webm';
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function inferAttachmentType(attachment: any, mimeType?: string): string | undefined {
|
|
63
|
+
const explicitType = attachment?.attachment_type || attachment?.type;
|
|
64
|
+
if (explicitType === 'voiceRecording') return 'voiceRecording';
|
|
65
|
+
if (explicitType === 'image' || explicitType === 'video' || explicitType === 'file') return explicitType;
|
|
66
|
+
if (mimeType?.startsWith('image/')) return 'image';
|
|
67
|
+
if (mimeType?.startsWith('video/')) return 'video';
|
|
68
|
+
if (mimeType?.startsWith('audio/')) return 'file';
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sourceAttachmentMetadata(
|
|
73
|
+
attachment: any,
|
|
74
|
+
blob: Blob,
|
|
75
|
+
index: number,
|
|
76
|
+
): { file: File; displayOverride: Record<string, unknown> } {
|
|
77
|
+
const manifestDisplay = isE2eeAttachmentManifest(attachment) ? originalAsset(attachment)?.display : undefined;
|
|
78
|
+
const sourceName =
|
|
79
|
+
displayString(manifestDisplay, 'name') ||
|
|
80
|
+
attachment?.file_name ||
|
|
81
|
+
attachment?.title ||
|
|
82
|
+
attachment?.name ||
|
|
83
|
+
`forwarded-attachment-${index + 1}`;
|
|
84
|
+
const sourceMime =
|
|
85
|
+
displayString(manifestDisplay, 'mime_type') ||
|
|
86
|
+
attachment?.mime_type ||
|
|
87
|
+
attachment?.content_type ||
|
|
88
|
+
blob.type ||
|
|
89
|
+
'application/octet-stream';
|
|
90
|
+
const hasExtension = /\.[A-Za-z0-9]{1,8}$/.test(sourceName);
|
|
91
|
+
const name = hasExtension ? sourceName : `${sourceName}${extensionForMimeType(sourceMime)}`;
|
|
92
|
+
const normalizedBlob = new File([blob], name, { type: sourceMime || blob.type || 'application/octet-stream' });
|
|
93
|
+
const attachmentType = inferAttachmentType(attachment, sourceMime);
|
|
94
|
+
const width = displayNumber(manifestDisplay, 'width') || attachment?.original_width || attachment?.width;
|
|
95
|
+
const height = displayNumber(manifestDisplay, 'height') || attachment?.original_height || attachment?.height;
|
|
96
|
+
const duration = displayNumber(manifestDisplay, 'duration') || attachment?.duration;
|
|
97
|
+
const displayOverride: Record<string, unknown> = {
|
|
98
|
+
name,
|
|
99
|
+
mime_type: sourceMime,
|
|
100
|
+
size: blob.size,
|
|
101
|
+
...(attachmentType ? { attachment_type: attachmentType } : {}),
|
|
102
|
+
...(typeof width === 'number' && Number.isFinite(width) ? { width } : {}),
|
|
103
|
+
...(typeof height === 'number' && Number.isFinite(height) ? { height } : {}),
|
|
104
|
+
...(typeof duration === 'number' && Number.isFinite(duration) ? { duration } : {}),
|
|
105
|
+
};
|
|
106
|
+
return { file: normalizedBlob, displayOverride };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function materializeForwardSourceAttachments(
|
|
110
|
+
manager: any,
|
|
111
|
+
sourceChannel: Channel,
|
|
112
|
+
sourceAttachments: any[],
|
|
113
|
+
): Promise<{ files: File[]; displayOverrides: Map<number, Record<string, unknown>> }> {
|
|
114
|
+
if (!manager?.initialized) throw new Error('E2EE forward requires an initialized encryption manager');
|
|
115
|
+
const files: File[] = [];
|
|
116
|
+
const displayOverrides = new Map<number, Record<string, unknown>>();
|
|
117
|
+
|
|
118
|
+
for (const [index, attachment] of sourceAttachments.entries()) {
|
|
119
|
+
let sourceBlob: Blob;
|
|
120
|
+
if (isE2eeAttachmentManifest(attachment)) {
|
|
121
|
+
sourceBlob = await manager.downloadE2eeAttachmentAsset(
|
|
122
|
+
sourceChannel.type,
|
|
123
|
+
sourceChannel.id!,
|
|
124
|
+
attachment,
|
|
125
|
+
'original',
|
|
126
|
+
);
|
|
127
|
+
} else {
|
|
128
|
+
const url = attachmentUrl(attachment);
|
|
129
|
+
if (!url) throw new Error('Forward source attachment has no downloadable URL');
|
|
130
|
+
const response = await fetch(url);
|
|
131
|
+
if (!response.ok) throw new Error(`Forward source attachment download failed: HTTP ${response.status}`);
|
|
132
|
+
sourceBlob = await response.blob();
|
|
133
|
+
}
|
|
134
|
+
const { file, displayOverride } = sourceAttachmentMetadata(attachment, sourceBlob, index);
|
|
135
|
+
files.push(file);
|
|
136
|
+
displayOverrides.set(index, displayOverride);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { files, displayOverrides };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function useForwardMessage(message: FormatMessageResponse, onDismiss: () => void) {
|
|
143
|
+
const { client, activeChannel } = useChatClient();
|
|
144
|
+
const [selectedChannels, setSelectedChannels] = useState<Set<string>>(new Set());
|
|
145
|
+
const [search, setSearch] = useState('');
|
|
146
|
+
const [sending, setSending] = useState(false);
|
|
147
|
+
const [results, setResults] = useState<{ success: string[]; failed: string[] } | null>(null);
|
|
148
|
+
|
|
149
|
+
/* ---------- Get channels from client state (include topics) ---------- */
|
|
150
|
+
const channels = useMemo(() => {
|
|
151
|
+
return (Object.values(client.activeChannels) as Channel[]).filter((ch) => {
|
|
152
|
+
const role = ch.state?.membership?.channel_role as string;
|
|
153
|
+
return !isPendingMember(role) && !isSkippedMember(role);
|
|
154
|
+
});
|
|
155
|
+
}, [client.activeChannels]);
|
|
156
|
+
|
|
157
|
+
/* ---------- Filter by search ---------- */
|
|
158
|
+
const filteredChannels = useMemo(() => {
|
|
159
|
+
if (!search.trim()) return channels;
|
|
160
|
+
const q = search.toLowerCase();
|
|
161
|
+
const cleanQ = removeAccents(q);
|
|
162
|
+
const isStrict = q !== cleanQ;
|
|
163
|
+
|
|
164
|
+
const result: Channel[] = [];
|
|
165
|
+
for (const ch of channels) {
|
|
166
|
+
const name = (ch.data?.name || ch.cid) as string;
|
|
167
|
+
const t = name.toLowerCase();
|
|
168
|
+
const cleanT = removeAccents(t);
|
|
169
|
+
|
|
170
|
+
const parentCid = ch.data?.parent_cid as string | undefined;
|
|
171
|
+
const parent = parentCid ? client.activeChannels[parentCid] : null;
|
|
172
|
+
const parentName = parent?.data?.name || '';
|
|
173
|
+
const pt = parentName.toLowerCase();
|
|
174
|
+
const cleanPT = removeAccents(pt);
|
|
175
|
+
|
|
176
|
+
let matched = false;
|
|
177
|
+
if (isStrict) {
|
|
178
|
+
// Strict match when query has accents
|
|
179
|
+
matched = t.startsWith(q) || pt.startsWith(q);
|
|
180
|
+
} else {
|
|
181
|
+
// Broad match when query is accent-less
|
|
182
|
+
matched = cleanT.startsWith(cleanQ) || cleanPT.startsWith(cleanQ);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (matched) {
|
|
186
|
+
result.push(ch);
|
|
187
|
+
if (result.length >= 50) break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
}, [channels, search, client.activeChannels]);
|
|
192
|
+
|
|
193
|
+
/* ---------- Toggle selection ---------- */
|
|
194
|
+
const toggleChannel = useCallback((channel: Channel) => {
|
|
195
|
+
setSelectedChannels((prev) => {
|
|
196
|
+
const next = new Set(prev);
|
|
197
|
+
if (next.has(channel.cid)) {
|
|
198
|
+
next.delete(channel.cid);
|
|
199
|
+
} else {
|
|
200
|
+
next.add(channel.cid);
|
|
201
|
+
}
|
|
202
|
+
return next;
|
|
203
|
+
});
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
206
|
+
/* ---------- Send forward ---------- */
|
|
207
|
+
const handleSend = useCallback(async () => {
|
|
208
|
+
if (!activeChannel || selectedChannels.size === 0 || sending) return;
|
|
209
|
+
setSending(true);
|
|
210
|
+
const success: string[] = [];
|
|
211
|
+
const failed: string[] = [];
|
|
212
|
+
let queuedBackgroundForward = false;
|
|
213
|
+
|
|
214
|
+
// Format message text to replace mention IDs with names
|
|
215
|
+
let formattedMessage = { ...message };
|
|
216
|
+
if (formattedMessage.text && formattedMessage.mentioned_users && formattedMessage.mentioned_users.length > 0) {
|
|
217
|
+
let newText = formattedMessage.text;
|
|
218
|
+
const userMap = buildUserMap(activeChannel.state);
|
|
219
|
+
|
|
220
|
+
formattedMessage.mentioned_users.forEach((userId) => {
|
|
221
|
+
const name = userMap[userId] || client.state.users[userId]?.name || userId;
|
|
222
|
+
newText = newText.replace(new RegExp(`@${userId}`, 'g'), `@${name}`);
|
|
223
|
+
});
|
|
224
|
+
formattedMessage.text = newText;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const cid of selectedChannels) {
|
|
228
|
+
const targetChannel = channels.find((c) => c.cid === cid);
|
|
229
|
+
if (!targetChannel) continue;
|
|
230
|
+
try {
|
|
231
|
+
if (!['messaging', 'team', 'topic'].includes(activeChannel.type)) {
|
|
232
|
+
throw new Error('Forward source channel type is not allowed');
|
|
233
|
+
}
|
|
234
|
+
if (formattedMessage.quoted_message_id || formattedMessage.parent_id) {
|
|
235
|
+
throw new Error('Reply/thread messages cannot be forwarded');
|
|
236
|
+
}
|
|
237
|
+
if (formattedMessage.mentioned_all || (formattedMessage.mentioned_users?.length || 0) > 0) {
|
|
238
|
+
throw new Error('Mention messages cannot be forwarded');
|
|
239
|
+
}
|
|
240
|
+
const targetIsE2ee = isEffectiveE2ee(targetChannel);
|
|
241
|
+
const sourceIsE2ee = isEffectiveE2ee(activeChannel);
|
|
242
|
+
if (sourceIsE2ee && !targetIsE2ee) {
|
|
243
|
+
const accepted =
|
|
244
|
+
typeof window === 'undefined' ||
|
|
245
|
+
window.confirm('Forwarding this encrypted message to a standard channel will remove E2EE protection.');
|
|
246
|
+
if (!accepted) throw new Error('Privacy downgrade canceled');
|
|
247
|
+
}
|
|
248
|
+
const forwardPayload = createForwardMessagePayload(
|
|
249
|
+
formattedMessage,
|
|
250
|
+
targetChannel.cid as string,
|
|
251
|
+
activeChannel.cid as string,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const sourceAttachments = (formattedMessage.attachments as any[] | undefined) || [];
|
|
255
|
+
if (targetIsE2ee && sourceAttachments.length > 0) {
|
|
256
|
+
const { files, displayOverrides } = await materializeForwardSourceAttachments(
|
|
257
|
+
client.encryptionManager,
|
|
258
|
+
activeChannel,
|
|
259
|
+
sourceAttachments,
|
|
260
|
+
);
|
|
261
|
+
await (targetChannel as any).enqueueE2eeAttachmentMessage(forwardPayload, files, { displayOverrides });
|
|
262
|
+
queuedBackgroundForward = true;
|
|
263
|
+
} else {
|
|
264
|
+
const standardForwardPayload = { ...forwardPayload };
|
|
265
|
+
if (!targetIsE2ee && sourceIsE2ee && sourceAttachments.length > 0) {
|
|
266
|
+
const { files } = await materializeForwardSourceAttachments(
|
|
267
|
+
client.encryptionManager,
|
|
268
|
+
activeChannel,
|
|
269
|
+
sourceAttachments,
|
|
270
|
+
);
|
|
271
|
+
const { attachments, failedFiles } = await targetChannel.uploadAndPrepareAttachments(files);
|
|
272
|
+
if (failedFiles.length > 0) {
|
|
273
|
+
throw new Error(`Forward standard attachment upload failed for ${failedFiles.length} file(s)`);
|
|
274
|
+
}
|
|
275
|
+
standardForwardPayload.attachments = attachments as any;
|
|
276
|
+
}
|
|
277
|
+
await targetChannel.forwardMessage(standardForwardPayload, {
|
|
278
|
+
type: targetChannel.type,
|
|
279
|
+
channelID: targetChannel.id!,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
success.push((targetChannel.data?.name || targetChannel.cid) as string);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
console.error(`Failed to forward to ${cid}`, err);
|
|
285
|
+
failed.push((targetChannel.data?.name || targetChannel.cid) as string);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
setResults({ success, failed });
|
|
290
|
+
setSending(false);
|
|
291
|
+
|
|
292
|
+
// Auto-close after success (short delay)
|
|
293
|
+
if (failed.length === 0) {
|
|
294
|
+
setTimeout(() => onDismiss(), queuedBackgroundForward ? 0 : 1200);
|
|
295
|
+
}
|
|
296
|
+
}, [client, activeChannel, selectedChannels, channels, message, sending, onDismiss]);
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
search,
|
|
300
|
+
setSearch,
|
|
301
|
+
selectedChannels,
|
|
302
|
+
toggleChannel,
|
|
303
|
+
sending,
|
|
304
|
+
results,
|
|
305
|
+
setResults,
|
|
306
|
+
filteredChannels,
|
|
307
|
+
handleSend,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useEffect, useState, useMemo } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from './useChatClient';
|
|
4
|
+
import { isPendingMember } from '../channelRoleUtils';
|
|
5
|
+
import { isTopicChannel } from '../channelTypeUtils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A hook that retrieves all pending invite channels from the SDK's local cache
|
|
9
|
+
* without triggering an extra API network query.
|
|
10
|
+
*
|
|
11
|
+
* Re-renders automatically when related events (e.g., invites, accepts, deletes) arrive.
|
|
12
|
+
*/
|
|
13
|
+
export function useInviteChannels(): Channel[] {
|
|
14
|
+
const { client } = useChatClient();
|
|
15
|
+
const [updateCount, setUpdateCount] = useState(0);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!client) return;
|
|
19
|
+
|
|
20
|
+
const forceUpdate = () => setUpdateCount((c) => c + 1);
|
|
21
|
+
|
|
22
|
+
const handleEvent = (event: any) => {
|
|
23
|
+
// If a new channel is created or we are added to it, wait for SDK initialization
|
|
24
|
+
const isNewChannelEvent =
|
|
25
|
+
event.type === 'member.added' ||
|
|
26
|
+
event.type === 'notification.added_to_channel' ||
|
|
27
|
+
event.type === 'channel.created';
|
|
28
|
+
|
|
29
|
+
if (isNewChannelEvent) {
|
|
30
|
+
const cid =
|
|
31
|
+
event.channel?.cid ||
|
|
32
|
+
event.cid ||
|
|
33
|
+
(event.channel_type && event.channel_id ? `${event.channel_type}:${event.channel_id}` : null);
|
|
34
|
+
|
|
35
|
+
if (cid) {
|
|
36
|
+
console.log('[useInviteChannels] Received new channel event:', event.type, cid);
|
|
37
|
+
let attempts = 0;
|
|
38
|
+
const checkInitialized = setInterval(() => {
|
|
39
|
+
attempts++;
|
|
40
|
+
const channel = client.activeChannels[cid];
|
|
41
|
+
if ((channel && channel.initialized) || attempts > 30) {
|
|
42
|
+
console.log(
|
|
43
|
+
'[useInviteChannels] Channel initialized or timeout:',
|
|
44
|
+
cid,
|
|
45
|
+
'initialized:',
|
|
46
|
+
channel?.initialized,
|
|
47
|
+
'attempts:',
|
|
48
|
+
attempts,
|
|
49
|
+
);
|
|
50
|
+
clearInterval(checkInitialized);
|
|
51
|
+
forceUpdate();
|
|
52
|
+
}
|
|
53
|
+
}, 100);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
setTimeout(forceUpdate, 0);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const listeners = [
|
|
61
|
+
client.on('channels.queried', handleEvent),
|
|
62
|
+
client.on('notification.invite_accepted', handleEvent),
|
|
63
|
+
client.on('notification.invite_rejected', handleEvent),
|
|
64
|
+
client.on('notification.invite_messaging_skipped', handleEvent),
|
|
65
|
+
client.on('channel.created', handleEvent),
|
|
66
|
+
client.on('channel.deleted', handleEvent),
|
|
67
|
+
client.on('notification.channel_deleted', handleEvent),
|
|
68
|
+
client.on('member.added', handleEvent),
|
|
69
|
+
client.on('member.removed', handleEvent),
|
|
70
|
+
client.on('notification.added_to_channel' as any, handleEvent),
|
|
71
|
+
client.on('notification.invited' as any, handleEvent),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
return () => listeners.forEach((l) => l.unsubscribe());
|
|
75
|
+
}, [client]);
|
|
76
|
+
|
|
77
|
+
return useMemo(() => {
|
|
78
|
+
if (!client) return [];
|
|
79
|
+
|
|
80
|
+
return Object.values(client.activeChannels).filter((ch) => {
|
|
81
|
+
// Exclude topic channels from the invites list
|
|
82
|
+
if (isTopicChannel(ch)) return false;
|
|
83
|
+
|
|
84
|
+
const ms = ch.state?.membership as Record<string, unknown> | undefined;
|
|
85
|
+
return isPendingMember(ms?.channel_role as string);
|
|
86
|
+
});
|
|
87
|
+
}, [client, updateCount]);
|
|
88
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useChatClient } from './useChatClient';
|
|
3
|
+
import { isPendingMember } from '../channelRoleUtils';
|
|
4
|
+
import { isTopicChannel } from '../channelTypeUtils';
|
|
5
|
+
|
|
6
|
+
export const useInviteCount = () => {
|
|
7
|
+
const { client } = useChatClient();
|
|
8
|
+
const [inviteCount, setInviteCount] = useState(0);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!client || !client.user) return;
|
|
12
|
+
|
|
13
|
+
const countInvites = () => {
|
|
14
|
+
let count = 0;
|
|
15
|
+
const channels = Object.values(client.activeChannels);
|
|
16
|
+
const userId = client.user?.id;
|
|
17
|
+
if (!userId) return 0;
|
|
18
|
+
for (const channel of channels) {
|
|
19
|
+
// Exclude topic channels from the count
|
|
20
|
+
if (isTopicChannel(channel)) continue;
|
|
21
|
+
|
|
22
|
+
const membership = channel.state?.membership || channel.state?.members?.[userId];
|
|
23
|
+
if (isPendingMember(membership?.channel_role as string)) {
|
|
24
|
+
count++;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return count;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Calculate initial count
|
|
31
|
+
setInviteCount(countInvites());
|
|
32
|
+
|
|
33
|
+
const handleEvent = (event: any) => {
|
|
34
|
+
if (
|
|
35
|
+
event.type === 'channel.created' &&
|
|
36
|
+
(event.user?.id === client.user?.id || event.user_id === client.user?.id)
|
|
37
|
+
) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// If a new channel is created or we are added to it, wait for SDK initialization
|
|
42
|
+
const isNewChannelEvent =
|
|
43
|
+
event.type === 'member.added' ||
|
|
44
|
+
event.type === 'notification.added_to_channel' ||
|
|
45
|
+
event.type === 'channel.created';
|
|
46
|
+
|
|
47
|
+
if (isNewChannelEvent) {
|
|
48
|
+
const cid =
|
|
49
|
+
event.channel?.cid ||
|
|
50
|
+
event.cid ||
|
|
51
|
+
(event.channel_type && event.channel_id ? `${event.channel_type}:${event.channel_id}` : null);
|
|
52
|
+
|
|
53
|
+
if (cid) {
|
|
54
|
+
console.log('[useInviteCount] Received new channel event:', event.type, cid);
|
|
55
|
+
let attempts = 0;
|
|
56
|
+
const checkInitialized = setInterval(() => {
|
|
57
|
+
attempts++;
|
|
58
|
+
const channel = client.activeChannels[cid];
|
|
59
|
+
if ((channel && channel.initialized) || attempts > 30) {
|
|
60
|
+
console.log(
|
|
61
|
+
'[useInviteCount] Channel initialized or timeout:',
|
|
62
|
+
cid,
|
|
63
|
+
'initialized:',
|
|
64
|
+
channel?.initialized,
|
|
65
|
+
'attempts:',
|
|
66
|
+
attempts,
|
|
67
|
+
);
|
|
68
|
+
clearInterval(checkInitialized);
|
|
69
|
+
const newCount = countInvites();
|
|
70
|
+
console.log('[useInviteCount] New invite count:', newCount);
|
|
71
|
+
setInviteCount(newCount);
|
|
72
|
+
}
|
|
73
|
+
}, 100);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Delay slightly to ensure client.activeChannels is updated by SDK internal handlers first
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
setInviteCount(countInvites());
|
|
81
|
+
}, 0);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const listeners = [
|
|
85
|
+
client.on('channels.queried', handleEvent),
|
|
86
|
+
client.on('notification.invite_accepted', handleEvent),
|
|
87
|
+
client.on('notification.invite_rejected', handleEvent),
|
|
88
|
+
client.on('notification.invite_messaging_skipped', handleEvent),
|
|
89
|
+
client.on('channel.created', handleEvent),
|
|
90
|
+
client.on('channel.deleted', handleEvent),
|
|
91
|
+
client.on('notification.channel_deleted', handleEvent),
|
|
92
|
+
client.on('member.added', handleEvent),
|
|
93
|
+
client.on('member.removed', handleEvent),
|
|
94
|
+
client.on('notification.added_to_channel' as any, handleEvent),
|
|
95
|
+
client.on('notification.invited' as any, handleEvent),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
return () => {
|
|
99
|
+
listeners.forEach((l) => l.unsubscribe());
|
|
100
|
+
};
|
|
101
|
+
}, [client]);
|
|
102
|
+
|
|
103
|
+
return { inviteCount };
|
|
104
|
+
};
|
|
@@ -21,6 +21,8 @@ export type UseLoadMessagesOptions = {
|
|
|
21
21
|
messagesRef: React.MutableRefObject<FormatMessageResponse[]>;
|
|
22
22
|
/** Shared guard ref — skip scroll-triggered loads during jump transitions */
|
|
23
23
|
jumpingRef: React.MutableRefObject<boolean>;
|
|
24
|
+
/** Blocks scroll-triggered pagination while auto-following appended messages. */
|
|
25
|
+
scrollLoadLockRef?: React.MutableRefObject<boolean>;
|
|
24
26
|
loadMoreLimit?: number;
|
|
25
27
|
};
|
|
26
28
|
|
|
@@ -45,6 +47,7 @@ export function useLoadMessages({
|
|
|
45
47
|
vlistRef,
|
|
46
48
|
messagesRef,
|
|
47
49
|
jumpingRef,
|
|
50
|
+
scrollLoadLockRef,
|
|
48
51
|
loadMoreLimit = 25,
|
|
49
52
|
}: UseLoadMessagesOptions): UseLoadMessagesReturn {
|
|
50
53
|
const { activeChannel, setMessages } = useChatClient();
|
|
@@ -52,10 +55,19 @@ export function useLoadMessages({
|
|
|
52
55
|
const [hasNewer, setHasNewer] = useState(false);
|
|
53
56
|
const [shiftMode, setShiftMode] = useState(false);
|
|
54
57
|
|
|
55
|
-
//
|
|
58
|
+
// Reset shiftMode on channel switch so initial load isn't treated as a prepend
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setShiftMode(false);
|
|
61
|
+
}, [activeChannel?.cid]);
|
|
62
|
+
|
|
63
|
+
// Reset shiftMode to false after the prepend render is committed.
|
|
64
|
+
// shift should only be true for the single render cycle when older messages
|
|
65
|
+
// are prepended; leaving it on causes virtua to mis-compensate scroll
|
|
66
|
+
// positions when new messages are appended at the bottom, which leads
|
|
67
|
+
// to intermittent message overlapping.
|
|
56
68
|
useEffect(() => {
|
|
57
69
|
if (shiftMode) {
|
|
58
|
-
|
|
70
|
+
setShiftMode(false);
|
|
59
71
|
}
|
|
60
72
|
}, [shiftMode]);
|
|
61
73
|
|
|
@@ -135,7 +147,7 @@ export function useLoadMessages({
|
|
|
135
147
|
|
|
136
148
|
const handleScroll = useCallback(
|
|
137
149
|
(offset: number) => {
|
|
138
|
-
if (jumpingRef.current) return;
|
|
150
|
+
if (jumpingRef.current || scrollLoadLockRef?.current) return;
|
|
139
151
|
const handle = vlistRef.current;
|
|
140
152
|
if (!handle) return;
|
|
141
153
|
const { scrollSize, viewportSize } = handle;
|
|
@@ -157,7 +169,7 @@ export function useLoadMessages({
|
|
|
157
169
|
loadNewer();
|
|
158
170
|
}
|
|
159
171
|
},
|
|
160
|
-
[loadMore, loadNewer],
|
|
172
|
+
[loadMore, loadNewer, scrollLoadLockRef],
|
|
161
173
|
);
|
|
162
174
|
|
|
163
175
|
return {
|
package/src/hooks/useMentions.ts
CHANGED
|
@@ -119,6 +119,31 @@ function getActiveMentionIds(editableEl: HTMLElement): Set<string> {
|
|
|
119
119
|
return ids;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Binary search to find the first index where the name starts with the given prefix.
|
|
124
|
+
*/
|
|
125
|
+
function findFirstMatchIndex(arr: MentionMember[], prefix: string): number {
|
|
126
|
+
let low = 0;
|
|
127
|
+
let high = arr.length - 1;
|
|
128
|
+
let result = -1;
|
|
129
|
+
|
|
130
|
+
while (low <= high) {
|
|
131
|
+
const mid = Math.floor((low + high) / 2);
|
|
132
|
+
const name = (arr[mid].name || '').toLowerCase();
|
|
133
|
+
|
|
134
|
+
if (name.startsWith(prefix)) {
|
|
135
|
+
result = mid; // Found match, keep searching left for the first one
|
|
136
|
+
high = mid - 1;
|
|
137
|
+
} else if (name < prefix) {
|
|
138
|
+
low = mid + 1;
|
|
139
|
+
} else {
|
|
140
|
+
high = mid - 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
122
147
|
export function useMentions({
|
|
123
148
|
members,
|
|
124
149
|
currentUserId,
|
|
@@ -137,6 +162,17 @@ export function useMentions({
|
|
|
137
162
|
[],
|
|
138
163
|
);
|
|
139
164
|
|
|
165
|
+
// Pre-sort members for binary range search
|
|
166
|
+
const sortedMembers = useMemo(() => {
|
|
167
|
+
return [...members].sort((a, b) => {
|
|
168
|
+
const nameA = (a.name || '').toLowerCase();
|
|
169
|
+
const nameB = (b.name || '').toLowerCase();
|
|
170
|
+
if (nameA < nameB) return -1;
|
|
171
|
+
if (nameA > nameB) return 1;
|
|
172
|
+
return 0;
|
|
173
|
+
});
|
|
174
|
+
}, [members]);
|
|
175
|
+
|
|
140
176
|
// Filter members based on deferred query, exclude self and already-mentioned
|
|
141
177
|
const filteredMembers = useMemo(() => {
|
|
142
178
|
const q = deferredQuery.toLowerCase();
|
|
@@ -144,20 +180,37 @@ export function useMentions({
|
|
|
144
180
|
// Start with @all if not already selected
|
|
145
181
|
const result: MentionMember[] = [];
|
|
146
182
|
if (!activeMentionIds.has('__all__')) {
|
|
147
|
-
if (!q || 'all'.
|
|
183
|
+
if (!q || 'all'.startsWith(q)) {
|
|
148
184
|
result.push(allItem);
|
|
149
185
|
}
|
|
150
186
|
}
|
|
151
187
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
188
|
+
if (!q) {
|
|
189
|
+
for (const m of sortedMembers) {
|
|
190
|
+
if (m.id === currentUserId) continue; // skip self
|
|
191
|
+
result.push(m);
|
|
192
|
+
if (result.length >= 50) break;
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Range search using binary search
|
|
198
|
+
const startIndex = findFirstMatchIndex(sortedMembers, q);
|
|
199
|
+
if (startIndex !== -1) {
|
|
200
|
+
for (let i = startIndex; i < sortedMembers.length; i++) {
|
|
201
|
+
const m = sortedMembers[i];
|
|
202
|
+
const name = (m.name || '').toLowerCase();
|
|
203
|
+
|
|
204
|
+
if (!name.startsWith(q)) break; // End of range
|
|
205
|
+
|
|
206
|
+
if (m.id === currentUserId) continue; // skip self
|
|
207
|
+
result.push(m);
|
|
208
|
+
if (result.length >= 50) break;
|
|
209
|
+
}
|
|
157
210
|
}
|
|
158
211
|
|
|
159
212
|
return result;
|
|
160
|
-
}, [
|
|
213
|
+
}, [sortedMembers, deferredQuery, activeMentionIds, currentUserId, allItem]);
|
|
161
214
|
|
|
162
215
|
// Detect @ trigger from cursor position
|
|
163
216
|
const detectTrigger = useCallback((): { triggered: boolean; query: string } => {
|