diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 6befa00..d1041cb 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -446,11 +446,13 @@ export class ConversationsService { if (lastType === 'image' || lastType === 'photo' || lastType === 'img') { preview = 'تصویر'; } else if (lastType === 'voice' || lastType === 'audio') { - preview = 'وویس'; + preview = 'پیام صوتی'; } else if (lastType === 'video') { preview = 'ویدیو'; } else if (lastType === 'file' || lastType === 'document' || lastType === 'attachment') { - preview = 'فایل'; + preview = /\.(mp4|webm|mov|m4v|mkv|avi|3gp)(?:\?|$)/i.test(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 be23f6d..4ce17ac 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -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) { 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 { 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 { @@ -211,16 +240,20 @@ function normalizeIncoming( raw.message && typeof raw.message === 'object' ? (raw.message as Record) : 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; + 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) { diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index 1b95d4a..d0de234 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -104,6 +104,22 @@ function deliveryLabel(delivery?: ChatDelivery) { return '…'; } +function roomPreviewText(room: ConversationRoomDto) { + const raw = (room.lastMessagePreview || '').trim(); + if (!raw) return 'بدون پیام'; + if (raw === 'وویس') return 'پیام صوتی'; + return raw; +} + +function closeMobileChat() { + clearActiveRoom(); + activeRoom.value = null; + clearPendingAttaches(); + if (recording.value) mediaRecorder?.stop(); +} + +const mobileChatOpen = computed(() => Boolean(activeRoom.value)); + function classifyPending(file: File): PendingAttach['kind'] { const mime = (file.type || '').toLowerCase(); if (mime.startsWith('image/')) return 'image'; @@ -446,25 +462,30 @@ onUnmounted(() => {