@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
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,19 +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
|
-
|
|
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
|
+
}
|
|
156
210
|
}
|
|
157
211
|
|
|
158
212
|
return result;
|
|
159
|
-
}, [
|
|
213
|
+
}, [sortedMembers, deferredQuery, activeMentionIds, currentUserId, allItem]);
|
|
160
214
|
|
|
161
215
|
// Detect @ trigger from cursor position
|
|
162
216
|
const detectTrigger = useCallback((): { triggered: boolean; query: string } => {
|
|
@@ -21,6 +21,7 @@ export type MessageActionList = {
|
|
|
21
21
|
hasCapPin: boolean;
|
|
22
22
|
hasCapReply: boolean;
|
|
23
23
|
hasCapQuote: boolean;
|
|
24
|
+
hasCapReact: boolean;
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
export const useMessageActions = (message: FormatMessageResponse, isOwnMessage: boolean): MessageActionList => {
|
|
@@ -50,6 +51,7 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
|
|
|
50
51
|
hasCapPin: false,
|
|
51
52
|
hasCapReply: false,
|
|
52
53
|
hasCapQuote: false,
|
|
54
|
+
hasCapReact: false,
|
|
53
55
|
};
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -58,7 +60,9 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
|
|
|
58
60
|
const isSticker = isStickerMessage(message);
|
|
59
61
|
const isPinned = isPinnedFlag;
|
|
60
62
|
|
|
61
|
-
const
|
|
63
|
+
const isDeleted = message.display_type === 'deleted';
|
|
64
|
+
|
|
65
|
+
const canEdit = !isPreviewMode && !isSystem && !isSignal && !isSticker && isOwnMessage && !isDeleted;
|
|
62
66
|
|
|
63
67
|
// Delete for everyone:
|
|
64
68
|
// + Team channel: only the owner can perform this action natively.
|
|
@@ -66,13 +70,13 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
|
|
|
66
70
|
const canDeleteForEveryoneTeam = isTeam && isOwner;
|
|
67
71
|
const canDeleteForEveryoneMessaging = !isTeam && isOwnMessage;
|
|
68
72
|
|
|
69
|
-
const canDelete = !isPreviewMode && !isSystem && (canDeleteForEveryoneTeam || canDeleteForEveryoneMessaging);
|
|
70
|
-
const canDeleteForMe = !isPreviewMode && !isSystem;
|
|
71
|
-
const canReply = !isPreviewMode && !isSystem && !isSignal;
|
|
72
|
-
const canQuote = !isPreviewMode && !isSystem && !isSignal;
|
|
73
|
-
const canForward = !isPreviewMode && !isSystem && !isSignal;
|
|
74
|
-
const canPin = !isPreviewMode && !isSystem && !isSignal;
|
|
75
|
-
const canCopy = !isSystem && !isSignal && Boolean(message.text?.trim()); // Allow copy even in preview mode
|
|
73
|
+
const canDelete = !isPreviewMode && !isSystem && (canDeleteForEveryoneTeam || canDeleteForEveryoneMessaging) && !isDeleted;
|
|
74
|
+
const canDeleteForMe = !isPreviewMode && !isSystem && !isDeleted;
|
|
75
|
+
const canReply = !isPreviewMode && !isSystem && !isSignal && !isDeleted;
|
|
76
|
+
const canQuote = !isPreviewMode && !isSystem && !isSignal && !isDeleted;
|
|
77
|
+
const canForward = !isPreviewMode && !isSystem && !isSignal && !isDeleted;
|
|
78
|
+
const canPin = !isPreviewMode && !isSystem && !isSignal && !isDeleted;
|
|
79
|
+
const canCopy = !isSystem && !isSignal && Boolean(message.text?.trim()) && !isDeleted; // Allow copy even in preview mode
|
|
76
80
|
|
|
77
81
|
const hasCapEdit = hasCapability('update-own-message');
|
|
78
82
|
const hasCapDelete = !isTeam || isOwner || (isOwnMessage && hasCapability('delete-own-message'));
|
|
@@ -82,6 +86,7 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
|
|
|
82
86
|
const hasCapReply = hasCapability('send-reply');
|
|
83
87
|
const hasCapQuote = hasCapability('quote-message');
|
|
84
88
|
const hasCapPin = hasCapability('pin-message');
|
|
89
|
+
const hasCapReact = hasCapability('send-reaction');
|
|
85
90
|
|
|
86
91
|
return {
|
|
87
92
|
canEdit,
|
|
@@ -99,6 +104,7 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
|
|
|
99
104
|
hasCapPin,
|
|
100
105
|
hasCapReply,
|
|
101
106
|
hasCapQuote,
|
|
107
|
+
hasCapReact,
|
|
102
108
|
};
|
|
103
109
|
}, [activeChannel, isTeam, isOwner, hasCapability, messageType, message.text, isPinnedFlag, isOwnMessage, isPreviewMode]); // Use capabilities from hook
|
|
104
110
|
};
|
|
@@ -58,7 +58,12 @@ export function useMessageSend({
|
|
|
58
58
|
|
|
59
59
|
const payload = buildPayload();
|
|
60
60
|
const text = payload.text.trim();
|
|
61
|
-
const
|
|
61
|
+
const isE2eeChannelForFiles =
|
|
62
|
+
!!activeChannel &&
|
|
63
|
+
(typeof (activeChannel as any)._isEffectiveE2ee === 'function'
|
|
64
|
+
? (activeChannel as any)._isEffectiveE2ee()
|
|
65
|
+
: activeChannel.data?.mls_enabled === true);
|
|
66
|
+
const uploadedFiles = files.filter((f) => f.status === 'done' || (isE2eeChannelForFiles && f.status === 'pending'));
|
|
62
67
|
|
|
63
68
|
if (!text && uploadedFiles.length === 0) return;
|
|
64
69
|
|
|
@@ -73,15 +78,49 @@ export function useMessageSend({
|
|
|
73
78
|
|
|
74
79
|
try {
|
|
75
80
|
setSending(true);
|
|
81
|
+
const isE2eeChannel =
|
|
82
|
+
typeof (activeChannel as any)._isEffectiveE2ee === 'function'
|
|
83
|
+
? (activeChannel as any)._isEffectiveE2ee()
|
|
84
|
+
: activeChannel.data?.mls_enabled === true;
|
|
76
85
|
|
|
77
86
|
// Build attachment payloads from already-uploaded files (only applied on new messages)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
let attachments: unknown[] = [];
|
|
88
|
+
let e2eeAttachmentIds: string[] | undefined;
|
|
89
|
+
if (isE2eeChannel && !editingMessage && uploadedFiles.length > 0) {
|
|
90
|
+
const encryptionMgr = (activeChannel as any).getClient?.().encryptionManager;
|
|
91
|
+
if (!encryptionMgr?.initialized) throw new Error('E2EE attachments require an initialized encryption manager');
|
|
92
|
+
const filesToUpload = uploadedFiles.map((f) => f.normalizedFile || f.file!).filter(Boolean);
|
|
93
|
+
const message: Record<string, any> = { text };
|
|
94
|
+
if (isTeamChannel) {
|
|
95
|
+
message.mentioned_all = payload.mentioned_all;
|
|
96
|
+
message.mentioned_users = payload.mentioned_users;
|
|
81
97
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
98
|
+
if (quotedMessage?.id) {
|
|
99
|
+
message.quoted_message_id = quotedMessage.id;
|
|
100
|
+
}
|
|
101
|
+
await (activeChannel as any).enqueueE2eeAttachmentMessage(message, filesToUpload);
|
|
102
|
+
syncMessages();
|
|
103
|
+
|
|
104
|
+
files.forEach((f) => {
|
|
105
|
+
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
|
|
106
|
+
});
|
|
107
|
+
const errorFiles = files.filter((f) => f.status === 'error');
|
|
108
|
+
setFiles(errorFiles);
|
|
109
|
+
setHasContent(errorFiles.length > 0);
|
|
110
|
+
reset();
|
|
111
|
+
clearQuotedMessage?.();
|
|
112
|
+
onSend?.(payload.text);
|
|
113
|
+
activeChannel?.stopTyping();
|
|
114
|
+
return;
|
|
115
|
+
} else {
|
|
116
|
+
attachments = uploadedFiles.map((f) => {
|
|
117
|
+
if (f.originalAttachment) {
|
|
118
|
+
return f.originalAttachment;
|
|
119
|
+
}
|
|
120
|
+
const fileObj = f.normalizedFile || f.file!;
|
|
121
|
+
return buildAttachmentPayload(fileObj, f.uploadedUrl!, f.thumbUrl);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
85
124
|
|
|
86
125
|
// Build message
|
|
87
126
|
const message: Record<string, any> = { text };
|
|
@@ -90,6 +129,9 @@ export function useMessageSend({
|
|
|
90
129
|
if (!editingMessage && attachments.length > 0) {
|
|
91
130
|
message.attachments = attachments;
|
|
92
131
|
}
|
|
132
|
+
if (!editingMessage && e2eeAttachmentIds && e2eeAttachmentIds.length > 0) {
|
|
133
|
+
message.e2ee_attachment_ids = e2eeAttachmentIds;
|
|
134
|
+
}
|
|
93
135
|
|
|
94
136
|
if (isTeamChannel) {
|
|
95
137
|
message.mentioned_all = payload.mentioned_all;
|
|
@@ -130,11 +172,17 @@ export function useMessageSend({
|
|
|
130
172
|
// --- 2. DELEGATE TO WEBSOCKET ---
|
|
131
173
|
// The API call runs in background. We do not block the UI for resolution.
|
|
132
174
|
// Message lists will automatically update when the backend blasts the `message.new` WS event.
|
|
133
|
-
sendPromise
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
175
|
+
sendPromise
|
|
176
|
+
.then(() => {
|
|
177
|
+
// E2EE own-device WS events may arrive before the SDK replaces the
|
|
178
|
+
// optimistic message with the confirmed local plaintext snapshot.
|
|
179
|
+
syncMessages();
|
|
180
|
+
})
|
|
181
|
+
.catch((err: Error) => {
|
|
182
|
+
console.error('Failed to send message over API:', err);
|
|
183
|
+
// Sync React to render the SDK's internal 'status: failed' UI state
|
|
184
|
+
syncMessages();
|
|
185
|
+
});
|
|
138
186
|
} catch (err) {
|
|
139
187
|
console.error('Failed to process message send:', err);
|
|
140
188
|
} finally {
|
|
@@ -158,6 +206,10 @@ export function useMessageSend({
|
|
|
158
206
|
editableRef,
|
|
159
207
|
setFiles,
|
|
160
208
|
setHasContent,
|
|
209
|
+
quotedMessage,
|
|
210
|
+
clearQuotedMessage,
|
|
211
|
+
editingMessage,
|
|
212
|
+
clearEditingMessage,
|
|
161
213
|
]);
|
|
162
214
|
|
|
163
215
|
return { sending, handleSend };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import type { PendingE2eeSendRecord } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from './useChatClient';
|
|
4
|
+
|
|
5
|
+
export function usePendingE2eeSends(statuses?: string[]) {
|
|
6
|
+
const { client } = useChatClient();
|
|
7
|
+
const [records, setRecords] = useState<PendingE2eeSendRecord[]>([]);
|
|
8
|
+
const [loading, setLoading] = useState(false);
|
|
9
|
+
|
|
10
|
+
const refresh = useCallback(async () => {
|
|
11
|
+
const storage = client.encryptionManager?.storage;
|
|
12
|
+
if (!storage?.listPendingE2eeSends) {
|
|
13
|
+
setRecords([]);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
setLoading(true);
|
|
17
|
+
try {
|
|
18
|
+
setRecords(await storage.listPendingE2eeSends(statuses));
|
|
19
|
+
} finally {
|
|
20
|
+
setLoading(false);
|
|
21
|
+
}
|
|
22
|
+
}, [client.encryptionManager, JSON.stringify(statuses || [])]);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
void refresh();
|
|
26
|
+
}, [refresh]);
|
|
27
|
+
|
|
28
|
+
return { records, loading, refresh };
|
|
29
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
EncryptedChannelRepairMode,
|
|
4
|
+
EncryptedChannelRepairResult,
|
|
5
|
+
RepairMode,
|
|
6
|
+
RepairResult,
|
|
7
|
+
RestoreProgressRecord,
|
|
8
|
+
} from '@ermis-network/ermis-chat-sdk';
|
|
9
|
+
|
|
10
|
+
import { useChatClient } from './useChatClient';
|
|
11
|
+
|
|
12
|
+
export type RecoveryPinStatus = 'idle' | 'working' | 'ready' | 'locked' | 'error';
|
|
13
|
+
|
|
14
|
+
export type RecoveryStatusInfo = {
|
|
15
|
+
hasVault: boolean;
|
|
16
|
+
unlocked: boolean;
|
|
17
|
+
hasIncompleteRestore: boolean;
|
|
18
|
+
incompleteChannels: string[];
|
|
19
|
+
channelsWithPermanentGaps: string[];
|
|
20
|
+
restoreProgressWithIssues: RestoreProgressRecord[];
|
|
21
|
+
e2eeBootstrapRunning?: boolean;
|
|
22
|
+
e2eeBootstrapCompleted?: number;
|
|
23
|
+
e2eeBootstrapTotal?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type RecoveryRestoredMessage = {
|
|
27
|
+
epoch: number;
|
|
28
|
+
messageId?: string;
|
|
29
|
+
plaintext?: unknown;
|
|
30
|
+
source?: 'archive';
|
|
31
|
+
createdAt?: string;
|
|
32
|
+
gap?: boolean;
|
|
33
|
+
reason?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type UseRecoveryPinReturn = {
|
|
37
|
+
status: RecoveryPinStatus;
|
|
38
|
+
error: Error | null;
|
|
39
|
+
hasRecoveryKey: boolean;
|
|
40
|
+
recoveryStatus: RecoveryStatusInfo | null;
|
|
41
|
+
setupRecoveryPin: (pin: string) => Promise<void>;
|
|
42
|
+
unlockRecoveryVault: (pin: string) => Promise<void>;
|
|
43
|
+
changeRecoveryPin: (oldPin: string, newPin: string) => Promise<void>;
|
|
44
|
+
changeUnlockedRecoveryPin: (newPin: string) => Promise<void>;
|
|
45
|
+
repairRecoveryChannel: (
|
|
46
|
+
channelType: string,
|
|
47
|
+
channelId: string,
|
|
48
|
+
options?: { mode?: RepairMode },
|
|
49
|
+
) => Promise<RepairResult>;
|
|
50
|
+
repairEncryptedChannel: (
|
|
51
|
+
channelType: string,
|
|
52
|
+
channelId: string,
|
|
53
|
+
options?: { mode?: EncryptedChannelRepairMode },
|
|
54
|
+
) => Promise<EncryptedChannelRepairResult>;
|
|
55
|
+
enqueueRestore: (
|
|
56
|
+
channelType: string,
|
|
57
|
+
channelId: string,
|
|
58
|
+
priority?: 'active' | 'background',
|
|
59
|
+
options?: { fromEpoch?: number; toEpoch?: number },
|
|
60
|
+
) => void;
|
|
61
|
+
loadRestoreProgress: (channelType: string, channelId: string) => Promise<RestoreProgressRecord | null>;
|
|
62
|
+
restoreHistoricalMessages: (
|
|
63
|
+
channelType: string,
|
|
64
|
+
channelId: string,
|
|
65
|
+
options?: { fromEpoch?: number; toEpoch?: number },
|
|
66
|
+
) => Promise<RecoveryRestoredMessage[]>;
|
|
67
|
+
refresh: () => void;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const requireEncryptionManager = (client: unknown): any => {
|
|
71
|
+
const manager = (client as any)?.encryptionManager;
|
|
72
|
+
if (!manager) {
|
|
73
|
+
throw new Error('Encryption manager is not initialized.');
|
|
74
|
+
}
|
|
75
|
+
return manager;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const useRecoveryPin = (): UseRecoveryPinReturn => {
|
|
79
|
+
const { client } = useChatClient();
|
|
80
|
+
const [status, setStatus] = useState<RecoveryPinStatus>('idle');
|
|
81
|
+
const [error, setError] = useState<Error | null>(null);
|
|
82
|
+
const [recoveryStatus, setRecoveryStatus] = useState<RecoveryStatusInfo | null>(null);
|
|
83
|
+
const [hasRecoveryKey, setHasRecoveryKey] = useState(() => {
|
|
84
|
+
try {
|
|
85
|
+
return !!requireEncryptionManager(client).hasRecoveryKey?.();
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const refresh = useCallback(() => {
|
|
92
|
+
void (async () => {
|
|
93
|
+
try {
|
|
94
|
+
const manager = requireEncryptionManager(client);
|
|
95
|
+
const hasKey = !!manager.hasRecoveryKey?.();
|
|
96
|
+
const nextStatus = manager.getRecoveryStatus ? await manager.getRecoveryStatus() : null;
|
|
97
|
+
setHasRecoveryKey(hasKey);
|
|
98
|
+
setRecoveryStatus(nextStatus);
|
|
99
|
+
setStatus(nextStatus?.hasVault === false ? 'idle' : nextStatus?.unlocked ? 'ready' : 'locked');
|
|
100
|
+
setError(null);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
const nextError = err instanceof Error ? err : new Error(String(err));
|
|
103
|
+
if (nextError.message.includes('Encryption manager is not initialized')) {
|
|
104
|
+
setStatus('idle');
|
|
105
|
+
setError(null);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
setStatus('error');
|
|
109
|
+
setError(nextError);
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
}, [client]);
|
|
113
|
+
|
|
114
|
+
const run = useCallback(
|
|
115
|
+
async <T>(fn: (manager: any) => Promise<T>): Promise<T> => {
|
|
116
|
+
setStatus('working');
|
|
117
|
+
setError(null);
|
|
118
|
+
try {
|
|
119
|
+
const manager = requireEncryptionManager(client);
|
|
120
|
+
const result = await fn(manager);
|
|
121
|
+
const hasKey = !!manager.hasRecoveryKey?.();
|
|
122
|
+
const nextStatus = manager.getRecoveryStatus ? await manager.getRecoveryStatus() : null;
|
|
123
|
+
setHasRecoveryKey(hasKey);
|
|
124
|
+
setRecoveryStatus(nextStatus);
|
|
125
|
+
setStatus(nextStatus?.hasVault === false ? 'idle' : nextStatus?.unlocked ? 'ready' : 'locked');
|
|
126
|
+
return result;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const nextError = err instanceof Error ? err : new Error(String(err));
|
|
129
|
+
setError(nextError);
|
|
130
|
+
setStatus('error');
|
|
131
|
+
throw nextError;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[client],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const setupRecoveryPin = useCallback(
|
|
138
|
+
async (pin: string): Promise<void> => {
|
|
139
|
+
await run((manager) => manager.setupRecoveryPin(pin));
|
|
140
|
+
},
|
|
141
|
+
[run],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const unlockRecoveryVault = useCallback(
|
|
145
|
+
async (pin: string): Promise<void> => {
|
|
146
|
+
await run((manager) => manager.unlockRecoveryVault(pin));
|
|
147
|
+
},
|
|
148
|
+
[run],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const changeRecoveryPin = useCallback(
|
|
152
|
+
async (oldPin: string, newPin: string): Promise<void> => {
|
|
153
|
+
await run((manager) => manager.changeRecoveryPin(oldPin, newPin));
|
|
154
|
+
},
|
|
155
|
+
[run],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const changeUnlockedRecoveryPin = useCallback(
|
|
159
|
+
async (newPin: string): Promise<void> => {
|
|
160
|
+
await run((manager) => manager.changeUnlockedRecoveryPin(newPin));
|
|
161
|
+
},
|
|
162
|
+
[run],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const repairRecoveryChannel = useCallback(
|
|
166
|
+
(channelType: string, channelId: string, options?: { mode?: RepairMode }): Promise<RepairResult> =>
|
|
167
|
+
run((manager) => manager.repairRecoveryChannel(channelType, channelId, options)),
|
|
168
|
+
[run],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const repairEncryptedChannel = useCallback(
|
|
172
|
+
(
|
|
173
|
+
channelType: string,
|
|
174
|
+
channelId: string,
|
|
175
|
+
options?: { mode?: EncryptedChannelRepairMode },
|
|
176
|
+
): Promise<EncryptedChannelRepairResult> =>
|
|
177
|
+
run((manager) => manager.repairEncryptedChannel(channelType, channelId, options)),
|
|
178
|
+
[run],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const restoreHistoricalMessages = useCallback(
|
|
182
|
+
(
|
|
183
|
+
channelType: string,
|
|
184
|
+
channelId: string,
|
|
185
|
+
options?: { fromEpoch?: number; toEpoch?: number },
|
|
186
|
+
): Promise<RecoveryRestoredMessage[]> =>
|
|
187
|
+
run((manager) => manager.restoreHistoricalMessages(channelType, channelId, options)),
|
|
188
|
+
[run],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const enqueueRestore = useCallback(
|
|
192
|
+
(
|
|
193
|
+
channelType: string,
|
|
194
|
+
channelId: string,
|
|
195
|
+
priority: 'active' | 'background' = 'active',
|
|
196
|
+
options?: { fromEpoch?: number; toEpoch?: number },
|
|
197
|
+
): void => {
|
|
198
|
+
try {
|
|
199
|
+
const manager = requireEncryptionManager(client);
|
|
200
|
+
manager.enqueueRestore?.(channelType, channelId, priority, options);
|
|
201
|
+
refresh();
|
|
202
|
+
} catch (err) {
|
|
203
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
204
|
+
setStatus('error');
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
[client, refresh],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const loadRestoreProgress = useCallback(
|
|
211
|
+
async (channelType: string, channelId: string): Promise<RestoreProgressRecord | null> => {
|
|
212
|
+
try {
|
|
213
|
+
const manager = requireEncryptionManager(client);
|
|
214
|
+
return manager.getRestoreProgress ? await manager.getRestoreProgress(channelType, channelId) : null;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const nextError = err instanceof Error ? err : new Error(String(err));
|
|
217
|
+
if (!nextError.message.includes('Encryption manager is not initialized')) {
|
|
218
|
+
setError(nextError);
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
[client],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const eventClient = client as any;
|
|
228
|
+
if (!eventClient?.on) return;
|
|
229
|
+
const progressSub = eventClient.on('e2ee.restore_progress' as any, refresh);
|
|
230
|
+
const bootstrapSub = eventClient.on('e2ee.bootstrap_progress' as any, refresh);
|
|
231
|
+
const initSub = eventClient.on('e2ee.initialized' as any, refresh);
|
|
232
|
+
return () => {
|
|
233
|
+
progressSub?.unsubscribe?.();
|
|
234
|
+
bootstrapSub?.unsubscribe?.();
|
|
235
|
+
initSub?.unsubscribe?.();
|
|
236
|
+
};
|
|
237
|
+
}, [client, refresh]);
|
|
238
|
+
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
const eventClient = client as any;
|
|
241
|
+
if (!eventClient || eventClient.encryptionManager?.initialized) return;
|
|
242
|
+
const interval = setInterval(() => {
|
|
243
|
+
refresh();
|
|
244
|
+
if (eventClient.encryptionManager?.initialized) clearInterval(interval);
|
|
245
|
+
}, 500);
|
|
246
|
+
return () => clearInterval(interval);
|
|
247
|
+
}, [client, refresh]);
|
|
248
|
+
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
refresh();
|
|
251
|
+
}, [refresh]);
|
|
252
|
+
|
|
253
|
+
return useMemo(
|
|
254
|
+
() => ({
|
|
255
|
+
status,
|
|
256
|
+
error,
|
|
257
|
+
hasRecoveryKey,
|
|
258
|
+
recoveryStatus,
|
|
259
|
+
setupRecoveryPin,
|
|
260
|
+
unlockRecoveryVault,
|
|
261
|
+
changeRecoveryPin,
|
|
262
|
+
changeUnlockedRecoveryPin,
|
|
263
|
+
repairRecoveryChannel,
|
|
264
|
+
repairEncryptedChannel,
|
|
265
|
+
enqueueRestore,
|
|
266
|
+
loadRestoreProgress,
|
|
267
|
+
restoreHistoricalMessages,
|
|
268
|
+
refresh,
|
|
269
|
+
}),
|
|
270
|
+
[
|
|
271
|
+
status,
|
|
272
|
+
error,
|
|
273
|
+
hasRecoveryKey,
|
|
274
|
+
recoveryStatus,
|
|
275
|
+
setupRecoveryPin,
|
|
276
|
+
unlockRecoveryVault,
|
|
277
|
+
changeRecoveryPin,
|
|
278
|
+
changeUnlockedRecoveryPin,
|
|
279
|
+
repairRecoveryChannel,
|
|
280
|
+
repairEncryptedChannel,
|
|
281
|
+
enqueueRestore,
|
|
282
|
+
loadRestoreProgress,
|
|
283
|
+
restoreHistoricalMessages,
|
|
284
|
+
refresh,
|
|
285
|
+
],
|
|
286
|
+
);
|
|
287
|
+
};
|
|
@@ -4,6 +4,7 @@ import { formatMessage } from '@ermis-network/ermis-chat-sdk';
|
|
|
4
4
|
import type { VListHandle } from 'virtua';
|
|
5
5
|
import { dedupMessages } from './useLoadMessages';
|
|
6
6
|
import { useChatClient } from './useChatClient';
|
|
7
|
+
import { getDateKey } from '../utils';
|
|
7
8
|
|
|
8
9
|
export type UseScrollToMessageOptions = {
|
|
9
10
|
vlistRef: React.RefObject<VListHandle | null>;
|
|
@@ -23,6 +24,23 @@ export type UseScrollToMessageReturn = {
|
|
|
23
24
|
jumpToLatest: () => void;
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
function getRenderedMessageIndex(messages: FormatMessageResponse[], messageId: string): number {
|
|
28
|
+
let renderedIndex = 0;
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
31
|
+
const message = messages[i];
|
|
32
|
+
const prevMessage = i > 0 ? messages[i - 1] : null;
|
|
33
|
+
const showDateSeparator =
|
|
34
|
+
!prevMessage || getDateKey(message.created_at) !== getDateKey(prevMessage.created_at);
|
|
35
|
+
|
|
36
|
+
if (showDateSeparator) renderedIndex += 1;
|
|
37
|
+
if (message.id === messageId) return renderedIndex;
|
|
38
|
+
renderedIndex += 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return -1;
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
export function useScrollToMessage({
|
|
27
45
|
vlistRef,
|
|
28
46
|
messagesRef,
|
|
@@ -60,7 +78,14 @@ export function useScrollToMessage({
|
|
|
60
78
|
// Case 1: message is already in current list
|
|
61
79
|
const idx = messagesRef.current.findIndex((m) => m.id === messageId);
|
|
62
80
|
if (idx !== -1) {
|
|
63
|
-
|
|
81
|
+
const renderedIdx = getRenderedMessageIndex(messagesRef.current, messageId);
|
|
82
|
+
if (renderedIdx !== -1) {
|
|
83
|
+
jumpingRef.current = true;
|
|
84
|
+
vlistRef.current?.scrollToIndex(renderedIdx, { align: 'center', smooth: true });
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
jumpingRef.current = false;
|
|
87
|
+
}, 500);
|
|
88
|
+
}
|
|
64
89
|
highlight(messageId);
|
|
65
90
|
return;
|
|
66
91
|
}
|
|
@@ -93,14 +118,14 @@ export function useScrollToMessage({
|
|
|
93
118
|
|
|
94
119
|
// Wait for VList to render, then jump while hidden, then fade in
|
|
95
120
|
setTimeout(() => {
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
121
|
+
const renderedIdx = getRenderedMessageIndex(unique, messageId);
|
|
122
|
+
if (renderedIdx === -1) {
|
|
98
123
|
jumpingRef.current = false;
|
|
99
124
|
if (vlistEl) vlistEl.style.opacity = '1';
|
|
100
125
|
return;
|
|
101
126
|
}
|
|
102
127
|
|
|
103
|
-
vlistRef.current?.scrollToIndex(
|
|
128
|
+
vlistRef.current?.scrollToIndex(renderedIdx, { align: 'center' });
|
|
104
129
|
|
|
105
130
|
setTimeout(() => {
|
|
106
131
|
if (vlistEl) {
|