@ermis-network/ermis-chat-react 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -0
- package/dist/index.cjs +5087 -11279
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +632 -152
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +273 -9
- package/dist/index.d.ts +273 -9
- package/dist/index.mjs +5085 -11295
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/Channel.tsx +0 -3
- package/src/components/ChannelActions.tsx +6 -1
- package/src/components/ChannelHeader.tsx +8 -32
- package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
- package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
- package/src/components/ChannelList.tsx +72 -13
- package/src/components/CreateChannelModal.tsx +131 -12
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +27 -16
- package/src/components/ForwardMessageModal.tsx +11 -3
- package/src/components/MediaLightbox.tsx +444 -304
- package/src/components/MessageActionsBox.tsx +2 -0
- package/src/components/MessageInput.tsx +41 -12
- package/src/components/MessageItem.tsx +70 -25
- package/src/components/MessageQuickReactions.tsx +131 -128
- package/src/components/MessageReactions.tsx +47 -2
- package/src/components/MessageRenderers.tsx +1030 -433
- package/src/components/PinnedMessages.tsx +40 -12
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +20 -5
- package/src/components/TypingIndicator.tsx +3 -3
- package/src/components/UserPicker.tsx +26 -25
- package/src/components/VirtualMessageList.tsx +345 -125
- package/src/context/ChatProvider.tsx +27 -1
- package/src/hooks/useChannelListUpdates.ts +22 -1
- package/src/hooks/useChannelMessages.ts +338 -51
- package/src/hooks/useChannelRowUpdates.ts +18 -6
- package/src/hooks/useChatUser.ts +9 -1
- 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 +210 -13
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -6
- package/src/hooks/useMessageActions.ts +14 -8
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useTopicGroupUpdates.ts +49 -11
- package/src/index.ts +23 -0
- package/src/messageTypeUtils.ts +14 -0
- package/src/styles/_channel-info.css +9 -0
- package/src/styles/_channel-list.css +37 -14
- package/src/styles/_media-lightbox.css +36 -3
- package/src/styles/_message-bubble.css +381 -41
- package/src/styles/_message-input.css +8 -0
- package/src/styles/_message-list.css +67 -10
- package/src/styles/_message-quick-reactions.css +101 -59
- package/src/styles/_message-reactions.css +18 -32
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +5 -5
- package/src/styles/_typing-indicator.css +23 -13
- package/src/styles/index.css +1 -0
- package/src/types.ts +115 -1
- package/src/utils/avatarColors.ts +1 -1
- package/src/utils.ts +38 -18
|
@@ -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) => {
|
|
@@ -1,10 +1,144 @@
|
|
|
1
1
|
import { useState, useMemo, useCallback } from 'react';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
Channel,
|
|
4
|
+
E2eeAttachmentManifest,
|
|
5
|
+
E2eeAttachmentManifestAsset,
|
|
6
|
+
FormatMessageResponse,
|
|
7
|
+
} from '@ermis-network/ermis-chat-sdk';
|
|
3
8
|
import { createForwardMessagePayload } from '@ermis-network/ermis-chat-sdk';
|
|
4
9
|
import { useChatClient } from './useChatClient';
|
|
5
|
-
import { removeAccents } from '../utils';
|
|
10
|
+
import { removeAccents, buildUserMap } from '../utils';
|
|
6
11
|
import { isPendingMember, isSkippedMember } from '../channelRoleUtils';
|
|
7
12
|
|
|
13
|
+
function isE2eeAttachmentManifest(attachment: unknown): attachment is E2eeAttachmentManifest {
|
|
14
|
+
return Boolean(
|
|
15
|
+
attachment &&
|
|
16
|
+
typeof attachment === 'object' &&
|
|
17
|
+
(attachment as E2eeAttachmentManifest).version === 1 &&
|
|
18
|
+
typeof (attachment as E2eeAttachmentManifest).attachment_id === 'string' &&
|
|
19
|
+
Array.isArray((attachment as E2eeAttachmentManifest).assets),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isEffectiveE2ee(channel: Channel | null | undefined): boolean {
|
|
24
|
+
if (!channel) return false;
|
|
25
|
+
return typeof (channel as any)._isEffectiveE2ee === 'function'
|
|
26
|
+
? (channel as any)._isEffectiveE2ee()
|
|
27
|
+
: channel.data?.mls_enabled === true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function attachmentUrl(attachment: any): string | undefined {
|
|
31
|
+
return attachment?.asset_url || attachment?.image_url || attachment?.url || attachment?.thumb_url;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function originalAsset(attachment: E2eeAttachmentManifest): E2eeAttachmentManifestAsset | undefined {
|
|
35
|
+
return attachment.assets.find((asset) => asset.kind === 'original') || attachment.assets[0];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function displayString(display: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
39
|
+
const value = display?.[key];
|
|
40
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function displayNumber(display: Record<string, unknown> | undefined, key: string): number | undefined {
|
|
44
|
+
const value = display?.[key];
|
|
45
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extensionForMimeType(mimeType?: string): string {
|
|
49
|
+
if (!mimeType) return '';
|
|
50
|
+
if (mimeType === 'image/jpeg') return '.jpg';
|
|
51
|
+
if (mimeType === 'image/png') return '.png';
|
|
52
|
+
if (mimeType === 'image/webp') return '.webp';
|
|
53
|
+
if (mimeType === 'image/gif') return '.gif';
|
|
54
|
+
if (mimeType === 'video/mp4') return '.mp4';
|
|
55
|
+
if (mimeType === 'video/quicktime') return '.mov';
|
|
56
|
+
if (mimeType === 'video/webm') return '.webm';
|
|
57
|
+
if (mimeType === 'audio/mpeg') return '.mp3';
|
|
58
|
+
if (mimeType === 'audio/webm') return '.webm';
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function inferAttachmentType(attachment: any, mimeType?: string): string | undefined {
|
|
63
|
+
const explicitType = attachment?.attachment_type || attachment?.type;
|
|
64
|
+
if (explicitType === 'voiceRecording') return 'voiceRecording';
|
|
65
|
+
if (explicitType === 'image' || explicitType === 'video' || explicitType === 'file') return explicitType;
|
|
66
|
+
if (mimeType?.startsWith('image/')) return 'image';
|
|
67
|
+
if (mimeType?.startsWith('video/')) return 'video';
|
|
68
|
+
if (mimeType?.startsWith('audio/')) return 'file';
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sourceAttachmentMetadata(
|
|
73
|
+
attachment: any,
|
|
74
|
+
blob: Blob,
|
|
75
|
+
index: number,
|
|
76
|
+
): { file: File; displayOverride: Record<string, unknown> } {
|
|
77
|
+
const manifestDisplay = isE2eeAttachmentManifest(attachment) ? originalAsset(attachment)?.display : undefined;
|
|
78
|
+
const sourceName =
|
|
79
|
+
displayString(manifestDisplay, 'name') ||
|
|
80
|
+
attachment?.file_name ||
|
|
81
|
+
attachment?.title ||
|
|
82
|
+
attachment?.name ||
|
|
83
|
+
`forwarded-attachment-${index + 1}`;
|
|
84
|
+
const sourceMime =
|
|
85
|
+
displayString(manifestDisplay, 'mime_type') ||
|
|
86
|
+
attachment?.mime_type ||
|
|
87
|
+
attachment?.content_type ||
|
|
88
|
+
blob.type ||
|
|
89
|
+
'application/octet-stream';
|
|
90
|
+
const hasExtension = /\.[A-Za-z0-9]{1,8}$/.test(sourceName);
|
|
91
|
+
const name = hasExtension ? sourceName : `${sourceName}${extensionForMimeType(sourceMime)}`;
|
|
92
|
+
const normalizedBlob = new File([blob], name, { type: sourceMime || blob.type || 'application/octet-stream' });
|
|
93
|
+
const attachmentType = inferAttachmentType(attachment, sourceMime);
|
|
94
|
+
const width = displayNumber(manifestDisplay, 'width') || attachment?.original_width || attachment?.width;
|
|
95
|
+
const height = displayNumber(manifestDisplay, 'height') || attachment?.original_height || attachment?.height;
|
|
96
|
+
const duration = displayNumber(manifestDisplay, 'duration') || attachment?.duration;
|
|
97
|
+
const displayOverride: Record<string, unknown> = {
|
|
98
|
+
name,
|
|
99
|
+
mime_type: sourceMime,
|
|
100
|
+
size: blob.size,
|
|
101
|
+
...(attachmentType ? { attachment_type: attachmentType } : {}),
|
|
102
|
+
...(typeof width === 'number' && Number.isFinite(width) ? { width } : {}),
|
|
103
|
+
...(typeof height === 'number' && Number.isFinite(height) ? { height } : {}),
|
|
104
|
+
...(typeof duration === 'number' && Number.isFinite(duration) ? { duration } : {}),
|
|
105
|
+
};
|
|
106
|
+
return { file: normalizedBlob, displayOverride };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function materializeForwardSourceAttachments(
|
|
110
|
+
manager: any,
|
|
111
|
+
sourceChannel: Channel,
|
|
112
|
+
sourceAttachments: any[],
|
|
113
|
+
): Promise<{ files: File[]; displayOverrides: Map<number, Record<string, unknown>> }> {
|
|
114
|
+
if (!manager?.initialized) throw new Error('E2EE forward requires an initialized encryption manager');
|
|
115
|
+
const files: File[] = [];
|
|
116
|
+
const displayOverrides = new Map<number, Record<string, unknown>>();
|
|
117
|
+
|
|
118
|
+
for (const [index, attachment] of sourceAttachments.entries()) {
|
|
119
|
+
let sourceBlob: Blob;
|
|
120
|
+
if (isE2eeAttachmentManifest(attachment)) {
|
|
121
|
+
sourceBlob = await manager.downloadE2eeAttachmentAsset(
|
|
122
|
+
sourceChannel.type,
|
|
123
|
+
sourceChannel.id!,
|
|
124
|
+
attachment,
|
|
125
|
+
'original',
|
|
126
|
+
);
|
|
127
|
+
} else {
|
|
128
|
+
const url = attachmentUrl(attachment);
|
|
129
|
+
if (!url) throw new Error('Forward source attachment has no downloadable URL');
|
|
130
|
+
const response = await fetch(url);
|
|
131
|
+
if (!response.ok) throw new Error(`Forward source attachment download failed: HTTP ${response.status}`);
|
|
132
|
+
sourceBlob = await response.blob();
|
|
133
|
+
}
|
|
134
|
+
const { file, displayOverride } = sourceAttachmentMetadata(attachment, sourceBlob, index);
|
|
135
|
+
files.push(file);
|
|
136
|
+
displayOverrides.set(index, displayOverride);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { files, displayOverrides };
|
|
140
|
+
}
|
|
141
|
+
|
|
8
142
|
export function useForwardMessage(message: FormatMessageResponse, onDismiss: () => void) {
|
|
9
143
|
const { client, activeChannel } = useChatClient();
|
|
10
144
|
const [selectedChannels, setSelectedChannels] = useState<Set<string>>(new Set());
|
|
@@ -27,7 +161,8 @@ export function useForwardMessage(message: FormatMessageResponse, onDismiss: ()
|
|
|
27
161
|
const cleanQ = removeAccents(q);
|
|
28
162
|
const isStrict = q !== cleanQ;
|
|
29
163
|
|
|
30
|
-
|
|
164
|
+
const result: Channel[] = [];
|
|
165
|
+
for (const ch of channels) {
|
|
31
166
|
const name = (ch.data?.name || ch.cid) as string;
|
|
32
167
|
const t = name.toLowerCase();
|
|
33
168
|
const cleanT = removeAccents(t);
|
|
@@ -38,14 +173,21 @@ export function useForwardMessage(message: FormatMessageResponse, onDismiss: ()
|
|
|
38
173
|
const pt = parentName.toLowerCase();
|
|
39
174
|
const cleanPT = removeAccents(pt);
|
|
40
175
|
|
|
176
|
+
let matched = false;
|
|
41
177
|
if (isStrict) {
|
|
42
178
|
// Strict match when query has accents
|
|
43
|
-
|
|
179
|
+
matched = t.startsWith(q) || pt.startsWith(q);
|
|
44
180
|
} else {
|
|
45
181
|
// Broad match when query is accent-less
|
|
46
|
-
|
|
182
|
+
matched = cleanT.startsWith(cleanQ) || cleanPT.startsWith(cleanQ);
|
|
47
183
|
}
|
|
48
|
-
|
|
184
|
+
|
|
185
|
+
if (matched) {
|
|
186
|
+
result.push(ch);
|
|
187
|
+
if (result.length >= 50) break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
49
191
|
}, [channels, search, client.activeChannels]);
|
|
50
192
|
|
|
51
193
|
/* ---------- Toggle selection ---------- */
|
|
@@ -67,21 +209,76 @@ export function useForwardMessage(message: FormatMessageResponse, onDismiss: ()
|
|
|
67
209
|
setSending(true);
|
|
68
210
|
const success: string[] = [];
|
|
69
211
|
const failed: string[] = [];
|
|
212
|
+
let queuedBackgroundForward = false;
|
|
213
|
+
|
|
214
|
+
// Format message text to replace mention IDs with names
|
|
215
|
+
let formattedMessage = { ...message };
|
|
216
|
+
if (formattedMessage.text && formattedMessage.mentioned_users && formattedMessage.mentioned_users.length > 0) {
|
|
217
|
+
let newText = formattedMessage.text;
|
|
218
|
+
const userMap = buildUserMap(activeChannel.state);
|
|
219
|
+
|
|
220
|
+
formattedMessage.mentioned_users.forEach((userId) => {
|
|
221
|
+
const name = userMap[userId] || client.state.users[userId]?.name || userId;
|
|
222
|
+
newText = newText.replace(new RegExp(`@${userId}`, 'g'), `@${name}`);
|
|
223
|
+
});
|
|
224
|
+
formattedMessage.text = newText;
|
|
225
|
+
}
|
|
70
226
|
|
|
71
227
|
for (const cid of selectedChannels) {
|
|
72
228
|
const targetChannel = channels.find((c) => c.cid === cid);
|
|
73
229
|
if (!targetChannel) continue;
|
|
74
230
|
try {
|
|
231
|
+
if (!['messaging', 'team', 'topic'].includes(activeChannel.type)) {
|
|
232
|
+
throw new Error('Forward source channel type is not allowed');
|
|
233
|
+
}
|
|
234
|
+
if (formattedMessage.quoted_message_id || formattedMessage.parent_id) {
|
|
235
|
+
throw new Error('Reply/thread messages cannot be forwarded');
|
|
236
|
+
}
|
|
237
|
+
if (formattedMessage.mentioned_all || (formattedMessage.mentioned_users?.length || 0) > 0) {
|
|
238
|
+
throw new Error('Mention messages cannot be forwarded');
|
|
239
|
+
}
|
|
240
|
+
const targetIsE2ee = isEffectiveE2ee(targetChannel);
|
|
241
|
+
const sourceIsE2ee = isEffectiveE2ee(activeChannel);
|
|
242
|
+
if (sourceIsE2ee && !targetIsE2ee) {
|
|
243
|
+
const accepted =
|
|
244
|
+
typeof window === 'undefined' ||
|
|
245
|
+
window.confirm('Forwarding this encrypted message to a standard channel will remove E2EE protection.');
|
|
246
|
+
if (!accepted) throw new Error('Privacy downgrade canceled');
|
|
247
|
+
}
|
|
75
248
|
const forwardPayload = createForwardMessagePayload(
|
|
76
|
-
|
|
249
|
+
formattedMessage,
|
|
77
250
|
targetChannel.cid as string,
|
|
78
251
|
activeChannel.cid as string,
|
|
79
252
|
);
|
|
80
253
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
254
|
+
const sourceAttachments = (formattedMessage.attachments as any[] | undefined) || [];
|
|
255
|
+
if (targetIsE2ee && sourceAttachments.length > 0) {
|
|
256
|
+
const { files, displayOverrides } = await materializeForwardSourceAttachments(
|
|
257
|
+
client.encryptionManager,
|
|
258
|
+
activeChannel,
|
|
259
|
+
sourceAttachments,
|
|
260
|
+
);
|
|
261
|
+
await (targetChannel as any).enqueueE2eeAttachmentMessage(forwardPayload, files, { displayOverrides });
|
|
262
|
+
queuedBackgroundForward = true;
|
|
263
|
+
} else {
|
|
264
|
+
const standardForwardPayload = { ...forwardPayload };
|
|
265
|
+
if (!targetIsE2ee && sourceIsE2ee && sourceAttachments.length > 0) {
|
|
266
|
+
const { files } = await materializeForwardSourceAttachments(
|
|
267
|
+
client.encryptionManager,
|
|
268
|
+
activeChannel,
|
|
269
|
+
sourceAttachments,
|
|
270
|
+
);
|
|
271
|
+
const { attachments, failedFiles } = await targetChannel.uploadAndPrepareAttachments(files);
|
|
272
|
+
if (failedFiles.length > 0) {
|
|
273
|
+
throw new Error(`Forward standard attachment upload failed for ${failedFiles.length} file(s)`);
|
|
274
|
+
}
|
|
275
|
+
standardForwardPayload.attachments = attachments as any;
|
|
276
|
+
}
|
|
277
|
+
await targetChannel.forwardMessage(standardForwardPayload, {
|
|
278
|
+
type: targetChannel.type,
|
|
279
|
+
channelID: targetChannel.id!,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
85
282
|
success.push((targetChannel.data?.name || targetChannel.cid) as string);
|
|
86
283
|
} catch (err) {
|
|
87
284
|
console.error(`Failed to forward to ${cid}`, err);
|
|
@@ -94,9 +291,9 @@ export function useForwardMessage(message: FormatMessageResponse, onDismiss: ()
|
|
|
94
291
|
|
|
95
292
|
// Auto-close after success (short delay)
|
|
96
293
|
if (failed.length === 0) {
|
|
97
|
-
setTimeout(() => onDismiss(), 1200);
|
|
294
|
+
setTimeout(() => onDismiss(), queuedBackgroundForward ? 0 : 1200);
|
|
98
295
|
}
|
|
99
|
-
}, [activeChannel, selectedChannels, channels, message, sending, onDismiss]);
|
|
296
|
+
}, [client, activeChannel, selectedChannels, channels, message, sending, onDismiss]);
|
|
100
297
|
|
|
101
298
|
return {
|
|
102
299
|
search,
|
|
@@ -21,6 +21,8 @@ export type UseLoadMessagesOptions = {
|
|
|
21
21
|
messagesRef: React.MutableRefObject<FormatMessageResponse[]>;
|
|
22
22
|
/** Shared guard ref — skip scroll-triggered loads during jump transitions */
|
|
23
23
|
jumpingRef: React.MutableRefObject<boolean>;
|
|
24
|
+
/** Blocks scroll-triggered pagination while auto-following appended messages. */
|
|
25
|
+
scrollLoadLockRef?: React.MutableRefObject<boolean>;
|
|
24
26
|
loadMoreLimit?: number;
|
|
25
27
|
};
|
|
26
28
|
|
|
@@ -45,6 +47,7 @@ export function useLoadMessages({
|
|
|
45
47
|
vlistRef,
|
|
46
48
|
messagesRef,
|
|
47
49
|
jumpingRef,
|
|
50
|
+
scrollLoadLockRef,
|
|
48
51
|
loadMoreLimit = 25,
|
|
49
52
|
}: UseLoadMessagesOptions): UseLoadMessagesReturn {
|
|
50
53
|
const { activeChannel, setMessages } = useChatClient();
|
|
@@ -52,10 +55,19 @@ export function useLoadMessages({
|
|
|
52
55
|
const [hasNewer, setHasNewer] = useState(false);
|
|
53
56
|
const [shiftMode, setShiftMode] = useState(false);
|
|
54
57
|
|
|
55
|
-
//
|
|
58
|
+
// Reset shiftMode on channel switch so initial load isn't treated as a prepend
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setShiftMode(false);
|
|
61
|
+
}, [activeChannel?.cid]);
|
|
62
|
+
|
|
63
|
+
// Reset shiftMode to false after the prepend render is committed.
|
|
64
|
+
// shift should only be true for the single render cycle when older messages
|
|
65
|
+
// are prepended; leaving it on causes virtua to mis-compensate scroll
|
|
66
|
+
// positions when new messages are appended at the bottom, which leads
|
|
67
|
+
// to intermittent message overlapping.
|
|
56
68
|
useEffect(() => {
|
|
57
69
|
if (shiftMode) {
|
|
58
|
-
|
|
70
|
+
setShiftMode(false);
|
|
59
71
|
}
|
|
60
72
|
}, [shiftMode]);
|
|
61
73
|
|
|
@@ -135,7 +147,7 @@ export function useLoadMessages({
|
|
|
135
147
|
|
|
136
148
|
const handleScroll = useCallback(
|
|
137
149
|
(offset: number) => {
|
|
138
|
-
if (jumpingRef.current) return;
|
|
150
|
+
if (jumpingRef.current || scrollLoadLockRef?.current) return;
|
|
139
151
|
const handle = vlistRef.current;
|
|
140
152
|
if (!handle) return;
|
|
141
153
|
const { scrollSize, viewportSize } = handle;
|
|
@@ -157,7 +169,7 @@ export function useLoadMessages({
|
|
|
157
169
|
loadNewer();
|
|
158
170
|
}
|
|
159
171
|
},
|
|
160
|
-
[loadMore, loadNewer],
|
|
172
|
+
[loadMore, loadNewer, scrollLoadLockRef],
|
|
161
173
|
);
|
|
162
174
|
|
|
163
175
|
return {
|