From 7996fc7f739cdbeaad5f93a41feaccae3c1121c9 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 29 Sep 2026 20:36:15 +0330 Subject: [PATCH] Fixing the video-to-file conversion error and audio appearance changes. --- .../conversations/conversations.service.ts | 13 ++- apps/web/src/lib/useOrgChat.ts | 107 +++++++++++++----- apps/web/src/views/ConversationsView.vue | 27 +++-- 3 files changed, 109 insertions(+), 38 deletions(-) diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index d1041cb..0236c5f 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -450,9 +450,16 @@ export class ConversationsService { } else if (lastType === 'video') { preview = 'ویدیو'; } else if (lastType === 'file' || lastType === 'document' || lastType === 'attachment') { - preview = /\.(mp4|webm|mov|m4v|mkv|avi|3gp)(?:\?|$)/i.test(preview) - ? 'ویدیو' - : 'فایل'; + if (/\.(mp4|mov|m4v|mkv|avi|3gp)(?:\?|$)/i.test(preview)) { + preview = 'ویدیو'; + } else if ( + /(?:^|[\\/._-])voice(?:[\\/._-]|$)/i.test(preview) || + /\.(webm|ogg|mp3|m4a|wav|aac|opus)(?:\?|$)/i.test(preview) + ) { + preview = 'پیام صوتی'; + } else { + preview = 'فایل'; + } } const lastAtRaw = lastMsg?.created_at ?? lastMsg?.createdAt ?? row.updated_at ?? row.updatedAt ?? null; diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index 4ce17ac..5452737 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -89,27 +89,34 @@ function normalizeMediaType(raw: string): ChatMediaType { return 'text'; } -const VIDEO_EXT_RE = /\.(mp4|webm|mov|m4v|mkv|avi|3gp|ogv)(?:\?|$)/i; +const VIDEO_EXT_RE = /\.(mp4|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; +const VOICE_NAME_RE = /(?:^|[\\/._-])voice(?:[\\/._-]|$)/i; function looksLikeVideo(pathOrName: string): boolean { return VIDEO_EXT_RE.test(pathOrName); } +function looksLikeVoiceAsset(pathOrName: string): boolean { + return VOICE_NAME_RE.test(pathOrName) || AUDIO_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'; + // Declared voice always wins — recordings are often .webm and must stay as audio UI if (declared === 'voice') return 'voice'; + // Path/name cues beat wrong declared types (MediaRecorder video/webm, backend "video" on voice) + if (looksLikeVoiceAsset(probe) && !looksLikeVideo(probe)) return 'voice'; + if (looksLikeVideo(probe)) return 'video'; + if (declared === 'video') return 'video'; + if (declared === 'image') return 'image'; 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; @@ -121,6 +128,7 @@ export function previewLabelForMessage(msg: Pick): string { return String(value || '').trim(); } -function classifyFile(file: File): Exclude { +/** Classify upload/attach files for chat UI + WS. Exported for composer pending previews. */ +export function classifyChatFile(file: File): Exclude { const mime = (file.type || '').toLowerCase(); - if (mime.startsWith('image/')) return 'image'; - if (mime.startsWith('audio/')) return 'voice'; - if (mime.startsWith('video/')) return 'video'; + const name = file.name || ''; + if (looksLikeVoiceAsset(name) || mime.startsWith('audio/')) return 'voice'; + if (mime.startsWith('image/') || IMAGE_EXT_RE.test(name)) return 'image'; + // Audio-only MediaRecorder often reports video/webm / video/ogg + if ( + mime.startsWith('video/webm') || + mime.startsWith('video/ogg') || + (mime.startsWith('video/') && AUDIO_EXT_RE.test(name) && !looksLikeVideo(name)) + ) { + return 'voice'; + } + if (mime.startsWith('video/') || looksLikeVideo(name)) return 'video'; + if (AUDIO_EXT_RE.test(name)) return 'voice'; return 'file'; } -/** Chat backend (Hamkar-compatible) accepts text|image|voice|file — not video. */ +function classifyFile(file: File): Exclude { + return classifyChatFile(file); +} + +/** + * Chat backend accepts text|image|voice reliably. + * Real video is sent as `file` (path + .mp4 name) — `video` is dropped; `image` can also fail for large binaries. + */ function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'file' | 'text' { if (type === 'image') return 'image'; + if (type === 'video') return 'file'; if (type === 'voice') return 'voice'; - if (type === 'video' || type === 'file') return 'file'; + if (type === 'file') return 'file'; return 'text'; } @@ -283,10 +310,15 @@ 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), + type: inferMediaType( + prev.type === 'voice' || incoming.type === 'voice' + ? 'voice' + : prev.type === 'video' || incoming.type === 'video' + ? 'video' + : incoming.type, + incoming.content || prev.content, + incoming.fileName || prev.fileName, + ), localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl, mediaUrl: incoming.mediaUrl || prev.mediaUrl, fileName: incoming.fileName || prev.fileName, @@ -316,10 +348,15 @@ 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), + type: inferMediaType( + prev.type === 'voice' || incoming.type === 'voice' + ? 'voice' + : prev.type === 'video' || incoming.type === 'video' + ? 'video' + : incoming.type, + incoming.content || prev.content, + incoming.fileName || prev.fileName, + ), localPreviewUrl: prev.localPreviewUrl, uploadProgress: undefined, mediaUrl: incoming.mediaUrl || prev.mediaUrl, @@ -703,13 +740,21 @@ export function useOrgChat() { const id = opts?.tempId || crypto.randomUUID(); const reply = opts?.reply ?? null; const wsType = wsMediaType(type); - const mediaUrl = buildChatFileUrl(filePath, session.downloadUrl); + let filePathHint = filePath; + if ( + (type === 'video' || type === 'voice') && + !/\.[a-z0-9]{2,5}(?:\?|$)/i.test(filePathHint) + ) { + const ext = opts?.fileName?.match(/(\.[a-z0-9]{2,5})$/i)?.[1]; + if (ext) filePathHint = `${filePathHint}${ext}`; + } + const mediaUrl = buildChatFileUrl(filePathHint, session.downloadUrl); const payload: Record = { id, type: wsType, room_id: roomId, - content: filePath, + content: filePathHint, }; if (reply) payload.reply = reply; if (opts?.fileName) payload.file_name = opts.fileName; @@ -719,7 +764,7 @@ export function useOrgChat() { const next = [...messages.value]; next[existingIdx] = { ...next[existingIdx], - content: filePath, + content: filePathHint, type, mediaUrl, fileName: opts?.fileName, @@ -733,7 +778,7 @@ export function useOrgChat() { id, roomId, senderId: session.chatUserId, - content: filePath, + content: filePathHint, type, createdAt: new Date().toISOString(), reply, @@ -748,7 +793,11 @@ export function useOrgChat() { emitRoomLive({ roomId, - preview: previewLabelForMessage({ type, content: filePath, fileName: opts?.fileName }), + preview: previewLabelForMessage({ + type, + content: filePathHint, + fileName: opts?.fileName, + }), at: new Date().toISOString(), fromSelf: true, }); @@ -807,7 +856,13 @@ export function useOrgChat() { try { setProgress(5); - const fileForUpload = type === 'image' ? await compressImageIfNeeded(file) : file; + let fileForUpload = type === 'image' ? await compressImageIfNeeded(file) : file; + if (type === 'video' && !looksLikeVideo(fileForUpload.name)) { + const base = fileForUpload.name.replace(/\.[^.]+$/, '') || 'video'; + fileForUpload = new File([fileForUpload], `${base}.mp4`, { + type: fileForUpload.type || 'video/mp4', + }); + } setProgress(12); const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => { setProgress(12 + Math.round(p * 0.86)); @@ -815,7 +870,7 @@ export function useOrgChat() { setProgress(99); await sendMediaPath(roomId, uploadedPath, type, { reply, - fileName: file.name, + fileName: fileForUpload.name || file.name, localPreviewUrl, tempId: id, }); diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index d0de234..5294709 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -9,6 +9,7 @@ import type { } from '@mento/shared'; import { formatClockFa, toFaDigits } from '@/lib/jalali'; import { + classifyChatFile, onRoomLiveEvent, useOrgChat, type ChatDelivery, @@ -121,11 +122,7 @@ function closeMobileChat() { const mobileChatOpen = computed(() => Boolean(activeRoom.value)); function classifyPending(file: File): PendingAttach['kind'] { - const mime = (file.type || '').toLowerCase(); - if (mime.startsWith('image/')) return 'image'; - if (mime.startsWith('audio/')) return 'voice'; - if (mime.startsWith('video/')) return 'video'; - return 'file'; + return classifyChatFile(file); } function clearPendingAttaches() { @@ -220,7 +217,15 @@ async function toggleVoiceRecord() { const blob = new Blob(recordChunks, { type }); recordChunks = []; const ext = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : 'webm'; - const file = new File([blob], `voice-${Date.now()}.${ext}`, { type }); + // Force audio/* so classify never treats MediaRecorder output as video + const audioMime = type.startsWith('audio/') + ? type.split(';')[0] + : ext === 'ogg' + ? 'audio/ogg' + : ext === 'm4a' + ? 'audio/mp4' + : 'audio/webm'; + const file = new File([blob], `voice-${Date.now()}.${ext}`, { type: audioMime }); addFiles([file]); recordSecs.value = 0; }; @@ -1045,13 +1050,17 @@ onUnmounted(() => { font-size: 0.9rem; font-weight: 700; } -.media-audio, +.media-audio { + display: block; + width: min(280px, 72vw); + max-width: 100%; + height: 40px; + border-radius: 20px; +} .media-video { display: block; width: min(280px, 72vw); max-width: 100%; -} -.media-video { border-radius: 12px; background: #0f172a; }