@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.
Files changed (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. package/src/utils.ts +219 -16
@@ -1,8 +1,9 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useChatClient } from './useChatClient';
3
3
  import { useChannelCapabilities } from './useChannelCapabilities';
4
+ import { usePreviewState } from './usePreviewState';
4
5
  import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
5
- import { isSignalMessage, isSystemMessage } from '../messageTypeUtils';
6
+ import { isSignalMessage, isSystemMessage, isStickerMessage } from '../messageTypeUtils';
6
7
 
7
8
  export type MessageActionList = {
8
9
  canEdit: boolean;
@@ -20,11 +21,13 @@ export type MessageActionList = {
20
21
  hasCapPin: boolean;
21
22
  hasCapReply: boolean;
22
23
  hasCapQuote: boolean;
24
+ hasCapReact: boolean;
23
25
  };
24
26
 
25
27
  export const useMessageActions = (message: FormatMessageResponse, isOwnMessage: boolean): MessageActionList => {
26
28
  const { activeChannel, client } = useChatClient();
27
29
  const { isGroupChannel: isTeam, isOwner, hasCapability } = useChannelCapabilities();
30
+ const { isPreviewMode } = usePreviewState(activeChannel, client?.userID);
28
31
 
29
32
  // Only depend on the specific message fields we actually read
30
33
  const messageType = message.type;
@@ -48,14 +51,18 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
48
51
  hasCapPin: false,
49
52
  hasCapReply: false,
50
53
  hasCapQuote: false,
54
+ hasCapReact: false,
51
55
  };
52
56
  }
53
57
 
54
58
  const isSystem = isSystemMessage(message);
55
59
  const isSignal = isSignalMessage(message);
60
+ const isSticker = isStickerMessage(message);
56
61
  const isPinned = isPinnedFlag;
57
62
 
58
- const canEdit = !isSystem && !isSignal && isOwnMessage;
63
+ const isDeleted = message.display_type === 'deleted';
64
+
65
+ const canEdit = !isPreviewMode && !isSystem && !isSignal && !isSticker && isOwnMessage && !isDeleted;
59
66
 
60
67
  // Delete for everyone:
61
68
  // + Team channel: only the owner can perform this action natively.
@@ -63,13 +70,13 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
63
70
  const canDeleteForEveryoneTeam = isTeam && isOwner;
64
71
  const canDeleteForEveryoneMessaging = !isTeam && isOwnMessage;
65
72
 
66
- const canDelete = !isSystem && (canDeleteForEveryoneTeam || canDeleteForEveryoneMessaging);
67
- const canDeleteForMe = !isSystem;
68
- const canReply = !isSystem && !isSignal;
69
- const canQuote = !isSystem && !isSignal;
70
- const canForward = !isSystem && !isSignal;
71
- const canPin = !isSystem && !isSignal;
72
- const canCopy = !isSystem && !isSignal && Boolean(message.text?.trim());
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
73
80
 
74
81
  const hasCapEdit = hasCapability('update-own-message');
75
82
  const hasCapDelete = !isTeam || isOwner || (isOwnMessage && hasCapability('delete-own-message'));
@@ -79,6 +86,7 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
79
86
  const hasCapReply = hasCapability('send-reply');
80
87
  const hasCapQuote = hasCapability('quote-message');
81
88
  const hasCapPin = hasCapability('pin-message');
89
+ const hasCapReact = hasCapability('send-reaction');
82
90
 
83
91
  return {
84
92
  canEdit,
@@ -96,6 +104,7 @@ export const useMessageActions = (message: FormatMessageResponse, isOwnMessage:
96
104
  hasCapPin,
97
105
  hasCapReply,
98
106
  hasCapQuote,
107
+ hasCapReact,
99
108
  };
100
- }, [activeChannel, isTeam, isOwner, hasCapability, messageType, message.text, isPinnedFlag, isOwnMessage]); // Use capabilities from hook
109
+ }, [activeChannel, isTeam, isOwner, hasCapability, messageType, message.text, isPinnedFlag, isOwnMessage, isPreviewMode]); // Use capabilities from hook
101
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
+ }
@@ -10,6 +10,7 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
10
10
  const membership = channel?.state?.membership || channel?.state?.members?.[currentUserId || ''];
11
11
  return isPendingMember(membership?.channel_role as string);
12
12
  });
13
+ const [inviteUpdateCount, setInviteUpdateCount] = useState(0);
13
14
 
