233 lines
7.5 KiB
TypeScript
233 lines
7.5 KiB
TypeScript
export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'VIDEO' | 'VIDEO_NOTE' | 'FILE';
|
|
|
|
const EXTENSION_TO_MIME: Record<string, string> = {
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
png: 'image/png',
|
|
webp: 'image/webp',
|
|
gif: 'image/gif',
|
|
heic: 'image/heic',
|
|
heif: 'image/heif',
|
|
mp3: 'audio/mpeg',
|
|
m4a: 'audio/mp4',
|
|
ogg: 'audio/ogg',
|
|
opus: 'audio/ogg',
|
|
webm: 'audio/webm',
|
|
wav: 'audio/wav',
|
|
aac: 'audio/aac',
|
|
flac: 'audio/flac',
|
|
mp4: 'video/mp4',
|
|
mov: 'video/quicktime',
|
|
mkv: 'video/x-matroska',
|
|
avi: 'video/x-msvideo',
|
|
'3gp': 'video/3gpp',
|
|
pdf: 'application/pdf',
|
|
doc: 'application/msword',
|
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
xls: 'application/vnd.ms-excel',
|
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
ppt: 'application/vnd.ms-powerpoint',
|
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
txt: 'text/plain',
|
|
csv: 'text/csv',
|
|
zip: 'application/zip',
|
|
rar: 'application/vnd.rar',
|
|
'7z': 'application/x-7z-compressed',
|
|
json: 'application/json',
|
|
apk: 'application/vnd.android.package-archive'
|
|
};
|
|
|
|
const MIME_ALIASES: Record<string, string> = {
|
|
'audio/x-m4a': 'audio/mp4',
|
|
'audio/x-aac': 'audio/aac',
|
|
'audio/x-wav': 'audio/wav',
|
|
'audio/x-flac': 'audio/flac',
|
|
'audio/x-mpeg': 'audio/mpeg',
|
|
'audio/mp3': 'audio/mpeg',
|
|
'image/jpg': 'image/jpeg',
|
|
'image/pjpeg': 'image/jpeg',
|
|
'application/x-pdf': 'application/pdf',
|
|
'application/x-zip-compressed': 'application/zip',
|
|
'video/x-matroska': 'video/x-matroska',
|
|
'video/3gpp': 'video/3gpp'
|
|
};
|
|
|
|
function extractFileExtension(value?: string | null) {
|
|
if (!value) return '';
|
|
const clean = value.split('?')[0]?.split('#')[0] ?? value;
|
|
const parts = clean.split('.');
|
|
if (parts.length < 2) return '';
|
|
return parts.pop()?.trim().toLowerCase() ?? '';
|
|
}
|
|
|
|
export function resolveChatContentType(file: Pick<File, 'type' | 'name'>) {
|
|
const raw = (file.type ?? '').trim().toLowerCase();
|
|
const base = raw.split(';')[0]?.trim() ?? '';
|
|
const aliased = MIME_ALIASES[base] ?? base;
|
|
|
|
if (aliased && aliased !== 'application/octet-stream') {
|
|
return aliased;
|
|
}
|
|
|
|
const extension = extractFileExtension(file.name);
|
|
if (extension && EXTENSION_TO_MIME[extension]) {
|
|
return EXTENSION_TO_MIME[extension];
|
|
}
|
|
|
|
return aliased || 'application/octet-stream';
|
|
}
|
|
|
|
const VIDEO_NOTE_FILE_NAME_PATTERNS = [
|
|
/video[_-]?circle/i,
|
|
/video[_-]?note/i,
|
|
/videonote/i,
|
|
/circle[_-]?video/i,
|
|
/round[_-]?video/i,
|
|
/tg[_-]?round/i,
|
|
/кружок/i
|
|
];
|
|
|
|
export function isVideoNoteFileName(fileName?: string | null) {
|
|
if (!fileName?.trim()) return false;
|
|
return VIDEO_NOTE_FILE_NAME_PATTERNS.some((pattern) => pattern.test(fileName.trim()));
|
|
}
|
|
|
|
export function isVideoNoteMessage(
|
|
message: {
|
|
type: string;
|
|
metadataJson?: string;
|
|
mimeType?: string | null;
|
|
content?: string | null;
|
|
},
|
|
e2ePayload?: { kind?: string; fileName?: string } | null
|
|
) {
|
|
if (message.type === 'VIDEO_NOTE') return true;
|
|
if (e2ePayload?.kind === 'video_note') return true;
|
|
|
|
const meta = parseMessageMetadata(message.metadataJson);
|
|
if (meta.videoNote) return true;
|
|
|
|
const fileName = e2ePayload?.fileName || meta.fileName || message.content;
|
|
if (!isVideoNoteFileName(fileName)) return false;
|
|
|
|
return message.type === 'VIDEO' || message.type === 'FILE' || message.mimeType?.startsWith('video/') === true;
|
|
}
|
|
|
|
export function inferChatMessageType(contentType: string, options?: { voice?: boolean; videoNote?: boolean }): ChatAttachmentKind {
|
|
const normalized = resolveChatContentType({ type: contentType, name: '' });
|
|
if (options?.videoNote && normalized.startsWith('video/')) return 'VIDEO_NOTE';
|
|
if (normalized.startsWith('image/')) return 'IMAGE';
|
|
if (normalized.startsWith('video/')) return 'VIDEO';
|
|
if (normalized.startsWith('audio/')) {
|
|
return options?.voice ? 'VOICE' : 'AUDIO';
|
|
}
|
|
return 'FILE';
|
|
}
|
|
|
|
export function detectChatMessageType(file: File, options?: { voice?: boolean; videoNote?: boolean }) {
|
|
const contentType = resolveChatContentType(file);
|
|
const videoNote = Boolean(options?.videoNote || isVideoNoteFileName(file.name));
|
|
return inferChatMessageType(contentType, { ...options, videoNote });
|
|
}
|
|
|
|
export function isVideoFile(file: Pick<File, 'type' | 'name'>) {
|
|
return resolveChatContentType(file).startsWith('video/');
|
|
}
|
|
|
|
export type ComposeMediaKind = 'image' | 'video' | 'audio' | 'file';
|
|
|
|
export function getComposeMediaKind(file: Pick<File, 'type' | 'name'>): ComposeMediaKind {
|
|
const contentType = resolveChatContentType(file);
|
|
if (contentType.startsWith('image/')) return 'image';
|
|
if (contentType.startsWith('video/')) return 'video';
|
|
if (contentType.startsWith('audio/')) return 'audio';
|
|
return 'file';
|
|
}
|
|
|
|
export function getComposeDialogTitle(files: Array<Pick<File, 'type' | 'name'>>) {
|
|
if (!files.length) return 'Отправить файл';
|
|
if (files.length > 1) return `Отправить ${files.length} файла`;
|
|
|
|
switch (getComposeMediaKind(files[0]!)) {
|
|
case 'video':
|
|
return 'Отправить видео';
|
|
case 'audio':
|
|
return 'Отправить аудио';
|
|
case 'image':
|
|
return 'Отправить изображение';
|
|
default:
|
|
return 'Отправить файл';
|
|
}
|
|
}
|
|
|
|
export function formatFileSize(bytes?: number) {
|
|
if (!bytes || bytes <= 0) return '';
|
|
if (bytes < 1024) return `${bytes} Б`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
|
}
|
|
|
|
export interface ChatLocationMetadata {
|
|
latitude?: number;
|
|
longitude?: number;
|
|
address?: string;
|
|
label?: string;
|
|
}
|
|
|
|
export function parseMessageMetadata(metadataJson?: string) {
|
|
if (!metadataJson) {
|
|
return {} as { fileName?: string; fileSize?: number; durationMs?: number; videoNote?: boolean } & ChatLocationMetadata;
|
|
}
|
|
try {
|
|
return JSON.parse(metadataJson) as {
|
|
fileName?: string;
|
|
fileSize?: number;
|
|
durationMs?: number;
|
|
videoNote?: boolean;
|
|
} & ChatLocationMetadata;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function parseLocationMetadata(
|
|
metadataJson?: string,
|
|
e2ePayload?: { kind?: string; latitude?: number; longitude?: number; address?: string; label?: string; text?: string } | null
|
|
): ChatLocationMetadata | null {
|
|
if (e2ePayload?.kind === 'location') {
|
|
const { latitude, longitude } = e2ePayload;
|
|
if (typeof latitude === 'number' && typeof longitude === 'number') {
|
|
return {
|
|
latitude,
|
|
longitude,
|
|
address: e2ePayload.address,
|
|
label: e2ePayload.label ?? e2ePayload.text
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
const meta = parseMessageMetadata(metadataJson);
|
|
if (typeof meta.latitude === 'number' && typeof meta.longitude === 'number') {
|
|
return {
|
|
latitude: meta.latitude,
|
|
longitude: meta.longitude,
|
|
address: meta.address,
|
|
label: meta.label
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function fileIconLabel(fileName?: string, mimeType?: string) {
|
|
const extension = extractFileExtension(fileName);
|
|
if (extension === 'pdf' || mimeType === 'application/pdf') return 'PDF';
|
|
if (['doc', 'docx'].includes(extension)) return 'DOC';
|
|
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'XLS';
|
|
if (['ppt', 'pptx'].includes(extension)) return 'PPT';
|
|
if (['zip', 'rar', '7z'].includes(extension)) return 'ZIP';
|
|
if (extension === 'apk') return 'APK';
|
|
if (['mp4', 'mov', 'webm', 'mkv'].includes(extension) || mimeType?.startsWith('video/')) return 'VID';
|
|
if (extension) return extension.toUpperCase().slice(0, 4);
|
|
return 'FILE';
|
|
}
|