Add file & video & voice in chat Crm
This commit is contained in:
@@ -134,8 +134,12 @@ export class ConversationsService {
|
|||||||
chatUserId,
|
chatUserId,
|
||||||
wsUrl: `wss://${host}/ws?${params.toString()}`,
|
wsUrl: `wss://${host}/ws?${params.toString()}`,
|
||||||
messagesBaseUrl: `https://${host}`,
|
messagesBaseUrl: `https://${host}`,
|
||||||
uploadUrl: creds.uploadUrl,
|
uploadUrl:
|
||||||
downloadUrl: creds.downloadUrl,
|
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 kind = tracked?.kind === 'group' ? 'group' : tracked?.kind === 'dm' ? 'dm' : 'group';
|
||||||
|
|
||||||
const lastMsg = (row.last_message ?? row.lastMessage) as Record<string, unknown> | undefined;
|
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()
|
? 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 =
|
const lastAtRaw =
|
||||||
lastMsg?.created_at ?? lastMsg?.createdAt ?? row.updated_at ?? row.updatedAt ?? null;
|
lastMsg?.created_at ?? lastMsg?.createdAt ?? row.updated_at ?? row.updatedAt ?? null;
|
||||||
const unread = Number(row.unread_count ?? row.unreadCount ?? 0);
|
const unread = Number(row.unread_count ?? row.unreadCount ?? 0);
|
||||||
|
|||||||
@@ -4,17 +4,24 @@ import { api } from '@/lib/api';
|
|||||||
|
|
||||||
export type ChatDelivery = 'sending' | 'sent' | 'read';
|
export type ChatDelivery = 'sending' | 'sent' | 'read';
|
||||||
|
|
||||||
|
export type ChatMediaType = 'text' | 'image' | 'voice' | 'video' | 'file';
|
||||||
|
|
||||||
export type ChatMessage = {
|
export type ChatMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
content: string;
|
content: string;
|
||||||
type: 'text' | 'image' | 'voice';
|
type: ChatMediaType;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
reply: string | null;
|
reply: string | null;
|
||||||
optimistic?: boolean;
|
optimistic?: boolean;
|
||||||
delivery?: ChatDelivery;
|
delivery?: ChatDelivery;
|
||||||
readAt?: string | null;
|
readAt?: string | null;
|
||||||
|
/** Absolute URL for media display/download */
|
||||||
|
mediaUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
uploadProgress?: number;
|
||||||
|
localPreviewUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomLiveEvent = {
|
export type RoomLiveEvent = {
|
||||||
@@ -25,6 +32,9 @@ export type RoomLiveEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
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 socket: WebSocket | null = null;
|
||||||
let sessionCache: ConversationSessionDto | 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(
|
function normalizeIncoming(
|
||||||
raw: Record<string, unknown>,
|
raw: Record<string, unknown>,
|
||||||
roomFallback: string,
|
roomFallback: string,
|
||||||
@@ -78,10 +211,17 @@ function normalizeIncoming(
|
|||||||
raw.message && typeof raw.message === 'object'
|
raw.message && typeof raw.message === 'object'
|
||||||
? (raw.message as Record<string, unknown>)
|
? (raw.message as Record<string, unknown>)
|
||||||
: raw;
|
: raw;
|
||||||
|
const type = normalizeMediaType(String(base.type_content ?? base.type ?? 'text'));
|
||||||
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
|
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
|
||||||
if (!content && !base.attachments) return null;
|
if (!content && !base.attachments) return null;
|
||||||
|
|
||||||
const id = String(base.id ?? base.message_id ?? '').trim();
|
const id = String(base.id ?? base.message_id ?? '').trim();
|
||||||
const readAt = base.read_at ?? base.readAt ?? null;
|
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 {
|
return {
|
||||||
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
roomId: String(base.room_id ?? raw.room_id ?? roomFallback),
|
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 ?? '',
|
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
|
||||||
),
|
),
|
||||||
content,
|
content,
|
||||||
type: (String(base.type_content ?? base.type ?? 'text') as ChatMessage['type']) || 'text',
|
type,
|
||||||
createdAt: String(
|
createdAt: String(
|
||||||
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
|
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
|
||||||
),
|
),
|
||||||
reply: base.reply ? String(base.reply) : null,
|
reply: base.reply ? String(base.reply) : null,
|
||||||
readAt: readAt ? String(readAt) : null,
|
readAt: readAt ? String(readAt) : null,
|
||||||
delivery: readAt ? 'read' : 'sent',
|
delivery: readAt ? 'read' : 'sent',
|
||||||
|
mediaUrl,
|
||||||
|
fileName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +250,8 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
|||||||
...prev,
|
...prev,
|
||||||
...incoming,
|
...incoming,
|
||||||
optimistic: false,
|
optimistic: false,
|
||||||
|
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
|
||||||
|
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
||||||
delivery:
|
delivery:
|
||||||
incoming.delivery === 'read' || prev.delivery === 'read'
|
incoming.delivery === 'read' || prev.delivery === 'read'
|
||||||
? 'read'
|
? 'read'
|
||||||
@@ -122,7 +266,10 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
|||||||
m.optimistic &&
|
m.optimistic &&
|
||||||
m.roomId === incoming.roomId &&
|
m.roomId === incoming.roomId &&
|
||||||
m.senderId === incoming.senderId &&
|
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) {
|
if (optimisticIdx >= 0) {
|
||||||
const prev = list[optimisticIdx];
|
const prev = list[optimisticIdx];
|
||||||
@@ -131,8 +278,18 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
|||||||
...prev,
|
...prev,
|
||||||
...incoming,
|
...incoming,
|
||||||
optimistic: false,
|
optimistic: false,
|
||||||
|
localPreviewUrl: undefined,
|
||||||
|
uploadProgress: undefined,
|
||||||
|
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
||||||
delivery: incoming.readAt ? 'read' : 'sent',
|
delivery: incoming.readAt ? 'read' : 'sent',
|
||||||
};
|
};
|
||||||
|
if (prev.localPreviewUrl?.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(prev.localPreviewUrl);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
return sortAsc(next);
|
return sortAsc(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +302,6 @@ function applySeenPayload(payload: Record<string, unknown>) {
|
|||||||
|
|
||||||
const myId = sessionCache?.chatUserId ?? '';
|
const myId = sessionCache?.chatUserId ?? '';
|
||||||
const readerId = String(payload.reader_id ?? '');
|
const readerId = String(payload.reader_id ?? '');
|
||||||
// وقتی طرف مقابل خوانده، پیامهای من تیک دوتایی میخورند
|
|
||||||
if (!readerId || readerId === myId) return;
|
if (!readerId || readerId === myId) return;
|
||||||
|
|
||||||
const ids = new Set(
|
const ids = new Set(
|
||||||
@@ -154,7 +310,6 @@ function applySeenPayload(payload: Record<string, unknown>) {
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
if (!ids.size) {
|
if (!ids.size) {
|
||||||
// اگر id لیست نبود، همه پیامهای خودم در این room را read کن
|
|
||||||
messages.value = messages.value.map((m) =>
|
messages.value = messages.value.map((m) =>
|
||||||
m.roomId === roomId && m.senderId === myId
|
m.roomId === roomId && m.senderId === myId
|
||||||
? { ...m, delivery: 'read' as const, readAt: String(payload.seen_at ?? new Date().toISOString()) }
|
? { ...m, delivery: 'read' as const, readAt: String(payload.seen_at ?? new Date().toISOString()) }
|
||||||
@@ -237,7 +392,7 @@ function handleIncomingMessage(msg: ChatMessage) {
|
|||||||
|
|
||||||
emitRoomLive({
|
emitRoomLive({
|
||||||
roomId: msg.roomId,
|
roomId: msg.roomId,
|
||||||
preview: msg.content,
|
preview: previewLabelForMessage(msg),
|
||||||
at: msg.createdAt,
|
at: msg.createdAt,
|
||||||
fromSelf,
|
fromSelf,
|
||||||
});
|
});
|
||||||
@@ -322,6 +477,56 @@ function sendRead(roomId: string) {
|
|||||||
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
|
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() {
|
export async function refreshUnreadTotal() {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<ConversationRoomDto[]>('/conversations/rooms');
|
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() {
|
function clearActiveRoom() {
|
||||||
activeRoomId.value = '';
|
activeRoomId.value = '';
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
@@ -436,6 +788,7 @@ export function useOrgChat() {
|
|||||||
loadingMessages,
|
loadingMessages,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
sendText,
|
sendText,
|
||||||
|
sendFile,
|
||||||
sendRead,
|
sendRead,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
setConversationsPageOpen,
|
setConversationsPageOpen,
|
||||||
|
|||||||
@@ -12,11 +12,20 @@ import {
|
|||||||
onRoomLiveEvent,
|
onRoomLiveEvent,
|
||||||
useOrgChat,
|
useOrgChat,
|
||||||
type ChatDelivery,
|
type ChatDelivery,
|
||||||
|
type ChatMessage,
|
||||||
type RoomLiveEvent,
|
type RoomLiveEvent,
|
||||||
} from '@/lib/useOrgChat';
|
} from '@/lib/useOrgChat';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
import UserAvatar from '@/components/UserAvatar.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 access = ref<ConversationAccessDto | null>(null);
|
||||||
const rooms = ref<ConversationRoomDto[]>([]);
|
const rooms = ref<ConversationRoomDto[]>([]);
|
||||||
const peers = ref<ConversationPeerDto[]>([]);
|
const peers = ref<ConversationPeerDto[]>([]);
|
||||||
@@ -30,6 +39,12 @@ const groupName = ref('');
|
|||||||
const groupMemberIds = ref<string[]>([]);
|
const groupMemberIds = ref<string[]>([]);
|
||||||
const dmPeerId = ref('');
|
const dmPeerId = ref('');
|
||||||
const chatUserId = 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 {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -37,6 +52,7 @@ const {
|
|||||||
status,
|
status,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
sendText,
|
sendText,
|
||||||
|
sendFile,
|
||||||
sendRead,
|
sendRead,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
setConversationsPageOpen,
|
setConversationsPageOpen,
|
||||||
@@ -45,6 +61,10 @@ const {
|
|||||||
} = useOrgChat();
|
} = useOrgChat();
|
||||||
const msgStreamEl = ref<HTMLElement | null>(null);
|
const msgStreamEl = ref<HTMLElement | null>(null);
|
||||||
let stopRoomLive: (() => boolean) | undefined;
|
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);
|
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
||||||
|
|
||||||
@@ -84,6 +104,129 @@ function deliveryLabel(delivery?: ChatDelivery) {
|
|||||||
return '…';
|
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) {
|
function applyRoomLive(event: RoomLiveEvent) {
|
||||||
const idx = rooms.value.findIndex((r) => r.externalRoomId === event.roomId);
|
const idx = rooms.value.findIndex((r) => r.externalRoomId === event.roomId);
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
@@ -186,12 +329,39 @@ async function openRoom(room: ConversationRoomDto) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitMessage() {
|
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 {
|
try {
|
||||||
await sendText(activeRoom.value.externalRoomId, draft.value);
|
if (text) {
|
||||||
draft.value = '';
|
await sendText(roomId, text);
|
||||||
} catch {
|
}
|
||||||
error.value = 'ارسال پیام ناموفق بود.';
|
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, () => {
|
watch(activeRoom, () => {
|
||||||
draft.value = '';
|
draft.value = '';
|
||||||
|
clearPendingAttaches();
|
||||||
|
if (recording.value) {
|
||||||
|
mediaRecorder?.stop();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -264,6 +438,10 @@ onUnmounted(() => {
|
|||||||
setConversationsPageOpen(false);
|
setConversationsPageOpen(false);
|
||||||
stopRoomLive?.();
|
stopRoomLive?.();
|
||||||
clearActiveRoom();
|
clearActiveRoom();
|
||||||
|
clearPendingAttaches();
|
||||||
|
if (recording.value) mediaRecorder?.stop();
|
||||||
|
stopRecordTimer();
|
||||||
|
stopMicTracks();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -342,8 +520,48 @@ onUnmounted(() => {
|
|||||||
class="msg-row"
|
class="msg-row"
|
||||||
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
|
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
|
||||||
>
|
>
|
||||||
<div class="bubble">
|
<div class="bubble" :class="{ media: msg.type !== 'text' }">
|
||||||
<p>{{ msg.content }}</p>
|
<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">
|
<div class="bubble-meta">
|
||||||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||||||
<span
|
<span
|
||||||
@@ -364,10 +582,66 @@ onUnmounted(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<form class="composer" @submit.prevent="submitMessage">
|
<form class="composer" @submit.prevent="submitMessage">
|
||||||
<input v-model="draft" type="text" placeholder="پیام خود را بنویسید…" autocomplete="off" />
|
<div v-if="pendingAttaches.length" class="attach-preview">
|
||||||
<button type="submit" :disabled="!draft.trim()">
|
<div v-for="item in pendingAttaches" :key="item.id" class="attach-chip" :class="item.kind">
|
||||||
<UiIcon name="send" :size="18" />
|
<img v-if="item.kind === 'image'" :src="item.previewUrl" :alt="item.name" />
|
||||||
</button>
|
<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>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@@ -408,6 +682,13 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -656,11 +937,76 @@ onUnmounted(() => {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
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 {
|
.bubble-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-inline: 0.25rem;
|
||||||
}
|
}
|
||||||
.bubble time {
|
.bubble time {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
@@ -684,27 +1030,177 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.composer {
|
.composer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem 1rem;
|
padding: 0.75rem 1rem 1rem;
|
||||||
border-top: 1px solid var(--line);
|
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;
|
flex: 1;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
.composer button {
|
.composer-row > button[type='submit'] {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
width: 2.75rem;
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
background: #0d9488;
|
background: #0d9488;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
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 {
|
.empty-stage {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -563,11 +563,19 @@ onMounted(() => {
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>آدرس آپلود فایل</span>
|
<span>آدرس آپلود فایل</span>
|
||||||
<input v-model="chatForm.uploadUrl" dir="ltr" />
|
<input
|
||||||
|
v-model="chatForm.uploadUrl"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="https://chat.example.ir/upload"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>آدرس پایه دانلود فایل</span>
|
<span>آدرس پایه دانلود فایل</span>
|
||||||
<input v-model="chatForm.downloadUrl" dir="ltr" />
|
<input
|
||||||
|
v-model="chatForm.downloadUrl"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="https://chat.example.ir/files"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user