@ermis-network/ermis-chat-react 1.0.9 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -0
- package/dist/index.cjs +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- package/src/utils.ts +219 -16
|
@@ -1,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
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
+
};
|