Add file & video & voice in chat Crm
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user