@ermis-network/ermis-chat-react 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +5087 -11279
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +632 -152
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +273 -9
  7. package/dist/index.d.ts +273 -9
  8. package/dist/index.mjs +5085 -11295
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/components/Channel.tsx +0 -3
  12. package/src/components/ChannelActions.tsx +6 -1
  13. package/src/components/ChannelHeader.tsx +8 -32
  14. package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
  17. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
  18. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
  19. package/src/components/ChannelList.tsx +72 -13
  20. package/src/components/CreateChannelModal.tsx +131 -12
  21. package/src/components/FilesPreview.tsx +8 -12
  22. package/src/components/FlatTopicGroupItem.tsx +27 -16
  23. package/src/components/ForwardMessageModal.tsx +11 -3
  24. package/src/components/MediaLightbox.tsx +444 -304
  25. package/src/components/MessageActionsBox.tsx +2 -0
  26. package/src/components/MessageInput.tsx +41 -12
  27. package/src/components/MessageItem.tsx +70 -25
  28. package/src/components/MessageQuickReactions.tsx +131 -128
  29. package/src/components/MessageReactions.tsx +47 -2
  30. package/src/components/MessageRenderers.tsx +1030 -433
  31. package/src/components/PinnedMessages.tsx +40 -12
  32. package/src/components/QuotedMessagePreview.tsx +99 -8
  33. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  34. package/src/components/RecoveryPin/index.ts +19 -0
  35. package/src/components/TopicList.tsx +20 -5
  36. package/src/components/TypingIndicator.tsx +3 -3
  37. package/src/components/UserPicker.tsx +26 -25
  38. package/src/components/VirtualMessageList.tsx +345 -125
  39. package/src/context/ChatProvider.tsx +27 -1
  40. package/src/hooks/useChannelListUpdates.ts +22 -1
  41. package/src/hooks/useChannelMessages.ts +338 -51
  42. package/src/hooks/useChannelRowUpdates.ts +18 -6
  43. package/src/hooks/useChatUser.ts +9 -1
  44. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  45. package/src/hooks/useE2eeFileUpload.ts +38 -0
  46. package/src/hooks/useFileUpload.ts +25 -5
  47. package/src/hooks/useForwardMessage.ts +210 -13
  48. package/src/hooks/useLoadMessages.ts +16 -4
  49. package/src/hooks/useMentions.ts +60 -6
  50. package/src/hooks/useMessageActions.ts +14 -8
  51. package/src/hooks/useMessageSend.ts +64 -12
  52. package/src/hooks/usePendingE2eeSends.ts +29 -0
  53. package/src/hooks/useRecoveryPin.ts +287 -0
  54. package/src/hooks/useScrollToMessage.ts +29 -4
  55. package/src/hooks/useTopicGroupUpdates.ts +49 -11
  56. package/src/index.ts +23 -0
  57. package/src/messageTypeUtils.ts +14 -0
  58. package/src/styles/_channel-info.css +9 -0
  59. package/src/styles/_channel-list.css +37 -14
  60. package/src/styles/_media-lightbox.css +36 -3
  61. package/src/styles/_message-bubble.css +381 -41
  62. package/src/styles/_message-input.css +8 -0
  63. package/src/styles/_message-list.css +67 -10
  64. package/src/styles/_message-quick-reactions.css +101 -59
  65. package/src/styles/_message-reactions.css +18 -32
  66. package/src/styles/_recovery-pin.css +97 -0
  67. package/src/styles/_tokens.css +5 -5
  68. package/src/styles/_typing-indicator.css +23 -13
  69. package/src/styles/index.css +1 -0
  70. package/src/types.ts +115 -1
  71. package/src/utils/avatarColors.ts +1 -1
  72. package/src/utils.ts +38 -18
@@ -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'.includes(q)) {
183
+ if (!q || 'all'.startsWith(q)) {
148
184
  result.push(allItem);
149
185
  }
150
186
  }
151
187
 
152
- for (const m of members) {
153
- if (m.id === currentUserId) continue; // skip self
154
- if (q && !m.name.toLowerCase().includes(q)) continue; // filter by query
155
- result.push(m);
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
- }, [members, deferredQuery, activeMentionIds, currentUserId, allItem]);
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 canEdit = !isPreviewMode && !isSystem && !isSignal && !isSticker && isOwnMessage;
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 uploadedFiles = files.filter((f) => f.status === 'done');
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
- const attachments = uploadedFiles.map((f) => {
79
- if (f.originalAttachment) {
80
- return f.originalAttachment;
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
- const fileObj = f.normalizedFile || f.file!;
83
- return buildAttachmentPayload(fileObj, f.uploadedUrl!, f.thumbUrl);
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.catch((err: Error) => {
134
- console.error('Failed to send message over API:', err);
135
- // Sync React to render the SDK's internal 'status: failed' UI state
136
- syncMessages();
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
- vlistRef.current?.scrollToIndex(idx, { align: 'center', smooth: true });
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 newIdx = unique.findIndex((m: any) => m.id === messageId);
97
- if (newIdx === -1) {
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(newIdx, { align: 'center' });
128
+ vlistRef.current?.scrollToIndex(renderedIdx, { align: 'center' });
104
129
 
105
130
  setTimeout(() => {
106
131
  if (vlistEl) {