Fixing the video-to-file conversion error and audio appearance changes.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user