@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
@@ -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.sendFile(fileToUpload, fileToUpload.name, fileToUpload.type);
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.sendFile(thumbFile, thumbFile.name, 'image/jpeg');
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
- newItems.forEach((item) => uploadSingleFile(item));
92
- }, [uploadSingleFile, setHasContent]);
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) => {