Convert video to file and change the appearance of mobile chat

This commit is contained in:
2026-09-29 20:18:17 +03:30
parent b0f05b6a27
commit 70bcd15847
3 changed files with 358 additions and 88 deletions
+74 -10
View File
@@ -89,11 +89,40 @@ function normalizeMediaType(raw: string): ChatMediaType {
return 'text';
}
const VIDEO_EXT_RE = /\.(mp4|webm|mov|m4v|mkv|avi|3gp|ogv)(?:\?|$)/i;
const AUDIO_EXT_RE = /\.(webm|ogg|mp3|m4a|wav|aac|opus|oga)(?:\?|$)/i;
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|bmp|svg|heic|heif)(?:\?|$)/i;
function looksLikeVideo(pathOrName: string): boolean {
return VIDEO_EXT_RE.test(pathOrName);
}
function inferMediaType(
declared: ChatMediaType,
content: string,
fileName?: string,
): ChatMediaType {
const probe = `${fileName || ''} ${content}`;
if (declared === 'video' || looksLikeVideo(probe)) return 'video';
if (declared === 'image') return 'image';
if (declared === 'voice') return 'voice';
if (declared === 'file' || declared === 'text') {
if (looksLikeVideo(probe)) return 'video';
if (IMAGE_EXT_RE.test(probe)) return 'image';
if (AUDIO_EXT_RE.test(probe)) return 'voice';
if (declared === 'file') return 'file';
}
return declared;
}
export function previewLabelForMessage(msg: Pick<ChatMessage, 'type' | 'content' | 'fileName'>) {
if (msg.type === 'image') return 'تصویر';
if (msg.type === 'voice') return 'وویس';
if (msg.type === 'voice') return 'پیام صوتی';
if (msg.type === 'video') return 'ویدیو';
if (msg.type === 'file') return msg.fileName?.trim() || 'فایل';
if (msg.type === 'file') {
if (looksLikeVideo(`${msg.fileName || ''} ${msg.content}`)) return 'ویدیو';
return msg.fileName?.trim() || 'فایل';
}
return msg.content || 'پیام';
}
@@ -155,12 +184,12 @@ function classifyFile(file: File): Exclude<ChatMediaType, 'text'> {
return 'file';
}
/** WS only officially documents image|voice; map video/file to closest supported + file. */
function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'video' | 'file' {
/** Chat backend (Hamkar-compatible) accepts text|image|voice|file — not video. */
function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'file' | 'text' {
if (type === 'image') return 'image';
if (type === 'voice') return 'voice';
if (type === 'video') return 'video';
return 'file';
if (type === 'video' || type === 'file') return 'file';
return 'text';
}
async function compressImageIfNeeded(file: File): Promise<File> {
@@ -211,16 +240,20 @@ function normalizeIncoming(
raw.message && typeof raw.message === 'object'
? (raw.message as Record<string, unknown>)
: raw;
const type = normalizeMediaType(String(base.type_content ?? base.type ?? 'text'));
const declared = normalizeMediaType(String(base.type_content ?? base.type ?? 'text'));
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
if (!content && !base.attachments) return null;
const id = String(base.id ?? base.message_id ?? '').trim();
const readAt = base.read_at ?? base.readAt ?? null;
const downloadUrl = sessionCache?.downloadUrl ?? '';
const fileName =
String(base.file_name ?? base.fileName ?? base.name ?? '').trim() ||
content.split('/').pop() ||
undefined;
const type = inferMediaType(declared, content, fileName);
const isMedia = type !== 'text';
const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined;
const fileName = String(base.file_name ?? base.fileName ?? base.name ?? '').trim() || undefined;
return {
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -250,8 +283,13 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
...prev,
...incoming,
optimistic: false,
type:
prev.type === 'video' && (incoming.type === 'file' || incoming.type === 'text')
? 'video'
: inferMediaType(incoming.type, incoming.content, incoming.fileName || prev.fileName),
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
fileName: incoming.fileName || prev.fileName,
delivery:
incoming.delivery === 'read' || prev.delivery === 'read'
? 'read'
@@ -278,9 +316,14 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
...prev,
...incoming,
optimistic: false,
localPreviewUrl: undefined,
type:
prev.type === 'video' && (incoming.type === 'file' || incoming.type === 'text')
? 'video'
: inferMediaType(incoming.type, incoming.content, incoming.fileName || prev.fileName),
localPreviewUrl: prev.localPreviewUrl,
uploadProgress: undefined,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
fileName: incoming.fileName || prev.fileName,
delivery: incoming.readAt ? 'read' : 'sent',
};
if (prev.localPreviewUrl?.startsWith('blob:')) {
@@ -477,6 +520,26 @@ function sendRead(roomId: string) {
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
}
function formatUploadFailure(status: number, bodyText: string): string {
const raw = bodyText.trim();
if (raw) {
try {
const json = JSON.parse(raw) as Record<string, unknown>;
const err = String(json.error ?? json.message ?? '').trim();
const details = String(json.details ?? '').trim();
if (err && details) return `آپلود ناموفق بود: ${err} (${details})`;
if (err) return `آپلود ناموفق بود: ${err}`;
} catch {
const snippet = raw.slice(0, 160);
if (snippet) return `آپلود ناموفق بود (${status}): ${snippet}`;
}
}
if (status === 500) {
return 'آپلود ناموفق بود (۵۰۰). سرویس ذخیره‌سازی فایل (S3) روی آدرس آپلود در دسترس نیست — تنظیمات upload/download در سوپرادمین را بررسی کنید.';
}
return `آپلود ناموفق بود (${status})`;
}
function uploadChatFile(
uploadUrl: string,
file: File,
@@ -497,7 +560,7 @@ function uploadChatFile(
};
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(`آپلود ناموفق بود (${xhr.status})`));
reject(new Error(formatUploadFailure(xhr.status, xhr.responseText || '')));
return;
}
try {
@@ -649,6 +712,7 @@ export function useOrgChat() {
content: filePath,
};
if (reply) payload.reply = reply;
if (opts?.fileName) payload.file_name = opts.fileName;
const existingIdx = messages.value.findIndex((m) => m.id === id);
if (existingIdx >= 0) {