Fixing the video-to-file conversion error and audio appearance changes.

This commit is contained in:
2026-09-29 20:36:15 +03:30
parent 70bcd15847
commit 7996fc7f73
3 changed files with 109 additions and 38 deletions
@@ -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;
+81 -26
View File
@@ -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<ChatMessage, 'type' | 'content'
if (msg.type === 'video') return 'ویدیو';
if (msg.type === 'file') {
if (looksLikeVideo(`${msg.fileName || ''} ${msg.content}`)) return 'ویدیو';
if (looksLikeVoiceAsset(`${msg.fileName || ''} ${msg.content}`)) return 'پیام صوتی';
return msg.fileName?.trim() || 'فایل';
}
return msg.content || 'پیام';
@@ -176,19 +184,38 @@ function extractUploadedPath(result: Record<string, unknown>): string {
return String(value || '').trim();
}
function classifyFile(file: File): Exclude<ChatMediaType, 'text'> {
/** Classify upload/attach files for chat UI + WS. Exported for composer pending previews. */
export function classifyChatFile(file: File): Exclude<ChatMediaType, 'text'> {
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<ChatMediaType, 'text'> {
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<string, string> = {
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,
});
+18 -9
View File
@@ -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;
}