@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.
Files changed (72) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +5087 -11279
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +632 -152
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +273 -9
  7. package/dist/index.d.ts +273 -9
  8. package/dist/index.mjs +5085 -11295
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/components/Channel.tsx +0 -3
  12. package/src/components/ChannelActions.tsx +6 -1
  13. package/src/components/ChannelHeader.tsx +8 -32
  14. package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
  17. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
  18. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
  19. package/src/components/ChannelList.tsx +72 -13
  20. package/src/components/CreateChannelModal.tsx +131 -12
  21. package/src/components/FilesPreview.tsx +8 -12
  22. package/src/components/FlatTopicGroupItem.tsx +27 -16
  23. package/src/components/ForwardMessageModal.tsx +11 -3
  24. package/src/components/MediaLightbox.tsx +444 -304
  25. package/src/components/MessageActionsBox.tsx +2 -0
  26. package/src/components/MessageInput.tsx +41 -12
  27. package/src/components/MessageItem.tsx +70 -25
  28. package/src/components/MessageQuickReactions.tsx +131 -128
  29. package/src/components/MessageReactions.tsx +47 -2
  30. package/src/components/MessageRenderers.tsx +1030 -433
  31. package/src/components/PinnedMessages.tsx +40 -12
  32. package/src/components/QuotedMessagePreview.tsx +99 -8
  33. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  34. package/src/components/RecoveryPin/index.ts +19 -0
  35. package/src/components/TopicList.tsx +20 -5
  36. package/src/components/TypingIndicator.tsx +3 -3
  37. package/src/components/UserPicker.tsx +26 -25
  38. package/src/components/VirtualMessageList.tsx +345 -125
  39. package/src/context/ChatProvider.tsx +27 -1
  40. package/src/hooks/useChannelListUpdates.ts +22 -1
  41. package/src/hooks/useChannelMessages.ts +338 -51
  42. package/src/hooks/useChannelRowUpdates.ts +18 -6
  43. package/src/hooks/useChatUser.ts +9 -1
  44. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  45. package/src/hooks/useE2eeFileUpload.ts +38 -0
  46. package/src/hooks/useFileUpload.ts +25 -5
  47. package/src/hooks/useForwardMessage.ts +210 -13
  48. package/src/hooks/useLoadMessages.ts +16 -4
  49. package/src/hooks/useMentions.ts +60 -6
  50. package/src/hooks/useMessageActions.ts +14 -8
  51. package/src/hooks/useMessageSend.ts +64 -12
  52. package/src/hooks/usePendingE2eeSends.ts +29 -0
  53. package/src/hooks/useRecoveryPin.ts +287 -0
  54. package/src/hooks/useScrollToMessage.ts +29 -4
  55. package/src/hooks/useTopicGroupUpdates.ts +49 -11
  56. package/src/index.ts +23 -0
  57. package/src/messageTypeUtils.ts +14 -0
  58. package/src/styles/_channel-info.css +9 -0
  59. package/src/styles/_channel-list.css +37 -14
  60. package/src/styles/_media-lightbox.css +36 -3
  61. package/src/styles/_message-bubble.css +381 -41
  62. package/src/styles/_message-input.css +8 -0
  63. package/src/styles/_message-list.css +67 -10
  64. package/src/styles/_message-quick-reactions.css +101 -59
  65. package/src/styles/_message-reactions.css +18 -32
  66. package/src/styles/_recovery-pin.css +97 -0
  67. package/src/styles/_tokens.css +5 -5
  68. package/src/styles/_typing-indicator.css +23 -13
  69. package/src/styles/index.css +1 -0
  70. package/src/types.ts +115 -1
  71. package/src/utils/avatarColors.ts +1 -1
  72. 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.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) => {
@@ -1,10 +1,144 @@
1
1
  import { useState, useMemo, useCallback } from 'react';
2
- import type { Channel, FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
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
- return channels.filter((ch) => {
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
- return t.includes(q) || pt.includes(q);
179
+ matched = t.startsWith(q) || pt.startsWith(q);
44
180
  } else {
45
181
  // Broad match when query is accent-less
46
- return cleanT.includes(cleanQ) || cleanPT.includes(cleanQ);
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
- message,
249
+ formattedMessage,
77
250
  targetChannel.cid as string,
78
251
  activeChannel.cid as string,
79
252
  );
80
253
 
81
- await activeChannel.forwardMessage(forwardPayload, {
82
- type: targetChannel.type,
83
- channelID: targetChannel.id!,
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
- // Auto-reset shiftMode after each prepend render
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
- requestAnimationFrame(() => setShiftMode(false));
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 {