14
15
  useEffect(() => {
15
16
  if (!channel || !currentUserId) {
@@ -18,6 +19,10 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
18
19
  }
19
20
 
20
21
  const checkPending = () => {
22
+ // Topics are accessible by default if the user is in the parent channel.
23
+ // We ignore the individual pending invite state for topics to avoid redundant overlays.
24
+ if (channel.type === 'topic') return false;
25
+
21
26
  const membership = channel.state?.membership || channel.state?.members?.[currentUserId];
22
27
  return isPendingMember(membership?.channel_role as string);
23
28
  };
@@ -40,29 +45,41 @@ export function usePendingState(channel: Channel | null | undefined, currentUser
40
45
  const eventMember = event.member as Record<string, unknown>;
41
46
  const eventUser = event.user as Record<string, unknown>;
42
47
  const eventUserId = eventMember?.user_id || (eventMember?.user as Record<string, unknown>)?.id || eventUser?.id;
43
- if (eventUserId !== currentUserId) return; // Only react to own invite events
44
48
 
45
49
  const eventCid =
46
50
  event.cid ||
47
51
  (event.channel as Record<string, unknown>)?.cid ||
48
52
  (event.channel_id ? `${event.channel_type}:${event.channel_id}` : undefined);
49
- if (eventCid === channel.cid) {
53
+
54
+ if (eventCid !== channel.cid) return; // Only react to events on this channel
55
+
56
+ // If this event is for the current user, update their membership state
57
+ if (eventUserId === currentUserId) {
50
58
  defensiveUpdateState(event);
51
- setIsPending(checkPending());
52
59
  }
60
+
61
+ // Re-check pending state regardless of which user triggered the event.
62
+ // This handles both cases:
63
+ // - Current user accepts/rejects their own invite
64
+ // - Another user accepts an invite (hide pending-invitee box on inviter's side)
65
+ setIsPending(checkPending());
66
+ setInviteUpdateCount(c => c + 1);
53
67
  };
54
68
 
55
69
  const client = channel.getClient();
56
70
  const sub1 = client.on('notification.invite_accepted', handleInviteAction);
57
71
  const sub2 = client.on('notification.invite_rejected', handleInviteAction);
58
72
  const sub3 = client.on('notification.invite_messaging_skipped', handleInviteAction);
73
+ // Public channel join sends 'member.joined' instead of 'notification.invite_accepted'
74
+ const sub4 = client.on('member.joined', handleInviteAction);
59
75
 
60
76
  return () => {
61
77
  sub1.unsubscribe();
62
78
  sub2.unsubscribe();
63
79
  sub3.unsubscribe();
80
+ sub4.unsubscribe();
64
81
  };
65
82
  }, [channel, currentUserId]);
66
83
 
67
- return { isPending };
84
+ return { isPending, inviteUpdateCount };
68
85
  }
@@ -0,0 +1,69 @@
1
+ import { useState, useEffect } from 'react';
2
+ import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
+ import { isPublicGroupChannel } from '../channelTypeUtils';
4
+
5
+ /**
6
+ * Hook that tracks whether the current user is previewing a public channel
7
+ * without being a member.
8
+ */
9
+ export function usePreviewState(channel: Channel | null | undefined, currentUserId?: string) {
10
+ const [isPreviewMode, setIsPreviewMode] = useState<boolean>(() => {
11
+ if (!channel || !currentUserId || !isPublicGroupChannel(channel)) return false;
12
+ const membership = channel?.state?.membership || channel?.state?.members?.[currentUserId];
13
+ const isMembershipEmpty = !membership || Object.keys(membership).length === 0;
14
+ return isMembershipEmpty;
15
+ });
16
+
17
+ useEffect(() => {
18
+ if (!channel || !currentUserId || !isPublicGroupChannel(channel)) {
19
+ setIsPreviewMode(false);
20
+ return;
21
+ }
22
+
23
+ const checkPreviewMode = () => {
24
+ const membership = channel.state?.membership || channel.state?.members?.[currentUserId];
25
+ const isMembershipEmpty = !membership || Object.keys(membership).length === 0;
26
+ return isMembershipEmpty;
27
+ };
28
+
29
+ // Sync initial state
30
+ setIsPreviewMode(checkPreviewMode());
31
+
32
+ const defensiveUpdateState = (event: Record<string, unknown>) => {
33
+ if (event.member && channel.state && channel.state.membership !== undefined) {
34
+ channel.state.membership = {
35
+ ...channel.state.membership,
36
+ ...(event.member as Record<string, unknown>),
37
+ } as unknown as Record<string, unknown>;
38
+ }
39
+ };
40
+
41
+ const handleMembershipChange = (event: Record<string, unknown>) => {
42
+ const eventMember = event.member as Record<string, unknown>;
43
+ const eventUser = event.user as Record<string, unknown>;
44
+ const eventUserId = eventMember?.user_id || (eventMember?.user as Record<string, unknown>)?.id || eventUser?.id;
45
+
46
+ // Only react if the event concerns the current user
47
+ if (eventUserId !== currentUserId) return;
48
+
49
+ const eventCid =
50
+ event.cid ||
51
+ (event.channel as Record<string, unknown>)?.cid ||
52
+ (event.channel_id ? `${event.channel_type}:${event.channel_id}` : undefined);
53
+
54
+ if (eventCid === channel.cid) {
55
+ defensiveUpdateState(event);
56
+ setIsPreviewMode(checkPreviewMode());
57
+ }
58
+ };
59
+
60
+ const client = channel.getClient();
61
+ const sub1 = client.on('member.joined', handleMembershipChange);
62
+
63
+ return () => {
64
+ sub1.unsubscribe();
65
+ };
66
+ }, [channel, currentUserId]);
67
+
68
+ return { isPreviewMode };
69
+ }
@@ -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
+ };