update
This commit is contained in:
@@ -747,6 +747,7 @@ export interface FamilyGroup {
|
||||
export interface BotChatMessage {
|
||||
id: string;
|
||||
direction: 'in' | 'out';
|
||||
scope?: 'private' | 'broadcast';
|
||||
text: string;
|
||||
messageType: string;
|
||||
messageId: number;
|
||||
|
||||
114
apps/frontend/lib/chat-image-compress.ts
Normal file
114
apps/frontend/lib/chat-image-compress.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
const DEFAULT_MAX_EDGE = 2560;
|
||||
const DEFAULT_QUALITY = 0.82;
|
||||
|
||||
function loadImageFromFile(file: File) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Не удалось прочитать изображение'));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Не удалось сжать изображение'));
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
},
|
||||
type,
|
||||
quality
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isCompressibleImageFile(file: File) {
|
||||
const type = (file.type || '').toLowerCase();
|
||||
if (type.startsWith('image/') && !type.includes('gif') && !type.includes('svg')) {
|
||||
return true;
|
||||
}
|
||||
return /\.(jpe?g|png|webp|heic|heif|bmp)$/i.test(file.name);
|
||||
}
|
||||
|
||||
export async function compressChatImage(
|
||||
file: File,
|
||||
options?: { maxEdge?: number; quality?: number }
|
||||
) {
|
||||
const maxEdge = options?.maxEdge ?? DEFAULT_MAX_EDGE;
|
||||
const quality = options?.quality ?? DEFAULT_QUALITY;
|
||||
|
||||
if (!isCompressibleImageFile(file)) {
|
||||
return { file, width: undefined, height: undefined, compressed: false };
|
||||
}
|
||||
|
||||
const image = await loadImageFromFile(file);
|
||||
const width = image.naturalWidth || image.width;
|
||||
const height = image.naturalHeight || image.height;
|
||||
if (!width || !height) {
|
||||
return { file, width: undefined, height: undefined, compressed: false };
|
||||
}
|
||||
|
||||
const scale = Math.min(1, maxEdge / Math.max(width, height));
|
||||
const targetWidth = Math.max(1, Math.round(width * scale));
|
||||
const targetHeight = Math.max(1, Math.round(height * scale));
|
||||
|
||||
if (scale >= 1 && file.size <= 350_000) {
|
||||
return { file, width, height, compressed: false };
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return { file, width, height, compressed: false };
|
||||
}
|
||||
|
||||
context.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
const outputType = file.type === 'image/png' ? 'image/jpeg' : file.type || 'image/jpeg';
|
||||
const blob = await canvasToBlob(canvas, outputType, quality);
|
||||
const extension = outputType.includes('webp') ? 'webp' : 'jpg';
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image';
|
||||
const compressedFile = new File([blob], `${baseName}.${extension}`, { type: outputType, lastModified: Date.now() });
|
||||
|
||||
return {
|
||||
file: compressedFile,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
compressed: true
|
||||
};
|
||||
}
|
||||
|
||||
export async function readImageDimensions(file: File) {
|
||||
if (!isCompressibleImageFile(file)) {
|
||||
return { width: undefined, height: undefined };
|
||||
}
|
||||
try {
|
||||
const image = await loadImageFromFile(file);
|
||||
return {
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height
|
||||
};
|
||||
} catch {
|
||||
return { width: undefined, height: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
export function createFilePreviewUrl(file: File) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
return URL.createObjectURL(file);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
75
apps/frontend/lib/chat-media-album.ts
Normal file
75
apps/frontend/lib/chat-media-album.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { ChatMessage } from '@/lib/api';
|
||||
|
||||
export interface ChatMediaMetadata {
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
durationMs?: number;
|
||||
albumId?: string;
|
||||
albumIndex?: number;
|
||||
albumCount?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
sendAsFile?: boolean;
|
||||
e2e?: boolean;
|
||||
}
|
||||
|
||||
export function parseChatMediaMetadata(metadataJson?: string): ChatMediaMetadata {
|
||||
if (!metadataJson) return {};
|
||||
try {
|
||||
return JSON.parse(metadataJson) as ChatMediaMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export type ChatDisplayItem =
|
||||
| { kind: 'message'; message: ChatMessage }
|
||||
| { kind: 'album'; albumId: string; messages: ChatMessage[] };
|
||||
|
||||
export function groupChatMessagesForDisplay(messages: ChatMessage[]): ChatDisplayItem[] {
|
||||
const items: ChatDisplayItem[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < messages.length) {
|
||||
const message = messages[index]!;
|
||||
const meta = parseChatMediaMetadata(message.metadataJson);
|
||||
|
||||
if (meta.albumId && message.storageKey) {
|
||||
const albumMessages: ChatMessage[] = [message];
|
||||
let cursor = index + 1;
|
||||
while (cursor < messages.length) {
|
||||
const next = messages[cursor]!;
|
||||
const nextMeta = parseChatMediaMetadata(next.metadataJson);
|
||||
if (
|
||||
nextMeta.albumId === meta.albumId &&
|
||||
next.senderId === message.senderId &&
|
||||
next.storageKey
|
||||
) {
|
||||
albumMessages.push(next);
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
albumMessages.sort((left, right) => {
|
||||
const leftIndex = parseChatMediaMetadata(left.metadataJson).albumIndex ?? 0;
|
||||
const rightIndex = parseChatMediaMetadata(right.metadataJson).albumIndex ?? 0;
|
||||
return leftIndex - rightIndex;
|
||||
});
|
||||
|
||||
items.push({ kind: 'album', albumId: meta.albumId, messages: albumMessages });
|
||||
index = cursor;
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({ kind: 'message', message });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function collectChatImageMessages(messages: ChatMessage[]) {
|
||||
return messages.filter((message) => message.type === 'IMAGE' && message.storageKey);
|
||||
}
|
||||
121
apps/frontend/lib/chat-media-upload.ts
Normal file
121
apps/frontend/lib/chat-media-upload.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
apiFetch,
|
||||
PresignedUploadResponse,
|
||||
sendChatMessage,
|
||||
uploadMediaObject,
|
||||
type ChatMessage
|
||||
} from '@/lib/api';
|
||||
import { compressChatImage, isCompressibleImageFile, readImageDimensions } from '@/lib/chat-image-compress';
|
||||
import { detectChatMessageType, resolveChatContentType } from '@/lib/chat-media';
|
||||
import { e2eKindFromMessageType, encryptE2EBlob, type E2EMessagePayload } from '@/lib/e2e-chat';
|
||||
|
||||
export interface ChatMediaUploadItem {
|
||||
file: File;
|
||||
sendAsFile: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
type EncryptOutgoing = (payload: E2EMessagePayload) => Promise<string>;
|
||||
|
||||
export async function uploadChatMediaBatch(params: {
|
||||
roomId: string;
|
||||
token: string;
|
||||
items: ChatMediaUploadItem[];
|
||||
caption?: string;
|
||||
isE2E: boolean;
|
||||
userId?: string;
|
||||
peerE2eKey?: string | null;
|
||||
encryptOutgoing?: EncryptOutgoing;
|
||||
}): Promise<ChatMessage[]> {
|
||||
const { roomId, token, items, caption, isE2E, userId, peerE2eKey, encryptOutgoing } = params;
|
||||
if (!items.length) return [];
|
||||
|
||||
const albumId = crypto.randomUUID();
|
||||
const created: ChatMessage[] = [];
|
||||
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index]!;
|
||||
let file = item.file;
|
||||
let width = item.width;
|
||||
let height = item.height;
|
||||
|
||||
const shouldCompress = isCompressibleImageFile(file) && !item.sendAsFile;
|
||||
if (shouldCompress) {
|
||||
const compressed = await compressChatImage(file);
|
||||
file = compressed.file;
|
||||
width = compressed.width ?? width;
|
||||
height = compressed.height ?? height;
|
||||
} else if (isCompressibleImageFile(file) && (!width || !height)) {
|
||||
const dimensions = await readImageDimensions(file);
|
||||
width = dimensions.width;
|
||||
height = dimensions.height;
|
||||
}
|
||||
|
||||
const contentType = resolveChatContentType(file);
|
||||
const messageType = item.sendAsFile && isCompressibleImageFile(item.file) ? 'FILE' : detectChatMessageType(file);
|
||||
const metadata = {
|
||||
fileName: item.file.name,
|
||||
fileSize: file.size,
|
||||
albumId,
|
||||
albumIndex: index,
|
||||
albumCount: items.length,
|
||||
...(width ? { width } : {}),
|
||||
...(height ? { height } : {}),
|
||||
...(item.sendAsFile ? { sendAsFile: true } : {})
|
||||
};
|
||||
|
||||
let uploadFile = file;
|
||||
let uploadContentType = contentType;
|
||||
let messageContent: string | undefined =
|
||||
index === 0 && caption?.trim()
|
||||
? caption.trim()
|
||||
: messageType === 'FILE'
|
||||
? item.file.name
|
||||
: undefined;
|
||||
let isEncrypted = false;
|
||||
|
||||
if (isE2E) {
|
||||
if (!userId || !peerE2eKey || !encryptOutgoing) {
|
||||
throw new Error('Секретный чат недоступен без ключей E2E у обоих участников');
|
||||
}
|
||||
const encryptedBuffer = await encryptE2EBlob(userId, peerE2eKey, await file.arrayBuffer());
|
||||
uploadFile = new File([encryptedBuffer], `${item.file.name}.lendryenc`, { type: 'application/octet-stream' });
|
||||
uploadContentType = 'application/octet-stream';
|
||||
messageContent = await encryptOutgoing({
|
||||
kind: e2eKindFromMessageType(messageType),
|
||||
fileName: item.file.name,
|
||||
mimeType: contentType,
|
||||
fileSize: file.size,
|
||||
text: index === 0 ? caption?.trim() : undefined
|
||||
});
|
||||
isEncrypted = true;
|
||||
}
|
||||
|
||||
const presigned = await apiFetch<PresignedUploadResponse>(
|
||||
`/media/chat/${roomId}/media/upload-url`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name })
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
await uploadMediaObject(presigned, uploadFile, uploadContentType);
|
||||
const message = await sendChatMessage(
|
||||
roomId,
|
||||
{
|
||||
type: messageType,
|
||||
storageKey: presigned.storageKey,
|
||||
mimeType: uploadContentType,
|
||||
content: messageContent,
|
||||
isEncrypted,
|
||||
metadataJson: JSON.stringify(isE2E ? { e2e: true, ...metadata } : metadata)
|
||||
},
|
||||
token
|
||||
);
|
||||
created.push(message);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
Reference in New Issue
Block a user