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
@@ -134,8 +134,12 @@ export class ConversationsService {
chatUserId,
wsUrl: `wss://${host}/ws?${params.toString()}`,
messagesBaseUrl: `https://${host}`,
uploadUrl: creds.uploadUrl,
downloadUrl: creds.downloadUrl,
uploadUrl:
creds.uploadUrl.trim() ||
`https://${host}/upload`,
downloadUrl:
creds.downloadUrl.trim() ||
`https://${host}/files`,
};
}
@@ -433,9 +437,21 @@ export class ConversationsService {
const kind = tracked?.kind === 'group' ? 'group' : tracked?.kind === 'dm' ? 'dm' : 'group';
const lastMsg = (row.last_message ?? row.lastMessage) as Record<string, unknown> | undefined;
const preview = lastMsg
const lastType = String(lastMsg?.type_content ?? lastMsg?.type ?? 'text')
.toLowerCase()
.trim();
let preview = lastMsg
? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim()
: '';
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
preview = 'تصویر';
} else if (lastType === 'voice' || lastType === 'audio') {
preview = 'وویس';
} else if (lastType === 'video') {
preview = 'ویدیو';
} else if (lastType === 'file' || lastType === 'document' || lastType === 'attachment') {
preview = 'فایل';
}
const lastAtRaw =
lastMsg?.created_at ?? lastMsg?.createdAt ?? row.updated_at ?? row.updatedAt ?? null;
const unread = Number(row.unread_count ?? row.unreadCount ?? 0);
+359 -6
View File
@@ -4,17 +4,24 @@ import { api } from '@/lib/api';
export type ChatDelivery = 'sending' | 'sent' | 'read';
export type ChatMediaType = 'text' | 'image' | 'voice' | 'video' | 'file';
export type ChatMessage = {
id: string;
roomId: string;
senderId: string;
content: string;
type: 'text' | 'image' | 'voice';
type: ChatMediaType;
createdAt: string;
reply: string | null;
optimistic?: boolean;
delivery?: ChatDelivery;
readAt?: string | null;
/** Absolute URL for media display/download */
mediaUrl?: string;
fileName?: string;
uploadProgress?: number;
localPreviewUrl?: string;
};
export type RoomLiveEvent = {
@@ -25,6 +32,9 @@ export type RoomLiveEvent = {
};
const PAGE_SIZE = 50;
const IMAGE_MAX_EDGE = 1600;
const IMAGE_WEBP_QUALITY = 0.82;
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
let socket: WebSocket | null = null;
let sessionCache: ConversationSessionDto | null = null;
@@ -70,6 +80,129 @@ function sortAsc(items: ChatMessage[]): ChatMessage[] {
});
}
function normalizeMediaType(raw: string): ChatMediaType {
const t = raw.toLowerCase().trim();
if (t === 'image' || t === 'photo' || t === 'img') return 'image';
if (t === 'voice' || t === 'audio') return 'voice';
if (t === 'video') return 'video';
if (t === 'file' || t === 'document' || t === 'attachment') return 'file';
return 'text';
}
export function previewLabelForMessage(msg: Pick<ChatMessage, 'type' | 'content' | 'fileName'>) {
if (msg.type === 'image') return 'تصویر';
if (msg.type === 'voice') return 'وویس';
if (msg.type === 'video') return 'ویدیو';
if (msg.type === 'file') return msg.fileName?.trim() || 'فایل';
return msg.content || 'پیام';
}
function buildChatFileUrl(value: string, downloadUrl: string): string {
const raw = String(value || '').trim();
if (!raw) return '';
if (/^(https?:\/\/|blob:|data:)/i.test(raw)) return raw;
if (!downloadUrl) return raw;
const cleanRaw = raw.replace(/^\/+/, '');
try {
const baseUrl = new URL(downloadUrl);
const basePath = baseUrl.pathname.replace(/^\/+|\/+$/g, '');
const baseParts = basePath.split('/').filter(Boolean);
const rawParts = cleanRaw.split('/').filter(Boolean);
if (basePath && cleanRaw.startsWith(`${basePath}/`)) {
return `${baseUrl.origin}/${cleanRaw}`;
}
const lastBasePart = baseParts[baseParts.length - 1];
if (lastBasePart && rawParts[0] === lastBasePart) {
const parentPath = baseParts.slice(0, -1).join('/');
return `${baseUrl.origin}/${[parentPath, cleanRaw].filter(Boolean).join('/')}`;
}
return `${downloadUrl.replace(/\/+$/, '')}/${cleanRaw}`;
} catch {
return raw;
}
}
function extractUploadedPath(result: Record<string, unknown>): string {
const nested = (result.data && typeof result.data === 'object'
? (result.data as Record<string, unknown>)
: null);
const file = (result.file && typeof result.file === 'object'
? (result.file as Record<string, unknown>)
: null);
const value =
result.path ||
result.url ||
result.file_url ||
result.fileUrl ||
nested?.path ||
nested?.url ||
nested?.file_url ||
nested?.fileUrl ||
file?.path ||
file?.url;
return String(value || '').trim();
}
function classifyFile(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';
return 'file';
}
/** WS only officially documents image|voice; map video/file to closest supported + file. */
function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'video' | 'file' {
if (type === 'image') return 'image';
if (type === 'voice') return 'voice';
if (type === 'video') return 'video';
return 'file';
}
async function compressImageIfNeeded(file: File): Promise<File> {
if (!file.type.startsWith('image/')) return file;
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file;
if (file.size < 350_000) return file;
try {
const bitmap = await createImageBitmap(file);
const ratio = Math.min(
IMAGE_MAX_EDGE / bitmap.width,
IMAGE_MAX_EDGE / bitmap.height,
1,
);
const width = Math.round(bitmap.width * ratio);
const height = Math.round(bitmap.height * ratio);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return file;
}
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/webp', IMAGE_WEBP_QUALITY);
});
if (!blob || blob.size >= file.size) return file;
const base = file.name.replace(/\.[^/.]+$/, '') || 'image';
return new File([blob], `${base}.webp`, {
type: 'image/webp',
lastModified: Date.now(),
});
} catch {
return file;
}
}
function normalizeIncoming(
raw: Record<string, unknown>,
roomFallback: string,
@@ -78,10 +211,17 @@ 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 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 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)}`,
roomId: String(base.room_id ?? raw.room_id ?? roomFallback),
@@ -89,13 +229,15 @@ function normalizeIncoming(
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
),
content,
type: (String(base.type_content ?? base.type ?? 'text') as ChatMessage['type']) || 'text',
type,
createdAt: String(
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
),
reply: base.reply ? String(base.reply) : null,
readAt: readAt ? String(readAt) : null,
delivery: readAt ? 'read' : 'sent',
mediaUrl,
fileName,
};
}
@@ -108,6 +250,8 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
...prev,
...incoming,
optimistic: false,
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
delivery:
incoming.delivery === 'read' || prev.delivery === 'read'
? 'read'
@@ -122,7 +266,10 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
m.optimistic &&
m.roomId === incoming.roomId &&
m.senderId === incoming.senderId &&
m.content === incoming.content,
(m.content === incoming.content ||
(m.type === incoming.type &&
m.type !== 'text' &&
Boolean(m.localPreviewUrl))),
);
if (optimisticIdx >= 0) {
const prev = list[optimisticIdx];
@@ -131,8 +278,18 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
...prev,
...incoming,
optimistic: false,
localPreviewUrl: undefined,
uploadProgress: undefined,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
delivery: incoming.readAt ? 'read' : 'sent',
};
if (prev.localPreviewUrl?.startsWith('blob:')) {
try {
URL.revokeObjectURL(prev.localPreviewUrl);
} catch {
// ignore
}
}
return sortAsc(next);
}
@@ -145,7 +302,6 @@ function applySeenPayload(payload: Record<string, unknown>) {
const myId = sessionCache?.chatUserId ?? '';
const readerId = String(payload.reader_id ?? '');
// وقتی طرف مقابل خوانده، پیام‌های من تیک دوتایی می‌خورند
if (!readerId || readerId === myId) return;
const ids = new Set(
@@ -154,7 +310,6 @@ function applySeenPayload(payload: Record<string, unknown>) {
: [],
);
if (!ids.size) {
// اگر id لیست نبود، همه پیام‌های خودم در این room را read کن
messages.value = messages.value.map((m) =>
m.roomId === roomId && m.senderId === myId
? { ...m, delivery: 'read' as const, readAt: String(payload.seen_at ?? new Date().toISOString()) }
@@ -237,7 +392,7 @@ function handleIncomingMessage(msg: ChatMessage) {
emitRoomLive({
roomId: msg.roomId,
preview: msg.content,
preview: previewLabelForMessage(msg),
at: msg.createdAt,
fromSelf,
});
@@ -322,6 +477,56 @@ function sendRead(roomId: string) {
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
}
function uploadChatFile(
uploadUrl: string,
file: File,
onProgress?: (percent: number) => void,
): Promise<string> {
return new Promise((resolve, reject) => {
if (!uploadUrl.trim()) {
reject(new Error('آدرس آپلود گفتگو تنظیم نشده است'));
return;
}
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', uploadUrl);
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
onProgress?.(Math.min(99, Math.round((event.loaded / event.total) * 100)));
};
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(`آپلود ناموفق بود (${xhr.status})`));
return;
}
try {
const contentType = xhr.getResponseHeader('content-type') || '';
if (contentType.includes('application/json')) {
const result = JSON.parse(xhr.responseText) as Record<string, unknown>;
const path = extractUploadedPath(result);
if (!path) {
reject(new Error('پاسخ آپلود فاقد مسیر فایل است'));
return;
}
resolve(path);
return;
}
const path = xhr.responseText.trim();
if (!path) {
reject(new Error('پاسخ آپلود خالی است'));
return;
}
resolve(path);
} catch (e) {
reject(e instanceof Error ? e : new Error('پاسخ آپلود نامعتبر است'));
}
};
xhr.onerror = () => reject(new Error('خطای شبکه هنگام آپلود'));
xhr.send(formData);
});
}
export async function refreshUnreadTotal() {
try {
const { data } = await api.get<ConversationRoomDto[]>('/conversations/rooms');
@@ -416,6 +621,153 @@ export function useOrgChat() {
);
}
async function sendMediaPath(
roomId: string,
path: string,
type: Exclude<ChatMediaType, 'text'>,
opts?: {
reply?: string | null;
fileName?: string;
localPreviewUrl?: string;
tempId?: string;
},
) {
const filePath = path.trim();
if (!filePath) return;
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
const session = await ensureSession();
const id = opts?.tempId || crypto.randomUUID();
const reply = opts?.reply ?? null;
const wsType = wsMediaType(type);
const mediaUrl = buildChatFileUrl(filePath, session.downloadUrl);
const payload: Record<string, string> = {
id,
type: wsType,
room_id: roomId,
content: filePath,
};
if (reply) payload.reply = reply;
const existingIdx = messages.value.findIndex((m) => m.id === id);
if (existingIdx >= 0) {
const next = [...messages.value];
next[existingIdx] = {
...next[existingIdx],
content: filePath,
type,
mediaUrl,
fileName: opts?.fileName,
uploadProgress: 100,
optimistic: false,
delivery: 'sent',
};
messages.value = next;
} else {
const optimistic: ChatMessage = {
id,
roomId,
senderId: session.chatUserId,
content: filePath,
type,
createdAt: new Date().toISOString(),
reply,
optimistic: true,
delivery: 'sending',
mediaUrl,
fileName: opts?.fileName,
localPreviewUrl: opts?.localPreviewUrl,
};
messages.value = mergeMessages(optimistic, messages.value);
}
emitRoomLive({
roomId,
preview: previewLabelForMessage({ type, content: filePath, fileName: opts?.fileName }),
at: new Date().toISOString(),
fromSelf: true,
});
socket.send(JSON.stringify(payload));
messages.value = messages.value.map((m) =>
m.id === id
? { ...m, optimistic: false, delivery: 'sent' as const, uploadProgress: undefined }
: m,
);
}
async function sendFile(
roomId: string,
file: File,
reply: string | null = null,
) {
if (!file) return;
if (file.size > MAX_UPLOAD_BYTES) {
throw new Error('حجم فایل بیش از حد مجاز است (۴۰ مگابایت)');
}
const session = await ensureSession();
if (!session.uploadUrl?.trim()) {
throw new Error(
'آدرس آپلود گفتگو خالی است. در سوپرادمین → محیط‌ها → تنظیمات گفتگو پر کنید.',
);
}
await connectSocket();
const type = classifyFile(file);
const id = crypto.randomUUID();
const localPreviewUrl = URL.createObjectURL(file);
const optimistic: ChatMessage = {
id,
roomId,
senderId: session.chatUserId,
content: '',
type,
createdAt: new Date().toISOString(),
reply,
optimistic: true,
delivery: 'sending',
fileName: file.name,
localPreviewUrl,
uploadProgress: 0,
};
messages.value = mergeMessages(optimistic, messages.value);
const setProgress = (percent: number) => {
messages.value = messages.value.map((m) =>
m.id === id ? { ...m, uploadProgress: percent } : m,
);
};
try {
setProgress(5);
const fileForUpload = type === 'image' ? await compressImageIfNeeded(file) : file;
setProgress(12);
const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => {
setProgress(12 + Math.round(p * 0.86));
});
setProgress(99);
await sendMediaPath(roomId, uploadedPath, type, {
reply,
fileName: file.name,
localPreviewUrl,
tempId: id,
});
} catch (e) {
messages.value = messages.value.filter((m) => m.id !== id);
if (localPreviewUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(localPreviewUrl);
} catch {
// ignore
}
}
throw e;
}
}
function clearActiveRoom() {
activeRoomId.value = '';
messages.value = [];
@@ -436,6 +788,7 @@ export function useOrgChat() {
loadingMessages,
loadMessages,
sendText,
sendFile,
sendRead,
ensureConnected,
setConversationsPageOpen,
+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>