Add file & video & voice in chat Crm

This commit is contained in:
2026-09-29 17:22:34 +03:30
parent d4972891f1
commit b0f05b6a27
4 changed files with 897 additions and 24 deletions
+509 -13
View File
@@ -12,11 +12,20 @@ import {
onRoomLiveEvent,
useOrgChat,
type ChatDelivery,
type ChatMessage,
type RoomLiveEvent,
} from '@/lib/useOrgChat';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
type PendingAttach = {
id: string;
file: File;
kind: 'image' | 'voice' | 'video' | 'file';
previewUrl: string;
name: string;
};
const access = ref<ConversationAccessDto | null>(null);
const rooms = ref<ConversationRoomDto[]>([]);
const peers = ref<ConversationPeerDto[]>([]);
@@ -30,6 +39,12 @@ const groupName = ref('');
const groupMemberIds = ref<string[]>([]);
const dmPeerId = ref('');
const chatUserId = ref('');
const pendingAttaches = ref<PendingAttach[]>([]);
const sending = ref(false);
const recording = ref(false);
const recordSecs = ref(0);
const lightboxUrl = ref('');
const fileInputEl = ref<HTMLInputElement | null>(null);
const {
messages,
@@ -37,6 +52,7 @@ const {
status,
loadMessages,
sendText,
sendFile,
sendRead,
ensureConnected,
setConversationsPageOpen,
@@ -45,6 +61,10 @@ const {
} = useOrgChat();
const msgStreamEl = ref<HTMLElement | null>(null);
let stopRoomLive: (() => boolean) | undefined;
let mediaRecorder: MediaRecorder | null = null;
let recordChunks: Blob[] = [];
let recordTimer: ReturnType<typeof setInterval> | null = null;
let recordStream: MediaStream | null = null;
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
@@ -84,6 +104,129 @@ function deliveryLabel(delivery?: ChatDelivery) {
return '…';
}
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';
}
function clearPendingAttaches() {
for (const item of pendingAttaches.value) {
if (item.previewUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(item.previewUrl);
} catch {
// ignore
}
}
}
pendingAttaches.value = [];
}
function removePending(id: string) {
const item = pendingAttaches.value.find((a) => a.id === id);
if (item?.previewUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(item.previewUrl);
} catch {
// ignore
}
}
pendingAttaches.value = pendingAttaches.value.filter((a) => a.id !== id);
}
function addFiles(files: FileList | File[]) {
const list = Array.from(files);
for (const file of list) {
pendingAttaches.value = [
...pendingAttaches.value,
{
id: crypto.randomUUID(),
file,
kind: classifyPending(file),
previewUrl: URL.createObjectURL(file),
name: file.name,
},
];
}
}
function onFilePicked(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files?.length) addFiles(input.files);
input.value = '';
}
function mediaSrc(msg: ChatMessage) {
return msg.localPreviewUrl || msg.mediaUrl || '';
}
function stopRecordTimer() {
if (recordTimer) {
clearInterval(recordTimer);
recordTimer = null;
}
}
function stopMicTracks() {
recordStream?.getTracks().forEach((t) => t.stop());
recordStream = null;
}
async function toggleVoiceRecord() {
if (recording.value) {
mediaRecorder?.stop();
return;
}
try {
recordStream = await navigator.mediaDevices.getUserMedia({ audio: true });
recordChunks = [];
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: '';
mediaRecorder = mime
? new MediaRecorder(recordStream, { mimeType: mime })
: new MediaRecorder(recordStream);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size) recordChunks.push(e.data);
};
mediaRecorder.onstop = () => {
stopRecordTimer();
recording.value = false;
const type = mediaRecorder?.mimeType || 'audio/webm';
stopMicTracks();
mediaRecorder = null;
if (!recordChunks.length) return;
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 });
addFiles([file]);
recordSecs.value = 0;
};
mediaRecorder.start();
recording.value = true;
recordSecs.value = 0;
recordTimer = setInterval(() => {
recordSecs.value += 1;
}, 1000);
} catch {
error.value = 'دسترسی به میکروفون ممکن نشد.';
stopMicTracks();
recording.value = false;
}
}
function recordLabel() {
const m = Math.floor(recordSecs.value / 60);
const s = recordSecs.value % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function applyRoomLive(event: RoomLiveEvent) {
const idx = rooms.value.findIndex((r) => r.externalRoomId === event.roomId);
if (idx < 0) {
@@ -186,12 +329,39 @@ async function openRoom(room: ConversationRoomDto) {
}
async function submitMessage() {
if (!activeRoom.value || !draft.value.trim()) return;
if (!activeRoom.value) return;
if (sending.value || recording.value) return;
const text = draft.value.trim();
const attaches = [...pendingAttaches.value];
if (!text && !attaches.length) return;
sending.value = true;
error.value = '';
const roomId = activeRoom.value.externalRoomId;
draft.value = '';
pendingAttaches.value = [];
try {
await sendText(activeRoom.value.externalRoomId, draft.value);
draft.value = '';
} catch {
error.value = 'ارسال پیام ناموفق بود.';
if (text) {
await sendText(roomId, text);
}
for (const item of attaches) {
try {
await sendFile(roomId, item.file);
} finally {
if (item.previewUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(item.previewUrl);
} catch {
// ignore
}
}
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'ارسال پیام ناموفق بود.';
} finally {
sending.value = false;
}
}
@@ -241,6 +411,10 @@ function toggleGroupMember(id: string) {
watch(activeRoom, () => {
draft.value = '';
clearPendingAttaches();
if (recording.value) {
mediaRecorder?.stop();
}
});
watch(
@@ -264,6 +438,10 @@ onUnmounted(() => {
setConversationsPageOpen(false);
stopRoomLive?.();
clearActiveRoom();
clearPendingAttaches();
if (recording.value) mediaRecorder?.stop();
stopRecordTimer();
stopMicTracks();
});
</script>
@@ -342,8 +520,48 @@ onUnmounted(() => {
class="msg-row"
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
>
<div class="bubble">
<p>{{ msg.content }}</p>
<div class="bubble" :class="{ media: msg.type !== 'text' }">
<template v-if="msg.type === 'image'">
<button
type="button"
class="media-image"
:disabled="!mediaSrc(msg)"
@click="lightboxUrl = mediaSrc(msg)"
>
<img :src="mediaSrc(msg)" :alt="msg.fileName || 'تصویر'" />
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-veil">
{{ toFaDigits(msg.uploadProgress) }}٪
</span>
</button>
</template>
<template v-else-if="msg.type === 'voice'">
<audio class="media-audio" :src="mediaSrc(msg)" controls preload="metadata" />
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
</span>
</template>
<template v-else-if="msg.type === 'video'">
<video class="media-video" :src="mediaSrc(msg)" controls preload="metadata" />
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
</span>
</template>
<template v-else-if="msg.type === 'file'">
<a
class="media-file"
:href="mediaSrc(msg)"
target="_blank"
rel="noopener"
:download="msg.fileName || true"
>
<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>
</template>
<p v-else>{{ msg.content }}</p>
<div class="bubble-meta">
<time>{{ formatListTime(msg.createdAt) }}</time>
<span
@@ -364,10 +582,66 @@ onUnmounted(() => {
</ul>
<form class="composer" @submit.prevent="submitMessage">
<input v-model="draft" type="text" placeholder="پیام خود را بنویسید…" autocomplete="off" />
<button type="submit" :disabled="!draft.trim()">
<UiIcon name="send" :size="18" />
</button>
<div v-if="pendingAttaches.length" class="attach-preview">
<div v-for="item in pendingAttaches" :key="item.id" class="attach-chip" :class="item.kind">
<img v-if="item.kind === 'image'" :src="item.previewUrl" :alt="item.name" />
<video v-else-if="item.kind === 'video'" :src="item.previewUrl" muted />
<span v-else class="attach-chip-label">
<UiIcon :name="item.kind === 'voice' ? 'mic' : 'document'" :size="14" />
{{ item.name }}
</span>
<button type="button" class="attach-remove" aria-label="حذف" @click="removePending(item.id)">
<UiIcon name="x" :size="12" />
</button>
</div>
</div>
<div v-if="recording" class="rec-bar">
<span class="rec-dot" />
در حال ضبط {{ toFaDigits(recordLabel()) }}
<button type="button" class="rec-stop" @click="toggleVoiceRecord">توقف</button>
</div>
<div class="composer-row">
<input
ref="fileInputEl"
type="file"
class="sr-only"
multiple
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.txt"
@change="onFilePicked"
/>
<button
type="button"
class="tool-btn"
title="پیوست فایل / عکس / فیلم"
:disabled="sending || recording"
@click="fileInputEl?.click()"
>
<UiIcon name="document" :size="18" />
</button>
<button
type="button"
class="tool-btn"
:class="{ on: recording }"
title="ضبط وویس"
:disabled="sending"
@click="toggleVoiceRecord"
>
<UiIcon :name="recording ? 'stop' : 'mic'" :size="18" />
</button>
<input
v-model="draft"
type="text"
placeholder="پیام خود را بنویسید…"
autocomplete="off"
:disabled="sending || recording"
/>
<button
type="submit"
:disabled="sending || recording || (!draft.trim() && !pendingAttaches.length)"
>
<UiIcon name="send" :size="18" />
</button>
</div>
</form>
</template>
</section>
@@ -408,6 +682,13 @@ onUnmounted(() => {
</div>
</div>
</dialog>
<dialog v-if="lightboxUrl" class="lightbox" open @click.self="lightboxUrl = ''">
<button type="button" class="lightbox-close" aria-label="بستن" @click="lightboxUrl = ''">
<UiIcon name="x" :size="18" />
</button>
<img :src="lightboxUrl" alt="" />
</dialog>
</div>
</template>
@@ -656,11 +937,76 @@ onUnmounted(() => {
white-space: pre-wrap;
word-break: break-word;
}
.bubble.media {
padding: 0.45rem;
}
.media-image {
position: relative;
display: block;
border: none;
padding: 0;
background: transparent;
cursor: zoom-in;
border-radius: 12px;
overflow: hidden;
max-width: min(280px, 72vw);
}
.media-image img {
display: block;
width: 100%;
max-height: 320px;
object-fit: cover;
}
.upload-veil {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(15, 23, 42, 0.45);
color: #fff;
font-size: 0.9rem;
font-weight: 700;
}
.media-audio,
.media-video {
display: block;
width: min(280px, 72vw);
max-width: 100%;
}
.media-video {
border-radius: 12px;
background: #0f172a;
}
.media-file {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.65rem;
border-radius: 12px;
background: rgba(15, 23, 42, 0.06);
color: inherit;
text-decoration: none;
font-size: 0.88rem;
max-width: min(280px, 72vw);
}
.media-file span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-line {
display: block;
margin-top: 0.35rem;
font-size: 0.72rem;
color: var(--muted);
}
.bubble-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
margin-top: 0.25rem;
padding-inline: 0.25rem;
}
.bubble time {
font-size: 0.68rem;
@@ -684,27 +1030,177 @@ onUnmounted(() => {
}
.composer {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--line);
}
.composer input {
.composer-row {
display: flex;
gap: 0.4rem;
align-items: center;
}
.composer-row input[type='text'] {
flex: 1;
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.65rem 0.85rem;
font: inherit;
}
.composer button {
.composer-row > button[type='submit'] {
border: none;
border-radius: 14px;
width: 2.75rem;
height: 2.75rem;
background: #0d9488;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.composer-row > button[type='submit']:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.tool-btn {
border: 1px solid var(--line);
background: var(--surface);
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 {
opacity: 0.45;
cursor: not-allowed;
}
.attach-preview {
display: flex;
gap: 0.45rem;
overflow-x: auto;
padding-bottom: 0.15rem;
}
.attach-chip {
position: relative;
flex: 0 0 auto;
border-radius: 12px;
border: 1px solid var(--line);
overflow: hidden;
background: #f8fafc;
}
.attach-chip.image,
.attach-chip.video {
width: 72px;
height: 72px;
}
.attach-chip img,
.attach-chip video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.attach-chip-label {
display: flex;
align-items: center;
gap: 0.35rem;
max-width: 160px;
padding: 0.55rem 0.7rem;
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attach-remove {
position: absolute;
top: 4px;
left: 4px;
border: none;
border-radius: 999px;
width: 1.35rem;
height: 1.35rem;
background: rgba(15, 23, 42, 0.7);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
}
.rec-bar {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #b91c1c;
}
.rec-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #ef4444;
animation: pulse 1s infinite;
}
.rec-stop {
margin-inline-start: auto;
border: 1px solid #fecaca;
background: #fff;
color: #b91c1c;
border-radius: 10px;
padding: 0.25rem 0.65rem;
cursor: pointer;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.lightbox {
position: fixed;
inset: 0;
z-index: 60;
border: none;
background: rgba(15, 23, 42, 0.82);
display: grid;
place-items: center;
padding: 1.5rem;
}
.lightbox img {
max-width: min(960px, 100%);
max-height: 90vh;
border-radius: 12px;
object-fit: contain;
}
.lightbox-close {
position: absolute;
top: 1rem;
left: 1rem;
border: none;
border-radius: 999px;
width: 2.5rem;
height: 2.5rem;
background: rgba(255, 255, 255, 0.15);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
}
.empty-stage {
flex: 1;
display: grid;
@@ -563,11 +563,19 @@ onMounted(() => {
</label>
<label>
<span>آدرس آپلود فایل</span>
<input v-model="chatForm.uploadUrl" dir="ltr" />
<input
v-model="chatForm.uploadUrl"
dir="ltr"
placeholder="https://chat.example.ir/upload"
/>
</label>
<label>
<span>آدرس پایه دانلود فایل</span>
<input v-model="chatForm.downloadUrl" dir="ltr" />
<input
v-model="chatForm.downloadUrl"
dir="ltr"
placeholder="https://chat.example.ir/files"
/>
</label>
</div>
</div>