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
@@ -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;
+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) {
+280 -76
View File
@@ -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(() => {
</script>
<template>
<div class="conv-page">
<div class="conv-page" :class="{ 'chat-open': mobileChatOpen }">
<div class="conv-aurora" aria-hidden="true" />
<header class="conv-head">
<header class="conv-head desktop-head">
<div>
<p class="eyebrow">درون سازمان</p>
<h1>گفتگو</h1>
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیام‌رسان بیرونی.</p>
</div>
<div class="head-actions">
<div class="head-actions">
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
</div>
</header>
<header class="mobile-list-head" :hidden="mobileChatOpen">
<h1>گفتگو</h1>
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
</header>
<p v-if="error" class="err-banner">{{ error }}</p>
<p v-else-if="loading" class="muted center">در حال آماده‌سازی…</p>
<div v-else class="conv-shell">
<aside class="conv-rail">
<aside class="conv-rail" :hidden="mobileChatOpen">
<div class="rail-actions">
<button type="button" class="fab" @click="showNewDm = true">+ گفتگو</button>
<button
@@ -489,7 +510,7 @@ onUnmounted(() => {
</span>
<span class="room-meta">
<strong>{{ roomTitle(room) }}</strong>
<small>{{ room.lastMessagePreview || 'بدون پیام' }}</small>
<small>{{ roomPreviewText(room) }}</small>
</span>
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
<time>{{ formatListTime(room.lastMessageAt) }}</time>
@@ -498,8 +519,11 @@ onUnmounted(() => {
</ul>
</aside>
<section class="conv-stage">
<div v-if="!activeRoom" class="empty-stage">
<section
class="conv-stage"
:class="{ empty: !activeRoom, open: mobileChatOpen }"
>
<div v-if="!activeRoom" class="empty-stage desktop-empty">
<div class="empty-glow" />
<UiIcon name="chat" :size="48" />
<h2>یک مکالمه را انتخاب کنید</h2>
@@ -508,8 +532,16 @@ onUnmounted(() => {
<template v-else>
<div class="stage-top">
<h2>{{ roomTitle(activeRoom) }}</h2>
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
<UiIcon name="arrow-right" :size="20" />
</button>
<span class="stage-avatar">
<UserAvatar :name="roomTitle(activeRoom)" :size="36" />
</span>
<div class="stage-titles">
<h2>{{ roomTitle(activeRoom) }}</h2>
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
</div>
</div>
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیام‌ها…</div>
@@ -541,7 +573,24 @@ onUnmounted(() => {
</span>
</template>
<template v-else-if="msg.type === 'video'">
<video class="media-video" :src="mediaSrc(msg)" controls preload="metadata" />
<video
v-if="mediaSrc(msg)"
class="media-video"
:src="mediaSrc(msg)"
controls
playsinline
preload="metadata"
/>
<a
v-else
class="media-file"
:href="msg.content"
target="_blank"
rel="noopener"
>
<UiIcon name="document" :size="18" />
<span>{{ msg.fileName || 'ویدیو' }}</span>
</a>
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
</span>
@@ -622,7 +671,7 @@ onUnmounted(() => {
type="button"
class="tool-btn"
:class="{ on: recording }"
title="ضبط وویس"
title="ضبط پیام صوتی"
:disabled="sending"
@click="toggleVoiceRecord"
>
@@ -631,12 +680,14 @@ onUnmounted(() => {
<input
v-model="draft"
type="text"
placeholder="پیام خود را بنویسید…"
class="composer-input"
placeholder="پیام…"
autocomplete="off"
:disabled="sending || recording"
/>
<button
type="submit"
class="send-btn"
:disabled="sending || recording || (!draft.trim() && !pendingAttaches.length)"
>
<UiIcon name="send" :size="18" />
@@ -718,6 +769,9 @@ onUnmounted(() => {
gap: 1rem;
margin-bottom: 1rem;
}
.mobile-list-head {
display: none;
}
.eyebrow {
font-size: 0.75rem;
letter-spacing: 0.08em;
@@ -754,16 +808,6 @@ onUnmounted(() => {
.ws-pill[data-state='open'] {
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35);
}
.gear {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--line);
color: var(--text);
}
.err-banner {
background: #fef2f2;
color: #b91c1c;
@@ -778,11 +822,7 @@ onUnmounted(() => {
grid-template-columns: minmax(260px, 320px) 1fr;
gap: 0.75rem;
min-height: 520px;
}
@media (max-width: 900px) {
.conv-shell {
grid-template-columns: 1fr;
}
height: min(72vh, 720px);
}
.conv-rail {
background: rgba(255, 255, 255, 0.72);
@@ -793,6 +833,7 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
}
.rail-actions {
display: flex;
@@ -818,12 +859,13 @@ onUnmounted(() => {
margin: 0;
padding: 0;
overflow: auto;
max-height: calc(100vh - 14rem);
flex: 1;
min-height: 0;
}
.room-chip {
width: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: auto auto;
gap: 0.15rem 0.65rem;
align-items: center;
@@ -845,6 +887,9 @@ onUnmounted(() => {
padding: 2px;
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
}
.room-meta {
min-width: 0;
}
.room-meta strong {
display: block;
font-size: 0.92rem;
@@ -855,7 +900,7 @@ onUnmounted(() => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
max-width: 100%;
display: block;
}
.unread {
@@ -883,26 +928,55 @@ onUnmounted(() => {
border: 1px solid rgba(148, 163, 184, 0.22);
display: flex;
flex-direction: column;
min-height: 480px;
min-height: 0;
overflow: hidden;
}
.stage-top {
padding: 1rem 1.15rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.55rem;
flex-shrink: 0;
}
.back-btn {
display: none;
border: none;
background: transparent;
color: var(--text);
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
place-items: center;
cursor: pointer;
flex-shrink: 0;
}
.stage-avatar {
flex-shrink: 0;
border-radius: 999px;
padding: 2px;
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
}
.stage-titles {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.stage-top h2 {
margin: 0;
font-size: 1.1rem;
font-size: 1.05rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kind-tag {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: rgba(124, 58, 237, 0.1);
color: #6d28d9;
width: fit-content;
}
.msg-stream {
flex: 1;
@@ -913,6 +987,10 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 0.65rem;
min-height: 0;
background:
radial-gradient(circle at 12% 8%, rgba(45, 212, 191, 0.07), transparent 40%),
radial-gradient(circle at 88% 92%, rgba(167, 139, 250, 0.08), transparent 42%);
}
.msg-row {
display: flex;
@@ -1031,57 +1109,58 @@ onUnmounted(() => {
.composer {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem;
gap: 0.45rem;
padding: 0.65rem 0.75rem calc(0.65rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--line);
background: rgba(255, 255, 255, 0.92);
flex-shrink: 0;
}
.composer-row {
display: flex;
gap: 0.4rem;
align-items: center;
min-width: 0;
}
.composer-row input[type='text'] {
flex: 1;
.composer-input {
flex: 1 1 auto;
min-width: 0;
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.65rem 0.85rem;
font: inherit;
background: #fff;
}
.composer-row > button[type='submit'] {
border: none;
border-radius: 14px;
width: 2.75rem;
height: 2.75rem;
background: #0d9488;
color: #fff;
cursor: pointer;
.tool-btn,
.send-btn {
flex: 0 0 auto;
border-radius: 12px;
width: 2.6rem;
height: 2.6rem;
min-width: 2.6rem;
display: grid;
place-items: center;
}
.composer-row > button[type='submit']:disabled {
opacity: 0.45;
cursor: not-allowed;
cursor: pointer;
}
.tool-btn {
border: 1px solid var(--line);
background: var(--surface);
background: var(--surface, #fff);
color: var(--text);
border-radius: 12px;
width: 2.5rem;
height: 2.5rem;
display: grid;
place-items: center;
cursor: pointer;
}
.tool-btn.on {
border-color: #ef4444;
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.tool-btn:disabled {
.tool-btn:disabled,
.send-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.send-btn {
border: none;
background: #0d9488;
color: #fff;
}
.attach-preview {
display: flex;
gap: 0.45rem;
@@ -1171,6 +1250,23 @@ onUnmounted(() => {
clip: rect(0, 0, 0, 0);
border: 0;
}
.empty-stage {
flex: 1;
display: grid;
place-content: center;
text-align: center;
gap: 0.35rem;
color: var(--muted);
position: relative;
}
.empty-glow {
position: absolute;
width: 200px;
height: 200px;
border-radius: 50%;
background: radial-gradient(circle, rgba(167, 139, 250, 0.25), transparent 70%);
filter: blur(8px);
}
.lightbox {
position: fixed;
inset: 0;
@@ -1201,23 +1297,6 @@ onUnmounted(() => {
place-items: center;
cursor: pointer;
}
.empty-stage {
flex: 1;
display: grid;
place-content: center;
text-align: center;
gap: 0.35rem;
color: var(--muted);
position: relative;
}
.empty-glow {
position: absolute;
width: 200px;
height: 200px;
border-radius: 50%;
background: radial-gradient(circle, rgba(167, 139, 250, 0.25), transparent 70%);
filter: blur(8px);
}
.modal {
position: fixed;
inset: 0;
@@ -1286,4 +1365,129 @@ onUnmounted(() => {
font-size: 0.85rem;
color: var(--muted);
}
@media (max-width: 900px) {
.conv-page {
margin: -1rem -0.85rem calc(-1.25rem - var(--mobile-tabbar-h, 3.65rem));
padding: 0;
min-height: calc(100dvh - 3.5rem - var(--mobile-tabbar-h, 3.65rem));
height: calc(100dvh - 3.5rem - var(--mobile-tabbar-h, 3.65rem));
display: flex;
flex-direction: column;
overflow: hidden;
background: #f8fafc;
}
.conv-page.chat-open {
background: #fff;
}
.conv-aurora,
.desktop-head {
display: none;
}
.mobile-list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--line);
background: #fff;
flex-shrink: 0;
}
.mobile-list-head[hidden] {
display: none !important;
}
.mobile-list-head h1 {
margin: 0;
font-size: 1.2rem;
font-weight: 800;
color: #0f766e;
}
.err-banner {
margin: 0.5rem 0.75rem;
border-radius: 12px;
}
.conv-shell {
flex: 1;
min-height: 0;
height: auto;
display: flex;
flex-direction: column;
gap: 0;
}
.conv-rail {
flex: 1;
min-height: 0;
border: none;
border-radius: 0;
background: #fff;
backdrop-filter: none;
padding: 0.5rem 0.35rem 0.75rem;
}
.conv-rail[hidden],
.conv-page.chat-open .conv-rail {
display: none !important;
}
.rail-actions {
padding: 0 0.5rem 0.35rem;
}
.room-list {
max-height: none;
}
.room-chip {
border-radius: 0;
padding: 0.7rem 0.75rem;
border-bottom: 1px solid #f1f5f9;
}
.room-chip.active,
.room-chip:hover {
background: rgba(13, 148, 136, 0.06);
}
.conv-stage {
display: none;
flex: 1;
min-height: 0;
border: none;
border-radius: 0;
background: #fff;
backdrop-filter: none;
}
.conv-stage.open {
display: flex;
}
.desktop-empty {
display: none;
}
.back-btn {
display: grid;
}
.stage-top {
padding: 0.55rem 0.65rem;
background: #fff;
position: sticky;
top: 0;
z-index: 2;
}
.msg-stream {
padding: 0.75rem 0.7rem 0.5rem;
}
.bubble {
max-width: min(300px, 82%);
}
.composer {
padding: 0.5rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -6px 18px rgba(15, 23, 42, 0.04);
}
.tool-btn,
.send-btn {
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
}
.composer-input {
padding: 0.7rem 0.8rem;
font-size: 1rem;
}
}
</style>