@ermis-network/ermis-chat-react 1.0.9 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -0
- package/dist/index.cjs +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- package/src/utils.ts +219 -16
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useEffect, useState, useMemo, useCallback } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from './useChatClient';
|
|
4
|
+
import { isDirectChannel } from '../channelTypeUtils';
|
|
5
|
+
import { isOwnerMember } from '../channelRoleUtils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A hook that retrieves all friend (contact) channels from the SDK's local cache
|
|
9
|
+
* without triggering an extra API network query.
|
|
10
|
+
*
|
|
11
|
+
* A contact is defined as a direct (1-1) channel where both members
|
|
12
|
+
* hold the 'owner' channel_role.
|
|
13
|
+
*
|
|
14
|
+
* Re-renders automatically when related events arrive.
|
|
15
|
+
*/
|
|
16
|
+
export function useContactChannels(): Channel[] {
|
|
17
|
+
const { client } = useChatClient();
|
|
18
|
+
const [updateCount, setUpdateCount] = useState(0);
|
|
19
|
+
|
|
20
|
+
const forceUpdate = useCallback(() => setUpdateCount((c) => c + 1), []);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!client) return;
|
|
24
|
+
|
|
25
|
+
const listeners = [
|
|
26
|
+
client.on('channels.queried', forceUpdate),
|
|
27
|
+
client.on('notification.invite_accepted', forceUpdate),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
return () => listeners.forEach((l) => l.unsubscribe());
|
|
31
|
+
}, [client, forceUpdate]);
|
|
32
|
+
|
|
33
|
+
return useMemo(() => {
|
|
34
|
+
if (!client) return [];
|
|
35
|
+
|
|
36
|
+
return Object.values(client.activeChannels).filter((channel) => {
|
|
37
|
+
if (!isDirectChannel(channel)) return false;
|
|
38
|
+
|
|
39
|
+
const members = Object.values(channel.state?.members || {});
|
|
40
|
+
if (members.length !== 2) return false;
|
|
41
|
+
|
|
42
|
+
return members.every((m) => isOwnerMember(m.channel_role as string));
|
|
43
|
+
});
|
|
44
|
+
}, [client, updateCount]);
|
|
45
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useChatClient } from './useChatClient';
|
|
3
|
+
import { isDirectChannel } from '../channelTypeUtils';
|
|
4
|
+
import { isOwnerMember } from '../channelRoleUtils';
|
|
5
|
+
|
|
6
|
+
export const useContactCount = () => {
|
|
7
|
+
const { client } = useChatClient();
|
|
8
|
+
const [contactCount, setContactCount] = useState(0);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!client || !client.user) return;
|
|
12
|
+
|
|
13
|
+
const countContacts = () => {
|
|
14
|
+
let count = 0;
|
|
15
|
+
const channels = Object.values(client.activeChannels);
|
|
16
|
+
for (const channel of channels) {
|
|
17
|
+
if (!isDirectChannel(channel)) continue;
|
|
18
|
+
|
|
19
|
+
const members = Object.values(channel.state?.members || {});
|
|
20
|
+
// Contacts are direct channels where both members are owners
|
|
21
|
+
if (members.length === 2) {
|
|
22
|
+
const isAllOwners = members.every((m) => isOwnerMember(m.channel_role as string));
|
|
23
|
+
if (isAllOwners) count++;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return count;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Calculate initial count
|
|
30
|
+
setContactCount(countContacts());
|
|
31
|
+
|
|
32
|
+
const handleEvent = () => {
|
|
33
|
+
// Delay slightly to ensure client.activeChannels is updated by SDK internal handlers first
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
setContactCount(countContacts());
|
|
36
|
+
}, 0);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const listeners = [
|
|
40
|
+
client.on('channels.queried', handleEvent),
|
|
41
|
+
client.on('notification.invite_accepted', handleEvent),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
listeners.forEach((l) => l.unsubscribe());
|
|
46
|
+
};
|
|
47
|
+
}, [client]);
|
|
48
|
+
|
|
49
|
+
return { contactCount };
|
|
50
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import { useChatClient } from './useChatClient';
|
|
3
|
+
|
|
4
|
+
export const useDownloadHandler = () => {
|
|
5
|
+
const { client } = useChatClient();
|
|
6
|
+
|
|
7
|
+
const downloadFile = useCallback(async (url: string | undefined, filename?: string) => {
|
|
8
|
+
if (!url) return;
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const blob = await client.downloadMedia(url);
|
|
12
|
+
const urlBlob = window.URL.createObjectURL(blob);
|
|
13
|
+
|
|
14
|
+
const a = document.createElement('a');
|
|
15
|
+
a.style.display = 'none';
|
|
16
|
+
a.href = urlBlob;
|
|
17
|
+
a.download = filename || 'file';
|
|
18
|
+
document.body.appendChild(a);
|
|
19
|
+
|
|
20
|
+
a.click();
|
|
21
|
+
|
|
22
|
+
// Cleanup after a delay to ensure the browser has started the download
|
|
23
|
+
setTimeout(() => {
|
|
24
|
+
if (document.body.contains(a)) {
|
|
25
|
+
document.body.removeChild(a);
|
|
26
|
+
}
|
|
27
|
+
window.URL.revokeObjectURL(urlBlob);
|
|
28
|
+
}, 1000);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.warn('Download failed, falling back to new tab:', err);
|
|
31
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
32
|
+
}
|
|
33
|
+
}, [client]);
|
|
34
|
+
|
|
35
|
+
return { downloadFile };
|
|
36
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { isHeicFile, isVideoFile } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
|
|
4
|
+
export function useDragAndDrop(
|
|
5
|
+
onFilesDrop: (files: FileList) => void,
|
|
6
|
+
disabled: boolean = false
|
|
7
|
+
) {
|
|
8
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
9
|
+
const dragCounter = useRef(0);
|
|
10
|
+
|
|
11
|
+
const handleDragEnter = useCallback((e: DragEvent) => {
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
e.stopPropagation();
|
|
14
|
+
|
|
15
|
+
if (disabled) return;
|
|
16
|
+
|
|
17
|
+
dragCounter.current += 1;
|
|
18
|
+
|
|
19
|
+
// Only allow files
|
|
20
|
+
if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) {
|
|
21
|
+
const hasFiles = Array.from(e.dataTransfer.items).some(
|
|
22
|
+
(item) => item.kind === 'file'
|
|
23
|
+
);
|
|
24
|
+
if (hasFiles) {
|
|
25
|
+
setIsDragging(true);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}, [disabled]);
|
|
29
|
+
|
|
30
|
+
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
e.stopPropagation();
|
|
33
|
+
|
|
34
|
+
if (disabled) return;
|
|
35
|
+
|
|
36
|
+
dragCounter.current -= 1;
|
|
37
|
+
if (dragCounter.current === 0) {
|
|
38
|
+
setIsDragging(false);
|
|
39
|
+
}
|
|
40
|
+
}, [disabled]);
|
|
41
|
+
|
|
42
|
+
const handleDragOver = useCallback((e: DragEvent) => {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const handleDrop = useCallback((e: DragEvent) => {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
e.stopPropagation();
|
|
50
|
+
|
|
51
|
+
dragCounter.current = 0;
|
|
52
|
+
setIsDragging(false);
|
|
53
|
+
|
|
54
|
+
if (disabled) return;
|
|
55
|
+
|
|
56
|
+
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
|
57
|
+
onFilesDrop(e.dataTransfer.files);
|
|
58
|
+
}
|
|
59
|
+
}, [disabled, onFilesDrop]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
// Attach to the entire window so anywhere the user drags a file in the chat layout, it works
|
|
63
|
+
window.addEventListener('dragenter', handleDragEnter);
|
|
64
|
+
window.addEventListener('dragleave', handleDragLeave);
|
|
65
|
+
window.addEventListener('dragover', handleDragOver);
|
|
66
|
+
window.addEventListener('drop', handleDrop);
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
window.removeEventListener('dragenter', handleDragEnter);
|
|
70
|
+
window.removeEventListener('dragleave', handleDragLeave);
|
|
71
|
+
window.removeEventListener('dragover', handleDragOver);
|
|
72
|
+
window.removeEventListener('drop', handleDrop);
|
|
73
|
+
};
|
|
74
|
+
}, [handleDragEnter, handleDragLeave, handleDragOver, handleDrop]);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
isDragging,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { Channel, E2eeAttachmentManifest, E2eeAttachmentTransferProgress } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
|
|
4
|
+
export const E2EE_PREVIEW_MAX_CONCURRENT = 3;
|
|
5
|
+
export const E2EE_PREVIEW_CACHE_LIMIT = 100;
|
|
6
|
+
|
|
7
|
+
const previewObjectUrlCache = new Map<string, { url: string; blob: Blob }>();
|
|
8
|
+
|
|
9
|
+
export function clearE2eePreviewObjectUrlCache(): void {
|
|
10
|
+
for (const value of previewObjectUrlCache.values()) {
|
|
11
|
+
URL.revokeObjectURL(value.url);
|
|
12
|
+
}
|
|
13
|
+
previewObjectUrlCache.clear();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type E2eeAttachmentRenderState = {
|
|
17
|
+
url?: string;
|
|
18
|
+
blob?: Blob;
|
|
19
|
+
loading: boolean;
|
|
20
|
+
error?: string;
|
|
21
|
+
progress?: E2eeAttachmentTransferProgress;
|
|
22
|
+
load: () => Promise<string | undefined>;
|
|
23
|
+
download: (filename?: string) => Promise<void>;
|
|
24
|
+
streamUrl?: string;
|
|
25
|
+
streamLoading: boolean;
|
|
26
|
+
loadStream: () => Promise<string | undefined>;
|
|
27
|
+
disposeStream: () => Promise<void>;
|
|
28
|
+
revoke: () => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function manifestDisplayString(
|
|
32
|
+
manifest: E2eeAttachmentManifest,
|
|
33
|
+
kind: 'original' | 'preview',
|
|
34
|
+
key: string,
|
|
35
|
+
): string | undefined {
|
|
36
|
+
const asset = manifest.assets.find((item) => item.kind === kind) || manifest.assets[0];
|
|
37
|
+
const value = asset?.display?.[key];
|
|
38
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function previewCacheKey(
|
|
42
|
+
channel: Channel,
|
|
43
|
+
manifest: E2eeAttachmentManifest,
|
|
44
|
+
kind: 'original' | 'preview',
|
|
45
|
+
): string | undefined {
|
|
46
|
+
if (kind !== 'preview') return undefined;
|
|
47
|
+
const asset = manifest.assets.find((item) => item.kind === 'preview');
|
|
48
|
+
if (!asset) return undefined;
|
|
49
|
+
const cid = (channel as any).cid || (channel as any).data?.cid || `${channel.type}:${channel.id}`;
|
|
50
|
+
return `${cid}:${manifest.attachment_id}:${asset.asset_id}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cachePreviewObjectUrl(key: string, url: string, blob: Blob): void {
|
|
54
|
+
if (previewObjectUrlCache.has(key)) {
|
|
55
|
+
const existing = previewObjectUrlCache.get(key);
|
|
56
|
+
if (existing) URL.revokeObjectURL(existing.url);
|
|
57
|
+
previewObjectUrlCache.delete(key);
|
|
58
|
+
}
|
|
59
|
+
previewObjectUrlCache.set(key, { url, blob });
|
|
60
|
+
while (previewObjectUrlCache.size > E2EE_PREVIEW_CACHE_LIMIT) {
|
|
61
|
+
const oldestKey = previewObjectUrlCache.keys().next().value;
|
|
62
|
+
if (!oldestKey) break;
|
|
63
|
+
const oldest = previewObjectUrlCache.get(oldestKey);
|
|
64
|
+
if (oldest) URL.revokeObjectURL(oldest.url);
|
|
65
|
+
previewObjectUrlCache.delete(oldestKey);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useE2eeAttachmentRenderer(
|
|
70
|
+
channel: Channel | null,
|
|
71
|
+
manifest?: E2eeAttachmentManifest,
|
|
72
|
+
kind: 'original' | 'preview' = 'original',
|
|
73
|
+
): E2eeAttachmentRenderState {
|
|
74
|
+
const [url, setUrl] = useState<string | undefined>();
|
|
75
|
+
const [blob, setBlob] = useState<Blob | undefined>();
|
|
76
|
+
const [loading, setLoading] = useState(false);
|
|
77
|
+
const [error, setError] = useState<string | undefined>();
|
|
78
|
+
const [progress, setProgress] = useState<E2eeAttachmentTransferProgress | undefined>();
|
|
79
|
+
const [streamUrl, setStreamUrl] = useState<string | undefined>();
|
|
80
|
+
const [streamLoading, setStreamLoading] = useState(false);
|
|
81
|
+
const streamDisposeRef = useRef<(() => Promise<void>) | undefined>(undefined);
|
|
82
|
+
const cachedPreviewRef = useRef(false);
|
|
83
|
+
|
|
84
|
+
const disposeStream = useCallback(async () => {
|
|
85
|
+
const dispose = streamDisposeRef.current;
|
|
86
|
+
streamDisposeRef.current = undefined;
|
|
87
|
+
setStreamUrl(undefined);
|
|
88
|
+
if (dispose) await dispose();
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const revoke = useCallback(() => {
|
|
92
|
+
setUrl((current) => {
|
|
93
|
+
if (current && !(kind === 'preview' && cachedPreviewRef.current)) URL.revokeObjectURL(current);
|
|
94
|
+
return undefined;
|
|
95
|
+
});
|
|
96
|
+
setBlob(undefined);
|
|
97
|
+
setProgress(undefined);
|
|
98
|
+
cachedPreviewRef.current = false;
|
|
99
|
+
void disposeStream();
|
|
100
|
+
}, [disposeStream, kind]);
|
|
101
|
+
|
|
102
|
+
const load = useCallback(async () => {
|
|
103
|
+
if (!channel || !manifest) return undefined;
|
|
104
|
+
if (url) return url;
|
|
105
|
+
const cacheKey = previewCacheKey(channel, manifest, kind);
|
|
106
|
+
if (cacheKey) {
|
|
107
|
+
const cached = previewObjectUrlCache.get(cacheKey);
|
|
108
|
+
if (cached) {
|
|
109
|
+
previewObjectUrlCache.delete(cacheKey);
|
|
110
|
+
previewObjectUrlCache.set(cacheKey, cached);
|
|
111
|
+
cachedPreviewRef.current = true;
|
|
112
|
+
setBlob(cached.blob);
|
|
113
|
+
setUrl(cached.url);
|
|
114
|
+
return cached.url;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const manager = (channel as any).getClient?.().encryptionManager;
|
|
118
|
+
if (!manager?.initialized) {
|
|
119
|
+
setError('E2EE is not initialized');
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
setLoading(true);
|
|
123
|
+
setError(undefined);
|
|
124
|
+
setProgress(undefined);
|
|
125
|
+
try {
|
|
126
|
+
const downloaded = await manager.downloadE2eeAttachmentAsset(channel.type, channel.id, manifest, kind, {
|
|
127
|
+
onProgress: setProgress,
|
|
128
|
+
});
|
|
129
|
+
const mimeType = manifestDisplayString(manifest, kind, 'mime_type');
|
|
130
|
+
const typedBlob =
|
|
131
|
+
mimeType && downloaded.type !== mimeType ? new Blob([downloaded], { type: mimeType }) : downloaded;
|
|
132
|
+
const objectUrl = URL.createObjectURL(typedBlob);
|
|
133
|
+
if (cacheKey) {
|
|
134
|
+
cachePreviewObjectUrl(cacheKey, objectUrl, typedBlob);
|
|
135
|
+
cachedPreviewRef.current = true;
|
|
136
|
+
} else {
|
|
137
|
+
cachedPreviewRef.current = false;
|
|
138
|
+
}
|
|
139
|
+
setBlob(typedBlob);
|
|
140
|
+
setUrl(objectUrl);
|
|
141
|
+
return objectUrl;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
144
|
+
setError(message);
|
|
145
|
+
return undefined;
|
|
146
|
+
} finally {
|
|
147
|
+
setLoading(false);
|
|
148
|
+
}
|
|
149
|
+
}, [channel, kind, manifest, url]);
|
|
150
|
+
|
|
151
|
+
const loadStream = useCallback(async () => {
|
|
152
|
+
if (!channel || !manifest || kind !== 'original') return undefined;
|
|
153
|
+
if (streamUrl) return streamUrl;
|
|
154
|
+
const manager = (channel as any).getClient?.().encryptionManager;
|
|
155
|
+
if (!manager?.initialized || typeof manager.createE2eeAttachmentStreamUrl !== 'function') return undefined;
|
|
156
|
+
setStreamLoading(true);
|
|
157
|
+
setError(undefined);
|
|
158
|
+
try {
|
|
159
|
+
const handle = await manager.createE2eeAttachmentStreamUrl(channel.type, channel.id, manifest, 'original');
|
|
160
|
+
if (!handle?.url) return undefined;
|
|
161
|
+
streamDisposeRef.current = handle.dispose;
|
|
162
|
+
setStreamUrl(handle.url);
|
|
163
|
+
return handle.url;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
166
|
+
setError(message);
|
|
167
|
+
return undefined;
|
|
168
|
+
} finally {
|
|
169
|
+
setStreamLoading(false);
|
|
170
|
+
}
|
|
171
|
+
}, [channel, kind, manifest, streamUrl]);
|
|
172
|
+
|
|
173
|
+
const download = useCallback(
|
|
174
|
+
async (filename?: string) => {
|
|
175
|
+
const objectUrl = await load();
|
|
176
|
+
if (!objectUrl || typeof document === 'undefined') return;
|
|
177
|
+
const anchor = document.createElement('a');
|
|
178
|
+
anchor.href = objectUrl;
|
|
179
|
+
anchor.download =
|
|
180
|
+
filename || (manifest ? manifestDisplayString(manifest, kind, 'name') : undefined) || 'encrypted-attachment';
|
|
181
|
+
document.body.appendChild(anchor);
|
|
182
|
+
anchor.click();
|
|
183
|
+
anchor.remove();
|
|
184
|
+
},
|
|
185
|
+
[kind, load, manifest],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
useEffect(() => revoke, [revoke]);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
url,
|
|
192
|
+
blob,
|
|
193
|
+
streamUrl,
|
|
194
|
+
streamLoading,
|
|
195
|
+
loading,
|
|
196
|
+
error,
|
|
197
|
+
progress,
|
|
198
|
+
load,
|
|
199
|
+
loadStream,
|
|
200
|
+
download,
|
|
201
|
+
disposeStream,
|
|
202
|
+
revoke,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import type { Channel, E2eeAttachmentManifest } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
|
|
4
|
+
export type E2eeFileUploadProgress = {
|
|
5
|
+
fileIndex: number;
|
|
6
|
+
phase: 'generating_preview' | 'encrypting' | 'uploading' | 'completing';
|
|
7
|
+
loaded: number;
|
|
8
|
+
total: number;
|
|
9
|
+
percentage: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function useE2eeFileUpload(channel: Channel | null) {
|
|
13
|
+
const [progress, setProgress] = useState<E2eeFileUploadProgress | null>(null);
|
|
14
|
+
const [uploading, setUploading] = useState(false);
|
|
15
|
+
const [error, setError] = useState<string | undefined>();
|
|
16
|
+
|
|
17
|
+
const upload = useCallback(
|
|
18
|
+
async (files: Blob[]): Promise<{ attachments: E2eeAttachmentManifest[]; e2ee_attachment_ids: string[] }> => {
|
|
19
|
+
if (!channel) return { attachments: [], e2ee_attachment_ids: [] };
|
|
20
|
+
const manager = (channel as any).getClient?.().encryptionManager;
|
|
21
|
+
if (!manager?.initialized) throw new Error('E2EE attachments require an initialized encryption manager');
|
|
22
|
+
setUploading(true);
|
|
23
|
+
setError(undefined);
|
|
24
|
+
try {
|
|
25
|
+
return await manager.uploadE2eeAttachments(channel.type, channel.id, files, { onProgress: setProgress });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
28
|
+
setError(message);
|
|
29
|
+
throw err;
|
|
30
|
+
} finally {
|
|
31
|
+
setUploading(false);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
[channel],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return { upload, progress, uploading, error };
|
|
38
|
+
}
|
|
@@ -17,6 +17,11 @@ export type UseFileUploadOptions = {
|
|
|
17
17
|
export function useFileUpload({ activeChannel, editableRef, setHasContent }: UseFileUploadOptions) {
|
|
18
18
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
19
19
|
const [files, setFiles] = useState<FilePreviewItem[]>([]);
|
|
20
|
+
const isE2eeChannel =
|
|
21
|
+
!!activeChannel &&
|
|
22
|
+
(typeof (activeChannel as any)._isEffectiveE2ee === 'function'
|
|
23
|
+
? (activeChannel as any)._isEffectiveE2ee()
|
|
24
|
+
: activeChannel.data?.mls_enabled === true);
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* Upload a single file immediately:
|
|
@@ -35,7 +40,16 @@ export function useFileUpload({ activeChannel, editableRef, setHasContent }: Use
|
|
|
35
40
|
? new File([file], normalizedName, { type: file.type, lastModified: file.lastModified })
|
|
36
41
|
: file;
|
|
37
42
|
|
|
38
|
-
const response = await activeChannel.
|
|
43
|
+
const response = await activeChannel.uploadFilePresigned(
|
|
44
|
+
fileToUpload,
|
|
45
|
+
fileToUpload.name,
|
|
46
|
+
fileToUpload.type || 'application/octet-stream',
|
|
47
|
+
(progress) => {
|
|
48
|
+
setFiles((prev) =>
|
|
49
|
+
prev.map((f) => (f.id === item.id ? { ...f, progress: progress.percentage } : f))
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
39
53
|
const uploadedUrl = response.file;
|
|
40
54
|
|
|
41
55
|
let thumbUrl = '';
|
|
@@ -44,7 +58,7 @@ export function useFileUpload({ activeChannel, editableRef, setHasContent }: Use
|
|
|
44
58
|
const thumbBlob = await activeChannel.getThumbBlobVideo(file);
|
|
45
59
|
if (thumbBlob) {
|
|
46
60
|
const thumbFile = new File([thumbBlob], `thumb_${normalizedName}.jpg`, { type: 'image/jpeg' });
|
|
47
|
-
const thumbResp = await activeChannel.
|
|
61
|
+
const thumbResp = await activeChannel.uploadFilePresigned(thumbFile, thumbFile.name, 'image/jpeg');
|
|
48
62
|
thumbUrl = thumbResp.file;
|
|
49
63
|
}
|
|
50
64
|
} catch {
|
|
@@ -81,15 +95,21 @@ export function useFileUpload({ activeChannel, editableRef, setHasContent }: Use
|
|
|
81
95
|
id: nextFileId(),
|
|
82
96
|
file,
|
|
83
97
|
previewUrl: isPreviewable ? URL.createObjectURL(file) : undefined,
|
|
84
|
-
status: 'uploading' as const,
|
|
98
|
+
status: isE2eeChannel ? ('pending' as const) : ('uploading' as const),
|
|
99
|
+
e2eePhase: isE2eeChannel ? ('encrypting' as const) : undefined,
|
|
85
100
|
};
|
|
86
101
|
});
|
|
87
102
|
|
|
88
103
|
setFiles((prev) => [...prev, ...newItems]);
|
|
89
104
|
setHasContent(true);
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
if (!isE2eeChannel) {
|
|
107
|
+
newItems.forEach((item) => uploadSingleFile(item));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Auto-focus the input so user can press Enter to send immediately
|
|
111
|
+
editableRef.current?.focus();
|
|
112
|
+
}, [uploadSingleFile, setHasContent, editableRef, isE2eeChannel]);
|
|
93
113
|
|
|
94
114
|
const handleRemoveFile = useCallback((id: string) => {
|
|
95
115
|
setFiles((prev) => {
|